Skip to content
Open
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
30 changes: 20 additions & 10 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) },
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions StrandTests/IntelligenceRRSourceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion Tools/parity_ledger_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
21 changes: 15 additions & 6 deletions android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
Expand Down
14 changes: 12 additions & 2 deletions android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading