Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,31 @@ public enum AnalyticsEngine {
/// Pair the strap's WRIST_OFF/WRIST_ON events into off-wrist `[start, end)` intervals for the sleep
/// detector's fractional wear filter (#500; design credited to j0b-dev's #504). Each WRIST_OFF opens
/// an interval that closes at the next WRIST_ON, or at `windowEnd` if the strap is still off at the
/// end of the read window. Events need not be pre-sorted; kinds are formatted "NAME(n)" (e.g.
/// end of the read window. An unmatched tail may end earlier when sustained valid HR resumes;
/// explicit OFF/ON pairs are never shortened. Events need not be pre-sorted; kinds are formatted "NAME(n)" (e.g.
/// "WRIST_OFF(10)"), matched by prefix. Repeated OFFs/ONs without a partner are coalesced.
public static func offWristIntervals(events: [WhoopEvent], windowEnd: Int) -> [(start: Int, end: Int)] {
public static func offWristIntervals(events: [WhoopEvent], windowEnd: Int,
hr: [HRSample] = []) -> [(start: Int, end: Int)] {
let wear = events
.filter { $0.kind.hasPrefix("WRIST_OFF") || $0.kind.hasPrefix("WRIST_ON") }
.sorted { $0.ts < $1.ts }
var intervals: [(start: Int, end: Int)] = []
var offStart: Int? = nil
for e in wear {
var lastOff: Int? = nil
for e in wear where e.ts <= windowEnd {
if e.kind.hasPrefix("WRIST_OFF") {
if offStart == nil { offStart = e.ts } // ignore repeated OFFs
if offStart == nil { offStart = e.ts }
lastOff = e.ts // a repeated OFF invalidates evidence before it
} else { // WRIST_ON closes an open off-wrist span
if let s = offStart, e.ts > s { intervals.append((start: s, end: e.ts)) }
offStart = nil
}
}
if let s = offStart, windowEnd > s { intervals.append((start: s, end: windowEnd)) }
if let s = offStart, windowEnd > s {
let end = WristWearRecovery.firstSustainedHR(hr, after: lastOff ?? s, before: windowEnd)
?? windowEnd
if end > s { intervals.append((start: s, end: end)) }
}
return intervals
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import WhoopProtocol

/// Reconcile a missing WRIST_ON event without declaring an unobserved tail worn.
/// Only the unpaired OFF tail uses this evidence; explicit OFF/ON intervals stay authoritative.
/// The 30...220 bpm range matches AnalyticsEngine's existing worn-HR gate. Five minutes with
/// no gap over five seconds rejects isolated pulses and sparse streams. The returned boundary is
/// the first observed sample of that confirmed run, never the OFF timestamp or a fabricated event.
/// This is event reconciliation, not a sleep classifier; all sleep and HR-gap gates still run.
public enum WristWearRecovery {
public static let confirmationSeconds = 5 * 60
public static let maximumGapSeconds = 5

public static func firstSustainedHR(_ hr: [HRSample], after: Int, before: Int) -> Int? {
// Collapse duplicate timestamps conservatively: an invalid observation wins a conflict.
var validByTimestamp: [Int: Bool] = [:]
for sample in hr where sample.ts > after && sample.ts < before {
validByTimestamp[sample.ts] = (validByTimestamp[sample.ts] ?? true)
&& (30...220).contains(sample.bpm)
}
var start: Int?
var previous: Int?
for ts in validByTimestamp.keys.sorted() {
guard validByTimestamp[ts] == true else {
start = nil; previous = nil
continue
}
if previous == nil || ts - previous! > maximumGapSeconds { start = ts }
previous = ts
if let start, ts - start >= confirmationSeconds { return start }
}
return nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import XCTest
import WhoopProtocol
@testable import StrandAnalytics

final class WristWearRecoveryTests: XCTestCase {
private func hr(_ from: Int, _ to: Int, step: Int = 1, bpm: Int = 60) -> [HRSample] {
stride(from: from, through: to, by: step).map { HRSample(ts: $0, bpm: bpm) }
}
private func event(_ ts: Int, _ off: Bool) -> WhoopEvent {
WhoopEvent(ts: ts, kind: off ? "WRIST_OFF(10)" : "WRIST_ON(9)", payload: [:])
}
private func spans(_ events: [WhoopEvent], _ samples: [HRSample], end: Int = 4000) -> [String] {
AnalyticsEngine.offWristIntervals(events: events, windowEnd: end, hr: samples)
.map { "\($0.start):\($0.end)" }
}

func testMissingOnEndsAtStartOfSustainedHR() {
XCTAssertEqual(spans([event(100, true)], hr(1000, 1600)), ["100:1000"])
XCTAssertEqual(spans([event(100, true)], []), ["100:4000"])
XCTAssertEqual(spans([], hr(1000, 1600)), [])
}

func testPairedEventsStayAuthoritativeEvenWithDenseHR() {
XCTAssertEqual(spans([event(100, true), event(3000, false)], hr(1000, 3500)), ["100:3000"])
}

func testRepeatedOffRestartsEvidenceAndPreservesEarlierPairs() {
let events = [event(2500, true), event(100, true), event(2000, true), event(500, false)]
XCTAssertEqual(spans(events, hr(2100, 2900)), ["100:500", "2000:2501"])
XCTAssertEqual(spans(events, hr(2100, 2700)), ["100:500", "2000:4000"])
}

func testFutureSamplesAndEventsCannotCloseCurrentTail() {
XCTAssertEqual(spans([event(100, true), event(5000, false)], hr(4100, 4500)), ["100:4000"])
XCTAssertEqual(spans([event(5000, true)], hr(1000, 1600)), [])
}

func testFiveMinuteConfirmationAndFiveSecondGapBoundaries() {
XCTAssertEqual(WristWearRecovery.firstSustainedHR(hr(1000, 1300, step: 5), after: 0, before: 2000), 1000)
XCTAssertNil(WristWearRecovery.firstSustainedHR(hr(1000, 1299), after: 0, before: 2000))
XCTAssertNil(WristWearRecovery.firstSustainedHR(hr(1000, 1600, step: 6), after: 0, before: 2000))
XCTAssertNil(WristWearRecovery.firstSustainedHR(hr(1000, 1300), after: 1000, before: 2000))
XCTAssertNil(WristWearRecovery.firstSustainedHR(hr(1000, 1300), after: 0, before: 1300))
}

func testGapsAndInvalidReadingsResetConfirmation() {
XCTAssertEqual(WristWearRecovery.firstSustainedHR(hr(1000, 1150) + hr(1200, 1500), after: 0, before: 2000), 1200)
for invalid in [0, 29, 221, 255] {
let samples = hr(1000, 1199) + [HRSample(ts: 1200, bpm: invalid)] + hr(1201, 1600)
XCTAssertEqual(WristWearRecovery.firstSustainedHR(samples, after: 0, before: 2000), 1201)
}
}

func testDuplicatesCannotManufactureCoverageAndInvalidWinsConflict() {
XCTAssertNil(WristWearRecovery.firstSustainedHR(Array(repeating: HRSample(ts: 1000, bpm: 60), count: 1000), after: 0, before: 2000))
let samples = hr(1000, 1600) + [HRSample(ts: 1200, bpm: 0)]
for rows in [samples, Array(samples.reversed())] {
XCTAssertEqual(WristWearRecovery.firstSustainedHR(rows, after: 0, before: 2000), 1201)
}
}

func testRecoveredNightMatchesControlAndPairedOffStillDropsIt() {
let start = 2 * 3600, end = start + 90 * 60
let gravity = stride(from: start, through: end, by: 5).map {
GravitySample(ts: $0, x: 0, y: 0, z: 1, unit: "g")
}
let samples = hr(start - 900, end, step: 5, bpm: 50)
let control = SleepStager.detectSleep(hr: samples, gravity: gravity)
XCTAssertEqual(control.count, 1)
let recovered = AnalyticsEngine.offWristIntervals(events: [event(start - 1800, true)], windowEnd: end + 1, hr: samples)
let actual = SleepStager.detectSleep(hr: samples, gravity: gravity, wristOff: recovered)
XCTAssertEqual(actual.map { $0.start }, control.map { $0.start })
XCTAssertEqual(actual.map { $0.end }, control.map { $0.end })
let paired = AnalyticsEngine.offWristIntervals(events: [event(start - 1800, true), event(end, false)], windowEnd: end + 1, hr: samples)
XCTAssertTrue(SleepStager.detectSleep(hr: samples, gravity: gravity, wristOff: paired).isEmpty)
}

func testRecoveryDoesNotDisableSubsequentHRGapGuard() {
let samples = hr(100, 1000) + hr(8000, 9000)
let off = AnalyticsEngine.offWristIntervals(events: [event(0, true)], windowEnd: 10000, hr: samples)
let period = SleepStager.Period(stage: "sleep", start: 2000, end: 7000)
XCTAssertEqual(SleepStager.offWristFraction(period, hr: samples, wristOff: off), 1)
}

// Swift oracle output is pinned verbatim in the Kotlin twin. Offsets and input cadence vary;
// neither fixtures nor expected values contain a wearer's recorded samples or timestamps.
func testParityOracle() {
var values: [String] = []
for offset in [0, 86400, 1700000000] {
for step in [1, 5, 6, 60] {
for duration in [299, 300, 600] {
let result = WristWearRecovery.firstSustainedHR(
hr(offset + 100, offset + 100 + duration, step: step), after: offset, before: offset + 1000)
values.append(result.map(String.init) ?? "nil")
}
}
}
let oracle = values.joined(separator: ",")
print("WRIST_ORACLE=\(oracle)")
XCTAssertEqual(oracle, "nil,100,100,nil,100,100,nil,nil,nil,nil,nil,nil,nil,86500,86500,nil,86500,86500,nil,nil,nil,nil,nil,nil,nil,1700000100,1700000100,nil,1700000100,1700000100,nil,nil,nil,nil,nil,nil")
}
}
5 changes: 3 additions & 2 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -486,8 +486,8 @@ final class AppModel: ObservableObject {
// flag → no-op on every subsequent launch; idempotent on a clean DB.
await self.intelligence.runTimestampHealIfNeeded()
// One-shot on-upgrade Effort rescore (#313): recompute strain from source across the FULL
// history once, so any deep-history rows an older build left on the 0–21 axis regenerate on
// the 0–100 axis. Guarded by a persisted flag, so this is a no-op on every subsequent launch.
// history and repair sleep rejected by unmatched WRIST_OFF in one pass. Both persisted flags
// describe that shared pass; either pending flag triggers it.
await self.intelligence.runEffortRescoreIfNeeded()
while !Task.isCancelled {
// #547 RE-POLLUTION: a sync since the last tick may have armed a re-heal (its ingest gate
Expand Down Expand Up @@ -705,6 +705,7 @@ final class AppModel: ObservableObject {
/// so that it cannot mark unscored data as scored — so gating on the fingerprint here would be asking
/// a question whose answer is already known to be "yes, there is work".
func runDeferredRescoreIfOwed() async {
await intelligence.runSleepWearRescoreIfNeeded()
// A pass already running here holds the owed mark itself and settles it when it finishes; forcing
// another would only queue a second full pass behind it.
guard RescoreBackgroundScheduler.isRescoreOwed, !intelligence.computing else { return }
Expand Down
Loading
Loading