From 287885776b7f06f66d89b82abebd50c0179635b9 Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 13 Jun 2026 12:04:27 -0400 Subject: [PATCH] fix(signal): multistatic fusion guard too tight for real TDM hardware (#1031) MultistaticConfig::default().guard_interval_us was 5_000 us (5 ms) with a comment claiming "well within the 50 ms TDMA cycle". That is wrong: on an 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 us spread, so every real frame set exceeded the 5 ms guard and fuse() silently fell back to per-node sum/dedup -- multistatic fusion never ran on hardware. - Raise default hard guard to 60 ms (full 50 ms TDMA cycle + 20% jitter headroom, derived from the slot model and documented in the field doc). - Raise soft guard to 20 ms (just above the observed 18.2 ms 2-slot spread). - Add MultistaticConfig::for_tdm_schedule(total_slots, slot_duration_us). - Keep the honest per-node fallback for genuinely-mismatched frames. Tests (fail on the old 5 ms default): - fuse_real_tdm_spread_18194us_fuses_with_default_guard - configurable_guard_rejects_too_large_spread - for_tdm_schedule_invariants Co-Authored-By: claude-flow --- .../src/ruvsense/multistatic.rs | 149 ++++++++++++++++-- 1 file changed, 140 insertions(+), 9 deletions(-) diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs index 220fbdfa..d4ca9f89 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs @@ -84,11 +84,32 @@ pub struct FusedSensingFrame { #[derive(Debug, Clone)] pub struct MultistaticConfig { /// Maximum timestamp spread (microseconds) across nodes in one cycle. - /// Default: 5000 us (5 ms), well within the 50 ms TDMA cycle. + /// + /// # Derivation from the TDM schedule (issue #1031) + /// + /// In an N-slot TDMA mesh, node `k` transmits in slot `k`, so two nodes + /// are *deliberately* separated by `(cycle_us × slot_fraction)`. On a real + /// 2-node mesh (slots 0 and 1 of a ~36 ms cycle) we measured an + /// **18,194 µs** spread between paired frames — i.e. the spread is the slot + /// offset, NOT clock jitter. The previous 5,000 µs default therefore + /// rejected every real frame set and fusion silently fell back to per-node + /// sum/dedup, so multistatic fusion never actually ran on hardware. + /// + /// The default is now **60,000 µs (60 ms)**: a full 50 ms TDMA cycle (the + /// worst-case spread for the last slot of a maximally-loaded schedule) plus + /// ~20% headroom for inter-cycle scheduling jitter. This accepts a real + /// N-node cycle as coherent while still rejecting a spread that exceeds one + /// whole cycle (which would mean frames from *different* sensing cycles were + /// mixed). Tune per deployment with [`MultistaticConfig::for_tdm_schedule`]. pub guard_interval_us: u64, /// ADR-137 soft guard (microseconds): a spread above this but within /// `guard_interval_us` is fused but recorded as a `TimestampMismatch` - /// contradiction (loose alignment ⇒ privacy demotion). Default guard/5. + /// contradiction (loose alignment ⇒ privacy demotion). + /// + /// Set to **20,000 µs (20 ms)**: just above the observed 18,194 µs 2-slot + /// spread, so a normal 2-node cycle fuses *cleanly* (no demotion), but a + /// spread approaching a full cycle is flagged as loose alignment. Kept below + /// `guard_interval_us` so the soft band is meaningful. pub soft_guard_us: u64, /// Minimum number of nodes for multistatic mode. /// Falls back to single-node mode if fewer nodes are available. @@ -106,8 +127,11 @@ pub struct MultistaticConfig { impl Default for MultistaticConfig { fn default() -> Self { Self { - guard_interval_us: 5000, - soft_guard_us: 1000, + // 60 ms hard / 20 ms soft — see field docs for the TDM derivation + // (issue #1031). The old 5 ms hard guard rejected every real frame + // set (observed 2-slot spread ≈ 18.2 ms), silently disabling fusion. + guard_interval_us: 60_000, + soft_guard_us: 20_000, min_nodes: 2, attention_temperature: 1.0, enable_person_separation: true, @@ -116,6 +140,43 @@ impl Default for MultistaticConfig { } } +impl MultistaticConfig { + /// Derive a guard interval from an explicit TDM schedule (issue #1031). + /// + /// In an N-slot schedule with per-slot duration `slot_duration_us`, the + /// maximum legitimate spread between two paired node frames in one cycle is + /// the full cycle length `tdm_total_slots × slot_duration_us` (last slot vs + /// first slot). The hard guard is set to that cycle length plus 20% jitter + /// headroom; the soft guard to ~⅓ of the cycle (a normal adjacent-slot pair + /// fuses cleanly, a near-full-cycle spread is flagged as loose alignment). + /// + /// `tdm_total_slots` is clamped to ≥ 1. All other fields take their + /// [`Default`] values. + /// + /// # Example + /// ``` + /// use wifi_densepose_signal::ruvsense::multistatic::MultistaticConfig; + /// // 2 slots × 18 ms = 36 ms cycle → ~43 ms hard guard accepts the + /// // reported 18,194 µs 2-slot spread. + /// let cfg = MultistaticConfig::for_tdm_schedule(2, 18_000); + /// assert!(cfg.guard_interval_us >= 18_194); + /// ``` + #[must_use] + pub fn for_tdm_schedule(tdm_total_slots: usize, slot_duration_us: u64) -> Self { + let slots = tdm_total_slots.max(1) as u64; + let cycle_us = slots.saturating_mul(slot_duration_us); + // +20% jitter headroom on the full cycle. + let guard_interval_us = cycle_us.saturating_add(cycle_us / 5).max(1); + // Soft band at ~⅓ cycle, kept strictly below the hard guard. + let soft_guard_us = (cycle_us / 3).clamp(1, guard_interval_us.saturating_sub(1).max(1)); + Self { + guard_interval_us, + soft_guard_us, + ..Default::default() + } + } +} + /// Multistatic frame fuser. /// /// Collects per-node multi-band frames and produces a single fused @@ -825,21 +886,87 @@ mod tests { #[test] fn ac_fuse_scored_loose_alignment_flags_soft_contradiction() { use super::super::fusion_quality::ContradictionFlag; - // guard 5000 us; spread 2000 us is within guard but > soft_guard 1000 us. + // Default soft_guard is now 20_000 us (#1031). A spread above soft but + // within the 60_000 us hard guard is fused yet flagged as loose. Use a + // 25_000 us spread: > soft (20 ms), < hard (60 ms). let fuser = MultistaticFuser::new(); - let f0 = make_node_frame(0, 1000, 56, 1.0); - let f1 = make_node_frame(1, 3000, 56, 1.0); + let f0 = make_node_frame(0, 1_000, 56, 1.0); + let f1 = make_node_frame(1, 26_000, 56, 1.0); let (_fused, score) = fuser.fuse_scored(&[f0, f1], 0.85).unwrap(); assert!(score.forces_privacy_demotion(), "loose alignment ⇒ demotion"); assert!(matches!( score.contradiction_flags[0], - ContradictionFlag::TimestampMismatch { spread_ns: 2_000_000, soft_guard_ns: 1_000_000 } + ContradictionFlag::TimestampMismatch { spread_ns: 25_000_000, soft_guard_ns: 20_000_000 } )); // Penalized coherence is strictly below base when a contradiction fires. assert!(score.penalized_coherence() < score.base_coherence); } + /// REGRESSION (issue #1031): a real 2-node TDM frame set with an 18,194 µs + /// spread (the reported value) must FUSE under the default config — the old + /// 5,000 µs guard rejected it with `TimestampMismatch`, silently disabling + /// multistatic fusion on every real deployment. + #[test] + fn fuse_real_tdm_spread_18194us_fuses_with_default_guard() { + let fuser = MultistaticFuser::new(); // default config + let f0 = make_node_frame(0, 1_000, 56, 1.0); + let f1 = make_node_frame(1, 1_000 + 18_194, 56, 1.0); + let fused = fuser + .fuse(&[f0, f1]) + .expect("18,194 us 2-slot spread must fuse under the #1031 default guard"); + assert_eq!(fused.active_nodes, 2, "both nodes contribute (real fusion)"); + // The 18.2 ms spread is below the soft guard (20 ms), so fuse_scored + // records it as a CLEAN fuse (no privacy demotion) — the common case. + let f0b = make_node_frame(0, 1_000, 56, 1.0); + let f1b = make_node_frame(1, 1_000 + 18_194, 56, 1.0); + let (_f, score) = fuser.fuse_scored(&[f0b, f1b], 0.85).unwrap(); + assert!( + !score.forces_privacy_demotion(), + "a normal 2-slot spread (18.2 ms < 20 ms soft) must NOT demote privacy" + ); + } + + /// The guard still does its job: a spread larger than a whole TDM cycle + /// (frames from different cycles) is rejected. Uses a tight per-deployment + /// config derived from the schedule via `for_tdm_schedule`. + #[test] + fn configurable_guard_rejects_too_large_spread() { + // 2 slots × 18 ms = 36 ms cycle → ~43 ms hard guard. + let cfg = MultistaticConfig::for_tdm_schedule(2, 18_000); + assert!( + cfg.guard_interval_us >= 18_194, + "derived guard must accept the reported 2-slot spread: {}", + cfg.guard_interval_us + ); + let fuser = MultistaticFuser::with_config(cfg.clone()); + // A spread well beyond a full cycle (e.g. 2× the hard guard) is rejected. + let too_large = cfg.guard_interval_us * 2; + let f0 = make_node_frame(0, 0, 56, 1.0); + let f1 = make_node_frame(1, too_large, 56, 1.0); + assert!( + matches!( + fuser.fuse(&[f0, f1]), + Err(MultistaticError::TimestampMismatch { .. }) + ), + "a spread beyond a full TDM cycle must still be rejected" + ); + } + + /// The derived soft guard stays strictly below the hard guard, and a + /// degenerate (0-slot) schedule clamps to a usable config. + #[test] + fn for_tdm_schedule_invariants() { + let cfg = MultistaticConfig::for_tdm_schedule(4, 12_500); // 50 ms cycle + assert!(cfg.soft_guard_us < cfg.guard_interval_us); + assert!(cfg.guard_interval_us >= 50_000); + // Degenerate input clamps instead of producing a zero/overflow guard. + let degenerate = MultistaticConfig::for_tdm_schedule(0, 0); + assert!(degenerate.guard_interval_us >= 1); + assert!(degenerate.soft_guard_us >= 1); + assert!(degenerate.soft_guard_us < degenerate.guard_interval_us.max(2)); + } + #[test] fn ac_fuse_scored_calibrated_agreement_sets_id() { use super::super::fusion_quality::{CalibrationId, EvidenceRef}; @@ -996,7 +1123,11 @@ mod tests { #[test] fn default_config() { let cfg = MultistaticConfig::default(); - assert_eq!(cfg.guard_interval_us, 5000); + // #1031: hard guard raised to 60 ms (was 5 ms) to accommodate the real + // TDM slot offset; soft guard 20 ms, both strictly ordered. + assert_eq!(cfg.guard_interval_us, 60_000); + assert_eq!(cfg.soft_guard_us, 20_000); + assert!(cfg.soft_guard_us < cfg.guard_interval_us); assert_eq!(cfg.min_nodes, 2); assert!((cfg.attention_temperature - 1.0).abs() < f32::EPSILON); assert!(cfg.enable_person_separation);