mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be48143f77 | |||
| c453268002 | |||
| 6ee21a0941 | |||
| 0cfd255730 | |||
| f5d0e1e69e | |||
| b12662a54d | |||
| 573b00fd98 | |||
| 91b0e625bd |
+17
-20
@@ -108,16 +108,18 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
# Swatinem/rust-cache replaces a naive `actions/cache` of the whole
|
||||
# `v2/target`. That manual cache of a 38-crate target dir (multi-GB) was an
|
||||
# intermittent failure source — several CI runs this cycle died at the
|
||||
# cache/setup step (after toolchain install, before "Run Rust tests"),
|
||||
# needing a rerun. rust-cache is purpose-built for Rust: it caches the
|
||||
# registry + git + a pruned target, evicts stale deps, and restores far more
|
||||
# reliably (and faster) on large workspaces. `workspaces: v2` points it at
|
||||
# the v2/ cargo workspace (keys on v2/Cargo.lock, caches v2/target).
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
workspaces: v2
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: v2
|
||||
@@ -267,20 +269,15 @@ jobs:
|
||||
pip install -r requirements.txt
|
||||
pip install pytest # the perf suite is pytest, not locust
|
||||
|
||||
- name: Start application
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
# No CSI hardware in CI — serve mock pose data so the pose endpoints
|
||||
# respond 200 under load instead of erroring "requires real CSI data".
|
||||
MOCK_POSE_DATA: "true"
|
||||
run: |
|
||||
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
|
||||
sleep 10
|
||||
# No "Start application" step: the gated test (test_frame_budget.py) drives
|
||||
# the CSIProcessor pipeline in-process and makes no HTTP calls, so the old
|
||||
# uvicorn server + `sleep 10` were dead weight — they only existed for the
|
||||
# now-excluded api_throughput/inference_speed tests, and on every run dumped
|
||||
# ~50 misleading "router requires hardware setup" ERROR lines for a server
|
||||
# no test touched. MOCK_POSE_DATA is server-only and unused here.
|
||||
|
||||
- name: Run performance tests
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
MOCK_POSE_DATA: "true"
|
||||
run: |
|
||||
# Gate only on the genuine, deterministic perf guard:
|
||||
# test_frame_budget.py times the *real* CSIProcessor pipeline against
|
||||
|
||||
+1
-1
@@ -430,7 +430,7 @@ Model release (no new firmware binary). Firmware remains at v0.6.0-esp32.
|
||||
- Security fix merged via PR #310.
|
||||
|
||||
### Performance
|
||||
- Presence detection: 100% accuracy on 60,630 overnight samples.
|
||||
- Presence detection: 100% accuracy on 60,630 overnight samples. *(Retracted — that recording was single-class (one sleeping person, 6,062/6,063 frames "present"), so a constant "yes" scores ~99.98%. Superseded by the honest 82.3% held-out temporal-triplet metric; see [#882](https://github.com/ruvnet/RuView/issues/882). Kept here as the in-place public record.)*
|
||||
- Inference: 0.008 ms per sample, 164K embeddings/sec.
|
||||
- Contrastive self-supervised training: 51.6% improvement over baseline.
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm # benchmark
|
||||
|
||||
| What we measured | Result | Why it matters |
|
||||
|-----------------|--------|---------------|
|
||||
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
|
||||
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric on the last 20% by time (v1's "100% presence" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
|
||||
| **Inference speed** | **0.008 ms** per embedding | 125,000x faster than real-time |
|
||||
| **Throughput** | **164,183 embeddings/sec** | One Mac Mini handles 1,600+ ESP32 nodes |
|
||||
| **Contrastive learning** | **51.6% improvement** | Strong pattern learning from real overnight data |
|
||||
@@ -233,7 +233,7 @@ python firmware/esp32-csi-node/provision.py --port COM9 --hop-channels "1,6,11"
|
||||
| **kNN similarity search** | "Find the 10 most similar states to right now" — anomaly detection, fingerprinting | Cognitum Seed |
|
||||
| **Witness chain** | SHA-256 tamper-evident audit trail for every measurement (1,747 entries validated) | Cognitum Seed |
|
||||
| **Camera-free pose training** | 17 COCO keypoints from 10 sensor signals — PIR, RSSI triangulation, subcarrier asymmetry, vibration, BME280 | 2x ESP32 + Seed |
|
||||
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 100% presence accuracy, 0 skeleton violations | Download from release |
|
||||
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 82.3% held-out temporal-triplet accuracy (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) | Download from release |
|
||||
| **Sub-ms inference** | 0.012 ms latency, 171,472 embeddings/sec on M4 Pro | Any machine with Node.js |
|
||||
| **SONA adaptation** | Adapts to new rooms in <1ms without retraining | ruvllm runtime |
|
||||
| **LoRA room adapters** | Per-node fine-tuning with 2,048 parameters per adapter | Automatic |
|
||||
@@ -262,7 +262,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
|
||||
|
||||
| What we measured | Result | Why it matters |
|
||||
|-----------------|--------|---------------|
|
||||
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
|
||||
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
|
||||
| **Person counting** | **24/24 correct** (MinCut) | Fixed the #1 user-reported issue |
|
||||
| **Inference speed** | **0.012 ms** per embedding | 83,000x faster than real-time |
|
||||
| **Throughput** | **171,472 embeddings/sec** | One Mac Mini handles 1,700+ ESP32 nodes |
|
||||
|
||||
+5
-5
@@ -1048,7 +1048,7 @@ The Rust sensing server binary accepts the following flags:
|
||||
| `--dataset` | (none) | Path to dataset directory (MM-Fi or Wi-Pose) |
|
||||
| `--dataset-type` | `mmfi` | Dataset format: `mmfi` or `wipose` |
|
||||
| `--epochs` | `100` | Training epochs |
|
||||
| `--export-rvf` | (none) | Export RVF model container and exit |
|
||||
| `--export-rvf` | (none) | Export a **placeholder** RVF container-format demo and exit — **not a trained model**. For a real model use `--train` (+ `--save-rvf`) or download a pretrained encoder. |
|
||||
| `--save-rvf` | (none) | Save model state to RVF on shutdown |
|
||||
| `--model` | (none) | Load a trained `.rvf` model for inference |
|
||||
| `--load-rvf` | (none) | Load model config from RVF container |
|
||||
@@ -1119,7 +1119,7 @@ What it ships (and what it does not):
|
||||
|
||||
| Capability | Status |
|
||||
|------------|--------|
|
||||
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
|
||||
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
|
||||
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
|
||||
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
|
||||
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
|
||||
@@ -1359,7 +1359,7 @@ docker run --rm \
|
||||
-v $(pwd)/output:/output \
|
||||
--entrypoint /app/sensing-server \
|
||||
ruvnet/wifi-densepose:latest \
|
||||
--train --dataset /data --epochs 100 --export-rvf /output/model.rvf
|
||||
--train --dataset /data --epochs 100 --save-rvf /output/model.rvf
|
||||
```
|
||||
|
||||
The pipeline runs 10 phases:
|
||||
@@ -1824,7 +1824,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
|
||||
# model.safetensors — 48 KB contrastive encoder
|
||||
# model-q4.bin — 8 KB quantized (recommended)
|
||||
# model-q2.bin — 4 KB ultra-compact (ESP32 edge)
|
||||
# presence-head.json — presence detection head (100% accuracy)
|
||||
# presence-head.json — presence detection head (v2 encoder: 82.3% held-out triplet acc)
|
||||
# node-1.json — LoRA adapter for room 1
|
||||
# node-2.json — LoRA adapter for room 2
|
||||
```
|
||||
@@ -1833,7 +1833,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
|
||||
|
||||
The pre-trained encoder converts 8-dim CSI feature vectors into 128-dim embeddings. These embeddings power all 17 sensing applications:
|
||||
|
||||
- **Presence detection** — 100% accuracy, never misses, never false alarms
|
||||
- **Presence detection** — v2 encoder: 82.3% held-out temporal-triplet accuracy (v1's "100%" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882))
|
||||
- **Environment fingerprinting** — kNN search finds "states like this one"
|
||||
- **Anomaly detection** — embeddings that don't match known clusters = anomaly
|
||||
- **Activity classification** — different activities cluster in embedding space
|
||||
|
||||
@@ -172,6 +172,14 @@ impl EnsembleClassifier {
|
||||
let has_movement = reading.movement.movement_type != MovementType::None;
|
||||
|
||||
if !has_breathing && !has_movement {
|
||||
// SAFETY: a detectable heartbeat means the survivor is ALIVE. No
|
||||
// sensed breathing/movement *with* a pulse is respiratory arrest —
|
||||
// the most time-critical savable state (Immediate), never Deceased.
|
||||
// Only the total absence of breathing, movement AND heartbeat is
|
||||
// reported Deceased.
|
||||
if reading.heartbeat.is_some() {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
return TriageStatus::Deceased;
|
||||
}
|
||||
|
||||
@@ -295,6 +303,27 @@ mod tests {
|
||||
assert_eq!(result.recommended_triage, TriageStatus::Deceased);
|
||||
}
|
||||
|
||||
/// SAFETY regression: heartbeat present but no sensed breathing/movement is
|
||||
/// respiratory arrest — Immediate, never Deceased. Only the *total* absence
|
||||
/// of breathing, movement AND heartbeat (the test above) is Deceased.
|
||||
#[test]
|
||||
fn test_heartbeat_with_no_breathing_or_movement_is_immediate() {
|
||||
// breathing: None, heartbeat: Some(72 bpm), movement: None
|
||||
let reading = make_reading(None, Some(72.0), MovementType::None);
|
||||
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
||||
min_ensemble_confidence: 0.0,
|
||||
..EnsembleConfig::default()
|
||||
});
|
||||
|
||||
let result = classifier.classify(&reading);
|
||||
assert_eq!(
|
||||
result.recommended_triage,
|
||||
TriageStatus::Immediate,
|
||||
"a survivor with a pulse must never be triaged Deceased"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensemble_confidence_weighting() {
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
||||
|
||||
@@ -104,7 +104,20 @@ impl TriageCalculator {
|
||||
let movement_status = Self::assess_movement(vitals);
|
||||
|
||||
// Step 4: Combine assessments
|
||||
Self::combine_assessments(breathing_status, movement_status)
|
||||
let status = Self::combine_assessments(breathing_status, movement_status);
|
||||
|
||||
// Step 5: SAFETY OVERRIDE — a detectable heartbeat means the survivor is
|
||||
// ALIVE. `combine_assessments` only sees breathing + movement, so a
|
||||
// person with a pulse but no *sensed* breathing/movement (respiratory
|
||||
// arrest, or breathing too shallow for CSI to pick up) would otherwise
|
||||
// be reported Deceased and deprioritized for rescue. No breathing + a
|
||||
// pulse is the most time-critical *savable* state, so escalate to
|
||||
// Immediate rather than ever calling a survivor with a heartbeat dead.
|
||||
if status == TriageStatus::Deceased && vitals.heartbeat.is_some() {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
/// Assess breathing status
|
||||
@@ -217,7 +230,9 @@ enum MovementAssessment {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile};
|
||||
use crate::domain::{
|
||||
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
fn create_vitals(
|
||||
@@ -233,6 +248,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// SAFETY regression: a survivor with a detectable heartbeat but no sensed
|
||||
/// breathing or movement is in respiratory arrest — Immediate (Red), and
|
||||
/// must NEVER be reported Deceased. (Before the fix, `combine_assessments`
|
||||
/// ignored heartbeat and returned Deceased; that path was in fact only
|
||||
/// reachable *because* a heartbeat made `has_vitals()` true.)
|
||||
#[test]
|
||||
fn heartbeat_with_no_breathing_or_movement_is_immediate_not_deceased() {
|
||||
let vitals = VitalSignsReading {
|
||||
breathing: None,
|
||||
heartbeat: Some(HeartbeatSignature {
|
||||
rate_bpm: 72.0,
|
||||
variability: 0.1,
|
||||
strength: SignalStrength::Moderate,
|
||||
}),
|
||||
movement: MovementProfile::default(),
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(0.8),
|
||||
};
|
||||
let status = TriageCalculator::calculate(&vitals);
|
||||
assert_eq!(status, TriageStatus::Immediate, "pulse present ⇒ alive");
|
||||
assert_ne!(status, TriageStatus::Deceased);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_vitals_is_unknown() {
|
||||
let vitals = create_vitals(None, MovementProfile::default());
|
||||
|
||||
@@ -100,7 +100,17 @@ pub async fn require_bearer(
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "));
|
||||
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme ("Bearer") is
|
||||
// case-insensitive. Match it as such (and tolerate extra leading
|
||||
// whitespace before the token) so a correct token isn't rejected
|
||||
// just because a client sent `bearer`/`BEARER`. The token compare
|
||||
// below stays exact + constant-time.
|
||||
.and_then(|s| {
|
||||
let (scheme, token) = s.split_once(' ')?;
|
||||
scheme
|
||||
.eq_ignore_ascii_case("Bearer")
|
||||
.then(|| token.trim_start())
|
||||
});
|
||||
let ok = supplied
|
||||
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
|
||||
.unwrap_or(false);
|
||||
@@ -185,6 +195,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_case_insensitive_bearer_scheme() {
|
||||
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme is case-insensitive.
|
||||
// A correct token must authenticate regardless of scheme casing or
|
||||
// extra whitespace; a wrong token must still be rejected.
|
||||
async fn req_status(auth_value: &str) -> StatusCode {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/info")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.headers_mut()
|
||||
.insert(AUTHORIZATION, auth_value.parse().unwrap());
|
||||
r.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("bearer s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("BEARER s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK); // extra space
|
||||
// Scheme leniency must NOT weaken the token check.
|
||||
assert_eq!(req_status("bearer nope").await, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(req_status("Basic s3cr3t").await, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_blocks_api_with_wrong_bearer() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
|
||||
@@ -5476,6 +5476,159 @@ async fn broadcast_tick_task(state: SharedState, tick_ms: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map one sensing-broadcast JSON document into the `VitalsSnapshot`(s) to
|
||||
/// publish over MQTT (issues #872/#898).
|
||||
///
|
||||
/// Multi-node sources carry a `nodes` array where **each node has its own
|
||||
/// `classification`** (`motion_level`, `presence`, `confidence`) and RSSI — so
|
||||
/// each node must surface its *own* presence/motion, not the room-level
|
||||
/// aggregate. Previously the bridge applied the aggregate `classification` to
|
||||
/// every per-node Home-Assistant device, so a node in an empty corner inherited
|
||||
/// another node's "present" (and `motion_level: "absent"` was mis-mapped to full
|
||||
/// motion). Vitals (breathing / heart rate) and the person count are room-level
|
||||
/// and shared across the per-node devices. Falls back to a single aggregate
|
||||
/// snapshot when there is no per-node data (e.g. wifi / simulate sources).
|
||||
#[cfg(feature = "mqtt")]
|
||||
fn vitals_snapshots_from_sensing_json(
|
||||
v: &serde_json::Value,
|
||||
base_id: &str,
|
||||
) -> Vec<wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot> {
|
||||
use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot;
|
||||
|
||||
// motion_level string -> motion scalar. "absent"/"none"/"still"/"idle"/""
|
||||
// are non-moving; anything else (walking, …) is motion. `fallback` is used
|
||||
// when the field is absent so a partial per-node payload defers to the
|
||||
// room aggregate rather than silently reading 0.
|
||||
fn motion_of(level: Option<&str>, fallback: f64) -> f64 {
|
||||
match level {
|
||||
Some("none") | Some("still") | Some("idle") | Some("absent") | Some("") => 0.0,
|
||||
Some(_) => 1.0,
|
||||
None => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
|
||||
let vit = &v["vital_signs"];
|
||||
let breathing = vit["breathing_rate_bpm"].as_f64();
|
||||
let hr = vit["heart_rate_bpm"].as_f64();
|
||||
let n_persons = v["persons"]
|
||||
.as_array()
|
||||
.map(|a| a.len() as u32)
|
||||
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
|
||||
.unwrap_or(0);
|
||||
|
||||
// Room-level aggregate: the no-nodes fallback, and the per-node default for
|
||||
// any field a node omits.
|
||||
let acls = &v["classification"];
|
||||
let agg_presence = acls["presence"].as_bool().unwrap_or(false);
|
||||
let agg_motion = motion_of(acls["motion_level"].as_str(), 0.0);
|
||||
let agg_conf = acls["confidence"].as_f64().unwrap_or(0.0);
|
||||
|
||||
let mk = |node_id: String, presence: bool, motion: f64, conf: f64, rssi: Option<f64>| {
|
||||
VitalsSnapshot {
|
||||
node_id,
|
||||
timestamp_ms: ts,
|
||||
presence,
|
||||
motion,
|
||||
presence_score: if presence { conf.max(0.0) } else { 0.0 },
|
||||
breathing_rate_bpm: breathing,
|
||||
heartrate_bpm: hr,
|
||||
n_persons,
|
||||
rssi_dbm: rssi,
|
||||
vital_confidence: conf,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
match v["nodes"].as_array() {
|
||||
Some(arr) if !arr.is_empty() => arr
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let n = node["node_id"].as_u64().unwrap_or(0);
|
||||
// Each node carries its OWN classification — use it, deferring to
|
||||
// the room aggregate only for fields the node omits.
|
||||
let ncls = &node["classification"];
|
||||
let presence = ncls["presence"].as_bool().unwrap_or(agg_presence);
|
||||
let motion = motion_of(ncls["motion_level"].as_str(), agg_motion);
|
||||
let conf = ncls["confidence"].as_f64().unwrap_or(agg_conf);
|
||||
mk(
|
||||
format!("{base_id}-node{n}"),
|
||||
presence,
|
||||
motion,
|
||||
conf,
|
||||
node["rssi_dbm"].as_f64(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![mk(
|
||||
base_id.to_string(),
|
||||
agg_presence,
|
||||
agg_motion,
|
||||
agg_conf,
|
||||
v["nodes"][0]["rssi_dbm"].as_f64(),
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
|
||||
///
|
||||
/// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files
|
||||
/// (`model.safetensors`, `model-q{2,4,8}.bin`, `model.rvf.jsonl`) are a
|
||||
/// different *format* — and a different encoder architecture — than the RVF
|
||||
/// binary container the `--model` progressive loader expects (`RVFS` magic
|
||||
/// `0x52564653`). Feeding one to `--model` produced a bare
|
||||
/// "invalid magic at offset 0 …" that left users stuck. Detect the common
|
||||
/// cases and explain plainly what's loadable instead.
|
||||
fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
// safetensors: 8-byte LE header length, then a JSON object starting with '{'.
|
||||
let looks_safetensors = ext == "safetensors" || (data.len() > 9 && data[8] == b'{');
|
||||
// JSONL manifest: starts with '{' (or the well-known suffix).
|
||||
let looks_jsonl =
|
||||
ext == "jsonl" || name.ends_with(".rvf.jsonl") || data.first() == Some(&b'{');
|
||||
// Quantized weight blob shipped on HF (model-q2/q4/q8.bin).
|
||||
let looks_quant_bin = ext == "bin" || name.contains("-q");
|
||||
|
||||
let kind = if looks_safetensors {
|
||||
"a safetensors weight file"
|
||||
} else if looks_jsonl {
|
||||
"a JSONL manifest, not the binary container"
|
||||
} else if looks_quant_bin {
|
||||
"a quantized weight blob (e.g. HuggingFace model-q4.bin)"
|
||||
} else {
|
||||
"not an RVF binary container"
|
||||
};
|
||||
|
||||
format!(
|
||||
"model `{}` could not be loaded: it is {kind}. The --model flag expects an \
|
||||
RVF binary container (`RVFS` magic 0x52564653) produced by the \
|
||||
wifi-densepose-train pipeline. The HuggingFace ruvnet/wifi-densepose-pretrained \
|
||||
files are a different format and encoder architecture, so they do not load \
|
||||
here directly (issue #894). Continuing with signal heuristics. (loader: {err})",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `--export-rvf` should emit the placeholder container-format demo.
|
||||
///
|
||||
/// It must only do so **standalone**. Combined with `--train`/`--pretrain` the
|
||||
/// real model is produced by the training pipeline, so short-circuiting here
|
||||
/// would silently skip training and write placeholder weights — the #894 bug
|
||||
/// where the documented `--train … --export-rvf` workflow produced a fake model.
|
||||
fn export_emits_placeholder_demo(export_set: bool, train: bool, pretrain: bool) -> bool {
|
||||
export_set && !train && !pretrain
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
|
||||
@@ -5519,9 +5672,24 @@ async fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle --export-rvf mode: build an RVF container package and exit
|
||||
if let Some(ref rvf_path) = args.export_rvf {
|
||||
eprintln!("Exporting RVF container package...");
|
||||
// Handle --export-rvf: writes a CONTAINER-FORMAT DEMO with placeholder
|
||||
// weights — it is NOT a trained model. Only short-circuit when standalone:
|
||||
// combined with --train/--pretrain the real model is exported by the
|
||||
// training pipeline, and short-circuiting here would silently skip training
|
||||
// and write placeholder weights (#894 — the documented `--train …
|
||||
// --export-rvf` workflow produced a placeholder and never trained).
|
||||
if export_emits_placeholder_demo(args.export_rvf.is_some(), args.train, args.pretrain) {
|
||||
let rvf_path = args
|
||||
.export_rvf
|
||||
.as_ref()
|
||||
.expect("export_emits_placeholder_demo implies export_rvf is set");
|
||||
eprintln!(
|
||||
"WARNING: --export-rvf writes a CONTAINER-FORMAT DEMO with placeholder \
|
||||
weights — it is NOT a trained model. Train one with \
|
||||
`--train --dataset <DIR>` (which exports a calibrated .rvf to the \
|
||||
models/ directory), or download a pretrained encoder. See issue #894."
|
||||
);
|
||||
eprintln!("Exporting RVF container package (placeholder weights)...");
|
||||
use rvf_pipeline::RvfModelBuilder;
|
||||
|
||||
let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0");
|
||||
@@ -5570,6 +5738,13 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else if args.export_rvf.is_some() {
|
||||
// --export-rvf alongside --train/--pretrain: don't emit a placeholder.
|
||||
// Fall through so training runs; it exports the real calibrated model.
|
||||
eprintln!(
|
||||
"Note: --export-rvf is ignored in training mode — the trained model \
|
||||
is exported by the training pipeline to the models/ directory."
|
||||
);
|
||||
}
|
||||
|
||||
// Handle --pretrain mode: self-supervised contrastive pretraining (ADR-024)
|
||||
@@ -6113,7 +6288,9 @@ async fn main() {
|
||||
model_loaded = true;
|
||||
progressive_loader = Some(loader);
|
||||
}
|
||||
Err(e) => error!("Progressive loader init failed: {e}"),
|
||||
Err(e) => {
|
||||
error!("{}", diagnose_model_load_error(mp, &data, &e.to_string()))
|
||||
}
|
||||
},
|
||||
Err(e) => error!("Failed to read model file: {e}"),
|
||||
}
|
||||
@@ -6200,56 +6377,13 @@ async fn main() {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) else {
|
||||
continue;
|
||||
};
|
||||
let cls = &v["classification"];
|
||||
let vit = &v["vital_signs"];
|
||||
let presence = cls["presence"].as_bool().unwrap_or(false);
|
||||
let n_persons = v["persons"]
|
||||
.as_array()
|
||||
.map(|a| a.len() as u32)
|
||||
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
|
||||
.unwrap_or(0);
|
||||
let motion = match cls["motion_level"].as_str() {
|
||||
Some("none") | Some("still") | Some("idle") | Some("") => 0.0,
|
||||
Some(_) => 1.0,
|
||||
None => 0.0,
|
||||
};
|
||||
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
|
||||
let conf = cls["confidence"].as_f64().unwrap_or(0.0);
|
||||
let presence_score = if presence { conf.max(0.0) } else { 0.0 };
|
||||
let breathing = vit["breathing_rate_bpm"].as_f64();
|
||||
let hr = vit["heart_rate_bpm"].as_f64();
|
||||
// #898: emit one snapshot per physical node so each
|
||||
// surfaces as its own Home-Assistant device (with
|
||||
// its own RSSI + availability). Falls back to a
|
||||
// single aggregate snapshot when there is no
|
||||
// per-node data (e.g. wifi / simulate sources).
|
||||
let mk = |nid: String, rssi: Option<f64>| mqtt::state::VitalsSnapshot {
|
||||
node_id: nid,
|
||||
timestamp_ms: ts,
|
||||
presence,
|
||||
motion,
|
||||
presence_score,
|
||||
breathing_rate_bpm: breathing,
|
||||
heartrate_bpm: hr,
|
||||
n_persons,
|
||||
rssi_dbm: rssi,
|
||||
vital_confidence: conf,
|
||||
..Default::default()
|
||||
};
|
||||
match v["nodes"].as_array() {
|
||||
Some(arr) if !arr.is_empty() => {
|
||||
for node in arr {
|
||||
let n = node["node_id"].as_u64().unwrap_or(0);
|
||||
let nid = format!("{node_id}-node{n}");
|
||||
let _ = vtx.send(mk(nid, node["rssi_dbm"].as_f64()));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = vtx.send(mk(
|
||||
node_id.clone(),
|
||||
v["nodes"][0]["rssi_dbm"].as_f64(),
|
||||
));
|
||||
}
|
||||
// #898/#872: emit one snapshot per physical node so
|
||||
// each surfaces as its own Home-Assistant device with
|
||||
// its *own* presence/motion/RSSI (see
|
||||
// vitals_snapshots_from_sensing_json). Falls back to a
|
||||
// single aggregate snapshot for per-node-less sources.
|
||||
for snap in vitals_snapshots_from_sensing_json(&v, &node_id) {
|
||||
let _ = vtx.send(snap);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -7068,3 +7202,169 @@ mod rolling_p95_tests {
|
||||
assert_eq!(p.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "mqtt"))]
|
||||
mod mqtt_bridge_tests {
|
||||
use super::vitals_snapshots_from_sensing_json;
|
||||
use serde_json::json;
|
||||
|
||||
/// Regression for the per-node presence bug (#872/#898): each node must
|
||||
/// surface its OWN classification, not the room-level aggregate. Node 1 is
|
||||
/// present+moving; node 2 is absent — node 2 must NOT inherit node 1's
|
||||
/// "present".
|
||||
#[test]
|
||||
fn per_node_presence_uses_each_nodes_own_classification() {
|
||||
let v = json!({
|
||||
"timestamp": 1.0,
|
||||
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.9 },
|
||||
"vital_signs": { "breathing_rate_bpm": 14.0, "heart_rate_bpm": 60.0 },
|
||||
"persons": [{}, {}],
|
||||
"nodes": [
|
||||
{ "node_id": 1, "rssi_dbm": -40.0,
|
||||
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.8 } },
|
||||
{ "node_id": 2, "rssi_dbm": -70.0,
|
||||
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.1 } }
|
||||
]
|
||||
});
|
||||
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
|
||||
assert_eq!(snaps.len(), 2, "one snapshot per node");
|
||||
|
||||
let n1 = snaps.iter().find(|s| s.node_id == "ruview-node1").unwrap();
|
||||
let n2 = snaps.iter().find(|s| s.node_id == "ruview-node2").unwrap();
|
||||
|
||||
assert!(n1.presence && n1.motion > 0.0, "node1 present + moving");
|
||||
assert!(
|
||||
!n2.presence && n2.motion == 0.0,
|
||||
"node2 must be absent — not inherit the room aggregate"
|
||||
);
|
||||
// Per-node RSSI preserved.
|
||||
assert_eq!(n1.rssi_dbm, Some(-40.0));
|
||||
assert_eq!(n2.rssi_dbm, Some(-70.0));
|
||||
// Vitals + person count are room-level, shared across node devices.
|
||||
assert_eq!(n1.n_persons, 2);
|
||||
assert_eq!(n2.n_persons, 2);
|
||||
assert_eq!(n1.breathing_rate_bpm, Some(14.0));
|
||||
assert_eq!(n2.heartrate_bpm, Some(60.0));
|
||||
// presence_score is gated on presence.
|
||||
assert!(n1.presence_score > 0.0);
|
||||
assert_eq!(n2.presence_score, 0.0);
|
||||
}
|
||||
|
||||
/// A node that omits a classification field defers to the room aggregate
|
||||
/// rather than silently reading false/0.
|
||||
#[test]
|
||||
fn per_node_missing_fields_fall_back_to_aggregate() {
|
||||
let v = json!({
|
||||
"timestamp": 1.0,
|
||||
"classification": { "presence": true, "motion_level": "still", "confidence": 0.7 },
|
||||
"vital_signs": {},
|
||||
"nodes": [ { "node_id": 3, "rssi_dbm": -55.0 } ] // no per-node classification
|
||||
});
|
||||
let snaps = vitals_snapshots_from_sensing_json(&v, "n");
|
||||
assert_eq!(snaps.len(), 1);
|
||||
assert_eq!(snaps[0].node_id, "n-node3");
|
||||
assert!(snaps[0].presence, "defers to aggregate presence");
|
||||
assert_eq!(snaps[0].motion, 0.0, "aggregate 'still' => no motion");
|
||||
}
|
||||
|
||||
/// No `nodes` array (wifi / simulate sources): single aggregate snapshot
|
||||
/// keyed by the base id.
|
||||
#[test]
|
||||
fn falls_back_to_single_aggregate_when_no_nodes() {
|
||||
let v = json!({
|
||||
"timestamp": 2.0,
|
||||
"classification": { "presence": true, "motion_level": "idle", "confidence": 0.6 },
|
||||
"vital_signs": { "breathing_rate_bpm": 12.0 },
|
||||
"persons": [{}]
|
||||
});
|
||||
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
|
||||
assert_eq!(snaps.len(), 1);
|
||||
assert_eq!(snaps[0].node_id, "ruview");
|
||||
assert!(snaps[0].presence);
|
||||
assert_eq!(snaps[0].motion, 0.0, "idle => no motion");
|
||||
assert_eq!(snaps[0].n_persons, 1);
|
||||
}
|
||||
|
||||
/// `motion_level: "absent"` must map to zero motion (the old aggregate
|
||||
/// match fell through to `Some(_) => 1.0`, treating absent as full motion).
|
||||
#[test]
|
||||
fn absent_motion_level_is_zero_motion() {
|
||||
let v = json!({
|
||||
"timestamp": 0.0,
|
||||
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.0 },
|
||||
"vital_signs": {}
|
||||
});
|
||||
let snaps = vitals_snapshots_from_sensing_json(&v, "x");
|
||||
assert_eq!(snaps[0].motion, 0.0);
|
||||
assert!(!snaps[0].presence);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod model_load_diagnostic_tests {
|
||||
use super::diagnose_model_load_error;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn safetensors_is_named_and_points_at_894() {
|
||||
// 8-byte LE header length then '{' — the safetensors signature.
|
||||
let data = [0x10, 0, 0, 0, 0, 0, 0, 0, b'{', b'"'];
|
||||
let msg = diagnose_model_load_error(
|
||||
Path::new("models/wifi-densepose-pretrained/model.safetensors"),
|
||||
&data,
|
||||
"invalid magic at offset 0",
|
||||
);
|
||||
assert!(msg.contains("safetensors"), "{msg}");
|
||||
assert!(msg.contains("#894"), "{msg}");
|
||||
assert!(msg.contains("signal heuristics"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantized_bin_is_identified() {
|
||||
let data = [0x35, 0x57, 0x45, 0x77]; // the 0x77455735 the loader reports
|
||||
let msg = diagnose_model_load_error(Path::new("model-q4.bin"), &data, "bad magic");
|
||||
assert!(msg.contains("quantized weight blob"), "{msg}");
|
||||
assert!(msg.contains("RVFS") || msg.contains("0x52564653"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_manifest_is_identified() {
|
||||
let data = *b"{\"seg\":0}";
|
||||
let msg = diagnose_model_load_error(Path::new("model.rvf.jsonl"), &data, "x");
|
||||
assert!(msg.contains("JSONL manifest"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_format_still_gives_guidance() {
|
||||
let data = [0u8, 1, 2, 3];
|
||||
let msg = diagnose_model_load_error(Path::new("weird.dat"), &data, "x");
|
||||
assert!(msg.contains("RVF binary container"), "{msg}");
|
||||
assert!(msg.contains("wifi-densepose-train"), "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod export_rvf_mode_tests {
|
||||
use super::export_emits_placeholder_demo;
|
||||
|
||||
#[test]
|
||||
fn standalone_export_emits_placeholder() {
|
||||
// --export-rvf alone → the container-format demo (placeholder weights).
|
||||
assert!(export_emits_placeholder_demo(true, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_with_train_does_not_short_circuit() {
|
||||
// #894: `--train --export-rvf` must NOT emit a placeholder + skip
|
||||
// training — it must fall through to the real training pipeline.
|
||||
assert!(!export_emits_placeholder_demo(true, true, false));
|
||||
assert!(!export_emits_placeholder_demo(true, false, true));
|
||||
assert!(!export_emits_placeholder_demo(true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_export_flag_never_emits() {
|
||||
assert!(!export_emits_placeholder_demo(false, false, false));
|
||||
assert!(!export_emits_placeholder_demo(false, true, false));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user