diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index cef0f97645..eff90d7468 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -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 @@ -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 @@ -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 diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift new file mode 100644 index 0000000000..2a5f5f73ae --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift @@ -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) + } +}