From a9240e72669fd8313b1795cea1fc08f4e5efec09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Sch=C3=A4dlich?= Date: Fri, 10 Jul 2026 11:04:02 +0200 Subject: [PATCH] Fix #201: re-fold the HRV baseline on a window switch instead of restarting calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching the HRV window (Whole-night ↔ Deep-sleep) re-anchored the HRV baseline epoch to now, which drops all history and restarts the ≥4-night calibration — so a user with plenty of nights fell back to "calibrating" for several nights and Charge went nil. That read as "the deep-sleep setting is broken" (#195). analyzeRecent already re-scores the recent ~21 nights' avgHrv under the new window AND re-folds the HRV baseline from them in the same pass, and the baseline's 14-night-half-life EWMA is dominated by that fresh re-scored tail. So the epoch reset is unnecessary: drop it from both window-switch handlers (SettingsView / SettingsScreen), keep the forced re-score. An established user (≥ minNightsSeed valid nights) now sees the switch take effect immediately; a genuine cold-start user still calibrates honestly. The manual "Recalibrate Charge baseline" button is untouched — it still resets both epochs on purpose. Updates the picker copy + Android toast to match, and adds a pure fold regression test on both platforms (Swift BaselinesTests + Android HrvBaselineRecalibrationTest) pinning that re-scored new-window nights fold to a usable baseline WITHOUT an epoch reset. Verification: swift test (StrandAnalytics BaselinesTests, 22/22) green; Strand macOS app target compiles (app-build CI is disabled). Android JVM test mirrors the Swift one but wasn't run locally (no Android SDK). HRV baseline behaviour across several nights still needs on-device confirmation, per the issue. Co-Authored-By: Claude Opus 4.8 --- .../StrandAnalyticsTests/BaselinesTests.swift | 26 +++++++++++++++++ Strand/Screens/SettingsView.swift | 16 ++++++----- .../main/java/com/noop/ui/SettingsScreen.kt | 22 +++++++-------- .../analytics/HrvBaselineRecalibrationTest.kt | 28 +++++++++++++++++++ 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesTests.swift index 3e87dcc822..6a88455110 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesTests.swift @@ -205,6 +205,32 @@ final class BaselinesTests: XCTestCase { XCTAssertEqual(s.status, .calibrating) } + // MARK: - #201: HRV window switch re-folds instead of restarting calibration + + /// #201: switching the HRV window re-scores the recent nights under the new window and folds the + /// baseline from them WITHOUT re-anchoring the epoch. An established user (≥ minNightsSeed valid + /// nights) therefore keeps a usable baseline centered on the new (here lower, deep-window) values + /// immediately — not a multi-night calibrating reset. Contrast the epoch-reset path + /// (testRecalibrateEpochAfterAllNightsResetsToColdStart), the old window-switch behaviour that dropped + /// every night to cold-start and read as "the deep-sleep setting is broken" (#195). + func testWindowSwitchRefoldStaysUsableWithoutEpochReset() { + // 21 recent nights, all re-scored under the new deep window → the lower deep-only value. + let days = (1...21).map { String(format: "2026-06-%02d", $0) } + let deepVals: [Double?] = Array(repeating: 48.0, count: 21) + + // #201 path: fold with no recalibration epoch → usable, centered on the new deep value. + let refolded = Baselines.foldHistory(deepVals, dayKeys: days, cfg: Baselines.hrvCfg, baselineEpoch: 0) + XCTAssertTrue(refolded.usable) + XCTAssertEqual(refolded.baseline, 48.0, accuracy: 2.0) + + // Old behaviour: re-anchoring the epoch to "now" (after every re-scored night) drops them all → + // calibrating cold-start. + let reset = Baselines.foldHistory(deepVals, dayKeys: days, cfg: Baselines.hrvCfg, + baselineEpoch: 4_000_000_000) + XCTAssertFalse(reset.usable) + XCTAssertEqual(reset.status, .calibrating) + } + // MARK: - "Recalibrate Charge baseline" reset helper (noop.hrvBaselineEpoch + noop.recoveryBaselineEpoch) /// An isolated UserDefaults suite so these tests never touch the real app domain. diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index bc0a7c8d46..9273bb4738 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -906,16 +906,18 @@ struct SettingsView: View { .tint(StrandPalette.accent) .accessibilityLabel("HRV window") .onChangeCompat(of: hrvWindowRaw) { _ in - // The new window shifts every night's avgHrv, so the HRV BASELINE must re-learn or - // recovery would compare the new value against a baseline still folded from the old - // window (the EWMA spans further than the ~21 nights that re-score → skewed for weeks). - // Re-anchor the HRV baseline to now (HRV-only sibling of "Recalibrate Charge baseline"), - // then re-score + refresh so the recent trend + fresh baseline both reflect the window. - UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Baselines.hrvBaselineEpochKey) + // #201: the new window shifts every night's avgHrv, so the HRV baseline must reflect it + // too — but a plain re-score already achieves that. analyzeRecent re-scores the recent + // ~21 nights' avgHrv under the new window AND re-folds the HRV baseline from them in the + // same pass, and the baseline's 14-night-half-life EWMA is dominated by that fresh + // re-scored tail. So DON'T re-anchor the baseline epoch: doing so would drop all history + // and force a multi-night "calibrating" reset for someone who already has plenty of nights + // (that reset reading as "the setting is broken" was #195). A genuine cold-start user + // (<4 valid nights) still calibrates honestly; established users see the switch immediately. Task { await model.intelligence.analyzeRecent(); await model.repo.refresh() } } } - Text("Whole night is NOOP's default measure; Deep sleep pools HRV over slow-wave sleep only, reading lower and matching WHOOP. Switching re-scores recent nights and recalibrates Charge over a few nights.") + Text("Whole night is NOOP's default measure; Deep sleep pools HRV over slow-wave sleep only, reading lower and matching WHOOP. Switching re-scores your recent nights over the new window and takes effect right away once you have a few nights of data.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 05f206456f..1c5fd1a5f8 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -1361,27 +1361,27 @@ fun SettingsScreen( onSelect = { hrvWindow = it UnitPrefs.setHrvWindow(context, it) - // The new window shifts every night's avgHrv, so the HRV BASELINE must re-learn or - // recovery would compare the new value against a baseline still folded from the old - // window (only the recent ~21 nights re-score, but the baseline EWMA spans further — - // it would read skewed for weeks). Re-anchor the HRV baseline to now (same key + - // mechanism as "Recalibrate Charge baseline"), then force a re-score so the recent - // trend + the fresh baseline both reflect the new window. A few nights to settle. - NoopPrefs.of(context).edit() - .putLong(Baselines.hrvBaselineEpochKey, System.currentTimeMillis() / 1000L) - .apply() + // #201: the new window shifts every night's avgHrv, so the HRV baseline must reflect + // it too — but a plain re-score already achieves that. analyzeRecent re-scores the + // recent ~21 nights' avgHrv under the new window AND re-folds the HRV baseline from + // them in the same pass, and the baseline's 14-night-half-life EWMA is dominated by + // that fresh re-scored tail. So DON'T re-anchor the baseline epoch: doing so would + // drop all history and force a multi-night "calibrating" reset for someone who already + // has plenty of nights (that reset reading as "the setting is broken" was #195). Clear + // the analyze watermark so the re-score runs even though the raw HR fingerprint is + // unchanged. A genuine cold-start user (<4 valid nights) still calibrates honestly. NoopPrefs.setAnalyzeWatermark(context, "") vm.syncNow() Toast.makeText( context, - "Re-learning your HRV over the ${if (it == HrvWindow.DEEP_SLEEP) "deep-sleep" else "whole-night"} window. Charge recalibrates over the next few nights.", + "Re-scoring your recent nights over the ${if (it == HrvWindow.DEEP_SLEEP) "deep-sleep" else "whole-night"} window. Charge updates as soon as it's done.", Toast.LENGTH_LONG, ).show() }, ) } Text( - "Whole night is NOOP's default measure; Deep sleep pools HRV over slow-wave sleep only, reading lower and matching WHOOP. Switching re-scores recent nights and recalibrates Charge over a few nights.", + "Whole night is NOOP's default measure; Deep sleep pools HRV over slow-wave sleep only, reading lower and matching WHOOP. Switching re-scores your recent nights over the new window and takes effect right away once you have a few nights of data.", style = NoopType.footnote, color = Palette.textTertiary, ) diff --git a/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt b/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt index e431475074..cc27cfc21a 100644 --- a/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt +++ b/android/app/src/test/java/com/noop/analytics/HrvBaselineRecalibrationTest.kt @@ -113,6 +113,34 @@ class HrvBaselineRecalibrationTest { assertEquals(BaselineStatus.CALIBRATING, s.status) } + // ── 2b. #201: HRV window switch re-folds instead of restarting calibration ─────────────────── + + /** + * #201: switching the HRV window re-scores the recent nights under the new window and folds the + * baseline from them WITHOUT re-anchoring the epoch. An established user (≥ minNightsSeed valid + * nights) keeps a usable baseline centered on the new (here lower, deep-window) values immediately — + * not a multi-night calibrating reset. Contrast [recalibrateEpoch_afterAllNights_resetsToColdStart], + * the old window-switch behaviour that dropped every night to cold-start and read as "the deep-sleep + * setting is broken" (#195). Parity twin of the Swift + * testWindowSwitchRefoldStaysUsableWithoutEpochReset. + */ + @Test + fun windowSwitchRefold_staysUsableWithoutEpochReset() { + // 21 recent nights, all re-scored under the new deep window → the lower deep-only value. + val days = (1..21).map { "2026-06-%02d".format(it) } + val deepVals: List = List(21) { 48.0 } + + // #201 path: fold with no recalibration epoch → usable, centered on the new deep value. + val refolded = Baselines.foldHistory(deepVals, days, hrvCfg, 0.0) + assertTrue("established user keeps a usable baseline after a window switch", refolded.usable) + assertEquals(48.0, refolded.baseline, 2.0) + + // Old behaviour: re-anchoring the epoch to "now" drops every re-scored night → calibrating. + val reset = Baselines.foldHistory(deepVals, days, hrvCfg, 4_000_000_000.0) + assertTrue("epoch reset drops all nights back to calibrating", !reset.usable) + assertEquals(BaselineStatus.CALIBRATING, reset.status) + } + // ── 3. "Recalibrate Charge baseline" reset helper (writes BOTH epoch keys) ─────────────────── /**