From 9790531a179cc897774ba0a3a1d6daf6febf095c Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Tue, 18 Aug 2026 03:59:00 +0200 Subject: [PATCH] Align rollingRmssd across platforms: (t-w,t] window, per-window cleaning (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The differential pilot (#24) reported three real divergences in rollingRmssd; this aligns them to one convention per divergence: - window boundary: (t - windowSec, ts] on both sides — Swift's left edge moves from > to >= (adopting the Kotlin semantics); - artifact cleaning: locally per window on both sides — Kotlin's global pre-cleaning of the whole series is removed (adopting the Swift semantics: artifact rejection belongs to the window's own local statistics); - Kotlin's value-based timestamp rematching is removed entirely — cleaning the window's raw samples directly makes it structurally unnecessary (it was the source of the extra emitted point). BEHAVIOR CHANGE, both platforms: rolling-RMSSD timeline values can shift slightly; points near the window edge or the min-beats bar can appear or disappear. In the pilot, Swift lost two 10 ms points at the window boundary; Android lost spurious extra points (117.48 ms, 141.42 ms). The nightly, non-rolling HRV computation is untouched. Proven through the harness: pilot 14 cases / 3 diffs before, 0 after; reverting the Swift boundary fix alone makes it red again (1 diff), re-applying returns it to green. No existing test pinned the old special-case values, so no test numbers were adjusted. Full suites: Swift 1429 / 2 skipped / 0 failures; Android 3977 / 0 / 6. Part of the drift-control plan in #17 (step 5). Fixes the #9 class. Co-Authored-By: Codex Co-Authored-By: Claude Fable 5 --- .../Sources/StrandAnalytics/HRVAnalyzer.swift | 15 +++-- .../java/com/noop/analytics/HrvAnalyzer.kt | 55 +++++++------------ 2 files changed, 28 insertions(+), 42 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift index 8b368db4df..64e573295a 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift @@ -331,12 +331,11 @@ public enum HRVAnalyzer { public init(ts: Int, rmssd: Double) { self.ts = ts; self.rmssd = rmssd } } - /// Pure rolling/windowed rMSSD over an R-R series (#803). For each input interval, the window is the - /// trailing `windowSec` seconds ending at that interval's `ts`; the window's R-R values are cleaned with - /// the SAME range filter + Malik ectopic rejection the nightly path uses (`cleanRR`), and a point is - /// emitted only when at least `minBeatsPerWindow` clean intervals survive (so a sparse / artifact-heavy - /// window emits nothing rather than a noisy spike). The result is one `(ts, rMSSD)` per qualifying - /// window, in input order. + /// Pure rolling/windowed rMSSD over an R-R series (#803). For each input interval at `ts`, the window is + /// `(ts - windowSec, ts]`; that window's raw R-R values are cleaned locally with the SAME range filter + + /// Malik ectopic rejection the nightly path uses (`cleanRR`). A point is emitted only when at least + /// `minBeatsPerWindow` clean intervals survive (so a sparse / artifact-heavy window emits nothing rather + /// than a noisy spike). The result is one `(ts, rMSSD)` per qualifying window, in timestamp order. /// /// - Parameters: /// - rr: the R-R intervals (each carries its own wall-clock `ts` and `rrMs`). Need not be pre-sorted; @@ -358,8 +357,8 @@ public enum HRVAnalyzer { var left = 0 // index of the oldest interval still inside the trailing window for right in 0.. windowSec { left += 1 } + // Advance the left edge so [left, right] contains exactly (edgeTs - windowSec, edgeTs]. + while left < right && edgeTs - sorted[left].ts >= windowSec { left += 1 } // Thinning stride: skip emitting until at least `stepSec` has passed since the last emitted point. if stepSec > 0, let last = lastEmitTs, edgeTs - last < stepSec { continue } // Clean the window's raw R-R values with the shared range + Malik ectopic pipeline, then diff --git a/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt b/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt index 8b0e41c6ef..43f3d1e873 100644 --- a/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt +++ b/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt @@ -651,11 +651,12 @@ object HrvAnalyzer { // The Deep Timeline's "HRV" trace used to plot RAW RR-interval values (ms) and label them "HRV", // which is dishonest: an RR interval is NOT an HRV number, and the spikiness it shows is just the // beat-to-beat heart period, not variability. [rollingRmssd] is the pure, on-device twin of the - // Swift HRVAnalyzer.rollingRmssd: it slides a [windowSec] window across the cleaned RR series and, - // for each RR sample, emits the Task-Force rMSSD over the beats inside that trailing window. The - // SAME Malik/range artifact filter the nightly path uses ([cleanRR]) is applied first, so an - // ectopic beat or an out-of-range RR can't inflate the curve. The Deep Timeline plots THIS, relabelled - // to honest windowed rMSSD. Pure: no clock, no IO. (#803) + // Swift HRVAnalyzer.rollingRmssd: it slides a [windowSec] window across the raw RR series and, for + // each RR sample, cleans the beats inside that trailing window before emitting the Task-Force rMSSD. + // The SAME Malik/range artifact filter the nightly path uses ([cleanRR]) is applied independently in + // each window, so an ectopic beat or an out-of-range RR can't inflate the curve or couple otherwise + // separate windows. The Deep Timeline plots THIS, relabelled to honest windowed rMSSD. Pure: no clock, + // no IO. (#803) /** Default rolling-window width (seconds) for the Deep Timeline windowed rMSSD trace. ~5 min, the * shortest span the Task Force calls a short-term recording, so the curve has enough beats to mean @@ -663,13 +664,12 @@ object HrvAnalyzer { const val DEFAULT_ROLLING_WINDOW_SEC: Int = 300 /** - * Rolling/windowed rMSSD over an RR series. For each input sample (ascending by ts), computes the - * Task-Force rMSSD over the cleaned beats whose ts falls in the trailing window `(ts - windowSec, ts]`, - * emitting `(ts, rmssd)` only when at least [minBeatsPerWindow] clean beats survive in that window (a - * 2-beat window is one successive difference = a noisy spike, not HRV — matches the Swift twin's - * minBeatsPerWindow gate). Range + Malik ectopic filtering ([cleanRR]) is applied to the WHOLE series - * once, so artifacts never enter any window. Empty when fewer than [minBeatsPerWindow] input rows. - * Pure, deterministic. + * Rolling/windowed rMSSD over an RR series. For each input sample at `ts`, the window is + * `(ts - windowSec, ts]`; that window's raw RR values are cleaned locally with the same range + Malik + * ectopic filter the nightly path uses ([cleanRR]). Emits `(ts, rmssd)` only when at least + * [minBeatsPerWindow] clean beats survive (a 2-beat window is one successive difference = a noisy + * spike, not HRV — matches the Swift twin's minBeatsPerWindow gate). Empty when fewer than + * [minBeatsPerWindow] input rows. Pure, deterministic. * * @param rr the RR intervals (each carries a ts in unix SECONDS and rrMs); the Android twin of the * Swift `[RRSample]`. @@ -692,39 +692,26 @@ object HrvAnalyzer { // Ascending by ts so the trailing-window scan is monotone (the table read is already ordered, but // we don't assume it). Stable on equal ts. val sorted = rr.sortedBy { it.ts } - // Clean the WHOLE series first (range + Malik ectopic), keeping each surviving beat's ts so a - // window can be cut by timestamp. cleanRR works on the raw ms values; we re-pair to ts by walking - // the same filters here so the kept ts/ms stay aligned (cleanRR drops items, losing the index map). - val ranged = sorted.filter { it.rrMs.toDouble() in RR_MIN_MS..RR_MAX_MS } - if (ranged.size < 2) return emptyList() - val cleanMs = rejectEctopic(ranged.map { it.rrMs.toDouble() }) - // rejectEctopic preserves order and only drops items, so re-pair by walking both in lockstep: a - // kept ms value corresponds to the next not-yet-consumed ranged beat with that value. - val kept = ArrayList(cleanMs.size) - var ri = 0 - for (ms in cleanMs) { - while (ri < ranged.size && ranged[ri].rrMs.toDouble() != ms) ri++ - if (ri < ranged.size) { kept.add(ranged[ri]); ri++ } - } - if (kept.size < 2) return emptyList() val window = windowSec.toLong() - val out = ArrayList>(kept.size) + val out = ArrayList>(sorted.size) var lo = 0 var lastEmitTs: Long? = null - for (hi in kept.indices) { - val tEnd = kept[hi].ts + for (hi in sorted.indices) { + val tEnd = sorted[hi].ts val tStart = tEnd - window // Advance the trailing edge so only beats with ts in (tStart, tEnd] remain. - while (lo < hi && kept[lo].ts <= tStart) lo++ + while (lo < hi && sorted[lo].ts <= tStart) lo++ // Thinning stride (#1036): skip emitting until at least [stepSec] has passed since the last // EMITTED point (measured against emits, not candidates), matching the Swift twin's stepSec branch. val last = lastEmitTs if (stepSec > 0 && last != null && tEnd - last < stepSec) continue - val span = kept.subList(lo, hi + 1).map { it.rrMs.toDouble() } + // Clean this raw window in place. Timestamps stay on the samples used to define the window, + // so no value-based matching back to timestamps is needed after cleaning. + val clean = cleanRR(sorted.subList(lo, hi + 1).map { it.rrMs.toDouble() }) // A window with too few clean beats is a noisy spike, not a trustworthy rMSSD — require // [minBeatsPerWindow] survivors (#1035), matching the Swift HRVAnalyzer.rollingRmssd default (8). - if (span.size < minBeatsPerWindow) continue - val r = rmssdRaw(span) ?: continue + if (clean.size < minBeatsPerWindow) continue + val r = rmssdRaw(clean) ?: continue out.add(tEnd to r) lastEmitTs = tEnd }