diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeBaselines.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeBaselines.swift new file mode 100644 index 0000000000..ecd3e82457 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeBaselines.swift @@ -0,0 +1,185 @@ +import Foundation +import WhoopStore + +// ChargeBaselines.swift — which nights the Charge baselines are folded from (#2525). +// +// Charge scores a night against personal baselines for HRV, resting HR, respiration and skin +// temperature. Before #2525 those baselines were folded from the WHOLE imported vendor history plus only +// the nights of the current scan window (21 by default). The wearer's own older nights dropped out on +// every pass while the import never did, so an import kept roughly a third of the weight for good, pinned +// to the wearer's state at the end of their subscription however old that was. +// +// The rule here is a correction of which nights count, not a new scoring method: +// - Only nights inside a fixed calendar window (`windowDays`, counted back from the anchor day) count. +// The window is the scan window's own 21 days, so a wearer with no import folds exactly the nights the +// 21-day pass always folded; what changes is that an imported night ages out like an own one, and +// that a full-history repair pass folds the same 21 days instead of the whole history. +// - The wearer's own (NOOP-computed) nights are the baseline. Imported vendor nights only SEED it: they +// take part while the own nights alone would not yet make a trusted baseline (`handoffNights`, the +// model's own `minNightsTrust`), and drop out entirely once they would. The two sources are different +// algorithms, so they are never mixed past the cold start. +// - The rule keeps no state. Every call decides from the nights it is given. +// +// Pure and database-free; the engine feeds it this pass's fresh values, the dashboard the stored rows. +// Byte-identical twin of the Kotlin `ChargeBaselines`. +public enum ChargeBaselines { + + /// Calendar days of nightly history a Charge baseline reads, counted back from the anchor day and + /// including it. Deliberately the default scan window (`analyzeRecent(maxDays: 21)`), so the own nights + /// a baseline reads are the ones the pass has just scored, and nothing changes for a wearer with no + /// import. + public static let windowDays: Int = 21 + + /// One metric's nightly history for a Charge baseline, oldest first, ready for + /// `Baselines.foldHistory(_:dayKeys:cfg:baselineEpoch:)`, plus the counts the diagnostic line reports. + public struct History: Equatable, Sendable { + /// `yyyy-MM-dd` keys, ascending, parallel to `values`. + public let dayKeys: [String] + /// The nightly values; nil is a night that exists but carried no value (skip-and-hold). + public let values: [Double?] + /// True while imported vendor nights still take part (the own nights are not yet trusted on + /// their own and the window holds at least one imported night). + public let seededByImport: Bool + /// Valid own nights on or after the epoch inside the window: the count the handoff is decided on. + public let ownValidNights: Int + /// Imported nights this history carries (0 once the own nights have taken over). + public let importedNights: Int + + public init(dayKeys: [String], values: [Double?], seededByImport: Bool, + ownValidNights: Int, importedNights: Int) { + self.dayKeys = dayKeys + self.values = values + self.seededByImport = seededByImport + self.ownValidNights = ownValidNights + self.importedNights = importedNights + } + } + + /// Build one metric's Charge history from the imported and own nights (#2525). + /// + /// - Parameters: + /// - imported: imported vendor nights as `(day, value)`; a nil value is a night the import covers + /// without that metric. A repeated day keeps its last value. + /// - own: NOOP-computed nights, same shape. A repeated day keeps its last value. + /// - anchorDay: the `yyyy-MM-dd` day the window ends on (the scoring pass's local today). + /// - cfg: the metric's baseline configuration, used to count the valid own nights exactly as the + /// fold will. + /// - baselineEpoch: the epoch (seconds) the caller's fold will drop earlier nights by: the manual + /// recalibration epoch, or a later device-era cut. Own nights before it do not count toward the + /// handoff, because the fold will not use them. + /// - windowDays: see `windowDays`. + /// - handoffNights: valid own nights at which the imported nights stop taking part. + /// + /// Days are filtered with pure civil-day arithmetic (no time zone), keeping only days on or before the + /// anchor and fewer than `windowDays` days before it. An unparseable key is dropped; an unparseable + /// anchor yields an empty history, so a caller can only ever under-state a baseline, never read one + /// from nights it cannot place. While seeding, an imported value wins a day both sources cover; an own + /// night fills a day the import does not cover or covers without a value (the precedence of the + /// dashboard's `mergeDaily` and of the engine merge before #2525, so a blank imported row can never + /// shadow a night the strap measured). Kotlin twin: `ChargeBaselines.history`. + public static func history(imported: [(day: String, value: Double?)], + own: [(day: String, value: Double?)], + anchorDay: String, + cfg: MetricCfg, + baselineEpoch: Double, + windowDays: Int = ChargeBaselines.windowDays, + handoffNights: Int = Baselines.minNightsTrust) -> History { + let empty = History(dayKeys: [], values: [], seededByImport: false, ownValidNights: 0, importedNights: 0) + guard windowDays > 0, let anchor = Baselines.isoEpochDay(anchorDay) else { return empty } + let inWindow: (String) -> Bool = { day in + guard let d = Baselines.isoEpochDay(day) else { return false } + return d <= anchor && anchor - d < windowDays + } + + var ownByDay: [String: Double?] = [:] + for night in own where inWindow(night.day) { ownByDay[night.day] = night.value } + let ownKeys = ownByDay.keys.sorted() + let ownValues = ownKeys.map { ownByDay[$0]! } + let ownValid = Baselines.foldHistory(ownValues, dayKeys: ownKeys, cfg: cfg, + baselineEpoch: baselineEpoch).nValid + + var importedByDay: [String: Double?] = [:] + for night in imported where inWindow(night.day) { importedByDay[night.day] = night.value } + + // Handed off, or nothing to seed with: the own nights are the whole history. + if ownValid >= handoffNights || importedByDay.isEmpty { + return History(dayKeys: ownKeys, values: ownValues, seededByImport: false, + ownValidNights: ownValid, importedNights: 0) + } + + // Seeding: every imported night in the window, then the own nights on the days it leaves open. The + // map's values are Optional, so `merged[day]` is `.some(nil)` for an imported night without a value: + // that slot is open too, and the own night fills it. An own night without a value still registers + // the day (a missing night), exactly as it would with no import. + var merged = importedByDay + for (day, value) in ownByDay { + if let existing = merged[day], existing != nil { continue } // an imported value wins + merged[day] = value + } + let keys = merged.keys.sorted() + return History(dayKeys: keys, values: keys.map { merged[$0]! }, seededByImport: true, + ownValidNights: ownValid, importedNights: importedByDay.count) + } + + /// The Charge baselines the dashboard reads, resolved from the stored daily rows with the same rule the + /// engine's pass-2 fold applies (#2525), so the "What shaped it" rows, the calibration count and the + /// confidence tier describe the baseline the Charge headline was scored against. + public struct Resolved: Equatable, Sendable { + public let hrvHistory: History + public let restingHRHistory: History + public let respHistory: History + public let hrv: BaselineState + public let restingHR: BaselineState + public let resp: BaselineState + + public init(hrvHistory: History, restingHRHistory: History, respHistory: History, + hrv: BaselineState, restingHR: BaselineState, resp: BaselineState) { + self.hrvHistory = hrvHistory + self.restingHRHistory = restingHRHistory + self.respHistory = respHistory + self.hrv = hrv + self.restingHR = restingHR + self.resp = resp + } + } + + /// Resolve HRV, resting-HR and respiration baselines from stored rows: `imported` are the imported + /// vendor rows, `own` the NOOP-computed ("-noop") rows. HRV folds on `hrvEpoch`, resting HR and + /// respiration on `recoveryEpoch`, exactly as the engine does. The engine additionally cuts respiration + /// at a device-era boundary (#459), which needs a per-night source the stored rows do not carry; the + /// two agree for every single-brand history. Kotlin twin: `ChargeBaselines.resolve`. + public static func resolve(imported: [DailyMetric], own: [DailyMetric], anchorDay: String, + hrvEpoch: Double, recoveryEpoch: Double) -> Resolved { + let hrvCfg = Baselines.hrvCfg, rhrCfg = Baselines.restingHRCfg, respCfg = Baselines.respCfg + let hrvHistory = history(imported: imported.map { (day: $0.day, value: $0.avgHrv) }, + own: own.map { (day: $0.day, value: $0.avgHrv) }, + anchorDay: anchorDay, cfg: hrvCfg, baselineEpoch: hrvEpoch) + let rhrHistory = history(imported: imported.map { (day: $0.day, value: $0.restingHr.map(Double.init)) }, + own: own.map { (day: $0.day, value: $0.restingHr.map(Double.init)) }, + anchorDay: anchorDay, cfg: rhrCfg, baselineEpoch: recoveryEpoch) + let respHistory = history(imported: imported.map { (day: $0.day, value: $0.respRateBpm) }, + own: own.map { (day: $0.day, value: $0.respRateBpm) }, + anchorDay: anchorDay, cfg: respCfg, baselineEpoch: recoveryEpoch) + return Resolved( + hrvHistory: hrvHistory, restingHRHistory: rhrHistory, respHistory: respHistory, + hrv: Baselines.foldHistory(hrvHistory.values, dayKeys: hrvHistory.dayKeys, cfg: hrvCfg, + baselineEpoch: hrvEpoch), + restingHR: Baselines.foldHistory(rhrHistory.values, dayKeys: rhrHistory.dayKeys, cfg: rhrCfg, + baselineEpoch: recoveryEpoch), + resp: Baselines.foldHistory(respHistory.values, dayKeys: respHistory.dayKeys, cfg: respCfg, + baselineEpoch: recoveryEpoch)) + } + + /// The Recovery test-mode line naming what each Charge baseline was folded from this pass. Per metric, + /// `own/N` means the own nights alone, N of them valid; `seed/N+M` means N valid own nights still seeded + /// by M imported nights. It reports only the composition the engine decided, not why the wearer's values + /// moved. Kotlin twin: `ChargeBaselines.logLine`, byte-identical. + public static func logLine(anchorDay: String, hrv: History, restingHR: History, resp: History, + skin: History, windowDays: Int = ChargeBaselines.windowDays) -> String { + let part: (History) -> String = { h in + h.seededByImport ? "seed/\(h.ownValidNights)+\(h.importedNights)" : "own/\(h.ownValidNights)" + } + return "charge baseline anchor=\(anchorDay) window=\(windowDays)d hrv=\(part(hrv)) " + + "rhr=\(part(restingHR)) resp=\(part(resp)) skin=\(part(skin))" + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ChargeBaselinesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ChargeBaselinesTests.swift new file mode 100644 index 0000000000..bac1a27776 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ChargeBaselinesTests.swift @@ -0,0 +1,354 @@ +import XCTest +@testable import StrandAnalytics +import WhoopStore + +/// Which nights the Charge baselines are folded from (#2525). +/// +/// The defect these pin: the imported vendor history was folded in full while the wearer's own nights +/// entered only from the scan window, so the import kept about a third of the weight however long NOOP had +/// been worn. Byte-identical twin: Kotlin `ChargeBaselinesTest` (same cases, same oracle literal). +final class ChargeBaselinesTests: XCTestCase { + + private let hrvCfg = Baselines.hrvCfg + private let rhrCfg = Baselines.restingHRCfg + + /// `yyyy-MM-dd` for a day count from 1970-01-01 (Howard Hinnant's civil-from-days), so the cases need no + /// calendar or time zone. The Kotlin twin uses `LocalDate.ofEpochDay`, which yields the same keys. + private static func dayKey(_ epochDay: Int) -> String { + let z = epochDay + 719_468 + let era = (z >= 0 ? z : z - 146_096) / 146_097 + let doe = z - era * 146_097 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + let mp = (5 * doy + 2) / 153 + let d = doy - (153 * mp + 2) / 5 + 1 + let m = mp < 10 ? mp + 3 : mp - 9 + let y = yoe + era * 400 + (m <= 2 ? 1 : 0) + return String(format: "%04d-%02d-%02d", y, m, d) + } + + /// 2026-09-30 as a day count from 1970-01-01. + private static let anchorEpochDay = 20_726 + + /// `count` consecutive nights ending `endBack` days before the anchor, all at `value`. + private static func run(_ count: Int, endBack: Int, value: Double?) -> [(day: String, value: Double?)] { + (0.. ChargeBaselines.History { + ChargeBaselines.history(imported: imported, own: own, anchorDay: Self.anchor, cfg: hrvCfg, baselineEpoch: 0) + } + + /// An imported value wins a day the own nights also cover (import users keep their seed). + func testWhileSeedingAnImportedValueWinsASharedDay() { + let h = seed(imported: [(day: Self.anchor, value: 62)], own: [(day: Self.anchor, value: 48)]) + XCTAssertTrue(h.seededByImport) + XCTAssertEqual(h.values, [62]) + } + + /// An own night fills a day the import does not cover. + func testWhileSeedingAnOwnNightFillsADayTheImportLacks() { + let yesterday = Self.dayKey(Self.anchorEpochDay - 1) + let h = seed(imported: [(day: yesterday, value: 62)], own: [(day: Self.anchor, value: 48)]) + XCTAssertEqual(h.dayKeys, [yesterday, Self.anchor]) + XCTAssertEqual(h.values, [62, 48]) + } + + /// A blank imported row must not shadow a night the strap measured, or an import whose rows are blank + /// for a metric blankets every strap night and the baseline never seeds ("Needs the strap"). + func testWhileSeedingABlankImportedRowIsFilledByTheOwnNight() { + let h = seed(imported: [(day: Self.anchor, value: nil)], own: [(day: Self.anchor, value: 48)]) + XCTAssertEqual(h.values, [48]) + } + + /// A blank imported row with no own night stays a missing night (an honest gap). + func testWhileSeedingABlankImportedRowWithNoOwnNightStaysMissing() { + let yesterday = Self.dayKey(Self.anchorEpochDay - 1) + let h = seed(imported: [(day: yesterday, value: nil), (day: Self.anchor, value: 62)], own: []) + XCTAssertTrue(h.seededByImport) + XCTAssertEqual(h.dayKeys, [yesterday, Self.anchor]) + XCTAssertEqual(h.values, [nil, 62]) + } + + /// An own night without a value neither overwrites an imported value nor removes a blank imported day. + func testWhileSeedingABlankOwnNightDisturbsNothing() { + let d1 = Self.dayKey(Self.anchorEpochDay - 2), d2 = Self.dayKey(Self.anchorEpochDay - 1) + let h = seed(imported: [(day: d1, value: 62), (day: d2, value: nil)], + own: [(day: d1, value: nil), (day: d2, value: nil), (day: Self.anchor, value: nil)]) + XCTAssertEqual(h.dayKeys, [d1, d2, Self.anchor]) + XCTAssertEqual(h.values, [62, nil, nil]) + } + + /// The starvation report's shape: a week of imported rows, all blank for HRV, over nights the strap + /// scored. The seven measured nights must reach the fold so the baseline can seed. + func testWhileSeedingAWeekOfBlankImportedRowsStillSeedsFromTheStrap() { + let imported = Self.run(7, endBack: 0, value: nil) + let own = (0..<7).map { i in (day: Self.dayKey(Self.anchorEpochDay - 6 + i), value: Optional(50.0 + Double(i))) } + let h = seed(imported: imported, own: own) + XCTAssertEqual(h.values.compactMap { $0 }.count, 7) + let folded = Baselines.foldHistory(h.values, dayKeys: h.dayKeys, cfg: hrvCfg, baselineEpoch: 0) + XCTAssertGreaterThanOrEqual(folded.nValid, Baselines.minNightsSeed) + } + + /// Nothing that cannot be placed on the calendar is folded. + func testUnparseableKeysAreDroppedAndAnUnparseableAnchorYieldsNothing() { + let own: [(day: String, value: Double?)] = [(day: "garbage", value: 50), (day: Self.anchor, value: 51)] + let h = ChargeBaselines.history(imported: [], own: own, anchorDay: Self.anchor, cfg: hrvCfg, baselineEpoch: 0) + XCTAssertEqual(h.dayKeys, [Self.anchor]) + let none = ChargeBaselines.history(imported: [], own: own, anchorDay: "not-a-day", cfg: hrvCfg, + baselineEpoch: 0) + XCTAssertEqual(none.dayKeys, []) + XCTAssertFalse(none.seededByImport) + } + + // MARK: - The defect (#2525) + + /// A wearer whose resting HR fell from 58 during their vendor subscription to 52 since. Under the old + /// rule (the whole import plus the last 21 own nights) the baseline stays about a third of the way back + /// towards 58 however long NOOP has been worn; under this rule it follows the wearer to 52. + func testTheBaselineFollowsTheWearerInsteadOfTheImport() { + let imported = Self.run(700, endBack: 181, value: 58) + let own = Self.run(180, endBack: 0, value: 52) + let oldRule = Baselines.foldHistory(imported.map(\.value) + own.suffix(21).map(\.value), cfg: rhrCfg) + XCTAssertEqual(oldRule.baseline, 54.1, accuracy: 0.2, + "the pinned third: 52 + 6 x 0.5^(21/14) is about 54.1") + + let h = ChargeBaselines.history(imported: imported, own: own, anchorDay: Self.anchor, cfg: rhrCfg, + baselineEpoch: 0) + let folded = Baselines.foldHistory(h.values, dayKeys: h.dayKeys, cfg: rhrCfg, baselineEpoch: 0) + XCTAssertEqual(folded.baseline, 52, accuracy: 1e-9) + } + + /// Once the own nights have taken over, the import has no weight at all: moving every imported value + /// leaves the baseline exactly where it was. + func testOnceHandedOffTheImportCarriesNoWeight() { + let own = Self.run(60, endBack: 0, value: 52) + let base = ChargeBaselines.history(imported: Self.run(200, endBack: 10, value: 58), own: own, + anchorDay: Self.anchor, cfg: rhrCfg, baselineEpoch: 0) + let shifted = ChargeBaselines.history(imported: Self.run(200, endBack: 10, value: 68), own: own, + anchorDay: Self.anchor, cfg: rhrCfg, baselineEpoch: 0) + XCTAssertEqual(base, shifted) + } + + /// The own nights of a varied 60-night history, as a full-history repair pass would score them. + private static func variedOwnNights() -> [(day: String, value: Double?)] { + (0..<60).map { i in + (day: dayKey(anchorEpochDay - 59 + i), value: i % 9 == 0 ? nil : 45 + Double((i * 7) % 13)) + } + } + + /// Without an import nothing changes on the 21-day pass: the history is exactly the nights the old rule + /// folded (the scan window's own nights), and the folded baseline is identical. + func testWithoutAnImportTheTwentyOneDayPassIsUnchanged() { + let scanWindow = Array(Self.variedOwnNights().suffix(21)) + let oldRule = Baselines.foldHistory(scanWindow.map(\.value), dayKeys: scanWindow.map(\.day), + cfg: hrvCfg, baselineEpoch: 0) + let h = ChargeBaselines.history(imported: [], own: scanWindow, anchorDay: Self.anchor, cfg: hrvCfg, + baselineEpoch: 0) + XCTAssertEqual(h.dayKeys, scanWindow.map(\.day)) + XCTAssertEqual(Baselines.foldHistory(h.values, dayKeys: h.dayKeys, cfg: hrvCfg, baselineEpoch: 0), oldRule) + } + + /// A full-history repair pass scores every day it can; the window trims it to the same 21, so the + /// baseline no longer depends on which pass ran last. + func testARepairPassFoldsTheSameTwentyOneDays() { + let all = Self.variedOwnNights() + let repair = ChargeBaselines.history(imported: [], own: all, anchorDay: Self.anchor, cfg: hrvCfg, + baselineEpoch: 0) + let tick = ChargeBaselines.history(imported: [], own: Array(all.suffix(21)), anchorDay: Self.anchor, + cfg: hrvCfg, baselineEpoch: 0) + XCTAssertEqual(repair, tick) + } + + // MARK: - Resolved (the dashboard's view) + + /// Resting HR follows the Charge-wide recalibration epoch in the dashboard as it does in the engine. + func testResolveFoldsRestingHROnTheRecoveryEpoch() { + func row(_ back: Int, rhr: Int) -> DailyMetric { + DailyMetric(day: Self.dayKey(Self.anchorEpochDay - back), totalSleepMin: nil, efficiency: nil, + deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, restingHr: rhr, + avgHrv: 50, recovery: nil, strain: nil, exerciseCount: nil) + } + let own = (0..<20).map { row($0, rhr: $0 < 5 ? 50 : 60) } + let epoch = Double(Self.anchorEpochDay - 4) * 86_400 + let r = ChargeBaselines.resolve(imported: [], own: own, anchorDay: Self.anchor, hrvEpoch: 0, + recoveryEpoch: epoch) + XCTAssertEqual(r.restingHR.nValid, 5) + XCTAssertEqual(r.restingHR.baseline, 50, accuracy: 1e-9) + XCTAssertEqual(r.hrv.nValid, 20) + } + + // MARK: - Diagnostic line + + func testLogLineNamesEachMetricsComposition() { + let seeded = ChargeBaselines.History(dayKeys: [], values: [], seededByImport: true, + ownValidNights: 9, importedNights: 40) + let own = ChargeBaselines.History(dayKeys: [], values: [], seededByImport: false, + ownValidNights: 45, importedNights: 0) + XCTAssertEqual(ChargeBaselines.logLine(anchorDay: "2026-09-30", hrv: own, restingHR: seeded, + resp: own, skin: own), + "charge baseline anchor=2026-09-30 window=21d hrv=own/45 rhr=seed/9+40 resp=own/45 skin=own/45") + } + + // MARK: - Oracle (shared with Kotlin) + + /// A deterministic spread of imported/own histories, anchors and epochs. The same generator runs in the + /// Kotlin twin and both assert the same literal, so either side drifting fails its own suite. + static func oracleLines() -> [String] { + var s: UInt64 = 2525 + func next() -> Int { + s = s &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + return Int(s >> 33) + } + var lines: [String] = [] + for c in 0..<40 { + let anchorDay = 20_089 + next() % 400 + var imported: [(day: String, value: Double?)] = [] + let nImported = next() % 120 + let importEndBack = next() % 40 + for i in 0.. Double?) -> [(day: String, value: Double?)] { + hist.map { (day: $0.day, value: value($0)) } + } + func ownNights(_ byDay: [String: Double?]) -> [(day: String, value: Double?)] { + byDay.map { (day: $0.key, value: $0.value) } + } + // Which SOURCE measured each night's respiration — the input `Baselines.deviceEraEpoch` (#459) - // needs, and respiration is now a metric that requires it: a WHOOP export reports its OWN measured - // rate (~16.1 for this history) while an Oura ring reports the rate its firmware measured (~14.6), - // and NOOP's own RSA estimate is a third method again. Pooling them in one 28-day baseline turns a - // strap SWITCH into a ~3σ illness-ward step against a ~0.52 bpm spread — a device artifact scored - // as physiology, which is exactly the failure #459 named for HRV (Oura RMSSD ~120-155 ms vs WHOOP - // ~72-112 ms). + // needs, and respiration is a metric that requires it: a WHOOP export reports its OWN measured rate + // (~16.1 for this history) while an Oura ring reports the rate its firmware measured (~14.6), and + // NOOP's own RSA estimate is a third method again. Pooling them in one baseline turns a strap SWITCH + // into a ~3σ illness-ward step against a ~0.52 bpm spread — a device artifact scored as physiology, + // which is exactly the failure #459 named for HRV (Oura RMSSD ~120-155 ms vs WHOOP ~72-112 ms). // // `resolvedScoreOwnerByDay` — THIS PASS's freshly resolved per-day owner (`resolveDayOwner`, - // straight off `DayOwnerResolver`, BEFORE any re-homing) — must win over `hist`, not just fill its - // gaps. `hist` is `store.dailyMetrics(deviceId: deviceId, ...)`: every row in it is already stored - // under THIS device's own id, by construction, because that is where `analyzeRecent` writes every - // day's result once scored — an Oura-owned day scored on a PRIOR run is filed there exactly the - // same as a WHOOP-owned one. Filling from `hist` first (the value-priority order, correct for - // `histRespByDay` because an import legitimately outranks a computed value) would tag that day - // "whoop" for every re-score after its first, which is precisely the "brand is lost once a - // wearable day is re-homed under the computed WHOOP id" trap `deviceEraEpoch`'s own contract warns - // against — it would neuter era-scoping for any day already scored once, i.e. almost all of them in - // steady state. `hist` still fills the days OUTSIDE this pass's scan window (older than `maxDays`), - // where no fresher source is available and the pre-existing storage id is the best guess. + // straight off `DayOwnerResolver`, BEFORE any re-homing) — is the only per-night source this pass + // knows, and it covers every own night the window holds. The imported rows in `hist` are stored + // under `deviceId` (the import id) and are tagged `deviceId`, which buckets to WHOOP; the days this + // engine scores are written under `computedId`, never into `hist`. var respSourceByDay: [String: String] = [:] for (day, owner) in resolvedScoreOwnerByDay { respSourceByDay[day] = owner } - for d in hist where respSourceByDay[d.day] == nil { respSourceByDay[d.day] = deviceId } + let respCandidateDays = Set(hist.map(\.day)).union(nightlyRespByDay.keys).sorted() // rhr/resp/skin honour the Charge-wide recalibration epoch (noop.recoveryBaselineEpoch); 0 = no-op, - // so this is byte-identical to the plain fold until the user taps Recalibrate, at which point the - // whole Charge build-up (HRV + resting HR + resp + skin) re-anchors together. + // so the manual Recalibrate re-anchors the whole Charge build-up (HRV + resting HR + resp + skin) + // together. The respiration baseline is additionally scoped to the CURRENT device era. + // `deviceEraEpoch` returns 0.0 for a single-brand history — every WHOOP-origin id (import, strap, + // the "-noop" computed sibling, the Apple/HC riders) buckets to one brand — so a WHOOP-only user is + // unaffected; `max` keeps whichever cut is LATER, since both mean "ignore nights before this". + // KNOWN GAP, and pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP + // vendor rate from NOOP's own RSA estimate on WHOOP nights; since #2525 the two meet only while the + // import seeds. #459's primitive is likewise still unwired for the HRV and resting-HR baselines it + // was written for; that is #459's own scope, not this change's. let recoveryEpoch = Baselines.recoveryBaselineEpoch() - let hrvDayKeys = histHrvByDay.keys.sorted() // chronological "yyyy-MM-dd" - let hrvSeq = hrvDayKeys.map { histHrvByDay[$0]! } // chronological [Double?] - let rhrDayKeys = histRhrByDay.keys.sorted() - let rhrSeq = rhrDayKeys.map { histRhrByDay[$0]! } - let respDayKeys = histRespByDay.keys.sorted() - let respSeq = respDayKeys.map { histRespByDay[$0]! } - // Skin-temp baseline is on-device-only (imported rows carry skinTempDevC, not the raw mean), - // so fold purely over the pass-1 nightly means in chronological order. - let skinDayKeys = nightlySkinByDay.keys.sorted() - let skinSeq = skinDayKeys.map { nightlySkinByDay[$0]! } + 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() + 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) }, + own: ownNights(nightlyRhrByDay), + anchorDay: newestDay, cfg: rhrCfg, baselineEpoch: recoveryEpoch) + let respHistory = ChargeBaselines.history(imported: importedNights { $0.respRateBpm }, own: ownNights(nightlyRespByDay), + anchorDay: newestDay, cfg: respCfg, baselineEpoch: respEpoch) + // Skin temperature has no imported counterpart (imported rows carry skinTempDevC, not the raw mean), + // so its history is the own nights alone. + let skinHistory = ChargeBaselines.history(imported: [], own: ownNights(nightlySkinByDay), + anchorDay: newestDay, cfg: skinCfg, baselineEpoch: recoveryEpoch) // Resp baseline gated on `usable`: RecoveryScorer includes the resp term whenever a // baseline object is present , a CALIBRATING (<4-night) baseline would let one noisy // RSA night move recovery (mirrors the skin-temp use-site gate; honest cold-start). - // The respiration baseline is scoped to the CURRENT device era. `deviceEraEpoch` returns 0.0 for a - // single-brand history — every WHOOP-origin id (import, strap, the "-noop" computed sibling, the - // Apple/HC riders) buckets to one brand — so a WHOOP-only user folds byte-identically to before; - // only a history that actually crosses brands is truncated. `max` with the manual Recalibrate - // epoch keeps whichever cut is LATER, since both mean "ignore nights before this". - // KNOWN GAP, and pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP - // vendor rate from NOOP's own RSA estimate on WHOOP nights — two methods that were already pooled - // before this change and still are. #459's primitive is likewise still unwired for the HRV and - // resting-HR baselines it was written for; that is #459's own scope, not this change's. - let respEraEpoch = Baselines.deviceEraEpoch(respDayKeys.map { (day: $0, sourceId: respSourceByDay[$0] ?? deviceId) }) - let respFold = Baselines.foldHistory(respSeq, dayKeys: respDayKeys, cfg: respCfg, - baselineEpoch: max(recoveryEpoch, respEraEpoch)) + let respFold = Baselines.foldHistory(respHistory.values, dayKeys: respHistory.dayKeys, cfg: respCfg, + baselineEpoch: respEpoch) // Skin-temp gated the same way for consistency: its only use-site re-checks `.usable` // (AnalyticsEngine's skinTempDevC guard) so this is belt-and-suspenders, but it stops a // future use-site from trusting a CALIBRATING baseline. (PR #97 review.) - let skinFold = Baselines.foldHistory(skinSeq, dayKeys: skinDayKeys, cfg: skinCfg, baselineEpoch: recoveryEpoch) + let skinFold = Baselines.foldHistory(skinHistory.values, dayKeys: skinHistory.dayKeys, cfg: skinCfg, + baselineEpoch: recoveryEpoch) // #1614: the per-night HRV fold, traced. HRV ONLY, deliberately: it is Charge's dominant driver // and the one whose spread the score divides by, so tracing all four baselines would quadruple // the log for the three that are not the question being asked. Capped at the last 14 nights, // which is enough to see whether the spread is lifting without an established user's history // 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() + // 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. if TestCentre.active(.recovery) { - let traced = Baselines.foldHistoryTrace(hrvSeq, dayKeys: hrvDayKeys, cfg: hrvCfg, + diagnosticSink?(ChargeBaselines.logLine(anchorDay: newestDay, hrv: hrvHistory, restingHR: rhrHistory, + resp: respHistory, skin: skinHistory), .recovery) + let traced = Baselines.foldHistoryTrace(hrvHistory.values, dayKeys: hrvHistory.dayKeys, cfg: hrvCfg, metric: "hrv", baselineEpoch: hrvEpoch, tail: 14) @@ -2068,8 +2068,10 @@ final class IntelligenceEngine: ObservableObject { let baselines2 = AnalyticsEngine.ProfileBaselines( // HRV honours noop.hrvBaselineEpoch; rhr/resp/skin honour noop.recoveryBaselineEpoch via their // parallel day keys, so the manual Recalibrate restarts the whole Charge build-up together. - hrv: Baselines.foldHistory(hrvSeq, dayKeys: hrvDayKeys, cfg: hrvCfg, baselineEpoch: hrvEpoch), - restingHR: Baselines.foldHistory(rhrSeq, dayKeys: rhrDayKeys, cfg: rhrCfg, baselineEpoch: recoveryEpoch), + hrv: Baselines.foldHistory(hrvHistory.values, dayKeys: hrvHistory.dayKeys, cfg: hrvCfg, + baselineEpoch: hrvEpoch), + restingHR: Baselines.foldHistory(rhrHistory.values, dayKeys: rhrHistory.dayKeys, cfg: rhrCfg, + baselineEpoch: recoveryEpoch), resp: respFold.usable ? respFold : nil, skinTemp: skinFold.usable ? skinFold : nil) @@ -2192,9 +2194,6 @@ final class IntelligenceEngine: ObservableObject { // each analytics-only/backfill decision can emit one `.workouts` line per derived bout. Diagnostic // only; this path no longer publishes or reconciles generic workout rows. let workoutsTraceActive = TestCentre.active(.workouts) - let oldestDay = AnalyticsEngine.dayString(nowLocalMidnight - (maxDays - 1) * 86_400, - offsetSec: tzOffset) - let newestDay = AnalyticsEngine.dayString(nowLocalMidnight, offsetSec: tzOffset) let strictCanonicalAlias = (try? await store.isWhoop5RRSource(deviceId: regActiveId)) ?? true let legacySnapshots = await Self.legacyScoreSnapshots( store: store, computedId: computedId, from: oldestDay, to: newestDay, @@ -3596,22 +3595,3 @@ extension DailyMetric { skinTempC: skinTempC, sleepHrOnly: sleepHrOnly) } } - -extension IntelligenceEngine { - /// Merge one metric's on-device pass-1 nightly values into the imported-history map. - /// Imported (cloud) values WIN per day; the computed estimate only fills days the import - /// does not cover at all (key absent). Twin of the Kotlin `mergeNightlyIntoHistory`. - nonisolated static func mergeNightlyIntoHistory( - _ hist: inout [String: Double?], _ nightly: [String: Double?] - ) { - // `hist` values are themselves Optional, so `hist[day] == nil` is only - // true when the KEY is absent — an imported row with a nil value is - // `.some(.none)` and would shadow the real computed night forever, - // starving the baseline (the "Needs the strap" bug). Imported non-nil - // wins; a nil (or absent) slot is backfilled by the computed value. - for (day, v) in nightly { - if let existing = hist[day], existing != nil { continue } // imported non-nil wins - hist[day] = v - } - } -} diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index adaaaf5abb..4fcbd75631 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -184,6 +184,12 @@ final class Repository: ObservableObject { /// Daily metrics (recovery/strain/sleep/HRV/RHR…) over the recent window, oldest→newest. @Published var days: [DailyMetric] = [] + /// The Charge baselines (HRV, resting HR, respiration) resolved from the imported and own daily rows + /// with the engine's own rule (#2525), recomputed on every `refresh()`. The single funnel every Charge + /// readout below the headline reads (the "What shaped it" rows, the calibration count, the confidence + /// tier), so none of them can fold a different history than the score was computed against. nil until + /// the first refresh. + @Published private(set) var chargeBaselines: ChargeBaselines.Resolved? /// Cached sleep sessions over the recent window, oldest→newest. @Published var sleeps: [CachedSleepSession] = [] /// Imported (export-verbatim) sleep figures by day. Empty until a WHOOP import lands. @@ -848,6 +854,7 @@ final class Repository: ObservableObject { private struct MergedCaches { let importedSleep: [String: ImportedSleepFigures] let days: [DailyMetric] + let chargeBaselines: ChargeBaselines.Resolved let sleeps: [CachedSleepSession] let vitalRows: [SourcedDailyMetric] let freshness: RepositoryFreshness @@ -956,6 +963,13 @@ final class Repository: ObservableObject { let impSleep = await unionSleepSessions(store: store, from: lo, to: hi) let compSleep = await unionComputedSleepSessions(store: store, from: lo, to: hi) + // #2525: the Charge baselines' inputs, read on the main actor before detaching: today's local day + // (the same local-calendar key the engine anchors its fold on) and the two recalibration epochs the + // engine reads from the same UserDefaults keys. + let chargeAnchorDay = AnalyticsEngine.dayString(nowTs, offsetSec: TimeZone.current.secondsFromGMT(for: now)) + let hrvEpoch = Baselines.hrvBaselineEpoch() + let recoveryEpoch = Baselines.recoveryBaselineEpoch() + // Export-verbatim sleep figures (long-format metricSeries rows from WhoopImporter). // SleepView prefers these per day over its APPROXIMATE recomputations. let perf = await unionMetricSeries(store: store, key: "sleep_performance", from: fromDay, to: toDay) @@ -983,6 +997,11 @@ final class Repository: ObservableObject { into: Self.mergeDaily(imported: imported, computed: computed, userEditedDays: editedDays), activityFile ), + // From the two buckets BEFORE `mergeDaily` blends them: the rule needs to know which nights + // are imported and which are the wearer's own. + chargeBaselines: ChargeBaselines.resolve(imported: imported, own: computed, + anchorDay: chargeAnchorDay, + hrvEpoch: hrvEpoch, recoveryEpoch: recoveryEpoch), sleeps: Self.mergeSleep(imported: impSleep, computed: compSleep), vitalRows: Self.sourceRows(imported: imported, computed: computed, apple: apple), freshness: Self.computeFreshness(imported: imported, computed: computed, apple: apple, @@ -999,6 +1018,7 @@ final class Repository: ObservableObject { // is what stops the analyze-tail's burst of refresh() calls each re-firing TodayView.loadAll(). let unchanged = loaded && merged.days == days + && merged.chargeBaselines == chargeBaselines && merged.sleeps == sleeps && merged.importedSleep == importedSleep && merged.vitalRows == vitalRows @@ -1009,6 +1029,7 @@ final class Repository: ObservableObject { // the intraday-updating views reload exactly once for this real change. self.importedSleep = merged.importedSleep self.days = merged.days + self.chargeBaselines = merged.chargeBaselines self.sleeps = merged.sleeps self.vitalRows = merged.vitalRows self.freshness = merged.freshness diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index abcfd48297..6544539e58 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -1635,8 +1635,8 @@ struct LiquidTodayView: View { // classic Today reads, so the two screens agree on when a wearer is genuinely mid-calibration // rather than simply lacking a scored night. let calNights = (selectedDayOffset == 0) - ? RecoveryScorer.calibrationNights(nightlyHrv: repo.days.map(\.avgHrv), - dayKeys: repo.days.map(\.day), + ? RecoveryScorer.calibrationNights(nightlyHrv: repo.chargeBaselines?.hrvHistory.values ?? [], + dayKeys: repo.chargeBaselines?.hrvHistory.dayKeys ?? [], hasRecovery: day?.recovery != nil) : nil let priorScored = TodayView.lastScoredRecoveryDay( diff --git a/Strand/Screens/ChargeBreakdownWiring.swift b/Strand/Screens/ChargeBreakdownWiring.swift index cc5eab7866..cbf3ba77f3 100644 --- a/Strand/Screens/ChargeBreakdownWiring.swift +++ b/Strand/Screens/ChargeBreakdownWiring.swift @@ -15,12 +15,20 @@ import WhoopStore // // Pure: no SwiftUI state, no I/O, no store access. Nothing here invents a number. The drivers come from // `RecoveryScorer.chargeDrivers` and the tier is SURFACED from `ScoreConfidence.charge` against the same -// folded HRV baseline the drivers scored with, so the header and the rows agree by construction. +// HRV baseline the drivers scored with, so the header and the rows agree by construction. The baselines +// themselves are resolved once per refresh by `Repository.chargeBaselines` (#2525). enum ChargeBreakdownWiring { - /// The ordered Charge driver rows for `row` plus its confidence tier, folded from the visible `days` - /// history, or nil when the night cannot honestly score (missing HRV or resting HR, or an HRV - /// baseline that is not yet usable) so the sheet hides rather than showing fabricated rows. + /// The ordered Charge driver rows for `row` plus its confidence tier, scored against `baselines`, or nil + /// when the night cannot honestly score (missing HRV or resting HR, or an HRV baseline that is not yet + /// usable) so the sheet hides rather than showing fabricated rows. + /// + /// `baselines` is `Repository.chargeBaselines`: the baselines resolved with the engine's own rule + /// (#2525), so these rows and the tier describe the baseline the Charge headline was scored against. + /// Before #2525 this folded the whole visible history itself, which kept every imported night and every + /// stored own night while the headline kept only the recent own nights plus the import; the page could + /// show one baseline in the rows and score against another. (#2315 had already closed the same gap for + /// the recalibration epoch; the resolver now applies both epochs.) /// /// `sleepPerfPercent` is the Rest composite on a 0-100 scale, divided by 100 here to match /// `AnalyticsEngine`'s `sleepPerf` form, so the Sleep row scores against the headline's own input. @@ -29,23 +37,14 @@ enum ChargeBreakdownWiring { /// `chargeDrivers` both apply that gate themselves since #1990, so these are belt-and-braces rather /// than load-bearing; they are kept because they say the intent at the call site, which is where a /// reader looks first. - static func breakdown(days: [DailyMetric], + static func breakdown(baselines: ChargeBaselines.Resolved, row: DailyMetric, - sleepPerfPercent: Double?, - hrvBaselineEpoch: Double = 0) -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { + sleepPerfPercent: Double?) -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { guard let hrv = row.avgHrv, let rhr = row.restingHr else { return nil } - // PERF: one pass per series. The two private copies this replaces each re-folded the full history - // per body evaluation of the open sheet; the guard above still runs before any fold. - // #2315: fold with the recalibration epoch, exactly as the engine does. Without it these rows and - // the confidence tier scored against the WHOLE history while the headline scored against the - // post-Recalibrate nights, so the Charge page showed two baselines for one metric. `0` (no - // recalibration) delegates to the plain fold, so a user who never recalibrated sees no change. - let hrvBase = Baselines.foldHistory(days.map(\.avgHrv), dayKeys: days.map(\.day), - cfg: Baselines.hrvCfg, baselineEpoch: hrvBaselineEpoch) + let hrvBase = baselines.hrv guard hrvBase.usable else { return nil } - let rhrBase = Baselines.foldHistory(days.map { $0.restingHr.map(Double.init) }, - cfg: Baselines.restingHRCfg) - let respBase = Baselines.foldHistory(days.map(\.respRateBpm), cfg: Baselines.respCfg) + let rhrBase = baselines.restingHR + let respBase = baselines.resp let drivers = RecoveryScorer.chargeDrivers( hrv: hrv, rhr: Double(rhr), resp: row.respRateBpm, hrvBaseline: hrvBase, diff --git a/Strand/Screens/CoupledView.swift b/Strand/Screens/CoupledView.swift index 2560118c7e..aff786baae 100644 --- a/Strand/Screens/CoupledView.swift +++ b/Strand/Screens/CoupledView.swift @@ -59,8 +59,8 @@ struct CoupledView: View { /// Recovery cold-start: nights banked so far while the HRV baseline still seeds, nil once recovery /// exists. The SAME pure helper Today's ring reads, so the two screens can't disagree. private var calibrationNights: Int? { - RecoveryScorer.calibrationNights(nightlyHrv: repo.days.map(\.avgHrv), - dayKeys: repo.days.map(\.day), + RecoveryScorer.calibrationNights(nightlyHrv: repo.chargeBaselines?.hrvHistory.values ?? [], + dayKeys: repo.chargeBaselines?.hrvHistory.dayKeys ?? [], hasRecovery: day?.recovery != nil) } @@ -527,16 +527,16 @@ struct CoupledView: View { return carriedRecoveryDay } - /// The ordered Charge drivers for the displayed ring PLUS the confidence tier from the SAME folded HRV - /// baseline — the exact TodayView derivation (pure engine scoring against the folded personal - /// baselines). nil for a calibrating / cold-start night, which gates the sheet through to the countdown - /// instead. PERF: mirrors TodayView.chargeBreakdown() — the old `chargeDrivers` property plus the - /// sheet's inline confidence fold re-folded the full `repo.days` history four times per body eval of - /// the open sheet; one call now folds each series exactly once, guards before any fold. + /// The ordered Charge drivers for the displayed ring PLUS the confidence tier from the SAME HRV + /// baseline — the exact TodayView derivation (pure engine scoring against `repo.chargeBaselines`, the + /// personal baselines resolved with the engine's own rule, #2525). nil for a calibrating / cold-start + /// night, which gates the sheet through to the countdown instead. PERF: mirrors + /// TodayView.chargeBreakdown() — the baselines are resolved once per refresh, so a body evaluation of + /// the open sheet folds nothing. private func chargeBreakdown() -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { guard let row = breakdownRow else { return nil } - return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: sleepPerformance, - hrvBaselineEpoch: Baselines.hrvBaselineEpoch()) + guard let baselines = repo.chargeBaselines else { return nil } + return ChargeBreakdownWiring.breakdown(baselines: baselines, row: row, sleepPerfPercent: sleepPerformance) } @ViewBuilder diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 1d7aaf9050..904e2ddc83 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -834,21 +834,20 @@ struct TodayView: View { /// The ordered "What shaped it" Charge drivers for the displayed Charge ring, PLUS the confidence tier /// computed from the SAME folded HRV baseline. PURE derivation from the SAME `displayDay` (post-#814 - /// union-read row) the ring already shows, plus the HRV/RHR/resp baselines folded from `repo.days` - /// (exactly the inputs `AnalyticsEngine` scored with), so a row can NEVER describe a term the ring's - /// number didn't use. This is NOT a second store read: it reads only data already resolved into - /// `repo.days`/`displayDay`. nil for a calibrating / cold-start night (no usable HRV baseline or no - /// value), so the sheet gates through to the calibration countdown instead. + /// union-read row) the ring already shows, plus the HRV/RHR/resp baselines `repo.chargeBaselines` + /// resolved with the engine's own rule (#2525), so a row can NEVER describe a term the ring's number + /// didn't use. This is NOT a second store read: it reads only data already resolved into + /// `repo.chargeBaselines`/`displayDay`. nil for a calibrating / cold-start night (no usable HRV baseline + /// or no value), so the sheet gates through to the calibration countdown instead. /// /// PERF: this replaces the two separate computed properties (`chargeDrivers` + /// `chargeBreakdownConfidence`) that EACH re-folded the full `repo.days` history per body evaluation of - /// the open sheet — four O(n) passes per eval, with the confidence's doc claiming it reused the drivers' - /// fold while actually recomputing it. One call folds each series exactly once (three passes), and the - /// sheet reads drivers + confidence out of a single sheet-local `let`. + /// the open sheet. The baselines are now resolved once per refresh, so a body evaluation folds nothing, + /// and the sheet reads drivers + confidence out of a single sheet-local `let`. private func chargeBreakdown() -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { guard let row = chargeBreakdownRow else { return nil } - return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: restScore, - hrvBaselineEpoch: Baselines.hrvBaselineEpoch()) + guard let baselines = repo.chargeBaselines else { return nil } + return ChargeBreakdownWiring.breakdown(baselines: baselines, row: row, sleepPerfPercent: restScore) } /// The night's relative skin-temp marker for the displayed row (A5), or nil. Surfaced verbatim from @@ -1143,8 +1142,8 @@ struct TodayView: View { private func computeCalibration() -> Int? { guard selectedDayOffset == 0 else { return nil } - return RecoveryScorer.calibrationNights(nightlyHrv: repo.days.map(\.avgHrv), - dayKeys: repo.days.map(\.day), + return RecoveryScorer.calibrationNights(nightlyHrv: repo.chargeBaselines?.hrvHistory.values ?? [], + dayKeys: repo.chargeBaselines?.hrvHistory.dayKeys ?? [], hasRecovery: repo.today?.recovery != nil) } diff --git a/StrandTests/ChargeBreakdownWiringTests.swift b/StrandTests/ChargeBreakdownWiringTests.swift index fb038774ea..6667612869 100644 --- a/StrandTests/ChargeBreakdownWiringTests.swift +++ b/StrandTests/ChargeBreakdownWiringTests.swift @@ -8,7 +8,8 @@ import WhoopStore /// This suite could not exist before: the derivation lived as a private method inside `TodayView` (and a /// byte-identical copy inside `CoupledView`), reachable only by rendering the view, so the iOS end of the /// "What shaped it" path had no test at all while Android's did. `ChargeBreakdownWiring.breakdown` is that -/// derivation lifted out unchanged. +/// derivation lifted out unchanged. Since #2525 it scores against `Repository.chargeBaselines`, the +/// baselines `ChargeBaselines.resolve` builds with the engine's own rule, so these cases resolve first. /// /// Case mapping against the Kotlin suite, stated so the gap is legible rather than implied: /// * `rhrRowIsAbsentWhenTheRestingHrBaselineIsNotUsable` - twinned below. @@ -25,6 +26,14 @@ final class ChargeBreakdownWiringTests: XCTestCase { exerciseCount: nil) } + /// The breakdown for `row` against own nights `days` (plus `imported`), anchored on `row`'s day. + private func breakdown(_ days: [DailyMetric], imported: [DailyMetric] = [], row: DailyMetric, + hrvEpoch: Double = 0) -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { + let baselines = ChargeBaselines.resolve(imported: imported, own: days, anchorDay: row.day, + hrvEpoch: hrvEpoch, recoveryEpoch: 0) + return ChargeBreakdownWiring.breakdown(baselines: baselines, row: row, sleepPerfPercent: 85) + } + /// A history with no banked resting HR folds to `foldHistory`'s synthetic midpoint (about 75 bpm), /// which is nobody's resting HR, so the row must be absent. The HRV row still stands, since that /// baseline is real. The display day itself carries a reading, so this pins the BASELINE being @@ -35,7 +44,7 @@ final class ChargeBreakdownWiringTests: XCTestCase { func testRhrRowIsAbsentWhenTheRestingHrBaselineIsNotUsable() { let history = (1...6).map { day(String(format: "2026-01-%02d", $0), rhr: nil) } let today = day("2026-01-07", rhr: 55) - let out = ChargeBreakdownWiring.breakdown(days: history + [today], row: today, sleepPerfPercent: 85) + let out = breakdown(history + [today], row: today) let labels = (out?.drivers ?? []).map(\.label) XCTAssertTrue(labels.contains("Heart rate variability"), "the HRV baseline is real, so its row must still be there: \(labels)") @@ -47,7 +56,7 @@ final class ChargeBreakdownWiringTests: XCTestCase { /// hides rather than showing fabricated rows. func testColdStartHistoryProducesNoBreakdown() { let days = [day("2026-01-01"), day("2026-01-02")] - XCTAssertNil(ChargeBreakdownWiring.breakdown(days: days, row: days[1], sleepPerfPercent: 85)) + XCTAssertNil(breakdown(days, row: days[1])) } /// The usable-baseline half of the pair: a real history banks both baselines, so both rows appear and @@ -55,7 +64,7 @@ final class ChargeBreakdownWiringTests: XCTestCase { func testAScoredDayProducesDriverRowsAndATier() { let past = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 50 + Double($0 % 3)) } let today = day("2026-01-20", hrv: 62, rhr: 51, recovery: 64) - let out = ChargeBreakdownWiring.breakdown(days: past + [today], row: today, sleepPerfPercent: 85) + let out = breakdown(past + [today], row: today) XCTAssertNotNil(out) let labels = (out?.drivers ?? []).map(\.label) XCTAssertTrue(labels.contains("Heart rate variability"), "\(labels)") @@ -85,10 +94,8 @@ final class ChargeBreakdownWiringTests: XCTestCase { let today = day("2026-01-21", hrv: 72, recovery: 70) let days = old + since + [today] - let whole = ChargeBreakdownWiring.breakdown(days: days, row: today, sleepPerfPercent: 85, - hrvBaselineEpoch: 0) - let fromEpoch = ChargeBreakdownWiring.breakdown(days: days, row: today, sleepPerfPercent: 85, - hrvBaselineEpoch: epochOf("2026-01-11")) + let whole = breakdown(days, row: today, hrvEpoch: 0) + let fromEpoch = breakdown(days, row: today, hrvEpoch: epochOf("2026-01-11")) let hrvRow: ((drivers: [ChargeDriver], confidence: ScoreConfidence)?) -> ChargeDriver? = { out in out?.drivers.first(where: { $0.label == "Heart rate variability" }) } @@ -101,15 +108,17 @@ final class ChargeBreakdownWiringTests: XCTestCase { + "fromEpoch=\(r.baselineText))") } - func testAnAbsentEpochLeavesTheBaselineExactlyAsItWas() { - // The common case: nobody who never recalibrated may see any change from this. - let past = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 50 + Double($0 % 3)) } - let today = day("2026-01-20", hrv: 62, rhr: 51, recovery: 64) - let implicit = ChargeBreakdownWiring.breakdown(days: past + [today], row: today, sleepPerfPercent: 85) - let explicitZero = ChargeBreakdownWiring.breakdown(days: past + [today], row: today, - sleepPerfPercent: 85, hrvBaselineEpoch: 0) - XCTAssertEqual(implicit?.drivers.map(\.label), explicitZero?.drivers.map(\.label)) - XCTAssertEqual(implicit?.drivers.map(\.baselineText), explicitZero?.drivers.map(\.baselineText)) - XCTAssertEqual(implicit?.drivers.map(\.deltaPoints), explicitZero?.drivers.map(\.deltaPoints)) + /// #2525: once the own nights are trusted on their own, the import no longer takes part in the + /// baseline the headline is scored against, so the rows must not move with it either. Before #2525 the + /// rows folded every imported night and could read a baseline the score never used. + func testAnImportTheHeadlineNoLongerUsesDoesNotMoveTheRows() { + let own = (1...20).map { day(String(format: "2026-01-%02d", $0), hrv: 70 + Double($0 % 3)) } + let today = day("2026-01-21", hrv: 72, recovery: 70) + // Inside the window and on the same days: only the handoff can keep it out of the baseline. + let lowImport = (1...20).map { day(String(format: "2026-01-%02d", $0), hrv: 40) } + let alone = breakdown(own + [today], row: today) + let withImport = breakdown(own + [today], imported: lowImport, row: today) + XCTAssertEqual(alone?.drivers.map(\.baselineText), withImport?.drivers.map(\.baselineText)) + XCTAssertEqual(alone?.drivers.map(\.deltaPoints), withImport?.drivers.map(\.deltaPoints)) } } diff --git a/StrandTests/IntelligenceBaselineShadowTests.swift b/StrandTests/IntelligenceBaselineShadowTests.swift deleted file mode 100644 index 7a01a5ad31..0000000000 --- a/StrandTests/IntelligenceBaselineShadowTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import XCTest -import StrandAnalytics -@testable import Strand - -/// Pins the baseline-merge precedence in `IntelligenceEngine.mergeNightlyIntoHistory` (the -/// "Needs the strap" / recovery-No-Data starvation). An imported history row that carries a -/// nil value for a metric holds no information for that day: it must NOT shadow the real -/// on-device nightly value into a permanently missing night, or an import whose rows are -/// blank for avgHrv/restingHr blankets every strap-covered day and the baseline never -/// crosses `Baselines.minNightsSeed`. Precedence pinned here: -/// -/// 1. imported non-nil → wins over the computed value (import users unchanged) -/// 2. key absent → computed value fills (the BLE-only recovery fix) -/// 3. imported nil → computed value backfills (the shadow fix) -/// 4. imported nil, no computed value → stays a missing night (honest gap) -/// -/// Twin of the Kotlin `IntelligenceBaselineShadowTest` so both platforms merge identically. -final class IntelligenceBaselineShadowTests: XCTestCase { - - func testImportedNonNilWinsOverComputed() { - var hist: [String: Double?] = ["2026-07-10": 62.0] - IntelligenceEngine.mergeNightlyIntoHistory(&hist, ["2026-07-10": 48.0]) - XCTAssertEqual(hist["2026-07-10"]!, 62.0) - } - - func testDayNotImportedComputedFills() { - var hist: [String: Double?] = ["2026-07-10": 62.0] - IntelligenceEngine.mergeNightlyIntoHistory(&hist, ["2026-07-11": 48.0]) - XCTAssertEqual(hist["2026-07-11"]!, 48.0) - XCTAssertEqual(hist.count, 2) - } - - func testImportedNilValueBackfilledByComputed() { - // THE bug: the user's import wrote a row for the day with a blank avgHrv while the - // strap actually scored the night. The blank row must not shadow the real value. - var hist: [String: Double?] = ["2026-07-10": nil] - IntelligenceEngine.mergeNightlyIntoHistory(&hist, ["2026-07-10": 48.0]) - XCTAssertEqual( - hist["2026-07-10"]!, 48.0, - "an imported row with a nil value must be backfilled by the real computed night") - } - - func testImportedNilValueNoComputedStaysMissing() { - var hist: [String: Double?] = ["2026-07-10": nil] - IntelligenceEngine.mergeNightlyIntoHistory(&hist, [:]) - XCTAssertNotNil(hist.index(forKey: "2026-07-10")) - XCTAssertNil(hist["2026-07-10"]!) - } - - func testNilComputedDoesNotDisturbAnything() { - // A computed pass that produced no value for a day it emitted (nil estimate) neither - // overwrites an imported value nor un-registers an imported-nil day. - var hist: [String: Double?] = ["2026-07-10": 62.0, "2026-07-11": nil] - IntelligenceEngine.mergeNightlyIntoHistory( - &hist, ["2026-07-10": nil, "2026-07-11": nil, "2026-07-12": nil]) - XCTAssertEqual(hist["2026-07-10"]!, 62.0) - XCTAssertNil(hist["2026-07-11"]!) - XCTAssertNotNil(hist.index(forKey: "2026-07-12")) - XCTAssertNil(hist["2026-07-12"]!) - } - - func testStarvationShapeEndToEnd() { - // The report's shape: a week of imported rows, ALL blank for HRV, over nights the - // strap scored. After the merge the fold input must contain the 7 real values, so - // the baseline can cross Baselines.minNightsSeed instead of reading "No Data". - var hist: [String: Double?] = [:] - var nightly: [String: Double?] = [:] - for i in stride(from: 6, through: 0, by: -1) { - let day = String(format: "2026-07-%02d", 12 - i) - hist[day] = Double?.none - nightly[day] = 50.0 + Double(i) - } - IntelligenceEngine.mergeNightlyIntoHistory(&hist, nightly) - let valid = hist.values.compactMap { $0 } - XCTAssertEqual(valid.count, 7, "all 7 strap nights must survive the merge") - XCTAssertGreaterThanOrEqual(valid.count, Baselines.minNightsSeed) - } -} diff --git a/Tools/doc_comment_lint_baseline.txt b/Tools/doc_comment_lint_baseline.txt index 87dffc43b9..f9926da3a4 100644 --- a/Tools/doc_comment_lint_baseline.txt +++ b/Tools/doc_comment_lint_baseline.txt @@ -3,7 +3,7 @@ # file not listed here has any. Ratchet DOWN as they are fixed — never up. # Generated at the time Tools/doc_comment_lint.py landed; see #846 for the pattern. -android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt 5 +android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt 4 android/app/src/main/java/com/noop/ble/WhoopBleClient.kt 8 android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt 1 android/app/src/main/java/com/noop/ingest/CaptureImporter.kt 1 diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 76c82ab661..78a22acc7b 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -267,6 +267,15 @@ "platform": "kotlin", "rationale": "This Kotlin extension dispatches a computed window to repository persistence. The corresponding persistComputedScores dispatch is inline in the excluded Swift app-layer analyzeRecent; this records its architectural role.", "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopRepository.kt::importedDailyUnionFlow/3#1", + "identity_sha256": "a1b8ac6c0842e6925e91e66bfb292e599945dff1dcb8e5eb3080e17951c16e21", + "platform": "kotlin", + "rationale": "Swift Repository.refresh reads the same imported daily union (unionDailyMetrics) in the excluded app layer and hands it, still apart from the computed union, to ChargeBaselines.resolve (#2525); this disposition does not assert behavioral parity.", + "twin_path": "Strand/Data/Repository.swift" } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 4fe84922f8..1806700a67 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,16 +18,16 @@ ] }, "authority": { - "files": {"count": 507, "sha256": "efc6950759de76273adccde8b6457f5e8577bdaf6ed9c6538cde79b0eaf0c612"}, - "functions": {"count": 4517, "sha256": "05dc56778990d1a6313fc8429b783110ef534ebe49693d91ab7da9c736df040c"}, + "files": {"count": 509, "sha256": "81f028aa094f55912ec1d10122c53f05f5df8283a3dfe6852a49609a4880eb54"}, + "functions": {"count": 4523, "sha256": "e0627d8e57ee1892d037415be73fbf28e320e8a16f17719fd8e6b6274ba3ceea"}, "properties": {"count": 491, "sha256": "d91dc8fbf47fdfeb7be4ee410281adfd522565f9abf109c6cd101022169f9037"}, - "constants": {"count": 1986, "sha256": "cb08c3e7a3503cf5a0ec9cd95ff78a68d7215328aa532c98f9046be63d6a8ece"}, - "file_pairs": {"count": 78, "sha256": "ec0d88b4e0535c3b1051b5c10205f21174944018669fbe3479a3c21801a6dc38"}, - "function_pairs": {"count": 215, "sha256": "f4ffaafbdf720d27594002cc2337a7b1be5635ec8d02720b7722f01656e8720b"}, + "constants": {"count": 1988, "sha256": "ccbe6613de3dce5b9c139b4c3bc5fc5effd363db49b9cff012c5fdf2c912aa96"}, + "file_pairs": {"count": 79, "sha256": "9d9a951ad8d62d6a1735a20769dedecf735ea0b32427ba5276437a116b355b61"}, + "function_pairs": {"count": 218, "sha256": "22e351c0e75c89b133acdc7c9970186658fc1f8584650f0d6d32e68d4959ffbd"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, - "constant_pairs": {"count": 694, "sha256": "ae11bdde04d49f9dfe83170f0aa362e6bf18d3fdc381c1bdf5bbc256caa400ab"}, + "constant_pairs": {"count": 695, "sha256": "540752b3b04f2b750b606be8940347e364a5a52762760032c2e7ccc63f8b2a49"}, "unpaired_files": {"count": 373, "sha256": "2b0c9bc717b301037f735ea7cc32f7a7cd9b4a7887d46550b9e727ffd66d3639"}, - "unpaired_functions": {"count": 4100, "sha256": "cc10b9887488dddae0c270071d3fde36a0c6c764df318e18a27e569e181051a9"}, + "unpaired_functions": {"count": 4100, "sha256": "7e9214e73d64098cb6e68ccccd66916bcaa8fc4c3224b3d613def690ac2769a9"}, "unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"}, "unpaired_constants": {"count": 598, "sha256": "59f90c169443ea5b3243b0138e125a6e995d115f64f9c4c4860eabbaababdc6d"} } diff --git a/android/app/src/main/java/com/noop/analytics/ChargeBaselines.kt b/android/app/src/main/java/com/noop/analytics/ChargeBaselines.kt new file mode 100644 index 0000000000..7328053024 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/ChargeBaselines.kt @@ -0,0 +1,194 @@ +package com.noop.analytics + +import com.noop.data.DailyMetric + +/* + * ChargeBaselines.kt — which nights the Charge baselines are folded from (#2525). + * + * Charge scores a night against personal baselines for HRV, resting HR, respiration and skin + * temperature. Before #2525 those baselines were folded from the WHOLE imported vendor history plus only + * the nights of the current scan window (21 by default). The wearer's own older nights dropped out on + * every pass while the import never did, so an import kept roughly a third of the weight for good, pinned + * to the wearer's state at the end of their subscription however old that was. + * + * The rule here is a correction of which nights count, not a new scoring method: + * - Only nights inside a fixed calendar window ([ChargeBaselines.windowDays], counted back from the anchor + * day) count. The window is the scan window's own 21 days, so a wearer with no import folds exactly the + * nights the 21-day pass always folded; what changes is that an imported night ages out like an own + * one, and that a full-history repair pass folds the same 21 days instead of the whole history. + * - The wearer's own (NOOP-computed) nights are the baseline. Imported vendor nights only SEED it: they + * take part while the own nights alone would not yet make a trusted baseline (`handoffNights`, the + * model's own `minNightsTrust`), and drop out entirely once they would. The two sources are different + * algorithms, so they are never mixed past the cold start. + * - The rule keeps no state. Every call decides from the nights it is given. + * + * Pure and Context-free; the engine feeds it this pass's fresh values, the dashboard the stored rows. + * Byte-identical twin of the Swift `ChargeBaselines`. + */ +object ChargeBaselines { + + /** + * Calendar days of nightly history a Charge baseline reads, counted back from the anchor day and + * including it. Deliberately the default scan window (`analyzeRecent(maxDays = 21)`), so the own nights a + * baseline reads are the ones the pass has just scored, and nothing changes for a wearer with no import. + */ + const val windowDays: Int = 21 + + /** + * One metric's nightly history for a Charge baseline, oldest first, ready for + * [Baselines.foldHistory] with day keys, plus the counts the diagnostic line reports. + */ + data class History( + /** `yyyy-MM-dd` keys, ascending, parallel to [values]. */ + val dayKeys: List, + /** The nightly values; null is a night that exists but carried no value (skip-and-hold). */ + val values: List, + /** True while imported vendor nights still take part (the own nights are not yet trusted on their + * own and the window holds at least one imported night). */ + val seededByImport: Boolean, + /** Valid own nights on or after the epoch inside the window: the count the handoff is decided on. */ + val ownValidNights: Int, + /** Imported nights this history carries (0 once the own nights have taken over). */ + val importedNights: Int, + ) + + /** + * Build one metric's Charge history from the imported and own nights (#2525). + * + * [imported] are imported vendor nights as `(day, value)`; a null value is a night the import covers + * without that metric. [own] are NOOP-computed nights, same shape. A repeated day keeps its last value in + * either list. [anchorDay] is the `yyyy-MM-dd` day the window ends on (the scoring pass's local today). + * [cfg] counts the valid own nights exactly as the fold will. [baselineEpoch] is the epoch (seconds) the + * caller's fold will drop earlier nights by: the manual recalibration epoch, or a later device-era cut. + * Own nights before it do not count toward the handoff, because the fold will not use them. + * [handoffNights] is the valid own-night count at which the imported nights stop taking part. + * + * Days are filtered with pure civil-day arithmetic (no time zone), keeping only days on or before the + * anchor and fewer than [windowDays] days before it. An unparseable key is dropped; an unparseable anchor + * yields an empty history, so a caller can only ever under-state a baseline, never read one from nights it + * cannot place. While seeding, an imported value wins a day both sources cover; an own night fills a day + * the import does not cover or covers without a value (the precedence of the dashboard's `mergeDaily` and + * of the engine merge before #2525, so a blank imported row can never shadow a night the strap measured). + * Swift twin: `ChargeBaselines.history`. + */ + fun history( + imported: List>, + own: List>, + anchorDay: String, + cfg: MetricCfg, + baselineEpoch: Double, + windowDays: Int = ChargeBaselines.windowDays, + handoffNights: Int = Baselines.minNightsTrust, + ): History { + val empty = History(emptyList(), emptyList(), seededByImport = false, ownValidNights = 0, importedNights = 0) + if (windowDays <= 0) return empty + val anchor = Baselines.isoEpochDay(anchorDay) ?: return empty + val inWindow = { day: String -> + val d = Baselines.isoEpochDay(day) + d != null && d <= anchor && anchor - d < windowDays + } + + val ownByDay = HashMap() + for ((day, value) in own) if (inWindow(day)) ownByDay[day] = value + val ownKeys = ownByDay.keys.sorted() + val ownValues = ownKeys.map { ownByDay.getValue(it) } + val ownValid = Baselines.foldHistory(ownValues, ownKeys, cfg, baselineEpoch).nValid + + val importedByDay = HashMap() + for ((day, value) in imported) if (inWindow(day)) importedByDay[day] = value + + // Handed off, or nothing to seed with: the own nights are the whole history. + if (ownValid >= handoffNights || importedByDay.isEmpty()) { + return History(ownKeys, ownValues, seededByImport = false, ownValidNights = ownValid, importedNights = 0) + } + + // Seeding: every imported night in the window, then the own nights on the days it leaves open. An + // imported night without a value leaves its day open too (`merged[day]` is null for an absent key and + // for a null value alike), and the own night fills it. An own night without a value still registers + // the day (a missing night), exactly as it would with no import. Mirrors Swift. + val merged = HashMap(importedByDay) + for ((day, value) in ownByDay) { + if (merged[day] != null) continue // an imported value wins + merged[day] = value + } + val keys = merged.keys.sorted() + return History( + keys, keys.map { merged.getValue(it) }, seededByImport = true, + ownValidNights = ownValid, importedNights = importedByDay.size, + ) + } + + /** + * The Charge baselines the dashboard reads, resolved from the stored daily rows with the same rule the + * engine's pass-2 fold applies (#2525), so the "What shaped it" rows, the calibration count and the + * confidence tier describe the baseline the Charge headline was scored against. + */ + data class Resolved( + val hrvHistory: History, + val restingHRHistory: History, + val respHistory: History, + val hrv: BaselineState, + val restingHR: BaselineState, + val resp: BaselineState, + ) + + /** + * Resolve HRV, resting-HR and respiration baselines from stored rows: [imported] are the imported vendor + * rows, [own] the NOOP-computed ("-noop") rows. HRV folds on [hrvEpoch], resting HR and respiration on + * [recoveryEpoch], exactly as the engine does. The engine additionally cuts respiration at a device-era + * boundary (#459), which needs a per-night source the stored rows do not carry; the two agree for every + * single-brand history. Swift twin: `ChargeBaselines.resolve`. + */ + fun resolve( + imported: List, + own: List, + anchorDay: String, + hrvEpoch: Double, + recoveryEpoch: Double, + ): Resolved { + val hrvCfg = Baselines.hrvCfg + val rhrCfg = Baselines.restingHRCfg + val respCfg = Baselines.respCfg + val hrvHistory = history( + imported.map { it.day to it.avgHrv }, own.map { it.day to it.avgHrv }, + anchorDay, hrvCfg, hrvEpoch, + ) + val rhrHistory = history( + imported.map { it.day to it.restingHr?.toDouble() }, own.map { it.day to it.restingHr?.toDouble() }, + anchorDay, rhrCfg, recoveryEpoch, + ) + val respHistory = history( + imported.map { it.day to it.respRateBpm }, own.map { it.day to it.respRateBpm }, + anchorDay, respCfg, recoveryEpoch, + ) + return Resolved( + hrvHistory = hrvHistory, + restingHRHistory = rhrHistory, + respHistory = respHistory, + hrv = Baselines.foldHistory(hrvHistory.values, hrvHistory.dayKeys, hrvCfg, hrvEpoch), + restingHR = Baselines.foldHistory(rhrHistory.values, rhrHistory.dayKeys, rhrCfg, recoveryEpoch), + resp = Baselines.foldHistory(respHistory.values, respHistory.dayKeys, respCfg, recoveryEpoch), + ) + } + + /** + * The Recovery test-mode line naming what each Charge baseline was folded from this pass. Per metric, + * `own/N` means the own nights alone, N of them valid; `seed/N+M` means N valid own nights still seeded by + * M imported nights. It reports only the composition the engine decided, not why the wearer's values + * moved. Swift twin: `ChargeBaselines.logLine`, byte-identical. + */ + fun logLine( + anchorDay: String, + hrv: History, + restingHR: History, + resp: History, + skin: History, + windowDays: Int = ChargeBaselines.windowDays, + ): String { + val part = { h: History -> + if (h.seededByImport) "seed/${h.ownValidNights}+${h.importedNights}" else "own/${h.ownValidNights}" + } + return "charge baseline anchor=$anchorDay window=${windowDays}d hrv=${part(hrv)} " + + "rhr=${part(restingHR)} resp=${part(resp)} skin=${part(skin)}" + } +} 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 e2e787ae54..9683d3f290 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -1498,89 +1498,85 @@ object IntelligenceEngine { ), ) - // ── Seed the baseline from the UNION of imported nightly history + the nightly - // values just computed. This is the recovery fix: the "-noop" nightly avgHrv/ - // restingHr that already exist (and are re-derived identically here) finally feed - // the baseline, so a BLE-only user crosses Baselines.minNightsSeed (4 valid nights) - // and recovery lights up. We fold over the in-memory pass-1 values rather than - // re-reading repo.days(computedId) to avoid a read-before-persist ordering hazard. - // Chronological (oldest-first) replay: a day present in both takes the computed value. - val histHrvByDay = LinkedHashMap() - val histRhrByDay = LinkedHashMap() - val histRespByDay = LinkedHashMap() - for (d in hist) { - histHrvByDay[d.day] = d.avgHrv - histRhrByDay[d.day] = d.restingHr?.toDouble() - histRespByDay[d.day] = d.respRateBpm - } - // Imported (cloud) nightly values WIN per day: the on-device estimate only fills days the - // import doesn't cover AT ALL, so an import user's baseline is unchanged. Use a key-absence - // check, NOT putIfAbsent: Java's putIfAbsent treats a key mapped to NULL as absent, so an - // imported day whose avgHrv/restingHr is blank would be REPLACED by the computed estimate — - // diverging from the Swift mirror (`histHrvByDay[day] == nil` is true only when the KEY is - // absent), which keeps that imported day as a missing night. HRV/RHR are the dominant - // recovery drivers (~60%/~20%), so this substitution skewed Charge vs iOS. (The author already - // fixed this for the low-weight resp term below; HRV/RHR were missed.) - mergeNightlyIntoHistory(histHrvByDay, nightlyHrvByDay) - mergeNightlyIntoHistory(histRhrByDay, nightlyRhrByDay) - mergeNightlyIntoHistory(histRespByDay, nightlyRespByDay) + // ── Charge baselines (#2525). Each baseline is folded from the wearer's OWN nights over the last + // [ChargeBaselines.windowDays] calendar days, counted back from today; imported vendor nights only + // SEED it until the own nights alone would be trusted, then drop out (see [ChargeBaselines]). This + // is still the recovery fix: the "-noop" nightly avgHrv/restingHr feed the baseline, so a BLE-only + // user crosses Baselines.minNightsSeed (4 valid nights) and recovery lights up. + // + // Before #2525 the own nights came from this pass's scan window while `hist` (every imported row, + // however old) was folded in full, so the import kept about a third of the weight for good. The window + // is the scan window's own 21 days, so the own nights are exactly this pass's in-memory pass-1 values + // (a full-history repair pass scores more days; the window trims it to the same 21), and a BLE-only + // user folds the same nights as before. Mirrors Swift. + val oldestDay = AnalyticsEngine.dayString( + nowLocalMidnight - (maxDays - 1) * SECONDS_PER_DAY, tzOffsetSeconds, + ) + val newestDay = AnalyticsEngine.dayString(nowLocalMidnight, tzOffsetSeconds) + val importedNights = { value: (DailyMetric) -> Double? -> hist.map { it.day to value(it) } } + val ownNights = { byDay: Map -> byDay.entries.map { it.key to it.value } } + // Which SOURCE measured each night's respiration — the input `Baselines.deviceEraEpoch` (#459) // needs for the resp fold below. `resolvedScoreOwnerByDay` (THIS PASS's freshly resolved per-day - // owner, before any re-homing) must win over `hist`, not just fill its gaps: `hist` is every day - // already stored under `importedDeviceId` by construction (that is where a scored day is written), - // so filling from `hist` first would tag an Oura-owned day "whoop" on every re-score after its - // first — the exact "brand is lost once a wearable day is re-homed" trap `deviceEraEpoch`'s own - // contract warns against. `hist` still fills days outside this pass's scan window. Mirrors Swift. - val respSourceByDay = LinkedHashMap() + // owner, before any re-homing) is the only per-night source this pass knows, and it covers every own + // night the window holds. The imported rows in `hist` are stored under `importedDeviceId` and are + // tagged with it, which buckets to WHOOP; the days this engine scores are written under `computedId`, + // never into `hist`. Mirrors Swift. + val respSourceByDay = HashMap() for ((day, owner) in resolvedScoreOwnerByDay) respSourceByDay[day] = owner - for (d in hist) respSourceByDay.putIfAbsent(d.day, importedDeviceId) - // Sort once so the HRV values + their "yyyy-MM-dd" day keys stay parallel (same order/length) for - // the recalibration-aware foldHistory below. - val hrvSorted = histHrvByDay.entries.sortedBy { it.key } - val hrvSeq = hrvSorted.map { it.value } - val hrvDayKeys = hrvSorted.map { it.key } - val rhrSorted = histRhrByDay.entries.sortedBy { it.key } - val rhrSeq = rhrSorted.map { it.value } - val rhrDayKeys = rhrSorted.map { it.key } - val respSorted = histRespByDay.entries.sortedBy { it.key } - val respSeq = respSorted.map { it.value } - val respDayKeys = respSorted.map { it.key } - // HRV baseline honours noop.hrvBaselineEpoch; rhr/resp/skin honour noop.recoveryBaselineEpoch via - // their parallel day keys, so the manual Recalibrate restarts the whole Charge build-up together. - // A 0.0 epoch is byte-identical to the plain fold, so scoring is unchanged until the user taps it. + val respCandidateDays = (hist.map { it.day }.toSet() + nightlyRespByDay.keys).sorted() + // Resp baseline: WITHIN one brand it pooled imported (cloud) values with on-device RSA estimates; + // since #2525 the two meet only while the import seeds. ACROSS brands it is not acceptable: a WHOOP + // export reports its own measured rate (~16.1 on this history) while an Oura ring reports the rate + // its firmware measured (~14.6), so pooling them turns a strap SWITCH into a ~3 sigma illness-ward + // step against a ~0.52 bpm spread — a device artifact scored as physiology, the same failure #459 + // named for HRV. `deviceEraEpoch` returns 0.0 for a single-brand history (every WHOOP-origin id — + // import, strap, the computed sibling, the Apple/HC riders — buckets to one brand), so a WHOOP-only + // user is unaffected; `max` with the manual Recalibrate epoch keeps whichever cut is LATER, since + // both mean "ignore nights before this". + val respEraEpoch = Baselines.deviceEraEpoch( + 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. + val hrvHistory = ChargeBaselines.history( + importedNights { it.avgHrv }, ownNights(nightlyHrvByDay), newestDay, hrvCfg, baselineEpoch, + ) + val rhrHistory = ChargeBaselines.history( + importedNights { it.restingHr?.toDouble() }, ownNights(nightlyRhrByDay), newestDay, rhrCfg, recoveryEpoch, + ) + val respHistory = ChargeBaselines.history( + importedNights { it.respRateBpm }, ownNights(nightlyRespByDay), newestDay, respCfg, respEpoch, + ) + // Skin temperature has no imported counterpart (imported rows carry skinTempDevC, not the raw mean), + // so its history is the own nights alone. (PR #85) + val skinHistory = ChargeBaselines.history( + emptyList(), ownNights(nightlySkinByDay), newestDay, skinCfg, recoveryEpoch, + ) + // #2525: one Recovery test-mode 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 consume. + recoveryTraceSink?.invoke( + ChargeBaselines.logLine(newestDay, hrvHistory, rhrHistory, respHistory, skinHistory), + ) // #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) - 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 - // winsorizes). ACROSS brands it is not acceptable, and that is new: a WHOOP export reports its own - // measured rate (~16.1 on this history) while an Oura ring reports the rate its firmware measured - // (~14.6), so pooling them turns a strap SWITCH into a ~3 sigma illness-ward step against a - // ~0.52 bpm spread — a device artifact scored as physiology, the same failure #459 named for HRV. - // `deviceEraEpoch` returns 0.0 for a single-brand history (every WHOOP-origin id — import, strap, - // the computed sibling, the Apple/HC riders — buckets to one brand), so a WHOOP-only user folds - // byte-identically to before; `max` with the manual Recalibrate epoch keeps whichever cut is - // LATER, since both mean "ignore nights before this". Gated on `usable` because RecoveryScorer - // includes the resp term whenever a baseline object is present , a CALIBRATING (<4-night) - // baseline would let one noisy night move recovery (mirrors the skin-temp use-site gate). - val respEraEpoch = Baselines.deviceEraEpoch( - respDayKeys.map { it to (respSourceByDay[it] ?: importedDeviceId) }, - ) + emitHrvFoldTrace(recoveryTraceSink, hrvHistory.values, hrvHistory.dayKeys, hrvCfg, baselineEpoch) + val hrvBase2 = Baselines.foldHistory(hrvHistory.values, hrvHistory.dayKeys, hrvCfg, baselineEpoch) + val rhrBase2 = Baselines.foldHistory(rhrHistory.values, rhrHistory.dayKeys, rhrCfg, recoveryEpoch) + // Gated on `usable` because RecoveryScorer includes the resp term whenever a baseline object is + // present , a CALIBRATING (<4-night) baseline would let one noisy night move recovery (mirrors the + // skin-temp use-site gate). val respBase2 = Baselines - .foldHistory(respSeq, respDayKeys, respCfg, maxOf(recoveryEpoch, respEraEpoch)) + .foldHistory(respHistory.values, respHistory.dayKeys, respCfg, respEpoch) .takeIf { it.usable } - // Skin-temp baseline is on-device-only (imported rows carry skinTempDevC, not the raw mean), - // so fold purely over the pass-1 nightly means in chronological order. (PR #85) // Gated on `usable` for consistency with the resp baseline above AND the Swift reference - // (IntelligenceEngine.swift:162 `skinFold.usable ? skinFold : nil`) , the use-site re-checks - // `usable` too, so this is belt-and-suspenders, but it keeps the platforms byte-aligned. - val skinSorted = nightlySkinByDay.entries.sortedBy { it.key } - val skinSeq = skinSorted.map { it.value } - val skinDayKeys = skinSorted.map { it.key } - val skinBase2 = Baselines.foldHistory(skinSeq, skinDayKeys, skinCfg, recoveryEpoch).takeIf { it.usable } + // (`skinFold.usable ? skinFold : nil`) , the use-site re-checks `usable` too, so this is + // belt-and-suspenders, but it keeps the platforms byte-aligned. + val skinBase2 = Baselines + .foldHistory(skinHistory.values, skinHistory.dayKeys, skinCfg, recoveryEpoch) + .takeIf { it.usable } val baselines2 = ProfileBaselines( hrv = hrvBase2, restingHR = rhrBase2, resp = respBase2, skinTemp = skinBase2, ) @@ -1867,11 +1863,8 @@ object IntelligenceEngine { // re-insert the local-keyed rows. Scoped to the computed source only , imported "my-whoop" rows // are never touched (a BLE-only WHOOP 4.0 user has no import fallback). Rows older than the // window keep their old keys (cosmetic off-by-one, acceptable). yyyy-MM-dd sorts - // chronologically, so the string range IS a date range. - val oldestDay = AnalyticsEngine.dayString( - nowLocalMidnight - (maxDays - 1) * SECONDS_PER_DAY, tzOffsetSeconds, - ) - val newestDay = AnalyticsEngine.dayString(nowLocalMidnight, tzOffsetSeconds) + // chronologically, so the string range IS a date range. `oldestDay` / `newestDay` are bound above, + // with the Charge baselines (#2525), which anchor their window on `newestDay`. // ── Source-only Charge/Rest fold for imported-only days (#823) ────────────────────────────────── // A user who ONLY imports (Health Connect, or an Oura/Fitbit/Garmin export, or Apple Health) has @@ -2880,23 +2873,6 @@ object IntelligenceEngine { * Floor a unix-seconds timestamp to 00:00:00 of its UTC calendar day. AnalyticsEngine.dayString * uses UTC, so UTC midnight = ts - floorMod(ts, 86400). floorMod is correct for any sign. */ - /** - * Merge one metric's on-device pass-1 nightly values into the imported-history map. - * Imported (cloud) values WIN per day; the computed estimate only fills days the import - * does not cover at all (key absent). Mirrors the Swift `mergeNightlyIntoHistory`. - */ - internal fun mergeNightlyIntoHistory( - hist: LinkedHashMap, - nightly: Map, - ) { - // `day !in hist` only checks KEY presence — an imported row with a null - // value would shadow the real computed night forever, starving the - // baseline (the "Needs the strap" bug). `hist[day] == null` is true for - // both absent keys and null values: imported non-null wins, a null (or - // absent) slot is backfilled by the computed value. - for ((day, v) in nightly) if (hist[day] == null) hist[day] = v - } - internal fun midnightUtc(ts: Long): Long = ts - Math.floorMod(ts, SECONDS_PER_DAY) /** diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index de479b04f4..6abc63e829 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -809,6 +809,12 @@ class WhoopRepository( fun computedDailyUnionFlow(activeStrapId: String, from: String, to: String): Flow> = unionDaysFlow(computedSourceIds(activeStrapId).map { dao.dailyMetricsRangeFlow(it, from, to) }) + /** Imported-only daily rows over the same active-and-canonical union, the twin of + * [computedDailyUnionFlow]. The Charge baselines (#2525) read the two buckets apart, because their rule + * needs to know which nights are imported; [daysMergedFlow] has already blended them. */ + fun importedDailyUnionFlow(activeStrapId: String, from: String, to: String): Flow> = + unionDaysFlow(importedSourceIds(activeStrapId).map { dao.dailyMetricsRangeFlow(it, from, to) }) + fun metricSeriesComputedUnionFlow( activeStrapId: String, key: String, diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 6e5092bb16..6610856ee0 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -9,7 +9,9 @@ import com.noop.alarm.SmartAlarmScheduler import com.noop.alarm.SmartAlarmStore import com.noop.alarm.WindDownScheduler import com.noop.alarm.WindDownStore +import com.noop.analytics.AnalyticsEngine import com.noop.analytics.Baselines +import com.noop.analytics.ChargeBaselines import com.noop.analytics.IllnessSignalEngine import com.noop.analytics.IllnessWatch import com.noop.analytics.IntelligenceEngine @@ -767,6 +769,39 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { repository.recentDaysMergedFlow(deviceId) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** + * #2525: the Charge baselines (HRV, resting HR, respiration) resolved with the engine's own rule from the + * imported and own daily rows, read apart because the rule needs to know which nights are imported. The + * single funnel every Charge readout below the headline reads (the "What shaped it" rows, the + * calibration count, the confidence tier), so none of them can fold a different history than the score + * was computed against. Anchored on today's local day and the two recalibration epochs, read on each + * emission exactly as the engine reads them per pass. The read range starts at this ViewModel's first + * window start, so it only ever covers MORE days than the window; [ChargeBaselines.history] trims to the + * current one. Mirrors the Swift `Repository.chargeBaselines`. + */ + val chargeBaselines: StateFlow = run { + val startSec = System.currentTimeMillis() / 1000L + val startTz = java.util.TimeZone.getDefault().getOffset(startSec * 1000L) / 1000L + val from = Baselines.cutoffKey(AnalyticsEngine.dayString(startSec, startTz), ChargeBaselines.windowDays - 1) + combine( + repository.importedDailyUnionFlow(deviceId, from, "9999-12-31"), + repository.computedDailyUnionFlow(deviceId, from, "9999-12-31"), + ) { imported, own -> + val nowSec = System.currentTimeMillis() / 1000L + val tz = java.util.TimeZone.getDefault().getOffset(nowSec * 1000L) / 1000L + val prefs = NoopPrefs.of(appContext) + ChargeBaselines.resolve( + imported = imported, + own = own, + anchorDay = AnalyticsEngine.dayString(nowSec, tz), + hrvEpoch = prefs.getLong(Baselines.hrvBaselineEpochKey, 0L).toDouble(), + recoveryEpoch = prefs.getLong(Baselines.recoveryBaselineEpochKey, 0L).toDouble(), + ) + } + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + } + /** * Today's measured steps follow the newest confirmed sleep-onset cycle, independently of the fixed * 04:00 presentation day used by the rest of the dashboard. The marker is persisted by the analytics diff --git a/android/app/src/main/java/com/noop/ui/CoupledScreen.kt b/android/app/src/main/java/com/noop/ui/CoupledScreen.kt index 1f9f959539..b85e871ff5 100644 --- a/android/app/src/main/java/com/noop/ui/CoupledScreen.kt +++ b/android/app/src/main/java/com/noop/ui/CoupledScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.analytics.Baselines +import com.noop.analytics.ChargeBaselines import com.noop.analytics.ReadinessEngine import com.noop.analytics.RestScorer import com.noop.data.DailyMetric @@ -90,6 +91,8 @@ fun CoupledScreen( ) { val today by vm.today.collectAsStateWithLifecycle() val days by vm.recentDays.collectAsStateWithLifecycle() + // #2525: the Charge baselines the engine's rule resolves, the SAME source Today reads. + val chargeBaselines by vm.chargeBaselines.collectAsStateWithLifecycle() // Last night's sleep sessions (imported + computed-only), the SAME resolution SleepScreen uses, keyed on // `days` so a sync/import reloads. Only needed for the bed-wake span footnote. @@ -146,14 +149,15 @@ fun CoupledScreen( // older night while the card beside it showed today's number. It also missed the #547 upper bound // (a future-dated row from a bad strap clock is how that bug read "12 Jul") and the calibrating gate. // One helper, one answer: the card and its own detail sheet cannot disagree again. - val carriedRecoveryDay = remember(days, todayKey, todayRow, hrvEpoch, logicalKey, localKey) { + val carriedRecoveryDay = remember(days, chargeBaselines, todayKey, todayRow, hrvEpoch, logicalKey, localKey) { lastScoredRecoveryDay( days = days, selectedDayKey = todayKey, isToday = true, // the Coupled view has no day selector; it is always today todayScored = todayRow?.recovery != null, isCalibrating = recoveryCalibrationNights( - days, hasRecovery = todayRow?.recovery != null, hrvBaselineEpoch = hrvEpoch, + chargeBaselines?.hrvHistory?.values.orEmpty(), chargeBaselines?.hrvHistory?.dayKeys.orEmpty(), + hasRecovery = todayRow?.recovery != null, hrvBaselineEpoch = hrvEpoch, ) != null, today = maxOf(logicalKey, localKey), ) @@ -182,8 +186,11 @@ fun CoupledScreen( // Recovery cold-start nights (the SAME pure helper Today's ring reads), for the honest calibrating // caption + accessibility copy while the HRV baseline still seeds. Threads the persisted // "Recalibrate HRV baseline" epoch so N folds the SAME epoch-aware history the engine folds (Bug B). - val calibrationNights = remember(days, todayRow, hrvEpoch) { - recoveryCalibrationNights(days, hasRecovery = todayRow?.recovery != null, hrvBaselineEpoch = hrvEpoch) + val calibrationNights = remember(chargeBaselines, todayRow, hrvEpoch) { + recoveryCalibrationNights( + chargeBaselines?.hrvHistory?.values.orEmpty(), chargeBaselines?.hrvHistory?.dayKeys.orEmpty(), + hasRecovery = todayRow?.recovery != null, hrvBaselineEpoch = hrvEpoch, + ) } // The Charge breakdown (the hero's tap target, the EXISTING Today sheet) + the scoring guide it @@ -255,6 +262,7 @@ fun CoupledScreen( ) { ChargeBreakdownSheet( days = days, + chargeBaselines = chargeBaselines, displayDay = todayRow, carriedDay = carriedRecoveryDay, showReadiness = true, diff --git a/android/app/src/main/java/com/noop/ui/TodayScoring.kt b/android/app/src/main/java/com/noop/ui/TodayScoring.kt index 712f95ceae..6a70d0e29f 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScoring.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScoring.kt @@ -3,6 +3,7 @@ package com.noop.ui import com.noop.R import com.noop.analytics.BaselineState import com.noop.analytics.Baselines +import com.noop.analytics.ChargeBaselines import com.noop.analytics.ChargeDriver import com.noop.analytics.RecoveryDrivers import com.noop.analytics.RestScorer @@ -20,7 +21,8 @@ import java.util.Locale * " progress shown in place of "No Data"; null once recovery exists or the baseline has crossed * the seed gate. N is the HRV baseline's `nValid` from folding the SAME day-keyed, epoch-aware history * the recovery engine folds ([Baselines.foldHistory] with [hrvBaselineEpoch]), NOT a looser per-night - * bounds count. + * bounds count. Callers pass the Charge HRV history ([ChargeBaselines.Resolved.hrvHistory], #2525), the + * nights the engine's fold actually reads. * * The old count advanced on every in-range night, including nights the engine's fold DROPS after a * manual "Recalibrate HRV baseline" (each night dated before the epoch is discarded, not skip-and-held). @@ -28,59 +30,51 @@ import java.util.Locale * and the Today score side fell through to [ScoreState.NeedsStrap] while the post-recalibration baseline * was still seeding (Bug B, #393 follow-up). `nValid` is the exact count Baselines.computeStatus gates * CALIBRATING on, so N now tracks the baseline the Charge ring rides and can never over-state it. - * [days] is oldest->newest (same order the engine folds). Pure + unit-tested (RecoveryCalibrationTest). - * (PR #85) + * [nightlyHrv] and [dayKeys] are parallel and oldest->newest (same order the engine folds). Mirrors the + * Swift `RecoveryScorer.calibrationNights`. Pure + unit-tested (RecoveryCalibrationTest). (PR #85) */ internal fun recoveryCalibrationNights( - days: List, + nightlyHrv: List, + dayKeys: List, hasRecovery: Boolean, hrvBaselineEpoch: Double, seed: Int = Baselines.minNightsSeed, ): Int? { if (hasRecovery) return null - val n = Baselines.foldHistory( - days.map { it.avgHrv }, days.map { it.day }, Baselines.hrvCfg, hrvBaselineEpoch, - ).nValid + val n = Baselines.foldHistory(nightlyHrv, dayKeys, Baselines.hrvCfg, hrvBaselineEpoch).nValid // Include 0: a brand-new user (no banked nights) reads "Calibrating, 0 of N" on Charge, not a // bare "No data" that looks broken (#335). Caller gates past days to null; >= seed -> null. return n.takeIf { it in 0 until seed } } /** - * The ordered "What shaped it" Charge driver rows for [displayDay], rebuilt PURELY from the visible - * [days] history (the same in-memory rows the dashboard already shows, imports win field-by-field in - * the merge), so no engine round-trip is needed and the bars match the Charge ring's own inputs. Folds - * the whole history (oldest first) into the four-plus-one personal baselines with [Baselines.foldHistory] - * (byte-identical to the engine's whole-history fold when no manual Recalibrate epoch is set, the common - * case), then defers to [RecoveryDrivers.chargeDrivers], which scores each row against the SAME inputs - * [RestScorer] reads through the Today recovery path. Empty when the displayed day can't score - * (cold-start / missing input), so the section hides rather than faking rows. Mirrors the iOS - * chargeDrivers wiring. + * The ordered "What shaped it" Charge driver rows for [displayDay], scored against [baselines], the + * Charge baselines [AppViewModel.chargeBaselines] resolves with the engine's own rule (#2525), so the bars + * describe the baseline the Charge ring was scored against. Before #2525 this folded the whole visible + * history itself, which kept every imported night and every stored own night while the headline kept only + * the recent own nights plus the import, so the page could show one baseline in the rows and score against + * another. (#2315 had already closed the same gap for the recalibration epoch; the resolver applies both + * epochs.) Defers to [RecoveryDrivers.chargeDrivers], which scores each row against the SAME inputs + * [RestScorer] reads through the Today recovery path. Empty when the displayed day can't score (cold-start / + * missing input / no baselines yet), so the section hides rather than faking rows. Mirrors the iOS + * `ChargeBreakdownWiring.breakdown`. */ internal fun recoveryChargeDrivers( - days: List, + baselines: ChargeBaselines.Resolved?, displayDay: DailyMetric?, - hrvBaselineEpoch: Double = 0.0, ): List { + val resolved = baselines ?: return emptyList() val day = displayDay ?: return emptyList() val hrv = day.avgHrv ?: return emptyList() val rhr = day.restingHr?.toDouble() ?: return emptyList() - // Whole-history fold (oldest first), exactly as the engine seeds baselines2. - val ordered = days.sortedBy { it.day } - // #2315: fold with the recalibration epoch, exactly as the engine does. Without it these rows - // scored against the WHOLE history while the headline scored against the post-Recalibrate nights, - // so the Charge page showed two different baselines for the same metric. `0.0` (no recalibration) - // delegates to the plain fold, so a user who never recalibrated sees no change at all. - val hrvBase = Baselines.foldHistory( - ordered.map { it.avgHrv }, ordered.map { it.day }, Baselines.hrvCfg, hrvBaselineEpoch, - ) + val hrvBase = resolved.hrv if (!hrvBase.usable) return emptyList() // Passed on ungated, unlike respBase below, and that is deliberate since #1988: chargeDrivers // gates this one itself, for its score AND for the row it builds from the baseline directly. // Gating again here would be harmless but would suggest the callee does not, which it does. - val rhrBase = Baselines.foldHistory(ordered.map { it.restingHr?.toDouble() }, Baselines.restingHRCfg) - val respBase = Baselines.foldHistory(ordered.map { it.respRateBpm }, Baselines.respCfg).takeIf { it.usable } + val rhrBase = resolved.restingHR + val respBase = resolved.resp.takeIf { it.usable } // sleepPerf: the Rest COMPOSITE (/100) when stages exist, else raw efficiency, the SAME derivation // recomputeRecovery uses, so the Sleep driver scores against the headline's own input. @@ -99,22 +93,17 @@ internal fun recoveryChargeDrivers( } /** - * The Charge (recovery) [ScoreConfidence] tier for [displayDay] against the HRV baseline folded from - * [days], surfaced as the confidence dot + tier tag under the "What shaped it" rows. SURFACED, never - * recomputed differently: it calls [ScoreConfidence.forCharge] with the SAME folded HRV baseline the - * drivers scored against. Mirrors the iOS surfacing of the existing ScoreConfidence on the recovery screen. + * The Charge (recovery) [ScoreConfidence] tier for [displayDay] against the HRV baseline in [baselines], + * surfaced as the confidence dot + tier tag under the "What shaped it" rows. SURFACED, never recomputed + * differently: it calls [ScoreConfidence.forCharge] with the SAME HRV baseline the drivers scored against. + * Before the first resolve it reads an empty fold, exactly as an empty history did. Mirrors the iOS + * surfacing of the existing ScoreConfidence on the recovery screen. */ internal fun chargeConfidenceTier( - days: List, + baselines: ChargeBaselines.Resolved?, displayDay: DailyMetric?, - hrvBaselineEpoch: Double = 0.0, ): ScoreConfidence { - // #2315: epoch-aware for the same reason as the drivers above. The tier is read off the baseline - // the ring rides, so folding a different history here could badge a scored day as CALIBRATING. - val ordered = days.sortedBy { it.day } - val hrvBase: BaselineState = Baselines.foldHistory( - ordered.map { it.avgHrv }, ordered.map { it.day }, Baselines.hrvCfg, hrvBaselineEpoch, - ) + val hrvBase: BaselineState = baselines?.hrv ?: Baselines.foldHistory(emptyList(), Baselines.hrvCfg) return ScoreConfidence.forCharge(displayDay?.recovery, hrvBase) } diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 3ba2cd4f89..b8a9a748cb 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -156,6 +156,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.noop.R import com.noop.ai.AiKeyStore import com.noop.analytics.Baselines +import com.noop.analytics.ChargeBaselines import com.noop.analytics.BatteryEstimator import com.noop.analytics.ChargeDriver import com.noop.analytics.ChargeDriverLabel @@ -332,6 +333,9 @@ fun TodayScreen( val today by viewModel.today.collectAsStateWithLifecycle() val alert by viewModel.healthAlert.collectAsStateWithLifecycle() val days by viewModel.recentDays.collectAsStateWithLifecycle() + // #2525: the Charge baselines the engine's rule resolves, the one source for every Charge readout below + // the headline (calibration count, "What shaped it", confidence tier). + val chargeBaselines by viewModel.chargeBaselines.collectAsStateWithLifecycle() val activeDayCycle by viewModel.activeDayCycle.collectAsStateWithLifecycle() val spo2CandidateByDay by viewModel.spo2CandidateByDay.collectAsStateWithLifecycle() // #2208: `connected` alone never said WHOSE charge liveSnap.batteryPct is. It goes true the moment any @@ -1062,7 +1066,10 @@ fun TodayScreen( // epoch-aware history the recovery engine folds — otherwise a post-recalibration user's pre-epoch // nights inflate the count past the seed gate and the score side wrongly reads NeedsStrap (Bug B). val hrvEpoch = NoopPrefs.of(context).getLong(Baselines.hrvBaselineEpochKey, 0L).toDouble() - recoveryCalibrationNights(days, displayMetric?.recovery != null, hrvEpoch) + recoveryCalibrationNights( + chargeBaselines?.hrvHistory?.values.orEmpty(), chargeBaselines?.hrvHistory?.dayKeys.orEmpty(), + displayMetric?.recovery != null, hrvEpoch, + ) } else { null } @@ -1995,6 +2002,7 @@ fun TodayScreen( ) { ChargeBreakdownSheet( days = days, + chargeBaselines = chargeBaselines, displayDay = displayMetric, carriedDay = lastScoredRecoveryDay, showReadiness = selectedDayOffset == 0, @@ -5302,6 +5310,7 @@ private fun TodayLayoutEditorDialog( @Composable internal fun ChargeBreakdownSheet( days: List, + chargeBaselines: ChargeBaselines.Resolved?, displayDay: DailyMetric?, carriedDay: DailyMetric?, showReadiness: Boolean, @@ -5345,7 +5354,7 @@ internal fun ChargeBreakdownSheet( ) { // The breakdown self-gates: a calibrating night (empty drivers) renders nothing here, the // Contributors + Readiness below still give an honest read, never a blank sheet. - RecoveryDriversSection(days = days, displayDay = displayDay, carriedDay = carriedDay) + RecoveryDriversSection(chargeBaselines = chargeBaselines, displayDay = displayDay, carriedDay = carriedDay) RecoveryContributorsSection(day = displayDay, carriedDay = carriedDay) // S4: the SEPARATE Readiness block now lives here behind the Charge-ring tap (today-only, // matching the old inline gate). A one-word read (Push / Maintain / Rest) stays on the hero. @@ -5458,21 +5467,18 @@ internal fun ChargeBreakdownSheet( @Composable private fun RecoveryDriversSection( - days: List, + chargeBaselines: ChargeBaselines.Resolved?, displayDay: DailyMetric?, carriedDay: DailyMetric? = null, ) { - // #2315: the same recalibration epoch the engine folds with. Read here rather than threaded from the - // caller because this section is the only consumer, and the pref read is one getLong behind remember. - val context = LocalContext.current - val hrvEpoch = remember { NoopPrefs.of(context).getLong(Baselines.hrvBaselineEpochKey, 0L).toDouble() } // Read the row the Charge ring itself reads: today's own when scored, else the carried last-scored - // day (#543) so the breakdown matches the carried ring instead of vanishing at the rollover. + // day (#543) so the breakdown matches the carried ring instead of vanishing at the rollover. The + // baselines already carry both recalibration epochs (#2315), applied where they were resolved (#2525). val readDay = carriedDay ?: displayDay - val drivers = remember(days, readDay, hrvEpoch) { recoveryChargeDrivers(days, readDay, hrvEpoch) } + val drivers = remember(chargeBaselines, readDay) { recoveryChargeDrivers(chargeBaselines, readDay) } if (drivers.isEmpty()) return - val tier = remember(days, readDay, hrvEpoch) { chargeConfidenceTier(days, readDay, hrvEpoch) } + val tier = remember(chargeBaselines, readDay) { chargeConfidenceTier(chargeBaselines, readDay) } val overline = carriedDay?.let { uiString(R.string.today_charge_carried, carriedCaption(it.day).localized()) } ?: uiString(R.string.trends_charge) diff --git a/android/app/src/test/java/com/noop/analytics/ChargeBaselinesTest.kt b/android/app/src/test/java/com/noop/analytics/ChargeBaselinesTest.kt new file mode 100644 index 0000000000..69713d8c77 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/ChargeBaselinesTest.kt @@ -0,0 +1,322 @@ +package com.noop.analytics + +import com.noop.data.DailyMetric +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which nights the Charge baselines are folded from (#2525). + * + * The defect these pin: the imported vendor history was folded in full while the wearer's own nights entered + * only from the scan window, so the import kept about a third of the weight however long NOOP had been worn. + * Byte-identical twin of the Swift `ChargeBaselinesTests` (same cases, same oracle literal). + */ +class ChargeBaselinesTest { + + private val hrvCfg = Baselines.hrvCfg + private val rhrCfg = Baselines.restingHRCfg + + private fun dayKey(epochDay: Int): String = LocalDate.ofEpochDay(epochDay.toLong()).toString() + + /** 2026-09-30 as a day count from 1970-01-01. */ + private val anchorEpochDay = 20_726 + private val anchor = dayKey(anchorEpochDay) + + /** [count] consecutive nights ending [endBack] days before the anchor, all at [value]. */ + private fun run(count: Int, endBack: Int, value: Double?): List> = + (0 until count).map { i -> dayKey(anchorEpochDay - endBack - (count - 1 - i)) to value } + + private fun history( + imported: List>, + own: List>, + cfg: MetricCfg = hrvCfg, + epoch: Double = 0.0, + ) = ChargeBaselines.history(imported, own, anchor, cfg, epoch) + + @Test fun theAnchorIsTheDayItClaims() { + assertEquals("2026-09-30", anchor) + } + + // ---- the window --------------------------------------------------------------------------------- + + /** The window holds the anchor and the 20 days before it; the 21st day back and any later day are out. */ + @Test fun theWindowKeepsTwentyOneCalendarDaysEndingOnTheAnchor() { + val own = listOf( + dayKey(anchorEpochDay - 21) to 50.0, + dayKey(anchorEpochDay - 20) to 51.0, + anchor to 52.0, + dayKey(anchorEpochDay + 1) to 53.0, + ) + val h = history(emptyList(), own) + assertEquals(listOf("2026-09-10", "2026-09-30"), h.dayKeys) + assertEquals(listOf(51.0, 52.0), h.values) + } + + /** An import that ended before the window has nothing left to seed with, however few own nights exist. */ + @Test fun anImportOlderThanTheWindowDoesNotSeed() { + val h = history(run(300, 25, 40.0), run(3, 0, 60.0)) + assertFalse(h.seededByImport) + assertEquals(listOf(60.0, 60.0, 60.0), h.values) + assertEquals(0, h.importedNights) + } + + // ---- the handoff -------------------------------------------------------------------------------- + + /** Thirteen valid own nights are not yet a trusted baseline, so the import still seeds; the fourteenth + * hands over and the import drops out entirely. */ + @Test fun importSeedsUntilTheOwnNightsAloneAreTrusted() { + val imported = run(7, 14, 40.0) + val seeding = history(imported, run(13, 0, 60.0)) + assertTrue(seeding.seededByImport) + assertEquals(13, seeding.ownValidNights) + assertEquals(7, seeding.importedNights) + assertEquals(20, seeding.dayKeys.size) + + val handedOff = history(imported, run(14, 0, 60.0)) + assertFalse(handedOff.seededByImport) + assertEquals(14, handedOff.ownValidNights) + assertEquals(0, handedOff.importedNights) + assertEquals(List(14) { 60.0 }, handedOff.values) + } + + /** Own nights without a value exist but are not valid, so they do not bring the handoff forward. */ + @Test fun ownNightsWithoutAValueDoNotCountTowardTheHandoff() { + val h = history(run(6, 14, 40.0), run(13, 1, 60.0) + run(1, 0, null)) + assertTrue(h.seededByImport) + assertEquals(13, h.ownValidNights) + } + + /** Own nights before the recalibration epoch are dropped by the fold, so they must not count here either. */ + @Test fun ownNightsBeforeTheEpochDoNotCountTowardTheHandoff() { + val epoch = (anchorEpochDay - 9).toDouble() * 86_400 // the last ten own nights survive + val h = history(run(3, 18, 40.0), run(30, 0, 60.0), epoch = epoch) + assertEquals(10, h.ownValidNights) + assertTrue(h.seededByImport) + } + + /** Nothing that cannot be placed on the calendar is folded. */ + @Test fun unparseableKeysAreDroppedAndAnUnparseableAnchorYieldsNothing() { + val own = listOf("garbage" to 50.0, anchor to 51.0) + assertEquals(listOf(anchor), history(emptyList(), own).dayKeys) + val none = ChargeBaselines.history(emptyList(), own, "not-a-day", hrvCfg, 0.0) + assertEquals(emptyList(), none.dayKeys) + assertFalse(none.seededByImport) + } + + // ---- seeding precedence (carried over from the pre-#2525 `mergeNightlyIntoHistory` pins) --------- + + /** An imported value wins a day the own nights also cover (import users keep their seed). */ + @Test fun whileSeedingAnImportedValueWinsASharedDay() { + val h = history(listOf(anchor to 62.0), listOf(anchor to 48.0)) + assertTrue(h.seededByImport) + assertEquals(listOf(62.0), h.values) + } + + /** An own night fills a day the import does not cover. */ + @Test fun whileSeedingAnOwnNightFillsADayTheImportLacks() { + val yesterday = dayKey(anchorEpochDay - 1) + val h = history(listOf(yesterday to 62.0), listOf(anchor to 48.0)) + assertEquals(listOf(yesterday, anchor), h.dayKeys) + assertEquals(listOf(62.0, 48.0), h.values) + } + + /** A blank imported row must not shadow a night the strap measured, or an import whose rows are blank + * for a metric blankets every strap night and the baseline never seeds ("Needs the strap"). */ + @Test fun whileSeedingABlankImportedRowIsFilledByTheOwnNight() { + val h = history(listOf(anchor to null), listOf(anchor to 48.0)) + assertEquals(listOf(48.0), h.values) + } + + /** A blank imported row with no own night stays a missing night (an honest gap). */ + @Test fun whileSeedingABlankImportedRowWithNoOwnNightStaysMissing() { + val yesterday = dayKey(anchorEpochDay - 1) + val h = history(listOf(yesterday to null, anchor to 62.0), emptyList()) + assertTrue(h.seededByImport) + assertEquals(listOf(yesterday, anchor), h.dayKeys) + assertEquals(listOf(null, 62.0), h.values) + } + + /** An own night without a value neither overwrites an imported value nor removes a blank imported day. */ + @Test fun whileSeedingABlankOwnNightDisturbsNothing() { + val d1 = dayKey(anchorEpochDay - 2) + val d2 = dayKey(anchorEpochDay - 1) + val h = history(listOf(d1 to 62.0, d2 to null), listOf(d1 to null, d2 to null, anchor to null)) + assertEquals(listOf(d1, d2, anchor), h.dayKeys) + assertEquals(listOf(62.0, null, null), h.values) + } + + /** The starvation report's shape: a week of imported rows, all blank for HRV, over nights the strap + * scored. The seven measured nights must reach the fold so the baseline can seed. */ + @Test fun whileSeedingAWeekOfBlankImportedRowsStillSeedsFromTheStrap() { + val own = (0 until 7).map { i -> dayKey(anchorEpochDay - 6 + i) to (50.0 + i) } + val h = history(run(7, 0, null), own) + assertEquals(7, h.values.count { it != null }) + val folded = Baselines.foldHistory(h.values, h.dayKeys, hrvCfg, 0.0) + assertTrue(folded.nValid >= Baselines.minNightsSeed) + } + + // ---- the defect (#2525) ------------------------------------------------------------------------- + + /** A wearer whose resting HR fell from 58 during their vendor subscription to 52 since. Under the old rule + * (the whole import plus the last 21 own nights) the baseline stays about a third of the way back towards + * 58 however long NOOP has been worn; under this rule it follows the wearer to 52. */ + @Test fun theBaselineFollowsTheWearerInsteadOfTheImport() { + val imported = run(700, 181, 58.0) + val own = run(180, 0, 52.0) + val oldRule = Baselines.foldHistory(imported.map { it.second } + own.takeLast(21).map { it.second }, rhrCfg) + assertEquals("the pinned third: 52 + 6 x 0.5^(21/14) is about 54.1", 54.1, oldRule.baseline, 0.2) + + val h = history(imported, own, cfg = rhrCfg) + val folded = Baselines.foldHistory(h.values, h.dayKeys, rhrCfg, 0.0) + assertEquals(52.0, folded.baseline, 1e-9) + } + + /** Once the own nights have taken over, the import has no weight at all: moving every imported value + * leaves the baseline exactly where it was. */ + @Test fun onceHandedOffTheImportCarriesNoWeight() { + val own = run(60, 0, 52.0) + assertEquals(history(run(200, 10, 58.0), own, cfg = rhrCfg), history(run(200, 10, 68.0), own, cfg = rhrCfg)) + } + + /** The own nights of a varied 60-night history, as a full-history repair pass would score them. */ + private fun variedOwnNights(): List> = (0 until 60).map { i -> + dayKey(anchorEpochDay - 59 + i) to (if (i % 9 == 0) null else 45.0 + ((i * 7) % 13)) + } + + /** Without an import nothing changes on the 21-day pass: the history is exactly the nights the old rule + * folded (the scan window's own nights), and the folded baseline is identical. */ + @Test fun withoutAnImportTheTwentyOneDayPassIsUnchanged() { + val scanWindow = variedOwnNights().takeLast(21) + val oldRule = Baselines.foldHistory(scanWindow.map { it.second }, scanWindow.map { it.first }, hrvCfg, 0.0) + val h = history(emptyList(), scanWindow) + assertEquals(scanWindow.map { it.first }, h.dayKeys) + assertEquals(oldRule, Baselines.foldHistory(h.values, h.dayKeys, hrvCfg, 0.0)) + } + + /** A full-history repair pass scores every day it can; the window trims it to the same 21, so the baseline + * no longer depends on which pass ran last. */ + @Test fun aRepairPassFoldsTheSameTwentyOneDays() { + val all = variedOwnNights() + assertEquals(history(emptyList(), all.takeLast(21)), history(emptyList(), all)) + } + + // ---- Resolved (the dashboard's view) ------------------------------------------------------------ + + /** Resting HR follows the Charge-wide recalibration epoch in the dashboard as it does in the engine. */ + @Test fun resolveFoldsRestingHROnTheRecoveryEpoch() { + val own = (0 until 20).map { back -> + DailyMetric( + deviceId = "my-whoop-noop", day = dayKey(anchorEpochDay - back), + restingHr = if (back < 5) 50 else 60, avgHrv = 50.0, + ) + } + val epoch = (anchorEpochDay - 4).toDouble() * 86_400 + val r = ChargeBaselines.resolve(emptyList(), own, anchor, hrvEpoch = 0.0, recoveryEpoch = epoch) + assertEquals(5, r.restingHR.nValid) + assertEquals(50.0, r.restingHR.baseline, 1e-9) + assertEquals(20, r.hrv.nValid) + } + + // ---- diagnostic line ---------------------------------------------------------------------------- + + @Test fun logLineNamesEachMetricsComposition() { + val seeded = ChargeBaselines.History(emptyList(), emptyList(), true, 9, 40) + val own = ChargeBaselines.History(emptyList(), emptyList(), false, 45, 0) + assertEquals( + "charge baseline anchor=2026-09-30 window=21d hrv=own/45 rhr=seed/9+40 resp=own/45 skin=own/45", + ChargeBaselines.logLine("2026-09-30", own, seeded, own, own), + ) + } + + // ---- oracle (shared with Swift) ----------------------------------------------------------------- + + /** The same deterministic spread the Swift twin generates; both assert the same literal, so either side + * drifting fails its own suite. */ + private fun oracleLines(): List { + var s = 2525L + fun next(): Int { + s = s * 6_364_136_223_846_793_005L + 1_442_695_040_888_963_407L + return (s ushr 33).toInt() + } + val lines = ArrayList() + for (c in 0 until 40) { + val anchorDay = 20_089 + next() % 400 + val imported = ArrayList>() + val nImported = next() % 120 + val importEndBack = next() % 40 + for (i in 0 until nImported) { + val value: Double? = if (next() % 10 == 0) null else 40 + (next() % 60).toDouble() / 2 + imported.add(dayKey(anchorDay - importEndBack - (nImported - 1 - i)) to value) + } + val own = ArrayList>() + val nOwn = next() % 30 + val ownEndBack = next() % 10 + for (i in 0 until nOwn) { + val value: Double? = if (next() % 8 == 0) null else 35 + (next() % 80).toDouble() / 2 + own.add(dayKey(anchorDay - ownEndBack - (nOwn - 1 - i)) to value) + } + if (next() % 7 == 0) own.add(dayKey(anchorDay + 1) to 50.0) + val epoch = if (next() % 5 == 0) (anchorDay - next() % 60).toDouble() * 86_400 else 0.0 + val h = ChargeBaselines.history(imported, own, dayKey(anchorDay), Baselines.hrvCfg, epoch) + val present = h.values.filterNotNull() + val sum = String.format(java.util.Locale.ROOT, "%.1f", present.sum()) + lines.add( + "c=$c anchor=${dayKey(anchorDay)} n=${h.dayKeys.size} " + + "first=${h.dayKeys.firstOrNull() ?: "-"} last=${h.dayKeys.lastOrNull() ?: "-"} " + + "seeded=${if (h.seededByImport) 1 else 0} own=${h.ownValidNights} imp=${h.importedNights} " + + "nils=${h.values.size - present.size} sum=$sum", + ) + } + return lines + } + + @Test fun oracleSpread() { + assertEquals(oracleLiteral, oracleLines().joinToString("\n")) + } + + private val oracleLiteral = """ + c=0 anchor=2026-01-25 n=7 first=2026-01-10 last=2026-01-16 seeded=0 own=7 imp=0 nils=0 sum=377.0 + c=1 anchor=2025-07-07 n=7 first=2025-06-17 last=2025-07-07 seeded=1 own=2 imp=5 nils=1 sum=322.0 + c=2 anchor=2025-12-01 n=13 first=2025-11-11 last=2025-11-25 seeded=1 own=9 imp=3 nils=3 sum=645.0 + c=3 anchor=2025-06-15 n=8 first=2025-05-26 last=2025-06-15 seeded=1 own=2 imp=6 nils=0 sum=435.5 + c=4 anchor=2025-08-01 n=15 first=2025-07-13 last=2025-07-27 seeded=0 own=13 imp=0 nils=2 sum=718.0 + c=5 anchor=2025-11-29 n=14 first=2025-11-14 last=2025-11-27 seeded=0 own=11 imp=0 nils=3 sum=626.5 + c=6 anchor=2025-05-06 n=12 first=2025-04-19 last=2025-04-30 seeded=0 own=11 imp=0 nils=1 sum=574.5 + c=7 anchor=2025-10-16 n=14 first=2025-10-01 last=2025-10-14 seeded=0 own=13 imp=0 nils=1 sum=673.5 + c=8 anchor=2026-01-27 n=12 first=2026-01-07 last=2026-01-18 seeded=0 own=10 imp=0 nils=2 sum=531.5 + c=9 anchor=2026-02-02 n=10 first=2026-01-13 last=2026-01-28 seeded=1 own=3 imp=7 nils=1 sum=456.5 + c=10 anchor=2025-02-01 n=14 first=2025-01-12 last=2025-01-28 seeded=1 own=5 imp=6 nils=5 sum=460.0 + c=11 anchor=2025-12-06 n=13 first=2025-11-22 last=2025-12-04 seeded=1 own=13 imp=2 nils=0 sum=687.0 + c=12 anchor=2025-07-27 n=21 first=2025-07-07 last=2025-07-27 seeded=1 own=11 imp=21 nils=0 sum=1110.0 + c=13 anchor=2025-04-30 n=9 first=2025-04-17 last=2025-04-25 seeded=0 own=8 imp=0 nils=1 sum=425.5 + c=14 anchor=2025-05-17 n=12 first=2025-04-27 last=2025-05-08 seeded=1 own=12 imp=6 nils=0 sum=603.0 + c=15 anchor=2025-06-17 n=19 first=2025-05-30 last=2025-06-17 seeded=0 own=16 imp=0 nils=3 sum=929.0 + c=16 anchor=2025-11-04 n=14 first=2025-10-16 last=2025-10-29 seeded=0 own=11 imp=0 nils=3 sum=628.5 + c=17 anchor=2025-07-22 n=10 first=2025-07-10 last=2025-07-19 seeded=0 own=8 imp=0 nils=2 sum=462.5 + c=18 anchor=2025-08-09 n=9 first=2025-07-20 last=2025-07-28 seeded=1 own=0 imp=9 nils=1 sum=402.5 + c=19 anchor=2025-01-01 n=17 first=2024-12-12 last=2024-12-28 seeded=0 own=16 imp=0 nils=1 sum=873.0 + c=20 anchor=2025-09-06 n=14 first=2025-08-19 last=2025-09-01 seeded=0 own=13 imp=0 nils=1 sum=716.5 + c=21 anchor=2025-02-05 n=2 first=2025-01-26 last=2025-01-27 seeded=0 own=2 imp=0 nils=0 sum=140.5 + c=22 anchor=2025-09-03 n=15 first=2025-08-14 last=2025-08-28 seeded=1 own=5 imp=10 nils=3 sum=701.0 + c=23 anchor=2025-11-14 n=14 first=2025-10-25 last=2025-11-07 seeded=1 own=3 imp=13 nils=0 sum=819.0 + c=24 anchor=2025-02-26 n=20 first=2025-02-06 last=2025-02-25 seeded=0 own=18 imp=0 nils=2 sum=989.0 + c=25 anchor=2025-01-27 n=15 first=2025-01-07 last=2025-01-21 seeded=0 own=13 imp=0 nils=2 sum=655.5 + c=26 anchor=2025-07-12 n=14 first=2025-06-22 last=2025-07-05 seeded=0 own=11 imp=0 nils=3 sum=583.5 + c=27 anchor=2025-12-20 n=12 first=2025-11-30 last=2025-12-11 seeded=0 own=11 imp=0 nils=1 sum=637.5 + c=28 anchor=2025-06-07 n=16 first=2025-05-18 last=2025-06-02 seeded=0 own=15 imp=0 nils=1 sum=867.0 + c=29 anchor=2025-05-29 n=18 first=2025-05-09 last=2025-05-26 seeded=0 own=14 imp=0 nils=4 sum=835.0 + c=30 anchor=2025-06-16 n=13 first=2025-05-27 last=2025-06-08 seeded=1 own=12 imp=10 nils=0 sum=644.0 + c=31 anchor=2025-08-10 n=15 first=2025-07-21 last=2025-08-04 seeded=1 own=9 imp=10 nils=1 sum=785.0 + c=32 anchor=2026-01-17 n=14 first=2025-12-28 last=2026-01-10 seeded=0 own=14 imp=0 nils=0 sum=863.5 + c=33 anchor=2026-01-10 n=21 first=2025-12-21 last=2026-01-10 seeded=1 own=12 imp=21 nils=1 sum=1130.0 + c=34 anchor=2025-08-30 n=6 first=2025-08-25 last=2025-08-30 seeded=0 own=4 imp=0 nils=2 sum=223.5 + c=35 anchor=2026-01-31 n=13 first=2026-01-11 last=2026-01-31 seeded=1 own=4 imp=9 nils=0 sum=722.5 + c=36 anchor=2025-06-22 n=5 first=2025-06-15 last=2025-06-19 seeded=0 own=5 imp=0 nils=0 sum=295.0 + c=37 anchor=2026-01-28 n=18 first=2026-01-08 last=2026-01-25 seeded=1 own=12 imp=11 nils=2 sum=844.0 + c=38 anchor=2025-05-08 n=16 first=2025-04-18 last=2025-05-03 seeded=0 own=13 imp=0 nils=3 sum=757.0 + c=39 anchor=2025-10-01 n=14 first=2025-09-11 last=2025-09-24 seeded=1 own=12 imp=8 nils=1 sum=714.5 + """.trimIndent() +} diff --git a/android/app/src/test/java/com/noop/analytics/IntelligenceBaselineShadowTest.kt b/android/app/src/test/java/com/noop/analytics/IntelligenceBaselineShadowTest.kt deleted file mode 100644 index c9407e8d3d..0000000000 --- a/android/app/src/test/java/com/noop/analytics/IntelligenceBaselineShadowTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -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 baseline-merge precedence in `IntelligenceEngine.mergeNightlyIntoHistory` (the - * "Needs the strap" / recovery-No-Data starvation). An imported history row that carries a - * NULL value for a metric holds no information for that day: it must NOT shadow the real - * on-device nightly value into a permanently missing night, or an import whose rows are - * blank for avgHrv/restingHr blankets every strap-covered day and the baseline never - * crosses Baselines.minNightsSeed. Precedence pinned here: - * - * 1. imported non-null → wins over the computed value (import users unchanged) - * 2. key absent → computed value fills (the BLE-only recovery fix) - * 3. imported NULL → computed value backfills (the shadow fix) - * 4. imported NULL, no computed value → stays a missing night (honest gap) - * - * Mirrors the Swift `IntelligenceBaselineShadowTests` so the two platforms merge identically. - */ -class IntelligenceBaselineShadowTest { - - @Test - fun importedNonNull_winsOverComputed() { - val hist = linkedMapOf("2026-07-10" to 62.0) - IntelligenceEngine.mergeNightlyIntoHistory(hist, mapOf("2026-07-10" to 48.0)) - assertEquals(62.0, hist["2026-07-10"]!!, 1e-9) - } - - @Test - fun dayNotImported_computedFills() { - val hist = linkedMapOf("2026-07-10" to 62.0) - IntelligenceEngine.mergeNightlyIntoHistory(hist, mapOf("2026-07-11" to 48.0)) - assertEquals(48.0, hist["2026-07-11"]!!, 1e-9) - assertEquals(2, hist.size) - } - - @Test - fun importedNullValue_backfilledByComputed() { - // THE bug: the user's Health Connect import wrote a row for the day with a blank - // avgHrv; the strap actually scored the night. The blank row must not shadow it. - val hist = linkedMapOf("2026-07-10" to null) - IntelligenceEngine.mergeNightlyIntoHistory(hist, mapOf("2026-07-10" to 48.0)) - assertEquals( - "an imported row with a null value must be backfilled by the real computed night", - 48.0, hist["2026-07-10"]!!, 1e-9, - ) - } - - @Test - fun importedNullValue_noComputed_staysMissing() { - val hist = linkedMapOf("2026-07-10" to null) - IntelligenceEngine.mergeNightlyIntoHistory(hist, emptyMap()) - assertTrue("2026-07-10" in hist) - assertNull(hist["2026-07-10"]) - } - - @Test - fun nullComputed_doesNotDisturbAnything() { - // A computed pass that produced no value for a day it emitted (nil estimate) neither - // overwrites an imported value nor un-registers an imported-null day. - val hist = linkedMapOf("2026-07-10" to 62.0, "2026-07-11" to null) - IntelligenceEngine.mergeNightlyIntoHistory( - hist, mapOf("2026-07-10" to null, "2026-07-11" to null, "2026-07-12" to null), - ) - assertEquals(62.0, hist["2026-07-10"]!!, 1e-9) - assertNull(hist["2026-07-11"]) - assertTrue("2026-07-12" in hist) - assertNull(hist["2026-07-12"]) - } - - @Test - fun starvationShape_endToEnd() { - // The report's shape: a week of imported rows, ALL blank for HRV, over nights the - // strap scored. After the merge the fold input must contain the 7 real values, so - // the baseline can cross Baselines.minNightsSeed instead of reading "No Data". - val hist = LinkedHashMap() - val nightly = LinkedHashMap() - for (i in 6 downTo 0) { - val day = "2026-07-%02d".format(12 - i) - hist[day] = null - nightly[day] = 50.0 + i - } - IntelligenceEngine.mergeNightlyIntoHistory(hist, nightly) - val valid = hist.values.filterNotNull() - assertEquals("all 7 strap nights must survive the merge", 7, valid.size) - assertTrue(valid.size >= Baselines.minNightsSeed) - } -} diff --git a/android/app/src/test/java/com/noop/ui/RecoveryCalibrationTest.kt b/android/app/src/test/java/com/noop/ui/RecoveryCalibrationTest.kt index 0ded85a51c..27e426c8c7 100644 --- a/android/app/src/test/java/com/noop/ui/RecoveryCalibrationTest.kt +++ b/android/app/src/test/java/com/noop/ui/RecoveryCalibrationTest.kt @@ -26,9 +26,10 @@ class RecoveryCalibrationTest { private fun epoch(dayKey: String): Double = LocalDate.parse(dayKey).atStartOfDay(ZoneOffset.UTC).toEpochSecond().toDouble() - /** Call helper pinning `hrvBaselineEpoch = 0.0` (no recalibration → plain fold). */ - private fun nights(days: List, hasRecovery: Boolean): Int? = - recoveryCalibrationNights(days, hasRecovery = hasRecovery, hrvBaselineEpoch = 0.0) + /** The helper takes the parallel HRV values + day keys the engine folds (#2525 passes the Charge HRV + * history); these cases build them from rows. */ + private fun nights(days: List, hasRecovery: Boolean, epoch: Double = 0.0): Int? = + recoveryCalibrationNights(days.map { it.avgHrv }, days.map { it.day }, hasRecovery, hrvBaselineEpoch = epoch) @Test fun nullWhenRecoveryAlreadyExists() { @@ -94,7 +95,7 @@ class RecoveryCalibrationTest { // The old per-night bounds count was 6 (>= seed) and returned null, stranding the score side on // "Needs the strap"; N must now read 2 (a genuinely-calibrating baseline). val days = (1..6).map { day("2026-01-0$it", 55.0) } - assertEquals(2, recoveryCalibrationNights(days, hasRecovery = false, hrvBaselineEpoch = epoch("2026-01-05"))) + assertEquals(2, nights(days, hasRecovery = false, epoch = epoch("2026-01-05"))) } @Test @@ -103,6 +104,6 @@ class RecoveryCalibrationTest { // null recovery is some OTHER gap — so we must not claim "calibrating". Epoch 2026-01-02 drops one // night; five post-epoch nights → nValid 5. val days = (1..6).map { day("2026-01-0$it", 55.0) } - assertNull(recoveryCalibrationNights(days, hasRecovery = false, hrvBaselineEpoch = epoch("2026-01-02"))) + assertNull(nights(days, hasRecovery = false, epoch = epoch("2026-01-02"))) } } diff --git a/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt b/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt index d0768f3908..4ec20c5c33 100644 --- a/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt +++ b/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt @@ -1,5 +1,6 @@ package com.noop.ui +import com.noop.analytics.ChargeBaselines import com.noop.analytics.ScoreConfidence import com.noop.analytics.ChargeDriverLabel import com.noop.data.DailyMetric @@ -9,9 +10,10 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * Unit tests for the Today "What shaped it" wiring: [recoveryChargeDrivers] (folds the visible history - * into baselines, then defers to RecoveryDrivers.chargeDrivers) and [chargeConfidenceTier] (surfaces the - * existing ScoreConfidence). Pure JVM, no Robolectric. Mirrors the iOS chargeDrivers wiring tests. + * Unit tests for the Today "What shaped it" wiring: [recoveryChargeDrivers] (scores against the Charge + * baselines [ChargeBaselines.resolve] builds with the engine's rule, #2525, then defers to + * RecoveryDrivers.chargeDrivers) and [chargeConfidenceTier] (surfaces the existing ScoreConfidence). Pure + * JVM, no Robolectric. Mirrors the iOS chargeDrivers wiring tests. */ class RecoveryDriversUiTest { @@ -29,6 +31,20 @@ class RecoveryDriversUiTest { recovery = recovery, efficiency = efficiency, totalSleepMin = sleepMin, skinTempDevC = skinTempDevC, ) + /** The Charge baselines for own nights [days] (plus [imported]), anchored on [anchor]. */ + private fun resolved( + days: List, + anchor: String, + imported: List = emptyList(), + hrvEpoch: Double = 0.0, + ) = ChargeBaselines.resolve(imported, days, anchor, hrvEpoch = hrvEpoch, recoveryEpoch = 0.0) + + private fun drivers(days: List, day: DailyMetric?, hrvEpoch: Double = 0.0) = + recoveryChargeDrivers(resolved(days, day?.day ?: days.last().day, hrvEpoch = hrvEpoch), day) + + private fun tier(days: List, day: DailyMetric?, hrvEpoch: Double = 0.0) = + chargeConfidenceTier(resolved(days, day?.day ?: days.last().day, hrvEpoch = hrvEpoch), day) + /** A history long enough to make the HRV baseline usable, plus a scored "today". */ private fun scoredHistory(): List { val past = (1..10).map { day("2026-01-%02d".format(it), hrv = 50.0 + (it % 3)) } @@ -38,9 +54,9 @@ class RecoveryDriversUiTest { @Test fun scoredDayProducesDriverRows() { val days = scoredHistory() - val drivers = recoveryChargeDrivers(days, days.last()) - assertTrue("a usable baseline should yield driver rows", drivers.isNotEmpty()) - val labels = drivers.map { it.label } + val rows = drivers(days, days.last()) + assertTrue("a usable baseline should yield driver rows", rows.isNotEmpty()) + val labels = rows.map { it.label } assertTrue(labels.contains(ChargeDriverLabel.HEART_RATE_VARIABILITY)) assertTrue(labels.contains(ChargeDriverLabel.RESTING_HEART_RATE)) // Skin-temp was supplied on the scored day, so its row is present. @@ -53,20 +69,21 @@ class RecoveryDriversUiTest { day("2026-01-01", hrv = 55.0), day("2026-01-02", hrv = 58.0, recovery = null), ) - assertTrue(recoveryChargeDrivers(days, days.last()).isEmpty()) + assertTrue(drivers(days, days.last()).isEmpty()) } @Test fun confidenceTierIsSurfaced() { val days = scoredHistory() // A scored day on a now-usable baseline surfaces a non-calibrating tier. - val tier = chargeConfidenceTier(days, days.last()) - assertTrue(tier == ScoreConfidence.BUILDING || tier == ScoreConfidence.SOLID) + val t = tier(days, days.last()) + assertTrue(t == ScoreConfidence.BUILDING || t == ScoreConfidence.SOLID) // A day with no recovery number surfaces CALIBRATING. - assertEquals(ScoreConfidence.CALIBRATING, chargeConfidenceTier(days, day("2026-01-21", recovery = null))) + assertEquals(ScoreConfidence.CALIBRATING, tier(days, day("2026-01-21", recovery = null))) } @Test fun nullDayProducesNoRows() { - assertTrue(recoveryChargeDrivers(scoredHistory(), null).isEmpty()) + assertTrue(drivers(scoredHistory(), null).isEmpty()) + assertTrue("no baselines resolved yet means no rows", recoveryChargeDrivers(null, scoredHistory().last()).isEmpty()) } // ---- #1988: the RHR row needs a USABLE resting-HR baseline ---------------------------------- @@ -88,8 +105,8 @@ class RecoveryDriversUiTest { @Test fun rhrRowIsAbsentWhenTheRestingHrBaselineIsNotUsable() { val history = (1..6).map { day("2026-01-0$it", rhr = null) } val today = day("2026-01-07", rhr = 55) - val drivers = recoveryChargeDrivers(history + today, today) - val labels = drivers.map { it.label } + val rows = drivers(history + today, today) + val labels = rows.map { it.label } assertTrue("the HRV baseline is real, so its row must still be there", labels.contains(ChargeDriverLabel.HEART_RATE_VARIABILITY)) assertFalse("no usable resting-HR baseline, so no RHR row: got $labels", @@ -109,8 +126,8 @@ class RecoveryDriversUiTest { val today = day("2026-01-21", hrv = 72.0, recovery = 70.0) val days = old + since + today - val wholeHistory = recoveryChargeDrivers(days, today, hrvBaselineEpoch = 0.0) - val fromEpoch = recoveryChargeDrivers(days, today, hrvBaselineEpoch = epochOf("2026-01-11")) + val wholeHistory = drivers(days, today, hrvEpoch = 0.0) + val fromEpoch = drivers(days, today, hrvEpoch = epochOf("2026-01-11")) val hrvOf = { rows: List -> rows.first { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY }.baseline @@ -125,14 +142,18 @@ class RecoveryDriversUiTest { ) } - @Test fun anAbsentEpochLeavesTheBaselineExactlyAsItWas() { - // The common case: nobody who never recalibrated may see any change from this. - val days = scoredHistory() - val implicit = recoveryChargeDrivers(days, days.last()) - val explicitZero = recoveryChargeDrivers(days, days.last(), hrvBaselineEpoch = 0.0) - assertEquals(implicit.map { it.label }, explicitZero.map { it.label }) - assertEquals(implicit.map { it.baseline }, explicitZero.map { it.baseline }) - assertEquals(implicit.map { it.deltaPoints }, explicitZero.map { it.deltaPoints }) + /** #2525: once the own nights are trusted on their own, the import no longer takes part in the baseline + * the headline is scored against, so the rows must not move with it either. Before #2525 the rows folded + * every imported night and could read a baseline the score never used. */ + @Test fun anImportTheHeadlineNoLongerUsesDoesNotMoveTheRows() { + val own = (1..20).map { day("2026-01-%02d".format(it), hrv = 70.0 + (it % 3)) } + val today = day("2026-01-21", hrv = 72.0, recovery = 70.0) + // Inside the window and on the same days: only the handoff can keep it out of the baseline. + val lowImport = (1..20).map { day("2026-01-%02d".format(it), hrv = 40.0).copy(deviceId = "my-whoop") } + val alone = recoveryChargeDrivers(resolved(own + today, today.day), today) + val withImport = recoveryChargeDrivers(resolved(own + today, today.day, imported = lowImport), today) + assertEquals(alone.map { it.baseline }, withImport.map { it.baseline }) + assertEquals(alone.map { it.deltaPoints }, withImport.map { it.deltaPoints }) } @Test fun theConfidenceTierUsesTheSameEpochAsTheDrivers() { @@ -144,8 +165,8 @@ class RecoveryDriversUiTest { val since = (21..22).map { day("2026-01-%02d".format(it), hrv = 60.0) } val today = day("2026-01-23", hrv = 61.0, recovery = 66.0) val days = old + since + today - val whole = chargeConfidenceTier(days, today, hrvBaselineEpoch = 0.0) - val fromEpoch = chargeConfidenceTier(days, today, hrvBaselineEpoch = epochOf("2026-01-21")) + val whole = tier(days, today, hrvEpoch = 0.0) + val fromEpoch = tier(days, today, hrvEpoch = epochOf("2026-01-21")) assertEquals(ScoreConfidence.CALIBRATING, fromEpoch) assertFalse("the whole-history fold is what made these disagree", whole == fromEpoch) }