From 5284a72fff212d90acc29b7e35ca85d09ffaab5d Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:12:19 -0700 Subject: [PATCH] feat(sleep): motion-aware wake refinement behind an Experimental toggle (#364 follow-up) Add WakeMotionRefinement (Swift + Kotlin twins), a post-pass over the already-staged hypnogram that reclassifies a scored WAKE segment to light when its per-minute step-tick cadence shows no locomotion and its per-minute gravity posture stays stable outside isolated turn-over burst minutes (kept as wake +/-1 min). Targets the HR-led wake call misreading a hot-but-still/atonic stretch as an awakening -- #364 "Proposal 2": a real anonymized night scored 194 min wake from single-minute turn-over bursts (20-36 step-motion-counter ticks, a posture-variance spike) between minutes of complete stillness, with zero walking cadence anywhere in the blocks. Self-gates on OBSERVED gravity + step-sample density, never on strap family/model (#345): a WHOOP 4.0 night fails the gate on the data itself (gravity too sparse for a per-minute variance to mean anything, and no step stream at all) and is left untouched every time; a WHOOP 5.0/MG night, which streams both channels densely, is the expected beneficiary. Default-off Experimental toggle (PuffinExperiment.motionAwareWake / Settings -> Experimental -> Sleep staging on both platforms). The pass only ever shrinks an already-scored wake segment -- it never invents wake time -- and detection plus the V1/V2 staging engines are untouched either way. Tests (synthetic fixtures only): a hot-but-still night with a turn-over burst every 90 min reclaims the still spans; a real 3-minute get-up stays wake; a sparse (WHOOP-4.0-shaped) night leaves the gate declined; the toggle's off-path is a byte-identical passthrough; five separate synthetic patterns with different injected reclaim amounts are each recovered exactly, proving the method tracks varying input rather than one fixture. --- .../StrandAnalytics/AnalyticsEngine.swift | 20 +- .../WakeMotionRefinement.swift | 324 +++++++++++++++ .../WakeMotionRefinementTests.swift | 195 +++++++++ Strand/BLE/PuffinExperiment.swift | 17 + Strand/Data/IntelligenceEngine.swift | 8 + Strand/Data/Repository.swift | 11 +- Strand/Screens/SettingsView.swift | 22 + .../com/noop/analytics/AnalyticsEngine.kt | 20 +- .../com/noop/analytics/IntelligenceEngine.kt | 20 +- .../com/noop/analytics/SleepStageHealer.kt | 25 +- .../noop/analytics/WakeMotionRefinement.kt | 382 ++++++++++++++++++ .../java/com/noop/ble/PuffinExperiment.kt | 18 + .../main/java/com/noop/ble/WhoopBleClient.kt | 2 + .../src/main/java/com/noop/ui/AppViewModel.kt | 4 + .../main/java/com/noop/ui/SettingsScreen.kt | 44 ++ .../analytics/WakeMotionRefinementTest.kt | 232 +++++++++++ 16 files changed, 1334 insertions(+), 10 deletions(-) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WakeMotionRefinementTests.swift create mode 100644 android/app/src/main/java/com/noop/analytics/WakeMotionRefinement.kt create mode 100644 android/app/src/test/java/com/noop/analytics/WakeMotionRefinementTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index c918efd697..8325881ea9 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -324,6 +324,16 @@ public enum AnalyticsEngine { // byte-identical default for pure-function callers/tests; IntelligenceEngine // threads `PuffinExperiment.experimentalSleepV2Enabled`. (V7 / #690) useSleepStagerV2: Bool = false, + // Opt-in motion-aware wake refinement (#364 "Proposal 2" follow-up; density + // gate precedent #345). When true, `WakeMotionRefinement` re-derives each + // detected session's stages, reclassifying a hot-but-still WAKE segment to + // `light` when it shows no locomotion and a stable posture outside isolated + // burst minutes; it only ever runs AFTER V1/V2 staging and self-gates on the + // observed gravity + step density, so it is a no-op on a sparse (e.g. WHOOP + // 4.0) night regardless of this flag. Default false keeps every pure-function + // caller/test byte-identical; IntelligenceEngine threads + // `PuffinExperiment.motionAwareWakeEnabled`. + useMotionAwareWake: Bool = false, // Sleep PROVENANCE for the per-day sleep trace (CAPTURE-C / #799). The // measured BLE path is `.measured` (the default); the caller passes // `.imported(...)` when a previously-imported sleep row WON the daily merge, @@ -361,11 +371,19 @@ public enum AnalyticsEngine { func tsInDay(_ ts: Int) -> Bool { (ts + tzOffsetSeconds) >= dayStartUtc && (ts + tzOffsetSeconds) < dayEndUtc } // ── Sleep detection + staging ───────────────────────────────────────── - let allSessions = SleepStager.detectSleep(hr: hr, rr: rr, resp: resp, gravity: gravity, + let detectedSessions = SleepStager.detectSleep(hr: hr, rr: rr, resp: resp, gravity: gravity, tzOffsetSeconds: tzOffsetSeconds, wristOff: wristOff, bandSleepState: bandSleepState, useSleepStagerV2: useSleepStagerV2, traceSink: traceSink) + // Motion-aware wake refinement (#364 follow-up) runs AFTER V1/V2 staging, over every detected + // session (naps included — the same eligibility gates apply). `steps` is the SAME calendar-day/ + // night-window stream the caller passed for the rest of this analysis; the pass self-gates on its + // observed density, so an empty/sparse `steps` (e.g. a WHOOP 4.0, which never emits StepSample at + // all) is a no-op regardless of `useMotionAwareWake`. + let allSessions = useMotionAwareWake + ? detectedSessions.map { WakeMotionRefinement.refine($0, grav: gravity, steps: steps) } + : detectedSessions // Sessions attributed to `day` = those whose end falls on `day` (LOCAL day, #277). `day` is // the caller's local-day key; attribute by the same offset so the bucket and the key agree. let matched = allSessions.filter { tsInDay($0.end) } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift new file mode 100644 index 0000000000..266a085183 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift @@ -0,0 +1,324 @@ +import Foundation +import WhoopProtocol + +// WakeMotionRefinement.swift — motion-aware wake refinement (#364 "Proposal 2", a follow-up to the +// sleep-presentation work in #364; density-gate precedent from #345). +// +// THE PROBLEM. Both stagers (`SleepStager` V1, `SleepStagerV2`) call "wake" primarily from HR / HR +// variability — neither consults locomotion or posture. On a strap night with pharmacologically- or +// metabolically-elevated resting HR (a supplement protocol, a hot room, alcohol — anything that keeps HR +// up without the wearer actually getting up), that HR-led call misreads hot-but-asleep as wake. A real +// anonymized night (5.0 strap, elevated HR from a supplement protocol, not illness) scored 194 min wake +// across ~6.4h in bed (efficiency 0.50) though the wearer reported sleeping through. Minute-level review of +// the four largest wake blocks found ZERO walking cadence: each block was a single-minute turn-over burst +// (20-36 step-motion-counter ticks, a posture-variance spike) bracketed by minutes of complete stillness +// (posture variance < 0.01, zero ticks) — recurring on a ~90-min rhythm consistent with hot-but-atonic REM +// being scored as wake, not a real awakening. A genuine get-up looks entirely different: sustained +// multi-minute walking cadence, not an isolated single-minute blip. +// +// THE FIX. A POST-PASS over the already-staged `[StageSegment]` output of either stager (V1 or V2 — this +// file never touches their HR-led emission models). For each scored wake segment of at least +// `minWakeSegmentSeconds`, look at the SAME two signals a human reviewer used above: per-minute +// step-motion-counter cadence (`StepSample.counter`, wrap-aware, `activityClass` 1=walk/2=run only — see +// `walkClassTicksPerMinute`) and per-minute gravity posture variance (`postureVariance`). A segment with NO +// locomotion evidence (`hasNoLocomotion`) and posture stable outside a minority of isolated burst minutes +// (`isPostureStable`) is a hot-but-still wake call: every non-burst minute is reclassified to `light`, +// while burst minutes (plus a ±`burstPadMinutes` buffer) are KEPT as wake — the pass only ever shrinks +// wake time, it never invents wake the incumbent stager didn't already call. +// +// THE DENSITY SELF-GATE (#345). `SleepStagerV2`'s own header notes the WHOOP 4.0 gravity stream is often +// too sparse to tell "restless in bed" from "out of bed" — the same limitation applies here even harder, +// because a per-minute posture VARIANCE needs multiple samples inside each minute to mean anything (a +// single sample has zero variance by construction, which would silently read as "stable" and rubber-stamp +// every wake block on a sparse night). And a WHOOP 4.0 never emits `StepSample` at all — `steps` is +// permanently empty on that model, so a naive "zero ticks = no locomotion" read would ALWAYS pass. Rather +// than branch on strap family/model (which the maintainer flagged in #345 as too coarse — a "4.0" string +// tells you nothing about how dense THIS night's data actually is), `isMotionDense` measures the OBSERVED +// stream directly: at least `minDenseMinuteCoverageFraction` of the night's minutes must carry +// `>= minGravitySamplesPerMinuteForVariance` gravity samples AND `>= minStepSamplesPerMinuteForDensity` +// step records. A 4.0 night fails this on the data (empty steps, ~1 gravity vector/min) every time, +// exactly as a synthetically-sparse 5.0/MG night would; a real 5.0/MG night — which streams both channels +// densely — is this refinement's expected beneficiary and clears the gate on its own merits. +// +// SAFETY POSTURE. Default-off `Experimental` toggle (`PuffinExperiment.motionAwareWakeEnabled` / +// `Strand/Screens/SettingsView.swift`), consistent with CLAUDE.md's rule for a derived physiological +// signal: land it as an opt-in refinement, never the default, until it has more than this one reference +// night behind it. All thresholds below are NAMED CONSTANTS fixed a-priori from that reference night, not +// fit to labels. Pure, deterministic, no I/O — the Kotlin twin is `WakeMotionRefinement.kt`. +public enum WakeMotionRefinement { + + // MARK: - Tunables (named, documented constants — see the header for where each one comes from) + + /// Only wake segments at least this long are considered. A short HR blip is either a real brief + /// arousal or too short to say anything reliable about locomotion/posture from per-minute buckets. + public static let minWakeSegmentSeconds: Int = 5 * 60 + + /// A minute with at least this many walk-class ticks, held for `sustainedWalkMinConsecutiveMinutes` + /// in a row, is real ambulation (a get-up), not a turn-over. + public static let sustainedWalkTicksPerMinute: Int = 10 + public static let sustainedWalkMinConsecutiveMinutes: Int = 2 + + /// A single minute this busy is vigorous enough to count as locomotion on its own, no second minute + /// required — the reference get-up example (3 consecutive minutes at 25 ticks/min) clears both this + /// and the sustained rule; this constant exists for a single very busy minute with a quiet neighbour. + public static let singleMinuteWalkTicks: Int = 40 + + /// Per-minute gravity posture variance (g², see `postureVariance`) below this reads as "stable" — the + /// reference night's motionless stretches measured < 0.01; 0.05 leaves headroom above strap/decode + /// noise while still well below the reference night's turn-over spikes. + public static let stablePostureVarianceG2: Double = 0.05 + + /// At least this fraction of a candidate segment's minutes must be posture-stable (i.e. NOT a burst + /// minute) before the segment is trusted as "hot-but-still" rather than a real restless stretch. + public static let minStableMinuteFraction: Double = 0.80 + + /// Minutes kept as wake around a burst minute (the turn-over itself, plus this many minutes on each + /// side) so a reclassified block doesn't shave the couple of seconds of real motion right at its edge. + public static let burstPadMinutes: Int = 1 + + /// A minute's posture variance is only meaningful with at least this many gravity samples inside it + /// (one sample has zero variance by construction and would trivially read as "stable"). + public static let minGravitySamplesPerMinuteForVariance: Int = 2 + + /// A minute needs at least this many step records to say its tick cadence is a real (possibly zero) + /// reading rather than a gap in the stream. + public static let minStepSamplesPerMinuteForDensity: Int = 1 + + /// Density self-gate (#345): the fraction of the WHOLE scored window's minutes that must clear both + /// per-minute density floors above before ANY segment in this call is touched. Gates on the observed + /// stream, never on strap family/model — see the header. + public static let minDenseMinuteCoverageFraction: Double = 0.80 + + /// WHOOP StepSample `activityClass` codes (community finding #316) that count as locomotion. + /// 0 = still (a turn-over, NOT ambulation) is deliberately excluded; nil (unknown/invalid byte) is + /// also excluded — unattributed ticks feed the posture-variance half of the gate instead. + private static let locomotionActivityClasses: Set = [1 /* walk */, 2 /* run */] + + // MARK: - Public entry points + + /// The core pass: reclassify non-burst minutes of eligible wake segments to `light`. `segments` must + /// tile a single contiguous window in order (as every `stageSession` in this module returns); `grav`/ + /// `steps` should cover at least that window. Byte-identical passthrough when `segments` is empty, the + /// window is degenerate, or the density self-gate declines. + public static func refine(_ segments: [StageSegment], grav: [GravitySample], steps: [StepSample]) -> [StageSegment] { + guard let windowStart = segments.first?.start, let windowEnd = segments.last?.end, windowEnd > windowStart else { + return segments + } + guard isMotionDense(start: windowStart, end: windowEnd, grav: grav, steps: steps) else { return segments } + + let gravByMinute = bucketByMinute(grav) { $0.ts } + let ticksByMinute = walkClassTicksPerMinute(steps) + + var out: [StageSegment] = [] + out.reserveCapacity(segments.count) + for seg in segments { + for piece in refineSegment(seg, gravByMinute: gravByMinute, ticksByMinute: ticksByMinute) { + appendMerging(piece, into: &out) + } + } + return out + } + + /// Toggle-shaped convenience: `enabled = false` is a guaranteed byte-identical passthrough (the + /// `PuffinExperiment.motionAwareWakeEnabled` off-path), `enabled = true` runs `refine`. + public static func apply(_ segments: [StageSegment], grav: [GravitySample], steps: [StepSample], + enabled: Bool) -> [StageSegment] { + enabled ? refine(segments, grav: grav, steps: steps) : segments + } + + /// Session-level convenience: refines `session.stages` and recomputes `efficiency` from the result + /// (efficiency is derived from wake time, so reclassifying wake -> light changes it). `restingHR` and + /// `avgHRV` are independent of stage labels and are carried over unchanged. Identity passthrough + /// (same instance's fields, no allocation of a changed copy beyond the equality check) when `refine` + /// makes no change to the stages. + public static func refine(_ session: SleepSession, grav: [GravitySample], steps: [StepSample]) -> SleepSession { + let newStages = refine(session.stages, grav: grav, steps: steps) + guard newStages != session.stages else { return session } + let newEfficiency = SleepStager.efficiency(start: session.start, end: session.end, stages: newStages) + return SleepSession(start: session.start, end: session.end, efficiency: newEfficiency, + stages: newStages, restingHR: session.restingHR, avgHRV: session.avgHRV) + } + + /// Toggle-shaped convenience for the session-level overload (see `apply(_:grav:steps:enabled:)`). + public static func apply(_ session: SleepSession, grav: [GravitySample], steps: [StepSample], + enabled: Bool) -> SleepSession { + enabled ? refine(session, grav: grav, steps: steps) : session + } + + // MARK: - Density self-gate (#345) + + /// True when both the gravity and step streams are dense enough, OVER THE OBSERVED DATA, to trust + /// per-minute locomotion/posture evidence across `[start, end)`. See the file header for why this + /// checks the streams themselves rather than a strap family/model string. + static func isMotionDense(start: Int, end: Int, grav: [GravitySample], steps: [StepSample]) -> Bool { + let gravDensity = denseMinuteFraction(grav, from: start, to: end, + minPerMinute: minGravitySamplesPerMinuteForVariance, ts: { $0.ts }) + let stepDensity = denseMinuteFraction(steps, from: start, to: end, + minPerMinute: minStepSamplesPerMinuteForDensity, ts: { $0.ts }) + return gravDensity >= minDenseMinuteCoverageFraction && stepDensity >= minDenseMinuteCoverageFraction + } + + /// Fraction of the wall-clock minutes tiling `[start, end)` that carry at least `minPerMinute` samples + /// of `samples`. 0 for a degenerate (empty or inverted) window. + static func denseMinuteFraction(_ samples: [T], from start: Int, to end: Int, minPerMinute: Int, + ts: (T) -> Int) -> Double { + guard end > start else { return 0 } + let firstMinute = start / 60 + let lastMinute = (end - 1) / 60 + guard lastMinute >= firstMinute else { return 0 } + var counts: [Int: Int] = [:] + for s in samples { + let m = ts(s) / 60 + if m >= firstMinute && m <= lastMinute { counts[m, default: 0] += 1 } + } + let totalMinutes = lastMinute - firstMinute + 1 + var denseMinutes = 0 + for m in firstMinute...lastMinute where (counts[m] ?? 0) >= minPerMinute { denseMinutes += 1 } + return Double(denseMinutes) / Double(totalMinutes) + } + + // MARK: - Per-segment refinement + + /// Refine one `StageSegment`. Non-wake segments, and wake segments shorter than + /// `minWakeSegmentSeconds`, pass through unchanged (wrapped in a single-element array). An eligible + /// segment that fails EITHER the locomotion gate or the stability gate also passes through unchanged — + /// this pass only ever acts when BOTH read "hot-but-still". + static func refineSegment(_ seg: StageSegment, gravByMinute: [Int: [GravitySample]], + ticksByMinute: [Int: Int]) -> [StageSegment] { + guard seg.stage == "wake", seg.end - seg.start >= minWakeSegmentSeconds else { return [seg] } + let mins = minutes(from: seg.start, to: seg.end) + guard !mins.isEmpty else { return [seg] } + + guard !hasLocomotion(mins, ticksByMinute: ticksByMinute) else { return [seg] } + + guard let burstMinutes = stableBurstMinutes(mins, gravByMinute: gravByMinute) else { return [seg] } + + // Keep every burst minute plus a ±burstPadMinutes buffer as wake, clamped to this segment's own + // bounds (never grows wake past what the stager already called); reclassify everything else. + let firstMinute = mins[0], lastMinute = mins[mins.count - 1] + var keepWake: Set = [] + for m in burstMinutes { + let lo = max(firstMinute, m - burstPadMinutes) + let hi = min(lastMinute, m + burstPadMinutes) + if lo <= hi { for p in lo...hi { keepWake.insert(p) } } + } + + var result: [StageSegment] = [] + for (idx, m) in mins.enumerated() { + let stage = keepWake.contains(m) ? "wake" : "light" + let minuteStart = idx == 0 ? seg.start : m * 60 + let minuteEnd = idx == mins.count - 1 ? seg.end : (m + 1) * 60 + appendMerging(StageSegment(start: minuteStart, end: minuteEnd, stage: stage), into: &result) + } + return result + } + + /// The locomotion gate: true when `mins` contains either a single minute at/above + /// `singleMinuteWalkTicks`, or `sustainedWalkMinConsecutiveMinutes` consecutive minutes each at/above + /// `sustainedWalkTicksPerMinute`. A minute absent from `ticksByMinute` (no step coverage that minute) + /// reads as 0 ticks and breaks a building streak, matching "no evidence of walking" rather than + /// crediting it. + static func hasLocomotion(_ mins: [Int], ticksByMinute: [Int: Int]) -> Bool { + var consecutive = 0 + for m in mins { + let ticks = ticksByMinute[m] ?? 0 + if ticks >= singleMinuteWalkTicks { return true } + if ticks >= sustainedWalkTicksPerMinute { + consecutive += 1 + if consecutive >= sustainedWalkMinConsecutiveMinutes { return true } + } else { + consecutive = 0 + } + } + return false + } + + /// The stability gate. Returns the set of "burst" (not posture-stable) minutes when at least + /// `minStableMinuteFraction` of `mins` ARE posture-stable, so the caller can keep those burst minutes + /// (± padding) as wake; returns `nil` when the segment isn't stable enough to trust (too many/too + /// little-understood unstable minutes), telling the caller to leave the segment untouched. A minute + /// with too few gravity samples to compute a variance (`postureVariance` returns nil) is conservatively + /// treated as a burst minute — silence is not proof of stillness. + static func stableBurstMinutes(_ mins: [Int], gravByMinute: [Int: [GravitySample]]) -> Set? { + var burstMinutes: Set = [] + var stableCount = 0 + for m in mins { + if let variance = postureVariance(gravByMinute[m] ?? []), variance < stablePostureVarianceG2 { + stableCount += 1 + } else { + burstMinutes.insert(m) + } + } + guard Double(stableCount) / Double(mins.count) >= minStableMinuteFraction else { return nil } + return burstMinutes + } + + // MARK: - Signal extraction + + /// Per-minute posture variance: the trace of the covariance matrix of a minute's gravity samples (the + /// mean squared deviation of each sample from the minute's own mean vector, summed over x/y/z). Near 0 + /// when the wrist orientation barely moves within the minute; spikes when the strap rotates through + /// positions inside the minute (a turn-over). `nil` below `minGravitySamplesPerMinuteForVariance` + /// samples — too few to say anything (see `stableBurstMinutes`'s nil handling). + static func postureVariance(_ samples: [GravitySample]) -> Double? { + guard samples.count >= minGravitySamplesPerMinuteForVariance else { return nil } + let n = Double(samples.count) + var sx = 0.0, sy = 0.0, sz = 0.0 + for s in samples { sx += s.x; sy += s.y; sz += s.z } + let mx = sx / n, my = sy / n, mz = sz / n + var sumSq = 0.0 + for s in samples { + let dx = s.x - mx, dy = s.y - my, dz = s.z - mz + sumSq += dx * dx + dy * dy + dz * dz + } + return sumSq / n + } + + /// Per-minute walk-class tick cadence: the wrap-aware u16 counter delta between consecutive + /// `StepSample`s, attributed to the LATER sample's minute, kept only when that later sample's + /// `activityClass` is walk (1) or run (2) — see `locomotionActivityClasses`. A still-class (0) or + /// unknown-class (nil) delta is real counter movement (a turn-over jostles the accelerometer too) but + /// is deliberately NOT counted as locomotion; it still shows up in `postureVariance` instead. Minutes + /// with no qualifying tick are simply absent from the result (callers read `?? 0`). + static func walkClassTicksPerMinute(_ steps: [StepSample]) -> [Int: Int] { + let sorted = steps.sorted { $0.ts < $1.ts } + guard sorted.count >= 2 else { return [:] } + var out: [Int: Int] = [:] + for i in 1.. [Int] { + guard end > start else { return [] } + let first = start / 60 + let last = (end - 1) / 60 + guard last >= first else { return [] } + return Array(first...last) + } + + static func bucketByMinute(_ samples: [T], ts: (T) -> Int) -> [Int: [T]] { + var out: [Int: [T]] = [:] + for s in samples { out[ts(s) / 60, default: []].append(s) } + return out + } + + /// Append `piece`, merging into the previous element when the stage matches AND the two are + /// time-contiguous (defensive: every caller here produces pieces in strict chronological order with + /// no gaps, so this is always a merge when the stage repeats). + static func appendMerging(_ piece: StageSegment, into out: inout [StageSegment]) { + if let lastIdx = out.indices.last, out[lastIdx].stage == piece.stage, out[lastIdx].end == piece.start { + out[lastIdx].end = piece.end + } else { + out.append(piece) + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WakeMotionRefinementTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WakeMotionRefinementTests.swift new file mode 100644 index 0000000000..72c3b36a2b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WakeMotionRefinementTests.swift @@ -0,0 +1,195 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Pins `WakeMotionRefinement` (#364 "Proposal 2" follow-up; density-gate precedent #345) against +/// SYNTHETIC fixtures only — no real user data. Every fixture is built from `buildNight`, which lays down +/// a dense, still, non-ambulatory baseline (4 gravity samples/min at one fixed orientation, 1 step record/ +/// min with a flat counter and `activityClass = 0`) and overrides specific minutes to inject either a +/// posture "burst" (a turn-over) or real walking cadence. Apple twin of the Android +/// `WakeMotionRefinementTest`. +final class WakeMotionRefinementTests: XCTestCase { + + /// Minute-aligned so every assertion below can reason in whole minutes. + private let start = 1_700_000_000 / 60 * 60 + + // MARK: - Fixture builder + + /// One night's gravity + step streams over minutes `[0, totalMinutes)` relative to `start`. + /// - `burstMinutes`: these minutes get a gravity vector that swings between two orientations + /// within the minute (posture variance well above `stablePostureVarianceG2`); every other minute + /// is a fixed, motionless orientation (variance 0). + /// - `walkMinutes`: these minutes accrue `ticksPerWalkMinute` walk-class (`activityClass = 1`) + /// step-counter ticks; every other minute accrues 0 ticks at `activityClass = 0` (still). + /// - `sparse`: when true, mimics a WHOOP 4.0 night instead — gravity only every 5th minute, no step + /// samples at all — so the density self-gate declines regardless of the burst/walk pattern. + private func buildNight(totalMinutes: Int, burstMinutes: Set = [], walkMinutes: Set = [], + ticksPerWalkMinute: Int = 25, + sparse: Bool = false) -> (grav: [GravitySample], steps: [StepSample]) { + var grav: [GravitySample] = [] + var steps: [StepSample] = [] + var counter = 0 + for m in 0.. StageSegment { + StageSegment(start: start, end: start + minutes * 60, stage: "wake") + } + + // MARK: (a) hot-but-still night, one turn-over burst every 90 min -> the still spans are reclaimed + + func testHotButStillNightReclaimsTheStillSpansAroundIsolatedBursts() { + let seg = wakeSegment(minutes: 180) + let (grav, steps) = buildNight(totalMinutes: 180, burstMinutes: [45, 135]) + + let result = WakeMotionRefinement.refine([seg], grav: grav, steps: steps) + + let expected: [StageSegment] = [ + StageSegment(start: start, end: start + 44 * 60, stage: "light"), + StageSegment(start: start + 44 * 60, end: start + 47 * 60, stage: "wake"), + StageSegment(start: start + 47 * 60, end: start + 134 * 60, stage: "light"), + StageSegment(start: start + 134 * 60, end: start + 137 * 60, stage: "wake"), + StageSegment(start: start + 137 * 60, end: start + 180 * 60, stage: "light"), + ] + XCTAssertEqual(result, expected, + "only the two burst minutes (+/-1 min pad) should survive as wake; the motionless stretches " + + "between them (the hot-but-atonic REM misread) should reclaim to light") + + let totalDuration = result.reduce(0) { $0 + ($1.end - $1.start) } + XCTAssertEqual(totalDuration, 180 * 60, "the refinement must never change total session duration") + let wakeDuration = result.filter { $0.stage == "wake" }.reduce(0) { $0 + ($1.end - $1.start) } + XCTAssertEqual(wakeDuration, 6 * 60, "wake should shrink from 180 min to the 2 burst blocks (3 min each)") + } + + // MARK: (b) a real get-up (3 consecutive minutes of 25 ticks/min) stays wake + + func testRealGetUpStaysWake() { + let seg = wakeSegment(minutes: 30) + let (grav, steps) = buildNight(totalMinutes: 30, walkMinutes: [10, 11, 12], ticksPerWalkMinute: 25) + + let result = WakeMotionRefinement.refine([seg], grav: grav, steps: steps) + + XCTAssertEqual(result, [seg], + "3 consecutive minutes at 25 ticks/min clears the sustained-walk locomotion gate (>=2 " + + "consecutive minutes >=10 ticks), so the whole segment must be left untouched") + } + + // MARK: (c) sparse-motion night -> the density self-gate declines to act + + func testSparseMotionNightDeclinesToAct() { + let seg = wakeSegment(minutes: 180) + // Same burst pattern as the dense test above (which DOES reclassify) so the only variable here + // is density -- proving the decline is the gate, not the burst pattern being ineligible. + let (grav, steps) = buildNight(totalMinutes: 180, burstMinutes: [45, 135], sparse: true) + + let result = WakeMotionRefinement.refine([seg], grav: grav, steps: steps) + + XCTAssertEqual(result, [seg], + "a WHOOP-4.0-shaped stream (sparse gravity, no step samples) must fail the density self-gate " + + "and leave the incumbent wake call untouched, per #345") + } + + // MARK: (d) toggle off -> byte-identical output + + func testToggleOffIsByteIdenticalPassthrough() { + let seg = wakeSegment(minutes: 180) + let (grav, steps) = buildNight(totalMinutes: 180, burstMinutes: [45, 135]) + + let off = WakeMotionRefinement.apply([seg], grav: grav, steps: steps, enabled: false) + XCTAssertEqual(off, [seg], "enabled=false must be a guaranteed byte-identical passthrough") + + // Sanity: the SAME fixture actually changes when enabled, so the assertion above isn't vacuous. + let on = WakeMotionRefinement.apply([seg], grav: grav, steps: steps, enabled: true) + XCTAssertNotEqual(on, [seg], "fixture sanity check: this fixture must be live when enabled") + } + + // MARK: (e) tracks VARYING inputs -- multiple patterns, each with a different injected reclaim + // amount, each recovered (repo hard rule for a derived physiological signal: prove the method tracks + // varying input, not a single lucky match). + + func testRecoversDifferentInjectedReclaimAmountsAcrossPatterns() { + struct Scenario { let name: String; let totalMinutes: Int; let burstMinutes: Set } + let scenarios: [Scenario] = [ + Scenario(name: "single burst", totalMinutes: 40, burstMinutes: [20]), + Scenario(name: "two well-separated bursts", totalMinutes: 120, burstMinutes: [20, 80]), + Scenario(name: "three well-separated bursts", totalMinutes: 90, burstMinutes: [10, 40, 70]), + Scenario(name: "two adjacent bursts (padding overlaps)", totalMinutes: 60, burstMinutes: [5, 6]), + Scenario(name: "burst at the segment's own edge (padding clamps)", totalMinutes: 50, burstMinutes: [0, 49]), + ] + + for s in scenarios { + let seg = wakeSegment(minutes: s.totalMinutes) + let (grav, steps) = buildNight(totalMinutes: s.totalMinutes, burstMinutes: s.burstMinutes) + let result = WakeMotionRefinement.refine([seg], grav: grav, steps: steps) + + // Expected kept-as-wake minutes = union of each burst minute +/-1, clamped to the segment. + var expectedKept: Set = [] + for m in s.burstMinutes { + let lo = max(0, m - 1), hi = min(s.totalMinutes - 1, m + 1) + if lo <= hi { expectedKept.formUnion(lo...hi) } + } + let expectedReclaimedMin = s.totalMinutes - expectedKept.count + + let totalDuration = result.reduce(0) { $0 + ($1.end - $1.start) } + XCTAssertEqual(totalDuration, s.totalMinutes * 60, "[\(s.name)] duration must be conserved") + + let actualWakeMin = result.filter { $0.stage == "wake" }.reduce(0) { $0 + ($1.end - $1.start) } / 60 + let actualReclaimedMin = s.totalMinutes - actualWakeMin + XCTAssertEqual(actualReclaimedMin, expectedReclaimedMin, + "[\(s.name)] expected \(expectedReclaimedMin) min reclaimed to light, got \(actualReclaimedMin)") + + // Every distinct injected pattern above yields a DIFFERENT reclaim amount -- this loop only + // proves the method tracks varying input if that's actually true of the fixtures. + } + + // Belt-and-suspenders on the point of the test: the scenarios really do inject different amounts. + let distinctReclaimAmounts = Set(scenarios.map { s -> Int in + var kept: Set = [] + for m in s.burstMinutes { + let lo = max(0, m - 1), hi = min(s.totalMinutes - 1, m + 1) + if lo <= hi { kept.formUnion(lo...hi) } + } + return s.totalMinutes - kept.count + }) + XCTAssertGreaterThan(distinctReclaimAmounts.count, 1, + "fixture sanity: scenarios must inject genuinely different reclaim amounts") + } + + // MARK: - Session-level convenience (efficiency recompute) + + func testSessionLevelRefineRecomputesEfficiency() { + let session = SleepSession(start: start, end: start + 180 * 60, efficiency: 0.0, + stages: [wakeSegment(minutes: 180)], restingHR: 52, avgHRV: 40.0) + let (grav, steps) = buildNight(totalMinutes: 180, burstMinutes: [45, 135]) + + let refined = WakeMotionRefinement.refine(session, grav: grav, steps: steps) + + XCTAssertGreaterThan(refined.efficiency, session.efficiency, + "reclassifying 174 of 180 wake minutes to light must raise efficiency") + XCTAssertEqual(refined.efficiency, SleepStager.efficiency(start: session.start, end: session.end, + stages: refined.stages), + "efficiency must be recomputed from the REFINED stages, not left stale") + XCTAssertEqual(refined.restingHR, session.restingHR, "restingHR is stage-independent, must be carried over") + XCTAssertEqual(refined.avgHRV, session.avgHRV, "avgHRV is stage-independent, must be carried over") + } +} diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index 90e65a6273..2dc724f41b 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -94,4 +94,21 @@ enum PuffinExperiment { static let autoDetectWorkoutsKey = "noopAutoDetectWorkouts" static var autoDetectWorkoutsEnabled: Bool { UserDefaults.standard.bool(forKey: autoDetectWorkoutsKey) } + + /// Opt-in "Motion-aware wake refinement" (default OFF, #364 "Proposal 2" follow-up): a post-pass + /// (`WakeMotionRefinement`) over the already-staged hypnogram that reclassifies a scored WAKE segment + /// to `light` when its per-minute step-tick cadence shows no locomotion AND its per-minute gravity + /// posture stays stable outside a minority of isolated "turn-over" burst minutes (which are kept as + /// wake). Targets the HR-led wake call misreading a hot-but-still/atonic stretch as an awakening — a + /// real anonymized night scored 194 min wake from single-minute turn-over bursts with zero walking + /// cadence between them. Self-gates on the OBSERVED gravity + step-sample density (never on strap + /// family/model, per #345): a WHOOP 4.0 night (sparse gravity, no step stream at all) fails the gate + /// and is left untouched every time; a WHOOP 5.0/MG night, which streams both densely, is the expected + /// beneficiary. Pure analysis switch — it only ever SHRINKS an already-scored wake segment, never + /// invents wake time; detection and the V1/V2 staging engines are untouched either way. Read at the + /// staging call sites (`Repository.restageFromRaw`, `IntelligenceEngine`, threaded through + /// `AnalyticsEngine.analyzeDay`). Mirrors the Android `PuffinExperiment.KEY_MOTION_AWARE_WAKE`. + static let motionAwareWakeKey = "noopMotionAwareWake" + + static var motionAwareWakeEnabled: Bool { UserDefaults.standard.bool(forKey: motionAwareWakeKey) } } diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index e9c750074b..94732848ad 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -631,6 +631,11 @@ final class IntelligenceEngine: ObservableObject { // toggle is the honest escape until real 4.0 ground truth settles it (#271/#319). Matches the // self-heal restage below, which reads the same toggle. let useSleepStagerV2 = PuffinExperiment.experimentalSleepV2Enabled + // #364 follow-up: read the motion-aware wake refinement toggle the same way (once, off the + // detached executor). Default OFF — see `PuffinExperiment.motionAwareWakeEnabled`. It only + // ever runs AFTER whichever stager above just ran, and self-gates on the night's observed + // gravity + step density, so flipping it on is a no-op for any night too sparse to trust. + let useMotionAwareWake = PuffinExperiment.motionAwareWakeEnabled // Already OFF the main actor , score directly (the prior nested `Task.detached` here only // existed to hop off the main actor; the whole loop now runs off it, so the score is computed @@ -656,6 +661,9 @@ final class IntelligenceEngine: ObservableObject { // #690: thread the V2 toggle into the NORMAL staging path so // it affects detected nights, not just the self-heal restage. useSleepStagerV2: useSleepStagerV2, + // #364 follow-up: same threading for the motion-aware wake + // refinement post-pass. + useMotionAwareWake: useMotionAwareWake, traceSink: traceSink, hrvTraceSink: hrvTraceSink, // Per-window HRV detail ONLY for the most-recent night diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index a16ea2ec5d..49682f2b9d 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -1246,15 +1246,24 @@ final class Repository: ObservableObject { let hr = (try? await store.hrSamples(deviceId: deviceId, from: lo, to: hi, limit: 200_000)) ?? [] let rr = (try? await store.rrIntervals(deviceId: deviceId, from: lo, to: hi, limit: 200_000)) ?? [] let resp = (try? await store.respSamples(deviceId: deviceId, from: lo, to: hi, limit: 200_000)) ?? [] + // Read only when the refinement below might actually use it (see `useMotionAwareWake`) — a plain + // read cost, but no point paying it on the (default) off path. + let useMotionAwareWake = PuffinExperiment.motionAwareWakeEnabled + let steps = useMotionAwareWake + ? ((try? await store.stepSamples(deviceId: deviceId, from: lo, to: hi, limit: 200_000)) ?? []) + : [] // Opt-in experimental staging (Settings → Experimental · Sleep staging): when the user has flipped // the V2 flag on, re-stage with the cardiorespiratory recipe `SleepStagerV2`; otherwise the default // V1 `SleepStager`. Read once here off the actor; the switch is purely which engine runs over the // already-detected window , V1 stays the default and is untouched. (V7 Pillar 3b) let useV2 = PuffinExperiment.experimentalSleepV2Enabled let segs = await Task.detached(priority: .utility) { - useV2 + let staged = useV2 ? SleepStagerV2.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp) : SleepStager.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp) + // #364 follow-up: motion-aware wake refinement post-pass, same toggle-shaped no-op when off + // as every other Experimental switch here. + return WakeMotionRefinement.apply(staged, grav: grav, steps: steps, enabled: useMotionAwareWake) }.value return AnalyticsEngine.encodeStages(segs) } diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 6c4af98801..486bb40ca3 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -58,6 +58,13 @@ struct SettingsView: View { /// `Repository`. See [PuffinExperiment.experimentalSleepV2Key]. @AppStorage(PuffinExperiment.experimentalSleepV2Key) private var experimentalSleepV2Enabled = true + /// "Motion-aware wake refinement" (#364 follow-up, OFF by default). A post-pass over the already-staged + /// hypnogram: reclassifies a scored WAKE segment to `light` when its per-minute step-tick cadence shows + /// no locomotion and its per-minute gravity posture is stable outside a minority of isolated burst + /// minutes. Self-gates on OBSERVED gravity + step density (#345) — a no-op on a sparse night (e.g. + /// WHOOP 4.0) regardless of this switch. See [PuffinExperiment.motionAwareWakeKey]. + @AppStorage(PuffinExperiment.motionAwareWakeKey) private var motionAwareWakeEnabled = false + // Imperial/Metric display preference (D#103). Stored data is always SI; this only changes how // distances/weights/heights/temperatures are SHOWN — and lets the profile fields below take // imperial entry. Temperature has a separate override so °C/°F can be picked independently. @@ -1230,6 +1237,21 @@ struct SettingsView: View { .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + + Divider().overlay(StrandPalette.hairline) + + // MARK: Motion-aware wake refinement (#364 follow-up) — default OFF. + Toggle(isOn: $motionAwareWakeEnabled) { + Text("Motion-aware wake refinement") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + Text("Reviews each scored wake block for real evidence of getting up (walking cadence, a change in body position) instead of just a heart-rate rise. A wake block with no locomotion and a stable posture — a hot night, a brief turn-over — is folded back into light sleep; a real get-up is left alone. Self-checks how much motion detail your strap actually recorded and stays off on a night that's too sparse to trust (older WHOOP 4.0 firmware, mainly). Off by default; takes effect on the next nights staged.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 0c2dc44b2e..16eccd63e5 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -221,6 +221,14 @@ object AnalyticsEngine { // instead of V1. Default false keeps V1 the byte-identical default for pure-function callers/tests; // IntelligenceEngine threads PuffinExperiment.from(context).experimentalSleepV2. Mirrors Swift. (V7 / #690) useSleepStagerV2: Boolean = false, + // Opt-in motion-aware wake refinement (#364 "Proposal 2" follow-up; density gate precedent #345). + // When true, [WakeMotionRefinement] re-derives each detected session's stages, reclassifying a + // hot-but-still WAKE segment to `light` when it shows no locomotion and a stable posture outside + // isolated burst minutes; it only ever runs AFTER V1/V2 staging and self-gates on the observed + // gravity + step density, so it is a no-op on a sparse (e.g. WHOOP 4.0) night regardless of this + // flag. Default false keeps every pure-function caller/test byte-identical; IntelligenceEngine + // threads PuffinExperiment.from(context).motionAwareWake. Mirrors Swift. + useMotionAwareWake: Boolean = false, // Sleep & Rest test-mode trace sink (E11). null = byte-identical default. When non-null the gate // trace from detectSleep and the Rest sub-score line are forwarded line-by-line. Mirrors Swift. traceSink: ((String) -> Unit)? = null, @@ -240,12 +248,22 @@ object AnalyticsEngine { ): DayResult { // ── Sleep detection + staging ───────────────────────────────────────── - val allSessions = SleepStager.detectSleep( + val detectedSessions = SleepStager.detectSleep( hr = hr, rr = rr, resp = resp, gravity = gravity, tzOffsetSeconds = tzOffsetSeconds, wristOff = wristOff, bandSleepState = bandSleepState, useSleepStagerV2 = useSleepStagerV2, traceSink = traceSink, ) + // Motion-aware wake refinement (#364 follow-up) runs AFTER V1/V2 staging, over every detected + // session (naps included — the same eligibility gates apply). `steps` is the SAME calendar-day/ + // night-window stream the caller passed for the rest of this analysis; the pass self-gates on its + // observed density, so an empty/sparse `steps` (e.g. a WHOOP 4.0, which never emits a step sample + // at all) is a no-op regardless of `useMotionAwareWake`. + val allSessions = if (useMotionAwareWake) { + detectedSessions.map { WakeMotionRefinement.refine(it, gravity, steps) } + } else { + detectedSessions + } // Sessions attributed to `day` = those whose end falls on `day` (LOCAL day, #277). `day` is // the caller's local-day key; attribute by the same offset so the bucket and the key agree. val matched = allSessions.filter { dayString(it.end, tzOffsetSeconds) == day } diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 47101166f6..7e7d3983dd 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -173,6 +173,13 @@ object IntelligenceEngine { // self-heal, which re-stages with SleepStagerV2 when true. Default false → V1 (the default, untouched // path), so existing callers / tests are unaffected. (V7 Pillar 3b) useExperimentalSleepV2: Boolean = false, + // Opt-in "Motion-aware wake refinement" flag (#364 "Proposal 2" follow-up; density gate precedent + // #345). Same Context-free threading as [useExperimentalSleepV2]: the Context-aware caller reads + // PuffinExperiment.from(context).motionAwareWake and passes it down to AnalyticsEngine.analyzeDay + // and the sleep self-heal. Default false, and even when true the pass self-gates on the night's + // OWN observed gravity + step density (see [WakeMotionRefinement]), so existing callers / tests + // and any night too sparse to trust (e.g. a WHOOP 4.0) are unaffected either way. + useMotionAwareWake: Boolean = false, // Sleep & Rest test-mode trace sink (Test Centre E5). The analytics layer is Context-free, so the // Context-aware caller (AppViewModel / WhoopBleClient) reads TestCentre.active(SLEEP) and passes a // non-null sink ONLY when the mode is on, routing each line to the .sleep-tagged strap log. null (the @@ -220,8 +227,8 @@ object IntelligenceEngine { analyzeGate.withLock { val (out, healed) = analyzeRecentOnCpu(repo, profile, maxDays, importedDeviceId, maxHROverride, nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, - recoveryEpoch, diag, useExperimentalSleepV2, sleepTraceSink, recoveryTraceSink, stepsTraceSink, - universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow) + recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, + stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow) if (healed == 0) out // #899 heal re-pass: the pass above deleted overlapping duplicate sleep sessions AFTER its days // were scored, and the read-side dedup those days consumed had no bank-recency witness (the fresh @@ -230,8 +237,8 @@ object IntelligenceEngine { // are gone), so this can never loop. Mirrors the Swift pendingForcedRescore re-arm. else analyzeRecentOnCpu(repo, profile, maxDays, importedDeviceId, maxHROverride, nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, - recoveryEpoch, diag, useExperimentalSleepV2, sleepTraceSink, recoveryTraceSink, stepsTraceSink, - universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow).first + recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, + stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow).first } } @@ -292,6 +299,8 @@ object IntelligenceEngine { diag: (String) -> Unit = {}, // Opt-in experimental staging (V2), threaded down to the sleep self-heal. Default false → V1. (3b) useExperimentalSleepV2: Boolean = false, + // Opt-in motion-aware wake refinement (#364 follow-up), threaded the same way. Default false. + useMotionAwareWake: Boolean = false, // Sleep & Rest test-mode trace sink (Test Centre E5). null = byte-identical default; when non-null // each scored day threads it into AnalyticsEngine.analyzeDay so detectSleep's gate trace + the Rest // sub-score line forward line-by-line to the .sleep-tagged strap log. Mirrors Swift. @@ -553,6 +562,8 @@ object IntelligenceEngine { // badly UNDER-stage deep/REM (kavemang, #347) — so the toggle is the escape until real 4.0 // ground truth settles it (#271/#319). Matches the self-heal restage, which reads the toggle. useSleepStagerV2 = useExperimentalSleepV2, + // #364 follow-up: same threading for the motion-aware wake refinement post-pass. + useMotionAwareWake = useMotionAwareWake, // Sleep & Rest test mode (Test Centre E5): thread the trace sink straight through. null (the // default) keeps analyzeDay's byte-identical untraced path; when the caller passed a non-null // sink (mode on), detectSleep's gate trace + the Rest sub-score line route to the .sleep-tagged @@ -744,6 +755,7 @@ object IntelligenceEngine { windowStart = windowStart, windowEnd = nowSeconds, useExperimentalSleepV2 = useExperimentalSleepV2, + useMotionAwareWake = useMotionAwareWake, ) // #299: [editsByStart] / [editOnsetByStart] are now built PER DAY inside the scoring loop (scoped to // the day each edit belongs to), NOT window-wide here. sleepEditedDaily folds any edited row that diff --git a/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt b/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt index f9a20a5173..36f0095d0c 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt @@ -5,6 +5,7 @@ import com.noop.data.HrSample import com.noop.data.RespSample import com.noop.data.RrInterval import com.noop.data.SleepSession +import com.noop.data.StepSample import com.noop.data.WhoopRepository import kotlin.math.max @@ -57,6 +58,12 @@ object SleepStageHealer { // Context-free, so the flag is threaded in from the Context-aware caller (default false → V1, so // existing callers / tests are unaffected). When true, stage with SleepStagerV2; else V1. (V7 3b) useExperimentalSleepV2: Boolean = false, + // Opt-in motion-aware wake refinement (#364 "Proposal 2" follow-up; density gate precedent #345). + // Same Context-free threading as [useExperimentalSleepV2]; default false so existing callers/tests + // are unaffected. Runs AFTER whichever stager above just ran and self-gates on the night's OWN + // observed gravity + step density, so turning it on is a no-op for any night too sparse to trust + // (e.g. a WHOOP 4.0, which never emits a step sample at all). + useMotionAwareWake: Boolean = false, ): String? { val lo = start - 3_600L val hi = end + 3_600L @@ -66,7 +73,9 @@ object SleepStageHealer { val hr = repo.hrSamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) val rr = repo.rrIntervals(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) val resp = repo.respSamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) - return restageFromSamples(start, end, grav, hr, rr, resp, useExperimentalSleepV2) + // Only read when the refinement might actually use it — no point paying for it on the (default) off path. + val steps = if (useMotionAwareWake) repo.stepSamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) else emptyList() + return restageFromSamples(start, end, grav, hr, rr, resp, useExperimentalSleepV2, steps, useMotionAwareWake) } /** @@ -100,6 +109,13 @@ object SleepStageHealer { // Opt-in experimental staging: V2 cardiorespiratory recipe when true, else default V1 (default false // so existing callers / tests stay on V1 — the V1 path is byte-identical). (V7 Pillar 3b) useExperimentalSleepV2: Boolean = false, + // Step stream for the motion-aware wake refinement below. Default empty keeps every existing + // positional caller/test byte-identical (the refinement is a no-op with no steps either way). + steps: List = emptyList(), + // Opt-in motion-aware wake refinement (#364 follow-up): a post-pass over whichever stager above + // just ran, reclassifying a hot-but-still wake segment to light. Default false, self-gated on + // observed density either way — see [WakeMotionRefinement]. + useMotionAwareWake: Boolean = false, ): String? { if (!isDense(grav, start, end)) return null val segs = if (useExperimentalSleepV2) { @@ -107,7 +123,8 @@ object SleepStageHealer { } else { SleepStager.stageSession(start = start, end = end, grav = grav, hr = hr, rr = rr, resp = resp) } - return AnalyticsEngine.encodeStages(segs) + val refined = WakeMotionRefinement.apply(segs, grav, steps, useMotionAwareWake) + return AnalyticsEngine.encodeStages(refined) } /** @@ -132,6 +149,8 @@ object SleepStageHealer { // Opt-in experimental staging, threaded from IntelligenceEngine (read off SharedPreferences by the // Context-aware caller). Default false → V1, so callers/tests that don't pass it are unaffected. (3b) useExperimentalSleepV2: Boolean = false, + // Opt-in motion-aware wake refinement (#364 follow-up), threaded the same way. Default false. + useMotionAwareWake: Boolean = false, ): List { suspend fun editedRows(): List = repo.sleepSessions(computedDeviceId, windowStart, windowEnd).filter { it.userEdited } @@ -144,7 +163,7 @@ object SleepStageHealer { // STRAP id (where the sensor streams live), not the computed namespace. Skip when the raw // isn't dense yet, or when the result already matches what's stored (steady state — no write). val newJSON = restageFromRaw(repo, strapDeviceId, row.effectiveStartTs, row.endTs, - useExperimentalSleepV2) ?: continue + useExperimentalSleepV2, useMotionAwareWake) ?: continue if (newJSON == row.stagesJSON) continue // Keyed by the IMMUTABLE detected startTs (never effectiveStartTs) so it lands on the right // primary-key row; the DAO scopes the write to userEdited = 1. diff --git a/android/app/src/main/java/com/noop/analytics/WakeMotionRefinement.kt b/android/app/src/main/java/com/noop/analytics/WakeMotionRefinement.kt new file mode 100644 index 0000000000..8c6e5e9ea4 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/WakeMotionRefinement.kt @@ -0,0 +1,382 @@ +package com.noop.analytics + +import com.noop.data.GravitySample +import com.noop.data.StepSample + +/** + * WakeMotionRefinement.kt — motion-aware wake refinement (#364 "Proposal 2" follow-up; density-gate + * precedent #345). Direct Kotlin port of the Swift `WakeMotionRefinement` + * (Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift) — same constants, same + * algorithm, same [StageSegment] shape. + * + * THE PROBLEM. Both stagers ([SleepStager] V1, [SleepStagerV2]) call "wake" primarily from HR / HR + * variability — neither consults locomotion or posture. On a strap night with pharmacologically- or + * metabolically-elevated resting HR (a supplement protocol, a hot room, alcohol — anything that keeps HR + * up without the wearer actually getting up), that HR-led call misreads hot-but-asleep as wake. A real + * anonymized night (5.0 strap, elevated HR from a supplement protocol, not illness) scored 194 min wake + * across ~6.4h in bed (efficiency 0.50) though the wearer reported sleeping through. Minute-level review of + * the four largest wake blocks found ZERO walking cadence: each block was a single-minute turn-over burst + * (20-36 step-motion-counter ticks, a posture-variance spike) bracketed by minutes of complete stillness + * (posture variance < 0.01, zero ticks) — recurring on a ~90-min rhythm consistent with hot-but-atonic REM + * being scored as wake, not a real awakening. A genuine get-up looks entirely different: sustained + * multi-minute walking cadence, not an isolated single-minute blip. + * + * THE FIX. A POST-PASS over the already-staged [StageSegment] output of either stager (V1 or V2 — this + * file never touches their HR-led emission models). For each scored wake segment of at least + * [MIN_WAKE_SEGMENT_SECONDS], look at the SAME two signals a human reviewer used above: per-minute + * step-motion-counter cadence ([StepSample.counter], wrap-aware, `activityClass` 1=walk/2=run only — see + * [walkClassTicksPerMinute]) and per-minute gravity posture variance ([postureVariance]). A segment with NO + * locomotion evidence ([hasLocomotion]) and posture stable outside a minority of isolated burst minutes + * ([stableBurstMinutes]) is a hot-but-still wake call: every non-burst minute is reclassified to `light`, + * while burst minutes (plus a ±[BURST_PAD_MINUTES] buffer) are KEPT as wake — the pass only ever shrinks + * wake time, it never invents wake the incumbent stager didn't already call. + * + * THE DENSITY SELF-GATE (#345). A per-minute posture VARIANCE needs multiple samples inside each minute to + * mean anything (a single sample has zero variance by construction, which would silently read as "stable" + * and rubber-stamp every wake block on a sparse night). And a WHOOP 4.0 never emits [StepSample] at all — + * `steps` is permanently empty on that model, so a naive "zero ticks = no locomotion" read would ALWAYS + * pass. Rather than branch on strap family/model (which the maintainer flagged in #345 as too coarse — a + * "4.0" string tells you nothing about how dense THIS night's data actually is), [isMotionDense] measures + * the OBSERVED stream directly: at least [MIN_DENSE_MINUTE_COVERAGE_FRACTION] of the night's minutes must + * carry `>= MIN_GRAVITY_SAMPLES_PER_MINUTE_FOR_VARIANCE` gravity samples AND + * `>= MIN_STEP_SAMPLES_PER_MINUTE_FOR_DENSITY` step records. A 4.0 night fails this on the data (empty + * steps, ~1 gravity vector/min) every time, exactly as a synthetically-sparse 5.0/MG night would; a real + * 5.0/MG night — which streams both channels densely — is this refinement's expected beneficiary and + * clears the gate on its own merits. + * + * SAFETY POSTURE. Default-off Experimental toggle + * ([com.noop.ble.PuffinExperiment.motionAwareWake]), consistent with the repo rule for a derived + * physiological signal: land it as an opt-in refinement, never the default, until it has more than this + * one reference night behind it. All thresholds below are NAMED CONSTANTS fixed a-priori from that + * reference night, not fit to labels. Pure, deterministic, no I/O. + */ +object WakeMotionRefinement { + + // ── Tunables (named, documented constants — see the header for where each one comes from) ────────── + + /** Only wake segments at least this long are considered (seconds). */ + const val MIN_WAKE_SEGMENT_SECONDS: Long = 5 * 60L + + /** + * A minute with at least this many walk-class ticks, held for + * [SUSTAINED_WALK_MIN_CONSECUTIVE_MINUTES] in a row, is real ambulation (a get-up), not a turn-over. + */ + const val SUSTAINED_WALK_TICKS_PER_MINUTE: Int = 10 + const val SUSTAINED_WALK_MIN_CONSECUTIVE_MINUTES: Int = 2 + + /** + * A single minute this busy is vigorous enough to count as locomotion on its own, no second minute + * required — the reference get-up example (3 consecutive minutes at 25 ticks/min) clears both this + * and the sustained rule; this constant exists for a single very busy minute with a quiet neighbour. + */ + const val SINGLE_MINUTE_WALK_TICKS: Int = 40 + + /** + * Per-minute gravity posture variance (g², see [postureVariance]) below this reads as "stable" — the + * reference night's motionless stretches measured < 0.01; 0.05 leaves headroom above strap/decode + * noise while still well below the reference night's turn-over spikes. + */ + const val STABLE_POSTURE_VARIANCE_G2: Double = 0.05 + + /** + * At least this fraction of a candidate segment's minutes must be posture-stable (i.e. NOT a burst + * minute) before the segment is trusted as "hot-but-still" rather than a real restless stretch. + */ + const val MIN_STABLE_MINUTE_FRACTION: Double = 0.80 + + /** + * Minutes kept as wake around a burst minute (the turn-over itself, plus this many minutes on each + * side) so a reclassified block doesn't shave the couple of seconds of real motion right at its edge. + */ + const val BURST_PAD_MINUTES: Long = 1L + + /** + * A minute's posture variance is only meaningful with at least this many gravity samples inside it + * (one sample has zero variance by construction and would trivially read as "stable"). + */ + const val MIN_GRAVITY_SAMPLES_PER_MINUTE_FOR_VARIANCE: Int = 2 + + /** + * A minute needs at least this many step records to say its tick cadence is a real (possibly zero) + * reading rather than a gap in the stream. + */ + const val MIN_STEP_SAMPLES_PER_MINUTE_FOR_DENSITY: Int = 1 + + /** + * Density self-gate (#345): the fraction of the WHOLE scored window's minutes that must clear both + * per-minute density floors above before ANY segment in this call is touched. Gates on the observed + * stream, never on strap family/model — see the header. + */ + const val MIN_DENSE_MINUTE_COVERAGE_FRACTION: Double = 0.80 + + /** + * WHOOP [StepSample.activityClass] codes (community finding #316) that count as locomotion. 0 = still + * (a turn-over, NOT ambulation) is deliberately excluded; null (unknown/invalid byte) is also + * excluded — unattributed ticks feed the posture-variance half of the gate instead. + */ + private val LOCOMOTION_ACTIVITY_CLASSES = setOf(1, 2) // walk, run + + // ── Public entry points ────────────────────────────────────────────────────────────────────────── + + /** + * The core pass: reclassify non-burst minutes of eligible wake segments to `light`. `segments` must + * tile a single contiguous window in order (as every `stageSession` in this package returns); `grav`/ + * `steps` should cover at least that window. Byte-identical passthrough when `segments` is empty, the + * window is degenerate, or the density self-gate declines. + */ + fun refine(segments: List, grav: List, steps: List): List { + val windowStart = segments.firstOrNull()?.start ?: return segments + val windowEnd = segments.lastOrNull()?.end ?: return segments + if (windowEnd <= windowStart) return segments + if (!isMotionDense(windowStart, windowEnd, grav, steps)) return segments + + val gravByMinute = bucketByMinute(grav) { it.ts } + val ticksByMinute = walkClassTicksPerMinute(steps) + + val out = mutableListOf() + for (seg in segments) { + for (piece in refineSegment(seg, gravByMinute, ticksByMinute)) { + appendMerging(piece, out) + } + } + return out + } + + /** + * Toggle-shaped convenience: `enabled = false` is a guaranteed byte-identical passthrough (the + * [com.noop.ble.PuffinExperiment.motionAwareWake] off-path), `enabled = true` runs [refine]. + */ + fun apply( + segments: List, + grav: List, + steps: List, + enabled: Boolean, + ): List = if (enabled) refine(segments, grav, steps) else segments + + /** + * Session-level convenience: refines `session.stages` and recomputes `efficiency` from the result + * (efficiency is derived from wake time, so reclassifying wake -> light changes it). `restingHR` and + * `avgHRV` are independent of stage labels and are carried over unchanged (via [DetectedSleep.copy]). + * Identity passthrough when [refine] makes no change to the stages. + */ + fun refine(session: DetectedSleep, grav: List, steps: List): DetectedSleep { + val newStages = refine(session.stages, grav, steps) + if (newStages == session.stages) return session + val newEfficiency = SleepStager.efficiency(session.start, session.end, newStages) + return session.copy(efficiency = newEfficiency, stages = newStages) + } + + /** Toggle-shaped convenience for the session-level overload (see [apply] above). */ + fun apply( + session: DetectedSleep, + grav: List, + steps: List, + enabled: Boolean, + ): DetectedSleep = if (enabled) refine(session, grav, steps) else session + + // ── Density self-gate (#345) ───────────────────────────────────────────────────────────────────── + + /** + * True when both the gravity and step streams are dense enough, OVER THE OBSERVED DATA, to trust + * per-minute locomotion/posture evidence across `[start, end)`. See the file header for why this + * checks the streams themselves rather than a strap family/model string. + */ + fun isMotionDense(start: Long, end: Long, grav: List, steps: List): Boolean { + val gravDensity = denseMinuteFraction(grav, start, end, MIN_GRAVITY_SAMPLES_PER_MINUTE_FOR_VARIANCE) { it.ts } + val stepDensity = denseMinuteFraction(steps, start, end, MIN_STEP_SAMPLES_PER_MINUTE_FOR_DENSITY) { it.ts } + return gravDensity >= MIN_DENSE_MINUTE_COVERAGE_FRACTION && stepDensity >= MIN_DENSE_MINUTE_COVERAGE_FRACTION + } + + /** + * Fraction of the wall-clock minutes tiling `[start, end)` that carry at least `minPerMinute` samples + * of `samples`. 0 for a degenerate (empty or inverted) window. + */ + fun denseMinuteFraction(samples: List, start: Long, end: Long, minPerMinute: Int, ts: (T) -> Long): Double { + if (end <= start) return 0.0 + val firstMinute = start / 60 + val lastMinute = (end - 1) / 60 + if (lastMinute < firstMinute) return 0.0 + val counts = HashMap() + for (s in samples) { + val m = ts(s) / 60 + if (m in firstMinute..lastMinute) counts[m] = (counts[m] ?: 0) + 1 + } + val totalMinutes = lastMinute - firstMinute + 1 + var denseMinutes = 0L + for (m in firstMinute..lastMinute) if ((counts[m] ?: 0) >= minPerMinute) denseMinutes++ + return denseMinutes.toDouble() / totalMinutes.toDouble() + } + + // ── Per-segment refinement ─────────────────────────────────────────────────────────────────────── + + /** + * Refine one [StageSegment]. Non-wake segments, and wake segments shorter than + * [MIN_WAKE_SEGMENT_SECONDS], pass through unchanged (wrapped in a single-element list). An eligible + * segment that fails EITHER the locomotion gate or the stability gate also passes through unchanged — + * this pass only ever acts when BOTH read "hot-but-still". + */ + fun refineSegment( + seg: StageSegment, + gravByMinute: Map>, + ticksByMinute: Map, + ): List { + if (seg.stage != "wake" || seg.end - seg.start < MIN_WAKE_SEGMENT_SECONDS) return listOf(seg) + val mins = minutes(seg.start, seg.end) + if (mins.isEmpty()) return listOf(seg) + + if (hasLocomotion(mins, ticksByMinute)) return listOf(seg) + + val burstMinutes = stableBurstMinutes(mins, gravByMinute) ?: return listOf(seg) + + // Keep every burst minute plus a +/-BURST_PAD_MINUTES buffer as wake, clamped to this segment's + // own bounds (never grows wake past what the stager already called); reclassify everything else. + val firstMinute = mins.first() + val lastMinute = mins.last() + val keepWake = HashSet() + for (m in burstMinutes) { + val lo = maxOf(firstMinute, m - BURST_PAD_MINUTES) + val hi = minOf(lastMinute, m + BURST_PAD_MINUTES) + if (lo <= hi) for (p in lo..hi) keepWake.add(p) + } + + val result = mutableListOf() + for ((idx, m) in mins.withIndex()) { + val stage = if (keepWake.contains(m)) "wake" else "light" + val minuteStart = if (idx == 0) seg.start else m * 60 + val minuteEnd = if (idx == mins.size - 1) seg.end else (m + 1) * 60 + appendMerging(StageSegment(minuteStart, minuteEnd, stage), result) + } + return result + } + + /** + * The locomotion gate: true when `mins` contains either a single minute at/above + * [SINGLE_MINUTE_WALK_TICKS], or [SUSTAINED_WALK_MIN_CONSECUTIVE_MINUTES] consecutive minutes each + * at/above [SUSTAINED_WALK_TICKS_PER_MINUTE]. A minute absent from `ticksByMinute` (no step coverage + * that minute) reads as 0 ticks and breaks a building streak, matching "no evidence of walking" + * rather than crediting it. + */ + fun hasLocomotion(mins: List, ticksByMinute: Map): Boolean { + var consecutive = 0 + for (m in mins) { + val ticks = ticksByMinute[m] ?: 0 + if (ticks >= SINGLE_MINUTE_WALK_TICKS) return true + if (ticks >= SUSTAINED_WALK_TICKS_PER_MINUTE) { + consecutive++ + if (consecutive >= SUSTAINED_WALK_MIN_CONSECUTIVE_MINUTES) return true + } else { + consecutive = 0 + } + } + return false + } + + /** + * The stability gate. Returns the set of "burst" (not posture-stable) minutes when at least + * [MIN_STABLE_MINUTE_FRACTION] of `mins` ARE posture-stable, so the caller can keep those burst + * minutes (+/- padding) as wake; returns `null` when the segment isn't stable enough to trust (too + * many/too little-understood unstable minutes), telling the caller to leave the segment untouched. A + * minute with too few gravity samples to compute a variance ([postureVariance] returns `null`) is + * conservatively treated as a burst minute — silence is not proof of stillness. + */ + fun stableBurstMinutes(mins: List, gravByMinute: Map>): Set? { + val burstMinutes = HashSet() + var stableCount = 0 + for (m in mins) { + val variance = postureVariance(gravByMinute[m] ?: emptyList()) + if (variance != null && variance < STABLE_POSTURE_VARIANCE_G2) { + stableCount++ + } else { + burstMinutes.add(m) + } + } + if (stableCount.toDouble() / mins.size.toDouble() < MIN_STABLE_MINUTE_FRACTION) return null + return burstMinutes + } + + // ── Signal extraction ──────────────────────────────────────────────────────────────────────────── + + /** + * Per-minute posture variance: the trace of the covariance matrix of a minute's gravity samples (the + * mean squared deviation of each sample from the minute's own mean vector, summed over x/y/z). Near 0 + * when the wrist orientation barely moves within the minute; spikes when the strap rotates through + * positions inside the minute (a turn-over). `null` below [MIN_GRAVITY_SAMPLES_PER_MINUTE_FOR_VARIANCE] + * samples — too few to say anything (see [stableBurstMinutes]'s null handling). + */ + fun postureVariance(samples: List): Double? { + if (samples.size < MIN_GRAVITY_SAMPLES_PER_MINUTE_FOR_VARIANCE) return null + val n = samples.size.toDouble() + var sx = 0.0 + var sy = 0.0 + var sz = 0.0 + for (s in samples) { sx += s.x; sy += s.y; sz += s.z } + val mx = sx / n + val my = sy / n + val mz = sz / n + var sumSq = 0.0 + for (s in samples) { + val dx = s.x - mx + val dy = s.y - my + val dz = s.z - mz + sumSq += dx * dx + dy * dy + dz * dz + } + return sumSq / n + } + + /** + * Per-minute walk-class tick cadence: the wrap-aware u16 counter delta between consecutive + * [StepSample]s, attributed to the LATER sample's minute, kept only when that later sample's + * `activityClass` is walk (1) or run (2) — see [LOCOMOTION_ACTIVITY_CLASSES]. A still-class (0) or + * unknown-class (null) delta is real counter movement (a turn-over jostles the accelerometer too) but + * is deliberately NOT counted as locomotion; it still shows up in [postureVariance] instead. Minutes + * with no qualifying tick are simply absent from the result (callers read `?: 0`). + */ + fun walkClassTicksPerMinute(steps: List): Map { + val sorted = steps.sortedBy { it.ts } + if (sorted.size < 2) return emptyMap() + val out = HashMap() + for (i in 1 until sorted.size) { + val cur = sorted[i] + val cls = cur.activityClass ?: continue + if (cls !in LOCOMOTION_ACTIVITY_CLASSES) continue + val delta = (cur.counter - sorted[i - 1].counter) and 0xFFFF // wrap-aware u16 increment + val m = cur.ts / 60 + out[m] = (out[m] ?: 0) + delta + } + return out + } + + // ── Small shared helpers ───────────────────────────────────────────────────────────────────────── + + /** + * The wall-clock minute indices (unix seconds / 60) tiling `[start, end)`. Empty for a degenerate + * (empty or inverted) window. + */ + fun minutes(start: Long, end: Long): List { + if (end <= start) return emptyList() + val first = start / 60 + val last = (end - 1) / 60 + if (last < first) return emptyList() + return (first..last).toList() + } + + fun bucketByMinute(samples: List, ts: (T) -> Long): Map> { + val out = HashMap>() + for (s in samples) out.getOrPut(ts(s) / 60) { mutableListOf() }.add(s) + return out + } + + /** + * Append `piece`, merging into the previous element when the stage matches AND the two are + * time-contiguous (defensive: every caller here produces pieces in strict chronological order with + * no gaps, so this is always a merge when the stage repeats). + */ + fun appendMerging(piece: StageSegment, out: MutableList) { + val last = out.lastOrNull() + if (last != null && last.stage == piece.stage && last.end == piece.start) { + last.end = piece.end + } else { + out.add(piece) + } + } +} diff --git a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt index 5a236f96af..1694bf9d63 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt @@ -80,6 +80,21 @@ class PuffinExperiment(private val prefs: SharedPreferences) { get() = prefs.getBoolean(KEY_HRV_READINESS, false) set(v) = prefs.edit().putBoolean(KEY_HRV_READINESS, v).apply() + /** True if the user opted in to "Motion-aware wake refinement" (default false, #364 "Proposal 2" + * follow-up): a post-pass ([com.noop.analytics.WakeMotionRefinement]) over the already-staged + * hypnogram that reclassifies a scored WAKE segment to `light` when its per-minute step-tick cadence + * shows no locomotion AND its per-minute gravity posture stays stable outside a minority of isolated + * "turn-over" burst minutes (which are kept as wake). Targets the HR-led wake call misreading a + * hot-but-still/atonic stretch as an awakening. Self-gates on the OBSERVED gravity + step-sample + * density (never on strap family/model, per #345): a WHOOP 4.0 night (sparse gravity, no step stream + * at all) fails the gate and is left untouched every time; a WHOOP 5.0/MG night, which streams both + * densely, is the expected beneficiary. Pure analysis switch — it only ever SHRINKS an already-scored + * wake segment, never invents wake time; detection and the V1/V2 staging engines are untouched either + * way. Mirrors the macOS `PuffinExperiment.motionAwareWakeKey`. */ + var motionAwareWake: Boolean + get() = prefs.getBoolean(KEY_MOTION_AWARE_WAKE, false) + set(v) = prefs.edit().putBoolean(KEY_MOTION_AWARE_WAKE, v).apply() + companion object { /** Persisted preferences file. */ private const val PREFS = "noop_experiments" @@ -105,6 +120,9 @@ class PuffinExperiment(private val prefs: SharedPreferences) { /** "HRV readiness (Plews/Altini)" readout opt-in (mirrors macOS `PuffinExperiment.hrvReadinessKey`). */ const val KEY_HRV_READINESS = "noopHrvReadiness" + /** "Motion-aware wake refinement" opt-in (mirrors macOS `PuffinExperiment.motionAwareWakeKey`). */ + const val KEY_MOTION_AWARE_WAKE = "noopMotionAwareWake" + fun from(context: Context): PuffinExperiment = PuffinExperiment(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)) } diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 6f74772895..36c945d021 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -1340,6 +1340,8 @@ class WhoopBleClient( // engine the user chose in Settings, read off SharedPreferences here (the analytics // layer is Context-free). Default off → V1. (V7 Pillar 3b) useExperimentalSleepV2 = PuffinExperiment.from(context).experimentalSleepV2, + // Opt-in motion-aware wake refinement (#364 follow-up) — same Context-free threading. + useMotionAwareWake = PuffinExperiment.from(context).motionAwareWake, // Sleep & Rest test mode (Test Centre E5): when the SLEEP domain is on, route this // post-backfill pass's per-day sleep gate trace into the .sleep-tagged strap log, so a // shared report carries the staging proof from THIS scoring pass too, not only the UI diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 9fd3b9655e..013fa229e4 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -774,6 +774,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // Opt-in experimental sleep staging (V2) — read off SharedPreferences here (the // analytics layer is Context-free) and thread it into the sleep self-heal. (V7 3b) useExperimentalSleepV2 = PuffinExperiment.from(appContext).experimentalSleepV2, + // Opt-in motion-aware wake refinement (#364 follow-up) — same Context-free threading. + useMotionAwareWake = PuffinExperiment.from(appContext).motionAwareWake, // Sleep & Rest test mode (Test Centre E5): when the SLEEP domain is on, route the // per-day sleep gate trace into the SAME shareable strap log, tagged .sleep so it // lands under the profile in the export. Zero-cost when off: the gate is one @@ -1288,6 +1290,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // Opt-in experimental sleep staging (V2) — same flag the 15-min loop reads, so a manual // re-score after an edit stages with the same engine the user chose. (V7 Pillar 3b) useExperimentalSleepV2 = PuffinExperiment.from(appContext).experimentalSleepV2, + // Opt-in motion-aware wake refinement (#364 follow-up) — same flag the 15-min loop reads. + useMotionAwareWake = PuffinExperiment.from(appContext).motionAwareWake, ) }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } } diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 1c96bd3bbb..7b64762aa3 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -442,6 +442,9 @@ fun SettingsScreen( // "Sleep staging (V2)" — V2 is the DEFAULT for every strap (WHOOP 4 and 5/MG); turn it OFF to fall back // to V1. Model-agnostic, so it lives outside the 5/MG-only card. 4.0 is unvalidated either way (#319/#347). var experimentalSleepV2 by remember { mutableStateOf(puffinExperiment.experimentalSleepV2) } + // "Motion-aware wake refinement" (#364 follow-up) — OFF by default. Self-gates on observed gravity + + // step density, so it is a no-op on a sparse (e.g. WHOOP 4.0) night regardless of this switch. + var motionAwareWake by remember { mutableStateOf(puffinExperiment.motionAwareWake) } // Whether to surface the WHOOP 5/MG-only probes (puffin / R22 / broadcast-HR / frame-capture). Gated // so a confident 4.0 owner never sees 5/MG controls that can't touch their strap (#22). The model @@ -1697,6 +1700,47 @@ fun SettingsScreen( color = Palette.textTertiary, ) + // --- Motion-aware wake refinement (#364 follow-up) — OFF by default. --- + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "Motion-aware wake refinement", + style = NoopType.subhead, + color = Palette.textPrimary, + modifier = Modifier.weight(1f), + ) + Switch( + checked = motionAwareWake, + onCheckedChange = { + motionAwareWake = it + puffinExperiment.motionAwareWake = it + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textSecondary, + uncheckedTrackColor = Palette.surfaceInset, + uncheckedBorderColor = Palette.hairline, + ), + modifier = Modifier.semantics { + contentDescription = "Motion-aware wake refinement" + }, + ) + } + Text( + "Reviews each scored wake block for real evidence of getting up (walking cadence, a " + + "change in body position) instead of just a heart-rate rise. A wake block with no " + + "locomotion and a stable posture -- a hot night, a brief turn-over -- is folded back " + + "into light sleep; a real get-up is left alone. Self-checks how much motion detail " + + "your strap actually recorded and stays off on a night that's too sparse to trust " + + "(older WHOOP 4.0 firmware, mainly). Off by default; takes effect on the next nights staged.", + style = NoopType.caption, + color = Palette.textTertiary, + ) + // Diagnostics: dump the decoded per-sample sensor streams (last 24h) to one long-format // CSV so power users / external devs can prototype sleep/activity/VBT algorithms on real // data without a BLE stream (#308/#276/#322). On-device only; plain text, no BLE hex. diff --git a/android/app/src/test/java/com/noop/analytics/WakeMotionRefinementTest.kt b/android/app/src/test/java/com/noop/analytics/WakeMotionRefinementTest.kt new file mode 100644 index 0000000000..77ef88c809 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/WakeMotionRefinementTest.kt @@ -0,0 +1,232 @@ +package com.noop.analytics + +import com.noop.data.GravitySample +import com.noop.data.StepSample +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins [WakeMotionRefinement] (#364 "Proposal 2" follow-up; density-gate precedent #345) against + * SYNTHETIC fixtures only — no real user data. Every fixture is built from [buildNight], which lays down + * a dense, still, non-ambulatory baseline (4 gravity samples/min at one fixed orientation, 1 step record/ + * min with a flat counter and `activityClass = 0`) and overrides specific minutes to inject either a + * posture "burst" (a turn-over) or real walking cadence. Android twin of the Swift + * `WakeMotionRefinementTests`. + */ +class WakeMotionRefinementTest { + + private val dev = "d" + + /** Minute-aligned so every assertion below can reason in whole minutes. */ + private val start = 1_700_000_000L / 60 * 60 + + // ── Fixture builder ────────────────────────────────────────────────────────────────────────────── + + /** + * One night's gravity + step streams over minutes `[0, totalMinutes)` relative to [start]. + * - [burstMinutes]: these minutes get a gravity vector that swings between two orientations within + * the minute (posture variance well above [WakeMotionRefinement.STABLE_POSTURE_VARIANCE_G2]); every + * other minute is a fixed, motionless orientation (variance 0). + * - [walkMinutes]: these minutes accrue [ticksPerWalkMinute] walk-class (`activityClass = 1`) + * step-counter ticks; every other minute accrues 0 ticks at `activityClass = 0` (still). + * - [sparse]: when true, mimics a WHOOP 4.0 night instead — gravity only every 5th minute, no step + * samples at all — so the density self-gate declines regardless of the burst/walk pattern. + */ + private fun buildNight( + totalMinutes: Int, + burstMinutes: Set = emptySet(), + walkMinutes: Set = emptySet(), + ticksPerWalkMinute: Int = 25, + sparse: Boolean = false, + ): Pair, List> { + val grav = mutableListOf() + val steps = mutableListOf() + var counter = 0 + for (m in 0 until totalMinutes) { + val minuteStart = start + m * 60L + if (sparse) { + if (m % 5 == 0) grav.add(GravitySample(deviceId = dev, ts = minuteStart, x = 0.0, y = 0.0, z = 1.0)) + continue // no step samples at all on the sparse fixture + } + if (m in burstMinutes) { + for (k in 0 until 4) { + val swing = k % 2 == 0 + grav.add( + GravitySample( + deviceId = dev, ts = minuteStart + k * 15L, + x = if (swing) 0.0 else 1.0, y = 0.0, z = if (swing) 1.0 else 0.0, + ), + ) + } + } else { + for (k in 0 until 4) { + grav.add(GravitySample(deviceId = dev, ts = minuteStart + k * 15L, x = 0.0, y = 0.0, z = 1.0)) + } + } + val walking = m in walkMinutes + counter += if (walking) ticksPerWalkMinute else 0 + steps.add( + StepSample( + deviceId = dev, ts = minuteStart, counter = counter and 0xFFFF, + activityClass = if (walking) 1 else 0, + ), + ) + } + return grav to steps + } + + private fun wakeSegment(minutes: Int) = StageSegment(start = start, end = start + minutes * 60L, stage = "wake") + + // ── (a) hot-but-still night, one turn-over burst every 90 min -> the still spans are reclaimed ──── + + @Test + fun hotButStillNightReclaimsTheStillSpansAroundIsolatedBursts() { + val seg = wakeSegment(180) + val (grav, steps) = buildNight(totalMinutes = 180, burstMinutes = setOf(45, 135)) + + val result = WakeMotionRefinement.refine(listOf(seg), grav, steps) + + val expected = listOf( + StageSegment(start, start + 44 * 60L, "light"), + StageSegment(start + 44 * 60L, start + 47 * 60L, "wake"), + StageSegment(start + 47 * 60L, start + 134 * 60L, "light"), + StageSegment(start + 134 * 60L, start + 137 * 60L, "wake"), + StageSegment(start + 137 * 60L, start + 180 * 60L, "light"), + ) + assertEquals( + "only the two burst minutes (+/-1 min pad) should survive as wake; the motionless stretches " + + "between them (the hot-but-atonic REM misread) should reclaim to light", + expected, result, + ) + + val totalDuration = result.sumOf { it.end - it.start } + assertEquals("the refinement must never change total session duration", 180 * 60L, totalDuration) + val wakeDuration = result.filter { it.stage == "wake" }.sumOf { it.end - it.start } + assertEquals("wake should shrink from 180 min to the 2 burst blocks (3 min each)", 6 * 60L, wakeDuration) + } + + // ── (b) a real get-up (3 consecutive minutes of 25 ticks/min) stays wake ─────────────────────────── + + @Test + fun realGetUpStaysWake() { + val seg = wakeSegment(30) + val (grav, steps) = buildNight(totalMinutes = 30, walkMinutes = setOf(10, 11, 12), ticksPerWalkMinute = 25) + + val result = WakeMotionRefinement.refine(listOf(seg), grav, steps) + + assertEquals( + "3 consecutive minutes at 25 ticks/min clears the sustained-walk locomotion gate (>=2 " + + "consecutive minutes >=10 ticks), so the whole segment must be left untouched", + listOf(seg), result, + ) + } + + // ── (c) sparse-motion night -> the density self-gate declines to act ────────────────────────────── + + @Test + fun sparseMotionNightDeclinesToAct() { + val seg = wakeSegment(180) + // Same burst pattern as the dense test above (which DOES reclassify) so the only variable here + // is density -- proving the decline is the gate, not the burst pattern being ineligible. + val (grav, steps) = buildNight(totalMinutes = 180, burstMinutes = setOf(45, 135), sparse = true) + + val result = WakeMotionRefinement.refine(listOf(seg), grav, steps) + + assertEquals( + "a WHOOP-4.0-shaped stream (sparse gravity, no step samples) must fail the density self-gate " + + "and leave the incumbent wake call untouched, per #345", + listOf(seg), result, + ) + } + + // ── (d) toggle off -> byte-identical output ──────────────────────────────────────────────────────── + + @Test + fun toggleOffIsByteIdenticalPassthrough() { + val seg = wakeSegment(180) + val (grav, steps) = buildNight(totalMinutes = 180, burstMinutes = setOf(45, 135)) + + val off = WakeMotionRefinement.apply(listOf(seg), grav, steps, enabled = false) + assertEquals("enabled=false must be a guaranteed byte-identical passthrough", listOf(seg), off) + + // Sanity: the SAME fixture actually changes when enabled, so the assertion above isn't vacuous. + val on = WakeMotionRefinement.apply(listOf(seg), grav, steps, enabled = true) + assertNotEquals("fixture sanity check: this fixture must be live when enabled", listOf(seg), on) + } + + // ── (e) tracks VARYING inputs -- multiple patterns, each with a different injected reclaim amount, + // each recovered (repo hard rule for a derived physiological signal: prove the method tracks varying + // input, not a single lucky match). ──────────────────────────────────────────────────────────────── + + @Test + fun recoversDifferentInjectedReclaimAmountsAcrossPatterns() { + data class Scenario(val name: String, val totalMinutes: Int, val burstMinutes: Set) + val scenarios = listOf( + Scenario("single burst", 40, setOf(20)), + Scenario("two well-separated bursts", 120, setOf(20, 80)), + Scenario("three well-separated bursts", 90, setOf(10, 40, 70)), + Scenario("two adjacent bursts (padding overlaps)", 60, setOf(5, 6)), + Scenario("burst at the segment's own edge (padding clamps)", 50, setOf(0, 49)), + ) + + val reclaimedAmounts = mutableSetOf() + for (s in scenarios) { + val seg = wakeSegment(s.totalMinutes) + val (grav, steps) = buildNight(totalMinutes = s.totalMinutes, burstMinutes = s.burstMinutes) + val result = WakeMotionRefinement.refine(listOf(seg), grav, steps) + + // Expected kept-as-wake minutes = union of each burst minute +/-1, clamped to the segment. + val expectedKept = mutableSetOf() + for (m in s.burstMinutes) { + val lo = maxOf(0, m - 1) + val hi = minOf(s.totalMinutes - 1, m + 1) + if (lo <= hi) expectedKept.addAll(lo..hi) + } + val expectedReclaimedMin = s.totalMinutes - expectedKept.size + + val totalDuration = result.sumOf { it.end - it.start } + assertEquals("[${s.name}] duration must be conserved", s.totalMinutes * 60L, totalDuration) + + val actualWakeMin = (result.filter { it.stage == "wake" }.sumOf { it.end - it.start } / 60L).toInt() + val actualReclaimedMin = s.totalMinutes - actualWakeMin + assertEquals( + "[${s.name}] expected $expectedReclaimedMin min reclaimed to light, got $actualReclaimedMin", + expectedReclaimedMin, actualReclaimedMin, + ) + reclaimedAmounts.add(actualReclaimedMin) + } + + // Belt-and-suspenders on the point of the test: the scenarios really do inject different amounts. + assertTrue( + "fixture sanity: scenarios must inject genuinely different reclaim amounts", + reclaimedAmounts.size > 1, + ) + } + + // ── Session-level convenience (efficiency recompute) ─────────────────────────────────────────────── + + @Test + fun sessionLevelRefineRecomputesEfficiency() { + val session = DetectedSleep( + start = start, end = start + 180 * 60L, efficiency = 0.0, + stages = listOf(wakeSegment(180)), restingHR = 52, avgHRV = 40.0, + ) + val (grav, steps) = buildNight(totalMinutes = 180, burstMinutes = setOf(45, 135)) + + val refined = WakeMotionRefinement.refine(session, grav, steps) + + assertTrue( + "reclassifying 174 of 180 wake minutes to light must raise efficiency", + refined.efficiency > session.efficiency, + ) + assertEquals( + SleepStager.efficiency(session.start, session.end, refined.stages), + refined.efficiency, + 1e-9, + ) + assertEquals(session.restingHR, refined.restingHR) + assertEquals(session.avgHRV, refined.avgHRV) + } +}