diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepSkipSummary.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepSkipSummary.swift new file mode 100644 index 0000000000..3a0df8f07b --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepSkipSummary.swift @@ -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: "; ") +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepSkipSummaryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepSkipSummaryTests.swift new file mode 100644 index 0000000000..e90f095f70 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepSkipSummaryTests.swift @@ -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)") + } + } +} diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 0018488fd8..e30bf429a9 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -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 @@ -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 @@ -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)) ?? [] @@ -1372,6 +1389,9 @@ final class IntelligenceEngine: ObservableObject { let dayCacheWindow = Set((0..=200-sample floor — can never be reused, so counting diff --git a/android/app/src/main/java/com/noop/analytics/SleepSkipSummary.kt b/android/app/src/main/java/com/noop/analytics/SleepSkipSummary.kt new file mode 100644 index 0000000000..74dbdc12bf --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SleepSkipSummary.kt @@ -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>, 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>()` 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>() + + 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) + } +} diff --git a/android/app/src/test/java/com/noop/analytics/SleepSkipSummaryTest.kt b/android/app/src/test/java/com/noop/analytics/SleepSkipSummaryTest.kt new file mode 100644 index 0000000000..16b90e99b6 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepSkipSummaryTest.kt @@ -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() + 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) + } +}