From bd70da7ee630d62314f1fc9259c45ebe1045389b Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Mon, 21 Sep 2026 09:19:59 +0200 Subject: [PATCH 1/2] diagnostics: REM funnel names the stager it replays vs the one that staged the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The funnel replays V1's classifier while every shipped install stages with V2 (default-on flag), and its summary read as an explanation of the displayed hypnogram. On a 5/MG the gap is maximal — V1's primary REM gate needs the raw resp channel that hardware never emits, while V2 recovers respiration from R-R — observed in the field as 46 min funnel vs 231 min screen for one night. Suffix the summary with both stager identities, byte-identical on both platforms, so the two readouts can no longer be mistaken for one fact. Refs #2365 --- Strand/System/DebugDataDiagnostics.swift | 12 +++++++++++- .../java/com/noop/testcentre/AndroidDiagnostics.kt | 13 ++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index 08cda7c2df..58a6af6645 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -309,7 +309,17 @@ enum DebugDataDiagnostics { return lines } if let rem = SleepStager.remFunnelDiagnostic(start: cs.startTs, end: cs.endTs, grav: grav, hr: hr, rr: rr, resp: resp) { - lines.append(rem.summary) + // The funnel replays the V1 classifier, but the shipped hypnogram is staged by V2 whenever + // the default-on flag says so — name both, or the two totals read as one fact disagreeing. + // On a 5/MG the gap is maximal: V1's primary REM gate needs the raw resp channel that + // hardware never emits, while V2 recovers respiration from R-R, so the funnel can report + // ~46min REM against a 231min screen for the same night. + let screenStager = PuffinExperiment.experimentalSleepV2Enabled ? "V2" : "V1" + var summary = rem.summary + " · funnel replays V1; screen staged by \(screenStager)" + if screenStager != "V1" { + summary += " — totals can differ" + } + lines.append(summary) } else { lines.append("REM funnel: insufficient motion data (<2 gravity samples)") } diff --git a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt index c2d6b607e4..948ca2d222 100644 --- a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt +++ b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt @@ -414,7 +414,18 @@ object AndroidDiagnostics { return@runCatching } com.noop.analytics.SleepStager.remFunnelDiagnostic(session.startTs, session.endTs, grav, hr, rr, resp) - ?.let { add(it.summary) } ?: add("REM funnel: insufficient motion data (<2 gravity samples)") + ?.let { + // The funnel replays the V1 classifier, but the shipped hypnogram is staged by V2 + // whenever the default-on flag says so — name both, or the two totals read as one + // fact disagreeing. On a 5/MG the gap is maximal: V1's primary REM gate needs the + // raw resp channel that hardware never emits, while V2 recovers respiration from + // R-R. Suffix byte-identical to the Swift twin in DebugDataDiagnostics. + val screenStager = + if (com.noop.ble.PuffinExperiment.from(context).experimentalSleepV2) "V2" else "V1" + var summary = it.summary + " · funnel replays V1; screen staged by $screenStager" + if (screenStager != "V1") summary += " — totals can differ" + add(summary) + } ?: add("REM funnel: insufficient motion data (<2 gravity samples)") val det = com.noop.analytics.DetectedSleep( start = session.startTs, end = session.endTs, efficiency = session.efficiency ?: 0.0, stages = emptyList(), From 986b5298cf665601a620c5734a4fb15792fd87a2 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 23 Sep 2026 18:37:12 +1200 Subject: [PATCH 2/2] sleep: correct the REM funnel's own claim that it explains the screen's hypnogram The funnel's doc said it re-runs "the SAME Stage-0-3 staging seam stageSession uses ... so we explain the SAME hypnogram". That has been false since V2 became the default: remFunnelDiagnostic is SleepStager's own seam, and the shipped hypnogram comes from SleepStagerV2 whenever experimentalSleepV2 says so, which is by default. The log line now names both stagers. Leaving the function saying the opposite would teach the next reader the belief the line exists to correct, and a change built on it would reintroduce the confusion. Both platforms, since the Kotlin twin carried the same sentence. --- .../Sources/StrandAnalytics/SleepStager.swift | 21 +++++++++++++------ .../java/com/noop/analytics/SleepStager.kt | 21 +++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index e81c814c38..8ee34755e1 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -2656,12 +2656,21 @@ public enum SleepStager { return .noRespFallbackBar // resp never measured and the no-resp bar unmet } - /// Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs the SAME Stage-0→3 - /// staging seam `stageSession` uses (epoch grid → Cole–Kripke → features → classify → smooth → - /// re-impose), but instead of emitting a hypnogram it COUNTS where REM was lost. Changes NOTHING: - /// no label, no score, no session. Returns nil only when the window has too little gravity to grid - /// (mirroring `stageSession`'s degenerate fallback, which carries no REM to explain). The caller - /// logs `.summary`; tests assert the counts. Pure + deterministic. (#688) + /// Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs THIS type's + /// Stage-0→3 seam (epoch grid → Cole–Kripke → features → classify → smooth → re-impose), but + /// instead of emitting a hypnogram it COUNTS where REM was lost. Changes NOTHING: no label, no + /// score, no session. Returns nil only when the window has too little gravity to grid (mirroring + /// `stageSession`'s degenerate fallback, which carries no REM to explain). The caller logs + /// `.summary`; tests assert the counts. Pure + deterministic. (#688) + /// + /// WHICH HYPNOGRAM THIS EXPLAINS. V1's, always — this function is `SleepStager`'s own seam. It used + /// to say it explained "the SAME hypnogram" as the screen, and that has been false since V2 became + /// the default: the shipped hypnogram is staged by `SleepStagerV2` whenever + /// `experimentalSleepV2Enabled` says so, which is by default. On a 5/MG the two can be far apart, + /// because V1's primary REM gate needs the raw respiratory channel the hardware never emits while V2 + /// recovers respiration regularity from R-R: one field pair reported ~46 min REM here against hours + /// on the screen for the same night (#2365). The caller's line names both stagers for that reason + /// (#2366); do not restore the claim that they are one. public static func remFunnelDiagnostic(start: Int, end: Int, grav: [GravitySample], hr: [HRSample], rr: [RRInterval], resp: [RespSample]) -> REMFunnelDiagnostic? { diff --git a/android/app/src/main/java/com/noop/analytics/SleepStager.kt b/android/app/src/main/java/com/noop/analytics/SleepStager.kt index 05cc12cd77..a5ef9f2a54 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -2955,12 +2955,21 @@ object SleepStager { } /** - * Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs the SAME - * Stage-0→3 staging seam [stageSession] uses (epoch grid → Cole–Kripke → features → classify → - * smooth → re-impose), but instead of emitting a hypnogram it COUNTS where REM was lost. Changes - * NOTHING: no label, no score, no session. Returns null only when the window has too little gravity - * to grid (mirroring [stageSession]'s degenerate fallback, which carries no REM to explain). The - * caller logs `.summary`; tests assert the counts. Pure + deterministic. Mirrors Swift. (#688) + * Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs THIS object's + * Stage-0→3 seam (epoch grid → Cole–Kripke → features → classify → smooth → re-impose), but + * instead of emitting a hypnogram it COUNTS where REM was lost. Changes NOTHING: no label, no + * score, no session. Returns null only when the window has too little gravity to grid (mirroring + * [stageSession]'s degenerate fallback, which carries no REM to explain). The caller logs + * `.summary`; tests assert the counts. Pure + deterministic. Mirrors Swift. (#688) + * + * WHICH HYPNOGRAM THIS EXPLAINS. V1's, always — this is [SleepStager]'s own seam. It used to say it + * explained "the SAME hypnogram" as the screen, and that has been false since V2 became the + * default: the shipped hypnogram is staged by `SleepStagerV2` whenever `experimentalSleepV2` says + * so, which is by default. On a 5/MG the two can be far apart, because V1's primary REM gate needs + * the raw respiratory channel the hardware never emits while V2 recovers respiration regularity + * from R-R: one field pair reported ~46 min REM here against hours on the screen for the same night + * (#2365). The caller's line names both stagers for that reason (#2366); do not restore the claim + * that they are one. */ fun remFunnelDiagnostic( start: Long, end: Long, grav: List,