Findings from the physiological-signal suite, written up so they need not be rediscovered. Each paper regenerates its tables and figures deterministically from a named, local analysis tool. Standardized format: paper.css. Drafts — not peer-reviewed, not for clinical use.
The production OxyDex ODI-4 detector recovered only ≈¼ of scored respiratory events (slope 0.23, R² 0.93), under-counting most in severe disease (≈−30 events/h). The shipped AHI ≈ ODI-4 × 1.1 surrogate has LOO-RMSE 15.2/h; a re-fit linear correction halves it to 7.2/h. Now corrected (v22.36): traced to trailing-mean baseline self-suppression and fixed with a p90 ceiling baseline — roughly halving the severe-stratum bias (≈−31→−16 events/h) and lifting the ODI-4↔AHI slope ≈0.42→0.69. Includes a closed-form sample-size analysis: a real-PSG validation needs ≈150–300 paired nights, bound by the severe-stratum count.
A three-rung recipe for stating a sensor's uncertainty when you own no calibrated reference: repeatability (no reference), transfer-standard Bland–Altman/Arms (promote the H10 ECG strap to ≈truth), and the three-cornered hat (three devices, none assumed canonical). On 126,277 co-recorded 1-Hz seconds the O2Ring pulse is unbiased vs ECG (−0.34 bpm) but carries a random σ ≈ 3.7 bpm at rest (one motion night hit 9.3). The Verity Sense's onboard HR was dead — but its HR recovered cleanly from raw PPG (SQI ≈1.0), and with the H10 leg re-derived from raw ECG, a real ~2-hour three-device hat gives reference-free σ of 1.7 / 2.2 / 6.2 bpm (O2Ring / H10 / Verity). SpO₂ trueness stays out of reach (no arterial reference).
Test–retest ICC(1,1) of three metrics measured per occasion by the real detectors on stable (flat-arc) patients. ODI-4 and rMSSD are reliable individual traits from a single night (ICC₁ 0.89 / 0.93; rMSSD at ICC≥0.90 from one night, ODI-4 from two). Daily CGM-CV shows negligible between-subject variance (ICC≈0) — a day-level state, not a one-number trait, so no recording length makes it reliable. Spearman–Brown → minimum occasions per metric; latent-target ANOVA confirms the detector preserves the reliability structure.
Measured (real PulseDex) rMSSD falls ≈4.2 ms/decade of age and ≈2.2 ms per 10 AHI — comparable independent weights, effectively additive (age×AHI interaction significant at n=559k but negligible, ≈+0.03 ms/decade·10AHI). A single-metric rMSSD screen for moderate+ OSA scores AUC 0.69 with 25% of flags being old-and-healthy; an age-adjustment recovers it to 0.78.
A single change-point detector on the per-night ODI-4 (OxyDex) and rMSSD (PulseDex) trajectory recovers the planted CPAP-start night with median error 0 nights and within ±1 night in 96–99% of patients. Fusing the respiratory and autonomic channels is best — exact 97%, within ±1 night 99%, detection AUC 0.99 vs flat-arc controls — beating either alone. Notes that the free-split step-R² is inflated under the null (≈25–28% on flat controls), so detection must be read against a control distribution.
GlucoDex (CGM) and PulseDex (RR→HRV) — sharing no code, parser, or input — recover a coherent negative coupling on co-generated nights: higher nocturnal glucose ↔ lower rMSSD (within-patient r −0.16; pooled −0.23). It is a shared-driver effect, not direct: partialling out apnea burden collapses it to +0.09, while the driver legs are strong and opposite-signed (apnea→glucose +0.58, apnea→rMSSD −0.51). Certifies cross-node temporal coherence + confound-aware recovery; the discrete nocturnal-hypo arm is underpowered and reported as exploratory.
Scoring rMSSD with all three real detectors on the same co-generated beats (one ~9-min window/patient): ECGDex ≡ PulseDex — bias +0.02 ms, 95% LoA ±0.6 ms, r 0.9997 (two detectors sharing no code, signal, or sampling rate recover the identical tachogram). PpgDex diverges — bias +12.6 ms (+32%), LoA −7.9…+33, r 0.57; the optical-only dispersion (≈10.4 ms) is almost entirely pulse-arrival-time jitter, not sampling or detection. So RR/ECG rMSSD may be pooled or cross-validated, but optical PRV is a different quantity — report it as its own metric and weight, don't substitute, in fusion.
On the FULL-lane waveform harness, ECGDex (Pan–Tompkins QRS) recovers the beat train apnea-invariantly (99.8% clean ≈ 99.8% apnea, precision 99.8%), while PpgDex optical pulse recovery is lower (97.3% clean), dips in apnea (96.4%) as perfusion nears the detection floor, and mildly over-detects (precision 92.4%). The detector's correlation-based SQI stays green throughout (0.93→0.90), under-representing both misses and false positives — and the imperfect optical train inflates rMSSD a median +16%. Conclusion: when both signals are present, use the electrical arm as the HRV reference and weight optical HRV by event-state, not SQI. ECG is faithful in both yield and rMSSD (R-peaks rendered at true beat times) — the reference arm.
20,000 frozen-seed synthetic patients (all 16 severity×arc strata, ≥1,092 each) run end-to-end through the unmodified production detectors + real Integrator fusion, harvesting a per-patient failure ledger. Zero fatals, throws, out-of-bounds metrics, or kernel mismatches; 99.7% cross-node fusion overlap; OxyDex worst-case 1.21 s/night (no hang). The one systematic failure is soft: ODI-4 desaturation recall collapses (cohort median 4%) and trips the severe-recall flag across the severe stratum, with the ODI→truth-AHI under-count deepening monotonically (−1.4/−5.1/−12.3/−30.8 events/h none→severe) while calibration tightens (R² 0.77→0.92) — proportional and predictable, not random. That under-count has since been corrected and the same 20,000-patient instrument re-run on v1.7: the OxyDex ceiling-baseline fix (v22.36) + generator de-pile collapse the severe bias −30.8→−17.2 events/h, flatten the gradient (none/mild/mod −1.0/−3.7/−8.1), and lift the ODI→AHI slope 0.14→0.38 (R² 0.92→0.96), with no inflation of the non-apneic stratum and the hard-failure ledger unchanged.
The metrology twin of nights-icc: how many co-recorded O2Ring + H10 + Verity windows pin each device's reference-free error σ? Monte-Carlo (720 trials/cell, Web-Worker pool) over the same three-cornered-hat kernel with planted σ 1.7 / 2.2 / 6.2 bpm shows one ~1-hour window pins the whole trio to ±0.5 bpm; ±0.25 needs ~5–8, ±0.15 ~12–20. The hat couples the corners, so the noisy Verity leg sets a shared floor (not "the noisy device needs far more data"). The regime cost is bias, not N: a resting night under-states the instantaneous devices (H10 −21%) because the TCH strips shared HRV — a dynamic session recovers the full σ and makes the uncorrelated-error assumption testable. One real window (7,057 s) recovers 1.67 / 2.17 / 6.22 bpm — an anecdotal comparison that matches the planted totals.
A parking note, not a result. Single-channel waveform synthesis is solved elsewhere; the open frontier the harness exposes is timestamp pathology, cross-node temporal coherence, and provenance metadata — three of which need no learned generator. Records the agenda so it isn't rediscovered later, and names the narrowest first paper (a deterministic timestamp-pathology benchmark).
PAPERS-ROADMAP-2026-06-24-BRIEF.md)The series is lopsided toward simulation scored by real detectors; the planned stack (vendor-adapter layer + multi-vendor unifier, OverDex, EEGDex, Ultrahuman/Spiro nodes) opens the real-world validation front. Each candidate is tagged with its stack dependency. Honest sim/real labelling and a named regeneration tool remain mandatory.
Buildable now — no new node
tMs model stated as a citable method. The narrowest first paper already named by synthetic-data-frontier; corpus mostly exists in the parseTimestamp tests. methods/repro LOW effort · now0 when inputs are absent); daily CGM-CV ICC≈0; SQI green while beat-yield fails under apnea; rolling-mean ODI self-suppression. A citable "map of the walls in the maze" — the manifesto made into an artifact. synthesis LOW–MED · nowUnlocked by the planned stack — the real-validation front
rmssd-equivalence: pool real co-recorded consumer devices across vendors (Polar, Coospo, Wahoo, Garmin…) and test rMSSD/SDNN agreement reference-free via the three-cornered hat. A hat across N consumer vendors is genuinely rare. real data needs adapter Phase 1 · MEDsigma-no-reference pinned σ from one ~2 h hat; OverDex auto-detects every co-recording in an archive → track each device's σ over months (does consumer-sensor error drift, caught with no reference?). sensor-trio-nights already did the power analysis. real data needs OverDex · MEDcgm-hrv-coupling: the first real test of the synthetically-predicted glucose↔HRV shared-driver effect on co-worn data. real data needs UltrahumanDex · MEDnights-icc). real data needs SpiroDex · MED–HIGHPAPERS-ROADMAP-2026-06-24-BRIEF.md (above). The earlier batched re-bundle pass (Integrator sample fix + OxyDex hardening) is DONE — both gates green; see the FIXED findings below. The provenance finding (buildHash covers only the bundle template, not the asset manifest) could still seed a short reproducibility methods note ("what does buildHash actually certify?").detectODI's baseline. It measured each dip against a trailing 5-min MEAN SpO₂ (computeBaselineArr); in severe OSA the closely-spaced dips drag that mean down, sinking the baseline−4% threshold so later events of equal depth fall below it and go uncounted — the worse the apnea, the more it self-suppresses. Fix: a new O(n) trailing p90 ceiling baseline (computeCeilingBaselineArr in oxydex-util.js, sliding 101-bin SpO₂ histogram) wired into detectODI only; brief dips sit in the lower tail and can't suppress a high percentile, so the ceiling tracks the resting SpO₂ a desaturation is clinically defined against. Representative re-run (N=220, same generator, identical SpO₂ both ways): severe bias −30.6→−15.7, gradient flattened (none −1.3→−0.9, mild −7.9→−6.2, mod −14.2→−10.3), ODI↔AHI slope 0.42→0.69, non-apneic stratum not inflated. The full-pipeline SubjectA pilot rises in step (severe night ODI-4 7.6→14.9). This is a genuine real-world oximetry improvement (documented trailing-mean-baseline behavior), not overfitting to the simulator — the percentile/window are defensible clinical constants (p90, 5-min), untuned. The ODI-4 × 1.1 AHI surrogate was re-examined and retained (corrected ODI still under-represents AHI, slope-to-truth ≈1.4 > 1, so ×1.1 does not over-shoot). JS-only change → buildHash unchanged (09c77b53517c), verify-provenance.html all-green; Dex-Test-Suite green (545/34) with a new ceiling-baseline contract assertion. Papers updated: odi4-ahi-bias (characterized→corrected), robustness-benchmark §3.3. Re-bundled OxyDex.html. See OXYDEX-ODI-CEILING-FIX-BRIEF.md.CohortGen.VERSION → 1.6-pilot. Because chance() consumes one RNG draw regardless of its probability, the RNG stream is unchanged → rMSSD / ODI / age / AHI are byte-identical to v1.5 (the hrv and treatment-response results are untouched); only which patients carry a CGM shifts. nights-icc re-run on v1.6: CGM-CV now has 4,059 subjects (was 1,818), near parity with ODI/rMSSD — and its ICC₁ is still ≈0, confirming the “daily glycemic variability is a state, not a trait” finding was never a coverage artifact.N≥1000 crashed the tab during generation. Root cause was isolated to hrv-confound-analysis.js: its progress-counting first pass built the entire cohort up front — patients.push(CohortGen.patient(s)) with no only filter, so every patient's OxyDex CSV + RR + CGM + HRV files for all 1–12 nights were rendered and retained in memory simultaneously. Fine at the ~250-patient default; fatal at 1k+. Fix: stream. Count nights from sampleProfile alone (no rendering), then generate one patient at a time with only:['rr'] (PulseDex's sole input), score it, and discard before the next. Memory is now flat at any N. Verified: 1000 patients (5699 nights) in ~3 min, no crash — and the tighter estimates replace the 626-night pilot (see hrv-age-confound.html).hrv-confound had the fatal pattern. The other FAST analysis tools (nights-icc, treatment-response, cgm-hrv-coupling) already stream one patient per loop iteration and accumulate only small numeric per-night/per-subject records — no crash at scale. cohort-runner.html persists each result to IndexedDB as it goes (refresh/crash-resumable) and was already built for N≤20000. The FULL-lane workers (qrs-yield-worker.js, qrs-equiv-worker.js) keep the 176 Hz waveforms inside the worker realm, returning only per-window stats — memory-safe per job (though FULL-lane runtime at 20k is hours, not minutes). The only remaining 20k blocker was non-memory: hard input caps of 5000 and profile-scan ceilings (100k–200k) that bind before a selective-arc target (flat / intervention) can fill. Raised all six analysis tools to max=20000 and scan CAP=2,000,000. The suite can now be driven to 20k without code changes; the practical limit is wall-clock (serial real-detector round-trips), not memory.cohort-gen.js applied therapy residual as ahi = Math.min(ahi, clamp(ahi*0.6, 0, 15)). For any treated night with baseline AHI ≥ 25, ahi*0.6 ≥ 15, so the clamp returned exactly 15.0 — pinning every such night onto one x-value. Measured over 2,480 sampled nights: 9.7% sat at exactly AHI = 15.0 (≈22% of all CPAP nights), drawing a hard vertical line with a sharp right edge on the low-AHI block (a secondary cause of the dense left wall is per-patient vertical streaks — a flat-arc patient's nights share a near-constant AHI while rMSSD varies night-to-night). A generator artifact, not a detector/render bug, but it distorted the AHI distribution (an unrealistic therapy-night spike that also carried the +6 ms CPAP rMSSD bump). Fix: proportional, jittered residual ahi = clamp(ahi*(0.15 + rng()*0.25), 0, ahi) — 15–40% of baseline, never above it, no pileup — and bumped CohortGen.VERSION 1.0-pilot → 1.1-pilot. Ripple: changes every synthetic-pilot AHI distribution, so all simulation pilots are being re-run on v1.1; buildHash provenance is unaffected (it fingerprints each app's bundle template, and cohort-gen.js is in no app bundle), and Dex-Test-Suite stays green (no test asserts an exact cohort AHI or the generator version).cohort-gen.js: (v1.2) worsening-arc severe nights overshot the hard clamp(ahi·jitter, 0, 90) and stacked a vertical line at exactly AHI=90 → replaced with a jittered ceiling (0 … 80+rng()·12, i.e. 80–92) so overshoot fades out instead of piling; (v1.3) the per-night rMSSD clamp(…, 9, 72) and per-patient baseline clamp(…, 12, 62) stacked horizontal lines at the rMSSD floor (~13 ms, low-HRV nights) and ceiling → replaced with jittered bounds (floor 5+rng()·5, ceiling 70+rng()·6; baseline 9+rng()·5 … 58+rng()·8). General rule established: a hard clamp() to a constant in a rendered quantity draws a pileup line on any scatter of that quantity — jitter the bound. CohortGen.VERSION → 1.3-pilot. Effect on results is trivial (the clamps bind only in the distribution tails; the age/AHI slopes and AUCs move within rounding), provenance unaffected (cohort-gen is in no app bundle), and the suite stays green. Figures re-rendered at high resolution.baseAHI·(1+0.7·t) ≈ 136, and the hard clamp clamp(ahi·jitter, 0, 80+rng()·12) collapsed that whole over-shooting mass into the narrow 80–92 band. The jitter softened the original razor line but not the pileup. Fix: replaced the hard ceiling with a soft asymptotic saturation (same approach as the v1.5 rMSSD floor): ahi = 95·(1 − exp(−jit/95)), so AHI fades smoothly toward ~95 instead of stacking at the cap. CohortGen.VERSION → 1.7-pilot. Cosmetic for the fit (R²≈0.98 unchanged) but removes the last clamp-pileup; AHI-dependent pilots are re-run on v1.7.integrator-app.js bindSamples() fetched two hardcoded paths — uploads/ecgdex_2026-06-07 (4).json and uploads/OxyDex_2026-06-09_0529_summary.json — neither of which existed any more, so both 404'd, a warning was pushed, and the button silently did nothing. Fix: repointed the two filenames at the surviving exports uploads/ecgdex-2026-06-12.node-export.json + uploads/oxydex-2026-06-12.summary.json (no Integrator.src.html change needed — the paths live only in the app JS). Re-bundled Integrator.html. Verified post-fix: both load through the real normalizeFile path and fuse — 360 min overlap across 2 recordings, confirmed-events panel populated, desat match rate 100%.cleanArtifactHR defensive hardening — applied June 2026. The processNight hang originally surfaced while bootstrapping the ODI pool does not reproduce through the real pipeline: parseCSV already drops --,--,0 rows (line ~727), so processNight never sees them, and the one infinite-loop path in cleanArtifactHR (i = j with a non-advancing inner while) is unreachable with the shipped constants (HARD 20 · RECOV 10 · SOFT 15 → a soft rise always exceeds RECOV, so j always advances). It stays gated by tests/oxy-hang.worker.js (real parseCSV→processNight on a heavy-dropout pool, watchdog-timed — green, 15 nights, worst 316 ms). The previously-deferred defensive 1-liner is now applied: the index advance is i = j > i ? j : i + 1, guaranteeing progress even if SOFT ≤ RECOV is ever configured. Signature and return shape unchanged (the shared assertions in tests/dex-tests.js are the public contract — pure internal guard). Re-bundled OxyDex.html; suite green pre- and post-bundle (508 passed / 31 groups).renderPPG) drove the real detector into 2:1 beat-halving — fixed June 2026. On the ≤500 FULL lane the real PPGDSP recovered only ~0.60× of the beats ECGDex did (which is ~1.00× truth) at meanSQI 0.92–0.98 — a 2:1 lock (every other equal-amplitude pulse), not perfusion loss. Bisected to a single ingredient (clean-room control: clean pulses, real buildRR timing, wander, hi-freq noise, and motion bursts all tracked 1:1; only the contact-loss dropout halved). The dropout jumped the channel from its ≈−481000 DC baseline to ≈−10 — a ~490k step (≈200× the 2300-unit pulse) that, after the detector's 0.5–8 Hz band-pass, rang as a low-frequency transient large enough to hijack the beat-detector's autocorrelation period estimate (locking T≈274 samp / 39 bpm vs the true 162/65) → oversized search window → halving. The shipped PPGDSP was exonerated throughout (tracks REAL Polar PPG cleanly). Fix: model contact-loss as the pulsatile AC vanishing at the held DC baseline (a true flatline — still trips the SQI/flatline QC gate) instead of a 490k step. Result: beat recovery 0.60→1.03–1.10, recall 96–98%, real PPG unaffected, Dex-Test-Suite green (495/495). Residual: PpgDex mildly over-detected (~3–10% on synthetic vs ~1% on real). That residual is now FIXED (June 2026, renderPPG tuning — see the PpgDex-over-detection entry below): FULL-lane beat recovery 1.06→1.05, precision 0.90→0.92, synthetic PPG rMSSD inflation +38%→+16%. The genuine apnea-perfusion yield signal (low-amplitude apnea beats missed while SQI stays high) is now cleanly isolated → written up in qrs-yield.html.renderPPG) — fixed June 2026. The optical detector recovered slightly too many beats on the FULL-lane synthetic PPG (~6% net / ~10% false-positive), inflating PPG rMSSD ≈+38% and contaminating any cross-modality HRV comparison. Diagnosed with the new qrs-yield-analysis.html harness (beat-level recall/precision vs ground truth): the clean-window excess was the dicrotic shoulder being re-detected at faster heart rates, and the apnea-window excess was an oversized motion artifact (≈4× pulse amplitude) leaking through (the FULL lane runs PPGDSP without its ACC motion gate). Fix (all in renderPPG, synth-gen.js — cheap, no bundle/provenance impact): dicrotic 0.12→0.04 and blended onto the diastolic decay; per-sample optical noise 60→30; motion artifact 9000→950 (realistic ≈1× pulse, no longer detector-dominating); apnea perfusion attenuation deepened 0.55→0.30 so a genuine low-amplitude yield dip survives (SQI is correlation-based, so it stays green — the real effect the QRS-yield paper reports). Result: FULL-lane beat recovery 1.06→1.05, precision 0.90→0.92, synthetic PPG rMSSD inflation +38%→+16% (PAT-jitter-dominated). Dex-Test-Suite.html green; FULL-lane fidelity gate seeds 1.03 / 1.07.renderECGInt16) under-rendered beat-to-beat timing — fixed June 2026. The renderer measured pulse phase from each beat's ONSET with the R lobe at template phase 0.41, so every R landed at bcur.tMs + 0.41·RR and the rendered R-to-R interval became RR + 0.41·ΔRR — a low-pass of the true tachogram that attenuated reconstructed ECG rMSSD ≈26% (surfaced by qrs-yield-analysis.html; it is a renderer artifact, not an ECGDex property — ECGDex is validated on real ECG). Fix (in cohort-full.js — cheap, no bundle/provenance impact): pick the NEAREST beat per sample and offset phase so template 0.41 sits exactly on nb.tMs, placing every R at its true instant. Result: ECG beat recovery now exactly 1.00, ECG rMSSD bias −26%→≈0 (faithful). This unblocked the three-way rMSSD equivalence (rmssd-equivalence.html), where ECGDex now matches PulseDex to ±0.6 ms (r 0.9997). Dex-Test-Suite.html green.buildHash certifies the bundle template, not the code that runs — surfaced June 2026 during the re-bundle pass; the resulting gate gap is now closed. The re-bundle brief (and CLAUDE.md) assumed re-bundling an app changes its buildHash and so flips its committed uploads/*.json provenance fixtures to stale. It did not: editing only the external oxydex-dsp.js / integrator-app.js (leaving each *.src.html untouched) and re-bundling left OxyDex.html at 09c77b53517c and Integrator.html at eb0454c3431b — unchanged — so verify-provenance.html stayed all-green with no fixture regeneration needed. Mechanism: the inliner stores the app as a <script type="__bundler/template"> (pristine HTML skeleton, assets referenced by stable UUID) plus a <script type="__bundler/manifest"> (the actual *.js/fonts, gzip+base64 — which is why cleanArtifactHR appears nowhere as plain text in the 453 KB bundle). ganglior-provenance.js buildSource() hashes only the template; the manifest where the executable code lives is excluded. So a code-only re-bundle is invisible to buildHash. This contradicts the gate's stated guarantee ("the hash always tracks the shipped code … forces a re-bundle whenever source changes"): provenance currently certifies the bundle skeleton, not the executed code, and cannot detect external-*.js drift. Resolved by decision (June 2026): buildHash is kept deliberately coarse (a true fix needs owning the inliner — re-hashing to include the manifest would flip every committed fixture at once), and executed-code provenance was moved off it onto a stronger signal. verify-provenance.html GATE A now compares each bundle's statically-computed manifestHash (SHA-256 of the __bundler/manifest — which does move on any bundled-module change) against the committed BUILD-MANIFEST.json, and GATE B reads a FIXTURE-PROVENANCE.json sidecar that code-gates each fixture to its producing bundle's manifestHash, turning red the moment the code that made it changes. So buildHash still only certifies the skeleton, but the gate no longer relies on it for code identity. Standing guidance: regenerate a node's fixtures (re-run + re-export) whenever you change its code, and record the producing manifestHash in the sidecar.nocturnalHypo flag drops sharp nocturnal hypos — root cause re-diagnosed (June 2026): it's compression-artifact rejection, not slice-truncation. First seen as the coupling pilot's hypo flag firing on ~0/4 sleep-window slices and assumed to be loss of full-day context. Probing the actual slice (seed 6) disproved that: the planted hypo genuinely reaches ~56 mg/dL for ~50 min (10 cells < 70) in the raw CGM — the dip is right there. The flag misses it because a sharp drop-and-recover excursion (insulin hypo + Somogyi rebound) carries the same bracketing signature as a positional sensor-pressure artifact, so GlucoDex's computeBaselineArr/compression rejection flags those cells (f===3) and excludes them from hypo events. Fix (pilot + fusion): score nocturnal hypos with a flag-independent window-local time-below-70 on the raw slice (any ≥15-min run < 70 mg/dL). In the Integrator this is glucoseMetricsInWindow() (integrator-dsp.js); in the coupling tool it's the new raw-CSV scorer in cohort-worker.js's cgmcouple branch, exposed as a hypo-enrichment knob. Hypo-enriched re-run (479 planted hypo-nights): window-local recall 1.00 (479/479) vs the cleaned-series flag 0.008 — a clean before/after, written up in cgm-hrv-coupling.html §3.3. Now fixed in-detector (June 2026): the shipped GlucoDex detector itself is fixed too, not just the pilot + fusion workaround. glucodex-dsp.js gained a _looksLikeGenuineHypo() discriminator that guards the compression-rejection pass: a sustained sub-70 run (≥15 min) that reaches a real nadir (≤~60 mg/dL) and is entered/left gradually (descent & rebound over multiple cells) is no longer marked f===3, so it survives into nocturnalHypo() — while a near-vertical single-cell drop-and-recover (true positional artifact) is still rejected. A permanent two-directional assertion (GlucoDex hypo ≠ compression artifact) locks both in tests/dex-tests.js: a hand-built ~56 mg/dL / ~50 min Somogyi-rebound hypo fires nocturnalHypo (was 0), and a vertical-edged plateau still does not. Signatures/returns unchanged (back-compat; new logic is additive).