mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +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:
Generated
+9
-1
@@ -11017,6 +11017,13 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-aether"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.1"
|
||||
@@ -11318,11 +11325,13 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq 2.12.1",
|
||||
"wifi-densepose-aether",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-engine",
|
||||
"wifi-densepose-geo",
|
||||
@@ -11394,7 +11403,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"walkdir",
|
||||
"wifi-densepose-nn",
|
||||
"wifi-densepose-signal",
|
||||
]
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ members = [
|
||||
"crates/wifi-densepose-mat",
|
||||
"crates/wifi-densepose-train",
|
||||
"crates/wifi-densepose-sensing-server",
|
||||
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
|
||||
"crates/wifi-densepose-wifiscan",
|
||||
"crates/wifi-densepose-vitals",
|
||||
"crates/wifi-densepose-ruvector",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "wifi-densepose-aether"
|
||||
description = "AETHER pure-compute stack (ADR-024): contrastive CSI embedding, CSI-to-pose transformer, SONA drift/LoRA, and quantization — std-only, no async/server deps so the Python `[aether]` wheel stays lean (ADR-185 §3.2)"
|
||||
version = "0.3.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
# Intentionally dependency-free: this crate is the leaf hoisted out of
|
||||
# `wifi-densepose-sensing-server` (ADR-185 §13) precisely so that binding it into
|
||||
# the `wifi_densepose[aether]` wheel does not pull the Axum/tokio/worldgraph/
|
||||
# ruvector server tree. Keep it std-only.
|
||||
[dependencies]
|
||||
|
||||
# Test-only: parses the committed golden fixtures shared with the Python parity
|
||||
# tests. Never linked into the library or the wheel.
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
[lib]
|
||||
name = "wifi_densepose_aether"
|
||||
path = "src/lib.rs"
|
||||
+143
@@ -822,8 +822,73 @@ impl EmbeddingExtractor {
|
||||
self.projection = proj;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize all weights (transformer + projection) to `path`.
|
||||
///
|
||||
/// Format (little-endian, zero-dep so the std-only leaf crate stays
|
||||
/// dependency-free): 8-byte magic `AETHERW1`, then a `u32` parameter
|
||||
/// count, then that many `f32` values — exactly `flatten_weights()`.
|
||||
///
|
||||
/// This is the counterpart of [`Self::load_weights`]. It enables loading a
|
||||
/// *real trained* checkpoint once one exists (ADR-185 §13.a); it does not
|
||||
/// itself make the default (random-init) extractor trained.
|
||||
pub fn save_weights<P: AsRef<std::path::Path>>(&self, path: P) -> std::io::Result<()> {
|
||||
let weights = self.flatten_weights();
|
||||
let mut buf = Vec::with_capacity(WEIGHT_HEADER_LEN + weights.len() * 4);
|
||||
buf.extend_from_slice(WEIGHT_MAGIC);
|
||||
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
|
||||
for v in &weights {
|
||||
buf.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
std::fs::write(path, buf)
|
||||
}
|
||||
|
||||
/// Load weights previously written by [`Self::save_weights`] (or any file in
|
||||
/// that format) into this extractor, replacing the current (random-init or
|
||||
/// prior) weights.
|
||||
///
|
||||
/// Errors (never panics) on: unreadable file, a payload shorter than the
|
||||
/// header, a wrong magic, a truncated/oversized payload, or a parameter
|
||||
/// count that does not match this extractor's architecture (delegated to
|
||||
/// [`Self::unflatten_weights`]).
|
||||
pub fn load_weights<P: AsRef<std::path::Path>>(&mut self, path: P) -> Result<(), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("failed to read weight file: {e}"))?;
|
||||
if bytes.len() < WEIGHT_HEADER_LEN {
|
||||
return Err(format!(
|
||||
"weight file too short: {} bytes < {WEIGHT_HEADER_LEN}-byte header",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
if &bytes[0..8] != WEIGHT_MAGIC {
|
||||
return Err("bad magic: not an AETHER weight file (expected 'AETHERW1')".to_string());
|
||||
}
|
||||
let count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
|
||||
let expected_len = WEIGHT_HEADER_LEN + count * 4;
|
||||
if bytes.len() != expected_len {
|
||||
return Err(format!(
|
||||
"weight payload size mismatch: header declares {count} params ({expected_len} bytes), file is {} bytes",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let mut weights = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = WEIGHT_HEADER_LEN + i * 4;
|
||||
weights.push(f32::from_le_bytes([
|
||||
bytes[o],
|
||||
bytes[o + 1],
|
||||
bytes[o + 2],
|
||||
bytes[o + 3],
|
||||
]));
|
||||
}
|
||||
self.unflatten_weights(&weights)
|
||||
}
|
||||
}
|
||||
|
||||
/// Magic prefix for AETHER weight files (see [`EmbeddingExtractor::save_weights`]).
|
||||
const WEIGHT_MAGIC: &[u8; 8] = b"AETHERW1";
|
||||
/// 8-byte magic + 4-byte `u32` param count.
|
||||
const WEIGHT_HEADER_LEN: usize = 12;
|
||||
|
||||
// ── CSI feature statistics ─────────────────────────────────────────────────
|
||||
|
||||
/// Compute mean and variance of all values in a CSI feature matrix.
|
||||
@@ -1219,6 +1284,84 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weight save/load (ADR-185 §13.a) ────────────────────────────────
|
||||
|
||||
/// Deterministic, non-random weight pattern. Values are `k/65536 - 0.5`
|
||||
/// with `k ∈ [0, 65535]`, i.e. multiples of 2⁻¹⁶ — exactly representable
|
||||
/// in both f32 and f64 so a cross-language (Rust ↔ Python) fixture using
|
||||
/// the same formula produces byte-identical weights.
|
||||
fn deterministic_weights(n: usize) -> Vec<f32> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let k = (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345) % 65_536;
|
||||
k as f32 / 65_536.0 - 0.5
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_weights_actually_replaces_weights_and_round_trips() {
|
||||
let mut ext = EmbeddingExtractor::new(small_config(), small_embed_config());
|
||||
let csi = make_csi(4, 16, 42);
|
||||
let baseline = ext.extract(&csi); // random Xavier init
|
||||
|
||||
// Build a source extractor with deterministic non-default weights and
|
||||
// serialize it.
|
||||
let det = deterministic_weights(ext.param_count());
|
||||
let mut src = EmbeddingExtractor::new(small_config(), small_embed_config());
|
||||
src.unflatten_weights(&det).unwrap();
|
||||
let src_emb = src.extract(&csi);
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("aether_wtest_{}_{:p}.bin", std::process::id(), &ext));
|
||||
src.save_weights(&path).unwrap();
|
||||
|
||||
// Load into the random-init extractor.
|
||||
ext.load_weights(&path).unwrap();
|
||||
let loaded_emb = ext.extract(&csi);
|
||||
|
||||
// (1) The loaded weights are ACTUALLY used — output moved away from the
|
||||
// random-init baseline (proves load is not a silent no-op).
|
||||
let differs = baseline
|
||||
.iter()
|
||||
.zip(&loaded_emb)
|
||||
.any(|(a, b)| (a - b).abs() > 1e-6);
|
||||
assert!(
|
||||
differs,
|
||||
"load_weights had no effect: embedding still equals the random-init baseline"
|
||||
);
|
||||
|
||||
// (2) It matches the source extractor whose weights we saved (round-trip).
|
||||
for (a, b) in src_emb.iter().zip(&loaded_emb) {
|
||||
assert!((a - b).abs() < 1e-6, "loaded embedding != source: {a} vs {b}");
|
||||
}
|
||||
|
||||
// (3) The weights are bit-identical after the file round-trip.
|
||||
assert_eq!(ext.flatten_weights(), det);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_weights_rejects_bad_magic_and_wrong_count() {
|
||||
let mut ext = EmbeddingExtractor::new(small_config(), small_embed_config());
|
||||
let base = std::env::temp_dir().join(format!("aether_wbad_{}.bin", std::process::id()));
|
||||
|
||||
// Bad magic.
|
||||
std::fs::write(&base, b"NOPEMAGIC\x00\x00\x00").unwrap();
|
||||
assert!(ext.load_weights(&base).is_err());
|
||||
|
||||
// Right magic, wrong param count for this architecture.
|
||||
let mut bad = Vec::new();
|
||||
bad.extend_from_slice(WEIGHT_MAGIC);
|
||||
bad.extend_from_slice(&3u32.to_le_bytes());
|
||||
bad.extend_from_slice(&[0u8; 12]); // 3 f32s — won't match param_count
|
||||
std::fs::write(&base, &bad).unwrap();
|
||||
assert!(ext.load_weights(&base).is_err());
|
||||
|
||||
std::fs::remove_file(&base).ok();
|
||||
}
|
||||
|
||||
// ── FingerprintIndex tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -0,0 +1,28 @@
|
||||
//! AETHER pure-compute stack (ADR-024 / ADR-185 §3.2).
|
||||
//!
|
||||
//! This crate is the dependency-free leaf hoisted out of
|
||||
//! `wifi-densepose-sensing-server` so that the Python `wifi_densepose[aether]`
|
||||
//! wheel can bind the contrastive-embedding surface without linking the server's
|
||||
//! Axum / tokio / worldgraph / ruvector tree (which blew the ADR-117 §5.4 ≤5 MB
|
||||
//! wheel budget).
|
||||
//!
|
||||
//! Modules:
|
||||
//! - [`embedding`] — AETHER contrastive CSI embedding: `EmbeddingConfig`,
|
||||
//! `EmbeddingExtractor`, `ProjectionHead`, `CsiAugmenter`, `info_nce_loss`,
|
||||
//! fingerprint indices.
|
||||
//! - [`graph_transformer`] — CSI-to-pose transformer primitives
|
||||
//! (`CsiToPoseTransformer`, `TransformerConfig`, `Linear`).
|
||||
//! - [`sona`] — self-organizing drift detection + LoRA adaptation + EWC.
|
||||
//! - [`sparse_inference`] — quantization helpers used by the embedding path.
|
||||
//!
|
||||
//! `wifi-densepose-sensing-server` re-exports these modules so its own code and
|
||||
//! public API are unchanged.
|
||||
|
||||
// `embedding` carries a couple of not-yet-read fields (e.g. `PoseEncoder.d_proj`);
|
||||
// this mirrors the `#[allow(dead_code)]` the module had at its previous home in
|
||||
// `wifi-densepose-sensing-server`.
|
||||
#[allow(dead_code)]
|
||||
pub mod embedding;
|
||||
pub mod graph_transformer;
|
||||
pub mod sona;
|
||||
pub mod sparse_inference;
|
||||
@@ -0,0 +1,115 @@
|
||||
//! ADR-185 §4.1 — NATIVE half of the AETHER parity gate, and the half that runs
|
||||
//! in CI.
|
||||
//!
|
||||
//! The committed golden vectors live under `python/tests/golden/` and are
|
||||
//! shared with `python/tests/test_aether.py` (the binding half). That pytest
|
||||
//! runs in python-ci; the native reference tests in `python/tests/*.rs` link
|
||||
//! against the PyO3 crate and are NOT run by any workflow. This test closes that
|
||||
//! gap: it recomputes the embedding through THIS std-only crate — no PyO3, no
|
||||
//! marshalling — and asserts it matches the same golden within tolerance.
|
||||
//!
|
||||
//! Together with the pytest half: native≈golden AND binding≈golden ⇒
|
||||
//! binding≈native, portably. And because this side has no marshalling, a
|
||||
//! binding-specific defect baked into the golden would surface here as a native
|
||||
//! mismatch — which is the failure mode a binding-derived golden + pytest-only
|
||||
//! CI would otherwise hide.
|
||||
//!
|
||||
//! Runs under the repo's `cargo test --workspace` (this crate is a member).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wifi_densepose_aether::embedding::{EmbeddingConfig, EmbeddingExtractor};
|
||||
use wifi_densepose_aether::graph_transformer::TransformerConfig;
|
||||
|
||||
// Same tolerance as the Python and .rs parity tests. f32 + transcendentals are
|
||||
// not bit-reproducible across arch, so the golden (generated on one machine) is
|
||||
// compared within a bound, not by hash.
|
||||
const PARITY_ATOL: f32 = 1e-4;
|
||||
const PARITY_RTOL: f32 = 1e-4;
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
// This crate lives at v2/crates/wifi-densepose-aether; the shared golden
|
||||
// fixtures are the single source of truth under python/tests/golden.
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../python/tests/golden")
|
||||
}
|
||||
|
||||
fn read_vec(name: &str) -> Vec<f32> {
|
||||
let raw = std::fs::read_to_string(golden_dir().join(name))
|
||||
.unwrap_or_else(|e| panic!("read {name}: {e}"));
|
||||
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {name}: {e}"))
|
||||
}
|
||||
|
||||
fn load_input() -> Vec<Vec<f32>> {
|
||||
let raw = std::fs::read_to_string(golden_dir().join("aether_input.json"))
|
||||
.expect("read aether_input.json");
|
||||
let rows: Vec<Vec<f64>> = serde_json::from_str(&raw).expect("parse aether_input.json");
|
||||
rows.into_iter()
|
||||
.map(|r| r.into_iter().map(|x| x as f32).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extractor() -> EmbeddingExtractor {
|
||||
let e = EmbeddingConfig { d_model: 64, d_proj: 128, temperature: 0.07, normalize: true };
|
||||
let t = TransformerConfig {
|
||||
n_subcarriers: 56,
|
||||
n_keypoints: 17,
|
||||
d_model: 64,
|
||||
n_heads: 4,
|
||||
n_gnn_layers: 2,
|
||||
};
|
||||
EmbeddingExtractor::new(t, e)
|
||||
}
|
||||
|
||||
fn formula_weights(n: usize) -> Vec<f32> {
|
||||
(0..n)
|
||||
.map(|i| ((i as u64 * 1103515245 + 12345) % 65536) as f32 / 65536.0 - 0.5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_matches_golden(embedding: &[f32], name: &str) {
|
||||
let golden = read_vec(name);
|
||||
assert_eq!(embedding.len(), golden.len(), "{name}: length mismatch");
|
||||
for (i, (&a, &b)) in embedding.iter().zip(&golden).enumerate() {
|
||||
assert!(a.is_finite(), "{name}: element {i} is not finite ({a})");
|
||||
let tol = PARITY_ATOL + PARITY_RTOL * b.abs();
|
||||
assert!(
|
||||
(a - b).abs() <= tol,
|
||||
"{name}: element {i} diverged beyond tolerance \
|
||||
(got {a}, golden {b}, |Δ|={}) — real regression, not arch drift",
|
||||
(a - b).abs()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_base_embedding_matches_committed_golden() {
|
||||
let emb = extractor().extract(&load_input());
|
||||
assert_matches_golden(&emb, "aether_embedding.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_loaded_embedding_matches_committed_golden() {
|
||||
let input = load_input();
|
||||
let mut ext = extractor();
|
||||
let baseline = ext.extract(&input);
|
||||
|
||||
let weights = formula_weights(ext.param_count());
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(b"AETHERW1");
|
||||
buf.extend_from_slice(&(weights.len() as u32).to_le_bytes());
|
||||
for w in &weights {
|
||||
buf.extend_from_slice(&w.to_le_bytes());
|
||||
}
|
||||
let wpath = std::env::temp_dir().join(format!("aether_golden_parity_{}.bin", std::process::id()));
|
||||
std::fs::write(&wpath, &buf).expect("write weights");
|
||||
ext.load_weights(&wpath).expect("load_weights");
|
||||
let _ = std::fs::remove_file(&wpath);
|
||||
|
||||
let loaded = ext.extract(&input);
|
||||
assert!(
|
||||
baseline.iter().zip(&loaded).any(|(a, b)| (a - b).abs() > 1e-6),
|
||||
"load_weights had no effect vs the random-init baseline"
|
||||
);
|
||||
assert_matches_golden(&loaded, "aether_loaded_embedding.json");
|
||||
}
|
||||
@@ -12,15 +12,25 @@ categories = ["science", "algorithms"]
|
||||
readme = "README.md"
|
||||
|
||||
[features]
|
||||
default = ["std", "api", "ruvector"]
|
||||
default = ["std", "api", "ruvector", "ml"]
|
||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||
std = []
|
||||
# ONNX-backed ML detection (debris + vital-signs classifiers). Pulls
|
||||
# `wifi-densepose-nn` (and, via its default `onnx` feature, the `ort`
|
||||
# ONNX Runtime + its download/reqwest stack) ONLY when enabled. The
|
||||
# survivor-detection/triage pipeline works without it (ML is an optional
|
||||
# enhancement, off unless `DetectionConfig::enable_ml`), so consumers that
|
||||
# don't need ONNX — e.g. the ADR-185 `wifi-densepose-py` `[mat]` wheel —
|
||||
# build `--no-default-features` and drop the entire ort/reqwest tree,
|
||||
# keeping the wheel within the ADR-117 §5.4 budget.
|
||||
ml = ["dep:wifi-densepose-nn"]
|
||||
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
|
||||
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
|
||||
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
|
||||
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
|
||||
# the dependency but left every `feature = "serde"` cfg dead).
|
||||
api = ["serde", "dep:axum", "dep:futures-util"]
|
||||
# The REST surface exposes ML status (`ml_ready`), so `api` implies `ml`.
|
||||
api = ["ml", "serde", "dep:axum", "dep:futures-util"]
|
||||
# Real ESP32 serial CSI ingest. Pulls the native `serialport` crate (libudev on
|
||||
# Linux) only when enabled, so the default/no-default appliance build stays free
|
||||
# of native serial deps. With the feature OFF, the ESP32 serial *parser* still
|
||||
@@ -36,14 +46,18 @@ serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
# Workspace dependencies
|
||||
wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" }
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
|
||||
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
|
||||
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn", optional = true }
|
||||
ruvector-solver = { workspace = true, optional = true }
|
||||
ruvector-temporal-tensor = { workspace = true, optional = true }
|
||||
|
||||
# Async runtime — required by the core integration layer (UDP CSI receiver,
|
||||
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
|
||||
# the REST API, so it is deliberately NOT gated behind `api`.
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
|
||||
# `macros` is needed by `tokio::select!` in integration/hardware_adapter.rs.
|
||||
# It was previously satisfied only by feature-unification from the (now
|
||||
# optional) `wifi-densepose-nn` dep; declare it explicitly so a
|
||||
# `--no-default-features` build (the ADR-185 [mat] wheel) still compiles.
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time", "macros"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Web framework (REST API) — only compiled with the `api` feature.
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::{
|
||||
MovementClassifier, MovementClassifierConfig,
|
||||
};
|
||||
use crate::domain::{ScanZone, VitalSignsReading};
|
||||
#[cfg(feature = "ml")]
|
||||
use crate::ml::{MlDetectionConfig, MlDetectionPipeline, MlDetectionResult};
|
||||
use crate::{DisasterConfig, MatError};
|
||||
|
||||
@@ -26,9 +27,10 @@ pub struct DetectionConfig {
|
||||
pub enable_heartbeat: bool,
|
||||
/// Minimum overall confidence to report detection
|
||||
pub min_confidence: f64,
|
||||
/// Enable ML-enhanced detection
|
||||
/// Enable ML-enhanced detection (requires the `ml` feature to have any effect)
|
||||
pub enable_ml: bool,
|
||||
/// ML detection configuration (if enabled)
|
||||
#[cfg(feature = "ml")]
|
||||
pub ml_config: Option<MlDetectionConfig>,
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ impl Default for DetectionConfig {
|
||||
enable_heartbeat: false,
|
||||
min_confidence: 0.3,
|
||||
enable_ml: false,
|
||||
#[cfg(feature = "ml")]
|
||||
ml_config: None,
|
||||
}
|
||||
}
|
||||
@@ -64,6 +67,7 @@ impl DetectionConfig {
|
||||
}
|
||||
|
||||
/// Enable ML-enhanced detection with the given configuration
|
||||
#[cfg(feature = "ml")]
|
||||
pub fn with_ml(mut self, ml_config: MlDetectionConfig) -> Self {
|
||||
self.enable_ml = true;
|
||||
self.ml_config = Some(ml_config);
|
||||
@@ -71,6 +75,7 @@ impl DetectionConfig {
|
||||
}
|
||||
|
||||
/// Enable ML-enhanced detection with default configuration
|
||||
#[cfg(feature = "ml")]
|
||||
pub fn with_default_ml(mut self) -> Self {
|
||||
self.enable_ml = true;
|
||||
self.ml_config = Some(MlDetectionConfig::default());
|
||||
@@ -147,12 +152,14 @@ pub struct DetectionPipeline {
|
||||
movement_classifier: MovementClassifier,
|
||||
data_buffer: parking_lot::RwLock<CsiDataBuffer>,
|
||||
/// Optional ML detection pipeline
|
||||
#[cfg(feature = "ml")]
|
||||
ml_pipeline: Option<MlDetectionPipeline>,
|
||||
}
|
||||
|
||||
impl DetectionPipeline {
|
||||
/// Create a new detection pipeline
|
||||
pub fn new(config: DetectionConfig) -> Self {
|
||||
#[cfg(feature = "ml")]
|
||||
let ml_pipeline = if config.enable_ml {
|
||||
config.ml_config.clone().map(MlDetectionPipeline::new)
|
||||
} else {
|
||||
@@ -164,12 +171,14 @@ impl DetectionPipeline {
|
||||
heartbeat_detector: HeartbeatDetector::new(config.heartbeat.clone()),
|
||||
movement_classifier: MovementClassifier::new(config.movement.clone()),
|
||||
data_buffer: parking_lot::RwLock::new(CsiDataBuffer::new(config.sample_rate)),
|
||||
#[cfg(feature = "ml")]
|
||||
ml_pipeline,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize ML models asynchronously (if enabled)
|
||||
#[cfg(feature = "ml")]
|
||||
pub async fn initialize_ml(&mut self) -> Result<(), MatError> {
|
||||
if let Some(ref mut ml) = self.ml_pipeline {
|
||||
ml.initialize().await.map_err(MatError::from)?;
|
||||
@@ -178,6 +187,7 @@ impl DetectionPipeline {
|
||||
}
|
||||
|
||||
/// Check if ML pipeline is ready
|
||||
#[cfg(feature = "ml")]
|
||||
pub fn ml_ready(&self) -> bool {
|
||||
self.ml_pipeline.as_ref().is_none_or(|ml| ml.is_ready())
|
||||
}
|
||||
@@ -210,13 +220,23 @@ impl DetectionPipeline {
|
||||
// `buffer` guard dropped here
|
||||
};
|
||||
|
||||
// If ML is enabled and ready, enhance with ML predictions
|
||||
let enhanced_reading = if self.config.enable_ml && self.ml_ready() {
|
||||
// Snapshot the buffer under the lock, then drop the guard before await.
|
||||
let buffer_snapshot = { self.data_buffer.read().clone() };
|
||||
self.enhance_with_ml(reading, &buffer_snapshot).await?
|
||||
} else {
|
||||
reading
|
||||
// If ML is enabled and ready, enhance with ML predictions (only
|
||||
// compiled under the `ml` feature; the base build is signal-only).
|
||||
let enhanced_reading = {
|
||||
#[cfg(feature = "ml")]
|
||||
{
|
||||
if self.config.enable_ml && self.ml_ready() {
|
||||
// Snapshot the buffer under the lock, then drop the guard before await.
|
||||
let buffer_snapshot = { self.data_buffer.read().clone() };
|
||||
self.enhance_with_ml(reading, &buffer_snapshot).await?
|
||||
} else {
|
||||
reading
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "ml"))]
|
||||
{
|
||||
reading
|
||||
}
|
||||
};
|
||||
|
||||
// Check minimum confidence
|
||||
@@ -230,6 +250,7 @@ impl DetectionPipeline {
|
||||
}
|
||||
|
||||
/// Enhance detection results with ML predictions
|
||||
#[cfg(feature = "ml")]
|
||||
async fn enhance_with_ml(
|
||||
&self,
|
||||
traditional_reading: Option<VitalSignsReading>,
|
||||
@@ -262,6 +283,7 @@ impl DetectionPipeline {
|
||||
}
|
||||
|
||||
/// Get the latest ML detection results (if ML is enabled)
|
||||
#[cfg(feature = "ml")]
|
||||
pub async fn get_ml_results(&self) -> Option<MlDetectionResult> {
|
||||
let ml = match &self.ml_pipeline {
|
||||
Some(ml) => ml,
|
||||
@@ -346,6 +368,7 @@ impl DetectionPipeline {
|
||||
self.movement_classifier = MovementClassifier::new(config.movement.clone());
|
||||
|
||||
// Update ML pipeline if configuration changed
|
||||
#[cfg(feature = "ml")]
|
||||
if config.enable_ml != self.config.enable_ml || config.ml_config != self.config.ml_config {
|
||||
self.ml_pipeline = if config.enable_ml {
|
||||
config.ml_config.clone().map(MlDetectionPipeline::new)
|
||||
@@ -358,6 +381,7 @@ impl DetectionPipeline {
|
||||
}
|
||||
|
||||
/// Get the ML pipeline (if enabled)
|
||||
#[cfg(feature = "ml")]
|
||||
pub fn ml_pipeline(&self) -> Option<&MlDetectionPipeline> {
|
||||
self.ml_pipeline.as_ref()
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ pub mod detection;
|
||||
pub mod domain;
|
||||
pub mod integration;
|
||||
pub mod localization;
|
||||
/// ONNX-backed ML detection. Requires the `ml` feature (pulls
|
||||
/// `wifi-densepose-nn` + `ort`). The core survivor-detection/triage
|
||||
/// pipeline works without it.
|
||||
#[cfg(feature = "ml")]
|
||||
pub mod ml;
|
||||
pub mod tracking;
|
||||
|
||||
@@ -130,6 +134,7 @@ pub use integration::{
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub use api::{create_router, AppState};
|
||||
|
||||
#[cfg(feature = "ml")]
|
||||
pub use ml::{
|
||||
AttenuationPrediction,
|
||||
BreathingClassification,
|
||||
@@ -207,6 +212,7 @@ pub enum MatError {
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// Machine learning error
|
||||
#[cfg(feature = "ml")]
|
||||
#[error("ML error: {0}")]
|
||||
Ml(#[from] ml::MlError),
|
||||
}
|
||||
@@ -592,8 +598,6 @@ pub mod prelude {
|
||||
AssociationResult,
|
||||
BreathingPattern,
|
||||
Coordinates3D,
|
||||
DebrisClassification,
|
||||
DebrisModel,
|
||||
DetectionEvent,
|
||||
DetectionObservation,
|
||||
// Detection
|
||||
@@ -614,11 +618,6 @@ pub mod prelude {
|
||||
// Localization
|
||||
LocalizationService,
|
||||
MatError,
|
||||
MaterialType,
|
||||
// ML types
|
||||
MlDetectionConfig,
|
||||
MlDetectionPipeline,
|
||||
MlDetectionResult,
|
||||
Priority,
|
||||
Result,
|
||||
ScanZone,
|
||||
@@ -631,12 +630,17 @@ pub mod prelude {
|
||||
TrackerConfig,
|
||||
TrackingEvent,
|
||||
TriageStatus,
|
||||
UncertaintyEstimate,
|
||||
VitalSignsClassifier,
|
||||
VitalSignsDetector,
|
||||
VitalSignsReading,
|
||||
ZoneBounds,
|
||||
};
|
||||
|
||||
// ONNX-backed ML types — only when the `ml` feature is enabled.
|
||||
#[cfg(feature = "ml")]
|
||||
pub use crate::{
|
||||
DebrisClassification, DebrisModel, MaterialType, MlDetectionConfig, MlDetectionPipeline,
|
||||
MlDetectionResult, UncertaintyEstimate, VitalSignsClassifier,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -41,6 +41,12 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
# CLI
|
||||
clap = { workspace = true }
|
||||
|
||||
# ADR-185 §3.2/§13: AETHER pure-compute stack (embedding / graph_transformer /
|
||||
# sona / sparse_inference), hoisted into a std-only leaf crate and re-exported
|
||||
# from `lib.rs` so the Python `[aether]` wheel can bind it without this server's
|
||||
# Axum/tokio/worldgraph/ruvector tree.
|
||||
wifi-densepose-aether = { version = "0.3.0", path = "../wifi-densepose-aether" }
|
||||
|
||||
# Multi-BSSID WiFi scanning pipeline (ADR-022 Phase 3)
|
||||
wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifiscan" }
|
||||
|
||||
@@ -120,6 +126,10 @@ matter = []
|
||||
tempfile = "3.10"
|
||||
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).
|
||||
tower = { workspace = true }
|
||||
# ADR-186 P6 — real-socket WebSocket client for the `/ws/train/progress`
|
||||
# 101-upgrade + live-progress-frame test. Pinned to the version already resolved
|
||||
# in the workspace lock (via homecore-api) so this adds no new lock entry.
|
||||
tokio-tungstenite = "0.24"
|
||||
# ADR-115 P9 — micro-benchmarks for MQTT hot paths + semantic bus.
|
||||
# Heavy dep tree (~80 transitive crates) so it's dev-only; benches live
|
||||
# behind --features mqtt because they bench the mqtt module.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,13 @@ cuda = ["tch-backend"]
|
||||
[dependencies]
|
||||
# Internal crates
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
|
||||
wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
|
||||
# NOTE: `wifi-densepose-nn` was declared here but never imported anywhere in
|
||||
# this crate's src/ or bin/ (the tch-backend model path uses `tch` directly,
|
||||
# not this crate). It was a dead dependency that pulled `ort` (ONNX Runtime) +
|
||||
# reqwest/hyper into every downstream consumer — including the ADR-185
|
||||
# `[meridian]` wheel. Removed to slim the dependency graph. Inference at
|
||||
# serving time is done via `wifi-densepose-nn` by the binaries that actually
|
||||
# load models, which depend on it directly.
|
||||
|
||||
# Core
|
||||
thiserror.workspace = true
|
||||
|
||||
Reference in New Issue
Block a user