feat(adr-120): /api/v1/adaptive/debug + softer smoothing (15/2)

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 <noreply@anthropic.com>
This commit is contained in:
arsen 2026-05-18 01:45:41 +07:00
parent 2956414bf8
commit 12e1cf9d5e
1 changed files with 47 additions and 2 deletions

View File

@ -2740,13 +2740,23 @@ fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classificati
/// Live UX target: user can read the badge without it changing /// Live UX target: user can read the badge without it changing
/// mid-read, while a genuine activity switch still propagates within /// mid-read, while a genuine activity switch still propagates within
/// ~3-4 seconds. /// ~3-4 seconds.
const ADAPTIVE_SMOOTH_WIN: usize = 30; const ADAPTIVE_SMOOTH_WIN: usize = 15;
const ADAPTIVE_CONFIRM_TICKS: u32 = 5; const ADAPTIVE_CONFIRM_TICKS: u32 = 2;
static ADAPTIVE_LABEL_HISTORY: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new(); static ADAPTIVE_LABEL_HISTORY: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
/// (committed_label, candidate_label, candidate_consecutive_count) /// (committed_label, candidate_label, candidate_consecutive_count)
static ADAPTIVE_COMMITTED: OnceLock<Mutex<(String, String, u32)>> = OnceLock::new(); static ADAPTIVE_COMMITTED: OnceLock<Mutex<(String, String, u32)>> = 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<Mutex<VecDeque<String>>> = OnceLock::new();
fn adaptive_raw_log_init() -> &'static Mutex<VecDeque<String>> {
ADAPTIVE_RAW_LOG.get_or_init(|| Mutex::new(VecDeque::with_capacity(30)))
}
fn adaptive_label_history_init() -> &'static Mutex<VecDeque<String>> { fn adaptive_label_history_init() -> &'static Mutex<VecDeque<String>> {
ADAPTIVE_LABEL_HISTORY.get_or_init(|| Mutex::new(VecDeque::with_capacity(ADAPTIVE_SMOOTH_WIN))) 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 /// committed one must persist for ADAPTIVE_CONFIRM_TICKS consecutive
/// mode-results before becoming the new committed. /// mode-results before becoming the new committed.
fn adaptive_label_smooth(raw_label: &str) -> String { 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. // Layer 1 — majority vote.
let mode = { let mode = {
let mut buf = adaptive_label_history_init().lock().unwrap(); let mut buf = adaptive_label_history_init().lock().unwrap();
@ -5025,6 +5041,34 @@ async fn adaptive_status(State(state): State<SharedState>) -> Json<serde_json::V
} }
} }
/// ADR-120 follow-up #3: GET /api/v1/adaptive/debug — return the raw
/// model labels from the last 30 ticks alongside the smoothed/committed
/// state. Lets the operator distinguish "smoother is sticky" from
/// "model keeps outputting the same class".
async fn adaptive_debug() -> Json<serde_json::Value> {
let raw: Vec<String> = adaptive_raw_log_init().lock().unwrap().iter().cloned().collect();
let smoothing_buf: Vec<String> = 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<String, usize> = 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::<Vec<_>>(),
"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). /// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds).
async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::Value> { async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::Value> {
let mut s = state.write().await; let mut s = state.write().await;
@ -7603,6 +7647,7 @@ async fn main() {
// Adaptive classifier endpoints // Adaptive classifier endpoints
.route("/api/v1/adaptive/train", post(adaptive_train)) .route("/api/v1/adaptive/train", post(adaptive_train))
.route("/api/v1/adaptive/status", get(adaptive_status)) .route("/api/v1/adaptive/status", get(adaptive_status))
.route("/api/v1/adaptive/debug", get(adaptive_debug))
.route("/api/v1/adaptive/unload", post(adaptive_unload)) .route("/api/v1/adaptive/unload", post(adaptive_unload))
// Field model calibration (eigenvalue-based person counting) // Field model calibration (eigenvalue-based person counting)
.route("/api/v1/calibration/start", post(calibration_start)) .route("/api/v1/calibration/start", post(calibration_start))