Skip to content

Commit 74ecce3

Browse files
authored
Merge pull request #1048 from ruvnet/fix/issues-1031-894-fusion-guard-model-load
fix: multistatic fusion guard for real TDM (#1031) + load published HF model via auto-detect/convert (#894)
2 parents 29e937e + fd1430e commit 74ecce3

6 files changed

Lines changed: 832 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Multistatic fusion guard was too tight for real TDM hardware (#1031).** `MultistaticConfig::default().guard_interval_us` was 5,000 µs (5 ms) with a comment claiming "well within the 50 ms TDMA cycle" — but on a real N-slot TDM schedule node `k` transmits in slot `k`, so two nodes are separated by the *slot offset*, not clock jitter. A real 2-node mesh (slots 0/1) measured an **18,194 µs** spread, so every real frame set exceeded the 5 ms guard and `fuse()` silently fell back to per-node sum/dedup — multistatic fusion never actually ran on hardware. Raised the default hard guard to **60 ms** (a full 50 ms TDMA cycle + 20% jitter headroom, derived from the slot model and documented in the field doc) and the soft guard to **20 ms** (just above the observed 18.2 ms 2-slot spread, so a normal cycle fuses cleanly with no privacy demotion). Added `MultistaticConfig::for_tdm_schedule(total_slots, slot_duration_us)` to derive the guard from a deployment's exact schedule, and a `WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US` env seam in sensing-server. The honest per-node fallback remains for genuinely-mismatched frames — now the exception, not the default. Pinned by `fuse_real_tdm_spread_18194us_fuses_with_default_guard` (fails on the old 5 ms default) + `configurable_guard_rejects_too_large_spread` (guard still rejects a spread beyond one cycle).
12+
- **Published HuggingFace model was unloadable — RVF format mismatch (#894).** The `ProgressiveLoader` rejected the published `ruvnet/wifi-densepose-pretrained` model with the opaque `invalid magic at offset 0: expected 0x52564653 (RVFS), got 0x77455735`, then silently fell back to signal heuristics (the "10 persons for 1" garbage reporters saw). The HF repo ships `model.safetensors`, `model-q{2,4,8}.bin` (magic `0x77455735` = "5WEw"), and `model.rvf.jsonl` — none carry the binary-RVF magic. New `model_format` module **auto-detects** RVFS / safetensors / HF-quant-bin / JSONL by magic+name, returns a **typed actionable** `ModelLoadError` (lists accepted formats + the one-command convert path — never the opaque magic), and **converts** `model.safetensors` / `model.rvf.jsonl` → RVF in-memory so the published full-precision model now loads via `--model`. A `--convert-model <in> --convert-out <out>` CLI subcommand gives a one-command offline path; the silent heuristics fallback is now a loud, actionable error. **Honest scope:** the converter wires the format/load path (safetensors F32 tensors → RVF weight segment, manifest written, Layer A/B/C all succeed, weights round-trip) — it does **not** claim end-to-end pose accuracy, since the HF pose-decoder architecture differs from this crate's inference head (still data-gated in #894). Quantized `.bin` blobs are rejected with a typed error pointing at the safetensors path. Pinned by `safetensors_converts_and_loads` + `hf_quant_classifies_to_actionable_error` (both fail on the old opaque-magic path).
13+
1014
### Changed
1115
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it fed the recalibration advisor). It now also contributes to the ADR-141 privacy demotion alongside fusion- and array-level contradictions: a mesh close to partitioning makes the fused belief less trustworthy, so the cycle emits at a more restricted class (monotonic — information only removed). Because `effective_class` feeds the BLAKE3 witness, a fragmenting array now shifts the witness — partition risk is auditable, not just logged. The mesh computation moved ahead of the demotion step in `process_cycle`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean *same-topology* baseline (the only delta between the two cycles is the forced risk).
1216

v2/crates/wifi-densepose-engine/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,8 +682,9 @@ mod tests {
682682
fn contradiction_demotes_privacy() {
683683
let (mut e, room) = engine();
684684
let cal = CalibrationId(7);
685-
// 2 ms spread: within the 5 ms hard guard but above the 1 ms soft guard.
686-
let frames = [node_frame(0, 1000, 56), node_frame(1, 3000, 56)];
685+
// 25 ms spread: within the 60 ms hard guard but above the 20 ms soft
686+
// guard (#1031 raised both to accommodate the real TDM slot offset).
687+
let frames = [node_frame(0, 1_000, 56), node_frame(1, 26_000, 56)];
687688
let out = e.process_cycle(&frames, cal, room, 20_000).unwrap();
688689

689690
assert!(out.demoted, "loose alignment must demote");

v2/crates/wifi-densepose-sensing-server/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod graph_transformer;
1717
pub mod host_validation;
1818
pub mod introspection;
1919
pub mod matter;
20+
pub mod model_format;
2021
pub mod mqtt;
2122
pub mod path_safety;
2223
pub mod semantic;

v2/crates/wifi-densepose-sensing-server/src/main.rs

Lines changed: 187 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod cli;
1414
pub mod csi;
1515
mod engine_bridge;
1616
mod field_bridge;
17+
mod model_format;
1718
mod multistatic_bridge;
1819
pub mod pose;
1920
mod rvf_container;
@@ -144,6 +145,16 @@ struct Args {
144145
#[arg(long, value_name = "PATH")]
145146
export_rvf: Option<PathBuf>,
146147

148+
/// Convert a published model file (model.safetensors / model.rvf.jsonl) to
149+
/// the RVF binary container the --model loader expects, then exit (#894).
150+
/// Pair with --convert-out for the destination path.
151+
#[arg(long, value_name = "PATH")]
152+
convert_model: Option<PathBuf>,
153+
154+
/// Output path for --convert-model (defaults to <input>.rvf).
155+
#[arg(long, value_name = "PATH")]
156+
convert_out: Option<PathBuf>,
157+
147158
/// Run training mode (train a model and exit)
148159
#[arg(long)]
149160
train: bool,
@@ -6221,6 +6232,34 @@ fn vitals_snapshots_from_sensing_json(
62216232
}
62226233
}
62236234

6235+
/// Build the multistatic guard config, optionally derived from the TDM schedule
6236+
/// declared in the environment (#1031).
6237+
///
6238+
/// When both `WDP_TDM_SLOTS` and `WDP_TDM_SLOT_US` parse as positive integers,
6239+
/// the guard is derived via [`MultistaticConfig::for_tdm_schedule`] so a
6240+
/// deployment can match its exact schedule. Otherwise the published default
6241+
/// (60 ms hard / 20 ms soft) is returned. `min_nodes` is *not* set here — the
6242+
/// caller overrides it for single-node passthrough.
6243+
fn multistatic_guard_config_from_env() -> MultistaticConfig {
6244+
multistatic_guard_config_from(
6245+
std::env::var("WDP_TDM_SLOTS").ok().as_deref(),
6246+
std::env::var("WDP_TDM_SLOT_US").ok().as_deref(),
6247+
)
6248+
}
6249+
6250+
/// Pure core of [`multistatic_guard_config_from_env`] for testability.
6251+
fn multistatic_guard_config_from(slots: Option<&str>, slot_us: Option<&str>) -> MultistaticConfig {
6252+
match (
6253+
slots.and_then(|s| s.trim().parse::<usize>().ok()),
6254+
slot_us.and_then(|s| s.trim().parse::<u64>().ok()),
6255+
) {
6256+
(Some(n), Some(us)) if n >= 1 && us >= 1 => {
6257+
MultistaticConfig::for_tdm_schedule(n, us)
6258+
}
6259+
_ => MultistaticConfig::default(),
6260+
}
6261+
}
6262+
62246263
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
62256264
///
62266265
/// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files
@@ -6230,6 +6269,11 @@ fn vitals_snapshots_from_sensing_json(
62306269
/// `0x52564653`). Feeding one to `--model` produced a bare
62316270
/// "invalid magic at offset 0 …" that left users stuck. Detect the common
62326271
/// cases and explain plainly what's loadable instead.
6272+
///
6273+
/// Superseded in the live load path by [`load_or_convert_model`] (which now
6274+
/// converts the convertible formats instead of just explaining), but retained
6275+
/// as the human-readable format-landscape summary and exercised by tests.
6276+
#[allow(dead_code)]
62336277
fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String {
62346278
let name = path
62356279
.file_name()
@@ -6270,6 +6314,124 @@ fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) ->
62706314
)
62716315
}
62726316

6317+
/// Load a model for `--model`, auto-detecting + converting the published
6318+
/// HuggingFace formats when the native RVF loader rejects them (issue #894).
6319+
///
6320+
/// Order of operations:
6321+
/// 1. Try the native RVF `ProgressiveLoader` (the only format with `RVFS` magic).
6322+
/// 2. On failure, **auto-detect** the format. If it is convertible
6323+
/// (`safetensors` / `model.rvf.jsonl`), convert it in-memory to RVF and load
6324+
/// that — so the published `model.safetensors` becomes loadable here.
6325+
/// 3. If it is a non-convertible format (quantized blob / unknown), return the
6326+
/// typed, actionable [`model_format::ModelLoadError`] message — never the
6327+
/// opaque "invalid magic …" string.
6328+
///
6329+
/// Returns the loaded `ProgressiveLoader` or a human-actionable error string.
6330+
fn load_or_convert_model(
6331+
path: &std::path::Path,
6332+
data: &[u8],
6333+
) -> Result<ProgressiveLoader, String> {
6334+
use model_format::{convert_to_rvf, detect_format, ModelFormat};
6335+
6336+
// 1. Native RVF.
6337+
if let Ok(loader) = ProgressiveLoader::new(data) {
6338+
return Ok(loader);
6339+
}
6340+
6341+
let name = path
6342+
.file_name()
6343+
.and_then(|n| n.to_str())
6344+
.unwrap_or("")
6345+
.to_string();
6346+
let model_id = path
6347+
.file_stem()
6348+
.and_then(|s| s.to_str())
6349+
.unwrap_or("converted-model");
6350+
6351+
match detect_format(data, &name) {
6352+
// 2. Convertible formats: convert in-memory, then load.
6353+
ModelFormat::Safetensors | ModelFormat::JsonlManifest => {
6354+
match convert_to_rvf(data, &name, model_id) {
6355+
Ok(rvf_bytes) => {
6356+
info!(
6357+
"Model `{}` is {} — converting to RVF in-memory and loading (issue #894)",
6358+
path.display(),
6359+
detect_format(data, &name).label()
6360+
);
6361+
ProgressiveLoader::new(&rvf_bytes).map_err(|e| {
6362+
format!(
6363+
"converted {} to RVF but the container failed to load: {e}",
6364+
detect_format(data, &name).label()
6365+
)
6366+
})
6367+
}
6368+
Err(conv_err) => Err(conv_err.to_string()),
6369+
}
6370+
}
6371+
// 3. Non-convertible: typed actionable error.
6372+
_ => Err(model_format::classify_load_failure(
6373+
data,
6374+
&name,
6375+
"RVF container parse failed",
6376+
)
6377+
.to_string()),
6378+
}
6379+
}
6380+
6381+
/// `--convert-model` entry point (issue #894): read `in_path`, convert it to an
6382+
/// RVF binary container, write it to `out_path`, and verify the result loads.
6383+
/// Returns a process exit code (0 = success).
6384+
fn run_convert_model(in_path: &std::path::Path, out_path: &std::path::Path) -> i32 {
6385+
let data = match std::fs::read(in_path) {
6386+
Ok(d) => d,
6387+
Err(e) => {
6388+
eprintln!("convert-model: failed to read {}: {e}", in_path.display());
6389+
return 1;
6390+
}
6391+
};
6392+
let name = in_path
6393+
.file_name()
6394+
.and_then(|n| n.to_str())
6395+
.unwrap_or("")
6396+
.to_string();
6397+
let model_id = in_path
6398+
.file_stem()
6399+
.and_then(|s| s.to_str())
6400+
.unwrap_or("converted-model");
6401+
6402+
let detected = model_format::detect_format(&data, &name);
6403+
eprintln!(
6404+
"convert-model: detected {} ({} bytes)",
6405+
detected.label(),
6406+
data.len()
6407+
);
6408+
6409+
match model_format::convert_to_rvf(&data, &name, model_id) {
6410+
Ok(rvf_bytes) => {
6411+
// Verify the converted bytes actually load before writing.
6412+
if let Err(e) = ProgressiveLoader::new(&rvf_bytes) {
6413+
eprintln!("convert-model: produced RVF did NOT load (bug): {e}");
6414+
return 1;
6415+
}
6416+
if let Err(e) = std::fs::write(out_path, &rvf_bytes) {
6417+
eprintln!("convert-model: failed to write {}: {e}", out_path.display());
6418+
return 1;
6419+
}
6420+
eprintln!(
6421+
"convert-model: wrote {} ({} bytes). Load it with `--model {}`.",
6422+
out_path.display(),
6423+
rvf_bytes.len(),
6424+
out_path.display()
6425+
);
6426+
0
6427+
}
6428+
Err(e) => {
6429+
eprintln!("convert-model: {e}");
6430+
1
6431+
}
6432+
}
6433+
}
6434+
62736435
/// Whether `--export-rvf` should emit the placeholder container-format demo.
62746436
///
62756437
/// It must only do so **standalone**. Combined with `--train`/`--pretrain` the
@@ -6323,6 +6485,17 @@ async fn main() {
63236485
return;
63246486
}
63256487

6488+
// Handle --convert-model: turn a published HF model file (safetensors /
6489+
// model.rvf.jsonl) into the RVF binary container --model expects, then exit
6490+
// (issue #894). Gives the reporter a one-command path off the heuristics.
6491+
if let Some(ref in_path) = args.convert_model {
6492+
let out_path = args
6493+
.convert_out
6494+
.clone()
6495+
.unwrap_or_else(|| in_path.with_extension("rvf"));
6496+
std::process::exit(run_convert_model(in_path, &out_path));
6497+
}
6498+
63266499
// Handle --export-rvf: writes a CONTAINER-FORMAT DEMO with placeholder
63276500
// weights — it is NOT a trained model. Only short-circuit when standalone:
63286501
// combined with --train/--pretrain the real model is exported by the
@@ -6951,7 +7124,7 @@ async fn main() {
69517124
if args.progressive || args.model.is_some() {
69527125
info!("Loading trained model (progressive) from {}", mp.display());
69537126
match std::fs::read(mp) {
6954-
Ok(data) => match ProgressiveLoader::new(&data) {
7127+
Ok(data) => match load_or_convert_model(mp, &data) {
69557128
Ok(mut loader) => {
69567129
if let Ok(la) = loader.load_layer_a() {
69577130
info!(
@@ -6963,7 +7136,13 @@ async fn main() {
69637136
progressive_loader = Some(loader);
69647137
}
69657138
Err(e) => {
6966-
error!("{}", diagnose_model_load_error(mp, &data, &e.to_string()))
7139+
// #894: typed, actionable message (never the opaque magic)
7140+
// and a LOUD warning that we are degrading to heuristics.
7141+
error!("{e}");
7142+
error!(
7143+
"Model NOT loaded — falling back to signal heuristics. \
7144+
Pose/person-count output will be approximate (issue #894)."
7145+
);
69677146
}
69687147
},
69697148
Err(e) => error!("Failed to read model file: {e}"),
@@ -7136,9 +7315,14 @@ async fn main() {
71367315
pose_tracker: PoseTracker::new(),
71377316
last_tracker_instant: None,
71387317
multistatic_fuser: {
7318+
// #1031: the default guard (60 ms hard / 20 ms soft) accommodates a
7319+
// real TDM slot offset. A deployment can override it to match its
7320+
// own schedule via WDP_TDM_SLOTS + WDP_TDM_SLOT_US (both set ⇒ derive
7321+
// from the schedule), else the published default is used.
7322+
let cfg = multistatic_guard_config_from_env();
71397323
let mut fuser = MultistaticFuser::with_config(MultistaticConfig {
71407324
min_nodes: 1, // single-node passthrough
7141-
..Default::default()
7325+
..cfg
71427326
});
71437327
if let Some(ref pos_str) = args.node_positions {
71447328
let positions = field_bridge::parse_node_positions(pos_str);

0 commit comments

Comments
 (0)