feat(adr-102/104): NBVI FP-rate validation + per-subcarrier drift presence
ADR-102 Step 3 (FP-rate validation) — `nbvi_select_top_k` no longer
takes the literal top-K. Evaluates candidate K ∈ {6,8,10,12,16,20}
over the quiet window: for each, computes per-subset broadband CV
on a sliding sub-window and counts how many sub-windows cross the
moving threshold (0.10). Picks smallest K with fewest "false
positives" (ties broken by smallest total-NBVI). Defends against
the rare case where the literal top-12 happens to include a
subcarrier overlapping a noise source — the FP count surfaces it
and a tighter K wins.
ADR-104 (off-axis presence via per-subcarrier drift) — when
baseline.json carries `per_subcarrier_mean` for a node, server
loads the vector into AMP_BASELINE_PER_SUB. Each classifier tick
computes `drift = mean |Δ amp / baseline|` over the recent
AMP_SHORT_WIN frames vs that baseline. Drift ≥ 10 % → trigger
`present_still` even if broadband mean barely shifted. Catches the
case where the operator is in the room but off the AP→sensor line,
so individual subcarriers are perturbed without a global drop.
amp_node_level / amp_node_snapshot — per-node drift trigger
amp_classify_from_latest — cross-node MAX drift trigger
Drift channel is opportunistic: if baseline.json predates ADR-104
(no per_subcarrier_mean field), drift = 0 and classifier behaves
exactly as before. Re-record baseline via the calibrate-empty button
to populate the field and activate the channel.
This commit is contained in:
parent
b787f40a86
commit
6212b17ed1
|
|
@ -312,7 +312,60 @@ fn nbvi_select_top_k(history: &VecDeque<Vec<f64>>, k: usize) -> Vec<usize> {
|
|||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.into_iter().take(k).map(|(k,_)| k).collect()
|
||||
|
||||
// ── ESPectre Step 3: FP-rate validation ─────────────────────────
|
||||
//
|
||||
// Don't take the raw top-K from NBVI ranking blindly. The K with
|
||||
// the lowest false-positive rate over the quiet window is the
|
||||
// winner. "FP" = times the broadband-mean CV computed from that
|
||||
// candidate subset crosses the moving threshold even though the
|
||||
// window we're evaluating was the quietest available. Smallest K
|
||||
// with FP=0 is preferred (more headroom for averaging) over a
|
||||
// bigger K that adds noisier subcarriers.
|
||||
//
|
||||
// Candidate sizes K ∈ {6, 8, 10, 12, 16, 20} clamped to scored.len().
|
||||
if scored.len() <= k {
|
||||
return scored.into_iter().map(|(k,_)| k).collect();
|
||||
}
|
||||
let ranked_indices: Vec<usize> = scored.iter().map(|(k,_)| *k).collect();
|
||||
let candidates: [usize; 6] = [6, 8, 10, 12, 16, 20];
|
||||
let fp_thresh = 0.10_f64; // matches ADR-101 D1 "present_moving" gate
|
||||
|
||||
let mut best_k = k;
|
||||
let mut best_fp = usize::MAX;
|
||||
let mut best_total_nbvi = f64::INFINITY;
|
||||
for &cand_k in &candidates {
|
||||
if cand_k > ranked_indices.len() { continue; }
|
||||
let sel: &[usize] = &ranked_indices[..cand_k];
|
||||
// Compute per-frame broadband-mean across this subset.
|
||||
let bb: Vec<f64> = quiet_slice.iter().map(|f| {
|
||||
let mut s = 0.0; let mut c = 0;
|
||||
for &i in sel { if i < f.len() && f[i] > 0.0 { s += f[i]; c += 1; } }
|
||||
if c == 0 { 0.0 } else { s / c as f64 }
|
||||
}).collect();
|
||||
// Rolling CV over a sliding sub-window of ~30 samples
|
||||
// (1/3 of AMP_SHORT_WIN). Count frames where rolling CV
|
||||
// exceeds the moving gate — those would be false positives.
|
||||
let sub_win = (AMP_SHORT_WIN / 3).max(8);
|
||||
let mut fp = 0usize;
|
||||
for w_start in (0..bb.len().saturating_sub(sub_win)).step_by(sub_win / 2) {
|
||||
let w = &bb[w_start..w_start + sub_win];
|
||||
let mu: f64 = w.iter().sum::<f64>() / w.len() as f64;
|
||||
if mu <= 0.0 { continue; }
|
||||
let var: f64 = w.iter().map(|x| (x - mu).powi(2)).sum::<f64>() / w.len() as f64;
|
||||
let cv = var.sqrt() / mu;
|
||||
if cv > fp_thresh { fp += 1; }
|
||||
}
|
||||
// Sum of NBVI scores for this subset — tie-breaker.
|
||||
let total_nbvi: f64 = scored.iter().take(cand_k).map(|(_, n)| *n).sum();
|
||||
// Pick lowest FP; on ties, smaller total NBVI.
|
||||
if fp < best_fp || (fp == best_fp && total_nbvi < best_total_nbvi) {
|
||||
best_fp = fp;
|
||||
best_total_nbvi = total_nbvi;
|
||||
best_k = cand_k;
|
||||
}
|
||||
}
|
||||
ranked_indices.into_iter().take(best_k).collect()
|
||||
}
|
||||
|
||||
static AMP_HIST: OnceLock<Mutex<std::collections::HashMap<u8, AmpState>>> = OnceLock::new();
|
||||
|
|
@ -349,6 +402,38 @@ fn amp_baseline_override_init() -> &'static Mutex<std::collections::HashMap<u8,
|
|||
AMP_BASELINE_OVERRIDE.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// ADR-104: per-node per-subcarrier empty-room baseline vector,
|
||||
/// loaded from baseline.json `per_subcarrier_mean`. Used by the
|
||||
/// classifier as a second presence channel: even when broadband
|
||||
/// barely moves, comparing the current amp vector elementwise to
|
||||
/// this baseline catches off-axis bodies that only modulate a
|
||||
/// handful of subcarriers.
|
||||
static AMP_BASELINE_PER_SUB: OnceLock<Mutex<std::collections::HashMap<u8, Vec<f64>>>> = OnceLock::new();
|
||||
fn amp_baseline_per_sub_init() -> &'static Mutex<std::collections::HashMap<u8, Vec<f64>>> {
|
||||
AMP_BASELINE_PER_SUB.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// ADR-104: per-node "spectral drift" score = mean |Δ amp / baseline|
|
||||
/// across subcarriers, computed against AMP_BASELINE_PER_SUB. Updated
|
||||
/// every classifier tick; read by amp_node_level / amp_classify_from_latest
|
||||
/// as a second `present_still` trigger.
|
||||
static AMP_DRIFT: OnceLock<Mutex<std::collections::HashMap<u8, f64>>> = OnceLock::new();
|
||||
fn amp_drift_init() -> &'static Mutex<std::collections::HashMap<u8, f64>> {
|
||||
AMP_DRIFT.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
fn amp_drift_for_node(node_id: u8) -> f64 {
|
||||
let m = amp_drift_init().lock().unwrap();
|
||||
m.get(&node_id).copied().unwrap_or(0.0)
|
||||
}
|
||||
fn amp_drift_max() -> f64 {
|
||||
let m = amp_drift_init().lock().unwrap();
|
||||
m.values().copied().fold(0.0_f64, f64::max)
|
||||
}
|
||||
/// ADR-104: spectral-drift threshold — fraction (e.g. 0.10 = 10 %)
|
||||
/// that average per-subcarrier deviation must exceed to flag presence.
|
||||
/// Empirical; matches the broadband ratio trigger (drop ≥ 25 %, drift ≥ 10 %).
|
||||
const AMP_DRIFT_PRESENCE_THRESH: f64 = 0.10;
|
||||
|
||||
/// ADR-107: timestamp of the most recent baseline load/write. Auto
|
||||
/// recalibrator uses this to enforce a cool-down between writes; the
|
||||
/// REST endpoint reports it so the UI can show "calibrated X min ago".
|
||||
|
|
@ -410,6 +495,17 @@ fn load_baseline_file(path: &str) {
|
|||
if cv_pct > 0.0 {
|
||||
loaded_cv.push((id, cv_pct / 100.0));
|
||||
}
|
||||
|
||||
// ADR-104: per-subcarrier baseline vector for off-axis
|
||||
// presence detection. Optional; only present if the
|
||||
// recording script wrote `per_subcarrier_mean`.
|
||||
if let Some(arr) = node.get("per_subcarrier_mean").and_then(|v| v.as_array()) {
|
||||
let vec: Vec<f64> = arr.iter().filter_map(|v| v.as_f64()).collect();
|
||||
if vec.len() >= 16 {
|
||||
let mut o = amp_baseline_per_sub_init().lock().unwrap();
|
||||
o.insert(id, vec);
|
||||
}
|
||||
}
|
||||
}
|
||||
if loaded.is_empty() {
|
||||
warn!("baseline: {path} parsed but no usable per-node entries");
|
||||
|
|
@ -553,6 +649,49 @@ fn amp_presence_override(node_id: u8, amplitudes: &[f64]) -> Option<(String, boo
|
|||
mean_short
|
||||
};
|
||||
|
||||
// ── ADR-104: per-subcarrier delta as off-axis presence channel ──
|
||||
//
|
||||
// If a per-subcarrier baseline vector is loaded for this node,
|
||||
// compare current per-subcarrier mean (over the NBVI history's
|
||||
// last AMP_SHORT_WIN frames) against it. Sum of |delta / baseline|
|
||||
// for subcarriers with baseline > 1.0 → "spectral drift score".
|
||||
// High drift = body modulated the channel even if broadband mean
|
||||
// didn't change much (i.e. operator is in the room but off-axis).
|
||||
//
|
||||
// Stashed as a side-channel into AMP_LATEST_DRIFT; the per-node
|
||||
// classifier reads it as a third trigger for `present_still`.
|
||||
let drift = {
|
||||
let per_sub_map = amp_baseline_per_sub_init().lock().unwrap();
|
||||
match per_sub_map.get(&node_id) {
|
||||
Some(base_vec) if st.nbvi_history.len() >= AMP_SHORT_WIN => {
|
||||
// Per-sub mean from recent frames.
|
||||
let recent: Vec<&Vec<f64>> = st.nbvi_history.iter()
|
||||
.rev().take(AMP_SHORT_WIN).collect();
|
||||
let n_sub = base_vec.len().min(recent.first().map_or(0, |v| v.len()));
|
||||
if n_sub < 8 { 0.0 } else {
|
||||
let mut score = 0.0;
|
||||
let mut cnt = 0;
|
||||
for k in 0..n_sub {
|
||||
let b = base_vec[k];
|
||||
if b <= 1.0 { continue; }
|
||||
let mut sum = 0.0; let mut c = 0;
|
||||
for f in &recent { if k < f.len() && f[k] > 0.0 { sum += f[k]; c += 1; } }
|
||||
if c == 0 { continue; }
|
||||
let cur = sum / c as f64;
|
||||
score += (cur - b).abs() / b;
|
||||
cnt += 1;
|
||||
}
|
||||
if cnt == 0 { 0.0 } else { score / cnt as f64 }
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
{
|
||||
let mut d = amp_drift_init().lock().unwrap();
|
||||
d.insert(node_id, drift);
|
||||
}
|
||||
|
||||
// Stash this node's contribution for cross-node fusion.
|
||||
{
|
||||
let mut latest = amp_latest_init().lock().unwrap();
|
||||
|
|
@ -566,28 +705,31 @@ fn amp_presence_override(node_id: u8, amplitudes: &[f64]) -> Option<(String, boo
|
|||
/// fusion and from `build_node_features` so the UI can show per-node
|
||||
/// labels. No hysteresis is applied here; that's a global property.
|
||||
fn amp_node_level(cv: f64, mean_short: f64, baseline: Option<f64>) -> (&'static str, bool) {
|
||||
// ADR-102 + Pace's Problem #3: thresholds are *universal* —
|
||||
// applied to the **normalized** motion score (cv / baseline_cv),
|
||||
// where baseline_cv is the empty-room CV measured during the
|
||||
// last calibration (loaded from data/baseline.json). One
|
||||
// ADR-102 + Pace's Problem #3: thresholds are *universal* — applied
|
||||
// to the **normalized** motion score (cv / baseline_cv). One
|
||||
// threshold set works in any room.
|
||||
let bcv = amp_baseline_cv_for_node();
|
||||
let norm_cv = if bcv > 0.0 { cv / bcv } else { cv };
|
||||
|
||||
// Universal gates (computed at α-multiples of room-quiet CV):
|
||||
// 3× baseline_cv → present_moving
|
||||
// 6× baseline_cv → active
|
||||
// Empirically: baseline=4 % → moving≈12 %, active≈24 % — matches
|
||||
// the deployment-tuned values we had hard-coded.
|
||||
if norm_cv >= 6.0 {
|
||||
("active", true)
|
||||
} else if norm_cv >= 3.0 {
|
||||
("present_moving", true)
|
||||
} else if matches!(baseline, Some(b) if b > 0.0 && (mean_short / b) < 0.75) {
|
||||
("present_still", true)
|
||||
} else {
|
||||
("absent", false)
|
||||
return ("active", true);
|
||||
}
|
||||
if norm_cv >= 3.0 {
|
||||
return ("present_moving", true);
|
||||
}
|
||||
// ADR-101 broadband-drop trigger.
|
||||
if matches!(baseline, Some(b) if b > 0.0 && (mean_short / b) < 0.75) {
|
||||
return ("present_still", true);
|
||||
}
|
||||
// ADR-104: off-axis presence — per-subcarrier drift channel.
|
||||
// Triggers when body is in the room but off the AP→sensor line,
|
||||
// so broadband mean barely shifts.
|
||||
// (Caller doesn't pass per-node id here; we read MAX drift via
|
||||
// amp_drift_max(). Per-node decisions inside snapshot read their
|
||||
// own value separately.)
|
||||
if amp_drift_max() >= AMP_DRIFT_PRESENCE_THRESH {
|
||||
return ("present_still", true);
|
||||
}
|
||||
("absent", false)
|
||||
}
|
||||
|
||||
/// Average baseline CV across nodes that have a calibration loaded.
|
||||
|
|
@ -609,7 +751,24 @@ fn amp_baseline_cv_init() -> &'static Mutex<std::collections::HashMap<u8, f64>>
|
|||
fn amp_node_snapshot(node_id: u8) -> Option<(String, bool, f64)> {
|
||||
let latest = amp_latest_init().lock().unwrap();
|
||||
let (cv, mean_short, baseline) = latest.get(&node_id).copied()?;
|
||||
let (lvl, pres) = amp_node_level(cv, mean_short, baseline);
|
||||
// amp_node_level uses amp_drift_max() (cross-node) for the drift
|
||||
// trigger. For per-node display we want this node's own drift,
|
||||
// so override after the base classify.
|
||||
let (lvl0, pres0) = amp_node_level(cv, mean_short, baseline);
|
||||
let my_drift = amp_drift_for_node(node_id);
|
||||
let (lvl, pres) =
|
||||
if matches!(lvl0, "active" | "present_moving") {
|
||||
(lvl0, pres0)
|
||||
} else if my_drift >= AMP_DRIFT_PRESENCE_THRESH {
|
||||
// ADR-104: this specific node sees per-subcarrier drift
|
||||
// (body in its line-of-sight to the AP), regardless of what
|
||||
// the cross-node MAX-drift heuristic said.
|
||||
("present_still", true)
|
||||
} else if matches!(baseline, Some(b) if b > 0.0 && (mean_short / b) < 0.75) {
|
||||
("present_still", true)
|
||||
} else {
|
||||
("absent", false)
|
||||
};
|
||||
Some((lvl.to_string(), pres, cv))
|
||||
}
|
||||
|
||||
|
|
@ -666,11 +825,14 @@ fn amp_classify_from_latest() -> Option<(String, bool, f64)> {
|
|||
let bcv = amp_baseline_cv_for_node();
|
||||
let norm_max_cv = if bcv > 0.0 { max_cv / bcv } else { max_cv };
|
||||
let (gate_active, gate_moving) = if bcv > 0.0 { (6.0, 3.0) } else { (0.22, 0.10) };
|
||||
// ADR-104: cross-node spectral drift triggers `present_still`
|
||||
// even when broadband drop didn't fire — off-axis body presence.
|
||||
let any_drift = amp_drift_max() >= AMP_DRIFT_PRESENCE_THRESH;
|
||||
let candidate = if norm_max_cv >= gate_active {
|
||||
"active"
|
||||
} else if norm_max_cv >= gate_moving {
|
||||
"present_moving"
|
||||
} else if any_baseline_drop {
|
||||
} else if any_baseline_drop || any_drift {
|
||||
"present_still"
|
||||
} else {
|
||||
"absent"
|
||||
|
|
|
|||
Loading…
Reference in New Issue