From 7ee0f9e201844dccf26cb3f5323e478e0b6e0a4c Mon Sep 17 00:00:00 2001 From: Pipiche Date: Sat, 29 Aug 2026 15:24:42 +0200 Subject: [PATCH] sleep: say when a night's hypnogram only covers part of its window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1716's coverage guard measures the right thing and nothing shows it. The gate downgrades Rest to `.building` on a holed night, but that verdict lands in `DayResult.restConfidence`, which — like `chargeConfidence` and `effortConfidence` — has no reader outside the analytics package on either platform. On the 2026-08-28/29 ring night (470.5 min claimed, 440.0 min of segments, 93.5% covered) the guard fired correctly and the Sleep screen was indistinguishable from a complete night. Give it a consumer: a third caveat under the stage breakdown, beside the existing #H9 and #345 notes. It is a separate note rather than a coverage argument threaded into the "Low confidence" badge. That badge is a different gate — it calls `ScoreConfidence.rest(...)` itself and then re-tests `restorativeShare < 0.10 && efficiency >= 0.85`, so a coverage-only downgrade would be filtered straight back out — and its wording claims the deep/REM SPLIT is doubted, when the claim here is that part of the night is MISSING. Different fact, different sentence. The copy names NO cause and offers NO remedy, and that is the whole of what it can attribute. Three captures say so. On 08-29/30 and 08-30/31 the final persist logged `hypnogram trimmed 136 code(s)` and `24 code(s) - unwritten (0xFF) and/or pre-onset`, the second matching the 12.0 min hole code for code, so the epochs DID reach the phone and the ring marked them unwritten; re-persisting those nights five and eight times left every hole intact; and part of a shortfall is not a hole at all but the 0x49 sleep-onset anchor landing before the first written code (13.6 min on 08-29/30, 366.8 min on 08-17), which nothing can ever fill. Nor does the note point at the totals by direction — both hosts render it BELOW the stage-breakdown card that carries them. It says only that this share of the window has stage data and that the totals cover only that part of the night. Underneath, one shared accumulation on both platforms: `HypnogramCoverage.coveredSeconds` / `groupFraction` / `isHoledGroup`. - It is asked of the bridged main-night GROUP, never of one row. Two fragments individually 90% and 99% covered are one night at neither figure, and the group total is what `analyzeDay` gates on; a per-row reading in the UI would have been a quietly different rule. - It reproduces `analyzeDay`'s decoded-segment accumulation term for term: every fragment contributes its full span to the denominator whether or not its payload is measurable, and contributes cover only from timestamped segments — so the mixed-source case still reads as holed and an all-timestamp-free group still comes back nil (unknown, not holed). The inter-fragment gap is NOT in the denominator: it is known-awake out-of-bed time (#777/#705), not time we failed to observe. - `coveredSeconds` returns 0, not nil, for an unmeasurable payload, because it is the summable half; "is this measurable at all" stays in `fraction`, which turns a zero total back into nil. `fraction(stagesJSON: spanSeconds:)` now delegates to it on both sides, which removes a duplicated segment loop and is behaviour-preserving. - The Kotlin twin takes entity-free `(payload, span)` fragments so `com.noop.analytics` need not reach into `com.noop.data` for a row type; the Swift `CachedSleepSession` overload is a thin adapter over the same arithmetic. The printed percentage is floored, never rounded, so 94.8% cannot render as "95%" beside a badge raised for falling below 95%. Pinned by test, since that is a property of the gate rather than of one screen, and both hosts print it. Verification. Swift WhoopStore 455, StrandAnalytics 1647, StrandTests 1403, macOS app target builds; Android 4778 with the two pre-existing `RecoveryDriversTest` float-rounding failures, confirmed failing identically on a clean tree at this base (the six further reds under a fr_FR default locale are the known locale-dependent set and are green under `-Duser.language=en`, on this tree and without it). Kotlin `HypnogramCoverageTest` 10/10, including three new cases pinned against stdout from the shipped Swift implementation linked as a package dependency — 16 group rows covering the mixed-source, all-unmeasurable, gap, overlap and degenerate-span cases, and the knife-edge row where 1900/2000 prints as 0.94999999999999996, the same double the threshold literal denotes, and is therefore NOT holed. Reading the two accumulations side by side could not have settled that row. doc-lint and the i18n gate pass; the new strings ship in en/de/es/fr/pt-PT plus pl/zh so the non-focus ratchet is not worsened. Confirmed on hardware data, both ways: the 2026-08-28/29 and 2026-08-29/30 databases loaded into the macOS build render the amber "Partly recorded" badge and a floored percentage above the raw-stages note, and the clean 2026-08-30/31 night (96.8%) shows no badge at all. No stored number changes. This wires the guard, not the field: `DayResult.restConfidence` and its two siblings still have no reader, and since none of the three is persisted, any future consumer faces the same choice between a migration and recomputing at the render site as this does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0153E6spximZQJUii9gn6Kwc --- .../WhoopStore/HypnogramCoverage.swift | 60 +++++++- .../HypnogramCoverageTests.swift | 74 ++++++++++ Strand/Resources/Localizable.xcstrings | 116 ++++++++++++++++ Strand/Screens/SleepView.swift | 48 +++++++ Strand/Screens/StagesCard.swift | 32 +++++ .../com/noop/analytics/HypnogramCoverage.kt | 106 ++++++++++---- .../src/main/java/com/noop/ui/SleepScreen.kt | 48 +++++++ .../app/src/main/res/values-de/strings.xml | 2 + .../app/src/main/res/values-es/strings.xml | 2 + .../app/src/main/res/values-fr/strings.xml | 2 + .../app/src/main/res/values-pl/strings.xml | 2 + .../src/main/res/values-pt-rPT/strings.xml | 2 + .../app/src/main/res/values-zh/strings.xml | 2 + android/app/src/main/res/values/strings.xml | 2 + .../noop/analytics/HypnogramCoverageTest.kt | 130 ++++++++++++++++++ 15 files changed, 601 insertions(+), 27 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift b/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift index 5b848ce22c..d955bf361e 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift @@ -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 diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift index e00cfdfcce..c6682befe3 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift @@ -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) + } } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 47959eeaca..3a1c0f5f0a 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -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": { diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index 6d24b89938..26b9dbd79a 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -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). @@ -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`, @@ -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; diff --git a/Strand/Screens/StagesCard.swift b/Strand/Screens/StagesCard.swift index 8c50f77a91..e3af83c922 100644 --- a/Strand/Screens/StagesCard.swift +++ b/Strand/Screens/StagesCard.swift @@ -123,6 +123,12 @@ struct StageDetailView: View { if stageStagingIsSparse(night) { stageIncompleteNote } + // #1716 — a device-provided hypnogram whose records 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, exactly as the Sleep tab does. + 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). @@ -246,6 +252,16 @@ struct StageDetailView: 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). Same shared group accumulation as + /// `SleepView.stageCoverage(_:)` — this host renders the same night and must not reach a different + /// verdict about it. 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) + } + /// The H9 low-confidence note shown beneath the stage breakdown — a warning-tinted badge plus a /// one-line honest explanation. No faked stages, no tanked score; just a clear "treat this split with /// care" so a user doesn't read a likely staging miss as a real deep/REM drought. (#H9) @@ -278,6 +294,22 @@ struct StageDetailView: View { .accessibilityElement(children: .combine) } + /// The PARTIAL-TIMELINE caveat (#1716) — twin of `SleepView.stagePartialNote(_:)`, same copy and same + /// floored percentage. Says that part of the night is MISSING, which is a different claim from the two + /// notes above (a doubted split, and a night staged on thin motion). Changes no number. + 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; diff --git a/android/app/src/main/java/com/noop/analytics/HypnogramCoverage.kt b/android/app/src/main/java/com/noop/analytics/HypnogramCoverage.kt index 9c9f072022..695d3c606e 100644 --- a/android/app/src/main/java/com/noop/analytics/HypnogramCoverage.kt +++ b/android/app/src/main/java/com/noop/analytics/HypnogramCoverage.kt @@ -77,31 +77,8 @@ object HypnogramCoverage { * that importer, unvalidated against a Xiaomi export, and it is pinned by test rather than left to * be discovered. */ - fun fraction(stagesJson: String?, spanSeconds: Double): Double? { - val json = stagesJson?.trim() ?: return null - if (json.isEmpty() || json == "[]") return null - // The minute-dict shape (imported totals) throws here and falls through to null — deliberately, - // since it carries no timestamps to measure. Same try/catch idiom as SleepStageTotals.minutes. - // Health Connect's `{stage,min}` array does NOT throw: it parses, reaches the loop, and every - // segment is skipped for want of bounds, so it exits with zero cover — unmeasurable by the same - // rule, along a different path. Both are pinned in the oracle. - val arr = try { JSONArray(json) } catch (_: Throwable) { return null } - var covered = 0.0 - for (i in 0 until arr.length()) { - // The Swift twin decodes the array as a WHOLE (`as? [[String: Any]]`), so ONE non-object - // element makes the entire payload unmeasurable there. Bail identically rather than - // measuring the remainder. Measured on the shipped pair, `[{seg},5]` and `[{seg},null]` - // read nil/not-holed on Swift and 0.1/HOLED here — and since this gate can only downgrade - // a night, refusing to judge a payload we do not fully understand is the safe half of that - // disagreement, and the one the module's fail-OPEN rule already commits to. - val seg = arr.optJSONObject(i) ?: return null - val s = num(seg.opt("start")) ?: continue - val e = num(seg.opt("end")) ?: continue - if (e <= s) continue - covered += e - s - } - return fraction(covered, spanSeconds) - } + fun fraction(stagesJson: String?, spanSeconds: Double): Double? = + fraction(coveredSeconds(stagesJson), spanSeconds) /** * A JSON scalar read the way Swift's `(seg["start"] as? NSNumber)?.doubleValue` reads it. @@ -130,4 +107,83 @@ object HypnogramCoverage { val f = fraction(stagesJson, spanSeconds) ?: return false return f < minCoverage } + + /** + * Seconds of [stagesJson] accounted for by timestamped segments, or 0 when the payload carries no + * measurable ones. + * + * Zero, not null, 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 null. Callers wanting the + * unknown/known distinction for a SINGLE session must therefore keep using [fraction], not this. + * + * Returns 0 for every shape [fraction] documents as unmeasurable, by the same paths and in the same + * order — including the whole-payload bail on a non-object element, which must stay a bail here too + * (returning a PARTIAL sum for such a payload would resurrect exactly the Swift/Kotlin disagreement + * the `optJSONObject` bail above was added to close). Mirrors Swift `coveredSeconds(stagesJSON:)`. + */ + fun coveredSeconds(stagesJson: String?): Double { + val json = stagesJson?.trim() ?: return 0.0 + if (json.isEmpty() || json == "[]") return 0.0 + // The minute-dict shape (imported totals) throws here and falls through to zero — deliberately, + // since it carries no timestamps to measure. Same try/catch idiom as SleepStageTotals.minutes. + // Health Connect's `{stage,min}` array does NOT throw: it parses, reaches the loop, and every + // segment is skipped for want of bounds, so it exits with zero cover — unmeasurable by the same + // rule, along a different path. Both are pinned in the oracle. + val arr = try { JSONArray(json) } catch (_: Throwable) { return 0.0 } + var covered = 0.0 + for (i in 0 until arr.length()) { + // The Swift twin decodes the array as a WHOLE (`as? [[String: Any]]`), so ONE non-object + // element makes the entire payload unmeasurable there. Bail identically — DISCARDING the + // partial sum, which is what the Swift cast does — rather than measuring the remainder. + // Measured on the shipped pair, `[{seg},5]` and `[{seg},null]` read nil/not-holed on Swift + // and 0.1/HOLED here — and since this gate can only downgrade a night, refusing to judge a + // payload we do not fully understand is the safe half of that disagreement, and the one the + // module's fail-OPEN rule already commits to. + val seg = arr.optJSONObject(i) ?: return 0.0 + val s = num(seg.opt("start")) ?: continue + val e = num(seg.opt("end")) ?: continue + if (e <= s) continue + covered += e - s + } + return covered + } + + /** One fragment of a bridged main-night group: its stored payload and its own `[start, end)` span. */ + data class Fragment(val stagesJson: String?, val spanSeconds: Double) + + /** + * 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 span to the denominator whether + * or not its payload is measurable, and contributes cover only from timestamped segments — which + * reproduces the engine's decoded-segment accumulation term for term, including the mixed-source case + * (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 null — unknown, not holed. Mirrors Swift `groupFraction(_:)`. + */ + fun groupFraction(fragments: List): Double? { + var covered = 0.0 + var span = 0.0 + for (f in fragments) { + span += f.spanSeconds + covered += coveredSeconds(f.stagesJson) + } + return fraction(covered, span) + } + + /** + * [isHoled] for a bridged main-night GROUP. Unknown coverage (null) is NOT holed — same fail-open + * contract as every other guard here. + */ + fun isHoledGroup(fragments: List): Boolean { + val f = groupFraction(fragments) ?: return false + return f < minCoverage + } } diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index 3994e2df6a..afba1ad8dc 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -72,6 +72,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.analytics.AnalyticsEngine import com.noop.analytics.CircadianEngine +import com.noop.analytics.HypnogramCoverage import com.noop.analytics.SleepEditGuard import com.noop.analytics.SleepGroupEdit import com.noop.analytics.SleepStageTotals @@ -85,6 +86,7 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.floor import kotlin.math.max import kotlin.math.roundToInt import androidx.compose.runtime.getValue @@ -1354,6 +1356,21 @@ private fun Hero( // anchor), so it carries the flag; nil (imported / pre-migration) is never flagged. Mirrors iOS // SleepView.stageIncompleteNote. if (session?.stagingSparse == true) SleepIncompleteNote() + // #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. Asked of the bridged main-night GROUP (the quantity + // analyzeDay gates on), never of one fragment. 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. Mirrors iOS SleepView.stagePartialNote. + val coverageGroup = heroGroup.ifEmpty { listOfNotNull(session) } + val stageCoverage = HypnogramCoverage.groupFraction( + coverageGroup.map { + HypnogramCoverage.Fragment(it.stagesJSON, (it.endTs - it.startTs).toDouble()) + } + ) + if (stageCoverage != null && stageCoverage < HypnogramCoverage.minCoverage) { + SleepPartialNote(stageCoverage) + } } // Naps card (#508/#518): the day's blocks OTHER than the main night, each editable / deletable // with the SAME mechanism main sleep uses, plus a Main / Nap(s) / Total split so what drives the @@ -1486,6 +1503,37 @@ private fun SleepIncompleteNote() { } } +/** + * 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. + * + * HONEST-DATA: reports only what was observed and changes no number. The percentage is FLOORED, never + * rounded — 94.8% must not print as "95%" beside a badge raised because coverage fell below 95% — which is + * why this takes the fraction and floors it here rather than accepting a pre-rounded Int from the caller. + * The copy names NO cause and offers NO remedy: the captures behind this note show the missing codes DID + * reach the phone (the ring reported them unwritten, 0xFF) and that re-persisting the night does not fill + * the hole. Mirrors iOS SleepView.stagePartialNote. + */ +@Composable +private fun SleepPartialNote(coverage: Double) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(horizontal = 2.dp), + ) { + SourceBadge(text = uiString(R.string.l10n_sleep_screen_partly_recorded_f43509ab), tint = Palette.statusWarning) + Text( + uiString(R.string.l10n_sleep_screen_only_of_this_night_s_window_9660332a, + floor(coverage * 100.0).toInt()), + style = NoopType.caption, + color = Palette.textTertiary, + ) + } +} + /** * The Naps card footer: the night's provenance badge (the REAL per-day merge winner) next to a tappable * "Why this sleep?" affordance that reveals the foundation [SleepStageTotals.MainNightReason] copy inline, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 5ee513b4b9..8398eb63b3 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1945,6 +1945,8 @@ Widgets erfinden keine Zahlen mehr, Nickerchen zählen für die Schlafschuld, und die Android-Statusanzeigen sprechen deine Sprache Möglicherweise unvollständig Über Nacht wurde wenig Bewegung aufgezeichnet, daher wurde diese Nacht möglicherweise nicht vollständig erfasst und die Schlafdauer kann zu kurz erscheinen. Stelle sicher, dass das Band vollständig synchronisiert wurde. + Teilweise aufgezeichnet + Nur %1$d%% des Zeitfensters dieser Nacht enthält Schlafphasendaten. Die Phasenwerte decken nur diesen Teil der Nacht ab. Haptik Atemtaktgeber Intervall-Timer diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 5a5f2b3d9b..043a358b18 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1912,6 +1912,8 @@ Los widgets dejan de inventar números, las siestas cuentan para la deuda de sueño y los indicadores de Android hablan tu idioma Puede estar incompleto Se registró poco movimiento durante la noche, por lo que esta noche puede haberse detectado de forma incompleta y el total de sueño puede parecer corto. Asegúrate de que la correa se haya sincronizado por completo. + Registro parcial + Solo el %1$d%% de la ventana de esta noche tiene datos de fases. Los totales de fases cubren solo esa parte de la noche. Vibración Guía de respiración Temporizador de intervalos diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 7c485ccfbf..03aa8a9bb7 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1930,6 +1930,8 @@ Les widgets n\'inventent plus de chiffres, les siestes comptent pour la dette de sommeil, et les indicateurs Android parlent votre langue Peut être incomplet Peu de mouvement a été enregistré cette nuit ; cette nuit peut donc être sous-détectée et le total de sommeil peut sembler court. Assurez-vous que le bracelet est entièrement synchronisé. + Enregistrement partiel + Seuls %1$d%% 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. Retour haptique Guide de respiration Minuteur d’intervalles diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 6ccee3f22b..399034def1 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -1900,6 +1900,8 @@ Widżety przestają wymyślać liczby, drzemki wliczają się do długów snu, a chipy stanu Android mówią w Twoim języku Może być niekompletny W nocy zarejestrowano niewielki ruch, więc ta noc może zostać niedostatecznie wykryta, a całkowity sen może być krótki. Upewnij się, że pasek jest w pełni zsynchronizowany. + Częściowo zarejestrowane + Tylko %1$d%% okna tej nocy ma dane o fazach. Sumy faz obejmują tylko tę część nocy. Haptyka Oddechowy tempomat Timer interwałowy diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 7650a0f1df..df41f3c2a7 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1906,6 +1906,8 @@ Os widgets deixam de inventar números, as sestas contam para a dívida de sono, e os indicadores Android falam a tua língua Pode estar incompleto Foi registado pouco movimento durante a noite, por isso esta noite pode ter sido subdetetada e o total de sono pode parecer curto. Certifique-se de que a pulseira sincronizou totalmente. + Registo parcial + Apenas %1$d%% da janela desta noite tem dados de fases. Os totais de fases cobrem apenas essa parte da noite. Vibração Guia de respiração Temporizador de intervalos diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 9748333fb0..ab59212f08 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1841,6 +1841,8 @@ 小组件不再编造数字、小睡计入睡眠债,Android 状态提示也终于会说你的语言 可能不完整 夜间记录到的动作很少,因此这一夜可能未被完整检测,睡眠总时长可能偏短。请确保表带已完全同步。 + 部分记录 + 这一夜的时间窗口中只有 %1$d%% 有睡眠阶段数据。阶段合计仅涵盖该部分时间。 触觉反馈 呼吸节拍 间隔计时器 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6c16dc6413..57f7b9371d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2043,6 +2043,8 @@ Widgets stop inventing numbers, naps count toward sleep debt, and the Android status chips speak your language May be incomplete Little motion was recorded overnight, so this night may be under-detected and the sleep total can read short. Make sure the strap fully synced. + Partly recorded + Only %1$d%% of this night\'s window has stage data. The stage totals cover only that part of the night. Haptics Breathing pacer Interval timer diff --git a/android/app/src/test/java/com/noop/analytics/HypnogramCoverageTest.kt b/android/app/src/test/java/com/noop/analytics/HypnogramCoverageTest.kt index 436ede1baf..1ac3b043a3 100644 --- a/android/app/src/test/java/com/noop/analytics/HypnogramCoverageTest.kt +++ b/android/app/src/test/java/com/noop/analytics/HypnogramCoverageTest.kt @@ -174,4 +174,134 @@ class HypnogramCoverageTest { assertEquals(0.8, HypnogramCoverage.fraction(8 * 3600.0, 10 * 3600.0)!!, 1e-12) assertTrue(0.8 < HypnogramCoverage.minCoverage) } + + // ── the GROUP accumulation (what a night is actually judged on) ────────────────────────────── + // + // A night is not one row: the engine bridges the day's main-sleep fragments and accumulates over + // ALL of them before testing the gate, and the Sleep screen's "Partly recorded" note asks the same + // question of the same group. Pinned by oracle for the same reason as the per-payload rules above — + // the accumulation has its own ways to drift (which fragments reach the denominator, whether an + // unmeasurable payload contributes span, whether a partial sum survives a bail) and none of them + // are visible by reading the two loops side by side. + + /** + * VERBATIM stdout of the SHIPPED Swift `HypnogramCoverage` (linked as a package dependency and run, + * not retyped), over the group cases below. + * Format: label|groupFraction (17 significant digits, or "null")|isHoledGroup|floored percentage. + * + * `two-bridged-90-100` is the knife-edge row: 1900/2000 prints as 0.94999999999999996, the SAME + * double the threshold literal denotes, so `< minCoverage` is false and the night is NOT flagged — + * the group twin of the `gate-exact` row above, and the one a reimplementation is most likely to + * get wrong in the other direction. + */ + private val swiftGroupOracle = """ + single-full|1|false|100 + single-holed|0.25|true|25 + two-bridged-90-100|0.94999999999999996|false|95 + gap-excluded|1|false|100 + mixed-source|0.80000000000000004|true|80 + all-minute-dict|null|false|- + health-connect|null|false|- + non-object-bails|null|false|- + non-object-in-group|0.5|true|50 + interior-hole|0.40000000000000002|true|40 + overlap-clamped|1|false|100 + empty-group|null|false|- + zero-span|null|false|- + night-20260828|0.93517534537725822|true|93 + boundary-0952|0.95199999999999996|false|95 + boundary-0948|0.94799999999999995|true|94 + """.trimIndent() + + private fun frag(json: String?, span: Double) = HypnogramCoverage.Fragment(json, span) + + @Test fun groupFractionMatchesTheSwiftOracle() { + val minuteDict = """{"light":60,"deep":30,"rem":20,"awake":10}""" + val hcArray = """[{"stage":"light","min":300},{"stage":"deep","min":100}]""" + val nonObject = """[{"start":0,"end":100,"stage":"deep"},5]""" + + val cases: List>> = listOf( + "single-full" to listOf(frag(seg(0 to 1000), 1000.0)), + "single-holed" to listOf(frag(seg(0 to 300), 1200.0)), + "two-bridged-90-100" to listOf(frag(seg(0 to 900), 1000.0), frag(seg(1200 to 2200), 1000.0)), + "gap-excluded" to listOf(frag(seg(0 to 1000), 1000.0), frag(seg(5000 to 6000), 1000.0)), + "mixed-source" to listOf(frag(seg(0 to 28800), 28800.0), frag(minuteDict, 7200.0)), + "all-minute-dict" to listOf(frag(minuteDict, 14400.0), frag(null, 14400.0)), + "health-connect" to listOf(frag(hcArray, 28800.0)), + "non-object-bails" to listOf(frag(nonObject, 1000.0)), + "non-object-in-group" to listOf(frag(seg(0 to 1000), 1000.0), frag(nonObject, 1000.0)), + "interior-hole" to listOf(frag(seg(0 to 200, 800 to 1000), 1000.0)), + "overlap-clamped" to listOf(frag(seg(0 to 1000, 0 to 1000), 1000.0)), + "empty-group" to emptyList(), + "zero-span" to listOf(frag(seg(0 to 100), 0.0)), + "night-20260828" to listOf(frag(seg(0 to 26400), 28230.0)), // 440.0 min of 470.5 min + "boundary-0952" to listOf(frag(seg(0 to 95200), 100000.0)), + "boundary-0948" to listOf(frag(seg(0 to 94800), 100000.0)), + ) + + val expected = swiftGroupOracle.lines().map { it.trim() }.filter { it.isNotEmpty() } + assertEquals("oracle row count must match the case list", cases.size, expected.size) + for ((i, c) in cases.withIndex()) { + val (label, frags) = c + val parts = expected[i].split("|") + assertEquals("oracle row $i is for a different case", label, parts[0]) + + val f = HypnogramCoverage.groupFraction(frags) + if (parts[1] == "null") { + assertNull("$label: expected unmeasurable", f) + } else { + assertEquals("$label", parts[1].toDouble(), f!!, 0.0) // EXACT: same double, not close + } + assertEquals("$label: isHoledGroup", parts[2].toBoolean(), HypnogramCoverage.isHoledGroup(frags)) + // The percentage the "Partly recorded" note prints. FLOORED, never rounded: 94.8% must not + // print as "95%" beside a badge raised because coverage fell below 95%. + val pct = f?.let { kotlin.math.floor(it * 100.0).toInt().toString() } ?: "-" + assertEquals("$label: printed percentage", parts[3], pct) + } + } + + /** + * [HypnogramCoverage.coveredSeconds] is the summable half — 0, not null, for every payload the + * ratio calls unmeasurable, so fragments add cleanly and the "is this even measurable" decision + * stays in [HypnogramCoverage.fraction]. Verbatim Swift stdout. + */ + @Test fun coveredSecondsMatchesTheSwiftOracle() { + val oracle = """ + full|1000.0 + minuteDict|0.0 + hc|0.0 + nonObject|0.0 + empty|0.0 + blank|0.0 + nil|0.0 + """.trimIndent() + val inputs = listOf( + "full" to seg(0 to 1000), + "minuteDict" to """{"light":60,"deep":30,"rem":20,"awake":10}""", + "hc" to """[{"stage":"light","min":300},{"stage":"deep","min":100}]""", + "nonObject" to """[{"start":0,"end":100,"stage":"deep"},5]""", + "empty" to "[]", + "blank" to " ", + "nil" to null, + ) + val expected = oracle.lines().map { it.trim() }.filter { it.isNotEmpty() } + assertEquals(inputs.size, expected.size) + for ((i, p) in inputs.withIndex()) { + val parts = expected[i].split("|") + assertEquals("oracle row $i is for a different case", p.first, parts[0]) + assertEquals(p.first, parts[1].toDouble(), HypnogramCoverage.coveredSeconds(p.second), 0.0) + } + } + + /** + * 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. Stated as its own test + * because it is a claim about what the CALLER must pass, which an oracle over fragments cannot pin. + */ + @Test fun groupDenominatorIsFragmentSpansNotTheWholeWindow() { + val frags = listOf(frag(seg(0 to 1000), 1000.0), frag(seg(5000 to 6000), 1000.0)) + assertEquals(1.0, HypnogramCoverage.groupFraction(frags)!!, 0.0) + assertFalse(HypnogramCoverage.isHoledGroup(frags)) + } }