mirror of
https://github.com/ruvnet/RuView
synced 2026-08-05 19:41:44 +00:00
Merge remote-tracking branch 'origin/main' into feat/ruview-auth-cognitum-oauth-verifier
# Conflicts: # v2/crates/wifi-densepose-sensing-server/src/main.rs
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,7 @@ pub mod ws_ticket;
|
||||
pub mod cli;
|
||||
pub mod dataset;
|
||||
pub mod edge_registry;
|
||||
#[allow(dead_code)]
|
||||
pub mod embedding;
|
||||
pub mod error_response;
|
||||
pub mod graph_transformer;
|
||||
pub mod host_validation;
|
||||
pub mod introspection;
|
||||
pub mod matter;
|
||||
@@ -31,8 +28,6 @@ pub mod semantic;
|
||||
pub mod rufield_surface;
|
||||
pub mod rvf_container;
|
||||
pub mod rvf_pipeline;
|
||||
pub mod sona;
|
||||
pub mod sparse_inference;
|
||||
#[allow(dead_code)]
|
||||
pub mod trainer;
|
||||
pub mod vital_signs;
|
||||
@@ -44,3 +39,12 @@ pub mod vendor_origin_plume;
|
||||
pub mod vendor_remaining;
|
||||
/// ADR-270 provider registry and canonical event helpers.
|
||||
pub mod vendor_rf;
|
||||
|
||||
// ADR-185 §3.2/§13: the AETHER pure-compute stack (contrastive embedding,
|
||||
// CSI-to-pose transformer, SONA, quantization) was hoisted into the std-only
|
||||
// `wifi-densepose-aether` leaf crate so the Python `[aether]` wheel can bind it
|
||||
// without this crate's Axum/tokio/worldgraph/ruvector tree. Re-exported here so
|
||||
// this crate's own code (`crate::embedding`, `crate::graph_transformer`,
|
||||
// `crate::sona`) and public API (`wifi_densepose_sensing_server::embedding`, …)
|
||||
// are unchanged.
|
||||
pub use wifi_densepose_aether::{embedding, graph_transformer, sona, sparse_inference};
|
||||
|
||||
@@ -20,8 +20,14 @@ mod multistatic_bridge;
|
||||
mod mediatek_csi;
|
||||
mod qualcomm_csi;
|
||||
mod realtek_radar;
|
||||
mod path_safety;
|
||||
pub mod pose;
|
||||
mod rvf_container;
|
||||
// ADR-186 (TRAIN-RECONNECT): the in-server training pipeline was written but
|
||||
// never declared as a module, so it was orphaned / uncompiled. Declaring it
|
||||
// here compiles it against the real `AppStateInner` and wires its `routes()`
|
||||
// (including `/ws/train/progress`) into the live router below.
|
||||
mod training_api;
|
||||
mod rvf_pipeline;
|
||||
mod tracker_bridge;
|
||||
pub mod types;
|
||||
@@ -1120,11 +1126,13 @@ struct AppStateInner {
|
||||
recording_current_id: Option<String>,
|
||||
/// Shutdown signal for the recording writer task.
|
||||
recording_stop_tx: Option<tokio::sync::watch::Sender<bool>>,
|
||||
// ── Training fields ─────────────────────────────────────────────────────
|
||||
/// Training status: "idle", "running", "completed", "failed".
|
||||
training_status: String,
|
||||
/// Training configuration, if any.
|
||||
training_config: Option<serde_json::Value>,
|
||||
// ── Training fields (ADR-186 TRAIN-RECONNECT) ────────────────────────────
|
||||
/// Live training state (shared status snapshot + cooperative cancel flag +
|
||||
/// background task handle) for the in-server trainer in `training_api`.
|
||||
training_state: training_api::TrainingState,
|
||||
/// Fan-out channel the background training job publishes progress JSON to;
|
||||
/// the `/ws/train/progress` WebSocket handler subscribes to it.
|
||||
training_progress_tx: broadcast::Sender<String>,
|
||||
// ── Adaptive classifier (environment-tuned) ──────────────────────────
|
||||
/// Trained adaptive model (loaded from data/adaptive_model.json or trained at runtime).
|
||||
adaptive_model: Option<adaptive_classifier::AdaptiveModel>,
|
||||
@@ -1248,6 +1256,87 @@ const FRAME_HISTORY_CAPACITY: usize = 100;
|
||||
|
||||
type SharedState = Arc<RwLock<AppStateInner>>;
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppStateInner {
|
||||
/// Minimal, dependency-free `AppStateInner` for in-process router tests
|
||||
/// (ADR-186 P6). Uses the same field constructors as the real state seeding
|
||||
/// in `main()` but with trivial values and no CLI/config inputs, so tests can
|
||||
/// build the training router without the full server boot.
|
||||
pub(crate) fn minimal() -> Self {
|
||||
AppStateInner {
|
||||
latest_update: None,
|
||||
rssi_history: VecDeque::new(),
|
||||
frame_history: VecDeque::new(),
|
||||
tick: 0,
|
||||
source: "test".to_string(),
|
||||
last_esp32_frame: None,
|
||||
latest_realtek_radar: None,
|
||||
last_realtek_frame: None,
|
||||
latest_mediatek_csi: None,
|
||||
last_mediatek_frame: None,
|
||||
latest_qualcomm_csi: None,
|
||||
last_qualcomm_frame: None,
|
||||
latest_vendor_rf: BTreeMap::new(),
|
||||
tx: broadcast::channel::<String>(16).0,
|
||||
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
|
||||
intro_tx: broadcast::channel::<String>(16).0,
|
||||
total_detections: 0,
|
||||
start_time: std::time::Instant::now(),
|
||||
vital_detector: VitalSignDetector::new(10.0),
|
||||
latest_vitals: VitalSigns::default(),
|
||||
rvf_info: None,
|
||||
save_rvf_path: None,
|
||||
progressive_loader: None,
|
||||
active_sona_profile: None,
|
||||
model_loaded: false,
|
||||
smoothed_person_score: 0.0,
|
||||
prev_person_count: 0,
|
||||
smoothed_motion: 0.0,
|
||||
current_motion_level: "absent".to_string(),
|
||||
debounce_counter: 0,
|
||||
debounce_candidate: "absent".to_string(),
|
||||
baseline_motion: 0.0,
|
||||
baseline_frames: 0,
|
||||
smoothed_hr: 0.0,
|
||||
smoothed_br: 0.0,
|
||||
smoothed_hr_conf: 0.0,
|
||||
smoothed_br_conf: 0.0,
|
||||
hr_buffer: VecDeque::with_capacity(8),
|
||||
br_buffer: VecDeque::with_capacity(8),
|
||||
edge_vitals: None,
|
||||
latest_wasm_events: None,
|
||||
discovered_models: Vec::new(),
|
||||
active_model_id: None,
|
||||
recordings: Vec::new(),
|
||||
recording_active: false,
|
||||
recording_start_time: None,
|
||||
recording_current_id: None,
|
||||
recording_stop_tx: None,
|
||||
training_state: training_api::TrainingState::default(),
|
||||
training_progress_tx: broadcast::channel::<String>(256).0,
|
||||
adaptive_model: None,
|
||||
node_states: HashMap::new(),
|
||||
pose_tracker: PoseTracker::new(),
|
||||
last_tracker_instant: None,
|
||||
multistatic_fuser: MultistaticFuser::new(),
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
wifi_densepose_bfld::PrivacyMode::PrivateHome,
|
||||
1,
|
||||
"default",
|
||||
"Default Room",
|
||||
None,
|
||||
),
|
||||
field_model: None,
|
||||
p95_variance: RollingP95::new(600, 60),
|
||||
p95_motion_band_power: RollingP95::new(600, 60),
|
||||
p95_spectral_power: RollingP95::new(600, 60),
|
||||
dedup_factor: 3.0,
|
||||
data_dir: std::path::PathBuf::from("data"),
|
||||
field_surface: Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ESP32 Edge Vitals Packet (ADR-039, magic 0xC511_0002) ────────────────────
|
||||
|
||||
/// Decoded vitals packet from ESP32 edge processing pipeline.
|
||||
@@ -4973,54 +5062,12 @@ fn scan_recording_files() -> Vec<serde_json::Value> {
|
||||
}
|
||||
|
||||
// ── Training Endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/v1/train/status — get training status.
|
||||
async fn train_status(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::json!({
|
||||
"status": s.training_status,
|
||||
"config": s.training_config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/train/start — start a training run.
|
||||
async fn train_start(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if s.training_status == "running" {
|
||||
return Json(serde_json::json!({
|
||||
"error": "training already running",
|
||||
"success": false,
|
||||
}));
|
||||
}
|
||||
s.training_status = "running".to_string();
|
||||
s.training_config = Some(body.clone());
|
||||
info!("Training started with config: {}", body);
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"status": "running",
|
||||
"message": "Training pipeline started. Use GET /api/v1/train/status to monitor.",
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/train/stop — stop the current training run.
|
||||
async fn train_stop(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if s.training_status != "running" {
|
||||
return Json(serde_json::json!({
|
||||
"error": "no training in progress",
|
||||
"success": false,
|
||||
}));
|
||||
}
|
||||
s.training_status = "idle".to_string();
|
||||
info!("Training stopped");
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"status": "idle",
|
||||
}))
|
||||
}
|
||||
//
|
||||
// ADR-186 (TRAIN-RECONNECT): the former stub handlers here flipped a status
|
||||
// string and logged one line without ever starting a job (issue #1233). They
|
||||
// are replaced by the real `training_api` router, merged into the app below,
|
||||
// which runs the pure-Rust trainer on a background task and streams live
|
||||
// progress over `/ws/train/progress`.
|
||||
|
||||
// ── Adaptive classifier endpoints ────────────────────────────────────────────
|
||||
|
||||
@@ -7826,9 +7873,9 @@ async fn main() {
|
||||
recording_start_time: None,
|
||||
recording_current_id: None,
|
||||
recording_stop_tx: None,
|
||||
// Training
|
||||
training_status: "idle".to_string(),
|
||||
training_config: None,
|
||||
// Training (ADR-186 TRAIN-RECONNECT)
|
||||
training_state: training_api::TrainingState::default(),
|
||||
training_progress_tx: broadcast::channel::<String>(256).0,
|
||||
adaptive_model:
|
||||
adaptive_classifier::AdaptiveModel::load(&adaptive_classifier::model_path())
|
||||
.ok()
|
||||
@@ -8117,10 +8164,12 @@ async fn main() {
|
||||
.route("/api/v1/recording/start", post(start_recording))
|
||||
.route("/api/v1/recording/stop", post(stop_recording))
|
||||
.route("/api/v1/recording/{id}", delete(delete_recording))
|
||||
// Training endpoints
|
||||
.route("/api/v1/train/status", get(train_status))
|
||||
.route("/api/v1/train/start", post(train_start))
|
||||
.route("/api/v1/train/stop", post(train_stop))
|
||||
// Training endpoints (ADR-186 TRAIN-RECONNECT): the real in-server
|
||||
// trainer + `/ws/train/progress` stream. Merged while the router is
|
||||
// still `Router<SharedState>` (before `.with_state`) so these routes
|
||||
// share `AppStateInner` and `/api/v1/train/*` sits under the bearer gate
|
||||
// applied below (like the rest of `/api/v1/*`).
|
||||
.merge(training_api::routes())
|
||||
// Adaptive classifier endpoints
|
||||
.route("/api/v1/adaptive/train", post(adaptive_train))
|
||||
.route("/api/v1/adaptive/status", get(adaptive_status))
|
||||
@@ -9369,3 +9418,256 @@ async fn oauth_status(
|
||||
"scope": session.as_ref().map(|s| s.scope.clone()),
|
||||
}))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod adr186_http_tests {
|
||||
//! ADR-186 P6: HTTP-level tests that build the real `training_api` router
|
||||
//! and drive it in-process, guarding against the module being orphaned again
|
||||
//! (`training_api::routes()` cannot compile unless the module is declared).
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Serializes tests that read/toggle the process-global
|
||||
/// `RUVIEW_DISABLE_SERVER_TRAINING` env var, so the disabled-path test cannot
|
||||
/// flip enablement while an enabled-path test is mid-request.
|
||||
static TRAIN_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn test_state() -> SharedState {
|
||||
Arc::new(RwLock::new(AppStateInner::minimal()))
|
||||
}
|
||||
|
||||
/// The `/ws/train/progress` route is registered and reaches the WebSocket
|
||||
/// handler (issue #1233 was a 404). Over `oneshot` there is no real socket to
|
||||
/// upgrade, so axum returns 426 Upgrade Required — which still distinguishes a
|
||||
/// wired WS endpoint (426) from an orphaned/absent route (404). The genuine
|
||||
/// 101 handshake is asserted by `ws_train_progress_live_101_and_frame`.
|
||||
#[tokio::test]
|
||||
async fn ws_train_progress_route_is_wired_not_404() {
|
||||
let app = training_api::routes().with_state(test_state());
|
||||
let req = Request::builder()
|
||||
.uri("/ws/train/progress")
|
||||
.header("connection", "upgrade")
|
||||
.header("upgrade", "websocket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_ne!(resp.status(), StatusCode::NOT_FOUND, "route must not 404");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UPGRADE_REQUIRED,
|
||||
"a wired WS route returns 426 under oneshot — got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
/// ADR-186 §7 acceptance: over a real socket, `/ws/train/progress` completes a
|
||||
/// genuine 101 WebSocket handshake and, after a `POST /api/v1/train/start`,
|
||||
/// delivers at least one real `progress` frame to the connected client.
|
||||
#[tokio::test]
|
||||
async fn ws_train_progress_live_101_and_frame() {
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_tungstenite::tungstenite::Message as TMsg;
|
||||
|
||||
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap(); // enablement must stay ON
|
||||
let shared = test_state();
|
||||
{
|
||||
let mut s = shared.write().await;
|
||||
for i in 0..40 {
|
||||
let sub: Vec<f64> = (0..56)
|
||||
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
|
||||
.collect();
|
||||
s.frame_history.push_back(sub);
|
||||
}
|
||||
}
|
||||
|
||||
// Serve the training router on an ephemeral port.
|
||||
let app = training_api::routes().with_state(shared.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
|
||||
// A successful `connect_async` IS the 101 handshake (it errors otherwise).
|
||||
let (mut ws, resp) =
|
||||
tokio_tungstenite::connect_async(format!("ws://{addr}/ws/train/progress"))
|
||||
.await
|
||||
.expect("WebSocket handshake should succeed (101)");
|
||||
assert_eq!(resp.status().as_u16(), 101, "handshake must be 101");
|
||||
|
||||
// Drive training via a real HTTP POST over a fresh TCP connection.
|
||||
let body = r#"{"dataset_ids":[],"config":{"epochs":3,"batch_size":8,"warmup_epochs":1,"early_stopping_patience":10}}"#;
|
||||
let req = format!(
|
||||
"POST /api/v1/train/start HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let mut post = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
post.write_all(req.as_bytes()).await.unwrap();
|
||||
post.flush().await.unwrap();
|
||||
|
||||
// Read WS frames until a `progress` frame arrives (or a 10s ceiling).
|
||||
let mut got_progress = false;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(2), ws.next()).await {
|
||||
Ok(Some(Ok(TMsg::Text(txt)))) => {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {
|
||||
if v.get("type").and_then(|t| t.as_str()) == Some("progress") {
|
||||
got_progress = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(Ok(_))) => {}
|
||||
Ok(Some(Err(_))) | Ok(None) => break,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
got_progress,
|
||||
"should receive a real progress frame over the live WS after POST start"
|
||||
);
|
||||
// NOTE: deliberately no directory-diff cleanup here. `data/models` is
|
||||
// gitignored, and deleting by dir-diff would race concurrent model-writing
|
||||
// tests (it could remove a `.rvf` another test is asserting exists).
|
||||
}
|
||||
|
||||
/// Full HTTP round-trip: POST /api/v1/train/start → poll /api/v1/train/status
|
||||
/// until completion → a real `.rvf` model artifact exists on disk, and real
|
||||
/// progress frames were streamed on the broadcast channel.
|
||||
#[tokio::test]
|
||||
async fn http_train_start_produces_model_and_streams() {
|
||||
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap(); // enablement must stay ON
|
||||
let shared = test_state();
|
||||
// Seed synthetic frames so training's fallback path has data (no files).
|
||||
{
|
||||
let mut s = shared.write().await;
|
||||
for i in 0..40 {
|
||||
let sub: Vec<f64> = (0..56)
|
||||
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
|
||||
.collect();
|
||||
s.frame_history.push_back(sub);
|
||||
}
|
||||
}
|
||||
let mut progress_rx = {
|
||||
let s = shared.read().await;
|
||||
s.training_progress_tx.subscribe()
|
||||
};
|
||||
|
||||
let models_dir = std::path::PathBuf::from(training_api::MODELS_DIR);
|
||||
let before: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&models_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
|
||||
let app = training_api::routes().with_state(shared.clone());
|
||||
|
||||
// POST start.
|
||||
let body = serde_json::json!({
|
||||
"dataset_ids": [],
|
||||
"config": {"epochs": 3, "batch_size": 8, "warmup_epochs": 1, "early_stopping_patience": 10}
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/train/start")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "start should be accepted");
|
||||
|
||||
// Poll status until the job reports completion.
|
||||
let mut completed = false;
|
||||
for _ in 0..250 {
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/train/status")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 65536).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
// Status also carries the P5 enablement flag.
|
||||
assert_eq!(v.get("enabled"), Some(&serde_json::Value::Bool(true)));
|
||||
if v.get("active") == Some(&serde_json::Value::Bool(false))
|
||||
&& v.get("phase").and_then(|p| p.as_str()) == Some("completed")
|
||||
{
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(completed, "training should reach the completed phase");
|
||||
|
||||
// Real progress frames were streamed.
|
||||
let mut saw_progress = false;
|
||||
while progress_rx.try_recv().is_ok() {
|
||||
saw_progress = true;
|
||||
}
|
||||
assert!(saw_progress, "expected streamed progress frames over the WS channel");
|
||||
|
||||
// A new .rvf artifact was written by the run.
|
||||
let after: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&models_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
let new_models: Vec<_> = after
|
||||
.difference(&before)
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("rvf"))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert!(
|
||||
!new_models.is_empty(),
|
||||
"training should write a new .rvf model artifact under {}",
|
||||
models_dir.display()
|
||||
);
|
||||
// No deletion here: removing by dir-diff would race concurrent
|
||||
// model-writing tests. `data/models` is gitignored.
|
||||
}
|
||||
|
||||
/// P5 fallback guarantee: with server training disabled, POST start returns a
|
||||
/// structured `{enabled:false, cli:...}` 409 — never a silent success.
|
||||
#[tokio::test]
|
||||
async fn http_train_start_disabled_returns_structured_409() {
|
||||
// Serialize against the enabled-path tests so our env toggle can't race
|
||||
// their in-flight requests.
|
||||
let _env_lock = TRAIN_ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("RUVIEW_DISABLE_SERVER_TRAINING", "1");
|
||||
|
||||
let app = training_api::routes().with_state(test_state());
|
||||
let body = serde_json::json!({"dataset_ids": [], "config": {"epochs": 1}});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/train/start")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 65536).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
std::env::remove_var("RUVIEW_DISABLE_SERVER_TRAINING");
|
||||
|
||||
assert_eq!(status, StatusCode::CONFLICT, "disabled start must be 4xx/409");
|
||||
assert_eq!(v.get("enabled"), Some(&serde_json::Value::Bool(false)));
|
||||
assert_eq!(
|
||||
v.get("cli").and_then(|c| c.as_str()),
|
||||
Some("wifi-densepose train-room"),
|
||||
"must point at the CLI fallback, never a silent success"
|
||||
);
|
||||
assert_ne!(
|
||||
v.get("success"),
|
||||
Some(&serde_json::Value::Bool(true)),
|
||||
"must never claim success:true when disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,838 +0,0 @@
|
||||
//! SONA online adaptation: LoRA + EWC++ for WiFi-DensePose (ADR-023 Phase 5).
|
||||
//!
|
||||
//! Enables rapid low-parameter adaptation to changing WiFi environments without
|
||||
//! catastrophic forgetting. All arithmetic uses `f32`, no external dependencies.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
// ── LoRA Adapter ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Low-Rank Adaptation layer storing factorised delta `scale * A * B`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoraAdapter {
|
||||
pub a: Vec<Vec<f32>>, // (in_features, rank)
|
||||
pub b: Vec<Vec<f32>>, // (rank, out_features)
|
||||
pub scale: f32, // alpha / rank
|
||||
pub in_features: usize,
|
||||
pub out_features: usize,
|
||||
pub rank: usize,
|
||||
}
|
||||
|
||||
impl LoraAdapter {
|
||||
pub fn new(in_features: usize, out_features: usize, rank: usize, alpha: f32) -> Self {
|
||||
Self {
|
||||
a: vec![vec![0.0f32; rank]; in_features],
|
||||
b: vec![vec![0.0f32; out_features]; rank],
|
||||
scale: alpha / rank.max(1) as f32,
|
||||
in_features,
|
||||
out_features,
|
||||
rank,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute `scale * input * A * B`, returning a vector of length `out_features`.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub fn forward(&self, input: &[f32]) -> Vec<f32> {
|
||||
assert_eq!(input.len(), self.in_features);
|
||||
let mut hidden = vec![0.0f32; self.rank];
|
||||
for (i, &x) in input.iter().enumerate() {
|
||||
for r in 0..self.rank {
|
||||
hidden[r] += x * self.a[i][r];
|
||||
}
|
||||
}
|
||||
let mut output = vec![0.0f32; self.out_features];
|
||||
for r in 0..self.rank {
|
||||
for j in 0..self.out_features {
|
||||
output[j] += hidden[r] * self.b[r][j];
|
||||
}
|
||||
}
|
||||
for v in output.iter_mut() {
|
||||
*v *= self.scale;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Full delta weight matrix `scale * A * B`, shape (in_features, out_features).
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub fn delta_weights(&self) -> Vec<Vec<f32>> {
|
||||
let mut delta = vec![vec![0.0f32; self.out_features]; self.in_features];
|
||||
for i in 0..self.in_features {
|
||||
for r in 0..self.rank {
|
||||
let a_val = self.a[i][r];
|
||||
for j in 0..self.out_features {
|
||||
delta[i][j] += a_val * self.b[r][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in delta.iter_mut() {
|
||||
for v in row.iter_mut() {
|
||||
*v *= self.scale;
|
||||
}
|
||||
}
|
||||
delta
|
||||
}
|
||||
|
||||
/// Add LoRA delta to base weights in place.
|
||||
pub fn merge_into(&self, base_weights: &mut [Vec<f32>]) {
|
||||
let delta = self.delta_weights();
|
||||
for (rb, rd) in base_weights.iter_mut().zip(delta.iter()) {
|
||||
for (w, &d) in rb.iter_mut().zip(rd.iter()) {
|
||||
*w += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtract LoRA delta from base weights in place.
|
||||
pub fn unmerge_from(&self, base_weights: &mut [Vec<f32>]) {
|
||||
let delta = self.delta_weights();
|
||||
for (rb, rd) in base_weights.iter_mut().zip(delta.iter()) {
|
||||
for (w, &d) in rb.iter_mut().zip(rd.iter()) {
|
||||
*w -= d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trainable parameter count: `rank * (in_features + out_features)`.
|
||||
pub fn n_params(&self) -> usize {
|
||||
self.rank * (self.in_features + self.out_features)
|
||||
}
|
||||
|
||||
/// Reset A and B to zero.
|
||||
pub fn reset(&mut self) {
|
||||
for row in self.a.iter_mut() {
|
||||
for v in row.iter_mut() {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
for row in self.b.iter_mut() {
|
||||
for v in row.iter_mut() {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── EWC++ Regularizer ───────────────────────────────────────────────────────
|
||||
|
||||
/// Elastic Weight Consolidation++ regularizer with running Fisher average.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EwcRegularizer {
|
||||
pub lambda: f32,
|
||||
pub decay: f32,
|
||||
pub fisher_diag: Vec<f32>,
|
||||
pub reference_params: Vec<f32>,
|
||||
}
|
||||
|
||||
impl EwcRegularizer {
|
||||
pub fn new(lambda: f32, decay: f32) -> Self {
|
||||
Self {
|
||||
lambda,
|
||||
decay,
|
||||
fisher_diag: Vec::new(),
|
||||
reference_params: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagonal Fisher via numerical central differences: F_i = grad_i^2.
|
||||
pub fn compute_fisher(
|
||||
params: &[f32],
|
||||
loss_fn: impl Fn(&[f32]) -> f32,
|
||||
n_samples: usize,
|
||||
) -> Vec<f32> {
|
||||
let eps = 1e-4f32;
|
||||
let n = params.len();
|
||||
let mut fisher = vec![0.0f32; n];
|
||||
let samples = n_samples.max(1);
|
||||
for _ in 0..samples {
|
||||
let mut p = params.to_vec();
|
||||
for i in 0..n {
|
||||
let orig = p[i];
|
||||
p[i] = orig + eps;
|
||||
let lp = loss_fn(&p);
|
||||
p[i] = orig - eps;
|
||||
let lm = loss_fn(&p);
|
||||
p[i] = orig;
|
||||
let g = (lp - lm) / (2.0 * eps);
|
||||
fisher[i] += g * g;
|
||||
}
|
||||
}
|
||||
for f in fisher.iter_mut() {
|
||||
*f /= samples as f32;
|
||||
}
|
||||
fisher
|
||||
}
|
||||
|
||||
/// Online update: `F = decay * F_old + (1-decay) * F_new`.
|
||||
pub fn update_fisher(&mut self, new_fisher: &[f32]) {
|
||||
if self.fisher_diag.is_empty() {
|
||||
self.fisher_diag = new_fisher.to_vec();
|
||||
return;
|
||||
}
|
||||
assert_eq!(self.fisher_diag.len(), new_fisher.len());
|
||||
for (old, &nv) in self.fisher_diag.iter_mut().zip(new_fisher.iter()) {
|
||||
*old = self.decay * *old + (1.0 - self.decay) * nv;
|
||||
}
|
||||
}
|
||||
|
||||
/// Penalty: `0.5 * lambda * sum(F_i * (theta_i - theta_i*)^2)`.
|
||||
pub fn penalty(&self, current_params: &[f32]) -> f32 {
|
||||
if self.reference_params.is_empty() || self.fisher_diag.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let n = current_params
|
||||
.len()
|
||||
.min(self.reference_params.len())
|
||||
.min(self.fisher_diag.len());
|
||||
let mut sum = 0.0f32;
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0..n {
|
||||
let d = current_params[i] - self.reference_params[i];
|
||||
sum += self.fisher_diag[i] * d * d;
|
||||
}
|
||||
0.5 * self.lambda * sum
|
||||
}
|
||||
|
||||
/// Gradient of penalty: `lambda * F_i * (theta_i - theta_i*)`.
|
||||
pub fn penalty_gradient(&self, current_params: &[f32]) -> Vec<f32> {
|
||||
if self.reference_params.is_empty() || self.fisher_diag.is_empty() {
|
||||
return vec![0.0f32; current_params.len()];
|
||||
}
|
||||
let n = current_params
|
||||
.len()
|
||||
.min(self.reference_params.len())
|
||||
.min(self.fisher_diag.len());
|
||||
let mut grad = vec![0.0f32; current_params.len()];
|
||||
for i in 0..n {
|
||||
grad[i] =
|
||||
self.lambda * self.fisher_diag[i] * (current_params[i] - self.reference_params[i]);
|
||||
}
|
||||
grad
|
||||
}
|
||||
|
||||
/// Save current params as the new reference point.
|
||||
pub fn consolidate(&mut self, params: &[f32]) {
|
||||
self.reference_params = params.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configuration & Types ───────────────────────────────────────────────────
|
||||
|
||||
/// SONA adaptation configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SonaConfig {
|
||||
pub lora_rank: usize,
|
||||
pub lora_alpha: f32,
|
||||
pub ewc_lambda: f32,
|
||||
pub ewc_decay: f32,
|
||||
pub adaptation_lr: f32,
|
||||
pub max_steps: usize,
|
||||
pub convergence_threshold: f32,
|
||||
pub temporal_consistency_weight: f32,
|
||||
}
|
||||
|
||||
impl Default for SonaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lora_rank: 4,
|
||||
lora_alpha: 8.0,
|
||||
ewc_lambda: 5000.0,
|
||||
ewc_decay: 0.99,
|
||||
adaptation_lr: 0.001,
|
||||
max_steps: 50,
|
||||
convergence_threshold: 1e-4,
|
||||
temporal_consistency_weight: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single training sample for online adaptation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptationSample {
|
||||
pub csi_features: Vec<f32>,
|
||||
pub target: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Result of a SONA adaptation run.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptationResult {
|
||||
pub adapted_params: Vec<f32>,
|
||||
pub steps_taken: usize,
|
||||
pub final_loss: f32,
|
||||
pub converged: bool,
|
||||
pub ewc_penalty: f32,
|
||||
}
|
||||
|
||||
/// Saved environment-specific adaptation profile.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SonaProfile {
|
||||
pub name: String,
|
||||
pub lora_a: Vec<Vec<f32>>,
|
||||
pub lora_b: Vec<Vec<f32>>,
|
||||
pub fisher_diag: Vec<f32>,
|
||||
pub reference_params: Vec<f32>,
|
||||
pub adaptation_count: usize,
|
||||
}
|
||||
|
||||
// ── SONA Adapter ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Full SONA system: LoRA adapter + EWC++ regularizer for online adaptation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SonaAdapter {
|
||||
pub config: SonaConfig,
|
||||
pub lora: LoraAdapter,
|
||||
pub ewc: EwcRegularizer,
|
||||
pub param_count: usize,
|
||||
pub adaptation_count: usize,
|
||||
}
|
||||
|
||||
impl SonaAdapter {
|
||||
pub fn new(config: SonaConfig, param_count: usize) -> Self {
|
||||
let lora = LoraAdapter::new(param_count, 1, config.lora_rank, config.lora_alpha);
|
||||
let ewc = EwcRegularizer::new(config.ewc_lambda, config.ewc_decay);
|
||||
Self {
|
||||
config,
|
||||
lora,
|
||||
ewc,
|
||||
param_count,
|
||||
adaptation_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run gradient descent with LoRA + EWC on the given samples.
|
||||
pub fn adapt(&mut self, base_params: &[f32], samples: &[AdaptationSample]) -> AdaptationResult {
|
||||
assert_eq!(base_params.len(), self.param_count);
|
||||
if samples.is_empty() {
|
||||
return AdaptationResult {
|
||||
adapted_params: base_params.to_vec(),
|
||||
steps_taken: 0,
|
||||
final_loss: 0.0,
|
||||
converged: true,
|
||||
ewc_penalty: self.ewc.penalty(base_params),
|
||||
};
|
||||
}
|
||||
let lr = self.config.adaptation_lr;
|
||||
let (mut prev_loss, mut steps, mut converged) = (f32::MAX, 0usize, false);
|
||||
let out_dim = samples[0].target.len();
|
||||
let in_dim = samples[0].csi_features.len();
|
||||
|
||||
for step in 0..self.config.max_steps {
|
||||
steps = step + 1;
|
||||
let df = self.lora_delta_flat();
|
||||
let eff: Vec<f32> = base_params
|
||||
.iter()
|
||||
.zip(df.iter())
|
||||
.map(|(&b, &d)| b + d)
|
||||
.collect();
|
||||
let (dl, dg) = Self::mse_loss_grad(&eff, samples, in_dim, out_dim);
|
||||
let ep = self.ewc.penalty(&eff);
|
||||
let eg = self.ewc.penalty_gradient(&eff);
|
||||
let total = dl + ep;
|
||||
if (prev_loss - total).abs() < self.config.convergence_threshold {
|
||||
converged = true;
|
||||
prev_loss = total;
|
||||
break;
|
||||
}
|
||||
prev_loss = total;
|
||||
let gl = df.len().min(dg.len()).min(eg.len());
|
||||
let mut tg = vec![0.0f32; gl];
|
||||
for i in 0..gl {
|
||||
tg[i] = dg[i] + eg[i];
|
||||
}
|
||||
self.update_lora(&tg, lr);
|
||||
}
|
||||
let df = self.lora_delta_flat();
|
||||
let adapted: Vec<f32> = base_params
|
||||
.iter()
|
||||
.zip(df.iter())
|
||||
.map(|(&b, &d)| b + d)
|
||||
.collect();
|
||||
let ewc_penalty = self.ewc.penalty(&adapted);
|
||||
self.adaptation_count += 1;
|
||||
AdaptationResult {
|
||||
adapted_params: adapted,
|
||||
steps_taken: steps,
|
||||
final_loss: prev_loss,
|
||||
converged,
|
||||
ewc_penalty,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_profile(&self, name: &str) -> SonaProfile {
|
||||
SonaProfile {
|
||||
name: name.to_string(),
|
||||
lora_a: self.lora.a.clone(),
|
||||
lora_b: self.lora.b.clone(),
|
||||
fisher_diag: self.ewc.fisher_diag.clone(),
|
||||
reference_params: self.ewc.reference_params.clone(),
|
||||
adaptation_count: self.adaptation_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_profile(&mut self, profile: &SonaProfile) {
|
||||
self.lora.a = profile.lora_a.clone();
|
||||
self.lora.b = profile.lora_b.clone();
|
||||
self.ewc.fisher_diag = profile.fisher_diag.clone();
|
||||
self.ewc.reference_params = profile.reference_params.clone();
|
||||
self.adaptation_count = profile.adaptation_count;
|
||||
}
|
||||
|
||||
fn lora_delta_flat(&self) -> Vec<f32> {
|
||||
self.lora
|
||||
.delta_weights()
|
||||
.into_iter()
|
||||
.map(|r| r[0])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mse_loss_grad(
|
||||
params: &[f32],
|
||||
samples: &[AdaptationSample],
|
||||
in_dim: usize,
|
||||
out_dim: usize,
|
||||
) -> (f32, Vec<f32>) {
|
||||
let n = samples.len() as f32;
|
||||
let ws = in_dim * out_dim;
|
||||
let mut grad = vec![0.0f32; params.len()];
|
||||
let mut loss = 0.0f32;
|
||||
for s in samples {
|
||||
let (inp, tgt) = (&s.csi_features, &s.target);
|
||||
let mut pred = vec![0.0f32; out_dim];
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for j in 0..out_dim {
|
||||
for i in 0..in_dim.min(inp.len()) {
|
||||
let idx = j * in_dim + i;
|
||||
if idx < ws && idx < params.len() {
|
||||
pred[j] += params[idx] * inp[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in 0..out_dim.min(tgt.len()) {
|
||||
let e = pred[j] - tgt[j];
|
||||
loss += e * e;
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0..in_dim.min(inp.len()) {
|
||||
let idx = j * in_dim + i;
|
||||
if idx < ws && idx < grad.len() {
|
||||
grad[idx] += 2.0 * e * inp[i] / n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(loss / n, grad)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn update_lora(&mut self, grad: &[f32], lr: f32) {
|
||||
let (scale, rank) = (self.lora.scale, self.lora.rank);
|
||||
if self.lora.b.iter().all(|r| r.iter().all(|&v| v == 0.0)) && rank > 0 {
|
||||
self.lora.b[0][0] = 1.0;
|
||||
}
|
||||
for i in 0..self.lora.in_features.min(grad.len()) {
|
||||
for r in 0..rank {
|
||||
self.lora.a[i][r] -= lr * grad[i] * scale * self.lora.b[r][0];
|
||||
}
|
||||
}
|
||||
for r in 0..rank {
|
||||
let mut g = 0.0f32;
|
||||
for i in 0..self.lora.in_features.min(grad.len()) {
|
||||
g += grad[i] * scale * self.lora.a[i][r];
|
||||
}
|
||||
self.lora.b[r][0] -= lr * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Environment Detector ────────────────────────────────────────────────────
|
||||
|
||||
/// CSI baseline drift information.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriftInfo {
|
||||
pub magnitude: f32,
|
||||
pub duration_frames: usize,
|
||||
pub baseline_mean: f32,
|
||||
pub current_mean: f32,
|
||||
}
|
||||
|
||||
/// Detects environmental drift in CSI statistics (>3 sigma from baseline).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnvironmentDetector {
|
||||
window_size: usize,
|
||||
means: VecDeque<f32>,
|
||||
variances: VecDeque<f32>,
|
||||
baseline_mean: f32,
|
||||
baseline_var: f32,
|
||||
baseline_std: f32,
|
||||
baseline_set: bool,
|
||||
drift_frames: usize,
|
||||
}
|
||||
|
||||
impl EnvironmentDetector {
|
||||
pub fn new(window_size: usize) -> Self {
|
||||
Self {
|
||||
window_size: window_size.max(2),
|
||||
means: VecDeque::with_capacity(window_size),
|
||||
variances: VecDeque::with_capacity(window_size),
|
||||
baseline_mean: 0.0,
|
||||
baseline_var: 0.0,
|
||||
baseline_std: 0.0,
|
||||
baseline_set: false,
|
||||
drift_frames: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, csi_mean: f32, csi_var: f32) {
|
||||
self.means.push_back(csi_mean);
|
||||
self.variances.push_back(csi_var);
|
||||
while self.means.len() > self.window_size {
|
||||
self.means.pop_front();
|
||||
}
|
||||
while self.variances.len() > self.window_size {
|
||||
self.variances.pop_front();
|
||||
}
|
||||
if !self.baseline_set && self.means.len() >= self.window_size {
|
||||
self.reset_baseline();
|
||||
}
|
||||
if self.drift_detected() {
|
||||
self.drift_frames += 1;
|
||||
} else {
|
||||
self.drift_frames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drift_detected(&self) -> bool {
|
||||
if !self.baseline_set || self.means.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let dev = (self.current_mean() - self.baseline_mean).abs();
|
||||
let thr = if self.baseline_std > f32::EPSILON {
|
||||
3.0 * self.baseline_std
|
||||
} else {
|
||||
f32::EPSILON * 100.0
|
||||
};
|
||||
dev > thr
|
||||
}
|
||||
|
||||
pub fn reset_baseline(&mut self) {
|
||||
if self.means.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = self.means.len() as f32;
|
||||
self.baseline_mean = self.means.iter().sum::<f32>() / n;
|
||||
let var = self
|
||||
.means
|
||||
.iter()
|
||||
.map(|&m| (m - self.baseline_mean).powi(2))
|
||||
.sum::<f32>()
|
||||
/ n;
|
||||
self.baseline_var = var;
|
||||
self.baseline_std = var.sqrt();
|
||||
self.baseline_set = true;
|
||||
self.drift_frames = 0;
|
||||
}
|
||||
|
||||
pub fn drift_info(&self) -> DriftInfo {
|
||||
let cm = self.current_mean();
|
||||
let abs_dev = (cm - self.baseline_mean).abs();
|
||||
let magnitude = if self.baseline_std > f32::EPSILON {
|
||||
abs_dev / self.baseline_std
|
||||
} else if abs_dev > f32::EPSILON {
|
||||
abs_dev / f32::EPSILON
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
DriftInfo {
|
||||
magnitude,
|
||||
duration_frames: self.drift_frames,
|
||||
baseline_mean: self.baseline_mean,
|
||||
current_mean: cm,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mean(&self) -> f32 {
|
||||
if self.means.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
self.means.iter().sum::<f32>() / self.means.len() as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Temporal Consistency Loss ───────────────────────────────────────────────
|
||||
|
||||
/// Penalises large velocity between consecutive outputs: `sum((c-p)^2) / dt`.
|
||||
pub struct TemporalConsistencyLoss;
|
||||
|
||||
impl TemporalConsistencyLoss {
|
||||
pub fn compute(prev_output: &[f32], curr_output: &[f32], dt: f32) -> f32 {
|
||||
if dt <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let n = prev_output.len().min(curr_output.len());
|
||||
let mut sq = 0.0f32;
|
||||
for i in 0..n {
|
||||
let d = curr_output[i] - prev_output[i];
|
||||
sq += d * d;
|
||||
}
|
||||
sq / dt
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lora_adapter_param_count() {
|
||||
let lora = LoraAdapter::new(64, 32, 4, 8.0);
|
||||
assert_eq!(lora.n_params(), 4 * (64 + 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_adapter_forward_shape() {
|
||||
let lora = LoraAdapter::new(8, 4, 2, 4.0);
|
||||
assert_eq!(lora.forward(&[1.0f32; 8]).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_adapter_zero_init_produces_zero_delta() {
|
||||
let delta = LoraAdapter::new(8, 4, 2, 4.0).delta_weights();
|
||||
assert_eq!(delta.len(), 8);
|
||||
for row in &delta {
|
||||
assert_eq!(row.len(), 4);
|
||||
for &v in row {
|
||||
assert_eq!(v, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_adapter_merge_unmerge_roundtrip() {
|
||||
let mut lora = LoraAdapter::new(3, 2, 1, 2.0);
|
||||
lora.a[0][0] = 1.0;
|
||||
lora.a[1][0] = 2.0;
|
||||
lora.a[2][0] = 3.0;
|
||||
lora.b[0][0] = 0.5;
|
||||
lora.b[0][1] = -0.5;
|
||||
let mut base = vec![vec![10.0, 20.0], vec![30.0, 40.0], vec![50.0, 60.0]];
|
||||
let orig = base.clone();
|
||||
lora.merge_into(&mut base);
|
||||
assert_ne!(base, orig);
|
||||
lora.unmerge_from(&mut base);
|
||||
for (rb, ro) in base.iter().zip(orig.iter()) {
|
||||
for (&b, &o) in rb.iter().zip(ro.iter()) {
|
||||
assert!((b - o).abs() < 1e-5, "roundtrip failed: {b} vs {o}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_adapter_rank_1_outer_product() {
|
||||
let mut lora = LoraAdapter::new(3, 2, 1, 1.0); // scale=1
|
||||
lora.a[0][0] = 1.0;
|
||||
lora.a[1][0] = 2.0;
|
||||
lora.a[2][0] = 3.0;
|
||||
lora.b[0][0] = 4.0;
|
||||
lora.b[0][1] = 5.0;
|
||||
let d = lora.delta_weights();
|
||||
let expected = [[4.0, 5.0], [8.0, 10.0], [12.0, 15.0]];
|
||||
for (i, row) in expected.iter().enumerate() {
|
||||
for (j, &v) in row.iter().enumerate() {
|
||||
assert!((d[i][j] - v).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_scale_factor() {
|
||||
assert!((LoraAdapter::new(8, 4, 4, 16.0).scale - 4.0).abs() < 1e-6);
|
||||
assert!((LoraAdapter::new(8, 4, 2, 8.0).scale - 4.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ewc_fisher_positive() {
|
||||
let fisher = EwcRegularizer::compute_fisher(
|
||||
&[1.0f32, -2.0, 0.5],
|
||||
|p: &[f32]| p.iter().map(|&x| x * x).sum::<f32>(),
|
||||
1,
|
||||
);
|
||||
assert_eq!(fisher.len(), 3);
|
||||
for &f in &fisher {
|
||||
assert!(f >= 0.0, "Fisher must be >= 0, got {f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ewc_penalty_zero_at_reference() {
|
||||
let mut ewc = EwcRegularizer::new(5000.0, 0.99);
|
||||
let p = vec![1.0, 2.0, 3.0];
|
||||
ewc.fisher_diag = vec![1.0; 3];
|
||||
ewc.consolidate(&p);
|
||||
assert!(ewc.penalty(&p).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ewc_penalty_positive_away_from_reference() {
|
||||
let mut ewc = EwcRegularizer::new(5000.0, 0.99);
|
||||
ewc.fisher_diag = vec![1.0; 3];
|
||||
ewc.consolidate(&[1.0, 2.0, 3.0]);
|
||||
let pen = ewc.penalty(&[2.0, 3.0, 4.0]);
|
||||
assert!(pen > 0.0); // 0.5 * 5000 * 3 = 7500
|
||||
assert!((pen - 7500.0).abs() < 1e-3, "expected ~7500, got {pen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ewc_penalty_gradient_direction() {
|
||||
let mut ewc = EwcRegularizer::new(100.0, 0.99);
|
||||
let r = vec![1.0, 2.0, 3.0];
|
||||
ewc.fisher_diag = vec![1.0; 3];
|
||||
ewc.consolidate(&r);
|
||||
let c = vec![2.0, 4.0, 5.0];
|
||||
let grad = ewc.penalty_gradient(&c);
|
||||
for (i, &g) in grad.iter().enumerate() {
|
||||
assert!(g * (c[i] - r[i]) > 0.0, "gradient[{i}] wrong sign");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ewc_online_update_decays() {
|
||||
let mut ewc = EwcRegularizer::new(1.0, 0.5);
|
||||
ewc.update_fisher(&[10.0, 20.0]);
|
||||
assert!((ewc.fisher_diag[0] - 10.0).abs() < 1e-6);
|
||||
ewc.update_fisher(&[0.0, 0.0]);
|
||||
assert!((ewc.fisher_diag[0] - 5.0).abs() < 1e-6); // 0.5*10 + 0.5*0
|
||||
assert!((ewc.fisher_diag[1] - 10.0).abs() < 1e-6); // 0.5*20 + 0.5*0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ewc_consolidate_updates_reference() {
|
||||
let mut ewc = EwcRegularizer::new(1.0, 0.99);
|
||||
ewc.consolidate(&[1.0, 2.0]);
|
||||
assert_eq!(ewc.reference_params, vec![1.0, 2.0]);
|
||||
ewc.consolidate(&[3.0, 4.0]);
|
||||
assert_eq!(ewc.reference_params, vec![3.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sona_config_defaults() {
|
||||
let c = SonaConfig::default();
|
||||
assert_eq!(c.lora_rank, 4);
|
||||
assert!((c.lora_alpha - 8.0).abs() < 1e-6);
|
||||
assert!((c.ewc_lambda - 5000.0).abs() < 1e-3);
|
||||
assert!((c.ewc_decay - 0.99).abs() < 1e-6);
|
||||
assert!((c.adaptation_lr - 0.001).abs() < 1e-6);
|
||||
assert_eq!(c.max_steps, 50);
|
||||
assert!((c.convergence_threshold - 1e-4).abs() < 1e-8);
|
||||
assert!((c.temporal_consistency_weight - 0.1).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sona_adapter_converges_on_simple_task() {
|
||||
let cfg = SonaConfig {
|
||||
lora_rank: 1,
|
||||
lora_alpha: 1.0,
|
||||
ewc_lambda: 0.0,
|
||||
ewc_decay: 0.99,
|
||||
adaptation_lr: 0.01,
|
||||
max_steps: 200,
|
||||
convergence_threshold: 1e-6,
|
||||
temporal_consistency_weight: 0.0,
|
||||
};
|
||||
let mut adapter = SonaAdapter::new(cfg, 1);
|
||||
let samples: Vec<_> = (1..=5)
|
||||
.map(|i| {
|
||||
let x = i as f32;
|
||||
AdaptationSample {
|
||||
csi_features: vec![x],
|
||||
target: vec![2.0 * x],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let r = adapter.adapt(&[0.0f32], &samples);
|
||||
assert!(
|
||||
r.final_loss < 1.0,
|
||||
"loss should decrease, got {}",
|
||||
r.final_loss
|
||||
);
|
||||
assert!(r.steps_taken > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sona_adapter_respects_max_steps() {
|
||||
let cfg = SonaConfig {
|
||||
max_steps: 5,
|
||||
convergence_threshold: 0.0,
|
||||
..SonaConfig::default()
|
||||
};
|
||||
let mut a = SonaAdapter::new(cfg, 4);
|
||||
let s = vec![AdaptationSample {
|
||||
csi_features: vec![1.0, 0.0, 0.0, 0.0],
|
||||
target: vec![1.0],
|
||||
}];
|
||||
assert_eq!(a.adapt(&[0.0; 4], &s).steps_taken, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sona_profile_save_load_roundtrip() {
|
||||
let mut a = SonaAdapter::new(SonaConfig::default(), 8);
|
||||
a.lora.a[0][0] = 1.5;
|
||||
a.lora.b[0][0] = -0.3;
|
||||
a.ewc.fisher_diag = vec![1.0, 2.0, 3.0];
|
||||
a.ewc.reference_params = vec![0.1, 0.2, 0.3];
|
||||
a.adaptation_count = 42;
|
||||
let p = a.save_profile("test-env");
|
||||
assert_eq!(p.name, "test-env");
|
||||
assert_eq!(p.adaptation_count, 42);
|
||||
let mut a2 = SonaAdapter::new(SonaConfig::default(), 8);
|
||||
a2.load_profile(&p);
|
||||
assert!((a2.lora.a[0][0] - 1.5).abs() < 1e-6);
|
||||
assert!((a2.lora.b[0][0] - (-0.3)).abs() < 1e-6);
|
||||
assert_eq!(a2.ewc.fisher_diag.len(), 3);
|
||||
assert!((a2.ewc.fisher_diag[2] - 3.0).abs() < 1e-6);
|
||||
assert_eq!(a2.adaptation_count, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_detector_no_drift_initially() {
|
||||
assert!(!EnvironmentDetector::new(10).drift_detected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_detector_detects_large_shift() {
|
||||
let mut d = EnvironmentDetector::new(10);
|
||||
for _ in 0..10 {
|
||||
d.update(10.0, 0.1);
|
||||
}
|
||||
assert!(!d.drift_detected());
|
||||
for _ in 0..10 {
|
||||
d.update(50.0, 0.1);
|
||||
}
|
||||
assert!(d.drift_detected());
|
||||
assert!(
|
||||
d.drift_info().magnitude > 3.0,
|
||||
"magnitude = {}",
|
||||
d.drift_info().magnitude
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_detector_reset_baseline() {
|
||||
let mut d = EnvironmentDetector::new(10);
|
||||
for _ in 0..10 {
|
||||
d.update(10.0, 0.1);
|
||||
}
|
||||
for _ in 0..10 {
|
||||
d.update(50.0, 0.1);
|
||||
}
|
||||
assert!(d.drift_detected());
|
||||
d.reset_baseline();
|
||||
assert!(!d.drift_detected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_consistency_zero_for_static() {
|
||||
let o = vec![1.0, 2.0, 3.0];
|
||||
assert!(TemporalConsistencyLoss::compute(&o, &o, 0.033).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,22 +26,23 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::{IntoResponse, Json},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::recording::{RecordedFrame, RECORDINGS_DIR};
|
||||
use crate::rvf_container::RvfBuilder;
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
@@ -49,6 +50,28 @@ use crate::rvf_container::RvfBuilder;
|
||||
/// Directory for trained model output.
|
||||
pub const MODELS_DIR: &str = "data/models";
|
||||
|
||||
/// Directory the training loop reads recorded CSI datasets from. Each
|
||||
/// `dataset_id` maps to `{RECORDINGS_DIR}/{dataset_id}.csi.jsonl`.
|
||||
pub const RECORDINGS_DIR: &str = "data/recordings";
|
||||
|
||||
/// Monotonic per-process counter appended to exported model filenames so two
|
||||
/// runs that complete in the same wall-clock microsecond still get distinct
|
||||
/// paths (prevents silent overwrite; keeps concurrent runs from colliding).
|
||||
static MODEL_ID_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Build a process-unique model id `trained-{type}-{ts_micros}-{seq}`. A
|
||||
/// second-resolution timestamp alone collided for runs finishing in the same
|
||||
/// second (silent overwrite); microseconds + the monotonic counter guarantee
|
||||
/// uniqueness even for same-microsecond concurrent completions.
|
||||
fn next_model_id(training_type: &str) -> String {
|
||||
format!(
|
||||
"trained-{}-{}-{}",
|
||||
training_type,
|
||||
chrono::Utc::now().format("%Y%m%d_%H%M%S_%6f"),
|
||||
MODEL_ID_SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
/// Number of COCO keypoints.
|
||||
const N_KEYPOINTS: usize = 17;
|
||||
/// Dimensions per keypoint in the target vector (x, y, z).
|
||||
@@ -67,6 +90,25 @@ const N_GLOBAL_FEATURES: usize = 3;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single recorded CSI frame line, as stored in the `.csi.jsonl` datasets the
|
||||
/// training loop consumes.
|
||||
///
|
||||
/// This mirrors the on-disk JSONL schema and is intentionally self-contained so
|
||||
/// the trainer does not couple to the (separate, orphaned) `recording.rs`
|
||||
/// module. Only the fields the feature extractor needs are read; `rssi` /
|
||||
/// `noise_floor` / `features` are carried for schema fidelity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordedFrame {
|
||||
pub timestamp: f64,
|
||||
pub subcarriers: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub rssi: f64,
|
||||
#[serde(default)]
|
||||
pub noise_floor: f64,
|
||||
#[serde(default)]
|
||||
pub features: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Training configuration submitted with a start request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrainingConfig {
|
||||
@@ -229,24 +271,45 @@ pub struct TrainingProgress {
|
||||
}
|
||||
|
||||
/// Runtime training state stored in `AppStateInner`.
|
||||
///
|
||||
/// `status` and `cancel` are shared handles (not owned snapshots) so the
|
||||
/// background training job can update progress and observe stop requests
|
||||
/// **without holding a reference to the full `AppStateInner`**. That decoupling
|
||||
/// is what makes the training core ([`run_training_job`]) unit-testable in
|
||||
/// isolation from the ~60-field server state.
|
||||
pub struct TrainingState {
|
||||
/// Current status snapshot.
|
||||
pub status: TrainingStatus,
|
||||
/// Handle to the background training task (for cancellation).
|
||||
/// Live status snapshot, shared with the running training job.
|
||||
pub status: Arc<Mutex<TrainingStatus>>,
|
||||
/// Cooperative stop flag; `stop_training` sets it and the job loop observes it.
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
/// Handle to the background training task.
|
||||
pub task_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Default for TrainingState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: TrainingStatus::default(),
|
||||
status: Arc::new(Mutex::new(TrainingStatus::default())),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
task_handle: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrainingState {
|
||||
/// Clone of the current status snapshot.
|
||||
pub fn snapshot(&self) -> TrainingStatus {
|
||||
self.status.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Whether a training job is currently active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.status.lock().unwrap().active
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared application state type.
|
||||
pub type AppState = Arc<RwLock<super::AppStateInner>>;
|
||||
pub type AppState = Arc<tokio::sync::RwLock<super::AppStateInner>>;
|
||||
|
||||
/// Feature normalization statistics computed from the training set.
|
||||
/// Stored alongside the model weights inside the .rvf container so that
|
||||
@@ -317,11 +380,11 @@ async fn load_recording_frames(dataset_ids: &[String]) -> Vec<RecordedFrame> {
|
||||
all_frames
|
||||
}
|
||||
|
||||
/// Attempt to collect frames from the live frame_history buffer in AppState.
|
||||
/// Each `Vec<f64>` in frame_history is a subcarrier amplitude vector.
|
||||
async fn load_frames_from_history(state: &AppState) -> Vec<RecordedFrame> {
|
||||
let s = state.read().await;
|
||||
let history: &VecDeque<Vec<f64>> = &s.frame_history;
|
||||
/// Build fallback training frames from a snapshot of the live `frame_history`
|
||||
/// buffer. Each `Vec<f64>` is one frame's subcarrier amplitude vector. Passed as
|
||||
/// an owned snapshot (not a live `AppState` borrow) so the training core stays
|
||||
/// state-free and independently testable.
|
||||
fn frames_from_history(history: &[Vec<f64>]) -> Vec<RecordedFrame> {
|
||||
history
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -938,13 +1001,15 @@ fn deterministic_shuffle(n: usize, seed: u64) -> Vec<usize> {
|
||||
/// linear model via mini-batch gradient descent.
|
||||
///
|
||||
/// On completion, exports a `.rvf` container with real calibrated weights.
|
||||
async fn real_training_loop(
|
||||
state: AppState,
|
||||
async fn run_training_job(
|
||||
status: Arc<Mutex<TrainingStatus>>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
progress_tx: broadcast::Sender<String>,
|
||||
config: TrainingConfig,
|
||||
dataset_ids: Vec<String>,
|
||||
history_snapshot: Vec<Vec<f64>>,
|
||||
training_type: &str,
|
||||
) {
|
||||
) -> Option<PathBuf> {
|
||||
let total_epochs = config.epochs;
|
||||
let patience = config.early_stopping_patience;
|
||||
let mut best_pck = 0.0f64;
|
||||
@@ -978,7 +1043,7 @@ async fn real_training_loop(
|
||||
let mut frames = load_recording_frames(&dataset_ids).await;
|
||||
if frames.is_empty() {
|
||||
info!("No recordings found for dataset_ids; falling back to live frame_history");
|
||||
frames = load_frames_from_history(&state).await;
|
||||
frames = frames_from_history(&history_snapshot);
|
||||
}
|
||||
|
||||
if frames.len() < 10 {
|
||||
@@ -999,11 +1064,12 @@ async fn real_training_loop(
|
||||
if let Ok(json) = serde_json::to_string(&fail) {
|
||||
let _ = progress_tx.send(json);
|
||||
}
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status.active = false;
|
||||
s.training_state.status.phase = "failed".to_string();
|
||||
s.training_state.task_handle = None;
|
||||
return;
|
||||
{
|
||||
let mut st = status.lock().unwrap();
|
||||
st.active = false;
|
||||
st.phase = "failed".to_string();
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
info!("Loaded {} frames for training", frames.len());
|
||||
@@ -1079,13 +1145,10 @@ async fn real_training_loop(
|
||||
// ── Phase 5: Training loop ───────────────────────────────────────────────
|
||||
|
||||
for epoch in 1..=total_epochs {
|
||||
// Check cancellation.
|
||||
{
|
||||
let s = state.read().await;
|
||||
if !s.training_state.status.active {
|
||||
info!("Training cancelled at epoch {epoch}");
|
||||
break;
|
||||
}
|
||||
// Check cancellation (cooperative stop flag set by `stop_training`).
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
info!("Training cancelled at epoch {epoch}");
|
||||
break;
|
||||
}
|
||||
|
||||
let phase = if epoch <= config.warmup_epochs {
|
||||
@@ -1245,10 +1308,10 @@ async fn real_training_loop(
|
||||
let remaining = total_epochs.saturating_sub(epoch);
|
||||
let eta_secs = (remaining as f64 * secs_per_epoch) as u64;
|
||||
|
||||
// Update shared state.
|
||||
// Update the shared status snapshot (read by GET /api/v1/train/status).
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status = TrainingStatus {
|
||||
let mut st = status.lock().unwrap();
|
||||
*st = TrainingStatus {
|
||||
active: true,
|
||||
epoch,
|
||||
total_epochs,
|
||||
@@ -1297,15 +1360,12 @@ async fn real_training_loop(
|
||||
|
||||
// ── Phase 6: Export .rvf model ───────────────────────────────────────────
|
||||
|
||||
let completed_phase;
|
||||
{
|
||||
let s = state.read().await;
|
||||
completed_phase = if s.training_state.status.active {
|
||||
"completed"
|
||||
} else {
|
||||
"cancelled"
|
||||
};
|
||||
}
|
||||
let completed_phase = if cancel.load(Ordering::Relaxed) {
|
||||
"cancelled"
|
||||
} else {
|
||||
"completed"
|
||||
};
|
||||
let mut written_rvf: Option<PathBuf> = None;
|
||||
|
||||
// Emit completion message.
|
||||
let completion = TrainingProgress {
|
||||
@@ -1326,11 +1386,7 @@ async fn real_training_loop(
|
||||
if let Err(e) = tokio::fs::create_dir_all(MODELS_DIR).await {
|
||||
error!("Failed to create models directory: {e}");
|
||||
} else {
|
||||
let model_id = format!(
|
||||
"trained-{}-{}",
|
||||
training_type,
|
||||
chrono::Utc::now().format("%Y%m%d_%H%M%S")
|
||||
);
|
||||
let model_id = next_model_id(training_type);
|
||||
let rvf_path = PathBuf::from(MODELS_DIR).join(format!("{model_id}.rvf"));
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
@@ -1407,28 +1463,32 @@ async fn real_training_loop(
|
||||
}),
|
||||
);
|
||||
|
||||
if let Err(e) = builder.write_to_file(&rvf_path) {
|
||||
error!("Failed to write trained model RVF: {e}");
|
||||
} else {
|
||||
info!(
|
||||
"Trained model saved: {} ({} params, pck_torso_h@0.2={:.4})",
|
||||
rvf_path.display(),
|
||||
total_params,
|
||||
best_pck
|
||||
);
|
||||
match builder.write_to_file(&rvf_path) {
|
||||
Err(e) => {
|
||||
error!("Failed to write trained model RVF: {e}");
|
||||
}
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Trained model saved: {} ({} params, pck_torso_h@0.2={:.4})",
|
||||
rvf_path.display(),
|
||||
total_params,
|
||||
best_pck
|
||||
);
|
||||
written_rvf = Some(rvf_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark training as inactive.
|
||||
// Mark training as inactive in the shared status snapshot.
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status.active = false;
|
||||
s.training_state.status.phase = completed_phase.to_string();
|
||||
s.training_state.task_handle = None;
|
||||
let mut st = status.lock().unwrap();
|
||||
st.active = false;
|
||||
st.phase = completed_phase.to_string();
|
||||
}
|
||||
|
||||
info!("Real {training_type} training finished: phase={completed_phase}");
|
||||
written_rvf
|
||||
}
|
||||
|
||||
// ── Public inference function ────────────────────────────────────────────────
|
||||
@@ -1559,56 +1619,151 @@ fn default_keypoints() -> Vec<[f64; 4]> {
|
||||
vec![[320.0, 240.0, 0.0, 0.0]; N_KEYPOINTS]
|
||||
}
|
||||
|
||||
// ── Server-training enablement gate (ADR-186 P5) ─────────────────────────────
|
||||
|
||||
/// Env var that opts a deployment out of in-server training (e.g. the
|
||||
/// lightweight appliance image without recordings). When set truthy, the start
|
||||
/// endpoints return a structured `enabled:false` response pointing at the CLI —
|
||||
/// never a silent `success:true` no-op.
|
||||
const DISABLE_ENV: &str = "RUVIEW_DISABLE_SERVER_TRAINING";
|
||||
|
||||
/// Whether in-server training is enabled for this deployment.
|
||||
fn server_training_enabled() -> bool {
|
||||
training_enabled_from_env(std::env::var(DISABLE_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
/// Pure decision (unit-testable without touching process env): enabled unless
|
||||
/// the flag is a truthy disable value.
|
||||
fn training_enabled_from_env(flag: Option<&str>) -> bool {
|
||||
match flag {
|
||||
Some(v) => {
|
||||
let v = v.trim();
|
||||
!(v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes"))
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured, honest "server training is off for this build — use the CLI"
|
||||
/// response (HTTP 409). Guarantees no silent no-op in the disabled config.
|
||||
fn disabled_response() -> Response {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"enabled": false,
|
||||
"reason": "In-server training is disabled for this deployment.",
|
||||
"cli": "wifi-densepose train-room",
|
||||
// `detail` is surfaced verbatim by the dashboard's API client.
|
||||
"detail": "In-server training is disabled on this build. Train from the CLI: wifi-densepose train-room",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ── Axum handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn start_training(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<StartTrainingRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Check if training is already active.
|
||||
{
|
||||
let s = state.read().await;
|
||||
if s.training_state.status.active {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "Training is already active. Stop it first.",
|
||||
"current_epoch": s.training_state.status.epoch,
|
||||
"total_epochs": s.training_state.status.total_epochs,
|
||||
}));
|
||||
}
|
||||
) -> Response {
|
||||
if !server_training_enabled() {
|
||||
return disabled_response();
|
||||
}
|
||||
|
||||
let config = body.config.clone();
|
||||
let dataset_ids = body.dataset_ids.clone();
|
||||
match spawn_training_job(&state, config, body.dataset_ids.clone(), "supervised").await {
|
||||
Ok(()) => Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "supervised",
|
||||
"dataset_ids": body.dataset_ids,
|
||||
"config": body.config,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(active) => Json(active_error(&active)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// Mark training as active and spawn background task.
|
||||
let progress_tx;
|
||||
{
|
||||
/// Snapshot of the already-running job returned when a start is rejected.
|
||||
fn active_error(snap: &TrainingStatus) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "Training is already active. Stop it first.",
|
||||
"current_epoch": snap.epoch,
|
||||
"total_epochs": snap.total_epochs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed the shared status, snapshot `frame_history`, and spawn the background
|
||||
/// training job. Returns `Err(current_status)` if a job is already active.
|
||||
///
|
||||
/// Centralises the single-job guard + spawn used by the supervised, pretrain,
|
||||
/// and LoRA start handlers so they cannot diverge.
|
||||
/// Atomically claim the single training slot.
|
||||
///
|
||||
/// Checks `active` and sets it `true` **in one `status` lock scope**, so two
|
||||
/// concurrent callers cannot both observe the slot free — the first claims it,
|
||||
/// the second gets `Err(current_status)`. Returns the seeded status on success.
|
||||
///
|
||||
/// This is the fix for a TOCTOU race: the previous code checked `is_active()`
|
||||
/// under a `state` READ lock, released it, and only afterward set `active`.
|
||||
/// A `tokio::RwLock` read lock is shared, so two starts could both hold it, both
|
||||
/// see the slot inactive, both proceed — spawning two jobs that then share and
|
||||
/// overwrite one status/cancel and orphan a task handle. The claim's atomicity
|
||||
/// lives on the `status` mutex, not the coarse `state` lock, which also keeps it
|
||||
/// unit-testable without a full `AppState`.
|
||||
fn claim_training_slot(
|
||||
status: &Mutex<TrainingStatus>,
|
||||
config: &TrainingConfig,
|
||||
) -> Result<(), TrainingStatus> {
|
||||
let mut st = status.lock().unwrap();
|
||||
if st.active {
|
||||
return Err(st.clone());
|
||||
}
|
||||
*st = TrainingStatus {
|
||||
active: true,
|
||||
total_epochs: config.epochs,
|
||||
lr: config.learning_rate,
|
||||
patience_remaining: config.early_stopping_patience,
|
||||
phase: "initializing".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn spawn_training_job(
|
||||
state: &AppState,
|
||||
config: TrainingConfig,
|
||||
dataset_ids: Vec<String>,
|
||||
training_type: &'static str,
|
||||
) -> Result<(), TrainingStatus> {
|
||||
// Grab the shared handles under a read lock; the RwLock is only guarding
|
||||
// access to the Arcs, not the single-job decision.
|
||||
let (progress_tx, status, cancel, history_snapshot) = {
|
||||
let s = state.read().await;
|
||||
progress_tx = s.training_progress_tx.clone();
|
||||
}
|
||||
(
|
||||
s.training_progress_tx.clone(),
|
||||
s.training_state.status.clone(),
|
||||
s.training_state.cancel.clone(),
|
||||
s.frame_history.iter().cloned().collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status = TrainingStatus {
|
||||
active: true,
|
||||
epoch: 0,
|
||||
total_epochs: config.epochs,
|
||||
train_loss: 0.0,
|
||||
val_pck: 0.0,
|
||||
val_oks: 0.0,
|
||||
lr: config.learning_rate,
|
||||
best_pck: 0.0,
|
||||
best_epoch: 0,
|
||||
patience_remaining: config.early_stopping_patience,
|
||||
eta_secs: None,
|
||||
phase: "initializing".to_string(),
|
||||
};
|
||||
}
|
||||
// Atomic check-and-set on the status mutex. This — not the read lock above —
|
||||
// is what serialises concurrent starts (see `claim_training_slot`).
|
||||
claim_training_slot(&status, &config)?;
|
||||
cancel.store(false, Ordering::Relaxed);
|
||||
|
||||
let state_clone = state.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
real_training_loop(state_clone, progress_tx, config, dataset_ids, "supervised").await;
|
||||
run_training_job(
|
||||
status,
|
||||
cancel,
|
||||
progress_tx,
|
||||
config,
|
||||
dataset_ids,
|
||||
history_snapshot,
|
||||
training_type,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
{
|
||||
@@ -1616,57 +1771,58 @@ async fn start_training(
|
||||
s.training_state.task_handle = Some(handle);
|
||||
}
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "supervised",
|
||||
"dataset_ids": body.dataset_ids,
|
||||
"config": body.config,
|
||||
}))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_training(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if !s.training_state.status.active {
|
||||
let s = state.read().await;
|
||||
if !s.training_state.is_active() {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "No training is currently active.",
|
||||
}));
|
||||
}
|
||||
|
||||
s.training_state.status.active = false;
|
||||
s.training_state.status.phase = "stopping".to_string();
|
||||
|
||||
// The background task checks the active flag and will exit.
|
||||
// We do not abort the handle -- we let it finish the current batch gracefully.
|
||||
// Set the cooperative stop flag; the background job observes it between
|
||||
// epochs and exits gracefully after the current batch. We do not abort the
|
||||
// task handle.
|
||||
s.training_state.cancel.store(true, Ordering::Relaxed);
|
||||
{
|
||||
let mut st = s.training_state.status.lock().unwrap();
|
||||
st.phase = "stopping".to_string();
|
||||
}
|
||||
let snap = s.training_state.snapshot();
|
||||
|
||||
info!("Training stop requested");
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "stopping",
|
||||
"epoch": s.training_state.status.epoch,
|
||||
"best_pck": s.training_state.status.best_pck,
|
||||
"epoch": snap.epoch,
|
||||
"best_pck": snap.best_pck,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn training_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::to_value(&s.training_state.status).unwrap_or_default())
|
||||
let mut value = serde_json::to_value(s.training_state.snapshot()).unwrap_or_default();
|
||||
// Surface the enablement flag so the dashboard can honestly disable the
|
||||
// Start button (with a CLI tooltip) without first firing a POST (ADR-186 P5).
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert(
|
||||
"enabled".to_string(),
|
||||
serde_json::Value::Bool(server_training_enabled()),
|
||||
);
|
||||
}
|
||||
Json(value)
|
||||
}
|
||||
|
||||
async fn start_pretrain(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<PretrainRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
{
|
||||
let s = state.read().await;
|
||||
if s.training_state.status.active {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "Training is already active. Stop it first.",
|
||||
}));
|
||||
}
|
||||
) -> Response {
|
||||
if !server_training_enabled() {
|
||||
return disabled_response();
|
||||
}
|
||||
|
||||
let config = TrainingConfig {
|
||||
epochs: body.epochs,
|
||||
learning_rate: body.lr,
|
||||
@@ -1675,56 +1831,26 @@ async fn start_pretrain(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let progress_tx;
|
||||
{
|
||||
let s = state.read().await;
|
||||
progress_tx = s.training_progress_tx.clone();
|
||||
match spawn_training_job(&state, config, body.dataset_ids.clone(), "pretrain").await {
|
||||
Ok(()) => Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "pretrain",
|
||||
"epochs": body.epochs,
|
||||
"lr": body.lr,
|
||||
"dataset_ids": body.dataset_ids,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(active) => Json(active_error(&active)).into_response(),
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status = TrainingStatus {
|
||||
active: true,
|
||||
total_epochs: body.epochs,
|
||||
phase: "initializing".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let state_clone = state.clone();
|
||||
let dataset_ids = body.dataset_ids.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
real_training_loop(state_clone, progress_tx, config, dataset_ids, "pretrain").await;
|
||||
});
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.task_handle = Some(handle);
|
||||
}
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "pretrain",
|
||||
"epochs": body.epochs,
|
||||
"lr": body.lr,
|
||||
"dataset_ids": body.dataset_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn start_lora_training(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoraTrainRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
{
|
||||
let s = state.read().await;
|
||||
if s.training_state.status.active {
|
||||
return Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"message": "Training is already active. Stop it first.",
|
||||
}));
|
||||
}
|
||||
) -> Response {
|
||||
if !server_training_enabled() {
|
||||
return disabled_response();
|
||||
}
|
||||
|
||||
let config = TrainingConfig {
|
||||
epochs: body.epochs,
|
||||
learning_rate: 0.0005, // lower LR for LoRA
|
||||
@@ -1735,42 +1861,19 @@ async fn start_lora_training(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let progress_tx;
|
||||
{
|
||||
let s = state.read().await;
|
||||
progress_tx = s.training_progress_tx.clone();
|
||||
match spawn_training_job(&state, config, body.dataset_ids.clone(), "lora").await {
|
||||
Ok(()) => Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "lora",
|
||||
"base_model_id": body.base_model_id,
|
||||
"profile_name": body.profile_name,
|
||||
"rank": body.rank,
|
||||
"epochs": body.epochs,
|
||||
"dataset_ids": body.dataset_ids,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(active) => Json(active_error(&active)).into_response(),
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.status = TrainingStatus {
|
||||
active: true,
|
||||
total_epochs: body.epochs,
|
||||
phase: "initializing".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let state_clone = state.clone();
|
||||
let dataset_ids = body.dataset_ids.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
real_training_loop(state_clone, progress_tx, config, dataset_ids, "lora").await;
|
||||
});
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.training_state.task_handle = Some(handle);
|
||||
}
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "started",
|
||||
"type": "lora",
|
||||
"base_model_id": body.base_model_id,
|
||||
"profile_name": body.profile_name,
|
||||
"rank": body.rank,
|
||||
"epochs": body.epochs,
|
||||
"dataset_ids": body.dataset_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── WebSocket handler for training progress ──────────────────────────────────
|
||||
@@ -1792,8 +1895,11 @@ async fn handle_train_ws_client(mut socket: WebSocket, state: AppState) {
|
||||
|
||||
// Send current status immediately.
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Ok(json) = serde_json::to_string(&s.training_state.status) {
|
||||
let snapshot = {
|
||||
let s = state.read().await;
|
||||
s.training_state.snapshot()
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||
let msg = serde_json::json!({
|
||||
"type": "status",
|
||||
"data": serde_json::from_str::<serde_json::Value>(&json).unwrap_or_default(),
|
||||
@@ -1869,6 +1975,60 @@ mod tests {
|
||||
assert_eq!(status.phase, "idle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_training_slot_admits_exactly_one_concurrent_start() {
|
||||
// Regression test for the single-job TOCTOU race. Many threads race to
|
||||
// claim one slot at the same instant (a barrier maximises contention);
|
||||
// the status mutex must admit EXACTLY ONE. A split check-then-set (the
|
||||
// old shape) would let several through under load — verified by
|
||||
// temporarily reverting the atomicity, which drops this from 1.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as O};
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
let status = Arc::new(Mutex::new(TrainingStatus::default()));
|
||||
let config = TrainingConfig::default();
|
||||
let winners = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
const N: usize = 32;
|
||||
let barrier = Arc::new(Barrier::new(N));
|
||||
let mut handles = Vec::with_capacity(N);
|
||||
for _ in 0..N {
|
||||
let status = status.clone();
|
||||
let config = config.clone();
|
||||
let winners = winners.clone();
|
||||
let barrier = barrier.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
if claim_training_slot(&status, &config).is_ok() {
|
||||
winners.fetch_add(1, O::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
winners.load(O::SeqCst),
|
||||
1,
|
||||
"exactly one concurrent start may claim the single training slot"
|
||||
);
|
||||
assert!(
|
||||
status.lock().unwrap().active,
|
||||
"the slot must be marked active after a successful claim"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_training_slot_rejects_when_already_active() {
|
||||
let status = Arc::new(Mutex::new(TrainingStatus::default()));
|
||||
let config = TrainingConfig::default();
|
||||
assert!(claim_training_slot(&status, &config).is_ok(), "first claim wins");
|
||||
let err = claim_training_slot(&status, &config)
|
||||
.expect_err("second claim must be refused while active");
|
||||
assert!(err.active, "the rejection carries the active status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_progress_serializes() {
|
||||
let progress = TrainingProgress {
|
||||
@@ -2132,4 +2292,169 @@ mod tests {
|
||||
assert_eq!(parsed.n_features, 2);
|
||||
assert_eq!(parsed.mean, vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
/// Build a small deterministic set of synthetic CSI frames with enough
|
||||
/// variation that feature extraction is non-degenerate.
|
||||
fn synthetic_history(n: usize, n_sub: usize) -> Vec<Vec<f64>> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
(0..n_sub)
|
||||
.map(|k| 10.0 + ((i as f64) * 0.3 + (k as f64) * 0.1).sin() * 2.0)
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// ADR-186 P3/P6 end-to-end: the real (state-free) training core must
|
||||
/// (a) stream real progress events over the broadcast channel and
|
||||
/// (b) actually write a `.rvf` model artifact on completion — not merely
|
||||
/// flip a status flag. This is the regression guard that keeps the trainer
|
||||
/// wired (the module was previously orphaned / uncompiled — ADR-186 §1.3).
|
||||
#[tokio::test]
|
||||
async fn training_job_streams_real_progress_and_writes_model() {
|
||||
let history = synthetic_history(40, 56);
|
||||
|
||||
let (tx, mut rx) = broadcast::channel::<String>(1024);
|
||||
let status = Arc::new(Mutex::new(TrainingStatus::default()));
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let config = TrainingConfig {
|
||||
epochs: 3,
|
||||
batch_size: 8,
|
||||
warmup_epochs: 1,
|
||||
early_stopping_patience: 10,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Empty dataset_ids → falls back to the in-memory history snapshot, so
|
||||
// this test does not depend on the recordings directory.
|
||||
let rvf = run_training_job(
|
||||
status.clone(),
|
||||
cancel,
|
||||
tx,
|
||||
config,
|
||||
Vec::new(),
|
||||
history,
|
||||
"supervised",
|
||||
)
|
||||
.await;
|
||||
|
||||
// (b) A real model artifact was produced and exists on disk.
|
||||
let rvf_path = rvf.expect("training must produce an .rvf model artifact");
|
||||
assert!(
|
||||
rvf_path.exists(),
|
||||
"rvf artifact should exist at {}",
|
||||
rvf_path.display()
|
||||
);
|
||||
|
||||
// (a) Real progress frames were streamed, at least one carrying an epoch.
|
||||
let mut n_frames = 0usize;
|
||||
let mut saw_epoch = false;
|
||||
let mut saw_completed = false;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
n_frames += 1;
|
||||
let v: serde_json::Value = serde_json::from_str(&msg).unwrap();
|
||||
if v.get("epoch").and_then(|e| e.as_u64()).unwrap_or(0) >= 1 {
|
||||
saw_epoch = true;
|
||||
}
|
||||
if v.get("phase").and_then(|p| p.as_str()) == Some("completed") {
|
||||
saw_completed = true;
|
||||
}
|
||||
}
|
||||
assert!(n_frames > 0, "expected streamed progress frames, got none");
|
||||
assert!(saw_epoch, "expected at least one epoch-tagged progress frame");
|
||||
assert!(saw_completed, "expected a terminal 'completed' progress frame");
|
||||
|
||||
// Final shared status reflects genuine completion, not just a flag flip:
|
||||
// real epochs ran (the loop wrote per-epoch status) and a finite loss was
|
||||
// computed from the real gradient-descent pass.
|
||||
let final_status = status.lock().unwrap().clone();
|
||||
assert!(!final_status.active, "job should be inactive when finished");
|
||||
assert_eq!(final_status.phase, "completed");
|
||||
assert!(
|
||||
final_status.epoch >= 1,
|
||||
"at least one real training epoch should have run"
|
||||
);
|
||||
assert!(
|
||||
final_status.train_loss.is_finite(),
|
||||
"a finite training loss should have been computed"
|
||||
);
|
||||
|
||||
// Keep the test hermetic — remove the artifact it wrote.
|
||||
let _ = std::fs::remove_file(&rvf_path);
|
||||
}
|
||||
|
||||
/// ADR-186 P4 (path safety): a `dataset_id` containing directory traversal
|
||||
/// is rejected before any file is opened, so the loader returns no frames
|
||||
/// rather than reading an arbitrary file.
|
||||
#[tokio::test]
|
||||
async fn load_recording_frames_rejects_path_traversal() {
|
||||
let frames = load_recording_frames(&["../../etc/passwd".to_string()]).await;
|
||||
assert!(
|
||||
frames.is_empty(),
|
||||
"path-traversal dataset_id must yield no frames"
|
||||
);
|
||||
}
|
||||
|
||||
/// Exported model ids must be unique per call — a second-resolution
|
||||
/// timestamp alone collided for runs finishing in the same wall-clock second
|
||||
/// (silently overwriting each other's `.rvf`, which also flaked the
|
||||
/// concurrent model-writing tests on CI). Guards against regressing the
|
||||
/// filename scheme back to non-unique.
|
||||
#[test]
|
||||
fn model_ids_are_unique_per_call() {
|
||||
let ids: Vec<String> = (0..1000).map(|_| next_model_id("supervised")).collect();
|
||||
let unique: std::collections::HashSet<&String> = ids.iter().collect();
|
||||
assert_eq!(unique.len(), ids.len(), "every model id must be distinct");
|
||||
assert!(ids[0].starts_with("trained-supervised-"));
|
||||
}
|
||||
|
||||
/// ADR-186 P5: the enablement gate is enabled by default and only disabled
|
||||
/// by an explicit truthy opt-out, so a `--no-default-features` / default
|
||||
/// build always has server training ON (no silent regression to disabled).
|
||||
#[test]
|
||||
fn training_enablement_gate() {
|
||||
assert!(training_enabled_from_env(None), "default is enabled");
|
||||
assert!(training_enabled_from_env(Some("0")), "0 keeps it enabled");
|
||||
assert!(training_enabled_from_env(Some("")), "empty keeps it enabled");
|
||||
assert!(!training_enabled_from_env(Some("1")), "1 disables");
|
||||
assert!(!training_enabled_from_env(Some("true")), "true disables");
|
||||
assert!(!training_enabled_from_env(Some("YES")), "case-insensitive");
|
||||
assert!(!training_enabled_from_env(Some(" 1 ")), "trims whitespace");
|
||||
}
|
||||
|
||||
/// A job that is cancelled before it starts still exits cleanly and reports
|
||||
/// the `cancelled` terminal phase (drives `stop_training`'s cooperative flag).
|
||||
#[tokio::test]
|
||||
async fn training_job_honors_cancellation() {
|
||||
let history = synthetic_history(40, 56);
|
||||
let (tx, _rx) = broadcast::channel::<String>(1024);
|
||||
let status = Arc::new(Mutex::new(TrainingStatus::default()));
|
||||
let cancel = Arc::new(AtomicBool::new(true)); // pre-cancelled
|
||||
|
||||
let config = TrainingConfig {
|
||||
epochs: 50,
|
||||
batch_size: 8,
|
||||
warmup_epochs: 1,
|
||||
early_stopping_patience: 10,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let rvf = run_training_job(
|
||||
status.clone(),
|
||||
cancel,
|
||||
tx,
|
||||
config,
|
||||
Vec::new(),
|
||||
history,
|
||||
"supervised",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Cancelled before the first epoch → no model, terminal phase cancelled.
|
||||
assert!(rvf.is_none(), "cancelled run should not export a model");
|
||||
let final_status = status.lock().unwrap().clone();
|
||||
assert!(!final_status.active);
|
||||
assert_eq!(final_status.phase, "cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user