research(R20.2): threshold-based hand-off — works at 0.5 m, harmonic gap at 1 m surfaces Pan-Tompkins requirement (#746)
Implements R20.1's catalogued refinement: when NV conf > 60% AND amplitude > 3 pT, trust NV entirely. Mixed result (5 distances): - 0.5 m: NV=72.00 ✓, smart=72.0 (+0.0 error, NV trusted) ✓ - 1.0 m: NV=144 (harmonic!), smart trusts wrong NV (+72 BPM error) - 1.5 m+: falls back to weighted (NV conf below threshold) Production lesson: the threshold-based policy is correct in spirit but incorrect with simple FFT rate estimator (picks harmonics). Production needs: 1. Harmonic rejection (Pan-Tompkins QRS or autocorrelation) 2. Cross-check vs breathing band 3. Per-frame plausibility window R20.1's 'production needs Pan-Tompkins' note is confirmed BINDING, not nice-to-have, before threshold hand-off can ship. ADR-114 implementation budget refined: +30-50 LOC for Pan-Tompkins. Five-step quantum arc: - R20 vision (tick 37) - Doc 17 bridge (tick 38) - ADR-114 spec (tick 39) - R20.1 working demo (tick 40) - R20.2 threshold refinement (this tick) Production ADR-114 cog now has all known refinements catalogued BEFORE any Rust code is written. Honest mixed result — catalogue-then-revisit pattern works: R20.1 flagged production gap; R20.2 attempted fix; fix surfaced deeper gap (harmonic rejection). Three layers of refinement.
This commit is contained in:
parent
eb88035699
commit
fecb1da252
|
|
@ -0,0 +1,66 @@
|
|||
# R20.2 — Threshold-based hand-off: mixed result reveals production gap
|
||||
|
||||
**Status:** implementation of R20.1's catalogued refinement; mixed result reveals harmonic-rejection requirement · **2026-05-22**
|
||||
|
||||
## What R20.2 set out to fix
|
||||
|
||||
R20.1's naive precision-weighted Bayesian gave 84 BPM for HR when classical (105 BPM, 38% conf) disagreed with NV @ 1 m (72 BPM, 64% conf). The fix specified: when NV confidence > 60% AND amplitude > 3 pT, trust NV entirely.
|
||||
|
||||
## Result (5 distances)
|
||||
|
||||
| Distance | NV amp | NV rate | NV conf | Naive | Smart | Error (smart) | Regime |
|
||||
|---:|---:|---:|---:|---:|---:|---:|---|
|
||||
| **0.5 m** | 50.00 pT | 72.00 ✓ | 84% | 82.3 | **72.0** | **+0.0** ✓ | nv_drives |
|
||||
| 1.0 m | 6.25 pT | 144.00 ✗ harmonic | 67% | 129.9 | **144.0** | **+72.0 ✗** | nv_drives |
|
||||
| 1.5 m | 1.85 pT | 72.00 ✓ | 39% | 88.3 | 88.3 | +16.3 | weighted_fallback |
|
||||
| 2.0 m | 0.78 pT | 77.00 | 36% | 91.5 | 91.5 | +19.5 | weighted_fallback |
|
||||
| 3.0 m | 0.23 pT | 78.00 | 38% | 91.5 | 91.5 | +19.5 | weighted_fallback |
|
||||
|
||||
## What this reveals
|
||||
|
||||
- **At 0.5 m**: threshold hand-off works perfectly (+0.0 error, NV trusted, breathing+HR correct)
|
||||
- **At 1 m**: smart hand-off **loses** to naive because the simple FFT picked a 2× harmonic of the true HR (144 vs 72)
|
||||
- **At 1.5-3 m**: falls back to weighted (NV below confidence threshold), same as naive
|
||||
|
||||
## The production lesson
|
||||
|
||||
The threshold-based policy is **correct in spirit** (trust NV when good) but **incorrect with simple FFT** (which picks harmonics for narrow-band signals). Production needs:
|
||||
|
||||
1. **Harmonic rejection** in the rate estimator (e.g. autocorrelation-based, or Pan-Tompkins QRS for cardiac signals)
|
||||
2. **Cross-check with classical breathing rate band** (true HR is rarely > 2× breathing rate × 6; the 144 result violates this and could be rejected)
|
||||
3. **Per-frame plausibility window** (a healthy adult won't transition from 72 to 144 BPM in 1 second)
|
||||
|
||||
R20.1's note already flagged "production needs Pan-Tompkins QRS detection". R20.2 confirms this is **binding, not nice-to-have** for the threshold hand-off to be safe.
|
||||
|
||||
## What R20.2 DOES enable
|
||||
|
||||
1. **Empirical confirmation** that the smart hand-off works at 0.5 m bedside (target deployment scenario per ADR-114).
|
||||
2. **Identification of a critical production gap**: harmonic rejection in the rate estimator is mandatory before threshold hand-off can ship.
|
||||
3. **Refined ADR-114 implementation budget**: add ~30-50 LOC for Pan-Tompkins QRS detection.
|
||||
|
||||
## What R20.2 DOES NOT enable
|
||||
|
||||
- A clean win across all distances — the 1 m harmonic shows real-world robustness needs more work.
|
||||
- Validation on real cardiac signals (synthetic Gaussian-pulse-train; real ECG/cardiac-B has different harmonic structure).
|
||||
- Multi-subject hand-off (single subject only).
|
||||
|
||||
## Honest scope
|
||||
|
||||
This is a **mixed result, honestly reported**. The smart hand-off is right in principle; the FFT rate estimator beneath it is the weak link. Production fix is well-understood (Pan-Tompkins or autocorrelation), but the demo as written doesn't include it.
|
||||
|
||||
## Composes with
|
||||
|
||||
- R20.1 (this is the catalogued refinement)
|
||||
- ADR-114 (production implementation needs Pan-Tompkins per R20.2)
|
||||
- R13 NEGATIVE (this confirms classical HR is unusable, which is why we need NV at all)
|
||||
- Doc 16 (cube-of-distance: at 3 m NV is below threshold and we fall back to weighted)
|
||||
|
||||
## Honest meta-observation
|
||||
|
||||
R20.2 is the **5-minute follow-up** to R20.1. The catalogue-then-revisit pattern works: R20.1 flagged production gap; R20.2 attempted the fix; the attempt surfaced a deeper gap (harmonic rejection). Three layers of refinement in one quantum integration arc.
|
||||
|
||||
## Connection back
|
||||
|
||||
R20 (vision, tick 37) → Doc 17 (bridge, tick 38) → ADR-114 (spec, tick 39) → R20.1 (working demo, tick 40) → **R20.2 (threshold refinement, this tick)**.
|
||||
|
||||
Five-step quantum integration arc. Production ADR-114 cog now has all known refinements catalogued before any Rust code is written.
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env python3
|
||||
"""R20.2 — Threshold-based hand-off fix for ADR-114 Bayesian fusion.
|
||||
|
||||
See docs/research/sota-2026-05-22/R20_2-threshold-handoff.md.
|
||||
|
||||
R20.1's naive precision-weighted Bayesian fusion gave 84 BPM for HR when
|
||||
classical (105 BPM, 38% conf) and NV @ 1 m (72 BPM, 64% conf) disagreed.
|
||||
Production needs threshold-based hand-off: when NV confidence > 60%
|
||||
AND B-field amplitude > 3 pT, trust NV entirely (reject classical HR).
|
||||
|
||||
This implements the fix and verifies it recovers correct HR (72 BPM)
|
||||
at bedside while gracefully degrading to classical when NV degrades.
|
||||
|
||||
Pure NumPy. Reuses R20.1 simulators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import numpy as np
|
||||
|
||||
# Reuse R20.1 simulator functions by importing them
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from r20_1_quantum_classical_fusion import (
|
||||
simulate_csi_breathing,
|
||||
simulate_nv_cardiac,
|
||||
estimate_rate_from_signal,
|
||||
extract_hrv_contour,
|
||||
)
|
||||
|
||||
|
||||
def fusion_threshold_handoff(classical_rate, classical_conf,
|
||||
nv_rate, nv_conf, nv_amplitude_pT,
|
||||
nv_conf_threshold=0.60,
|
||||
nv_amplitude_threshold_pT=3.0):
|
||||
"""Threshold-based hand-off:
|
||||
- If NV is "good enough" (conf > 0.6 AND amplitude > 3 pT), trust NV entirely.
|
||||
- Else fall back to precision-weighted average.
|
||||
- If NV has no signal, classical drives.
|
||||
"""
|
||||
nv_trusted = (nv_conf > nv_conf_threshold) and (nv_amplitude_pT > nv_amplitude_threshold_pT)
|
||||
if nv_trusted:
|
||||
return nv_rate, nv_conf, "nv_drives"
|
||||
if classical_conf < 1e-3:
|
||||
return nv_rate, nv_conf, "fallback_nv"
|
||||
if nv_conf < 1e-3:
|
||||
return classical_rate, classical_conf, "fallback_classical"
|
||||
# Precision-weighted fallback (R20.1's naive default)
|
||||
w_c = classical_conf
|
||||
w_n = nv_conf
|
||||
fused = (w_c * classical_rate + w_n * nv_rate) / (w_c + w_n + 1e-9)
|
||||
conf = float(1 - (1 - classical_conf) * (1 - nv_conf))
|
||||
return fused, conf, "weighted_fallback"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="examples/research-sota/09-quantum-fusion/r20_2_threshold_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
true_breathing = 15.0
|
||||
true_hr = 72.0
|
||||
|
||||
# Same setup as R20.1
|
||||
t_csi, csi = simulate_csi_breathing(duration_s=60, fs=50, true_rate_bpm=true_breathing, rng=rng)
|
||||
_, csi_hr_conf, _ = estimate_rate_from_signal(t_csi, csi, search_band=(0.8, 3.0))
|
||||
csi_hr_rate, csi_hr_conf, _ = estimate_rate_from_signal(t_csi, csi, search_band=(0.8, 3.0))
|
||||
|
||||
# NV at five distances to show degradation
|
||||
results = []
|
||||
for d in [0.5, 1.0, 1.5, 2.0, 3.0]:
|
||||
t_nv, nv, amp = simulate_nv_cardiac(duration_s=60, fs=200, true_hr_bpm=true_hr,
|
||||
distance_m=d, rng=np.random.default_rng(int(42 + d * 10)))
|
||||
nv_rate, nv_conf, nv_snr = estimate_rate_from_signal(t_nv, nv, search_band=(0.8, 3.0))
|
||||
|
||||
# R20.1 naive precision-weighted
|
||||
w_c, w_n = csi_hr_conf, nv_conf
|
||||
naive = (w_c * csi_hr_rate + w_n * nv_rate) / (w_c + w_n + 1e-9)
|
||||
|
||||
# R20.2 threshold hand-off
|
||||
smart, smart_conf, regime = fusion_threshold_handoff(
|
||||
csi_hr_rate, csi_hr_conf, nv_rate, nv_conf, amp
|
||||
)
|
||||
|
||||
err_naive = abs(naive - true_hr)
|
||||
err_smart = abs(smart - true_hr)
|
||||
|
||||
results.append({
|
||||
"distance_m": d,
|
||||
"nv_amplitude_pT": amp,
|
||||
"nv_rate_bpm": nv_rate,
|
||||
"nv_conf": nv_conf,
|
||||
"naive_fused_bpm": naive,
|
||||
"smart_fused_bpm": smart,
|
||||
"regime": regime,
|
||||
"true_hr_bpm": true_hr,
|
||||
"naive_error_bpm": err_naive,
|
||||
"smart_error_bpm": err_smart,
|
||||
})
|
||||
|
||||
out = {
|
||||
"true_hr_bpm": true_hr,
|
||||
"classical_hr_rate": csi_hr_rate,
|
||||
"classical_hr_conf": csi_hr_conf,
|
||||
"results_per_distance": results,
|
||||
"thresholds": {"nv_conf": 0.60, "nv_amplitude_pT": 3.0},
|
||||
}
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
|
||||
print(f"=== R20.2 threshold-based hand-off ===")
|
||||
print(f"True HR: {true_hr} BPM")
|
||||
print(f"Classical HR: {csi_hr_rate:.2f} BPM (conf {csi_hr_conf*100:.1f}%)")
|
||||
print()
|
||||
print(f"{'distance':>9} {'NV amp':>8} {'NV rate':>8} {'NV conf':>8} {'naive':>7} {'naive err':>9} {'smart':>7} {'smart err':>9} {'regime':>20}")
|
||||
for r in results:
|
||||
print(f"{r['distance_m']:>7.1f} m "
|
||||
f"{r['nv_amplitude_pT']:>6.2f} pT "
|
||||
f"{r['nv_rate_bpm']:>6.2f} BPM "
|
||||
f"{r['nv_conf']*100:>6.1f}% "
|
||||
f"{r['naive_fused_bpm']:>5.1f} BPM "
|
||||
f"{r['naive_error_bpm']:>+6.1f} BPM "
|
||||
f"{r['smart_fused_bpm']:>5.1f} BPM "
|
||||
f"{r['smart_error_bpm']:>+6.1f} BPM "
|
||||
f"{r['regime']:>20}")
|
||||
print()
|
||||
# Total error
|
||||
total_naive = sum(r['naive_error_bpm'] for r in results)
|
||||
total_smart = sum(r['smart_error_bpm'] for r in results)
|
||||
print(f"Total naive error across 5 distances: {total_naive:.1f} BPM")
|
||||
print(f"Total smart error across 5 distances: {total_smart:.1f} BPM")
|
||||
print(f"Improvement factor: {total_naive / max(total_smart, 0.1):.2f}x")
|
||||
print()
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"true_hr_bpm": 72.0,
|
||||
"classical_hr_rate": 105.0,
|
||||
"classical_hr_conf": 0.3805459134253063,
|
||||
"results_per_distance": [
|
||||
{
|
||||
"distance_m": 0.5,
|
||||
"nv_amplitude_pT": 50.0,
|
||||
"nv_rate_bpm": 72.0,
|
||||
"nv_conf": 0.8348130933810896,
|
||||
"naive_fused_bpm": 82.33276175218474,
|
||||
"smart_fused_bpm": 72.0,
|
||||
"regime": "nv_drives",
|
||||
"true_hr_bpm": 72.0,
|
||||
"naive_error_bpm": 10.332761752184737,
|
||||
"smart_error_bpm": 0.0
|
||||
},
|
||||
{
|
||||
"distance_m": 1.0,
|
||||
"nv_amplitude_pT": 6.25,
|
||||
"nv_rate_bpm": 144.0,
|
||||
"nv_conf": 0.6689134324169522,
|
||||
"naive_fused_bpm": 129.85815561865903,
|
||||
"smart_fused_bpm": 144.0,
|
||||
"regime": "nv_drives",
|
||||
"true_hr_bpm": 72.0,
|
||||
"naive_error_bpm": 57.858155618659026,
|
||||
"smart_error_bpm": 72.0
|
||||
},
|
||||
{
|
||||
"distance_m": 1.5,
|
||||
"nv_amplitude_pT": 1.8518518518518514,
|
||||
"nv_rate_bpm": 72.0,
|
||||
"nv_conf": 0.39058395452018735,
|
||||
"naive_fused_bpm": 88.28521417307823,
|
||||
"smart_fused_bpm": 88.28521417307823,
|
||||
"regime": "weighted_fallback",
|
||||
"true_hr_bpm": 72.0,
|
||||
"naive_error_bpm": 16.28521417307823,
|
||||
"smart_error_bpm": 16.28521417307823
|
||||
},
|
||||
{
|
||||
"distance_m": 2.0,
|
||||
"nv_amplitude_pT": 0.78125,
|
||||
"nv_rate_bpm": 77.0,
|
||||
"nv_conf": 0.3549718835175086,
|
||||
"naive_fused_bpm": 91.48678132427328,
|
||||
"smart_fused_bpm": 91.48678132427328,
|
||||
"regime": "weighted_fallback",
|
||||
"true_hr_bpm": 72.0,
|
||||
"naive_error_bpm": 19.48678132427328,
|
||||
"smart_error_bpm": 19.48678132427328
|
||||
},
|
||||
{
|
||||
"distance_m": 3.0,
|
||||
"nv_amplitude_pT": 0.23148148148148143,
|
||||
"nv_rate_bpm": 78.0,
|
||||
"nv_conf": 0.3829009843660022,
|
||||
"naive_fused_bpm": 91.45835525791024,
|
||||
"smart_fused_bpm": 91.45835525791024,
|
||||
"regime": "weighted_fallback",
|
||||
"true_hr_bpm": 72.0,
|
||||
"naive_error_bpm": 19.45835525791024,
|
||||
"smart_error_bpm": 19.45835525791024
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"nv_conf": 0.6,
|
||||
"nv_amplitude_pT": 3.0
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue