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/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/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, 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(),