Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 11 additions & 11 deletions android/app/src/main/java/com/noop/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Double?> = 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) ───────────────────

/**
Expand Down
Loading