From 2f4b2d5304f108b75c9e1334c5e1c20957226975 Mon Sep 17 00:00:00 2001 From: arsen Date: Sun, 17 May 2026 10:14:33 +0700 Subject: [PATCH] feat(adr-103 v2): universal threshold via baseline-CV normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wifi-densepose-sensing-server/src/main.rs | 74 ++++++++++++++++--- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index f7575ebd..fcf5a7d7 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -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 = 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 = 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) -> (&'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) -> (&'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::() / 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>> = OnceLock::new(); +fn amp_baseline_cv_init() -> &'static Mutex> { + 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"