Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

/// One line summarising every day a scoring pass skipped for want of HR samples, replacing one line per
/// skipped day.
///
/// A pass walks a fixed recent window, so a day whose raw HR never arrived is re-read, re-skipped and
/// re-logged on every pass, forever. The strap banks days rather than weeks of raw HR while an import can
/// supply a much longer spine of daily rows, so the steady state for an importing user is a permanent
/// block of un-scoreable days. In one field capture that was 1262 lines - 21 days re-skipped across 63
/// passes in two hours, about a fifth of everything the log had to say.
///
/// That matters because the strap log is a fixed-size rolling buffer: noise does not merely annoy, it
/// evicts the older lines an investigation needs. Collapsing per pass keeps every fact - which days, and
/// each day's own HR count - while removing the repetition, so days are grouped by their sample count
/// and listed rather than summarised into a range that could hide a gap.
///
/// Returns `nil` when nothing was skipped, so a healthy pass stays silent. Kotlin twin:
/// `com.noop.analytics.skippedSleepDaysLine`.
public func skippedSleepDaysLine(_ skipped: [(day: String, hrSamples: Int)], minHrSamples: Int) -> String? {
if skipped.isEmpty { return nil }
var byCount: [Int: [String]] = [:]
for entry in skipped { byCount[entry.hrSamples, default: []].append(entry.day) }
let groups = byCount.keys.sorted().map { count -> String in
let days = byCount[count]!.sorted()
return "hrSamples=\(count) on \(days.count) day(s): \(days.joined(separator: ", "))"
}
return "sleep SKIPPED \(skipped.count) day(s) — need ≥\(minHrSamples) hrSamples: "
+ groups.joined(separator: "; ")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import XCTest
@testable import StrandAnalytics

/// Pins the collapsed skipped-day line (#1121). Kotlin twin: `SleepSkipSummaryTest`. The two must emit
/// byte-identical strings, so these expectations are spelled out rather than pattern-matched.
final class SleepSkipSummaryTests: XCTestCase {

func testNothingSkippedStaysSilent() {
XCTAssertNil(skippedSleepDaysLine([], minHrSamples: 200))
}

func testOneDay() {
XCTAssertEqual(
skippedSleepDaysLine([(day: "2026-08-25", hrSamples: 97)], minHrSamples: 200),
"sleep SKIPPED 1 day(s) — need ≥200 hrSamples: hrSamples=97 on 1 day(s): 2026-08-25"
)
}

func testGroupsByCountAscendingAndSortsDays() {
let line = skippedSleepDaysLine([
(day: "2026-08-07", hrSamples: 0),
(day: "2026-08-25", hrSamples: 97),
(day: "2026-08-05", hrSamples: 0),
], minHrSamples: 200)
XCTAssertEqual(
line,
"sleep SKIPPED 3 day(s) — need ≥200 hrSamples: "
+ "hrSamples=0 on 2 day(s): 2026-08-05, 2026-08-07; "
+ "hrSamples=97 on 1 day(s): 2026-08-25"
)
}

func testEveryDayIsListed() {
// Lossless on purpose: a range would hide a gap, and a gap in which days lack raw HR is exactly
// the thing an investigation is looking for.
let days = (5...9).map { (day: String(format: "2026-08-%02d", $0), hrSamples: 0) }
let line = skippedSleepDaysLine(days, minHrSamples: 200)!
for d in ["2026-08-05", "2026-08-06", "2026-08-07", "2026-08-08", "2026-08-09"] {
XCTAssertTrue(line.contains(d), "missing \(d)")
}
}
}
24 changes: 22 additions & 2 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ import StrandAnalytics
/// itself rather than relying on the values WHOOP computed in the imported CSV.
@MainActor
final class IntelligenceEngine: ObservableObject {
/// Minimum raw HR samples a day needs before it is scored at all. Mirrors the Kotlin
/// `IntelligenceEngine.MIN_HR_SAMPLES`, which has been a named constant there while this side carried
/// the literal in more than one place — so a threshold change had to find every copy on this platform
/// and exactly one on the other.
/// Spelled `IntelligenceEngine.minHrSamples` at both use sites rather than `Self.` — both are inside
/// the `Task.detached` @Sendable closure, and the explicit type name cannot raise a dynamic-`Self` or
/// closure-capture question there. No app-target Swift compiles outside app-build, so the boring form
/// is worth the few characters.
static let minHrSamples = 200

private let repo: Repository
private let profile: ProfileStore
/// The CANONICAL id under whose `-noop` sibling this engine WRITES the computed daily rows, and from
Expand Down Expand Up @@ -804,6 +814,13 @@ final class IntelligenceEngine: ObservableObject {
// along on one; carried out alongside `out` and replayed through `diagnosticSink` on the main
// actor below, same as `rhrLine`/the trace arrays. Mirrors the Kotlin `diag` sink.
var skippedDayLines: [String] = []
// #1121: days skipped for too little raw HR, collected and emitted as ONE line after the loop
// instead of one line each, every pass — see `skippedSleepDaysLine`. A LOCAL here, where the
// Kotlin twin has to use a field: its `analyzeRecentOnCpu` is `suspend` and sits against the JVM
// method-size ceiling, so an extra local live across the loop costs a save/restore at every
// suspension point. No such constraint applies here, and the emitted line is identical either
// way — do not "align" the two shapes.
var skippedSleepDays: [(day: String, hrSamples: Int)] = []
// #938: the WHOOP 4.0 ADC offset is per-device, not per-night. Learn one anchor per owner
// from the whole scan window and reuse it for every night so cross-night deviations survive.
let skinAnchorScanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - 30 * 3_600
Expand Down Expand Up @@ -900,11 +917,11 @@ final class IntelligenceEngine: ObservableObject {
// guessed, for the same reason the day-cache duration is.
let tPrep0 = Date()
let hr = (try? await store.hrSamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? []
guard hr.count >= 200 else {
guard hr.count >= IntelligenceEngine.minHrSamples else {
// This day still paid for its read; count it, or the tally under-reports exactly the
// sparse-history installs where reads dominate most.
dayPrepSeconds += Date().timeIntervalSince(tPrep0)
skippedDayLines.append("sleep day=\(day) SKIPPED hrSamples=\(hr.count) (need ≥200)")
skippedSleepDays.append((day: day, hrSamples: hr.count))
continue
}
let rr = (try? await store.rrIntervals(deviceId: owner, from: from, to: to, limit: 200_000)) ?? []
Expand Down Expand Up @@ -1372,6 +1389,9 @@ final class IntelligenceEngine: ObservableObject {
let dayCacheWindow = Set((0..<maxDays).map {
AnalyticsEngine.dayString(nowLocalMidnight - $0 * 86_400, offsetSec: tzOffset) })
dayScanCacheLocal = dayScanCacheLocal.filter { dayCacheWindow.contains($0.key) }
if let line = skippedSleepDaysLine(skippedSleepDays, minHrSamples: IntelligenceEngine.minHrSamples) {
skippedDayLines.append(line)
}
// #1538: the denominator is the number of CACHEABLE days this pass (reused + freshly cached),
// not `maxDays`. A day that never reaches the cache — an import/ring owner, an active Test-Centre
// trace, an unreadable fingerprint, or a night under the ≥200-sample floor — can never be reused,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ object IntelligenceEngine {
*/
private val analyzeGate = Mutex()

/** #1121: days this pass skipped for too little raw HR, emitted as ONE line after the loop instead of
* one line each — see [skippedSleepDaysLine]. A FIELD, not a local, for a mechanical reason:
* [analyzeRecentOnCpu] is `suspend` and sits 64 bytes under the JVM ceiling #1524 guards, so one more
* local live across a loop full of suspension points is saved and restored at every one of them —
* measured at +444 bytes, seven times the whole margin. Cleared at the top of each pass. Safe for the
* same reason [dayScanCache] is: every pass runs under [analyzeGate], so there is no concurrent access. */
private val skippedSleepDays = SleepSkipCollector()

/**
* #1005 BATTERY: in-memory per-day reuse for [analyzeRecent]'s pass-1 loop, keyed by day. On a heavy user
* (21 nights, ~178 k HR rows/night, a 1.26 GB store) every re-score re-read *every* night's raw streams
Expand Down Expand Up @@ -733,6 +741,7 @@ object IntelligenceEngine {
// itself. Emitted once per pass beside the reuse line. Byte-identical line to the Swift twin.
var dayPrepNanos = 0L
var dayScoreNanos = 0L
skippedSleepDays.reset()
// #1538: days that were actually cacheable this pass (freshly scored AND stored under a key).
// Together with [dayCacheReused] this is the honest denominator for the reuse ratio — see the
// diagnostic at the end of the loop.
Expand Down Expand Up @@ -861,7 +870,9 @@ object IntelligenceEngine {
// This day still paid for its read; count it, or the tally under-reports exactly the
// sparse-history installs where reads dominate most.
dayPrepNanos += System.nanoTime() - tPrep0
diag("sleep day=$day SKIPPED hrSamples=${hr.size} (need ≥$MIN_HR_SAMPLES)")
// Collected, not emitted: a day that will never have raw HR is re-skipped on every pass,
// so per-day lines repeat forever and evict older lines from the rolling log (#1121).
skippedSleepDays.add(day, hr.size)
continue
}
val rr = repo.rrIntervals(owner, from, to, STREAM_LIMIT)
Expand Down Expand Up @@ -1269,6 +1280,7 @@ object IntelligenceEngine {
val dayCacheWindow = (0 until maxDays)
.map { AnalyticsEngine.dayString(nowLocalMidnight - it * SECONDS_PER_DAY, tzOffsetSeconds) }.toHashSet()
dayScanCache.keys.retainAll(dayCacheWindow)
skippedSleepDays.emit(MIN_HR_SAMPLES, diag)
// #1538: the denominator is the number of CACHEABLE days this pass (reused + freshly cached), not
// [maxDays]. A day that never reaches the cache — an import/ring owner, an active trace, an
// unreadable fingerprint, or a night under the >=200-sample floor — can never be reused, so counting
Expand Down
65 changes: 65 additions & 0 deletions android/app/src/main/java/com/noop/analytics/SleepSkipSummary.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.noop.analytics

/**
* One line summarising every day a scoring pass skipped for want of HR samples, replacing one line per
* skipped day.
*
* A pass walks a fixed recent window, so a day whose raw HR never arrived is re-read, re-skipped and
* re-logged on every pass, forever. The strap banks days rather than weeks of raw HR while an import can
* supply a much longer spine of daily rows, so the steady state for an importing user is a permanent
* block of un-scoreable days. In one field capture that was 1262 lines - 21 days re-skipped across 63
* passes in two hours, about a fifth of everything the log had to say.
*
* That matters because the strap log is a fixed-size rolling buffer: noise does not merely annoy, it
* evicts the older lines an investigation needs. Collapsing per pass keeps every fact - which days, and
* each day's own HR count - while removing the repetition, so days are grouped by their sample count and
* listed rather than summarised into a range that could hide a gap.
*
* Returns null when nothing was skipped, so a healthy pass stays silent. Swift twin:
* `StrandAnalytics.skippedSleepDaysLine`.
*/
internal fun skippedSleepDaysLine(skipped: List<Pair<String, Int>>, minHrSamples: Int): String? {
if (skipped.isEmpty()) return null
val groups = skipped.groupBy { it.second }.toSortedMap().map { (count, entries) ->
val days = entries.map { it.first }.sorted()
"hrSamples=$count on ${days.size} day(s): ${days.joinToString(", ")}"
}
return "sleep SKIPPED ${skipped.size} day(s) — need ≥$minHrSamples hrSamples: " +
groups.joinToString("; ")
}

/**
* Accumulates the skipped days for one pass.
*
* A class rather than a `mutableListOf<Pair<String, Int>>()` built inline, for the same reason
* [DayTraceRecorders] is one: `analyzeRecentOnCpu` sits against the JVM's 64 KB per-method ceiling that
* #1524 guards, and the generic construction plus the null-check branch cost enough there to break it.
* Holding both inside the collector leaves the hot method with three plain calls.
*/
internal class SleepSkipCollector {
private val skipped = mutableListOf<Pair<String, Int>>()

fun add(day: String, hrSamples: Int) {
skipped += day to hrSamples
}

/** Drop the previous pass's days. Called at the top of every pass, under `analyzeGate`. */
fun reset() {
skipped.clear()
}

/** The one summary line for this pass, or null when nothing was skipped. */
fun line(minHrSamples: Int): String? = skippedSleepDaysLine(skipped, minHrSamples)

/**
* Emit this pass's summary to [diag], if there is one.
*
* The null check lives here rather than at the call site deliberately: the caller is
* `analyzeRecentOnCpu`, which passes the JVM ceiling test with single-digit bytes to spare, so a local
* plus a branch there costs margin that this method has in abundance.
*/
fun emit(minHrSamples: Int, diag: (String) -> Unit) {
val text = line(minHrSamples)
if (text != null) diag(text)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.noop.analytics

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* Pins the collapsed skipped-day line (#1121). Swift twin: `SleepSkipSummaryTests`. The two must emit
* byte-identical strings, so these expectations are spelled out rather than pattern-matched.
*/
class SleepSkipSummaryTest {

@Test
fun `nothing skipped stays silent`() {
assertNull(skippedSleepDaysLine(emptyList(), 200))
}

@Test
fun `one day`() {
assertEquals(
"sleep SKIPPED 1 day(s) — need ≥200 hrSamples: hrSamples=97 on 1 day(s): 2026-08-25",
skippedSleepDaysLine(listOf("2026-08-25" to 97), 200),
)
}

@Test
fun `groups by count ascending and sorts days`() {
assertEquals(
"sleep SKIPPED 3 day(s) — need ≥200 hrSamples: " +
"hrSamples=0 on 2 day(s): 2026-08-05, 2026-08-07; " +
"hrSamples=97 on 1 day(s): 2026-08-25",
skippedSleepDaysLine(
listOf("2026-08-07" to 0, "2026-08-25" to 97, "2026-08-05" to 0), 200,
),
)
}

@Test
fun `every day is listed`() {
// Lossless on purpose: a range would hide a gap, and a gap in which days lack raw HR is exactly
// the thing an investigation is looking for.
val days = (5..9).map { "2026-08-%02d".format(it) to 0 }
val line = skippedSleepDaysLine(days, 200)!!
days.forEach { assertTrue("missing ${it.first}", line.contains(it.first)) }
}

@Test
fun `the collector emits once per pass and resets`() {
val out = mutableListOf<String>()
val c = SleepSkipCollector()
c.add("2026-08-05", 0)
c.add("2026-08-06", 0)
c.emit(200) { out += it }
assertEquals(1, out.size)
c.reset()
c.emit(200) { out += it } // a pass that skipped nothing must add no line
assertEquals(1, out.size)
}
}
Loading