Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
12 changes: 11 additions & 1 deletion Strand/System/DebugDataDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
21 changes: 15 additions & 6 deletions android/app/src/main/java/com/noop/analytics/SleepStager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<GravitySample>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading