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
69 changes: 69 additions & 0 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,57 @@ public enum SleepStager {
return merged
}

/// Why `bridgeSparseSleep` did or did not merge one adjacent pair of runs (#737).
///
/// The existing trace reports only `runsBefore`/`runsAfter`, so a bridge that changed NOTHING gives
/// no reason — and there are three very different causes (gap over the tolerance, HR outside the
/// sleep band, or the pair simply not being two adjacent sleep runs). A reporter's night showed
/// `runsBefore=13 runsAfter=13` with 8 runs then discarded by `minSleepMin`, and the log could not
/// say which cause applied, so no fix could be chosen responsibly. This names it per pair.
struct SparseBridgeAttempt: Equatable, Sendable {
/// Gap between the two runs, in whole minutes (negative when they overlap).
let gapMin: Int
/// Whether the intervening HR stayed in the sleep band (the bridge's second condition).
let hrInSleepBand: Bool
/// Whether this pair was merged.
let bridged: Bool
/// Stable token for the log: bridged / gapTooLong / hrOutOfBand / overlap.
let reason: String
}

/// Per-pair explanation of `bridgeSparseSleep`, mirroring its rule EXACTLY (same adjacency walk,
/// same `gap >= 0 && gap <= sparseBridgeGapMin*60`, same HR-band check) so the reasons describe what
/// actually happened rather than an approximation. Only pairs the bridge itself CONSIDERS (two
/// adjacent sleep runs) produce an attempt; a pair separated by an active run is never considered,
/// which is itself the answer when no attempts are reported. Pure — no I/O, no side effects.
static func sparseBridgeAttempts(_ periods: [Period], sparse: Bool,
hr: [HRSample], baseline: Double?) -> [SparseBridgeAttempt] {
guard sparse, !periods.isEmpty else { return [] }
let bridgeGapS = sparseBridgeGapMin * 60
var attempts: [SparseBridgeAttempt] = []
var out: [Period] = []
for p in periods {
if let last = out.last, last.stage == "sleep", p.stage == "sleep" {
let gap = p.start - last.end
let inBand = hrSleepBandAcross(last.end, p.start, hr: hr, baseline: baseline)
let bridged = gap >= 0 && gap <= bridgeGapS && inBand
let reason: String
if bridged { reason = "bridged" }
else if gap < 0 { reason = "overlap" }
else if gap > bridgeGapS { reason = "gapTooLong" }
else { reason = "hrOutOfBand" }
attempts.append(SparseBridgeAttempt(gapMin: gap / 60, hrInSleepBand: inBand,
bridged: bridged, reason: reason))
if bridged {
out[out.count - 1] = Period(stage: "sleep", start: last.start, end: p.end)
continue
}
}
out.append(p)
}
return attempts
}

/// Sparse-gravity bridge (#308): merge two adjacent SLEEP runs separated ONLY by a gap up to
/// sparseBridgeGapMin minutes when the intervening HR stays in the sleep band — so a real night
/// fragmented by gravity dropouts is re-stitched into one continuous in-bed span BEFORE the
Expand Down Expand Up @@ -875,6 +926,10 @@ public enum SleepStager {
runs = mergePeriods(runs)
// Re-stitch sleep runs fragmented by pure gravity dropouts (sparse only) before minSleepMin.
let runsBeforeBridge = traceSink == nil ? 0 : runs.filter { $0.stage == "sleep" }.count
// #737: capture the per-pair reasons BEFORE the merge mutates `runs`, so a bridge that changed
// nothing still says why (gapTooLong / hrOutOfBand / overlap) instead of only before==after.
let bridgeAttempts = traceSink == nil ? [] : sparseBridgeAttempts(runs, sparse: sparse, hr: hrS,
baseline: baseline)
runs = bridgeSparseSleep(runs, sparse: sparse, hr: hrS, baseline: baseline)
// Sleep & Rest test mode (E3): record the sparse-gravity bridge result, so a sparse 5.0 night
// rescued from fragmentation is visible. Only emitted when gravity is sparse (the only case the
Expand All @@ -884,6 +939,20 @@ public enum SleepStager {
traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0,
verdict: runsAfterBridge < runsBeforeBridge ? .kept : .dropped, gate: "sparseBridge",
detail: "sparse=true gapMin=\(sparseBridgeGapMin) runsBefore=\(runsBeforeBridge) runsAfter=\(runsAfterBridge)"))
// #737: one line per pair the bridge CONSIDERED. No lines at all means no two adjacent sleep
// runs were ever seen — i.e. the fragments are separated by active runs, which the bridge
// deliberately never crosses. That absence is itself the diagnosis.
if bridgeAttempts.isEmpty {
traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0, verdict: .dropped,
gate: "sparseBridge",
detail: "no adjacent sleep pairs considered (fragments separated by active runs)"))
}
for (i, a) in bridgeAttempts.enumerated() {
traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0,
verdict: a.bridged ? .kept : .dropped, gate: "sparseBridgePair",
detail: "pair=\(i) gapMin=\(a.gapMin) hrInSleepBand=\(a.hrInSleepBand) "
+ "reason=\(a.reason)"))
}
}

let minSleepS = minSleepMin * 60
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import XCTest
import WhoopProtocol
@testable import StrandAnalytics

/// #737: the per-pair sparse-bridge diagnostic must describe what `bridgeSparseSleep` ACTUALLY did, so a
/// "runsBefore == runsAfter" night finally says why. Every test pins the diagnostic against the real
/// bridge rather than asserting it in isolation.
final class SparseBridgeAttemptTests: XCTestCase {

/// Sleep HR well under any plausible baseline, so `hrSleepBandAcross` is satisfied when we want it.
private func sleepHR(from: Int, to: Int, bpm: Int = 50) -> [HRSample] {
stride(from: from, through: to, by: 30).map { HRSample(ts: $0, bpm: bpm) }
}

private func sleep(_ start: Int, _ end: Int) -> SleepStager.Period {
SleepStager.Period(stage: "sleep", start: start, end: end)
}
private func active(_ start: Int, _ end: Int) -> SleepStager.Period {
SleepStager.Period(stage: "active", start: start, end: end)
}

/// The invariant that makes the diagnostic trustworthy: the number of pairs it reports as bridged
/// must equal the reduction in sleep-run count the REAL bridge produces.
private func assertAgreesWithBridge(_ periods: [SleepStager.Period], hr: [HRSample],
baseline: Double?, file: StaticString = #filePath, line: UInt = #line) {
let attempts = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: baseline)
let before = periods.filter { $0.stage == "sleep" }.count
let after = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: hr, baseline: baseline)
.filter { $0.stage == "sleep" }.count
XCTAssertEqual(attempts.filter { $0.bridged }.count, before - after,
"bridged-pair count must equal the real bridge's run reduction", file: file, line: line)
}

func testShortGapInSleepBandBridgesAndIsReported() {
// Two sleep runs 10 min apart, HR asleep across the gap → bridged.
let periods = [sleep(0, 3_600), sleep(4_200, 8_000)]
let hr = sleepHR(from: 0, to: 8_000)
let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70)
XCTAssertEqual(a.count, 1)
XCTAssertEqual(a[0].gapMin, 10)
XCTAssertTrue(a[0].bridged)
XCTAssertEqual(a[0].reason, "bridged")
assertAgreesWithBridge(periods, hr: hr, baseline: 70)
}

func testGapBeyondToleranceReportsGapTooLong() {
// 120 min apart — over sparseBridgeGapMin (90), so the bridge declines and says so.
let gap = (SleepStager.sparseBridgeGapMin + 30) * 60
let periods = [sleep(0, 3_600), sleep(3_600 + gap, 3_600 + gap + 3_600)]
let hr = sleepHR(from: 0, to: 3_600 + gap + 3_600)
let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70)
XCTAssertEqual(a.count, 1)
XCTAssertFalse(a[0].bridged)
XCTAssertEqual(a[0].reason, "gapTooLong")
XCTAssertEqual(a[0].gapMin, SleepStager.sparseBridgeGapMin + 30)
assertAgreesWithBridge(periods, hr: hr, baseline: 70)
}

func testAwakeHrAcrossGapReportsHrOutOfBand() {
// Gap is short enough, but HR across it is clearly awake → the HR condition is what refused.
let periods = [sleep(0, 3_600), sleep(4_200, 8_000)]
let hr = sleepHR(from: 0, to: 8_000, bpm: 110)
let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 60)
XCTAssertEqual(a.count, 1)
XCTAssertFalse(a[0].bridged)
XCTAssertFalse(a[0].hrInSleepBand)
XCTAssertEqual(a[0].reason, "hrOutOfBand")
assertAgreesWithBridge(periods, hr: hr, baseline: 60)
}

/// The reporter's shape (#737): fragments separated by ACTIVE runs are never adjacent sleep pairs, so
/// the bridge considers nothing — which is exactly why their log showed runsBefore == runsAfter with
/// no explanation. An EMPTY attempt list is the diagnosis, and the emitter says so in words.
func testFragmentsSeparatedByActiveRunsProduceNoAttempts() {
let periods = [sleep(0, 3_000), active(3_000, 3_300), sleep(3_300, 6_000)]
let hr = sleepHR(from: 0, to: 6_000)
let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70)
XCTAssertTrue(a.isEmpty, "an intervening active run is never a considered pair")
assertAgreesWithBridge(periods, hr: hr, baseline: 70) // and the real bridge merges nothing
}

func testNoOpWhenNotSparse() {
let periods = [sleep(0, 3_600), sleep(4_200, 8_000)]
let hr = sleepHR(from: 0, to: 8_000)
XCTAssertTrue(SleepStager.sparseBridgeAttempts(periods, sparse: false, hr: hr, baseline: 70).isEmpty,
"the dense 4.0 path is untouched, so there is nothing to explain")
}

/// A chain of three bridgeable runs must report two pairs and agree with the real bridge's collapse
/// to one run — the case where a naive diagnostic (not re-walking the growing run) would drift.
func testChainOfThreeReportsTwoPairsAndAgrees() {
let periods = [sleep(0, 3_600), sleep(4_200, 8_000), sleep(8_600, 12_000)]
let hr = sleepHR(from: 0, to: 12_000)
let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70)
XCTAssertEqual(a.count, 2)
XCTAssertTrue(a.allSatisfy { $0.bridged })
assertAgreesWithBridge(periods, hr: hr, baseline: 70)
}
}
Loading