Skip to content

[diagnostics] The REM funnel explains a hypnogram no shipped install shows: it replays V1 while the screen stages with V2 #2365

Description

@ayiskakov

Symptom

On a WHOOP 5/MG (iOS 11.8.2, fw 50.39.1.0), two consecutive nights show the strap-log REM funnel
and the sleep screen disagreeing by 5–7× on the same night:

night funnel final= funnel minutes (30s epochs) screen REM
2026-09-19 85 rem of 1326 sleep-epochs ~42 min 4h 54m
2026-09-20 92 rem of 1230 sleep-epochs ~46 min 3h 51m

Funnel line for the second night:

REM-funnel: 1230 sleep-epochs, classify=100 rem, final=92 rem (onset-guard stripped 5); resp=ABSENT;
blocked[notStill=61, noCardiac=549, respRegular=0, noRespBar=335], otherStage=185

Read together with the screen, this says the app displayed ~3h of REM it could not substantiate.
That reading is wrong — but it is the reading the funnel's own documentation instructs.

Cause

The two numbers come from two different stagers, and the funnel claims otherwise.

  • The funnel replays V1: remFunnelDiagnostic mirrors classifyOne
    (Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift:2627-2656, :2464-2508).
  • The screen is staged by V2: IntelligenceEngine.swift:1331
    SleepStager.detectSleep(useSleepStagerV2:)SleepStagerV2.stageSession, and the selector flag
    is default-ON (Strand/BLE/PuffinExperiment.swift:221-225; restated at SleepStager.swift:1307-1312:
    "a normal user's nights are staged by V2").
  • The funnel's doc comments assert identity with the display: SleepStager.swift:2566-2568
    ("shares the exact classifier seam with stageSession, so it explains the SAME hypnogram the app
    shows") and :2697. Both sentences are true of V1's stageSession and false of every shipped
    install.

A 5/MG makes the divergence maximal rather than incidental. V1's primary REM rule
(still && cardiacActivated && resp.meetsIrregularBar) is structurally unreachable there: the v18
layout carries no raw resp channel (SleepStager.swift:2348-2353), every resp epoch is .unmeasured,
and meetsIrregularBar is false for .unmeasured (:2456-2461) — so only the strict double-cardiac
fallback can award REM, and noRespBar/respRegular are structural rather than informative. V2 never
reads that channel at all; it recovers respiration regularity from R-R via RSA
(SleepStagerV2.swift:393-428), which is fully populated on the same night (rr=32540). So
resp=ABSENT is true for the code the funnel ran and false for the code that produced the number on
screen.

This is the shape the repo's own rules name: "a funnel free to disagree with the code it describes is
worse than no funnel, because it will be believed" (WorkoutDetector.swift:153-154), and AGENTS.md's
"a diagnostic may only assert what it can attribute" / "two readouts of one fact must not be able to
disagree".

What this is NOT claiming

The funnel's arithmetic is sound (the partition sums exactly, as SleepStagerTests.swift:1478-1480
pins), and V2 being the display stager is deliberate. The defect is the funnel's identity claim — the
sentence, not the numbers.

Fix shape

Minimum honest fix: the funnel line names which stager it replays and which one staged the screen —
e.g. append · funnel replays V1; screen staged by V2 — totals can differ at the print site
(Strand/System/DebugDataDiagnostics.swift:299-303), keyed off PuffinExperiment.experimentalSleepV2Enabled.
Better long-term: a funnel that replays whichever stager the flag selects.

The Android twin has the identical defect: AndroidDiagnostics.kt:348 prints the Kotlin
SleepStager.remFunnelDiagnostic (V1, SleepStager.kt:2962) while IntelligenceEngine.kt:1184
stages with V2.

Secondary observation (separate issue if wanted)

The screen's 37–48% REM on these ~10h nights sits well outside the 20–25% band the funnel's own
header cites (SleepStager.swift:2555), and lands in V2's measured long-night failure mode
(Tools/SleepPSG/.../main.swift:307-310: corr(total sleep, REM%) = +0.579, traced to the fixed
60-minute REM-latency penalty). No display-side gate can catch over-assignment — H9
(SleepView.swift:1019-1039) and "May be incomplete" (:996-1001) both test only for too-little /
too-short. A REM-plausibility note on the display path may be worth its own discussion; happy to file
separately rather than mix concerns here.

Environment

WHOOP MG (DIS-attested), fw 50.39.1.0, iOS app 11.8.2 (build-from-source fork at upstream
e29bfe91 + fork-only re-pointing), iPhone17,2, iOS 26.6.2. Both strap logs available on request
(funnel lines quoted verbatim above).

Refs #688, #2012, #319, #347.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions