fix(adr-120): 7-tick majority smoothing — stops UI label flicker
After hybrid priority fix (442c03da) the W-MLP labels reach the live UI
but at ~10 Hz tick rate they flip between adjacent classes (transition /
present_still / present_moving) too fast to read. Adds majority-vote
smoothing over last 7 ticks (~700ms window) — snappy enough for real-
time feedback, stable enough that the displayed label persists long
enough to be readable.
Implementation: static ADAPTIVE_LABEL_HISTORY VecDeque + helper
adaptive_label_smooth() called at end of adaptive_override after the
model emits its raw decision. Mode of last 7 raw labels wins; ties
break sticky to the previous committed label.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
442c03da3b
commit
3e12686ae9
|
|
@ -2713,13 +2713,56 @@ fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classificati
|
|||
model.classify(&feat_arr)
|
||||
};
|
||||
|
||||
classification.motion_level = label.to_string();
|
||||
classification.presence = label != "absent";
|
||||
// ADR-120 follow-up: majority-vote smoothing across ~700 ms of
|
||||
// history. Stops the per-tick flicker that made the live label
|
||||
// unreadable. Hybrid priority downstream re-checks via
|
||||
// adaptive_owns_class on the smoothed label, so waving/transition
|
||||
// ownership is preserved.
|
||||
let smoothed = adaptive_label_smooth(&label);
|
||||
classification.motion_level = smoothed.clone();
|
||||
classification.presence = smoothed != "absent";
|
||||
// Blend model confidence with existing smoothed confidence.
|
||||
classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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). We keep the last 7 ticks
|
||||
/// (~700 ms) and display the mode. Snappy enough for live UX, stable
|
||||
/// enough that the user can read the label without it changing mid-read.
|
||||
const ADAPTIVE_SMOOTH_WIN: usize = 7;
|
||||
|
||||
static ADAPTIVE_LABEL_HISTORY: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
|
||||
|
||||
fn adaptive_label_history_init() -> &'static Mutex<VecDeque<String>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
best.0.to_string()
|
||||
}
|
||||
|
||||
/// ADR-120: classes that ONLY the adaptive W-MLP model can produce.
|
||||
/// The rule-based amp_presence_override / amp_classify_from_latest paths
|
||||
/// know only {absent, present_still, present_moving, active}; if the
|
||||
|
|
|
|||
Loading…
Reference in New Issue