From abb67c88cd31f7478048a0beff5b75bf39a6f6c2 Mon Sep 17 00:00:00 2001 From: kavemang Date: Mon, 28 Sep 2026 22:53:51 -0500 Subject: [PATCH 1/4] fix: isolate WHOOP 5 HRV baseline after labelled R-R Refs #2126 --- Strand/Data/IntelligenceEngine.swift | 26 ++++++++++++------ .../com/noop/analytics/IntelligenceEngine.kt | 27 ++++++++++++++----- .../analytics/HrvBaselineRecalibrationTest.kt | 24 +++++++++++++++++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index f264cedb9b..e99dfc2950 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -892,12 +892,24 @@ final class IntelligenceEngine: ObservableObject { // refresh last ran (4000 vs 120 days). This mirrors the Android port's `days(importedDeviceId)`. let hist = ((try? await store.dailyMetrics(deviceId: deviceId, from: "0000-01-01", to: "9999-12-31")) ?? []) .sorted { $0.day < $1.day } + // #2126: pre-label WHOOP 5 R-R rows used a different unit interpretation. Keep their + // displayed nights, but never let those HRV values judge nights scored from labelled beats. + // foldHistory compares UTC-midnight day keys, so encode the first beat's LOCAL day as the + // UTC midnight of that day key (the same convention used by the manual epoch). + let hrvRegimeEpoch: Double + if (try? await store.isWhoop5RRSource(deviceId: regActiveId)) == true, + let first = try? await store.firstScorableWhoop5RRTimestamp(deviceId: regActiveId) { + hrvRegimeEpoch = Double(Self.midnightUtc(first + tzOffset)) + } else { + hrvRegimeEpoch = 0 + } + let hrvEpoch = max(Baselines.hrvBaselineEpoch(), hrvRegimeEpoch) // HRV baseline honours the manual "Recalibrate baseline" epoch (noop.hrvBaselineEpoch); the // resting-HR baseline honours the Charge-wide sibling (noop.recoveryBaselineEpoch). Pass the // per-value "yyyy-MM-dd" day keys (parallel to the values) so foldHistory can drop every night - // before the epoch. A 0 / absent epoch makes this byte-identical to the plain fold, so scoring is - // unchanged until the user taps Recalibrate. - let hrvBase1 = Baselines.foldHistory(hist.map { $0.avgHrv }, dayKeys: hist.map { $0.day }, cfg: hrvCfg) + // before the later of the manual and WHOOP 5 unit-regime epochs. + let hrvBase1 = Baselines.foldHistory(hist.map { $0.avgHrv }, dayKeys: hist.map { $0.day }, + cfg: hrvCfg, baselineEpoch: hrvEpoch) let rhrBase1 = Baselines.foldHistory(hist.map { $0.restingHr.map(Double.init) }, dayKeys: hist.map { $0.day }, cfg: rhrCfg, baselineEpoch: Baselines.recoveryBaselineEpoch()) let baselines1 = AnalyticsEngine.ProfileBaselines(hrv: hrvBase1, restingHR: rhrBase1) @@ -2021,11 +2033,9 @@ final class IntelligenceEngine: ObservableObject { // burying the rest of the export. The whole history is still folded, so the state is unchanged // and the trace cannot describe a baseline the scorer is not using. // - // The epoch is read ONCE, here, and handed to BOTH folds. Baselines+Trace is deliberately pure so - // it cannot read the pref itself, and letting the scored fold take its UserDefaults default while - // the trace read its own copy would leave a window - however small - where a diagnostic describes - // a fold the score did not perform. Passing it explicitly below is byte-identical to that default. - let hrvEpoch = Baselines.hrvBaselineEpoch() + // The effective epoch was computed once before pass 1 and is handed to BOTH folds and this trace. + // Baselines+Trace is deliberately pure; a separate preference read here could describe a fold + // other than the one the scorer actually performed. if TestCentre.active(.recovery) { let traced = Baselines.foldHistoryTrace(hrvSeq, dayKeys: hrvDayKeys, cfg: hrvCfg, metric: "hrv", diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index d1c2cda90d..cdccdb9e30 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -702,6 +702,9 @@ object IntelligenceEngine { // The durable fix is the Swift shape — `analyzeRecent` reads `registry.all()` itself (:623), so no // caller CAN omit it — but that changes this signature and every caller, so it is left as follow-up. val candidatePriorities = ownerSource?.candidatePriorities().orEmpty() + // #2126: the first labelled WHOOP 5 beat begins a new HRV unit regime. The fold compares + // UTC-midnight day keys, so the boundary is the UTC midnight of that beat's local day. + val hrvEpoch = whoop5HrvRegimeEpoch(repo, candidatePriorities, importedDeviceId, tzOffsetSeconds, baselineEpoch) if (ownerSource == null) { diag(ownerSourceAbsentLine(importedDeviceId)) } @@ -729,9 +732,8 @@ object IntelligenceEngine { // repository revision below handles step-only inserts even when the HR-keyed day cache is reused. // HRV baseline honours the manual "Recalibrate baseline" epoch (noop.hrvBaselineEpoch): pass the // per-value "yyyy-MM-dd" day keys (parallel to the values) so foldHistory drops every night before - // the epoch. baselineEpoch is threaded down from the Context-aware caller (0.0 = no recalibration). - // rhr/resp/skin stay on the 2-arg fold , recalibration is HRV-only. - val hrvBase1 = Baselines.foldHistory(hist.map { it.avgHrv }, hist.map { it.day }, hrvCfg, baselineEpoch) + // the later of the manual recalibration and WHOOP 5 unit-regime epochs. + val hrvBase1 = Baselines.foldHistory(hist.map { it.avgHrv }, hist.map { it.day }, hrvCfg, hrvEpoch) val rhrBase1 = Baselines.foldHistory(hist.map { it.restingHr?.toDouble() }, hist.map { it.day }, rhrCfg, recoveryEpoch) val baselines1 = ProfileBaselines(hrv = hrvBase1, restingHR = rhrBase1) @@ -1543,9 +1545,9 @@ object IntelligenceEngine { // A 0.0 epoch is byte-identical to the plain fold, so scoring is unchanged until the user taps it. // #1614: the per-night HRV fold, traced (see [emitHrvFoldTrace] for scope and why it is a call // rather than an inline lambda). Sits immediately above the fold it describes, and takes the SAME - // baselineEpoch, so the trace can only ever describe the fold the scorer actually performed. - emitHrvFoldTrace(recoveryTraceSink, hrvSeq, hrvDayKeys, hrvCfg, baselineEpoch) - val hrvBase2 = Baselines.foldHistory(hrvSeq, hrvDayKeys, hrvCfg, baselineEpoch) + // hrvEpoch, so the trace can only ever describe the fold the scorer actually performed. + emitHrvFoldTrace(recoveryTraceSink, hrvSeq, hrvDayKeys, hrvCfg, hrvEpoch) + val hrvBase2 = Baselines.foldHistory(hrvSeq, hrvDayKeys, hrvCfg, hrvEpoch) val rhrBase2 = Baselines.foldHistory(rhrSeq, rhrDayKeys, rhrCfg, recoveryEpoch) // Resp baseline: WITHIN one brand it still mixes imported (cloud) values with on-device RSA // estimates, which stays an accepted tradeoff (the z-score is scale-tolerant and foldHistory @@ -3003,6 +3005,19 @@ object IntelligenceEngine { repo: com.noop.data.WhoopRepository, candidates: List>, fallback: String, ): Boolean = repo.isWhoop5RrSource(candidates.firstOrNull { it.second == 0 }?.first ?: fallback) + private suspend fun whoop5HrvRegimeEpoch( + repo: com.noop.data.WhoopRepository, candidates: List>, + fallback: String, offsetSec: Long, manualEpoch: Double, + ): Double { + val owner = candidates.firstOrNull { it.second == 0 }?.first ?: fallback + if (!repo.isWhoop5RrSource(owner)) return manualEpoch + return hrvBaselineEpochForWhoop5(repo.firstScorableWhoop5RrTs(owner), offsetSec, manualEpoch) + } + + internal fun hrvBaselineEpochForWhoop5(firstScorableTs: Long?, offsetSec: Long, manualEpoch: Double): Double = + if (firstScorableTs == null) manualEpoch + else maxOf(manualEpoch, midnightUtc(firstScorableTs + offsetSec).toDouble()) + /** Keep both stream witnesses and the R-R alias policy in the nightly cache key (#29). * The two database awaits live here to preserve the scoring method's instrumentation budget. */ private suspend fun rrAwareDayCacheKey( diff --git a/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt b/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt index cc27cfc21a..99412b437f 100644 --- a/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt +++ b/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt @@ -24,6 +24,30 @@ class HrvBaselineRecalibrationTest { private val hrvCfg = Baselines.metricCfg.getValue("hrv") + @Test + fun whoop5UnitBoundaryDropsOldHrvWithoutDroppingLaterLabelledNights() { + // 02:00 UTC is still the previous local day at UTC-5. The fold's day keys use + // UTC-midnight epochs, so the regime starts at the UTC midnight of that local day. + val firstBeat = LocalDate.parse("2026-06-15").atStartOfDay(ZoneOffset.UTC).toEpochSecond() + 2 * 3600 + val epoch = IntelligenceEngine.hrvBaselineEpochForWhoop5(firstBeat, -5 * 3600L, 0.0) + val expected = LocalDate.parse("2026-06-14").atStartOfDay(ZoneOffset.UTC).toEpochSecond().toDouble() + assertEquals(expected, epoch, 0.0) + + val days = listOf("2026-06-10", "2026-06-11", "2026-06-12", "2026-06-13", + "2026-06-14", "2026-06-15", "2026-06-16", "2026-06-17") + val values: List = listOf(90.0, 91.0, 89.0, 92.0, 50.0, 51.0, 49.0, 50.0) + val folded = Baselines.foldHistory(values, days, hrvCfg, epoch) + assertEquals(4, folded.nValid) + assertEquals(50.0, folded.baseline, 2.0) + assertTrue(folded.usable) + + val laterManual = LocalDate.parse("2026-06-16").atStartOfDay(ZoneOffset.UTC).toEpochSecond().toDouble() + assertEquals(laterManual, + IntelligenceEngine.hrvBaselineEpochForWhoop5(firstBeat, -5 * 3600L, laterManual), 0.0) + assertEquals(laterManual, + IntelligenceEngine.hrvBaselineEpochForWhoop5(null, -5 * 3600L, laterManual), 0.0) + } + // ── 1. Early-life anti-anchoring ──────────────────────────────────────────────────────────── @Test From 1116f48b553e81bf5285d635c73db09ac60c2e6d Mon Sep 17 00:00:00 2001 From: kavemang Date: Tue, 29 Sep 2026 00:18:03 -0500 Subject: [PATCH 2/4] fix: repair baseline PR build and calibration checks Refs #2126 --- Strand/Data/IntelligenceEngine.swift | 4 +- .../com/noop/analytics/IntelligenceEngine.kt | 38 ++++++++----------- .../analytics/HrvBaselineRecalibrationTest.kt | 24 ------------ .../java/com/noop/data/Whoop5RRSqliteTest.kt | 14 ++++++- 4 files changed, 30 insertions(+), 50 deletions(-) diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index e99dfc2950..7528fe677b 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -892,6 +892,8 @@ final class IntelligenceEngine: ObservableObject { // refresh last ran (4000 vs 120 days). This mirrors the Android port's `days(importedDeviceId)`. let hist = ((try? await store.dailyMetrics(deviceId: deviceId, from: "0000-01-01", to: "9999-12-31")) ?? []) .sorted { $0.day < $1.day } + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + let regActiveId = (try? registry.activeDeviceId()) ?? deviceId // #2126: pre-label WHOOP 5 R-R rows used a different unit interpretation. Keep their // displayed nights, but never let those HRV values judge nights scored from labelled beats. // foldHistory compares UTC-midnight day keys, so encode the first beat's LOCAL day as the @@ -938,7 +940,6 @@ final class IntelligenceEngine: ObservableObject { // stable for the run. With only the seeded 'my-whoop' row paired (the default and every // single-WHOOP install) the active strap is `deviceId`, so `resolveDayOwner` below returns // `deviceId` for every day and the per-day reads are byte-identical to the pre-I2 behaviour. - let registry = DeviceRegistryStore(dbQueue: store.registryWriter) // #1567: `try?` used to swallow this read, and an empty device list is NOT a neutral outcome — // every day then resolves to `.whoop5` (see `skinTempFamily(forOwner:devices:)`), so a WHOOP 4.0's // raw skin-temp ADC is read as centidegrees, misses the 28–42 °C worn gate, and the night yields @@ -953,7 +954,6 @@ final class IntelligenceEngine: ObservableObject { regDevices = [] diagnosticSink?(AnalyticsEngine.registryUnavailableLine(importedDeviceId: deviceId), nil) } - let regActiveId = (try? registry.activeDeviceId()) ?? deviceId // Floor `now` to LOCAL midnight (#277) so each `dayStart` lands on a local-day boundary and the // day keys are LOCAL calendar days, consistent with the dashboard's local "today" lookup. A diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index cdccdb9e30..0bce0c40f4 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -526,8 +526,18 @@ object IntelligenceEngine { stepsMotionCachePersisted = raw } } + // Resolve the unit-regime boundary outside analyzeRecentOnCpu: that method is close to + // its JaCoCo bytecode budget, and both the initial and heal pass must fold the same era. + val offsetSec = java.util.TimeZone.getDefault().getOffset(nowSeconds * 1_000L) / 1_000L + val owner = ownerSource?.candidatePriorities()?.firstOrNull { it.second == 0 }?.first ?: importedDeviceId + val firstLabelledBeat = if (repo.isWhoop5RrSource(owner)) repo.firstScorableWhoop5RrTs(owner) else null + // foldHistory compares UTC-midnight day keys. Convert the beat's local day to the + // UTC midnight of that day key, then keep whichever cut is later. + val hrvEpoch = firstLabelledBeat?.let { + maxOf(baselineEpoch, midnightUtc(it + offsetSec).toDouble()) + } ?: baselineEpoch val (out, healed) = analyzeRecentOnCpu(repo, profile, maxDays, importedDeviceId, maxHROverride, - nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, + nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, hrvEpoch, recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow, spo2CandidateDisplay, effortMethod, dayCycleMode) @@ -538,7 +548,7 @@ object IntelligenceEngine { // re-scores the window against the cleaned store; its own heal then finds nothing (the duplicates // are gone), so this can never loop. Mirrors the Swift pendingForcedRescore re-arm. else analyzeRecentOnCpu(repo, profile, maxDays, importedDeviceId, maxHROverride, - nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, + nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, hrvEpoch, recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow, spo2CandidateDisplay, effortMethod, dayCycleMode).first @@ -702,9 +712,6 @@ object IntelligenceEngine { // The durable fix is the Swift shape — `analyzeRecent` reads `registry.all()` itself (:623), so no // caller CAN omit it — but that changes this signature and every caller, so it is left as follow-up. val candidatePriorities = ownerSource?.candidatePriorities().orEmpty() - // #2126: the first labelled WHOOP 5 beat begins a new HRV unit regime. The fold compares - // UTC-midnight day keys, so the boundary is the UTC midnight of that beat's local day. - val hrvEpoch = whoop5HrvRegimeEpoch(repo, candidatePriorities, importedDeviceId, tzOffsetSeconds, baselineEpoch) if (ownerSource == null) { diag(ownerSourceAbsentLine(importedDeviceId)) } @@ -733,7 +740,7 @@ object IntelligenceEngine { // HRV baseline honours the manual "Recalibrate baseline" epoch (noop.hrvBaselineEpoch): pass the // per-value "yyyy-MM-dd" day keys (parallel to the values) so foldHistory drops every night before // the later of the manual recalibration and WHOOP 5 unit-regime epochs. - val hrvBase1 = Baselines.foldHistory(hist.map { it.avgHrv }, hist.map { it.day }, hrvCfg, hrvEpoch) + val hrvBase1 = Baselines.foldHistory(hist.map { it.avgHrv }, hist.map { it.day }, hrvCfg, baselineEpoch) val rhrBase1 = Baselines.foldHistory(hist.map { it.restingHr?.toDouble() }, hist.map { it.day }, rhrCfg, recoveryEpoch) val baselines1 = ProfileBaselines(hrv = hrvBase1, restingHR = rhrBase1) @@ -1545,9 +1552,9 @@ object IntelligenceEngine { // A 0.0 epoch is byte-identical to the plain fold, so scoring is unchanged until the user taps it. // #1614: the per-night HRV fold, traced (see [emitHrvFoldTrace] for scope and why it is a call // rather than an inline lambda). Sits immediately above the fold it describes, and takes the SAME - // hrvEpoch, so the trace can only ever describe the fold the scorer actually performed. - emitHrvFoldTrace(recoveryTraceSink, hrvSeq, hrvDayKeys, hrvCfg, hrvEpoch) - val hrvBase2 = Baselines.foldHistory(hrvSeq, hrvDayKeys, hrvCfg, hrvEpoch) + // baselineEpoch, so the trace can only ever describe the fold the scorer actually performed. + emitHrvFoldTrace(recoveryTraceSink, hrvSeq, hrvDayKeys, hrvCfg, baselineEpoch) + val hrvBase2 = Baselines.foldHistory(hrvSeq, hrvDayKeys, hrvCfg, baselineEpoch) val rhrBase2 = Baselines.foldHistory(rhrSeq, rhrDayKeys, rhrCfg, recoveryEpoch) // Resp baseline: WITHIN one brand it still mixes imported (cloud) values with on-device RSA // estimates, which stays an accepted tradeoff (the z-score is scale-tolerant and foldHistory @@ -3005,19 +3012,6 @@ object IntelligenceEngine { repo: com.noop.data.WhoopRepository, candidates: List>, fallback: String, ): Boolean = repo.isWhoop5RrSource(candidates.firstOrNull { it.second == 0 }?.first ?: fallback) - private suspend fun whoop5HrvRegimeEpoch( - repo: com.noop.data.WhoopRepository, candidates: List>, - fallback: String, offsetSec: Long, manualEpoch: Double, - ): Double { - val owner = candidates.firstOrNull { it.second == 0 }?.first ?: fallback - if (!repo.isWhoop5RrSource(owner)) return manualEpoch - return hrvBaselineEpochForWhoop5(repo.firstScorableWhoop5RrTs(owner), offsetSec, manualEpoch) - } - - internal fun hrvBaselineEpochForWhoop5(firstScorableTs: Long?, offsetSec: Long, manualEpoch: Double): Double = - if (firstScorableTs == null) manualEpoch - else maxOf(manualEpoch, midnightUtc(firstScorableTs + offsetSec).toDouble()) - /** Keep both stream witnesses and the R-R alias policy in the nightly cache key (#29). * The two database awaits live here to preserve the scoring method's instrumentation budget. */ private suspend fun rrAwareDayCacheKey( diff --git a/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt b/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt index 99412b437f..cc27cfc21a 100644 --- a/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt +++ b/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt @@ -24,30 +24,6 @@ class HrvBaselineRecalibrationTest { private val hrvCfg = Baselines.metricCfg.getValue("hrv") - @Test - fun whoop5UnitBoundaryDropsOldHrvWithoutDroppingLaterLabelledNights() { - // 02:00 UTC is still the previous local day at UTC-5. The fold's day keys use - // UTC-midnight epochs, so the regime starts at the UTC midnight of that local day. - val firstBeat = LocalDate.parse("2026-06-15").atStartOfDay(ZoneOffset.UTC).toEpochSecond() + 2 * 3600 - val epoch = IntelligenceEngine.hrvBaselineEpochForWhoop5(firstBeat, -5 * 3600L, 0.0) - val expected = LocalDate.parse("2026-06-14").atStartOfDay(ZoneOffset.UTC).toEpochSecond().toDouble() - assertEquals(expected, epoch, 0.0) - - val days = listOf("2026-06-10", "2026-06-11", "2026-06-12", "2026-06-13", - "2026-06-14", "2026-06-15", "2026-06-16", "2026-06-17") - val values: List = listOf(90.0, 91.0, 89.0, 92.0, 50.0, 51.0, 49.0, 50.0) - val folded = Baselines.foldHistory(values, days, hrvCfg, epoch) - assertEquals(4, folded.nValid) - assertEquals(50.0, folded.baseline, 2.0) - assertTrue(folded.usable) - - val laterManual = LocalDate.parse("2026-06-16").atStartOfDay(ZoneOffset.UTC).toEpochSecond().toDouble() - assertEquals(laterManual, - IntelligenceEngine.hrvBaselineEpochForWhoop5(firstBeat, -5 * 3600L, laterManual), 0.0) - assertEquals(laterManual, - IntelligenceEngine.hrvBaselineEpochForWhoop5(null, -5 * 3600L, laterManual), 0.0) - } - // ── 1. Early-life anti-anchoring ──────────────────────────────────────────────────────────── @Test diff --git a/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt b/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt index 28ae3555eb..98d7cfcb17 100644 --- a/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt +++ b/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt @@ -340,12 +340,22 @@ class Whoop5RRSqliteTest { val restored = score() assertEquals(40.0, restored.avgHrv!!, 0.001) - assertNotNull(restored.recovery) + if (expectsProtection) { + // #2126: the first labelled beat restarts the WHOOP 5 HRV baseline. This one-day + // fixture has a real HRV again, but Charge must calibrate rather than compare it + // with the eight pre-label history nights seeded above. + assertNull(restored.recovery) + } else { + assertNotNull(restored.recovery) + } if (expectsProtection) { assertNotEquals(legacySnapshot.respRateBpm, restored.respRateBpm) assertNotEquals(legacySnapshot.avgSdnn, restored.avgSdnn) } - if (expectsProtection) assertEquals(owner, repo.scoreInputSource(computedId, restored.day, "recovery")) + if (expectsProtection) { + assertNull("a calibrating Charge has no scoring provenance", + repo.scoreInputSource(computedId, restored.day, "recovery")) + } assertFalse(showsLegacyGap(restored, owner)) val idle = score() assertEquals(restored.avgHrv, idle.avgHrv) From 46ec76b6340ae7f82f8568621799a17886388ed8 Mon Sep 17 00:00:00 2001 From: kavemang Date: Tue, 29 Sep 2026 00:23:14 -0500 Subject: [PATCH 3/4] chore: refresh parity baseline after production UTC day use Refs #2126 --- Tools/parity_ledger_baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/parity_ledger_baseline.json b/Tools/parity_ledger_baseline.json index 211bf10701..debb583d5b 100644 --- a/Tools/parity_ledger_baseline.json +++ b/Tools/parity_ledger_baseline.json @@ -19,7 +19,7 @@ {"rule": "test-only-callsite", "scope": "Packages/StrandImport", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/StrandImport; no wildcard matching.", "count": 14, "identities_sha256": "1acdc71178e0a555f000c42980345e8c38495f9e3806a866f31b260865d3ac98"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopProtocol; no wildcard matching.", "count": 30, "identities_sha256": "a9dc115534e8710217db9cd39bcceaa1419741737bfd3b733007dc0215cd5af2"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopStore", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopStore; no wildcard matching.", "count": 37, "identities_sha256": "2a62c1ece1c541b662dd407f0668d9a02782bb4d21f9bb181518272ff12f0e40"}, - {"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 65, "identities_sha256": "fda58e5bb0fe07a9fa773d8e5f8961d483e8b177f1529b6e9dc3ac26df7c31cf"}, + {"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 64, "identities_sha256": "4d797b332a543f7a6ef83c7fe841cccf89fcab1366eac3aed3d2eb6110ef9649"}, {"rule": "test-only-callsite", "scope": "android/data", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/data; no wildcard matching.", "count": 13, "identities_sha256": "6abaae19303cadccbe259d7db320850e269433472b28c84a1bfce7d32d54ce5f"}, {"rule": "test-only-callsite", "scope": "android/ingest", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/ingest; no wildcard matching.", "count": 12, "identities_sha256": "d4f45d66087768eab133b918af6658b06ec1d91305e38fb045f6ae1629c94678"}, {"rule": "test-only-callsite", "scope": "android/oura", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/oura; no wildcard matching.", "count": 4, "identities_sha256": "9beaa9034af0a1021b9a9f43739971934f260147e0a9a5c462468548a7fd577d"}, From ac56c3c669fef0dbae4bd6ee6a973be7a41e5402 Mon Sep 17 00:00:00 2001 From: kavemang Date: Tue, 29 Sep 2026 16:28:55 -0500 Subject: [PATCH 4/4] test: expect WHOOP 5 Charge calibration after source promotion Refs #2126 --- StrandTests/IntelligenceRRSourceTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/StrandTests/IntelligenceRRSourceTests.swift b/StrandTests/IntelligenceRRSourceTests.swift index e0d01bfecb..889aa875ac 100644 --- a/StrandTests/IntelligenceRRSourceTests.swift +++ b/StrandTests/IntelligenceRRSourceTests.swift @@ -162,12 +162,14 @@ final class IntelligenceRRSourceTests: XCTestCase { let restored = try await score() XCTAssertGreaterThan(try XCTUnwrap(restored.avgHrv), 0) XCTAssertNotEqual(restored.avgHrv, legacySnapshot.avgHrv) - XCTAssertNotNil(restored.recovery) + // #2126: the first labelled night restores HRV, but Charge recalibrates against + // labelled nights rather than scoring against the eight pre-label seed nights. + XCTAssertNil(restored.recovery) XCTAssertNotEqual(restored.respRateBpm, legacySnapshot.respRateBpm) XCTAssertNotEqual(restored.avgSdnn, legacySnapshot.avgSdnn) let promotedSource = try await store.scoreInputSource(deviceId: canonical + "-noop", day: input.day, key: "recovery") - XCTAssertEqual(promotedSource, active, "freshly scored provenance replaces the snapshot") + XCTAssertNil(promotedSource, "a calibrating Charge has no scoring provenance") let restoredGap = try await showsLegacyGap(restored, store: store, owner: active) XCTAssertFalse(restoredGap) let idle = try await score()