From db81dce7baf2bbba2ec4c18c8ec709115309eb33 Mon Sep 17 00:00:00 2001 From: Mathew Beane Date: Sun, 4 Oct 2026 16:41:53 -0400 Subject: [PATCH] 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 --- ...R-029-ruvsense-multistatic-sensing-mode.md | 1 + .../benches/engine_cycle.rs | 3 +- v2/crates/wifi-densepose-engine/src/lib.rs | 24 +- .../src/engine_bridge.rs | 26 +- .../wifi-densepose-sensing-server/src/main.rs | 6 + .../src/multistatic_bridge.rs | 39 ++- .../wifi-densepose-signal/src/ruvsense/mod.rs | 4 +- .../src/ruvsense/multiband.rs | 33 ++- .../src/ruvsense/multistatic.rs | 280 +++++++++++++++++- 9 files changed, 394 insertions(+), 22 deletions(-) diff --git a/docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md b/docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md index 45e1c781c..78c593147 100644 --- a/docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md +++ b/docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md @@ -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) | diff --git a/v2/crates/wifi-densepose-engine/benches/engine_cycle.rs b/v2/crates/wifi-densepose-engine/benches/engine_cycle.rs index 5145cbd77..c38f29ff4 100644 --- a/v2/crates/wifi-densepose-engine/benches/engine_cycle.rs +++ b/v2/crates/wifi-densepose-engine/benches/engine_cycle.rs @@ -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, } } diff --git a/v2/crates/wifi-densepose-engine/src/lib.rs b/v2/crates/wifi-densepose-engine/src/lib.rs index 3ba767297..6d86c80d4 100644 --- a/v2/crates/wifi-densepose-engine/src/lib.rs +++ b/v2/crates/wifi-densepose-engine/src/lib.rs @@ -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, + /// 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, } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs index 25ab95b1c..f046a28d9 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs @@ -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, + /// Phase-combining mode of the most recent successful cycle (#1752). + phase_fusion: Option, } 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 { + 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); diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index b233710ed..9d8682e22 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -6627,6 +6627,12 @@ async fn health_ready(State(state): State) -> Json = 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" + ); + } } diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs index bd2d46787..e2f56af43 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs @@ -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, diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multiband.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multiband.rs index c71967424..5719ca418 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multiband.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multiband.rs @@ -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, /// 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()); } diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs index c543afe51..c2b312f3e 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs @@ -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, /// 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, /// Per-node multi-band frames (preserved for geometry computations). pub node_frames: Vec, @@ -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 { + 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(&litudes, &phases, self.config.attention_temperature) + attention_weighted_fusion(&litudes, 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, Vec, 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, (&, &ph)) in amplitudes.iter().zip(phases.iter()).enumerate() { + for (n, &) 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 = fused_ph_sin - .iter() - .zip(fused_ph_cos.iter()) - .map(|(&s, &c)| s.atan2(c)) - .collect(); + let fused_ph: Vec = 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 = (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 = 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"); + } }