Skip to content
Merged
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 @@ -21,8 +21,8 @@ import Foundation
// dominant driver either way.
//
// The hard honesty rule: we return nil recovery + `.calibrating` when today's SDNN is
// missing, OR the SDNN baseline isn't usable yet, OR we have fewer than `minBaselineNights`
// nights of history. We NEVER fabricate a number to fill a sparse week. Confidence comes
// missing, OR the SDNN baseline isn't usable yet, OR the baseline has accepted fewer than
// `minBaselineNights` nights. We NEVER fabricate a number to fill a sparse week. Confidence comes
// straight from the existing `ScoreConfidence.charge(recovery:hrvBaseline:)`, so the watch
// "calibrating → building → solid" arc is the same one the strap uses.
public enum WatchRecovery {
Expand All @@ -41,7 +41,8 @@ public enum WatchRecovery {
}
}

/// Minimum nights of SDNN history before we'll score recovery from the watch. The spec's
/// Minimum VALID nights (nights the baseline accepted, `BaselineState.nValid`) before we'll score
/// recovery from the watch — not raw history entries. The spec's
/// honesty stance is to keep recovery "calibrating" for about a week of nights rather than
/// ship a misleading number off a thin baseline. This sits ABOVE the baseline's own seed
/// gate (`Baselines.minNightsSeed` = 4) deliberately: a strap user crosses the seed faster
Expand All @@ -52,7 +53,8 @@ public enum WatchRecovery {
///
/// - Parameters:
/// - todaySDNN: today's HRV SDNN reading (ms), or nil if the watch logged none.
/// - todayRHR: today's resting HR (bpm), or nil to drop the RHR term.
/// - todayRHR: today's resting HR (bpm), or nil to drop the RHR term. The term is also
/// dropped when `rhrHistory` has not yet produced a usable RHR baseline.
/// - sdnnHistory: ordered nightly SDNN values (oldest → newest), the baseline input.
/// - rhrHistory: ordered nightly resting-HR values (oldest → newest).
/// - Returns: a `Result` with recovery in [0,100] and a confidence tier, or nil recovery +
Expand All @@ -71,21 +73,29 @@ public enum WatchRecovery {

// Honesty gate: no number unless we have today's SDNN, a usable baseline, AND at least a
// week of nights. Any miss → nil recovery + calibrating, never a fabricated value.
// The week is counted in nights the baseline ACCEPTED (`nValid`), not in raw history entries:
// a physiologically implausible reading is skip-and-held by `Baselines.update` and contributes
// nothing to the baseline, so counting it would let rejected values buy a score a week early.
guard let sdnn = todaySDNN,
hrvBase.usable,
sdnnHistory.count >= minBaselineNights else {
hrvBase.nValid >= minBaselineNights else {
return Result(recovery: nil, confidence: .calibrating)
}

// Reuse the canonical Charge engine. Drop the resp / sleep / skin-temp terms (the watch
// daily aggregate doesn't carry them here) — RecoveryScorer renormalises to HRV + RHR.
// RHR is optional: when the watch logged no resting HR today we pass the HRV-only path.
// RHR is optional: the term needs BOTH today's reading AND a usable personal RHR baseline.
// An empty or all-implausible RHR history folds to `foldHistory`'s synthetic midpoint (the
// config's min/max mean, e.g. 75 bpm), which is nobody's resting HR — scoring against it would
// move Charge on a fabricated baseline. Same `usable ? state : nil` gate the strap Charge
// surfaces already apply (TodayView / CoupledView); without a usable RHR baseline we fall back
// to the documented HRV-only path, exactly as when today's reading is missing.
let recovery = RecoveryScorer.recovery(
hrv: sdnn,
rhr: todayRHR.map(Double.init) ?? rhrBase.baseline, // missing RHR → at-baseline (z≈0, neutral term)
resp: nil,
hrvBaseline: hrvBase,
rhrBaseline: todayRHR != nil ? rhrBase : nil, // drop the RHR term entirely if no reading
rhrBaseline: (todayRHR != nil && rhrBase.usable) ? rhrBase : nil,
respBaseline: nil,
sleepPerf: nil
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,89 @@ final class WatchRecoveryTests: XCTestCase {
XCTAssertLessThanOrEqual(out.recovery!, 70)
}

// MARK: - Week gate counts ACCEPTED nights, not raw entries (fork issue #62)

// Seven RAW history entries of which only four are physiologically valid must NOT clear the
// week gate: `minBaselineNights` means nights the baseline ACCEPTED (`nValid`), so the rejected
// -1 / 0 / 999 readings can't buy a score a week early.
func testSevenRawNightsWithFourValidCalibrates() {
let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil,
sdnnHistory: [45, 46, 47, 48, -1, 0, 999], rhrHistory: [])
XCTAssertNil(out.recovery)
XCTAssertEqual(out.confidence, .calibrating)
}

// Seven raw entries of which only three are valid: below the baseline's own seed gate too.
func testSevenRawNightsWithThreeValidCalibrates() {
let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil,
sdnnHistory: [45, 46, 47, -1, 0, 999, 1000], rhrHistory: [])
XCTAssertNil(out.recovery)
XCTAssertEqual(out.confidence, .calibrating)
}

// Six raw entries, all six valid → still one accepted night short of the gate.
func testSixRawNightsWithSixValidCalibrates() {
let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil,
sdnnHistory: [45, 46, 47, 48, 45, 46], rhrHistory: [])
XCTAssertNil(out.recovery)
XCTAssertEqual(out.confidence, .calibrating)
}

// Nine raw entries of which exactly seven are valid → the gate is met by accepted nights.
func testNineRawNightsWithSevenValidScores() {
let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil,
sdnnHistory: [45, 46, 47, 48, 45, 46, 47, -1, 999],
rhrHistory: [])
XCTAssertNotNil(out.recovery)
XCTAssertNotEqual(out.confidence, .calibrating)
}

// MARK: - RHR term needs a USABLE RHR baseline (fork issue #61)

// An empty RHR history yields `foldHistory`'s synthetic midpoint baseline (75 bpm), which must
// never score today's reading: with no usable RHR baseline the result is the documented
// HRV-only path, exactly as if today's RHR were missing.
func testEmptyRHRHistoryScoresLikeMissingRHR() {
let hist = Array(repeating: 45.0, count: 7)
let withRHR = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52,
sdnnHistory: hist, rhrHistory: [])
let hrvOnly = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil,
sdnnHistory: hist, rhrHistory: [])
XCTAssertNotNil(withRHR.recovery)
XCTAssertNotNil(hrvOnly.recovery)
XCTAssertEqual(withRHR.recovery!, hrvOnly.recovery!, accuracy: 1e-12)
// The same literal the Kotlin twin pins, so the oracle guards BOTH directions: a Swift-side
// drift would break here rather than silently diverging from Android.
XCTAssertEqual(withRHR.recovery!, 57.932425214874954, accuracy: 1e-12)
}

// An RHR history that is entirely out of physiological range accepts no night at all, so its
// baseline is the same synthetic midpoint — likewise dropped.
func testUnusableRHRHistoryScoresLikeMissingRHR() {
let hist = Array(repeating: 45.0, count: 7)
let junk = Array(repeating: 300.0, count: 7) // above restingHRCfg.maxVal (120)
let withRHR = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52,
sdnnHistory: hist, rhrHistory: junk)
let hrvOnly = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil,
sdnnHistory: hist, rhrHistory: junk)
XCTAssertNotNil(withRHR.recovery)
XCTAssertEqual(withRHR.recovery!, hrvOnly.recovery!, accuracy: 1e-12)
}

// Once the RHR baseline IS usable (≥ Baselines.minNightsSeed accepted nights) the term returns:
// a resting HR above baseline must pull the score below the HRV-only number.
func testUsableRHRHistoryStillContributes() {
let hist = Array(repeating: 45.0, count: 7)
let rhrHist = Array(repeating: 52.0, count: 4) // exactly the seed gate → provisional
let withRHR = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 62,
sdnnHistory: hist, rhrHistory: rhrHist)
let hrvOnly = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil,
sdnnHistory: hist, rhrHistory: rhrHist)
XCTAssertNotNil(withRHR.recovery)
XCTAssertNotNil(hrvOnly.recovery)
XCTAssertLessThan(withRHR.recovery!, hrvOnly.recovery! - 1.0)
}

// Watch recovery is on the SAME scale as strap recovery: feeding identical at-baseline inputs
// to RecoveryScorer directly (HRV + RHR terms only) reproduces WatchRecovery's number.
func testSameScaleAsStrapRecovery() {
Expand Down
24 changes: 17 additions & 7 deletions android/app/src/main/java/com/noop/analytics/WatchRecovery.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package com.noop.analytics
* here, so RecoveryScorer renormalises the remaining HRV + RHR weights. The HRV term stays dominant.
*
* Honesty rule: return null recovery + CALIBRATING when today's HRV is missing, OR the HRV baseline isn't
* usable yet, OR there are fewer than [minBaselineNights] nights of history. We NEVER fabricate a number to
* usable yet, OR the baseline has accepted fewer than [minBaselineNights] nights. We NEVER fabricate a number to
* fill a sparse week. Confidence comes from the existing [ScoreConfidence.forCharge].
*/
object WatchRecovery {
Expand All @@ -27,7 +27,8 @@ object WatchRecovery {
data class Result(val recovery: Double?, val confidence: ScoreConfidence)

/**
* Minimum nights of HRV history before we score recovery from a daily-aggregate source. Sits ABOVE the
* Minimum VALID nights (nights the baseline accepted, `BaselineState.nValid`) before we score recovery
* from a daily-aggregate source — not raw history entries. Sits ABOVE the
* baseline's own seed gate (4) deliberately: a strap user crosses the seed faster on dense data, but a
* sparse daily HRV deserves a longer warm-up before we trust it. Mirrors the Swift constant.
*/
Expand All @@ -37,7 +38,8 @@ object WatchRecovery {
* Compute recovery/Charge from a daily HRV + resting HR vs the person's own baseline.
*
* @param todayHrv today's HRV reading (ms), or null if the source logged none.
* @param todayRhr today's resting HR (bpm), or null to drop the RHR term.
* @param todayRhr today's resting HR (bpm), or null to drop the RHR term. The term is also dropped when
* [rhrHistory] has not yet produced a usable RHR baseline.
* @param hrvHistory ordered nightly HRV values (oldest -> newest), the baseline input.
* @param rhrHistory ordered nightly resting-HR values (oldest -> newest).
*/
Expand All @@ -58,19 +60,27 @@ object WatchRecovery {

// Honesty gate: no number unless we have today's HRV, a usable baseline, AND at least a week of
// nights. Any miss -> null recovery + calibrating, never a fabricated value.
if (todayHrv == null || !hrvBase.usable || hrvHistory.size < minBaselineNights) {
// The week is counted in nights the baseline ACCEPTED (nValid), not in raw history entries: a
// physiologically implausible reading is skip-and-held by [Baselines.update] and contributes
// nothing to the baseline, so counting it would let rejected values buy a score a week early.
if (todayHrv == null || !hrvBase.usable || hrvBase.nValid < minBaselineNights) {
return Result(recovery = null, confidence = ScoreConfidence.CALIBRATING)
}

// Reuse the canonical Charge engine. Drop the resp / sleep / skin-temp terms (the daily aggregate
// doesn't carry them here) -> RecoveryScorer renormalises to HRV + RHR. RHR is optional: a missing
// resting HR today passes the at-baseline value (z~0, neutral term) and drops the RHR term entirely.
// doesn't carry them here) -> RecoveryScorer renormalises to HRV + RHR. RHR is optional: the term
// needs BOTH today's reading AND a usable personal RHR baseline. An empty or all-implausible RHR
// history folds to foldHistory's synthetic midpoint (the config's min/max mean, e.g. 75 bpm), which
// is nobody's resting HR — scoring against it would move Charge on a fabricated baseline. Mirrors
// the `usable ? state : nil` gate the macOS Charge driver breakdown applies (Swift TodayView /
// CoupledView); without a usable RHR baseline we fall back to the HRV-only path, exactly as when
// today's reading is missing.
val recovery = RecoveryScorer.recovery(
hrv = todayHrv,
rhr = todayRhr?.toDouble() ?: rhrBase.baseline,
resp = null,
hrvBaseline = hrvBase,
rhrBaseline = if (todayRhr != null) rhrBase else null,
rhrBaseline = if (todayRhr != null && rhrBase.usable) rhrBase else null,
respBaseline = null,
sleepPerf = null,
) ?: return Result(recovery = null, confidence = ScoreConfidence.CALIBRATING)
Expand Down
108 changes: 108 additions & 0 deletions android/app/src/test/java/com/noop/analytics/WatchRecoveryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,112 @@ class WatchRecoveryTest {
assertNull(out.recovery)
assertEquals(ScoreConfidence.CALIBRATING, out.confidence)
}

// --- Week gate counts ACCEPTED nights, not raw entries (fork issue #62) ---

/**
* Seven RAW history entries of which only four are physiologically valid must NOT clear the week gate:
* [WatchRecovery.minBaselineNights] means nights the baseline ACCEPTED (nValid), so the rejected
* -1 / 0 / 999 readings cannot buy a score a week early. Swift twin:
* `testSevenRawNightsWithFourValidCalibrates`.
*/
@Test
fun sevenRawNightsWithFourValidCalibrates() {
val out = WatchRecovery.compute(
todayHrv = 45.0, todayRhr = null,
hrvHistory = listOf(45.0, 46.0, 47.0, 48.0, -1.0, 0.0, 999.0), rhrHistory = emptyList(),
)
assertNull(out.recovery)
assertEquals(ScoreConfidence.CALIBRATING, out.confidence)
}

/** Seven raw entries of which only three are valid: below the baseline's own seed gate too. */
@Test
fun sevenRawNightsWithThreeValidCalibrates() {
val out = WatchRecovery.compute(
todayHrv = 45.0, todayRhr = null,
hrvHistory = listOf(45.0, 46.0, 47.0, -1.0, 0.0, 999.0, 1000.0), rhrHistory = emptyList(),
)
assertNull(out.recovery)
assertEquals(ScoreConfidence.CALIBRATING, out.confidence)
}

/** Six raw entries, all six valid -> still one accepted night short of the gate. */
@Test
fun sixRawNightsWithSixValidCalibrates() {
val out = WatchRecovery.compute(
todayHrv = 45.0, todayRhr = null,
hrvHistory = listOf(45.0, 46.0, 47.0, 48.0, 45.0, 46.0), rhrHistory = emptyList(),
)
assertNull(out.recovery)
assertEquals(ScoreConfidence.CALIBRATING, out.confidence)
}

/** Nine raw entries of which exactly seven are valid -> the gate is met by accepted nights. */
@Test
fun nineRawNightsWithSevenValidScores() {
val out = WatchRecovery.compute(
todayHrv = 45.0, todayRhr = null,
hrvHistory = listOf(45.0, 46.0, 47.0, 48.0, 45.0, 46.0, 47.0, -1.0, 999.0),
rhrHistory = emptyList(),
)
assertNotNull(out.recovery)
assertTrue(out.confidence != ScoreConfidence.CALIBRATING)
}

// --- RHR term needs a USABLE RHR baseline (fork issue #61) ---

/**
* An empty RHR history yields foldHistory's synthetic midpoint baseline (75 bpm), which must never
* score today's reading: with no usable RHR baseline the result is the documented HRV-only path,
* exactly as if today's RHR were missing. Swift twin: `testEmptyRHRHistoryScoresLikeMissingRHR`.
*
* The pinned literal is the Swift value, produced by a standalone oracle executable linked against
* the production `StrandAnalytics` package (so it cannot drift from the Swift source):
*
* import StrandAnalytics
* let hist = Array(repeating: 45.0, count: 7)
* print(WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52,
* sdnnHistory: hist, rhrHistory: []).recovery!)
* // -> 57.932425214874954
*/
@Test
fun emptyRhrHistoryScoresLikeMissingRhr() {
val hist = List(7) { 45.0 }
val withRhr = WatchRecovery.compute(todayHrv = 45.0, todayRhr = 52, hrvHistory = hist, rhrHistory = emptyList())
val hrvOnly = WatchRecovery.compute(todayHrv = 45.0, todayRhr = null, hrvHistory = hist, rhrHistory = emptyList())
assertNotNull(withRhr.recovery)
assertNotNull(hrvOnly.recovery)
assertEquals(hrvOnly.recovery!!, withRhr.recovery!!, 1e-12)
assertEquals(57.932425214874954, withRhr.recovery!!, 1e-12)
}

/**
* An RHR history that is entirely out of physiological range accepts no night at all, so its baseline
* is the same synthetic midpoint — likewise dropped.
*/
@Test
fun unusableRhrHistoryScoresLikeMissingRhr() {
val hist = List(7) { 45.0 }
val junk = List(7) { 300.0 } // above restingHRCfg.maxVal (120)
val withRhr = WatchRecovery.compute(todayHrv = 45.0, todayRhr = 52, hrvHistory = hist, rhrHistory = junk)
val hrvOnly = WatchRecovery.compute(todayHrv = 45.0, todayRhr = null, hrvHistory = hist, rhrHistory = junk)
assertNotNull(withRhr.recovery)
assertEquals(hrvOnly.recovery!!, withRhr.recovery!!, 1e-12)
}

/**
* Once the RHR baseline IS usable (>= Baselines.minNightsSeed accepted nights) the term returns: a
* resting HR above baseline must pull the score below the HRV-only number.
*/
@Test
fun usableRhrHistoryStillContributes() {
val hist = List(7) { 45.0 }
val rhrHist = List(4) { 52.0 } // exactly the seed gate -> provisional
val withRhr = WatchRecovery.compute(todayHrv = 45.0, todayRhr = 62, hrvHistory = hist, rhrHistory = rhrHist)
val hrvOnly = WatchRecovery.compute(todayHrv = 45.0, todayRhr = null, hrvHistory = hist, rhrHistory = rhrHist)
assertNotNull(withRhr.recovery)
assertNotNull(hrvOnly.recovery)
assertTrue(withRhr.recovery!! < hrvOnly.recovery!! - 1.0)
}
}