From 55aa7420ecdc80d4a3a16268e6c2cd334091ff42 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 18 Sep 2026 19:24:42 +1200 Subject: [PATCH 1/3] fix(charge): score the breakdown against the baseline the ring rides The Charge page could show two different HRV baselines at once: one at the top from the engine, another on the "What shaped it" rows below it. Reported as 57 against 48 on one screen. Three folds of the same history disagreed because one argument was missing: TodayScoring.kt:41 foldHistory(..., hrvCfg, hrvBaselineEpoch) headline TodayScoring.kt:70 foldHistory(..., hrvCfg) driver rows ChargeBreakdownWiring.swift:38 the same, on Apple `noop.hrvBaselineEpoch` is written by Settings, Recalibrate HRV baseline. The engine discards every night dated before it; these folds kept the whole history. Until someone recalibrates the two agree, which is why this survived: the existing comment says as much, calling the whole-history fold "byte-identical to the engine's when no manual Recalibrate epoch is set, the common case". It is the uncommon case that shows two numbers for one metric. `chargeConfidenceTier` had the same gap and is fixed with it. That one reads ScoreConfidence off the baseline, so a whole-history fold could badge a scored day from a history the ring is not using. Both signatures take the epoch with a default of 0, and both foldHistory overloads delegate to the plain fold at <= 0, so a user who never recalibrated gets byte-identical behaviour. Each platform has a test asserting exactly that: labels, baselines and deltaPoints all unchanged when the epoch is absent. WHAT THIS DOES NOT SETTLE. Whether it resolves the report depends on whether that wearer has ever recalibrated, which is unanswered. If they have not, their epoch was already 0, these folds were already identical, and their 57 against 48 has another cause. Asked on the issue; the fix stands on its own either way, because the rows should score against the baseline the headline scores against. Verified: Android 6256 tests / 0 failures on a forced full run, doc lint clean. The Swift half is app-target and compiles in no local package, so it is parse-checked here and first type-checked by CI. --- Strand/Screens/ChargeBreakdownWiring.swift | 10 +++- Strand/Screens/CoupledView.swift | 3 +- Strand/Screens/TodayView.swift | 3 +- StrandTests/ChargeBreakdownWiringTests.swift | 44 +++++++++++++++ .../src/main/java/com/noop/ui/TodayScoring.kt | 18 +++++-- .../src/main/java/com/noop/ui/TodayScreen.kt | 8 ++- .../java/com/noop/ui/RecoveryDriversUiTest.kt | 54 +++++++++++++++++++ 7 files changed, 131 insertions(+), 9 deletions(-) diff --git a/Strand/Screens/ChargeBreakdownWiring.swift b/Strand/Screens/ChargeBreakdownWiring.swift index a5bfcb38b5..cc5eab7866 100644 --- a/Strand/Screens/ChargeBreakdownWiring.swift +++ b/Strand/Screens/ChargeBreakdownWiring.swift @@ -31,11 +31,17 @@ enum ChargeBreakdownWiring { /// reader looks first. static func breakdown(days: [DailyMetric], row: DailyMetric, - sleepPerfPercent: Double?) -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { + sleepPerfPercent: Double?, + hrvBaselineEpoch: Double = 0) -> (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. - let hrvBase = Baselines.foldHistory(days.map(\.avgHrv), cfg: Baselines.hrvCfg) + // #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) guard hrvBase.usable else { return nil } let rhrBase = Baselines.foldHistory(days.map { $0.restingHr.map(Double.init) }, cfg: Baselines.restingHRCfg) diff --git a/Strand/Screens/CoupledView.swift b/Strand/Screens/CoupledView.swift index a0575d43f5..2560118c7e 100644 --- a/Strand/Screens/CoupledView.swift +++ b/Strand/Screens/CoupledView.swift @@ -535,7 +535,8 @@ struct CoupledView: View { /// the open sheet; one call now folds each series exactly once, guards before any fold. private func chargeBreakdown() -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { guard let row = breakdownRow else { return nil } - return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: sleepPerformance) + return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: sleepPerformance, + hrvBaselineEpoch: Baselines.hrvBaselineEpoch()) } @ViewBuilder diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index dc67eb45b2..739c882b86 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -832,7 +832,8 @@ struct TodayView: View { /// 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) + return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: restScore, + hrvBaselineEpoch: Baselines.hrvBaselineEpoch()) } /// The night's relative skin-temp marker for the displayed row (A5), or nil. Surfaced verbatim from diff --git a/StrandTests/ChargeBreakdownWiringTests.swift b/StrandTests/ChargeBreakdownWiringTests.swift index 2ee14cd409..ba77913614 100644 --- a/StrandTests/ChargeBreakdownWiringTests.swift +++ b/StrandTests/ChargeBreakdownWiringTests.swift @@ -62,4 +62,48 @@ final class ChargeBreakdownWiringTests: XCTestCase { XCTAssertTrue(labels.contains("Resting heart rate"), "\(labels)") XCTAssertNotEqual(out?.confidence, .calibrating) } + + /// Epoch for a `yyyy-MM-dd` key at UTC midnight, matching what Recalibrate writes. + private func epochOf(_ day: String) -> Double { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(secondsFromGMT: 0)! + let parts = day.split(separator: "-").compactMap { Int($0) } + let date = cal.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2]))! + return date.timeIntervalSince1970 + } + + func testDriverBaselineHonoursTheRecalibrationEpoch() { + // The reported shape: low-HRV history, Recalibrate, then higher nights. Folding the WHOLE history + // gives one baseline and folding from the epoch gives another, and the headline uses the second. + // These rows have to agree with the headline rather than with history. + let old = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 40) } + let since = (11...20).map { day(String(format: "2026-01-%02d", $0), hrv: 70) } + 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 hrvBaseline: ([ChargeDriver]) -> Double? = { rows in + rows.first { $0.label == .heartRateVariability }?.baseline + } + guard let w = whole.map({ hrvBaseline($0.drivers) }) ?? nil, + let r = fromEpoch.map({ hrvBaseline($0.drivers) }) ?? nil else { + return XCTFail("both folds must produce an HRV row") + } + XCTAssertGreaterThan(r, w, "the epoch fold must discard the pre-recalibration nights") + } + + 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(\.baseline), explicitZero?.drivers.map(\.baseline)) + XCTAssertEqual(implicit?.drivers.map(\.deltaPoints), explicitZero?.drivers.map(\.deltaPoints)) + } } 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 0365a2a7d7..e92816d921 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScoring.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScoring.kt @@ -60,6 +60,7 @@ internal fun recoveryCalibrationNights( internal fun recoveryChargeDrivers( days: List, displayDay: DailyMetric?, + hrvBaselineEpoch: Double = 0.0, ): List { val day = displayDay ?: return emptyList() val hrv = day.avgHrv ?: return emptyList() @@ -67,7 +68,13 @@ internal fun recoveryChargeDrivers( // Whole-history fold (oldest first), exactly as the engine seeds baselines2. val ordered = days.sortedBy { it.day } - val hrvBase = Baselines.foldHistory(ordered.map { it.avgHrv }, Baselines.hrvCfg) + // #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, + ) 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. @@ -100,9 +107,14 @@ internal fun recoveryChargeDrivers( internal fun chargeConfidenceTier( days: List, displayDay: DailyMetric?, + hrvBaselineEpoch: Double = 0.0, ): ScoreConfidence { - val hrvBase: BaselineState = - Baselines.foldHistory(days.sortedBy { it.day }.map { it.avgHrv }, Baselines.hrvCfg) + // #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, + ) 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 50c8ac8b63..9b89727650 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -5353,13 +5353,17 @@ private fun RecoveryDriversSection( 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. val readDay = carriedDay ?: displayDay - val drivers = remember(days, readDay) { recoveryChargeDrivers(days, readDay) } + val drivers = remember(days, readDay, hrvEpoch) { recoveryChargeDrivers(days, readDay, hrvEpoch) } if (drivers.isEmpty()) return - val tier = remember(days, readDay) { chargeConfidenceTier(days, readDay) } + val tier = remember(days, readDay, hrvEpoch) { chargeConfidenceTier(days, readDay, hrvEpoch) } 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/RecoveryDriversUiTest.kt b/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt index b140a547b6..d0768f3908 100644 --- a/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt +++ b/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt @@ -95,4 +95,58 @@ class RecoveryDriversUiTest { assertFalse("no usable resting-HR baseline, so no RHR row: got $labels", labels.contains(ChargeDriverLabel.RESTING_HEART_RATE)) } + + /** Epoch for a `yyyy-MM-dd` day key, UTC midnight, matching what Recalibrate writes. */ + private fun epochOf(day: String): Double = + java.time.LocalDate.parse(day).atStartOfDay(java.time.ZoneOffset.UTC).toEpochSecond().toDouble() + + @Test fun driverBaselineHonoursTheRecalibrationEpoch() { + // #2315: the reported shape. A history of low-HRV nights, then Recalibrate, then higher nights. + // Folding the WHOLE history gives one baseline and folding from the epoch gives another, and the + // headline uses the second. These rows must agree with the headline rather than with history. + val old = (1..10).map { day("2026-01-%02d".format(it), hrv = 40.0) } + val since = (11..20).map { day("2026-01-%02d".format(it), hrv = 70.0) } + 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 hrvOf = { rows: List -> + rows.first { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY }.baseline + } + val whole = hrvOf(wholeHistory) + val recent = hrvOf(fromEpoch) + assertTrue("both folds must produce an HRV row", whole != null && recent != null) + assertTrue( + "the epoch fold must discard the pre-recalibration nights, so its baseline sits higher " + + "(whole=$whole, fromEpoch=$recent)", + recent!! > whole!!, + ) + } + + @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 }) + } + + @Test fun theConfidenceTierUsesTheSameEpochAsTheDrivers() { + // The tier is read off the baseline the ring rides. A post-Recalibrate history that is still + // seeding must not be badged from a whole-history fold that looks fully trusted. + // Two nights since Recalibrate plus today is three, under the four-night seed gate, so the + // post-epoch baseline is not yet usable while the whole-history one looks fully trusted. + val old = (1..20).map { day("2026-01-%02d".format(it), hrv = 45.0) } + 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")) + assertEquals(ScoreConfidence.CALIBRATING, fromEpoch) + assertFalse("the whole-history fold is what made these disagree", whole == fromEpoch) + } } From 572b24491bca3db14ee12e644c65157918bded15 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 18 Sep 2026 19:27:17 +1200 Subject: [PATCH 2/3] fix(charge): honour the epoch in the watch score-state gate too A fourth fold, found re-reading the change rather than by any tool. `TodayView.watchScoreState` derives ScoreConfidence for the watch pill and its comment says: Same HRV-baseline gate the Charge engine uses It was not. It folded the whole history while the engine folds from the recalibration epoch, so after a Recalibrate the pill could read solid off nights the ring has stopped using. The comment is now true. Left alone deliberately: `WatchRecovery` on both platforms takes a value-only history with no day keys, so an epoch cannot be applied there without changing the shape of its input, and `AppModel`'s cycle-phase fold z-scores nights for a different feature, where honouring a recalibration is a behaviour decision rather than a correctness one. Verified: doc lint clean. App-target, so parse-checked here and type-checked by CI. --- Strand/Screens/TodayView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 739c882b86..27d2551200 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -1035,7 +1035,12 @@ struct TodayView: View { switch metricKey { case "recovery": // Same HRV-baseline gate the Charge engine uses, fed by the loaded nightly SDNN history. - let hrvBase = Baselines.foldHistory(repo.days.map(\.avgHrv), cfg: Baselines.hrvCfg) + // #2315: with the recalibration epoch, which is what makes the claim above true. Without it this + // gate folded the whole history while the Charge engine folded from the epoch, so the pill could + // read solid off nights the ring is no longer using. + let hrvBase = Baselines.foldHistory(repo.days.map(\.avgHrv), dayKeys: repo.days.map(\.day), + cfg: Baselines.hrvCfg, + baselineEpoch: Baselines.hrvBaselineEpoch()) conf = ScoreConfidence.charge(recovery: displayDay?.recovery, hrvBaseline: hrvBase) case "sleep_performance": // A watch night with a Rest score reads as built; without one it's still calibrating. From e04e3caec2b755d5607da188b39c48440511e6b4 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 18 Sep 2026 21:37:03 +1200 Subject: [PATCH 3/3] test(charge): write the Swift assertions against the real ChargeDriver The Swift tests were written against the Kotlin type. They do not share a shape: Kotlin's ChargeDriver carries a `ChargeDriverLabel` enum and a numeric `baseline`, Swift's carries a `label` String and a pre-formatted `baselineText`, because that side formats for display inside the driver. So `$0.label == .heartRateVariability` and `.baseline` compiled nowhere, and `swiftc -parse` accepted all of it: both are type errors, not syntax. This is the app target, which builds in no local package here, so CI's macOS leg was the first thing that could say so, and it did. Rewritten to match: the label is matched as the string it is, and the assertion is that the two folds report DIFFERENT baselineText rather than an ordering between numbers that side does not expose. Different text is the observable proof that a different history was folded, which is the property under test. --- StrandTests/ChargeBreakdownWiringTests.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/StrandTests/ChargeBreakdownWiringTests.swift b/StrandTests/ChargeBreakdownWiringTests.swift index ba77913614..fb038774ea 100644 --- a/StrandTests/ChargeBreakdownWiringTests.swift +++ b/StrandTests/ChargeBreakdownWiringTests.swift @@ -76,6 +76,10 @@ final class ChargeBreakdownWiringTests: XCTestCase { // The reported shape: low-HRV history, Recalibrate, then higher nights. Folding the WHOLE history // gives one baseline and folding from the epoch gives another, and the headline uses the second. // These rows have to agree with the headline rather than with history. + // + // Asserted on `baselineText` because that is what a Swift ChargeDriver carries: it pre-formats for + // display where the Kotlin twin keeps a numeric `baseline`. Different text is the observable proof + // that a different history was folded. let old = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 40) } let since = (11...20).map { day(String(format: "2026-01-%02d", $0), hrv: 70) } let today = day("2026-01-21", hrv: 72, recovery: 70) @@ -85,14 +89,16 @@ final class ChargeBreakdownWiringTests: XCTestCase { hrvBaselineEpoch: 0) let fromEpoch = ChargeBreakdownWiring.breakdown(days: days, row: today, sleepPerfPercent: 85, hrvBaselineEpoch: epochOf("2026-01-11")) - let hrvBaseline: ([ChargeDriver]) -> Double? = { rows in - rows.first { $0.label == .heartRateVariability }?.baseline + let hrvRow: ((drivers: [ChargeDriver], confidence: ScoreConfidence)?) -> ChargeDriver? = { out in + out?.drivers.first(where: { $0.label == "Heart rate variability" }) } - guard let w = whole.map({ hrvBaseline($0.drivers) }) ?? nil, - let r = fromEpoch.map({ hrvBaseline($0.drivers) }) ?? nil else { + guard let w = hrvRow(whole), let r = hrvRow(fromEpoch) else { return XCTFail("both folds must produce an HRV row") } - XCTAssertGreaterThan(r, w, "the epoch fold must discard the pre-recalibration nights") + XCTAssertNotEqual(w.baselineText, r.baselineText, + "the epoch fold must discard the pre-recalibration nights, so the baseline it " + + "reports differs from the whole-history one (whole=\(w.baselineText), " + + "fromEpoch=\(r.baselineText))") } func testAnAbsentEpochLeavesTheBaselineExactlyAsItWas() { @@ -103,7 +109,7 @@ final class ChargeBreakdownWiringTests: XCTestCase { 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(\.baseline), explicitZero?.drivers.map(\.baseline)) + XCTAssertEqual(implicit?.drivers.map(\.baselineText), explicitZero?.drivers.map(\.baselineText)) XCTAssertEqual(implicit?.drivers.map(\.deltaPoints), explicitZero?.drivers.map(\.deltaPoints)) } }