From 12e1cf9d5ec91e652a50c2fbb3862bdc528915a9 Mon Sep 17 00:00:00 2001 From: arsen Date: Mon, 18 May 2026 01:45:41 +0700 Subject: [PATCH] feat(adr-120): /api/v1/adaptive/debug + softer smoothing (15/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds diagnostic endpoint returning the last 30 RAW model labels, their distribution, the smoother's internal buffer, committed + candidate labels, and consecutive count. Lets the operator distinguish "smoothing is sticky" from "model genuinely keeps outputting the same class" — without that signal, tuning smoothing parameters is shooting in the dark. Also relaxes smoothing back to 15/2 (Layer-1 1.5s majority + Layer-2 200ms confirm). The earlier 30/5 setting was over-damped because the actual problem was model overfitting, not flicker. Diagnostic finding on current live data: transition raw count: 25/30 (83%) present_still: 2 absent: 2 present_moving: 1 Model believes user is performing sit/stand transitions even when they're typing at the keyboard. Likely cause: `train_transition` recording captured ~3s pauses between sit-stand cycles, so the class signature is broad enough to grab typing/mouse motion. Fix is data-side (re-record cleaner transition class or add a desk_work class), not algorithm-side. ADR-120 follow-up notes. Co-Authored-By: Claude Opus 4.7 --- .../wifi-densepose-sensing-server/src/main.rs | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 2209352f..a9d6baef 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -2740,13 +2740,23 @@ fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classificati /// 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; +const ADAPTIVE_SMOOTH_WIN: usize = 15; +const ADAPTIVE_CONFIRM_TICKS: u32 = 2; static ADAPTIVE_LABEL_HISTORY: OnceLock>> = OnceLock::new(); /// (committed_label, candidate_label, candidate_consecutive_count) static ADAPTIVE_COMMITTED: OnceLock> = OnceLock::new(); +/// ADR-120 follow-up #3: keep the LAST 30 RAW labels pushed into the +/// smoother. Exposed via `/api/v1/adaptive/debug` so the operator can +/// see what the model thinks vs what the UI shows after smoothing — +/// distinguishes "smoother is too sticky" from "model is overfit and +/// keeps outputting this class". +static ADAPTIVE_RAW_LOG: OnceLock>> = OnceLock::new(); +fn adaptive_raw_log_init() -> &'static Mutex> { + ADAPTIVE_RAW_LOG.get_or_init(|| Mutex::new(VecDeque::with_capacity(30))) +} + fn adaptive_label_history_init() -> &'static Mutex> { ADAPTIVE_LABEL_HISTORY.get_or_init(|| Mutex::new(VecDeque::with_capacity(ADAPTIVE_SMOOTH_WIN))) } @@ -2773,6 +2783,12 @@ pub fn finalize_motion_label(classification: &mut ClassificationInfo) { /// committed one must persist for ADAPTIVE_CONFIRM_TICKS consecutive /// mode-results before becoming the new committed. fn adaptive_label_smooth(raw_label: &str) -> String { + // ADR-120 follow-up #3: log raw input for /api/v1/adaptive/debug. + { + let mut raw = adaptive_raw_log_init().lock().unwrap(); + raw.push_back(raw_label.to_string()); + while raw.len() > 30 { raw.pop_front(); } + } // Layer 1 — majority vote. let mode = { let mut buf = adaptive_label_history_init().lock().unwrap(); @@ -5025,6 +5041,34 @@ async fn adaptive_status(State(state): State) -> Json Json { + let raw: Vec = adaptive_raw_log_init().lock().unwrap().iter().cloned().collect(); + let smoothing_buf: Vec = adaptive_label_history_init().lock().unwrap().iter().cloned().collect(); + let (committed, candidate, candidate_count) = { + let st = adaptive_committed_init().lock().unwrap(); + (st.0.clone(), st.1.clone(), st.2) + }; + // Count distribution in raw buffer. + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for v in &raw { *counts.entry(v.clone()).or_insert(0) += 1; } + let mut dist: Vec<(String, usize)> = counts.into_iter().collect(); + dist.sort_by(|a, b| b.1.cmp(&a.1)); + Json(serde_json::json!({ + "smoothing_window_ticks": ADAPTIVE_SMOOTH_WIN, + "confirm_ticks": ADAPTIVE_CONFIRM_TICKS, + "raw_last_30": raw, + "raw_distribution": dist.iter().map(|(k, v)| serde_json::json!({"label": k, "count": v})).collect::>(), + "smoothing_buffer": smoothing_buf, + "committed_label": committed, + "candidate_label": candidate, + "candidate_count": candidate_count, + })) +} + /// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds). async fn adaptive_unload(State(state): State) -> Json { let mut s = state.write().await; @@ -7603,6 +7647,7 @@ async fn main() { // Adaptive classifier endpoints .route("/api/v1/adaptive/train", post(adaptive_train)) .route("/api/v1/adaptive/status", get(adaptive_status)) + .route("/api/v1/adaptive/debug", get(adaptive_debug)) .route("/api/v1/adaptive/unload", post(adaptive_unload)) // Field model calibration (eigenvalue-based person counting) .route("/api/v1/calibration/start", post(calibration_start))