Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions StrandTests/RespRateLogLineTests.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
53 changes: 47 additions & 6 deletions android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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 " +
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
)
}
}
Loading