From 21e01b72e585e58e03fd481f243a80e1911dc4e4 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:58:31 +0200 Subject: [PATCH 1/3] fix(charge): add the rule for which nights a Charge baseline reads Refs #2525. The Charge baselines were folded from the whole imported vendor history plus only the nights of the current scan window, so an import kept about a third of the weight for good (0.5^(21/14) with the 14-night half-life), however long ago it ended. ChargeBaselines (StrandAnalytics, with its Kotlin twin) states the corrected rule as one pure function: - only nights inside a 21-calendar-day window ending on the anchor day count; 21 is the default scan window, so a wearer with no import folds exactly the nights the 21-day pass always folded; - imported nights only seed the baseline while the wearer's own nights alone would not be trusted (minNightsTrust valid nights after the epoch), then drop out entirely; - while seeding, the per-day precedence is the existing one: an imported value wins, an own night fills a day the import leaves open. A bug fix, not a new scoring method. Both suites assert the same oracle literal over a deterministic spread of histories, produced by the Swift implementation. Wired into the engines and the dashboard in the next two commits. --- .../StrandAnalytics/ChargeBaselines.swift | 185 +++++++++ .../ChargeBaselinesTests.swift | 354 ++++++++++++++++++ .../com/noop/analytics/ChargeBaselines.kt | 194 ++++++++++ .../com/noop/analytics/ChargeBaselinesTest.kt | 322 ++++++++++++++++ 4 files changed, 1055 insertions(+) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeBaselines.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ChargeBaselinesTests.swift create mode 100644 android/app/src/main/java/com/noop/analytics/ChargeBaselines.kt create mode 100644 android/app/src/test/java/com/noop/analytics/ChargeBaselinesTest.kt 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.., + /** 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/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() +} From a21d5e1ee1d2bb7d89cc30b7c15943ad66a3d86c Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:58:38 +0200 Subject: [PATCH 2/3] fix(charge): fold the engine's Charge baselines through ChargeBaselines Refs #2525. Pass 2 of analyzeRecent now builds the HRV, resting-HR, respiration and skin-temperature histories with ChargeBaselines.history on both platforms, anchored on the pass's local today. The own nights are the pass's fresh values; the window trims a full-history repair pass to the same 21 days as the normal tick. Recalibration epochs and the respiration device-era cut apply as before. A Recovery Test Centre line per pass names what each baseline was folded from (own nights, or own nights still seeded by the import). mergeNightlyIntoHistory is removed on both platforms, since it would be reached only from tests; its precedence cases now live in the ChargeBaselines suites. The respiration era comment no longer claims that hist holds the days this engine scored: those are written under computedId. Removing the Kotlin helper reattaches a detached doc comment, so its doc_comment_lint count drops from 5 to 4. --- Strand/Data/IntelligenceEngine.swift | 164 ++++++++--------- .../IntelligenceBaselineShadowTests.swift | 78 -------- Tools/doc_comment_lint_baseline.txt | 2 +- .../com/noop/analytics/IntelligenceEngine.kt | 168 ++++++++---------- .../IntelligenceBaselineShadowTest.kt | 91 ---------- 5 files changed, 145 insertions(+), 358 deletions(-) delete mode 100644 StrandTests/IntelligenceBaselineShadowTests.swift delete mode 100644 android/app/src/test/java/com/noop/analytics/IntelligenceBaselineShadowTest.kt diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index e792de6c44..f7a8b6bef6 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -1968,93 +1968,93 @@ final class IntelligenceEngine: ObservableObject { } markPostLoopPhase("dayReplay") - // ── Seed the baseline from the UNION of imported nightly history + the values just computed. - // THIS is the BLE-only recovery fix: the "-noop" nightly avgHrv/restingHr finally feed the - // baseline so a strap-only user crosses Baselines.minNightsSeed and recovery lights up. - // IMPORTED values win per day: write them first, then fill ONLY days the import doesn't cover - // (Swift has no putIfAbsent , `dict[day] == nil` is true only when the KEY is absent, so a day - // imported with a nil avgHrv stays imported, not overwritten by the computed value). - var histHrvByDay: [String: Double?] = [:] - var histRhrByDay: [String: Double?] = [:] - var histRespByDay: [String: Double?] = [:] - for d in hist { - histHrvByDay[d.day] = d.avgHrv - histRhrByDay[d.day] = d.restingHr.map(Double.init) - histRespByDay[d.day] = d.respRateBpm - } - Self.mergeNightlyIntoHistory(&histHrvByDay, nightlyHrvByDay) - Self.mergeNightlyIntoHistory(&histRhrByDay, nightlyRhrByDay) - Self.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 BLE-only recovery fix: the "-noop" nightly avgHrv/restingHr feed the baseline so a + // strap-only user crosses Baselines.minNightsSeed 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 fresh values + // (a full-history repair pass scores more days; the window trims it to the same 21), and a wearer + // with no import folds the same nights as before. + let oldestDay = AnalyticsEngine.dayString(nowLocalMidnight - (maxDays - 1) * 86_400, + offsetSec: tzOffset) + let newestDay = AnalyticsEngine.dayString(nowLocalMidnight, offsetSec: tzOffset) + func importedNights(_ value: (DailyMetric) -> 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) @@ -2063,8 +2063,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) @@ -2187,9 +2189,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, @@ -3589,22 +3588,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/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/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/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) - } -} From 6319bd4f803ae9ff731272d12069ce9976e7fafc Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:58:47 +0200 Subject: [PATCH 3/3] fix(charge): read the headline's baselines in the Charge breakdown Refs #2525. The "What shaped it" rows, the "Calibrating N of 4" count and the Charge confidence tier folded repo.days themselves: every imported and every stored own night, and the resting-HR row without the recovery epoch. That is a different history than the one the Charge headline was scored against. They now read one resolver per platform, Repository.chargeBaselines and AppViewModel.chargeBaselines, which applies ChargeBaselines.resolve to the imported and own rows before the dashboard merge blends them. Android gains WhoopRepository.importedDailyUnionFlow, the imported twin of computedDailyUnionFlow, for that. Parity ledger refreshed against upstream/main; importedDailyUnionFlow is recorded as an out_of_scope_twin of Repository.refresh. --- Strand/Data/Repository.swift | 21 ++++++ Strand/Liquid/LiquidTodayView.swift | 4 +- Strand/Screens/ChargeBreakdownWiring.swift | 35 +++++---- Strand/Screens/CoupledView.swift | 20 +++--- Strand/Screens/TodayView.swift | 23 +++--- StrandTests/ChargeBreakdownWiringTests.swift | 45 +++++++----- Tools/parity_dispositions.json | 9 +++ Tools/parity_twin_map.json | 14 ++-- .../java/com/noop/data/WhoopRepository.kt | 6 ++ .../src/main/java/com/noop/ui/AppViewModel.kt | 35 +++++++++ .../main/java/com/noop/ui/CoupledScreen.kt | 16 +++-- .../src/main/java/com/noop/ui/TodayScoring.kt | 71 ++++++++----------- .../src/main/java/com/noop/ui/TodayScreen.kt | 26 ++++--- .../com/noop/ui/RecoveryCalibrationTest.kt | 11 +-- .../java/com/noop/ui/RecoveryDriversUiTest.kt | 71 ++++++++++++------- 15 files changed, 255 insertions(+), 152 deletions(-) diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index ebc81720b0..cb4ec3a7fb 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -182,6 +182,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. @@ -846,6 +852,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 @@ -954,6 +961,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) @@ -981,6 +995,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, @@ -997,6 +1016,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 @@ -1007,6 +1027,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 80948dec8c..01ade35781 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -1652,8 +1652,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 43f3a850ea..3d59e9e50d 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/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index fab146f350..407341f626 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -291,6 +291,15 @@ "identity_sha256": "28fa51ec52e1a418a0c8bd500cceb13c529cf8dc7e8bbe290003fe97602e6dff", "platform": "kotlin", "rationale": "Formats the reading's day for the Settings caption of the Health Connect weight sync. Apple platforms have no Health Connect, so the opt-in 'Use weight from Health Connect' profile sync exists only on Android and there is no Swift counterpart to pair with, now or later." + }, + { + "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 f059364994..f462057855 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,16 +18,16 @@ ] }, "authority": { - "files": {"count": 508, "sha256": "1524b35bf2905f5541a3557cd1b078278ae8c5f03975eef0d0a78bf8f4f4d1b5"}, - "functions": {"count": 4528, "sha256": "152b26b419936b5dd946f3ec3706a6c82761d5177631d1806a50059b9057a376"}, + "files": {"count": 510, "sha256": "b1c73338e79009d984d1313d8b3166e52672712c68074f5fee190c503e299b18"}, + "functions": {"count": 4534, "sha256": "91a1670dfe86761a907dc0d1fa73b219e9ca8b5b6d90c36310db21c1657dc884"}, "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, - "constants": {"count": 1989, "sha256": "8e6fc271a9c1135d358d1b1b6754b11991a58ff2dfd09aa0b22479ee2e8f7d41"}, - "file_pairs": {"count": 79, "sha256": "02ee127080cf87c98925a60b30b6e87d25e7a82ef6cf43b6251fe24c20600dd1"}, - "function_pairs": {"count": 221, "sha256": "7147e16d7f96771120b96a6c36691ff382a5e03096f99c884c475887b4c6425d"}, + "constants": {"count": 1991, "sha256": "ab5fad0319f0ee260993445c0388fbcd89801491c316977095e01014888f9228"}, + "file_pairs": {"count": 80, "sha256": "eeeb9e0f4adabed6e6c3c5886e980ea3a3656b65b5bd2a9b3b9e43b5ea3b051c"}, + "function_pairs": {"count": 224, "sha256": "af7d03c31dc065970bbefee6d86f549137dd3acbcb698017df57f63f436098b6"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, - "constant_pairs": {"count": 696, "sha256": "93a9ab9817fa3a535bcd815ac1de0782d2cfd32fca6aea9599c4d7a0d0627f20"}, + "constant_pairs": {"count": 697, "sha256": "1f02bdcb642db9d6b24a4cdcbe1351fefbf5ec269830900eba0df49104745b5a"}, "unpaired_files": {"count": 372, "sha256": "37aa705af1be255d3d18adfe912b93bcfeaa3760e1513412aad81388a4c1410b"}, - "unpaired_functions": {"count": 4099, "sha256": "0c2152bbd8664256ef65f2e76676395bf3fb72d9c003be3cc4189e20270195ef"}, + "unpaired_functions": {"count": 4099, "sha256": "3ee3e74d901e17225e294c309c1b00a7a5b178bb3164bff43eaaa5309d574169"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, "unpaired_constants": {"count": 597, "sha256": "578c607cc05b1cd1f867c206c9fab70237ac40707cd49d4e0c5cc66b400184c7"} } 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 34a55f1c5a..a62b4680c5 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -807,6 +807,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 48f1d5ec1d..d95c5a482a 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 9607dd7619..13e7e1d93f 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 @@ -1065,7 +1069,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 } @@ -2002,6 +2009,7 @@ fun TodayScreen( ) { ChargeBreakdownSheet( days = days, + chargeBaselines = chargeBaselines, displayDay = displayMetric, carriedDay = lastScoredRecoveryDay, showReadiness = selectedDayOffset == 0, @@ -5309,6 +5317,7 @@ private fun TodayLayoutEditorDialog( @Composable internal fun ChargeBreakdownSheet( days: List, + chargeBaselines: ChargeBaselines.Resolved?, displayDay: DailyMetric?, carriedDay: DailyMetric?, showReadiness: Boolean, @@ -5352,7 +5361,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. @@ -5465,21 +5474,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/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) }