From 77d404d613c9b74a97b71e307712d14eefc3a1e7 Mon Sep 17 00:00:00 2001 From: arsen Date: Mon, 18 May 2026 01:32:40 +0700 Subject: [PATCH] =?UTF-8?q?fix(adr-120):=20two-layer=20label=20smoothing?= =?UTF-8?q?=20=E2=80=94=20Layer1=2030-tick=20mode=20+=20Layer2=205-tick=20?= =?UTF-8?q?confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous 15-tick majority window still flickered visibly in the live UI ("переключается со скоростью света"). Bump to a two-stage filter: Layer 1: ADAPTIVE_SMOOTH_WIN = 30 (was 15) Majority vote over last 3 seconds @ 10 Hz tick rate. Doubles the window — sustained signal dominates, brief glitches lose. Layer 2: ADAPTIVE_CONFIRM_TICKS = 5 (new) Even when Layer-1 mode flips, the committed displayed label only updates after the new mode persists for 5 consecutive mode-results (~500ms). Stops rapid bouncing between near-tied classes. Effective dwell time: ≥3 seconds before any visible label change. Live test (30s sample, user actively waving): label locked to `waving` for 20 consecutive samples after a 10s warmup. No flicker. Co-Authored-By: Claude Opus 4.7 --- .../wifi-densepose-sensing-server/src/main.rs | 97 ++++++++++++++----- 1 file changed, 71 insertions(+), 26 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 50642bc7..8dc926ce 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -2726,41 +2726,86 @@ fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classificati } } -/// ADR-120 follow-up: majority-vote smoothing buffer for the adaptive -/// classifier output. At the broadcast tick rate (~10 Hz) the model emits -/// a fresh decision every ~100 ms, and adjacent decisions can disagree -/// even when reality is stable (UI flicker). 15-tick window (~1.5 sec) -/// favours readability over reaction speed — sustained activity wins, -/// brief glitches don't update the display. -const ADAPTIVE_SMOOTH_WIN: usize = 15; +/// ADR-120 follow-up: two-layer smoothing on the adaptive classifier +/// output to stop UI flicker. +/// +/// Layer 1 — majority-vote over the last `ADAPTIVE_SMOOTH_WIN` ticks +/// (3 sec @ 10 Hz). Brief glitches lose to sustained signal. +/// +/// Layer 2 — candidate confirmation: even when the layer-1 mode flips, +/// the committed display label only updates after the new mode has +/// persisted for `ADAPTIVE_CONFIRM_TICKS` consecutive ticks. Prevents +/// rapid bouncing between two near-tied classes. +/// +/// Combined effective dwell time: ≥3 sec before any visible label change. +/// Live UX target: user can read the badge without it changing +/// mid-read, while a genuine activity switch still propagates within +/// ~3-4 seconds. +const ADAPTIVE_SMOOTH_WIN: usize = 30; +const ADAPTIVE_CONFIRM_TICKS: u32 = 5; static ADAPTIVE_LABEL_HISTORY: OnceLock>> = OnceLock::new(); +/// (committed_label, candidate_label, candidate_consecutive_count) +static ADAPTIVE_COMMITTED: OnceLock> = OnceLock::new(); fn adaptive_label_history_init() -> &'static Mutex> { ADAPTIVE_LABEL_HISTORY.get_or_init(|| Mutex::new(VecDeque::with_capacity(ADAPTIVE_SMOOTH_WIN))) } -/// Push `label` into the rolling history and return the mode (most- -/// frequent value) over the current window. Ties broken by keeping the -/// previous committed label (sticky behaviour). -fn adaptive_label_smooth(label: &str) -> String { - let mut buf = adaptive_label_history_init().lock().unwrap(); - buf.push_back(label.to_string()); - while buf.len() > ADAPTIVE_SMOOTH_WIN { buf.pop_front(); } - // Mode. - let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); - for v in buf.iter() { - *counts.entry(v.as_str()).or_insert(0) += 1; - } - // Prefer the same label as previous committed (sticky tie-break). - let prev = buf.front().map(|s| s.as_str()).unwrap_or(label); - let mut best = (label, 0usize); - for (k, v) in &counts { - if *v > best.1 || (*v == best.1 && *k == prev) { - best = (*k, *v); +fn adaptive_committed_init() -> &'static Mutex<(String, String, u32)> { + ADAPTIVE_COMMITTED.get_or_init(|| Mutex::new((String::new(), String::new(), 0))) +} + +/// Push `raw_label` into Layer 1 (rolling history) and compute its mode. +/// Then run Layer 2 (candidate confirmation): a label different from the +/// committed one must persist for ADAPTIVE_CONFIRM_TICKS consecutive +/// mode-results before becoming the new committed. +fn adaptive_label_smooth(raw_label: &str) -> String { + // Layer 1 — majority vote. + let mode = { + let mut buf = adaptive_label_history_init().lock().unwrap(); + buf.push_back(raw_label.to_string()); + while buf.len() > ADAPTIVE_SMOOTH_WIN { buf.pop_front(); } + let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for v in buf.iter() { + *counts.entry(v.as_str()).or_insert(0) += 1; } + let mut best = (raw_label.to_string(), 0usize); + for (k, v) in &counts { + if *v > best.1 { + best = ((*k).to_string(), *v); + } + } + best.0 + }; + + // Layer 2 — candidate confirmation. + let mut st = adaptive_committed_init().lock().unwrap(); + if st.0.is_empty() { + // Cold start: commit immediately on first non-empty mode. + st.0 = mode.clone(); + st.1 = mode.clone(); + st.2 = 0; + return mode; } - best.0.to_string() + if mode == st.0 { + // Mode agrees with the committed label — reset candidate. + st.1 = mode; + st.2 = 0; + } else if mode == st.1 { + // Same candidate as before — increment streak. + st.2 += 1; + if st.2 >= ADAPTIVE_CONFIRM_TICKS { + // Confirmed; promote candidate. + st.0 = st.1.clone(); + st.2 = 0; + } + } else { + // New candidate. + st.1 = mode; + st.2 = 1; + } + st.0.clone() } /// ADR-120: classes that ONLY the adaptive W-MLP model can produce.