From 725c163be7ac759359d72929ba5b43ec162cf06d Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:34:05 +0200 Subject: [PATCH] stress: take each live R-R packet once in the iOS check-in AppModel.ingestHR runs from two @Published sinks, heartRate and rr, and calls evaluateStress each time. A sink runs inside willSet, so the handler reads the packet before the one being written, and a packet whose heart rate also changed reached it twice: its intervals entered rrBuf twice and the detector's slow EMA baseline advanced on every call instead of every packet. Driven through a real LiveState in BLEManager's write order, 21 packets were taken as 800, 800, 801, 802, 802, ... RRPacketCursor states RRPacketObserver.swift's rule (take a packet once, keyed on rrSeq) for a consumer that is not a view; evaluateStress asks it first. setRRIntervals moves rr and rrSeq together, so live.rr always belongs to live.rrSeq whichever sink is running. The check-in is off by default; Android runs the same detector once per offload on rrRecent and is unchanged. Co-Authored-By: Claude Opus 5.5 --- Strand/App/AppModel.swift | 6 ++++ Strand/Screens/RRPacketObserver.swift | 17 +++++++++ StrandTests/RRPacketCursorTests.swift | 51 +++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 StrandTests/RRPacketCursorTests.swift diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 588a25d37e..db21cdeffb 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -169,6 +169,8 @@ final class AppModel: ObservableObject { // L3 stress-onset detector state: a rolling R-R buffer + the replay-safe detector state (persisted // via BiofeedbackPrefs so a relaunch can't re-fire), carried verbatim between evaluations. private var rrBuf: [Int] = [] + /// Which live R-R packet `rrBuf` last took, so each packet enters it once (`RRPacketCursor`). + private var stressPackets = RRPacketCursor() private var stressState = BiofeedbackPrefs.loadStressState() /// Import source currently writing to the local store, if any. @@ -1071,6 +1073,10 @@ final class AppModel: ObservableObject { /// baseline + rate limit), persisted via `BiofeedbackPrefs` so a relaunch can't re-fire. Honest / /// non-clinical: "stress" is an autonomic proxy vs the user's own baseline, never a diagnosis. private func evaluateStress() { + // Once per R-R packet. `ingestHR` runs from both the heart-rate and the R-R sink, so a packet reached + // this once or twice, its intervals entered `rrBuf` as often, and the detector's slow baseline + // advanced on every call rather than every packet. + guard stressPackets.isNew(live.rrSeq) else { return } let fresh = live.rr.filter { $0 > 300 && $0 < 2000 } // plausible R-R (30–200 bpm) guard !fresh.isEmpty else { return } rrBuf.append(contentsOf: fresh) diff --git a/Strand/Screens/RRPacketObserver.swift b/Strand/Screens/RRPacketObserver.swift index 97aefd8a9d..e5822f6160 100644 --- a/Strand/Screens/RRPacketObserver.swift +++ b/Strand/Screens/RRPacketObserver.swift @@ -12,3 +12,20 @@ extension View { } } } + +/// The same rule for a consumer that is not a view: take a live R-R packet once, keyed on `rrSeq`. +/// +/// A `@Published` sink runs inside `willSet`, before the new value lands, so a handler that reads `live` from +/// a sink sees the packet the strap sent BEFORE the one being written. `setRRIntervals` moves `rr` and `rrSeq` +/// together, so `live.rr` always belongs to `live.rrSeq`, whichever sink is running: asking this cursor whether +/// that sequence is new takes every packet exactly once, however many sinks reach the handler for it. +struct RRPacketCursor { + private(set) var lastSeq = 0 + + /// True the first time `seq` is offered, and false for any repeat of it. + mutating func isNew(_ seq: Int) -> Bool { + guard seq != lastSeq else { return false } + lastSeq = seq + return true + } +} diff --git a/StrandTests/RRPacketCursorTests.swift b/StrandTests/RRPacketCursorTests.swift new file mode 100644 index 0000000000..cf376d8b28 --- /dev/null +++ b/StrandTests/RRPacketCursorTests.swift @@ -0,0 +1,51 @@ +import XCTest +import Combine +@testable import Strand + +/// A handler reached from `LiveState`'s heart-rate AND R-R sinks — as `AppModel.ingestHR`, and through it the stress +/// check-in, is — takes each R-R packet once when it asks `RRPacketCursor`. +@MainActor +final class RRPacketCursorTests: XCTestCase { + + func testASequenceIsNewOnlyTheFirstTime() { + var cursor = RRPacketCursor() + XCTAssertFalse(cursor.isNew(0)) // nothing has arrived yet + XCTAssertTrue(cursor.isNew(1)) + XCTAssertFalse(cursor.isNew(1)) + XCTAssertTrue(cursor.isNew(2)) + } + + /// Writes a real `LiveState` the way BLEManager's standard-HR path does — the packet's intervals first, then the + /// heart rate only when it changed — and collects `live.rr` from both sinks, as AppModel wires them. + private func feedStrap(_ packets: [(bpm: Int, rr: Int)], throughCursor: Bool) -> [Int] { + let live = LiveState() + var cursor = RRPacketCursor() + var taken: [Int] = [] + let handler = { + if throughCursor, !cursor.isNew(live.rrSeq) { return } + taken.append(contentsOf: live.rr) + } + var subscriptions = Set() + live.$heartRate.sink { _ in handler() }.store(in: &subscriptions) + live.$rr.sink { _ in handler() }.store(in: &subscriptions) + for packet in packets { + live.setRRIntervals([packet.rr]) + if live.heartRate != packet.bpm { live.heartRate = packet.bpm } + } + return taken + } + + /// 21 packets, the heart rate moving every second one (and on the last, so it too is handed over). + private let packets = (0...20).map { (bpm: 70 + $0 / 2, rr: 800 + $0) } + + func testEachPacketIsTakenOnceAndInOrder() { + XCTAssertEqual(feedStrap(packets, throughCursor: true), packets.map(\.rr)) + } + + /// What the stress check-in did before: the same packets, read from both sinks with no cursor. + func testWithoutTheCursorPacketsAreTakenMoreThanOnce() { + let taken = feedStrap(packets, throughCursor: false) + XCTAssertGreaterThan(taken.count, packets.count) + XCTAssertEqual(Array(Set(taken)).sorted(), packets.map(\.rr)) + } +}