feat(calibrate-serve): POST /room/train + fix AnchorLabel JSON to snake_case

- POST /api/v1/room/train: { room_id, baseline_id, anchors[] } → trains a
  SpecialistBank and persists it as <output_dir>/<room_id>.json (path-sanitized),
  readable via /room/state?bank=<room_id>. Completes the HTTP train→infer loop.
- Fix data-contract bug: AnchorLabel serialized as PascalCase variant names
  (serde default) while as_str() + the integration doc used snake_case. Added
  #[serde(rename_all = "snake_case")] so the JSON wire format matches the
  documented contract (empty/stand_still/…). Locked with a roundtrip test.

Validated live (ESP32-S3): POST train (4 anchors → 6 specialists, persisted) →
GET /room/state returns RoomState with the trained presence/restlessness; the
synthetic-vs-real scale mismatch correctly triggers the anomaly veto. 36
calibration tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-06-09 13:58:40 -04:00
parent 370f0a9265
commit b2cd752830
3 changed files with 62 additions and 3 deletions
@@ -88,7 +88,7 @@ UUID (`calibration_uuid()`) is the `baseline_id` referenced by enrollments and b
"session": { "room_id": "...", "baseline_id": "...", "events": [ /* event-sourced audit log */ ] }
}
```
Anchor labels (fixed sequence): `empty, stand_still, sit, lie_down, breathe_slow, breathe_normal, small_move, sleep_posture`.
Anchor labels (fixed sequence, **JSON wire = snake_case**, test-enforced): `empty, stand_still, sit, lie_down, breathe_slow, breathe_normal, small_move, sleep_posture`.
### 3.4 Specialist bank — JSON (`train-room` → `room-watch` / runtime)
@@ -136,6 +136,7 @@ Anchor labels (fixed sequence): `empty, stand_still, sit, lie_down, breathe_slow
| GET | `/api/v1/calibration/result` | last finalized baseline summary |
| GET | `/api/v1/calibration/baselines` | list persisted `.bin` baselines |
| GET | `/api/v1/room/state?bank=<name>` | **live RoomState** (mixture-of-specialists over the CSI window; bank resolved as a sanitized name under `output_dir`) |
| POST | `/api/v1/room/train` | `{ room_id, baseline_id, anchors[] }` → train + persist a specialist bank as `<output_dir>/<room_id>.json` (read back via `/room/state?bank=<room_id>`) |
A single background task owns the UDP socket + recorder (handlers talk to it over an mpsc channel +
shared status snapshot), so the API is non-blocking. Enrollment/train/room-state are CLI today
@@ -20,7 +20,11 @@ pub enum Posture {
}
/// The fixed guided-anchor sequence (ADR-151 §2.2).
///
/// Serializes as snake_case (`empty`, `stand_still`, …) to match
/// [`AnchorLabel::as_str`] and the documented JSON contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorLabel {
/// Empty room reference (reuses the ADR-135 baseline).
Empty,
@@ -284,6 +288,17 @@ mod tests {
assert_eq!(AnchorLabel::from_str("nope"), None);
}
#[test]
fn label_serde_is_snake_case_matching_as_str() {
// The JSON wire format must equal as_str() (the documented contract).
for l in AnchorLabel::SEQUENCE {
let json = serde_json::to_string(&l).unwrap();
assert_eq!(json, format!("\"{}\"", l.as_str()));
let back: AnchorLabel = serde_json::from_str(&json).unwrap();
assert_eq!(back, l);
}
}
#[test]
fn sequence_order_and_next() {
let mut s = EnrollmentSession::new("living-room", "base-1", 0);
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, oneshot, RwLock};
use tower_http::cors::CorsLayer;
use wifi_densepose_calibration::extract::Features;
use wifi_densepose_calibration::extract::{AnchorFeature, Features};
use wifi_densepose_calibration::{MixtureOfSpecialists, SpecialistBank};
use wifi_densepose_core::types::CsiFrame;
use wifi_densepose_signal::{BaselineCalibration, CalibrationRecorder};
@@ -274,6 +274,7 @@ pub async fn execute(args: CalibrateServeArgs) -> Result<()> {
.route("/api/v1/calibration/result", get(result))
.route("/api/v1/calibration/baselines", get(baselines))
.route("/api/v1/room/state", get(room_state))
.route("/api/v1/room/train", post(train_room))
.layer(CorsLayer::permissive())
.with_state(state);
@@ -528,7 +529,8 @@ async fn descriptor() -> impl IntoResponse {
"POST /api/v1/calibration/stop": "finalize current session early",
"GET /api/v1/calibration/result": "last finalized baseline summary",
"GET /api/v1/calibration/baselines": "list persisted baseline files",
"GET /api/v1/room/state?bank=<name>": "live mixture-of-specialists RoomState over the CSI window"
"GET /api/v1/room/state?bank=<name>": "live mixture-of-specialists RoomState over the CSI window",
"POST /api/v1/room/train": "{ room_id, baseline_id, anchors[] } → train + persist a specialist bank"
}
}))
}
@@ -588,6 +590,47 @@ async fn result(State(st): State<ApiState>) -> impl IntoResponse {
}
}
/// Body for `POST /api/v1/room/train` — an enrollment (CLI `enroll` output or
/// any client that gathered labelled anchor features).
#[derive(Deserialize)]
struct TrainRequest {
room_id: String,
baseline_id: String,
#[serde(default)]
anchors: Vec<AnchorFeature>,
}
/// Train a per-room specialist bank from posted anchors and persist it as
/// `<output_dir>/<room_id>.json` (the name `room-state` reads back).
async fn train_room(State(st): State<ApiState>, Json(req): Json<TrainRequest>) -> impl IntoResponse {
if req.anchors.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"no anchors in request"}))).into_response();
}
let at = (unix_ms() / 1000) as i64;
let bank = match SpecialistBank::train(&req.room_id, &req.baseline_id, &req.anchors, at) {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("training failed: {e}")}))).into_response(),
};
let name = sanitize_room_id(&req.room_id);
let dir = { st.status.read().await.output_dir.clone() };
let path = format!("{dir}/{name}.json");
let json = match bank.to_json() {
Ok(j) => j,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("serialize: {e}")}))).into_response(),
};
if let Err(e) = tokio::fs::write(&path, json).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("cannot write {path}: {e}")}))).into_response();
}
let kinds: Vec<String> = bank.trained_kinds().iter().map(|k| format!("{k:?}")).collect();
(StatusCode::OK, Json(serde_json::json!({
"room_id": bank.room_id,
"bank": name, // pass as ?bank=<name> to /room/state
"anchor_count": bank.anchor_count,
"specialists": kinds,
"path": path,
}))).into_response()
}
/// Query for `GET /api/v1/room/state`.
#[derive(Deserialize)]
struct RoomStateQuery {