mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 22:22:33 -05:00
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:
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(&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<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, (&, &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<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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user