fix(multistatic): gate phase combining on a declared shared reference

The sensing-server bridge builds every node frame with a zero phase
placeholder and the fuser combined those phases across nodes as if they
were measured against one reference. ESP32 nodes each run an independent
crystal with a random PLL phase after reset; mesh time sync aligns frames,
not RF phase, so there is nothing to combine.

- Add PhaseReference (Unavailable / NodeLocal / Shared{id}) to
  MultiBandCsiFrame. The server bridge declares Unavailable.
- MultistaticFuser combines phase only when every frame declares the same
  Shared id. Otherwise fused_phase is empty and FusedSensingFrame carries
  PhaseFusion::AmplitudeOnly(reason). Amplitude fusion is unchanged.
- The ADR-134 CIR blend is skipped when the phase is a placeholder.
- TrustedOutput and /health/ready (trust.phase_fusion) report the mode
  and reason.
- ADR-029 status note updated.

Refs #1752, #1710, #1750.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Mathew Beaneandclaude-flow committed 2026-10-04 16:42:03 -04:00
1 parent 7c8aeacea2
commit db81dce7ba
9 files changed
+394 -22

No files matched your search

@@ -4,6 +4,7 @@
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-02 |
| **Updated** | 2026-10-04 — multistatic fusion combines phase across nodes only when every frame declares the same shared phase reference; independent ESP32 nodes and the amplitude-only server bridge fuse amplitude only and report the reason (`phase_fusion` in `/health/ready`, issue #1752). |
| **Deciders** | ruv |
| **Codename** | **RuvSense** -- RuVector-Enhanced Sensing for Multistatic Fidelity |
| **Relates to** | ADR-012 (ESP32 Mesh), ADR-014 (SOTA Signal Processing), ADR-016 (RuVector Training), ADR-017 (RuVector Signal+MAT), ADR-018 (ESP32 Implementation), ADR-024 (AETHER Embeddings), ADR-026 (Survivor Track Lifecycle), ADR-027 (MERIDIAN Generalization) |
@@ -11,7 +11,7 @@ use wifi_densepose_engine::StreamingEngine;
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_signal::ruvsense::MultiBandCsiFrame;
use wifi_densepose_signal::ruvsense::{MultiBandCsiFrame, PhaseReference};
fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
MultiBandCsiFrame {
@@ -24,6 +24,7 @@ fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
phase_reference: PhaseReference::NodeLocal,
}
}
+23 -1
View File
@@ -36,7 +36,7 @@ use wifi_densepose_bfld::{PrivacyAction, PrivacyClass, PrivacyMode, PrivacyModeR
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_ruvector::viewpoint::coherence::ClockQualityScore;
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, MultistaticFuser};
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, MultistaticFuser, PhaseFusion};
use wifi_densepose_signal::ruvsense::{
ArrayCoordinator, ArrayCoordinatorConfig, ArrayNodeInput, ChangePoint, DirectionalEvidence,
EvolutionTracker, MultiBandCsiFrame, QualityScore, ReflectorObservation, RfSlam,
@@ -109,6 +109,10 @@ pub struct TrustedOutput {
/// structural event for the recalibration advisor and names the nodes
/// (`weak_side`) closest to splitting off — failure/jamming triage.
pub mesh: Option<MeshPartitionReport>,
/// Whether phase was combined across nodes this cycle, and why not when it
/// was not (issue #1752). Amplitude-only sources report
/// `AmplitudeOnly(PhaseUnavailable)`.
pub phase_fusion: PhaseFusion,
}
/// Composition root for the RuView streaming engine.
@@ -594,6 +598,7 @@ impl StreamingEngine {
witness,
recalibration_recommended,
mesh,
phase_fusion: fused.phase_fusion,
})
}
@@ -715,6 +720,7 @@ fn demote_one(c: PrivacyClass) -> PrivacyClass {
mod tests {
use super::*;
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
use wifi_densepose_signal::ruvsense::PhaseReference;
fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
MultiBandCsiFrame {
@@ -727,6 +733,7 @@ mod tests {
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
phase_reference: PhaseReference::NodeLocal,
}
}
@@ -737,6 +744,20 @@ mod tests {
(e, room)
}
/// Issue #1752: independent node oscillators never yield a coherent cycle;
/// the trusted output says phase was not combined and why.
#[test]
fn independent_nodes_report_amplitude_only_phase_fusion() {
use wifi_densepose_signal::ruvsense::NonCoherentReason;
let (mut e, room) = engine();
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
let out = e.process_cycle(&frames, CalibrationId(1), room, 10_000).unwrap();
assert_eq!(
out.phase_fusion,
PhaseFusion::AmplitudeOnly(NonCoherentReason::IndependentClocks)
);
}
/// End-to-end trust invariant: a clean cycle produces a SemanticState whose
/// provenance names evidence + model + calibration + privacy decision, and
/// the calibration id flows from input → QualityScore → provenance.
@@ -1015,6 +1036,7 @@ mod tests {
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
phase_reference: PhaseReference::NodeLocal,
}
}
@@ -38,7 +38,7 @@ use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_signal::ruvsense::multistatic::MultistaticConfig;
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, PhaseFusion};
use wifi_densepose_worldgraph::WorldId;
use super::multistatic_bridge::node_frames_from_states_with_guard;
@@ -77,6 +77,8 @@ pub struct EngineBridge {
engine_error_count: u64,
/// Last time an engine error was actually logged (rate limiter).
last_error_warn_at: Option<Instant>,
/// Phase-combining mode of the most recent successful cycle (#1752).
phase_fusion: Option<PhaseFusion>,
}
impl EngineBridge {
@@ -118,6 +120,7 @@ impl EngineBridge {
demoted: false,
engine_error_count: 0,
last_error_warn_at: None,
phase_fusion: None,
}
}
@@ -218,6 +221,7 @@ impl EngineBridge {
self.recalibration_recommended = trust.recalibration_recommended;
self.effective_class = Some(trust.effective_class);
self.demoted = trust.demoted;
self.phase_fusion = Some(trust.phase_fusion);
Some(trust)
}
Err(e) => {
@@ -260,6 +264,12 @@ impl EngineBridge {
self.demoted
}
/// Phase-combining mode of the most recent successful cycle (#1752);
/// `None` until a governed cycle has run.
pub fn phase_fusion(&self) -> Option<PhaseFusion> {
self.phase_fusion
}
/// Engine cycles that returned an error since startup.
pub fn engine_error_count(&self) -> u64 {
self.engine_error_count
@@ -308,6 +318,20 @@ mod tests {
assert_eq!(bridge.registered_node_count(), 0);
}
/// Issue #1752: the live amplitude-only path reports that phase was not
/// combined, with the reason, instead of implying coherent fusion.
#[test]
fn live_cycle_reports_amplitude_only_phase_fusion() {
use wifi_densepose_signal::ruvsense::multistatic::NonCoherentReason;
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None);
assert_eq!(bridge.phase_fusion(), None);
bridge.observe_cycle(&two_node_states(), 10_000).expect("cycle succeeds");
let mode = bridge.phase_fusion().expect("recorded after a cycle");
assert_eq!(mode, PhaseFusion::AmplitudeOnly(NonCoherentReason::PhaseUnavailable));
assert_eq!(mode.mode_str(), "amplitude_only");
assert_eq!(mode.reason().map(NonCoherentReason::as_str), Some("phase_unavailable"));
}
#[test]
fn live_cycle_produces_witnessed_belief_with_provenance() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None);
@@ -6627,6 +6627,12 @@ async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Valu
"recalibration_recommended": s.engine_bridge.recalibration_recommended(),
"engine_error_count": s.engine_bridge.engine_error_count(),
"raw_outputs_suppressed": s.engine_bridge.suppress_raw_outputs(),
// Issue #1752: whether phase was combined across nodes, and why
// not. Amplitude-only ESP32 nodes report "amplitude_only".
"phase_fusion": s.engine_bridge.phase_fusion().map(|p| serde_json::json!({
"mode": p.mode_str(),
"reason": p.reason().map(|r| r.as_str()),
})),
},
}))
}
@@ -11,7 +11,7 @@ use std::sync::LazyLock;
use std::time::{Duration, Instant};
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareNormalizer, HardwareType};
use wifi_densepose_signal::ruvsense::multiband::MultiBandCsiFrame;
use wifi_densepose_signal::ruvsense::multiband::{MultiBandCsiFrame, PhaseReference};
use wifi_densepose_signal::ruvsense::multistatic::{
FusedSensingFrame, MultistaticConfig, MultistaticFuser,
};
@@ -78,6 +78,9 @@ fn node_frame_from_state_at(
let canonical_amp = NORMALIZER.resample_to_canonical(latest);
let amplitude: Vec<f32> = canonical_amp.iter().map(|&v| v as f32).collect();
let n_sub = amplitude.len();
// Issue #1752: the server keeps amplitude history only, so this vector is
// a placeholder, not measured phase. `PhaseReference::Unavailable` below
// stops the fuser from combining it or treating it as evidence.
let phase = vec![0.0_f32; n_sub];
let canonical = CanonicalCsiFrame {
@@ -91,7 +94,12 @@ fn node_frame_from_state_at(
timestamp_us,
channel_frames: vec![canonical],
frequencies_mhz: vec![DEFAULT_FREQ_MHZ],
coherence: 1.0, // single-channel, perfect self-coherence
// Cross-channel amplitude agreement: trivially 1.0 for one channel.
// This is not cross-node phase coherence; that is `phase_reference`.
coherence: 1.0,
// Independent ESP32 crystals with a random PLL phase per reset, and
// no phase kept here: never phase-coherent across nodes.
phase_reference: PhaseReference::Unavailable,
})
}
@@ -305,8 +313,9 @@ mod tests {
// the person-score relies on is intact.
assert!((ch.amplitude[0] - 10.0_f32).abs() < 1e-3);
assert!((ch.amplitude[55] - 30.5_f32).abs() < 1e-3);
// Phase should be all zeros
// Phase is a zero placeholder and is declared as such (#1752).
assert!(ch.phase.iter().all(|&p| p == 0.0));
assert_eq!(frame.phase_reference, PhaseReference::Unavailable);
assert_eq!(ch.hardware_type, HardwareType::Esp32S3);
}
@@ -610,4 +619,28 @@ mod tests {
assert!(fused.is_none());
assert_eq!(count, Some(0));
}
/// Issue #1752: live ESP32 nodes have independent oscillators and the
/// bridge keeps no phase, so a multi-node cycle must not emit a fused
/// phase vector built from the zero placeholders.
#[test]
fn live_nodes_are_fused_amplitude_only() {
let now = Instant::now();
let mut states = HashMap::new();
for (node_id, scale) in [(1u8, 1.0), (2u8, 1.4)] {
let mut history = VecDeque::new();
history.push_back((0..64).map(|i| scale * (1.0 + 0.1 * f64::from(i))).collect());
states.insert(node_id, make_node_state(history, Some(now), 0));
}
let fuser = MultistaticFuser::new();
let (fused, fallback) = fuse_or_fallback(&fuser, &states, 3.0);
let fused = fused.expect("two fresh nodes fuse");
assert!(fallback.is_none());
assert_eq!(fused.active_nodes, 2);
assert_eq!(fused.fused_amplitude.len(), 56);
assert!(
fused.fused_phase.is_empty(),
"placeholder phase must not be combined across nodes"
);
}
}
@@ -86,8 +86,8 @@ pub use rf_slam::{PersistentReflector, ReflectorClass, ReflectorObservation, RfS
pub use fusion_quality::{
CalibrationId, ContradictionFlag, EvidenceRef, FamilyId, QualityScore,
};
pub use multiband::MultiBandCsiFrame;
pub use multistatic::FusedSensingFrame;
pub use multiband::{MultiBandCsiFrame, PhaseReference};
pub use multistatic::{FusedSensingFrame, NonCoherentReason, PhaseFusion};
pub use phase_align::{PhaseAlignError, PhaseAligner};
pub use pose_tracker::{
CompressedPoseHistory, KeypointState, PoseTrack, SkeletonConstraints,
@@ -39,10 +39,36 @@ pub enum MultiBandError {
DuplicateFrequency { freq_mhz: u32, idx: usize },
}
/// What a frame's phase vector is evidence of (issue #1752).
///
/// Phase from two radios can only be combined when both were measured against
/// one reference. Independent ESP32 nodes each run a free crystal and pick a
/// random PLL phase at every reset, so their phases are unrelated even when
/// their timestamps agree to the frame. Mesh time sync aligns frames, not RF
/// phase, and does not by itself justify [`PhaseReference::Shared`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PhaseReference {
/// No measured phase. The phase vector is a placeholder (for example the
/// amplitude-only sensing-server bridge) and must never be read as evidence.
#[default]
Unavailable,
/// Phase measured against this node's own oscillator. Meaningful within the
/// node, meaningless across nodes.
NodeLocal,
/// The source declares a shared clock or calibrated phase reference. Frames
/// carrying the same `id` may be combined coherently.
Shared {
/// Identifier of the shared reference (one per clock domain / array).
id: u32,
},
}
/// Fused multi-band CSI from one node at one time slot.
///
/// Holds one canonical-56 row per channel, ordered by center frequency.
/// The `coherence` field quantifies agreement across channels (0.0-1.0).
/// The `coherence` field quantifies amplitude agreement across this node's
/// channels (0.0-1.0). It says nothing about phase coherence with other
/// nodes; that is carried by `phase_reference`.
#[derive(Debug, Clone)]
pub struct MultiBandCsiFrame {
/// Originating node identifier (0-255).
@@ -55,6 +81,8 @@ pub struct MultiBandCsiFrame {
pub frequencies_mhz: Vec<u32>,
/// Cross-channel coherence score (0.0-1.0).
pub coherence: f32,
/// What `channel_frames[*].phase` is evidence of (issue #1752).
pub phase_reference: PhaseReference,
}
/// Configuration for the multi-band fusion process.
@@ -165,6 +193,8 @@ impl MultiBandBuilder {
channel_frames: self.frames,
frequencies_mhz: self.frequencies,
coherence,
// One node's own channels: measured phase, local reference only.
phase_reference: PhaseReference::NodeLocal,
})
}
}
@@ -415,6 +445,7 @@ mod tests {
channel_frames: vec![],
frequencies_mhz: vec![],
coherence: 1.0,
phase_reference: PhaseReference::Unavailable,
};
assert!(mean_amplitude(&frame).is_empty());
}
@@ -30,7 +30,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use super::cir::{CirConfig, CirEstimator};
use super::multiband::MultiBandCsiFrame;
use super::multiband::{MultiBandCsiFrame, PhaseReference};
/// Errors from multistatic fusion.
#[derive(Debug, thiserror::Error)]
@@ -68,7 +68,12 @@ pub struct FusedSensingFrame {
/// Length = n_subcarriers.
pub fused_amplitude: Vec<f32>,
/// Fused phase vector across all nodes.
/// Length = n_subcarriers.
///
/// Length = n_subcarriers when [`Self::phase_fusion`] carries phase
/// ([`PhaseFusion::Coherent`] or [`PhaseFusion::SingleNode`]). **Empty**
/// for [`PhaseFusion::AmplitudeOnly`]: phases with no shared reference are
/// not combined, because a weighted mean of unrelated phases is not a
/// measurement (issue #1752).
pub fused_phase: Vec<f32>,
/// Per-node multi-band frames (preserved for geometry computations).
pub node_frames: Vec<MultiBandCsiFrame>,
@@ -84,7 +89,115 @@ pub struct FusedSensingFrame {
pub active_nodes: usize,
/// Cross-node coherence score (0.0-1.0). Higher means more agreement
/// across viewpoints, indicating a strong body reflection signal.
///
/// Derived from amplitude attention weights. It is **not** RF phase
/// coherence and never implies the nodes share a phase reference; see
/// [`Self::phase_fusion`] for that.
pub cross_node_coherence: f32,
/// How (and whether) phase was combined this cycle, with the reason when
/// it was not (issue #1752).
pub phase_fusion: PhaseFusion,
}
/// Why phase was not combined across nodes (issue #1752).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NonCoherentReason {
/// At least one frame carries no measured phase (placeholder vector).
PhaseUnavailable,
/// Phases were measured, but against independent per-node oscillators.
IndependentClocks,
/// Frames declare shared references, but not the same one.
MixedReferences,
}
impl NonCoherentReason {
/// Stable machine-readable label for status/API output.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::PhaseUnavailable => "phase_unavailable",
Self::IndependentClocks => "independent_clocks",
Self::MixedReferences => "mixed_references",
}
}
}
/// Phase-combining mode for one fusion cycle (issue #1752).
///
/// Amplitude fusion is identical in every mode; only the phase output differs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhaseFusion {
/// Every frame declared the same shared phase reference, so phase was
/// combined across nodes.
Coherent {
/// The shared reference all frames declared.
reference_id: u32,
},
/// One node with measured phase: its phase passes through uncombined.
SingleNode,
/// Phase was not combined; `fused_phase` is empty.
AmplitudeOnly(NonCoherentReason),
}
impl PhaseFusion {
/// Decide the phase-combining mode for a cohort. Coherent combining needs
/// every frame to declare the same [`PhaseReference::Shared`] id; anything
/// less falls back to amplitude-only with the reason.
#[must_use]
pub fn for_frames(node_frames: &[MultiBandCsiFrame]) -> Self {
if node_frames.is_empty()
|| node_frames
.iter()
.any(|f| f.phase_reference == PhaseReference::Unavailable)
{
return Self::AmplitudeOnly(NonCoherentReason::PhaseUnavailable);
}
if node_frames.len() == 1 {
return Self::SingleNode;
}
let mut shared = node_frames.iter().map(|f| match f.phase_reference {
PhaseReference::Shared { id } => Some(id),
_ => None,
});
let Some(Some(first)) = shared.next() else {
return Self::AmplitudeOnly(NonCoherentReason::IndependentClocks);
};
for id in shared {
match id {
None => return Self::AmplitudeOnly(NonCoherentReason::IndependentClocks),
Some(id) if id != first => {
return Self::AmplitudeOnly(NonCoherentReason::MixedReferences)
}
Some(_) => {}
}
}
Self::Coherent { reference_id: first }
}
/// Whether this cycle produced a phase vector.
#[must_use]
pub fn carries_phase(self) -> bool {
!matches!(self, Self::AmplitudeOnly(_))
}
/// Stable machine-readable mode label for status/API output.
#[must_use]
pub fn mode_str(self) -> &'static str {
match self {
Self::Coherent { .. } => "coherent",
Self::SingleNode => "single_node",
Self::AmplitudeOnly(_) => "amplitude_only",
}
}
/// Reason phase was not combined, if it was not.
#[must_use]
pub fn reason(self) -> Option<NonCoherentReason> {
match self {
Self::AmplitudeOnly(reason) => Some(reason),
_ => None,
}
}
}
/// Configuration for multistatic fusion.
@@ -443,13 +556,19 @@ impl MultistaticFuser {
}
}
// Issue #1752: only combine phase when the frames share a declared
// phase reference. Otherwise fuse amplitude alone and say why.
let phase_fusion = PhaseFusion::for_frames(node_frames);
let phases = phase_fusion.carries_phase().then_some(phases.as_slice());
let n_nodes = amplitudes.len();
let (fused_amp, fused_ph, freq_coherence) = if n_nodes == 1 {
// Single-node fallback
(amplitudes[0].to_vec(), phases[0].to_vec(), 1.0_f32)
let ph = phases.map_or_else(Vec::new, |p| p[0].to_vec());
(amplitudes[0].to_vec(), ph, 1.0_f32)
} else {
// Multi-node attention-weighted fusion
attention_weighted_fusion(&amplitudes, &phases, self.config.attention_temperature)
attention_weighted_fusion(&amplitudes, phases, self.config.attention_temperature)
};
// ADR-134 CIR gate: blend freq-domain coherence with CIR dominant-tap
@@ -488,6 +607,7 @@ impl MultistaticFuser {
node_positions: positions,
active_nodes: n_nodes,
cross_node_coherence: coherence,
phase_fusion,
})
}
@@ -662,6 +782,11 @@ impl MultistaticFuser {
let Some(cf) = first_frame.channel_frames.first() else {
return freq_coherence;
};
// Issue #1752: a placeholder phase vector would make the CIR describe
// a zero-phase spectrum, not the channel. Skip the blend.
if first_frame.phase_reference == PhaseReference::Unavailable {
return freq_coherence;
}
// Reconstruct Complex64 data from amplitude+phase for the CIR estimator.
let csi_frame = build_csi_frame_from_channel(cf);
@@ -747,10 +872,12 @@ fn build_csi_frame_from_channel(
/// Attention-weighted fusion of amplitude and phase vectors from multiple nodes.
///
/// Each node's contribution is weighted by its agreement with the consensus.
/// Phase is combined only when `phases` is `Some` (a shared phase reference,
/// issue #1752); otherwise the returned phase vector is empty.
/// Returns (fused_amplitude, fused_phase, cross_node_coherence).
fn attention_weighted_fusion(
amplitudes: &[&[f32]],
phases: &[&[f32]],
phases: Option<&[&[f32]]>,
temperature: f32,
) -> (Vec<f32>, Vec<f32>, f32) {
let n_sub = amplitudes[0].len();
@@ -763,21 +890,29 @@ fn attention_weighted_fusion(
let mut fused_ph_sin = vec![0.0_f32; n_sub];
let mut fused_ph_cos = vec![0.0_f32; n_sub];
for (n, (&amp, &ph)) in amplitudes.iter().zip(phases.iter()).enumerate() {
for (n, &amp) in amplitudes.iter().enumerate() {
let w = weights[n];
for i in 0..n_sub {
fused_amp[i] += w * amp[i];
fused_ph_sin[i] += w * ph[i].sin();
fused_ph_cos[i] += w * ph[i].cos();
}
if let Some(ph) = phases.and_then(|p| p.get(n)) {
for i in 0..n_sub {
fused_ph_sin[i] += w * ph[i].sin();
fused_ph_cos[i] += w * ph[i].cos();
}
}
}
// Recover phase from sin/cos weighted average
let fused_ph: Vec<f32> = fused_ph_sin
.iter()
.zip(fused_ph_cos.iter())
.map(|(&s, &c)| s.atan2(c))
.collect();
let fused_ph: Vec<f32> = if phases.is_some() {
fused_ph_sin
.iter()
.zip(fused_ph_cos.iter())
.map(|(&s, &c)| s.atan2(c))
.collect()
} else {
Vec::new()
};
// Coherence = mean weight entropy proxy: high when weights are balanced
let coherence = compute_weight_coherence(&weights);
@@ -942,9 +1077,15 @@ mod tests {
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
phase_reference: PhaseReference::NodeLocal,
}
}
fn with_reference(mut f: MultiBandCsiFrame, r: PhaseReference) -> MultiBandCsiFrame {
f.phase_reference = r;
f
}
#[test]
fn fuse_single_node_fallback() {
let fuser = MultistaticFuser::new();
@@ -1705,4 +1846,117 @@ mod tests {
"dead ht20 gate silently equals gate-off: dead={coh_dead} off={coh_off}"
);
}
// ===== Issue #1752: non-coherent phase gate =====
#[test]
fn placeholder_phase_is_never_combined() {
let fuser = MultistaticFuser::new();
let frames = [
with_reference(make_node_frame(0, 1000, 56, 1.0), PhaseReference::Unavailable),
with_reference(make_node_frame(1, 1001, 56, 1.1), PhaseReference::Unavailable),
];
let fused = fuser.fuse(&frames).unwrap();
assert_eq!(
fused.phase_fusion,
PhaseFusion::AmplitudeOnly(NonCoherentReason::PhaseUnavailable)
);
assert!(fused.fused_phase.is_empty(), "placeholder phase must not be fused");
assert_eq!(fused.fused_amplitude.len(), 56);
}
#[test]
fn independent_node_clocks_fall_back_to_amplitude_only() {
let fuser = MultistaticFuser::new();
let frames = [make_node_frame(0, 1000, 56, 1.0), make_node_frame(1, 1001, 56, 1.1)];
let fused = fuser.fuse(&frames).unwrap();
assert_eq!(
fused.phase_fusion,
PhaseFusion::AmplitudeOnly(NonCoherentReason::IndependentClocks)
);
assert_eq!(fused.phase_fusion.reason().map(NonCoherentReason::as_str), Some("independent_clocks"));
assert!(fused.fused_phase.is_empty());
}
#[test]
fn one_placeholder_node_demotes_the_whole_cohort() {
let shared = PhaseReference::Shared { id: 7 };
let frames = [
with_reference(make_node_frame(0, 1000, 56, 1.0), shared),
with_reference(make_node_frame(1, 1001, 56, 1.0), PhaseReference::Unavailable),
];
assert_eq!(
PhaseFusion::for_frames(&frames),
PhaseFusion::AmplitudeOnly(NonCoherentReason::PhaseUnavailable)
);
}
#[test]
fn mixed_shared_references_are_not_coherent() {
let frames = [
with_reference(make_node_frame(0, 1000, 56, 1.0), PhaseReference::Shared { id: 1 }),
with_reference(make_node_frame(1, 1001, 56, 1.0), PhaseReference::Shared { id: 2 }),
];
assert_eq!(
PhaseFusion::for_frames(&frames),
PhaseFusion::AmplitudeOnly(NonCoherentReason::MixedReferences)
);
}
/// Opt-in path for a future shared-clock source (for example a coherent
/// multi-radio array): frames that declare one shared reference are
/// combined coherently and produce a phase vector.
#[test]
fn shared_reference_source_opts_into_coherent_fusion() {
let fuser = MultistaticFuser::new();
let shared = PhaseReference::Shared { id: 42 };
let frames: Vec<MultiBandCsiFrame> = (0..3)
.map(|i| with_reference(make_node_frame(i, 1000 + u64::from(i), 56, 1.0), shared))
.collect();
let fused = fuser.fuse(&frames).unwrap();
assert_eq!(fused.phase_fusion, PhaseFusion::Coherent { reference_id: 42 });
assert_eq!(fused.fused_phase.len(), 56);
// Identical phases under one reference fuse back to themselves.
let expected = &frames[0].channel_frames[0].phase;
for (got, want) in fused.fused_phase.iter().zip(expected) {
assert!((got - want).abs() < 1e-5, "{got} vs {want}");
}
}
#[test]
fn single_node_passes_measured_phase_through() {
let fuser = MultistaticFuser::new();
let f = make_node_frame(0, 1000, 56, 1.0);
let fused = fuser.fuse(std::slice::from_ref(&f)).unwrap();
assert_eq!(fused.phase_fusion, PhaseFusion::SingleNode);
assert_eq!(fused.fused_phase, f.channel_frames[0].phase);
}
#[test]
fn amplitude_fusion_is_unchanged_by_the_phase_gate() {
let fuser = MultistaticFuser::new();
let local = [make_node_frame(0, 1000, 56, 1.0), make_node_frame(1, 1001, 56, 1.3)];
let shared: Vec<MultiBandCsiFrame> = local
.iter()
.cloned()
.map(|f| with_reference(f, PhaseReference::Shared { id: 1 }))
.collect();
let a = fuser.fuse(&local).unwrap();
let b = fuser.fuse(&shared).unwrap();
assert_eq!(a.fused_amplitude, b.fused_amplitude);
assert_eq!(a.cross_node_coherence, b.cross_node_coherence);
}
#[test]
fn cir_gate_ignores_placeholder_phase() {
let on = MultistaticFuser::with_cir_canonical56();
let off = MultistaticFuser::new();
let frames = [
with_reference(make_node_frame(0, 1000, 56, 1.0), PhaseReference::Unavailable),
with_reference(make_node_frame(1, 1001, 56, 1.2), PhaseReference::Unavailable),
];
let coh_on = on.fuse(&frames).unwrap().cross_node_coherence;
let coh_off = off.fuse(&frames).unwrap().cross_node_coherence;
assert_eq!(coh_on, coh_off, "no CIR blend from a placeholder phase vector");
}
}