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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-06-13 12:04:27 -04:00
parent 29e937ef52
commit 287885776b
1 changed files with 140 additions and 9 deletions

View File

@ -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);