From 2dea677ba87645a913e59ece4f3aedae49e2ee13 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 26 Aug 2026 10:36:59 +1200 Subject: [PATCH 1/3] diagnostics: make a nil respiratory rate say which gate refused it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Respiratory: No data" on a WHOOP 4.0, with 106,673 raw respiratory samples banked and HRV computing on all 22 of the same nights. The log said only `resp day=2026-08-26 rpm=nil`, which is true and useless: finding the cause meant cross-referencing the resp trace against the HRV diagnostic by hand, on a device where 21 of 22 nights were affected. The cause: the RSA estimate needs per-beat-accurate R-R, so beatValuesAreTrustworthy refuses a stream whose intervals are not beat-to-beat measurements. On this strap beatAccurate reads 0.44-0.52 against a 0.50 boundary, with rrIntegrity=crossSecondOverCount — the #1008/#1118 over-count. It straddles the gate, so the card fills on the rare night that clears it and is empty the rest of the time. On the capture that prompted this, the ONE day with a rate (2026-08-11, 16.0 bpm) is the ONE day that measured 0.52; all 22 days match that rule exactly. Print the fraction beside the boundary when the rate is nil. It reports what it knows and no more: 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 four other exits (beats, span, grid, window), which the line deliberately does not guess between. A night that never reached the HRV block emits the original one-field line unchanged. Instrumentation only — no scoring or gating change. Deliberately NOT lowering BEAT_ACCURACY_MIN_FRACTION: a stream at 0.45 is exactly the population the constant's own docstring says we should not guess about, and moving the boundary to fill the card would silently un-withhold SDNN on the same evidence. The fix belongs in #1118. Both platforms, byte-identical output across six cases, diffed rather than eyeballed. 6 JVM tests and a twinned StrandTests file, including the boundary case (0.50 must read as passed, not refused) and the no-HRV-block fallback. Verified: compileFullDebugKotlin clean; full com.noop.analytics suite (1698 tests) green; doc_comment_lint and i18n_audit clean. The Swift edits are app-target and need app-build. --- Strand/Data/IntelligenceEngine.swift | 45 +++++++++++-- StrandTests/RespRateLogLineTests.swift | 63 +++++++++++++++++ .../com/noop/analytics/IntelligenceEngine.kt | 48 +++++++++++-- .../com/noop/analytics/RespRateLogLineTest.kt | 67 +++++++++++++++++++ 4 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 StrandTests/RespRateLogLineTests.swift create mode 100644 android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index e30bf429a9..0365ace05f 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -251,10 +251,35 @@ 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 } + let acc = 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 +1170,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 +1232,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 +1241,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 +1357,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..80d435fffe --- /dev/null +++ b/StrandTests/RespRateLogLineTests.swift @@ -0,0 +1,63 @@ +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" + ) + } +} 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..5001aad775 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 " + @@ -1209,7 +1216,7 @@ object IntelligenceEngine { // #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)) + 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,41 @@ 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 + val acc = 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..71982d4965 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt @@ -0,0 +1,67 @@ +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), + ) + } +} From bab8a7a830b5f09922096b0637f45d60bddf5632 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 26 Aug 2026 11:00:55 +1200 Subject: [PATCH 2/3] diagnostics: pin the non-finite rendering so the twin lines cannot diverge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review. Adding a NaN case to the tests exposed a real parity break in the code under test: the JVM renders a non-finite double as "NaN" and C-style %f renders it "nan", so the two platforms would have emitted different bytes for exactly the input the gate treats specially — and the Swift test I had just written would have failed in app-build. Both sides now spell it explicitly rather than leaving it to the formatter. Re-diffed across all seven cases including NaN: byte-identical. The NaN case is worth testing at all because the gate is written as !(f < MIN) deliberately, so NaN lands on "trustworthy" — an unmeasured window must not be silently refused. This line uses the same `<` for the same reason, and a rewrite to `f >= MIN` would read identically for every real number while flipping NaN to "refused". The test pins the mirroring. Also confirmed this pass, both platforms: the gate variables are declared INSIDE the per-day loop (Kotlin `for (offset in 0 until maxDays)` line 754, Swift `for offset in 0.. String { let base = "resp day=\(day) rpm=\(respRateBpm.map { String(format: "%.1f", $0) } ?? "nil")" guard respRateBpm == nil, let beatAccurate else { return base } - let acc = String(format: "%.2f", beatAccurate) + // "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 { diff --git a/StrandTests/RespRateLogLineTests.swift b/StrandTests/RespRateLogLineTests.swift index 80d435fffe..9610478cee 100644 --- a/StrandTests/RespRateLogLineTests.swift +++ b/StrandTests/RespRateLogLineTests.swift @@ -60,4 +60,17 @@ final class RespRateLogLineTests: XCTestCase { + " — 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 5001aad775..5d18aa7f0e 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -2745,7 +2745,10 @@ object IntelligenceEngine { ): String { val base = "resp day=$day rpm=${respRateBpm?.let { String.format(Locale.US, "%.1f", it) } ?: "nil"}" if (respRateBpm != null || beatAccurate == null) return base - val acc = String.format(Locale.US, "%.2f", beatAccurate) + // "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) { From 2698c83c9f5428dd2cb99dae531730f17d01a182 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 26 Aug 2026 11:29:25 +1200 Subject: [PATCH 3/3] diagnostics: add the Kotlin NaN test that never made it onto the branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review, and this one is a correction to my own previous commit. Its `git add` used the main/ path and missed test/, so the Kotlin NaN case stayed in my working tree: the pushed branch carried SIX Kotlin tests against SEVEN Swift ones — a parity break in the PR's own coverage, in a change whose whole subject is keeping the two lines identical. The "7 JVM tests green" line in that commit message described my working tree, not what I had pushed. It is on the branch now. Also corrected a comment two lines above the call site claiming "the Swift diag twin lands with the iOS carry (#1331 follow-up)". It landed some time ago — Swift has respRateLogLine and emits the line — so the comment was telling a reader to expect a gap that is not there, next to code I was already editing. No behaviour change. --- .../java/com/noop/analytics/IntelligenceEngine.kt | 2 +- .../java/com/noop/analytics/RespRateLogLineTest.kt | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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 5d18aa7f0e..6e02ac98d7 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -1215,7 +1215,7 @@ 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). + // 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 diff --git a/android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt b/android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt index 71982d4965..d1cb8baa36 100644 --- a/android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RespRateLogLineTest.kt @@ -64,4 +64,17 @@ class RespRateLogLineTest { 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"), + ) + } }