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:
Dragan Spiridonov
2026-07-24 15:26:15 +02:00
71 changed files with 10058 additions and 345 deletions
Generated
+9 -1
View File
@@ -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",
]
+1
View File
@@ -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"
@@ -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");
}
+18 -4
View File
@@ -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()
}
+13 -9
View File
@@ -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");
}
}
+7 -1
View File
@@ -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