wifi-densepose/docs/adr/ADR-138-linkgroup-array-coo...

45 KiB
Raw Permalink Blame History

ADR-138: WiFi-7 MLO LinkGroup Abstraction and ArrayCoordinator Clock-Quality Gating

Field Value
Status Proposed
Date 2026-05-28
Deciders ruv
Codebase target wifi-densepose-signal (ruvsense/multiband.rs, ruvsense/multistatic.rs); wifi-densepose-ruvector (viewpoint/geometry.rs, viewpoint/coherence.rs, viewpoint/attention.rs, viewpoint/fusion.rs)
Relates to ADR-008 (CSI Frame Primitives), ADR-029 (RuvSense Multistatic), ADR-030 (Persistent Field Model), ADR-031 (RuView Sensing-First RF Mode), ADR-110 (ESP32-C6 Firmware Extension / 802.15.4 sync), ADR-136 (RuView Rust Streaming Engine — frame contracts), ADR-137 (Fusion Engine Quality Scoring — evidence references and contradiction flags)

1. Context

1.1 The Gap

Searching across the two named crates for LinkGroup, ArrayCoordinator, clock_quality, DirectionalEvidence, and FreqSet finds no production module. The pieces that an MLO-aware coordinator would compose all exist, but each is wired to a single CSI stream, a single clock domain, and emits a hard fused output rather than weighted evidence. Concretely:

  • ruvsense/multiband.rs has MultiBandCsiFrame { node_id, timestamp_us, channel_frames: Vec<CanonicalCsiFrame>, frequencies_mhz: Vec<u32>, coherence } and a MultiBandBuilder that fuses per-channel rows from a channel-hopping radio (one ESP32-S3 cycling 1/6/11). This is the closest thing to a per-band feature stream, but it models sequential channel hopping on one radio, not simultaneous WiFi-7 Multi-Link Operation (MLO) where bands stream concurrently. There is no aggregate that tracks which bands are currently live versus which have dropped out, and coherence is a single Pearson scalar (compute_cross_channel_coherence), not an inter-band consensus with promotion semantics.

  • ruvsense/multistatic.rs has MultistaticFuser::fuse(&[MultiBandCsiFrame]) -> FusedSensingFrame. It already validates a guard_interval_us timestamp spread (MultistaticConfig.guard_interval_us, default 5000 µs) and computes geometric_diversity(&[[f32;3]]) from node positions. But: (a) the timestamp spread is a hard accept/reject — there is no notion of clock quality (a node whose clock is merely uncertain is treated identically to one whose clock is good); (b) geometric_diversity() is a free function returning a bare f32, not gated into the fusion decision; (c) the output FusedSensingFrame is a committed fused_amplitude/fused_phase pose-bearing artifact, not directional evidence with credence intervals.

  • viewpoint/geometry.rs has GeometricDiversityIndex::compute(azimuths, node_ids) -> Option<Self> with value, n_effective, worst_pair, is_sufficient() (threshold value >= PI/N), plus CramerRaoBound::estimate(target, &[ViewpointPosition]) -> Option<Self> returning crb_x, crb_y, rmse_lower_bound, gdop. This is exactly the GDI + Cramér-Rao machinery this ADR needs to convert into a gate and into credence intervals — but nothing currently calls it from the multistatic path. The two geometric_diversity implementations (the multistatic.rs free function and the geometry.rs GeometricDiversityIndex) are unaware of each other.

  • viewpoint/coherence.rs has CoherenceState (rolling phasor window with push/coherence()) and CoherenceGate { threshold, hysteresis, evaluate() }. The gate already implements hysteresis and a duty cycle. But it gates only on phase coherence — there is no clock-quality term, and no "contradiction" notion: a coherence drop merely closes the gate, it does not demote a band/group to monitoring-only nor flag the contradiction for downstream.

  • viewpoint/fusion.rs has MultistaticArray (the DDD aggregate root) with submit_viewpoint, push_phase_diff, fuse() -> FusedEmbedding, compute_gdi(), and a ViewpointFusionEvent enum (ViewpointCaptured, TdmCycleCompleted, FusionCompleted, CoherenceGateTriggered, GeometryUpdated). fuse() already filters by SNR and gates on coherence, returning FusionError::CoherenceGateClosed when the environment is unstable. But the aggregate is keyed on embeddings (AETHER 128-d vectors) and produces a pose-feeding FusedEmbedding — there is no per-band lifecycle, no clock-quality input, and the "gate closed" path silently drops the cycle rather than demoting to a monitoring-only state that still emits evidence.

  • wifi-densepose-hardware/src/sync_packet.rs is fully implemented: SyncPacket decodes the ADR-110 §A0.12 wire format (magic 0xC511A110, 32 bytes LE), exposes local_minus_epoch_us(), apply_to_local(), and mesh_aligned_us_for_sequence(frame_seq, fps_hz). The sensing server (wifi-densepose-sensing-server/src/main.rs) already dispatches on SYNC_PACKET_MAGIC and applies a 9-second staleness gate (mesh_aligned_us_for_csi_frame). What is missing: a clock-quality score derived from the sync stream (offset dispersion / leader-vs-follower / staleness) that the signal-domain fusion can consult. The hardware crate recovers mesh_aligned_us but never propagates a quality of that alignment into multistatic.rs or viewpoint/.

The consequence: the array treats every node as if its clock were perfect and its geometry adequate, and it commits to a fused pose even when (a) only one MLO band survived, (b) the contributing nodes are clustered (low GDI), or (c) a node's clock has drifted past the point where its phase is comparable to its peers. ADR-137 (sibling, Proposed) requires every fused output to carry evidence references and contradiction flags; ADR-136 (sibling, Proposed) defines the FrameMeta frame contract that should carry mesh_aligned_us and clock metadata per frame. This ADR supplies the missing middle: a lifetime-managed LinkGroup that knows which bands are live, and an ArrayCoordinator service that gates on geometry and clock quality and emits DirectionalEvidence instead of a hard decision.

1.2 What "LinkGroup" and "ArrayCoordinator" Mean Here

  • A LinkGroup is a lifetime-managed aggregate representing one physical link operating WiFi-7 MLO: a set of concurrent bands (2.4 / 5 / 6 GHz) that the radio streams simultaneously, each producing its own CanonicalCsiFrame. The LinkGroup wraps a FreqSet (the declared band membership) plus a rolling Vec<MultiBandCsiFrame> per band, and tracks band lifecycle — a band can enter (start streaming), exit (drop out, e.g. 6 GHz lost when the AP reboots), and be promoted to the consensus set once it agrees with its peers. This is distinct from today's MultiBandCsiFrame, which is a snapshot of one hop cycle with no membership lifecycle.

  • An ArrayCoordinator is a service (not an aggregate). It consumes a set of LinkGroups plus the per-node frames already modelled by multistatic.rs, applies two gates — a geometry gate (GDI / Cramér-Rao from viewpoint/geometry.rs) and a clock-quality gate (ADR-110 sync dispersion) — and returns DirectionalEvidence: attention weights per viewpoint plus credence intervals derived from the Cramér-Rao bound. It does not decide pose. The pose/semantic decision is downstream (ADR-137 fusion-engine quality scoring); the coordinator only says "here is what the array can and cannot see right now, and how much to trust each direction."

1.3 Why Not a Single Hard Gate

The existing CoherenceGate::evaluate() and MultistaticConfig.guard_interval_us are both binary: update / no-update, accept / reject. WiFi-7 MLO and multi-node arrays degrade gracefully — losing the 6 GHz band, or a node whose clock dispersion rose from 40 µs to 180 µs, does not invalidate the array; it narrows what it can resolve and widens the credence interval. A hard gate throws away usable evidence. The decision below replaces the binary gates with a graded coordinator output that downgrades rather than discards, and feeds the graded result into ADR-137's contradiction machinery.

1.4 Pipeline Position

Per-band CSI (MLO: 2.4 / 5 / 6 GHz concurrent)
  → multiband.rs MultiBandBuilder          (per-band CanonicalCsiFrame rows)
  → LinkGroup::ingest()           ← NEW     (band enter/exit + consensus promote)
  → ArrayCoordinator::coordinate()  ← NEW   (service: GDI gate + clock-quality gate)
        │  consumes: Vec<LinkGroup>, node_frames, Vec<SyncPacket> (ADR-110)
        │  uses:     GeometricDiversityIndex + CramerRaoBound (viewpoint/geometry.rs)
        │            ClockQualityGate  ← NEW (wraps viewpoint/coherence.rs CoherenceGate)
        ▼
  → DirectionalEvidence            ← NEW     (attention weights + credence intervals)
  → multistatic.rs MultistaticFuser.fuse()  (consumes weights, NOT a re-decision)
  → ADR-137 FusionEngine quality scoring + contradiction flags

The coordinator sits between per-band ingestion and the existing MultistaticFuser. It does not replace fuse(); it supplies the weights fuse() already wants (today attention_weighted_fusion derives them internally from amplitude similarity only) and the contradiction flags ADR-137 consumes.


2. Decision

2.1 LinkGroup: Lifetime-Managed MLO Aggregate

A LinkGroup is added to ruvsense/multiband.rs (it composes the existing MultiBandCsiFrame and CanonicalCsiFrame). It is an aggregate with explicit band lifecycle, not a snapshot.

use crate::hardware_norm::CanonicalCsiFrame;

/// The declared set of MLO bands a link operates on (WiFi-7: up to 3).
/// Membership is *declared* at construction; liveness is tracked separately.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FreqSet {
    /// Center frequencies (MHz), sorted ascending. e.g. [2412, 5180, 5955].
    pub bands_mhz: Vec<u32>,
}

impl FreqSet {
    pub fn new(mut bands_mhz: Vec<u32>) -> Self {
        bands_mhz.sort_unstable();
        bands_mhz.dedup();
        Self { bands_mhz }
    }
    pub fn contains(&self, freq_mhz: u32) -> bool { self.bands_mhz.contains(&freq_mhz) }
    pub fn len(&self) -> usize { self.bands_mhz.len() }
    pub fn is_empty(&self) -> bool { self.bands_mhz.is_empty() }
}

/// Lifecycle state of one band within a LinkGroup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BandState {
    /// Declared in the FreqSet but no frame seen yet (warm-up).
    Pending,
    /// Streaming frames, but not yet agreeing with peers.
    Live,
    /// Live AND consensus-promoted: agrees with the group's other live bands.
    Promoted,
    /// Was Live, has missed `exit_after_missed` expected frames.
    Exited,
}

/// Domain events emitted by a LinkGroup (event-sourced state changes, per house rule).
#[derive(Debug, Clone, PartialEq)]
pub enum LinkGroupEvent {
    BandEntered { freq_mhz: u32, at_us: u64 },
    BandExited  { freq_mhz: u32, at_us: u64, missed: u32 },
    BandPromoted { freq_mhz: u32, at_us: u64, consensus: f32 },
    BandDemoted  { freq_mhz: u32, at_us: u64, reason: DemotionReason },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DemotionReason {
    /// Inter-band consensus dropped below threshold.
    ConsensusLoss,
    /// Coherence fell >2σ from the rolling mean (contradiction; §2.5).
    CoherenceContradiction,
}

#[derive(Debug, thiserror::Error)]
pub enum LinkGroupError {
    #[error("Frequency {freq_mhz} MHz is not a member of this LinkGroup's FreqSet")]
    UnknownBand { freq_mhz: u32 },
    #[error("Subcarrier count mismatch on band {freq_mhz}: expected {expected}, got {got}")]
    SubcarrierMismatch { freq_mhz: u32, expected: usize, got: usize },
}

/// A WiFi-7 MLO physical link: a FreqSet plus per-band feature streams with
/// explicit enter/exit and consensus-promotion lifecycle.
///
/// # Concurrency
/// Requires `&mut self` for `ingest()`; not `Sync`. One ingest loop per link.
#[derive(Debug)]
pub struct LinkGroup {
    node_id: u8,
    freq_set: FreqSet,
    /// Most recent frame per band, indexed parallel to `freq_set.bands_mhz`.
    latest: Vec<Option<CanonicalCsiFrame>>,
    /// Lifecycle state per band (parallel to `freq_set.bands_mhz`).
    state: Vec<BandState>,
    /// Rolling per-band inter-band consensus score (Pearson vs. the group mean).
    consensus: Vec<f32>,
    /// Frame count per band since last seen, for exit detection.
    missed: Vec<u32>,
    /// Config: promote/exit thresholds.
    config: LinkGroupConfig,
    /// Pending domain events (drained by the ArrayCoordinator).
    events: Vec<LinkGroupEvent>,
}

#[derive(Debug, Clone)]
pub struct LinkGroupConfig {
    /// Pearson consensus required to promote a Live band to Promoted. Default 0.6.
    pub promote_consensus: f32,
    /// Consecutive missed expected frames before a Live band Exits. Default 5.
    pub exit_after_missed: u32,
}

impl Default for LinkGroupConfig {
    fn default() -> Self { Self { promote_consensus: 0.6, exit_after_missed: 5 } }
}

impl LinkGroup {
    pub fn new(node_id: u8, freq_set: FreqSet, config: LinkGroupConfig) -> Self;

    /// Ingest one band's frame. Marks the band Live (emitting BandEntered on the
    /// first frame), recomputes inter-band consensus against the current live
    /// mean, promotes/demotes per thresholds, and ages out unseen bands toward
    /// Exited. Bands not in `freq_set` are rejected with `UnknownBand`.
    pub fn ingest(&mut self, freq_mhz: u32, frame: CanonicalCsiFrame, at_us: u64)
        -> Result<(), LinkGroupError>;

    /// Bands currently in the consensus (Promoted) set.
    pub fn promoted_bands(&self) -> Vec<u32>;

    /// Build a MultiBandCsiFrame from the currently Promoted bands only.
    /// Returns None if fewer than 1 band is Promoted.
    pub fn consensus_frame(&self, at_us: u64) -> Option<MultiBandCsiFrame>;

    /// Drain pending domain events (the ArrayCoordinator forwards these to ADR-137).
    pub fn drain_events(&mut self) -> Vec<LinkGroupEvent>;
}

Inter-band consensus reuses the existing pearson_correlation_f32 already in multiband.rs (private today; promoted to pub(crate)). The consensus_frame() output is intentionally a MultiBandCsiFrame, so the existing MultistaticFuser consumes it unchanged.

Why an aggregate, not a snapshot. MLO band membership is stateful: the 6 GHz band dropping for 250 ms and returning is a different physical situation from a node permanently losing 6 GHz. A snapshot (MultiBandCsiFrame) cannot represent "this band exited and we are now operating degraded." The lifecycle (Pending → Live → Promoted, with → Exited and → Demoted transitions) is the minimum state required to (a) feed graceful degradation into the coordinator and (b) emit the band-level contradiction events ADR-137 wants.

2.2 ClockQualityScore and the Clock-Quality Gate

A clock-quality term is derived from the ADR-110 SyncPacket stream and folded into a gate alongside the existing phase-coherence gate. The score lives in viewpoint/coherence.rs next to CoherenceState/CoherenceGate.

/// Per-node clock-quality summary derived from the ADR-110 sync stream.
///
/// All fields are computed by the host from the `SyncPacket` series for one
/// node (`wifi_densepose_hardware::sync_packet::SyncPacket`).
#[derive(Debug, Clone, Copy)]
pub struct ClockQualityScore {
    /// EMA stdev of (local_us - epoch_us) over the recent sync window (µs).
    /// This is the dispersion of the node's mesh-alignment offset.
    pub offset_stdev_us: f32,
    /// 802.15.4 stratum: 0 = leader, 1 = direct follower, etc.
    pub stratum: u8,
    /// Age of the most recent valid SyncPacket (µs); large = stale.
    pub age_us: u64,
    /// Whether the most recent packet had flags.is_valid set.
    pub valid: bool,
}

impl ClockQualityScore {
    /// Normalised quality in [0, 1]: 1.0 = leader-grade, 0.0 = unusable.
    /// Combines offset dispersion (vs. the ADR-110 ±100 µs target), stratum
    /// penalty, and staleness. 0.0 if `!valid`.
    pub fn quality(&self) -> f32;

    /// Convenience: the ADR-110 ±100 µs sync target as a hard usability floor.
    /// `offset_stdev_us < 200.0` (2× the target) is the gate's default accept.
    pub const SYNC_TARGET_US: f32 = 100.0;
}

/// Gate that admits a node's frames into directional fusion only when both
/// its phase coherence AND its clock quality are adequate. Wraps the existing
/// `CoherenceGate` (phase term) and adds the clock term.
#[derive(Debug, Clone)]
pub struct ClockQualityGate {
    /// Existing phase-coherence gate (unchanged semantics).
    pub coherence: CoherenceGate,
    /// Reject when offset_stdev_us >= this. Default 200.0 (2× ADR-110 target).
    pub max_offset_stdev_us: f32,
    /// Reject when sync age exceeds this. Default 9_000_000 (the sensing-server
    /// 9-second staleness gate already used in main.rs).
    pub max_age_us: u64,
}

impl ClockQualityGate {
    pub fn new(coherence: CoherenceGate, max_offset_stdev_us: f32, max_age_us: u64) -> Self;
    pub fn default_params() -> Self {
        Self::new(CoherenceGate::default_params(), 200.0, 9_000_000)
    }

    /// Evaluate both terms. Returns the gate decision for one node this cycle.
    /// `coherence_value` is the rolling phasor coherence (CoherenceState::coherence()).
    pub fn evaluate(&mut self, coherence_value: f32, clock: &ClockQualityScore)
        -> ClockGateDecision;
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ClockGateDecision {
    /// Both terms pass: node admitted at full weight.
    Admit,
    /// Phase OK but clock degraded: admit at reduced weight (monitoring-only;
    /// frame contributes to evidence but NOT to model/environment update).
    MonitorOnly { clock_quality: f32 },
    /// Either term fails hard: node excluded this cycle.
    Reject { reason: ClockRejectReason },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClockRejectReason { Incoherent, ClockStale, ClockDispersed, ClockInvalid }

Why a 200 µs default floor. ADR-110 §A0.10 measured the COM9↔COM12 follower offset stdev at ~104 µs after EMA smoothing, against the ±100 µs 802.15.4 target. A node whose dispersion has risen to 2× the measured baseline (200 µs) has lost roughly one phase wrap of cross-node comparability at 5 GHz (wavelength ≈ 5 cm; 200 µs of clock skew at sensing motion velocities corrupts the inter-node phase term that attention_weighted_fusion relies on). Below 200 µs the node is admitted; between 200 µs and the staleness ceiling it is MonitorOnly (evidence yes, environment update no); above the 9 s age ceiling — the same staleness gate the sensing server already enforces (main.rs::mesh_aligned_us_honors_9s_staleness_gate) — it is rejected.

Why gate environment updates specifically. The clock term must not block evidence emission — a clock-degraded node still sees real motion and should contribute weighted evidence. It must block environment/model updates (ADR-030 field model, ADR-031 model update path), because those updates assume cross-node phase comparability that a dispersed clock breaks. MonitorOnly encodes exactly this: contribute to DirectionalEvidence, do not promote to a model/environment change. This mirrors the existing CoherenceGate semantics ("only allow model updates when coherence exceeds threshold") and extends them with the clock dimension.

2.3 ArrayCoordinator: a Service, Not an Aggregate

ArrayCoordinator is added to viewpoint/fusion.rs alongside MultistaticArray. It holds no long-lived domain state of its own (the lifecycle state lives in the LinkGroups and MultistaticArray); it is a stateless-per-call domain service that applies gates and projects evidence.

use crate::viewpoint::geometry::{GeometricDiversityIndex, CramerRaoBound, ViewpointPosition, NodeId};
use crate::viewpoint::coherence::{ClockQualityGate, ClockQualityScore, ClockGateDecision};

/// Directional evidence: what the array can resolve right now, and how much to
/// trust each direction. This is the coordinator's output — NOT a pose decision.
///
/// Per the house rule that every semantic state traces to evidence, this struct
/// carries the geometry + clock provenance that ADR-137 attaches to any state
/// it derives downstream.
#[derive(Debug, Clone)]
pub struct DirectionalEvidence {
    /// Per-viewpoint attention weight (softmax, sums to 1.0 over admitted nodes).
    pub weights: Vec<(NodeId, f32)>,
    /// Geometric Diversity Index at evaluation time.
    pub gdi: GeometricDiversityIndex,
    /// Cramér-Rao credence interval: RMSE lower bound (m) for a centroid target.
    /// `None` when fewer than 3 admitted viewpoints (under-determined).
    pub credence_rmse_m: Option<f32>,
    /// Per-node gate decisions (Admit / MonitorOnly / Reject) — the audit trail.
    pub gate_decisions: Vec<(NodeId, ClockGateDecision)>,
    /// Contradiction flags forwarded to ADR-137 (see §2.5).
    pub contradictions: Vec<ContradictionFlag>,
    /// Number of viewpoints admitted at full weight (Admit).
    pub n_admitted: usize,
    /// Number admitted MonitorOnly (evidence-only, no environment update).
    pub n_monitoring: usize,
}

// `ContradictionFlag` is NOT redefined here. It is the canonical enum owned by
// ADR-137 §2.3 (`wifi-densepose-signal::ruvsense::multistatic`). The coordinator
// imports it and emits only its array-origin variants:
//
//   use wifi_densepose_signal::ruvsense::multistatic::ContradictionFlag;
//
//   ContradictionFlag::CoherenceDrop { node_idx, sigma }   // coherence > Nσ off rolling mean
//   ContradictionFlag::GeometryInsufficient { gdi }        // array GDI below the floor
//
// A previously-Promoted band being demoted (inter-band disagreement) is surfaced
// through the per-node `gate_decisions` audit trail above, not as a contradiction
// flag — it suppresses the model update without contradicting the observation.
// `NodeId` → `node_idx` resolution happens at the ADR-137 hand-off (ADR-137 §2.3).

#[derive(Debug, Clone)]
pub struct ArrayCoordinatorConfig {
    /// Per-node clock+coherence gate.
    pub gate: ClockQualityGate,
    /// σ multiple defining a coherence contradiction. Default 2.0.
    pub contradiction_sigma: f32,
    /// Per-measurement noise std (m) for the Cramér-Rao credence estimate.
    pub crb_noise_std_m: f32,
    /// Attention temperature for the directional weight softmax. Default 1.0.
    pub attention_temperature: f32,
}

/// Domain service: gates LinkGroups + node frames on geometry and clock quality,
/// returns DirectionalEvidence. Holds NO aggregate state.
pub struct ArrayCoordinator {
    config: ArrayCoordinatorConfig,
}

impl ArrayCoordinator {
    pub fn new(config: ArrayCoordinatorConfig) -> Self;

    /// The single service operation. For each node:
    ///   1. Take its LinkGroup consensus frame (Promoted bands only).
    ///   2. Evaluate the clock-quality gate (coherence × clock).
    ///   3. Admit / MonitorOnly / Reject.
    /// Then over the admitted set:
    ///   4. Compute GDI (geometry.rs); raise GeometryInsufficient if !is_sufficient().
    ///   5. Compute Cramér-Rao credence RMSE for a centroid target.
    ///   6. Build attention weights (softmax over admitted nodes, biased by clock
    ///      quality and inverse-CRB so well-placed, well-clocked nodes weigh more).
    ///   7. Collect contradiction flags from LinkGroup demotions + coherence drops.
    ///
    /// `coherence_per_node` and `clock_per_node` are parallel to `viewpoints`.
    pub fn coordinate(
        &mut self,
        viewpoints: &[(NodeId, f32 /*azimuth*/, ViewpointPosition)],
        coherence_per_node: &[f32],
        clock_per_node: &[ClockQualityScore],
        link_events: &[LinkGroupEventRef],
    ) -> DirectionalEvidence;
}

The coordinator deliberately reuses, not reimplements:

  • GeometricDiversityIndex::compute + is_sufficient() for the geometry gate.
  • CramerRaoBound::estimate for the credence interval (its rmse_lower_bound is the credence radius).
  • ClockQualityGate::evaluate for the per-node admit/monitor/reject decision.
  • The softmax shape from multistatic.rs::attention_weighted_fusion (numerically stable, subtract-max), but biased by clock quality and inverse-CRB rather than amplitude-cosine alone.

Why a service rather than folding this into MultistaticArray. MultistaticArray is the aggregate root for ViewpointFusion — it owns embedding lifecycle and the coherence window. The coordinator's job spans multiple aggregates (every node's LinkGroup plus the array) and is read-mostly: it inspects state and projects evidence, but the authoritative state transitions (band promotion, viewpoint upsert) belong to the aggregates. Putting cross-aggregate gating logic in a stateless service keeps the aggregate boundaries clean (DDD) and makes the coordinator trivially testable with synthetic inputs.

2.4 Wiring the ADR-110 SyncPacket Decoder Into the Pipeline

Today SyncPacket is decoded in wifi-densepose-sensing-server/src/main.rs and used only to recover mesh_aligned_us. This ADR widens that path so the recovered alignment carries a quality:

  1. The sensing server already keeps NodeState::latest_sync: Option<SyncPacket> and latest_sync_at: Option<Instant>. Add a rolling buffer NodeState::sync_offsets: VecDeque<i64> of the last N local_minus_epoch_us() values and an EMA. From these, build a ClockQualityScore { offset_stdev_us, stratum, age_us, valid } per node per cycle.
    • stratum is derived from SyncPacketFlags::is_leader (leader = 0, follower = 1; deeper strata are reserved).
    • age_us is now - latest_sync_at in the mesh domain.
    • valid is latest_sync.flags.is_valid.
  2. Per ADR-136, the per-frame FrameMeta contract gains mesh_aligned_us: Option<u64> and clock_quality: Option<ClockQualityScore>, populated at frame ingestion by pairing (node_id, sequence) against the most recent SyncPacket (exactly the pairing mesh_aligned_us_for_sequence already implements). This keeps the signal crates free of any UDP/socket dependency — they receive FrameMeta, not raw packets.
  3. The ArrayCoordinator::coordinate() call receives clock_per_node: &[ClockQualityScore] extracted from those FrameMeta records. No new socket code lands in wifi-densepose-signal or wifi-densepose-ruvector; the hardware crate remains the only owner of the wire format (SYNC_PACKET_MAGIC = 0xC511A110).

This preserves the existing crate dependency direction: hardware → (FrameMeta) → signal/ruvector. The coordinator never imports wifi-densepose-hardware; it sees only the ClockQualityScore value object.

2.5 Contradiction-to-Environment-Change Semantics

The coordinator converts two array-level conditions into ADR-137 contradiction flags, and uses them to demote rather than to commit:

  • Coherence drop > 2σ. Each node's CoherenceState already maintains a rolling phasor coherence. The coordinator additionally tracks a rolling mean/std of that coherence per node (Welford, consistent with ADR-135's reuse of WelfordStats). When the current coherence falls more than contradiction_sigma (default 2.0) below the rolling mean, the coordinator (a) raises ContradictionKind::CoherenceDrop { magnitude }, and (b) the node's ClockQualityGate returns at most MonitorOnly for that cycle — its frame contributes evidence but cannot trigger an environment/model update. This is the signal-domain analogue of LinkGroupEvent::BandDemoted { reason: CoherenceContradiction }.

  • GDI below the sufficiency floor. GeometricDiversityIndex::is_sufficient() already encodes the value >= (2π/N) × 0.5 floor. When the admitted set's GDI is insufficient, the coordinator raises ContradictionKind::GeometryInsufficient { magnitude: gdi.value } and widens the credence interval (the Cramér-Rao rmse_lower_bound already grows automatically as geometry degrades, so this flag is advisory for ADR-137, not a separate widening).

A LinkGroup band demotion (BandDemoted) is forwarded verbatim as ContradictionKind::BandDemoted. In all three cases the rule is identical and is the core of this ADR: a contradiction demotes to monitoring-only; it never forces an environment change. Only a sustained consensus (admitted nodes agreeing across a window) promotes an environment update — and that promotion is owned downstream by ADR-137, which receives the coordinator's DirectionalEvidence complete with its contradiction list.

2.6 Provenance / Evidence Tracing

Per the project rule that every semantic state traces to signal evidence + model version + calibration version + privacy decision, the DirectionalEvidence struct is designed as the evidence half of that chain:

  • Signal evidence: the per-node weights and gate_decisions are the audit trail of which viewpoints (and which MLO bands, via the LinkGroup consensus) contributed and how much.
  • Calibration version: when an ADR-135 BaselineCalibration is loaded for a node, its captured_at_unix_s/device id flow through FrameMeta; the coordinator does not re-derive calibration but passes it through so ADR-137 can stamp it.
  • Model / privacy version: these are not the coordinator's concern (it makes no model inference and no privacy decision); ADR-137 attaches model_version and the active privacy decision when it consumes DirectionalEvidence. The coordinator's contract is to make the evidence and contradiction set complete enough that ADR-137 can construct the full provenance tuple without re-reading raw frames.

2.7 Downstream Consumers and Interface Boundaries

Consumer What it receives Change required
multistatic.rs::MultistaticFuser::fuse() DirectionalEvidence.weights instead of internally-derived amplitude-cosine weights MultistaticConfig gains external_weights: Option<Vec<(u8, f32)>>; when present, attention_weighted_fusion uses them rather than recomputing. Backward compatible (None = today's behaviour).
multiband.rs::MultiBandBuilder Unchanged; LinkGroup::consensus_frame() produces a MultiBandCsiFrame it already understands No change to MultiBandBuilder; pearson_correlation_f32 promoted to pub(crate) for LinkGroup reuse
viewpoint/fusion.rs::MultistaticArray Coordinator runs before fuse(); the CoherenceGateClosed path is replaced by MonitorOnly evidence New ViewpointFusionEvent::DirectionalEvidenceEmitted { gdi, n_admitted, n_monitoring }; fuse() no longer hard-drops on closed coherence — it returns evidence with zero admitted nodes
viewpoint/geometry.rs Called by the coordinator (GeometricDiversityIndex, CramerRaoBound) No API change; the existing is_sufficient() and rmse_lower_bound are exactly the gate/credence primitives
viewpoint/coherence.rs Hosts the new ClockQualityScore / ClockQualityGate next to CoherenceGate New types added; existing CoherenceGate/CoherenceState unchanged and reused as the phase term
ADR-137 FusionEngine DirectionalEvidence (weights + credence + contradictions) The coordinator is ADR-137's upstream; ContradictionFlag is the agreed hand-off type
ADR-136 streaming engine Populates FrameMeta.mesh_aligned_us + clock_quality The coordinator reads these from FrameMeta; ADR-136 owns the frame contract

Interface boundary statement. The coordinator's only inputs are value objects (ViewpointPosition, f32 coherence, ClockQualityScore, LinkGroupEventRef); its only output is the DirectionalEvidence value object. It imports from viewpoint::geometry and viewpoint::coherence within the same crate, and is invoked by the sensing server / streaming engine which assemble the inputs. It does not import wifi-densepose-hardware, does not touch sockets, and does not make pose or privacy decisions.

2.8 Test Plan / Acceptance Criteria

T1 — LinkGroup band lifecycle (unit). Construct a LinkGroup with FreqSet::new(vec![2412, 5180, 5955]). Ingest 2.4 + 5 GHz frames that correlate (consensus > 0.6) for 10 cycles; ingest 6 GHz frames that do not. Assert: 2.4 and 5 GHz reach BandState::Promoted (emitting BandPromoted); 6 GHz stays Live; promoted_bands() == [2412, 5180]; consensus_frame() yields a 2-band MultiBandCsiFrame.

T2 — Band exit and re-entry (unit). With the same group, stop feeding 6 GHz for exit_after_missed (5) cycles → assert BandExited emitted and state Exited. Resume 6 GHz → assert BandEntered emitted and state returns to Live.

T3 — Clock-quality gate thresholds (unit). Build ClockQualityScores: (a) offset_stdev_us = 50, valid = true, age_us = 1_000_000quality() > 0.8 and gate Admit; (b) offset_stdev_us = 250 (> 200 floor) but coherent → gate MonitorOnly; (c) age_us = 10_000_000 (> 9 s) → gate Reject { ClockStale }; (d) valid = falseReject { ClockInvalid } and quality() == 0.0.

T4 — ArrayCoordinator geometry gate + credence (unit). Four nodes at the corners of a 5×5 m room (reuse geometry.rs::gdi_four_corners layout), all Admit. Assert: gdi.is_sufficient(); credence_rmse_m is Some and decreases when a 5th well-placed node is added (mirrors crb_decreases_with_more_viewpoints); weights sum to 1.0; n_admitted == 4.

T5 — Clustered nodes raise GeometryInsufficient (unit). Four nodes clustered within 0.12 rad (reuse gdi_clustered_viewpoints_have_low_value). Assert ContradictionKind::GeometryInsufficient present and credence_rmse_m is much larger than T4.

T6 — Coherence-drop contradiction demotes, not decides (unit). Feed one node a stable coherence (~0.8) for 30 cycles to seed the rolling mean, then a single 0.2 coherence (> 2σ drop). Assert: ContradictionKind::CoherenceDrop raised for that node; its gate decision is at most MonitorOnly; the node still appears in weights (evidence preserved); n_monitoring >= 1.

T7 — SyncPacket → ClockQualityScore (unit, hardware crate test reuse). Using the canonical COM9 follower packet from sync_packet.rs (local_minus_epoch_us() == 1_163_565) and the COM12 leader packet, build offset series and assert: leader → stratum == 0, high quality(); follower with low dispersion → Admit. Assert no wifi-densepose-hardware symbol leaks into the coordinator's public API (compile-fence test).

T8 — Determinism proof (CI-compatible, extends ADR-028 chain). Drive a fixed synthetic 3-band, 4-node scenario through LinkGroup::ingestArrayCoordinator::coordinate, serialise DirectionalEvidence.weights (rounded to f32) and the sorted contradiction kinds, and SHA-256 the result. Record under archive/v1/data/proof/expected_features.sha256 as array_coordinator_evidence_v1; verify.py regenerates and asserts the hash.

Acceptance gate: cargo test -p wifi-densepose-signal -p wifi-densepose-ruvector --no-default-features passes all of T1T8; no new unsafe; the coordinator's public API contains no type from wifi-densepose-hardware.


3. Consequences

3.1 Positive

  • Graceful MLO degradation. Losing the 6 GHz band narrows resolution and widens the credence interval rather than invalidating the link. The LinkGroup lifecycle makes "degraded but operating" a first-class state instead of an undetected silent failure.
  • Clock quality becomes observable and actionable. Today a drifting node is treated identically to a good one until it crosses the 9 s staleness cliff. The ClockQualityScore exposes the continuum, and MonitorOnly lets a clock-degraded node still contribute evidence without corrupting environment updates.
  • Evidence, not premature decisions. The coordinator emits DirectionalEvidence with attention weights and Cramér-Rao credence intervals, giving ADR-137 the provenance it needs and removing the hard CoherenceGateClosed drop that currently discards usable cycles.
  • Reuse over reinvention. GDI, Cramér-Rao, coherence gate, sync-packet decode, and Pearson consensus already exist and are tested; this ADR composes them. The two duplicate geometric_diversity notions converge on viewpoint/geometry.rs.
  • Clean crate boundaries preserved. No socket or wire-format code enters the signal/ruvector crates; the FrameMeta contract (ADR-136) is the only coupling point.

3.2 Negative

  • More state to manage. LinkGroup adds per-band lifecycle state and an event buffer. For a 4-node, 3-band array that is 12 band state machines plus the coordinator — modest, but non-zero, and the events must be drained or they accumulate (bounded like MultistaticArray::max_events).
  • Two gates instead of one. Operators and tests must reason about coherence and clock quality. The MonitorOnly middle state, while useful, is a third outcome that downstream code (ADR-137) must handle explicitly rather than a simple boolean.
  • Depends on sibling ADRs not yet landed. FrameMeta (ADR-136) and the contradiction-consumer (ADR-137) are both Proposed. Until they land, the coordinator can be tested with synthetic ClockQualityScores but cannot be wired end-to-end. The mesh_aligned_us plumbing exists today only in the sensing server, not in a shared FrameMeta.

3.3 Risks

Risk Probability Impact Mitigation
offset_stdev_us is noisy on small sync windows, causing gate flapping between Admit/MonitorOnly Medium Weights jitter cycle-to-cycle Use the CoherenceGate hysteresis pattern for the clock term too: open at 200 µs, close only above 240 µs; EMA the offset series (the firmware already EMA-smooths, per smoothed_used flag)
Inter-band consensus false-demotes a band that is genuinely seeing a different multipath (legitimately decorrelated across 2.4 vs 6 GHz) Medium A useful band drops out of consensus promote_consensus default 0.6 is deliberately lenient; band frequency-dependent decorrelation is expected, so demotion requires sustained loss, and a demoted band still streams (it is not Exited)
Cramér-Rao credence assumes a centroid target; a real target off-centroid has a different bound Low Credence interval mildly optimistic/pessimistic off-centre Documented as a centroid-referenced bound; ADR-137 may recompute per-hypothesis if it needs target-specific credence
ADR-136 FrameMeta shape changes during its own design, breaking the clock_quality field Medium Re-plumb the coordinator's input extraction Coordinator consumes a ClockQualityScore value object, not FrameMeta directly; only the thin extraction adapter changes

4. Alternatives Considered

4.1 Extend MultiBandCsiFrame In Place Instead of a New LinkGroup

Rejected. MultiBandCsiFrame is a value-type snapshot consumed throughout multistatic.rs and the sensing server; bolting mutable band-lifecycle state onto it would break its Clone-cheap, pass-by-value contract and entangle every consumer with lifecycle logic. A separate aggregate that produces MultiBandCsiFrame via consensus_frame() keeps the snapshot type immutable and the lifecycle isolated.

4.2 Make ArrayCoordinator Part of MultistaticArray

Rejected. MultistaticArray is an aggregate root with a single-aggregate invariant boundary (its viewpoints, its coherence window). Cross-aggregate gating that reads every node's LinkGroup belongs in a domain service, not inside an aggregate — folding it in would force the aggregate to hold references to other aggregates, violating DDD boundaries and making it untestable in isolation. The service is stateless-per-call and trivially unit-testable.

4.3 Keep the Binary Coherence Gate, Add Clock as a Second Binary Gate

Rejected. Two ANDed binary gates still throw away graded information: a node that is 90% coherent with a 210 µs clock would be hard-rejected, discarding real evidence. The MonitorOnly middle state is the whole point — it admits the evidence while withholding the environment update. A pure binary design cannot express "trust this for motion evidence but not for re-learning the room."

4.4 Derive Clock Quality on the ESP32 and Ship a Single Byte

Rejected for now. The ESP32 firmware already computes the EMA offset (the smoothed_used flag), and shipping a pre-computed quality byte would save host work. But the host has the full offset series across all nodes and can compute a comparative stratum and dispersion the single node cannot. Per-node self-assessment also cannot detect a node that is confidently wrong. Host-side derivation from the existing SyncPacket stream keeps the firmware unchanged (no reflash) and centralises the cross-node comparison. This may revisit once ADR-110 firmware exposes a richer sync telemetry field.

4.5 Use Raw guard_interval_us Rejection for Clock Handling

Rejected. The existing MultistaticConfig.guard_interval_us (5 ms spread) is a timestamp-alignment sanity check, not a clock-quality measure — it catches gross desync but says nothing about the sub-millisecond dispersion that corrupts cross-node phase. The two are complementary: guard_interval_us stays as the coarse alignment precondition; ClockQualityScore.offset_stdev_us is the fine-grained quality term feeding the gate.


ADR Relationship
ADR-008 (CSI Frame Primitives) Substrate: CsiFrame/CanonicalCsiFrame are the per-band frame types LinkGroup aggregates
ADR-029 (RuvSense Multistatic) Extended: LinkGroup::consensus_frame() feeds the existing MultistaticFuser; the coordinator supplies the attention weights fuse() previously derived internally
ADR-030 (Persistent Field Model) Gated: environment/model updates are exactly what MonitorOnly withholds when clock quality degrades
ADR-031 (RuView Sensing-First RF Mode) Extended: this ADR builds directly on viewpoint/geometry.rs, coherence.rs, attention.rs, fusion.rs introduced by ADR-031
ADR-110 (ESP32-C6 Firmware Extension) Substrate: SyncPacket (magic 0xC511A110) and its local_minus_epoch_us/mesh_aligned_us_for_sequence are the source of ClockQualityScore; the ±100 µs target defines the 200 µs gate floor
ADR-136 (RuView Rust Streaming Engine) Contract: FrameMeta carries mesh_aligned_us + clock_quality; the coordinator reads these rather than raw packets
ADR-137 (Fusion Engine Quality Scoring) Downstream consumer: DirectionalEvidence.contradictions (ContradictionFlag) is the agreed hand-off; ADR-137 attaches model/privacy version to complete the provenance tuple

6. References

Production Code

  • v2/crates/wifi-densepose-signal/src/ruvsense/multiband.rsMultiBandCsiFrame, MultiBandBuilder, compute_cross_channel_coherence, pearson_correlation_f32 (consensus reuse); LinkGroup lands here
  • v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rsMultistaticFuser, FusedSensingFrame, attention_weighted_fusion, geometric_diversity, MultistaticConfig.guard_interval_us
  • v2/crates/wifi-densepose-ruvector/src/viewpoint/geometry.rsGeometricDiversityIndex::compute/is_sufficient, CramerRaoBound::estimate, ViewpointPosition, NodeId
  • v2/crates/wifi-densepose-ruvector/src/viewpoint/coherence.rsCoherenceState, CoherenceGate (phase term); ClockQualityScore/ClockQualityGate land here
  • v2/crates/wifi-densepose-ruvector/src/viewpoint/attention.rsCrossViewpointAttention, GeometricBias (softmax shape reference)
  • v2/crates/wifi-densepose-ruvector/src/viewpoint/fusion.rsMultistaticArray aggregate, ViewpointFusionEvent, FusionError::CoherenceGateClosed; ArrayCoordinator lands here
  • v2/crates/wifi-densepose-hardware/src/sync_packet.rsSyncPacket, SYNC_PACKET_MAGIC = 0xC511A110, local_minus_epoch_us, apply_to_local, mesh_aligned_us_for_sequence
  • v2/crates/wifi-densepose-sensing-server/src/main.rsNodeState::latest_sync, mesh_aligned_us_for_csi_frame, 9 s staleness gate (source of ClockQualityScore.age_us ceiling)
  • docs/adr/ADR-110-esp32-c6-firmware-extension.md — §A0.10 measured 104 µs offset stdev, §A0.12 sync-packet wire format
  • archive/v1/data/proof/expected_features.sha256 — hash entry array_coordinator_evidence_v1 to be added; verify.py array_coordinator_check() extension

External References

  • Mardia, K.V. & Jupp, P.E. (2000). Directional Statistics. Wiley. — Circular phasor coherence underlying CoherenceState and the >2σ contradiction test.
  • Van Trees, H.L. (2002). Optimum Array Processing. Wiley. Ch. 8. — Cramér-Rao bound and Fisher information matrix used by CramerRaoBound for the credence interval.
  • IEEE 802.11be (WiFi-7) Multi-Link Operation. — Concurrent multi-band streaming model that the LinkGroup FreqSet abstraction targets.
  • IEEE 802.15.4 time synchronization. — Stratum / mesh-epoch model underlying ADR-110's SyncPacket and the ClockQualityScore.stratum field.

Implementation Status & Integration (2026-05-29)

Part of the ADR-136 streaming-engine series -- skeleton/scaffolding, trust-first, mostly not yet on the live 20 Hz path. See ADR-136 (Implementation Status) for the series framing.

Built -- tested building block (commit fc7674bde, issue #842): ClockQualityGate (in wifi-densepose-ruvector) and ArrayCoordinator + DirectionalEvidence (in wifi-densepose-signal, placed there to avoid a dependency cycle). 8 tests.

Integration glue -- not yet on the live path: the LinkGroup per-band consensus aggregate; the ADR-110 SyncPacket UDP decode -> FrameMeta.mesh_aligned_us; and live coherence/clock-quality feeds per node.

Trust contribution: only well-synced, well-placed nodes are allowed to change the world-model; a clock-degraded node still contributes evidence but is held in watch-only mode.