feat(adr-103 v2): universal threshold via baseline-CV normalization

Pace's Problem #3 ("threshold=1.0 means different things on different
devices") solved by normalizing the runtime CV against the empty-room
baseline CV measured during calibration.

  norm_cv = current_cv / baseline_cv
  gates:  norm_cv ≥ 3.0  → present_moving
          norm_cv ≥ 6.0  → active

Baseline CV loaded per-node from data/baseline.json (full_broadband_cv_pct).
When no calibration loaded, falls back to absolute gates (0.10 / 0.22)
that were deployment-tuned earlier — keeps backwards compatibility.

Both per-node `amp_node_level` and global `amp_classify_from_latest` use
the same normalization. On the operator's deployment with baseline CV
~4 %, the universal 3×/6× gates map to ~12 %/24 % absolute — same numbers
the hard-coded thresholds had, but now any-room-portable.
This commit is contained in:
arsen 2026-05-17 10:14:33 +07:00
parent f411992435
commit 2f4b2d5304
1 changed files with 62 additions and 12 deletions

View File

@ -371,6 +371,7 @@ fn load_baseline_file(path: &str) {
None => { warn!("baseline: no .nodes object in {path}"); return; }
};
let mut loaded: Vec<(u8, f64)> = Vec::new();
let mut loaded_cv: Vec<(u8, f64)> = Vec::new();
for (k, node) in nodes {
let id: u8 = match k.parse() { Ok(i) => i, Err(_) => continue };
// ADR-103 v2 schema (preferred): full_broadband_p95 / full_broadband_mean
@ -384,16 +385,35 @@ fn load_baseline_file(path: &str) {
.into_iter().flatten().find(|v| *v > 0.0);
let Some(b) = baseline else { continue };
loaded.push((id, b));
// ADR-103 v2: per-node baseline CV for universal threshold
// normalization (Pace's Problem #3). Accept either schema field.
let cv_pct = node.get("full_broadband_cv_pct")
.or_else(|| node.get("cv_pct"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
if cv_pct > 0.0 {
loaded_cv.push((id, cv_pct / 100.0));
}
}
if loaded.is_empty() {
warn!("baseline: {path} parsed but no usable per-node entries");
return;
}
let mut o = amp_baseline_override_init().lock().unwrap();
for (id, b) in &loaded { o.insert(*id, *b); }
{
let mut o = amp_baseline_override_init().lock().unwrap();
for (id, b) in &loaded { o.insert(*id, *b); }
}
{
let mut o = amp_baseline_cv_init().lock().unwrap();
for (id, cv) in &loaded_cv { o.insert(*id, *cv); }
}
let summary: Vec<String> = loaded.iter().map(|(id, b)| format!("node{id}={b:.2}")).collect();
info!("baseline: loaded {} node overrides from {} ({})",
loaded.len(), path, summary.join(", "));
let cv_summary: Vec<String> = loaded_cv.iter()
.map(|(id, cv)| format!("node{id}_cv={:.2}%", cv * 100.0)).collect();
info!("baseline: loaded {} node overrides from {} ({}; {})",
loaded.len(), path, summary.join(", "),
if cv_summary.is_empty() { "no CV normalization".to_string() } else { cv_summary.join(", ") });
}
/// Classify motion/presence for one node from the raw amplitude vector.
@ -525,13 +545,22 @@ 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: NBVI subcarrier selection drops baseline CV from ~5-7 %
// down to ~3-4 % in a quiet room. Thresholds tightened proportionally
// (was 30/15, now 22/10) so subtle motion gets flagged without
// raising the false-positive rate.
if cv >= 0.22 {
// 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
// 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 cv >= 0.10 {
} 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)
@ -540,6 +569,21 @@ fn amp_node_level(cv: f64, mean_short: f64, baseline: Option<f64>) -> (&'static
}
}
/// Average baseline CV across nodes that have a calibration loaded.
/// Returns 0.0 if no calibration is loaded — caller falls back to raw CV.
fn amp_baseline_cv_for_node() -> f64 {
let cvs = amp_baseline_cv_init().lock().unwrap();
if cvs.is_empty() { return 0.0; }
cvs.values().sum::<f64>() / cvs.len() as f64
}
/// Per-node baseline CV (decimal, not %) loaded from data/baseline.json.
/// Used to normalize the runtime CV so threshold comparison is universal.
static AMP_BASELINE_CV: OnceLock<Mutex<std::collections::HashMap<u8, f64>>> = OnceLock::new();
fn amp_baseline_cv_init() -> &'static Mutex<std::collections::HashMap<u8, f64>> {
AMP_BASELINE_CV.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
/// Per-node snapshot exposed to `build_node_features`.
fn amp_node_snapshot(node_id: u8) -> Option<(String, bool, f64)> {
let latest = amp_latest_init().lock().unwrap();
@ -595,9 +639,15 @@ fn amp_classify_from_latest() -> Option<(String, bool, f64)> {
matches!(b, Some(bv) if *bv > 0.0 && (*m / *bv) < 0.75)
});
let candidate = if max_cv >= 0.22 {
// ADR-103 v2: normalize max_cv by loaded baseline CV (Pace's
// Problem #3 universal threshold). Falls back to absolute gates
// when no calibration is loaded — keeps backwards compatibility.
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) };
let candidate = if norm_max_cv >= gate_active {
"active"
} else if max_cv >= 0.10 {
} else if norm_max_cv >= gate_moving {
"present_moving"
} else if any_baseline_drop {
"present_still"