diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index e30bf429a9..639342fccc 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -251,10 +251,38 @@ final class IntelligenceEngine: ObservableObject { /// SAME span the floor came from, so the two numbers are directly comparable). Empty in-bed → nightMean /// is "nil". Counts/bpm only , no timestamps or PII. Pure so it's unit-tested directly and is the SAME /// line `analyzeRecent` ships. Byte-identical to the Android `rhrFloorMeanLogLine`. - /// #1331 diagnostic line: the night's computed respiratory rate (breaths/min) or "nil". Format kept - /// simple so it stays byte-identical to the Android `respRateLogLine`. - nonisolated static func respRateLogLine(day: String, respRateBpm: Double?) -> String { - "resp day=\(day) rpm=\(respRateBpm.map { String(format: "%.1f", $0) } ?? "nil")" + /// #1331 diagnostic line: the night's computed respiratory rate (breaths/min) or "nil". + /// + /// When the rate is nil the line now carries WHY, because "nil" on its own sent an investigation + /// across two subsystems to find out. The RSA estimate needs per-beat-accurate R-R, so + /// `HRVAnalyzer.beatValuesAreTrustworthy` refuses a stream whose intervals are not beat-to-beat + /// measurements — and on a WHOOP 4.0 carrying the #1008/#1118 over-count that gate is what empties + /// the card, on nearly every night, silently. Printing the fraction beside the boundary turns + /// "Respiratory: No data" from a mystery into a reading. + /// + /// It distinguishes the two cases rather than asserting one: below the boundary the gate refused the + /// R-R; at or above it the gate passed and the cause is one of the estimator's other exits (too few + /// beats, too short a span, too coarse a grid), which this deliberately does not guess between. + /// Omitting `beatAccurate` restores the original one-field line exactly, so a night that never + /// reached the HRV block reads as it always did. + /// + /// Byte-identical to the Android `respRateLogLine`. + nonisolated static func respRateLogLine(day: String, + respRateBpm: Double?, + beatAccurate: Double? = nil, + rrIntegrity: String? = nil) -> String { + let base = "resp day=\(day) rpm=\(respRateBpm.map { String(format: "%.1f", $0) } ?? "nil")" + guard respRateBpm == nil, let beatAccurate else { return base } + // "NaN" explicitly rather than via %.2f: C-style %f renders a non-finite as "nan" while the + // JVM renders it "NaN", so leaving it to the formatter would make the twin lines differ on + // exactly the input the gate treats specially. One spelling, chosen here, on both platforms. + let acc = beatAccurate.isNaN ? "NaN" : String(format: "%.2f", beatAccurate) + let gate = String(format: "%.2f", HRVAnalyzer.beatAccuracyMinFraction) + let integrity = rrIntegrity ?? "unknown" + if beatAccurate < HRVAnalyzer.beatAccuracyMinFraction { + return "\(base) beatAccurate=\(acc)<\(gate) rrIntegrity=\(integrity) — RSA gate refused the R-R" + } + return "\(base) beatAccurate=\(acc)>=\(gate) rrIntegrity=\(integrity) — gate passed, cause is elsewhere" } nonisolated static func rhrFloorMeanLogLine(day: String, floor: Int, inBedBpms: [Int]) -> String { @@ -1145,6 +1173,11 @@ final class IntelligenceEngine: ObservableObject { let sleepRr = sleepRrRows.map { Double($0.rrMs) } let hrvDiag: String? let hrvOverCounted: Bool? // #1118: nil = no in-sleep R-R (no HRV to caveat) + // #1331: the RSA gate's inputs, carried to the resp diagnostic below. Declared out here because + // the HRV block is one scope deeper; a night with no sleep R-R leaves them nil and the resp line + // falls back to its original one-field form. + var respGateAcc: Double? + var respGateIntegrity: String? if sleepRr.isEmpty { hrvOverCounted = nil // #1244: no in-sleep R-R means no HRV summary. If the whole night also detected NO @@ -1202,6 +1235,7 @@ final class IntelligenceEngine: ObservableObject { // measurements: the per-record SUM is right to ~1% (meanNN and RHR stay correct and // WHOOP-validated) while the individual intervals are not. Gate on that too. let accVal = HRVAnalyzer.beatAccurateFraction(tsSec: ts, rrMs: sleepRr) + respGateAcc = accVal let acc = String(format: "%.2f", accVal) let sdnnField = HRVAnalyzer.beatSpreadIsTrustworthy(verdict) && HRVAnalyzer.beatValuesAreTrustworthy(beatAccurateFraction: accVal) @@ -1210,6 +1244,7 @@ final class IntelligenceEngine: ObservableObject { + "meanNN=\(ms(h.meanNN))ms rr=\(h.nInput)/\(h.nClean) rejected=\(rej)% coverage=\(cov) collapsedCov=\(colCov) dupBeats=\(dup) " + "beatAccurate=\(acc) " + "rrIntegrity=\(verdict.rawValue)" + respGateIntegrity = verdict.rawValue // #1008: on an OVER-COUNT night only, append a raw-row sample around the densest second // (carried as a second \n-joined line, split back apart at the emit site) so the // over-count's MECHANISM is readable from the always-on log — clean nights stay quiet. @@ -1325,7 +1360,10 @@ final class IntelligenceEngine: ObservableObject { } // #1331 respiratory diagnostic — a run of nil nights localises when it stopped. Same // pure-compute-here / replay-on-main-actor path as rhrLine. - let respLine: String? = Self.respRateLogLine(day: res.daily.day, respRateBpm: res.daily.respRateBpm) + let respLine: String? = Self.respRateLogLine(day: res.daily.day, + respRateBpm: res.daily.respRateBpm, + beatAccurate: respGateAcc, + rrIntegrity: respGateIntegrity) // #103/queue-11a: SpO₂ candidate nightly mean. Only computed when the display toggle is // ON, and the transform is device-conditional (#1086-style brand lookup, matching // `skinTempWornToleranceSec`'s idiom just above): a WHOOP owner averages the in-band diff --git a/StrandTests/RespRateLogLineTests.swift b/StrandTests/RespRateLogLineTests.swift new file mode 100644 index 0000000000..9610478cee --- /dev/null +++ b/StrandTests/RespRateLogLineTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import Strand + +/// Pins the resp diagnostic's nil-reason (#1331). Kotlin twin: `RespRateLogLineTest`. +/// +/// The line exists because "rpm=nil" alone cost a cross-subsystem investigation to explain: on a WHOOP +/// 4.0 the RSA beat-accuracy gate empties the card on nearly every night, and nothing said so. +final class RespRateLogLineTests: XCTestCase { + + func testARealRateIsUnchangedAndCarriesNoReason() { + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-11", respRateBpm: 16.0, + beatAccurate: 0.52, rrIntegrity: "crossSecondOverCount"), + "resp day=2026-08-11 rpm=16.0" + ) + } + + func testNilBelowTheGateNamesTheGateThatRefusedIt() { + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, + beatAccurate: 0.45, rrIntegrity: "crossSecondOverCount"), + "resp day=2026-08-26 rpm=nil beatAccurate=0.45<0.50 rrIntegrity=crossSecondOverCount" + + " — RSA gate refused the R-R" + ) + } + + func testNilAboveTheGateSaysTheCauseIsElsewhere() { + // The estimator has four other NaN exits (beats, span, grid, window). Naming the gate here would + // be wrong, so the line says only what it knows. + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, + beatAccurate: 0.83, rrIntegrity: "plausible"), + "resp day=2026-08-26 rpm=nil beatAccurate=0.83>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere" + ) + } + + func testANightWithNoHRVBlockReadsExactlyAsItAlwaysDid() { + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil), + "resp day=2026-08-26 rpm=nil" + ) + } + + func testTheBoundaryItselfPasses() { + // 0.50 is >= the gate, so it must NOT read as refused — an off-by-one here would blame the gate + // for a night it actually admitted. + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, + beatAccurate: 0.50, rrIntegrity: "plausible"), + "resp day=2026-08-26 rpm=nil beatAccurate=0.50>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere" + ) + } + + func testAnUnknownIntegrityIsLabelledRatherThanBlank() { + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, beatAccurate: 0.45), + "resp day=2026-08-26 rpm=nil beatAccurate=0.45<0.50 rrIntegrity=unknown" + + " — RSA gate refused the R-R" + ) + } + + func testANaNFractionReadsAsPassedMirroringTheGatesOwnNaNConvention() { + // beatValuesAreTrustworthy is written as !(f < MIN) precisely so NaN lands on TRUE — "not + // measured" must not be silently refused. This line uses the same `<` for the same reason. + // Rewriting it as `f >= MIN` would read identically for every real number and flip NaN to + // "refused", diverging from the gate it reports on. + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, + beatAccurate: Double.nan, rrIntegrity: "plausible"), + "resp day=2026-08-26 rpm=nil beatAccurate=NaN>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere" + ) + } +} diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 22c031b80e..6e02ac98d7 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -1051,6 +1051,11 @@ object IntelligenceEngine { // windowed avgHrv. Emitted here where `rr` is in scope; byte-identical to the Swift line. val sleepRrRows = rr.filter { r -> res.sleepSessions.any { r.ts >= it.start && r.ts < it.end } } val sleepRr = sleepRrRows.map { it.rrMs.toDouble() } + // #1331: the RSA gate's inputs, carried to the resp diagnostic below. Declared out here because + // the HRV block is one scope deeper; a night with no sleep R-R leaves them null and the resp + // line falls back to its original one-field form. + var respGateAcc: Double? = null + var respGateIntegrity: String? = null if (sleepRr.isNotEmpty()) { val h = HrvAnalyzer.analyzeRaw(sleepRr) val ms = { v: Double? -> v?.let { String.format(java.util.Locale.US, "%.0f", it) } ?: "nil" } @@ -1090,10 +1095,12 @@ object IntelligenceEngine { // right to ~1% (meanNN and RHR stay correct and WHOOP-validated) while the individual // intervals are not. Gate on that too. Twin of the Swift line. val accVal = HrvAnalyzer.beatAccurateFraction(ts, sleepRr) + respGateAcc = accVal val acc = String.format(java.util.Locale.US, "%.2f", accVal) val sdnnField = if (HrvAnalyzer.beatSpreadIsTrustworthy(verdict) && HrvAnalyzer.beatValuesAreTrustworthy(accVal)) "${ms(h.sdnn)}ms" else "withheld" + respGateIntegrity = verdict.raw dayDiag("hrv diag day=${res.daily.day} rmssd=${ms(h.rmssd)}ms sdnn=$sdnnField meanNN=${ms(h.meanNN)}ms " + "rr=${h.nInput}/${h.nClean} rejected=$rej% coverage=$cov collapsedCov=$colCov dupBeats=$dup " + "beatAccurate=$acc " + @@ -1208,8 +1215,8 @@ object IntelligenceEngine { nightlyRespByDay[day] = res.daily.respRateBpm // #1331 respiratory diagnostic: log each night's breaths/min (or "nil") so a "respiratory not // showing" report is explainable from the strap log — a run of nil nights localises when it - // stopped. Logging only; no scoring change. The Swift diag twin lands with the iOS carry (#1331 follow-up). - dayDiag(respRateLogLine(day, res.daily.respRateBpm)) + // stopped. Logging only; no scoring change. The Swift twin exists and emits the same line. + dayDiag(respRateLogLine(day, res.daily.respRateBpm, respGateAcc, respGateIntegrity)) // ── RHR floor-vs-mean diagnostic (#691) ──────────────────────────────────────────────── // Make the recurring "NOOP's resting HR reads LOWER than my sleeping-HR app" reports // explainable from the strap log instead of a guess. The two numbers measure different @@ -2712,10 +2719,44 @@ object IntelligenceEngine { "hrRows=$hrRows provenance=$provenance" } - /** #1331 diagnostic line: the night's computed respiratory rate (breaths/min) or "nil". Format kept - * simple so the planned Swift twin (iOS #1331 follow-up) can match it byte-for-byte. */ - internal fun respRateLogLine(day: String, respRateBpm: Double?): String = - "resp day=$day rpm=${respRateBpm?.let { String.format(Locale.US, "%.1f", it) } ?: "nil"}" + /** + * #1331 diagnostic line: the night's computed respiratory rate (breaths/min) or "nil". + * + * When the rate is nil the line now carries WHY, because "nil" on its own sent an investigation + * across two subsystems to find out. The RSA estimate needs per-beat-accurate R-R, so + * [HrvAnalyzer.beatValuesAreTrustworthy] refuses a stream whose intervals are not beat-to-beat + * measurements — and on a WHOOP 4.0 carrying the #1008/#1118 over-count that gate is what empties + * the card, on nearly every night, silently. Printing the fraction beside the boundary turns + * "Respiratory: No data" from a mystery into a reading. + * + * It distinguishes the two cases rather than asserting one: below the boundary the gate refused the + * R-R; at or above it the gate passed and the cause is one of the estimator's other exits (too few + * beats, too short a span, too coarse a grid), which this deliberately does not guess between. + * Omitting [beatAccurate] restores the original one-field line exactly, so a night that never + * reached the HRV block reads as it always did. + * + * Swift twin: `IntelligenceEngine.respRateLogLine`. + */ + internal fun respRateLogLine( + day: String, + respRateBpm: Double?, + beatAccurate: Double? = null, + rrIntegrity: String? = null, + ): String { + val base = "resp day=$day rpm=${respRateBpm?.let { String.format(Locale.US, "%.1f", it) } ?: "nil"}" + if (respRateBpm != null || beatAccurate == null) return base + // "NaN" explicitly rather than via %.2f: the JVM renders a non-finite as "NaN" and C-style + // %f renders it "nan", so leaving it to the formatter would make the twin lines differ on + // exactly the input the gate treats specially. One spelling, chosen here, on both platforms. + val acc = if (beatAccurate.isNaN()) "NaN" else String.format(Locale.US, "%.2f", beatAccurate) + val gate = String.format(Locale.US, "%.2f", HrvAnalyzer.BEAT_ACCURACY_MIN_FRACTION) + val integrity = rrIntegrity ?: "unknown" + return if (beatAccurate < HrvAnalyzer.BEAT_ACCURACY_MIN_FRACTION) { + "$base beatAccurate=$acc<$gate rrIntegrity=$integrity — RSA gate refused the R-R" + } else { + "$base beatAccurate=$acc>=$gate rrIntegrity=$integrity — gate passed, cause is elsewhere" + } + } /** * The per-day RHR floor-vs-mean diagnostic line (#691). NOOP's [floor] is the WHOOP-style resting diff --git a/android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt b/android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt new file mode 100644 index 0000000000..d1cb8baa36 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt @@ -0,0 +1,80 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins the resp diagnostic's nil-reason (#1331). Swift twin: `RespRateLogLineTests`. + * + * The line exists because "rpm=nil" alone cost a cross-subsystem investigation to explain: on a WHOOP + * 4.0 the RSA beat-accuracy gate empties the card on nearly every night, and nothing said so. + */ +class RespRateLogLineTest { + + @Test + fun `a real rate is unchanged and carries no reason`() { + assertEquals( + "resp day=2026-08-11 rpm=16.0", + IntelligenceEngine.respRateLogLine("2026-08-11", 16.0, 0.52, "crossSecondOverCount"), + ) + } + + @Test + fun `nil below the gate names the gate that refused it`() { + assertEquals( + "resp day=2026-08-26 rpm=nil beatAccurate=0.45<0.50 rrIntegrity=crossSecondOverCount" + + " — RSA gate refused the R-R", + IntelligenceEngine.respRateLogLine("2026-08-26", null, 0.45, "crossSecondOverCount"), + ) + } + + @Test + fun `nil above the gate says the cause is elsewhere rather than guessing`() { + // The estimator has four other NaN exits (beats, span, grid, window). Naming the gate here would + // be wrong, so the line says only what it knows. + assertEquals( + "resp day=2026-08-26 rpm=nil beatAccurate=0.83>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere", + IntelligenceEngine.respRateLogLine("2026-08-26", null, 0.83, "plausible"), + ) + } + + @Test + fun `a night with no HRV block reads exactly as it always did`() { + assertEquals("resp day=2026-08-26 rpm=nil", IntelligenceEngine.respRateLogLine("2026-08-26", null)) + } + + @Test + fun `the boundary itself passes`() { + // 0.50 is >= the gate, so it must NOT read as refused — an off-by-one here would blame the gate + // for a night it actually admitted. + val line = IntelligenceEngine.respRateLogLine("2026-08-26", null, 0.50, "plausible") + assertEquals( + "resp day=2026-08-26 rpm=nil beatAccurate=0.50>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere", + line, + ) + } + + @Test + fun `an unknown integrity is labelled rather than blank`() { + assertEquals( + "resp day=2026-08-26 rpm=nil beatAccurate=0.45<0.50 rrIntegrity=unknown" + + " — RSA gate refused the R-R", + IntelligenceEngine.respRateLogLine("2026-08-26", null, 0.45, null), + ) + } + + @Test + fun `a NaN fraction reads as passed, mirroring the gate's own NaN convention`() { + // beatValuesAreTrustworthy is written as !(f < MIN) precisely so NaN lands on TRUE — "not + // measured" must not be silently refused. This line uses the same `<` for the same reason. + // Rewriting it as `f >= MIN` would read identically for every real number and flip NaN to + // "refused", diverging from the gate it reports on. + assertEquals( + "resp day=2026-08-26 rpm=nil beatAccurate=NaN>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere", + IntelligenceEngine.respRateLogLine("2026-08-26", null, Double.NaN, "plausible"), + ) + } +}