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
60 changes: 58 additions & 2 deletions Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,74 @@ public enum HypnogramCoverage {
/// that importer, unvalidated against a Xiaomi export, and it is pinned by test rather than left to
/// be discovered.
public static func fraction(stagesJSON: String?, spanSeconds: Double) -> Double? {
fraction(coveredSeconds: coveredSeconds(stagesJSON: stagesJSON), spanSeconds: spanSeconds)
}

/// Seconds of `stagesJSON` accounted for by timestamped segments, or 0 when the payload carries no
/// measurable ones.
///
/// Zero, not nil, because this is a SUMMABLE quantity: a caller accumulating several fragments needs
/// "this one contributed nothing" to add cleanly, and the "is coverage even a meaningful question"
/// decision belongs to `fraction`, which turns a zero total back into nil. Callers that want the
/// unknown/known distinction for a SINGLE session must therefore keep using `fraction`, not this.
///
/// Returns 0 for every shape `fraction(stagesJSON:spanSeconds:)` documents as unmeasurable — a
/// nil/blank/`"[]"` payload, the imported minute-dict `{light,deep,rem,awake}`, Health Connect's
/// `{stage,min}` array, and an array holding any non-object element (the whole payload bails rather
/// than measuring the remainder). Splitting it out is behaviour-preserving for that function: a 0
/// total fails `fraction`'s `coveredSeconds > 0` guard and still yields nil.
public static func coveredSeconds(stagesJSON: String?) -> Double {
guard let json = stagesJSON?.trimmingCharacters(in: .whitespacesAndNewlines),
!json.isEmpty, json != "[]",
let data = json.data(using: .utf8),
let segs = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]]
else { return nil }
else { return 0 }
var covered = 0.0
for seg in segs {
guard let s = (seg["start"] as? NSNumber)?.doubleValue,
let e = (seg["end"] as? NSNumber)?.doubleValue, e > s else { continue }
covered += e - s
}
return fraction(coveredSeconds: covered, spanSeconds: spanSeconds)
return covered
}

/// Coverage summed across a bridged main-night GROUP — the quantity a night is actually judged on.
///
/// A night is not one row. `AnalyticsEngine.analyzeDay` bridges the day's main-sleep fragments into a
/// group (#561) and accumulates coverage over ALL of them before testing the gate, so a per-row answer
/// is the wrong question for anything presenting a night: two fragments that are individually 90% and
/// 99% covered are one night at neither figure. This exists so a UI can ask the same question the
/// engine asked without restating the accumulation, which is where a twin rule would drift.
///
/// PARITY WITH THE ENGINE, exactly. Every fragment contributes its full `[startTs, endTs)` to the
/// denominator whether or not its payload is measurable, and contributes cover only from timestamped
/// segments — which reproduces `analyzeDay`'s decoded-segment accumulation term for term, including
/// the mixed-source case it calls out (a timestamp-free fragment adds span and no cover, so a group
/// mixing one with a timestamped fragment reads as holed). A group whose fragments are ALL
/// timestamp-free totals 0 covered seconds and comes back nil — unknown, not holed.
/// The accumulation itself, over `(payload, span)` fragments. This is the shape the Kotlin twin
/// implements — entity-free, because `HypnogramCoverage` sits in `com.noop.analytics` over there and
/// must not reach into `com.noop.data` for a row type. The session overload below is a thin adapter,
/// so both platforms run the identical arithmetic and only the packaging differs.
public static func groupFraction(_ fragments: [(stagesJSON: String?, spanSeconds: Double)]) -> Double? {
var covered = 0.0, span = 0.0
for f in fragments {
span += f.spanSeconds
covered += coveredSeconds(stagesJSON: f.stagesJSON)
}
return fraction(coveredSeconds: covered, spanSeconds: span)
}

public static func groupFraction(_ sessions: [CachedSleepSession]) -> Double? {
groupFraction(sessions.map { (stagesJSON: $0.stagesJSON,
spanSeconds: Double($0.endTs - $0.startTs)) })
}

/// `isHoled` for a bridged main-night GROUP. Unknown coverage (nil) is NOT holed — same fail-open
/// contract as every other guard here.
public static func isHoledGroup(_ sessions: [CachedSleepSession]) -> Bool {
guard let f = groupFraction(sessions) else { return false }
return f < minCoverage
}

/// True when the timeline is known to cover less than `minCoverage` of its span — i.e. the session
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,78 @@ final class HypnogramCoverageTests: XCTestCase {
XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: #"[{"start":null,"end":100,"stage":"deep"}]"#,
spanSeconds: 1000))
}

// MARK: - the GROUP accumulation (what a night is actually judged on)

private func sess(_ start: Int, _ end: Int, _ stagesJSON: String?) -> CachedSleepSession {
CachedSleepSession(startTs: start, endTs: end, efficiency: nil, restingHr: nil,
avgHrv: nil, stagesJSON: stagesJSON)
}

/// A night is not one row. Two bridged fragments that are individually 90% and 100% covered are ONE
/// night at neither figure, and the group answer is what the engine gates on — so the per-row reading
/// is the wrong question for anything presenting a night.
func testGroupFractionSumsAcrossBridgedFragments() {
let a = sess(0, 1000, segs([(0, 900, "light")])) // 90% of its own span
let b = sess(1200, 2200, segs([(1200, 2200, "deep")])) // 100% of its own span
XCTAssertEqual(HypnogramCoverage.groupFraction([a, b])!, 1900.0 / 2000.0, accuracy: 1e-12)
XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: a.stagesJSON, spanSeconds: 1000)!,
0.9, accuracy: 1e-12)
XCTAssertFalse(HypnogramCoverage.isHoledGroup([a, b]))
XCTAssertTrue(HypnogramCoverage.isHoled(a)) // and the fragment alone still is
}

/// The inter-fragment GAP is not in the denominator. It belongs to no fragment's `[startTs, endTs)`,
/// and it is known-awake out-of-bed time (#777/#705) rather than time we failed to observe — folding
/// it in would report a correctly-recorded biphasic night as partly missing.
func testGroupFractionExcludesTheInterFragmentGap() {
let a = sess(0, 1000, segs([(0, 1000, "light")]))
let b = sess(5000, 6000, segs([(5000, 6000, "deep")])) // 4000 s gap between them
XCTAssertEqual(HypnogramCoverage.groupFraction([a, b])!, 1.0, accuracy: 1e-12)
}

/// `testMixedSourceGroupReadsAsHoled`'s arithmetic, now asserted through the accumulation itself
/// rather than restated as two literals: a timestamp-free fragment contributes span and no cover.
func testGroupFractionMixedSourceReadsAsHoled() {
let staged = sess(0, 8 * 3600, segs([(0, 8 * 3600, "light")]))
let minuteDict = sess(8 * 3600, 10 * 3600, #"{"light":60,"deep":30,"rem":20,"awake":10}"#)
XCTAssertEqual(HypnogramCoverage.groupFraction([staged, minuteDict])!, 0.8, accuracy: 1e-12)
XCTAssertTrue(HypnogramCoverage.isHoledGroup([staged, minuteDict]))
}

/// All-timestamp-free: zero cover over a real span is UNKNOWN, not bad. This is what keeps the gate
/// off the WHOOP CSV / Apple / Health Connect imports at the group level too.
func testGroupFractionAllTimestampFreeIsUnmeasurable() {
let a = sess(0, 4 * 3600, #"{"light":60,"deep":30,"rem":20,"awake":10}"#)
let b = sess(4 * 3600, 8 * 3600, nil)
XCTAssertNil(HypnogramCoverage.groupFraction([a, b]))
XCTAssertFalse(HypnogramCoverage.isHoledGroup([a, b]))
XCTAssertFalse(HypnogramCoverage.isHoledGroup([]))
}

/// The REAL night this guard's UI was built against — 2026-08-28/29 on an Oura ring: a 470.5-minute
/// window whose 96 stage segments account for 440.0 minutes. It renders as a complete night and is
/// not; at 93.5% it is the case the Sleep tab's "Partly recorded" note exists to state. Pinned with
/// the boundary night from the same 31-night run (08-26, 95.2%) so the gate is shown to separate
/// them rather than to flag everything nearby.
func testGroupFractionMatchesTheObservedHoledNight() {
let holed = sess(0, Int(470.5 * 60), segs([(0, Int(440.0 * 60), "light")]))
let f = HypnogramCoverage.groupFraction([holed])!
XCTAssertEqual(f, 440.0 / 470.5, accuracy: 1e-12)
XCTAssertEqual(Int((f * 100).rounded(.down)), 93) // the percentage the note prints
XCTAssertTrue(HypnogramCoverage.isHoledGroup([holed]))

let boundary = sess(0, 100_000, segs([(0, 95_200, "light")]))
XCTAssertFalse(HypnogramCoverage.isHoledGroup([boundary]))
}

/// The note floors its percentage rather than rounding it: 94.8% must never print as "95%" beside a
/// badge raised because coverage fell BELOW 95%. Pinned here because the rule is a property of the
/// gate, not of one screen, and both hosts that print it must agree.
func testPrintedPercentageNeverContradictsTheGate() {
let s = sess(0, 100_000, segs([(0, 94_800, "light")]))
let f = HypnogramCoverage.groupFraction([s])!
XCTAssertTrue(f < HypnogramCoverage.minCoverage)
XCTAssertEqual(Int((f * 100).rounded(.down)), 94)
}
}
116 changes: 116 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -195810,6 +195810,122 @@
}
}
},
"Only %lld%% of this night's window has stage data. The stage totals cover only that part of the night.": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur %lld%% des Zeitfensters dieser Nacht enthält Schlafphasendaten. Die Phasenwerte decken nur diesen Teil der Nacht ab."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Solo el %lld%% de la ventana de esta noche tiene datos de fases. Los totales de fases cubren solo esa parte de la noche."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Seuls %lld%% de la fenêtre de cette nuit comportent des données de phases. Les totaux de phases ne couvrent que cette partie de la nuit."
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Solo il %lld%% della finestra di questa notte ha dati sulle fasi. I totali delle fasi coprono solo quella parte della notte."
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "Apenas %lld%% da janela desta noite tem dados de fases. Os totais de fases cobrem apenas essa parte da noite."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Только %lld%% окна этой ночи содержит данные о фазах. Итоги по фазам охватывают только эту часть ночи."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "这一夜的时间窗口中只有 %lld%% 有睡眠阶段数据。阶段合计仅涵盖该部分时间。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "這一夜的時間視窗中只有 %lld%% 有睡眠階段資料。階段合計僅涵蓋該部分時間。"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Tylko %lld%% okna tej nocy ma dane o fazach. Sumy faz obejmują tylko tę część nocy."
}
}
}
},
"Partly recorded": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Teilweise aufgezeichnet"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Registro parcial"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Enregistrement partiel"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Registrazione parziale"
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "Registo parcial"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Записано частично"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "部分记录"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "部分記錄"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Częściowo zarejestrowane"
}
}
}
},
"Customize": {
"localizations": {
"de": {
Expand Down
48 changes: 48 additions & 0 deletions Strand/Screens/SleepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,14 @@ struct SleepView: View {
if stageStagingIsSparse(night) {
stageIncompleteNote
}
// #1716 — a device-provided hypnogram assembled from records that never all arrived leaves a
// HOLE in the timeline while the session still spans the whole night, so a night we saw a
// fraction of renders as a complete one. Say which fraction. This is the only place the
// coverage guard becomes visible: the engine's matching Rest downgrade lands in a transient
// `DayResult` field no screen reads, so the gate was otherwise correct and inert.
if let coverage = stageCoverage(night), coverage < HypnogramCoverage.minCoverage {
stagePartialNote(coverage)
}
// For an Oura-provided night, say plainly that this split is the ring's RAW on-device
// classification — so the larger Awake / smaller Deep+REM here isn't misread as the polished
// numbers the Oura app shows for the same night (the app post-processes the same stream).
Expand Down Expand Up @@ -944,6 +952,17 @@ struct SleepView: View {
night.sourceBlocks.contains { $0.stagingSparse == true }
}

/// How much of this night's window its stage timeline actually accounts for, or nil when coverage is
/// not a measurable question for the payloads it was built from (#1716). Asked of the bridged main-night
/// GROUP via the SAME shared accumulation `analyzeDay` uses, threading the same learned habitual so the
/// group resolves identically to the hero's — a per-row answer would be the wrong question for a
/// fragmented night. Mirror in Kotlin.
private func stageCoverage(_ night: Night) -> Double? {
let group = SleepView.mainNightGroup(night.sourceBlocks,
habitualMidsleepSec: night.habitualMidsleepSec)
return HypnogramCoverage.groupFraction(group.isEmpty ? night.sourceBlocks : group)
}

/// Pure H9 gate (unit-testable without a live view) — true when a night's staging is low-confidence:
/// a high-efficiency night whose deep+REM share is below the restorative floor. Built on the engine's
/// own `ScoreConfidence.rest(...)` so the UI flag and the persisted Rest confidence agree. `asleepMin`,
Expand Down Expand Up @@ -1003,6 +1022,35 @@ struct SleepView: View {
.accessibilityElement(children: .combine)
}

/// The PARTIAL-TIMELINE caveat (#1716): this night's stage segments account for less than
/// `HypnogramCoverage.minCoverage` of the window the session claims, so the stage totals describe only
/// the part of the night the timeline accounts for. Distinct from BOTH notes above — H9 doubts the
/// deep/REM SPLIT of a fully-described night, #345 doubts a night staged on thin motion, and this one
/// says plainly that some of the night is MISSING rather than doubted. It is the visible half of the
/// engine-side guard: `analyzeDay` already downgrades Rest to `.building` on exactly this condition,
/// but that tier is transient engine output no screen reads, so without this the gate was inert.
///
/// HONEST-DATA: it reports only what was observed and changes no number. The percentage is floored,
/// never rounded — 94.8% must not print as "95%" and appear to contradict the gate that flagged it.
/// The copy names NO cause and offers NO remedy, deliberately: on the 08-29/30 and 08-30/31 captures the
/// missing codes DID reach NOOP — the ring reported them unwritten (0xFF), the persist log trimmed
/// exactly as many as the hole is wide — and re-persisting the same night 5 and 8 times left the hole
/// intact. "The rest never reached NOOP" and "syncing again can fill in" were both wrong. Nor does the
/// copy point at the totals by DIRECTION: both hosts render this note below the stage-breakdown card
/// that carries them, so "the totals below" pointed the wrong way on every screen that shipped it.
private func stagePartialNote(_ coverage: Double) -> some View {
let pct = Int((coverage * 100).rounded(.down))
return HStack(alignment: .top, spacing: 8) {
SourceBadge("Partly recorded", tint: StrandPalette.statusWarning)
Text("Only \(pct)% of this night's window has stage data. The stage totals cover only that part of the night.")
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 2)
.accessibilityElement(children: .combine)
}

/// Honest caveat for an Oura-provided night: the stage split shown here is the ring's RAW on-device
/// SleepNet classification, read straight off the BLE phase stream — NOT the adjusted stages the Oura
/// app displays. The app post-processes the same night, so its Deep/REM run higher and its Awake lower;
Expand Down
Loading
Loading