fix(adr-120): centralised motion-label smoothing — 0 flips in 30s

Previous smoothing covered only the adaptive_override path. The 5 other
classification.motion_level writes (amp_presence_override and
amp_classify_from_latest in 3 different tick handlers) wrote raw
values that bypassed the smoother entirely — explaining the lingering
"переключается со скоростью света" complaint after the two-layer fix.

New finalize_motion_label(&mut classification) runs at end-of-tick AFTER
all overrides have settled, applies the same two-layer (30-tick mode +
5-tick confirm) smoothing uniformly to whatever label survived the
priority cascade. Called from 3 sites:
  - multi-BSSID tick handler
  - feature_state tick handler
  - per-node loop in broadcast tick task

adaptive_override now emits raw model label (no double-smoothing).

Verified: 30-second sample, user actively performing transitions,
ZERO flips. Label persisted as `transition` all 30 samples.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
arsen 2026-05-18 01:39:41 +07:00
parent 77d404d613
commit 2956414bf8
1 changed files with 35 additions and 8 deletions

View File

@ -2713,14 +2713,13 @@ fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classificati
model.classify(&feat_arr)
};
// 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";
// ADR-120 follow-up #2: emit raw model label here. Smoothing is
// applied centrally at end-of-tick via finalize_motion_label so
// it covers BOTH the adaptive path AND the rule-based override
// paths (amp_presence_override / amp_classify_from_latest) which
// previously wrote raw values directly to motion_level.
classification.motion_level = label.to_string();
classification.presence = label != "absent";
// Blend model confidence with existing smoothed confidence.
classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0);
}
@ -2756,6 +2755,19 @@ fn adaptive_committed_init() -> &'static Mutex<(String, String, u32)> {
ADAPTIVE_COMMITTED.get_or_init(|| Mutex::new((String::new(), String::new(), 0)))
}
/// ADR-120 follow-up #2: smooth WHATEVER label the cascade of overrides
/// produced, regardless of source (adaptive model OR amp_presence_override
/// OR amp_classify_from_latest). The earlier adaptive_label_smooth ONLY
/// covered the adaptive output — anything else (the 4 baseline classes)
/// passed through raw, so the live label kept flipping on every tick.
/// This is the final chokepoint called from each tick handler after all
/// overrides have run.
pub fn finalize_motion_label(classification: &mut ClassificationInfo) {
let smoothed = adaptive_label_smooth(&classification.motion_level);
classification.presence = smoothed != "absent";
classification.motion_level = smoothed;
}
/// 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
@ -3150,6 +3162,11 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
// ADR-104 phase-domain: update phase drift score for this node
// alongside the amplitude classifier. No-op if no phase baseline.
phase_drift_update(frame.node_id, &frame.phases);
// ADR-120 follow-up #2: final smoothing pass over the post-
// override classification. Catches flicker from BOTH adaptive
// and rule-based paths.
finalize_motion_label(&mut classification);
drop(s_write_pre);
// ── Step 5: Build enhanced fields from pipeline result ───────
@ -6160,6 +6177,10 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
}
}
// ADR-120 follow-up #2: final smoothing pass — uniformly
// damps flicker from both adaptive and rule-based outputs.
finalize_motion_label(&mut classification);
// ADR-112: prefer multistatic-derived signal_field
// when ≥ 2 ESP32 nodes are active; falls back to
// ADR-105's zero grid on single-sensor / fusion-fail.
@ -6417,6 +6438,12 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
phase_drift_update(node_id, ph);
}
// ADR-120 follow-up #2: final smoothing pass on the
// per-node loop's classification. Same shared smoother
// state as the other two tick sites — single source
// of truth for the displayed label.
finalize_motion_label(&mut classification);
ns.rssi_history.push_back(features.mean_rssi);
if ns.rssi_history.len() > 60 {
ns.rssi_history.pop_front();