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:
parent
2956414bf8
commit
12e1cf9d5e
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue