diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index a47bc0fb3f..0a09838ef2 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -919,12 +919,26 @@ 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 + // 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) @@ -953,7 +967,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 @@ -968,7 +981,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 @@ -2017,11 +2029,8 @@ final class IntelligenceEngine: ObservableObject { let recoveryEpoch = Baselines.recoveryBaselineEpoch() let respEraEpoch = Baselines.deviceEraEpoch(respCandidateDays.map { (day: $0, sourceId: respSourceByDay[$0] ?? deviceId) }) let respEpoch = max(recoveryEpoch, respEraEpoch) - // The HRV epoch is read ONCE, here, and handed to the history, the scored fold and the #1614 trace. - // 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. - let hrvEpoch = Baselines.hrvBaselineEpoch() + // The effective HRV epoch was resolved once before pass 1. Reuse it for the history, scored fold + // and #1614 trace so none can silently revert to the manual epoch or observe another pref read. let hrvHistory = ChargeBaselines.history(imported: importedNights { $0.avgHrv }, own: ownNights(nightlyHrvByDay), anchorDay: newestDay, cfg: hrvCfg, baselineEpoch: hrvEpoch) let rhrHistory = ChargeBaselines.history(imported: importedNights { $0.restingHr.map(Double.init) }, @@ -2051,6 +2060,7 @@ final class IntelligenceEngine: ObservableObject { // and the trace cannot describe a baseline the scorer is not using. #2525 adds one line naming // what each of the four baselines was folded from (own nights, or own nights still seeded by the // import), read off the same histories the folds below consume. + // The effective epoch was computed once before pass 1 and is handed to both folds and this trace. if TestCentre.active(.recovery) { diagnosticSink?(ChargeBaselines.logLine(anchorDay: newestDay, hrv: hrvHistory, restingHR: rhrHistory, resp: respHistory, skin: skinHistory), .recovery) 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() diff --git a/Tools/parity_ledger_baseline.json b/Tools/parity_ledger_baseline.json index 9fbbdc2a3a..06a1b56267 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": 29, "identities_sha256": "a13306c37eb1a9272b8666d09700d5120fb5fe0e629f3aaaeef649357f7f8b45"}, {"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"}, 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 9683d3f290..f5afa4e5fd 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -531,8 +531,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) @@ -543,7 +553,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 @@ -736,8 +746,7 @@ 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. + // 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, baselineEpoch) val rhrBase1 = Baselines.foldHistory(hist.map { it.restingHr?.toDouble() }, hist.map { it.day }, rhrCfg, recoveryEpoch) val baselines1 = ProfileBaselines(hrv = hrvBase1, restingHR = rhrBase1) @@ -1538,8 +1547,8 @@ object IntelligenceEngine { respCandidateDays.map { it to (respSourceByDay[it] ?: importedDeviceId) }, ) val respEpoch = maxOf(recoveryEpoch, respEraEpoch) - // HRV honours noop.hrvBaselineEpoch; rhr/resp/skin honour noop.recoveryBaselineEpoch, so the manual - // Recalibrate restarts the whole Charge build-up together. A 0.0 epoch drops nothing. + // HRV uses the later manual or WHOOP 5 unit-regime epoch already threaded through pass 1; + // rhr/resp/skin honour noop.recoveryBaselineEpoch. A 0.0 epoch drops nothing. val hrvHistory = ChargeBaselines.history( importedNights { it.avgHrv }, ownNights(nightlyHrvByDay), newestDay, hrvCfg, baselineEpoch, ) 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)