feat(signal,ruvector): ADR-138 LinkGroup/ArrayCoordinator clock-quality gating (#842)

- ruvector viewpoint/coherence.rs: ClockQualityScore, ClockQualityGate,
  ClockGateDecision (Admit/MonitorOnly/Reject), ClockRejectReason. 200us floor,
  9s staleness ceiling per ADR-110.
- signal ruvsense/array_coordinator.rs: ArrayCoordinator domain service +
  DirectionalEvidence. Gates nodes, computes GDI + Cramer-Rao credence, builds
  attention weights (real node_attention_weights when amplitudes present, else
  clock-quality softmax), emits CoherenceDrop + GeometryInsufficient flags.
- Cycle resolution: ArrayCoordinator lives in signal (depends on ruvector), not
  ruvector, so it can emit ADR-137 canonical ContradictionFlag. Documented.
- 8 tests (5 coordinator + 3 clock gate); workspace 0 errors.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-05-28 23:09:06 -04:00
parent 4fa3847acd
commit fc7674bde9
4 changed files with 494 additions and 1 deletions
@@ -212,6 +212,109 @@ impl CoherenceGate {
}
}
// ---------------------------------------------------------------------------
// ADR-138 — Clock-quality gate (coherence × clock dispersion/age)
// ---------------------------------------------------------------------------
/// Per-node clock-synchronisation quality (ADR-138 §2.2), derived from the
/// ADR-110 802.15.4 time-sync follower offset statistics.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClockQualityScore {
/// EMA-smoothed follower offset standard deviation (µs). ADR-110 measured
/// ~104 µs against the ±100 µs target on COM9↔COM12.
pub offset_stdev_us: f32,
/// Age of the most recent sync packet (µs). The sensing server enforces a
/// 9 s staleness ceiling.
pub age_us: u64,
/// Whether a valid sync has ever been observed for this node.
pub valid: bool,
}
impl ClockQualityScore {
/// Scalar clock quality in `[0, 1]` (1 = perfectly synced). Reaches 0 at
/// `5 × max_offset_stdev_us`; used to bias directional attention weights.
#[must_use]
pub fn quality(&self, max_offset_stdev_us: f32) -> f32 {
if !self.valid || max_offset_stdev_us <= 0.0 {
return 0.0;
}
(1.0 - self.offset_stdev_us / (5.0 * max_offset_stdev_us)).clamp(0.0, 1.0)
}
}
/// Why a node failed the clock-quality gate hard.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClockRejectReason {
/// Phase coherence below the gate threshold.
Incoherent,
/// Sync packet older than the staleness ceiling.
ClockStale,
/// Offset dispersion far beyond the floor (≥ 5× the monitor threshold).
ClockDispersed,
/// No valid sync ever observed for this node.
ClockInvalid,
}
/// One node's gate decision for one sensing cycle (ADR-138 §2.2).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ClockGateDecision {
/// Both terms pass: node admitted at full weight.
Admit,
/// Phase OK but clock degraded: evidence-only, NO environment/model update.
MonitorOnly { clock_quality: f32 },
/// Either term fails hard: node excluded this cycle.
Reject { reason: ClockRejectReason },
}
/// Clock-quality gate: combines the phase [`CoherenceGate`] with clock
/// dispersion and age terms (ADR-138 §2.2).
#[derive(Debug, Clone)]
pub struct ClockQualityGate {
/// Phase-coherence gate (threshold + hysteresis).
pub coherence: CoherenceGate,
/// Offset-stdev floor (µs): at or above ⇒ `MonitorOnly`. Default 200.0.
pub max_offset_stdev_us: f32,
/// Sync-age ceiling (µs): above ⇒ hard reject. Default 9_000_000.
pub max_age_us: u64,
}
impl ClockQualityGate {
/// Construct from a phase gate and the two clock thresholds.
pub fn new(coherence: CoherenceGate, max_offset_stdev_us: f32, max_age_us: u64) -> Self {
Self { coherence, max_offset_stdev_us, max_age_us }
}
/// Defaults: phase gate 0.7/0.05, 200 µs floor, 9 s staleness ceiling.
pub fn default_params() -> Self {
Self::new(CoherenceGate::default_params(), 200.0, 9_000_000)
}
/// Evaluate both terms 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 {
if !clock.valid {
return ClockGateDecision::Reject { reason: ClockRejectReason::ClockInvalid };
}
if clock.age_us > self.max_age_us {
return ClockGateDecision::Reject { reason: ClockRejectReason::ClockStale };
}
if clock.offset_stdev_us >= 5.0 * self.max_offset_stdev_us {
return ClockGateDecision::Reject { reason: ClockRejectReason::ClockDispersed };
}
// Phase term (hysteretic). Clock-degraded but coherent ⇒ MonitorOnly.
if !self.coherence.evaluate(coherence_value) {
return ClockGateDecision::Reject { reason: ClockRejectReason::Incoherent };
}
if clock.offset_stdev_us >= self.max_offset_stdev_us {
ClockGateDecision::MonitorOnly {
clock_quality: clock.quality(self.max_offset_stdev_us),
}
} else {
ClockGateDecision::Admit
}
}
}
/// Stateless coherence gate function matching the ADR-031 specification.
///
/// Computes the complex mean of unit phasors from the given phase differences
@@ -388,4 +491,42 @@ mod tests {
}
assert_eq!(state.len(), 5, "count should be capped at window size");
}
// ===== ADR-138 clock-quality gate =====
#[test]
fn clock_gate_invalid_rejected() {
let mut g = ClockQualityGate::default_params();
let c = ClockQualityScore { offset_stdev_us: 10.0, age_us: 0, valid: false };
assert_eq!(
g.evaluate(0.9, &c),
ClockGateDecision::Reject { reason: ClockRejectReason::ClockInvalid }
);
}
#[test]
fn clock_gate_dispersed_rejected() {
let mut g = ClockQualityGate::default_params(); // floor 200 → 5× = 1000 µs
let c = ClockQualityScore { offset_stdev_us: 1500.0, age_us: 0, valid: true };
assert_eq!(
g.evaluate(0.9, &c),
ClockGateDecision::Reject { reason: ClockRejectReason::ClockDispersed }
);
}
#[test]
fn clock_gate_admit_and_monitor_and_quality() {
let mut g = ClockQualityGate::default_params();
let good = ClockQualityScore { offset_stdev_us: 50.0, age_us: 0, valid: true };
assert_eq!(g.evaluate(0.9, &good), ClockGateDecision::Admit);
// quality: 1 - 50/(5*200) = 0.95
assert!((good.quality(200.0) - 0.95).abs() < 1e-4);
let mut g2 = ClockQualityGate::default_params();
let degraded = ClockQualityScore { offset_stdev_us: 250.0, age_us: 0, valid: true };
assert!(matches!(
g2.evaluate(0.9, &degraded),
ClockGateDecision::MonitorOnly { .. }
));
}
}
@@ -22,6 +22,9 @@ pub mod geometry;
// Re-export primary types at the module root for ergonomic imports.
pub use attention::{CrossViewpointAttention, GeometricBias};
pub use coherence::{CoherenceGate, CoherenceState};
pub use coherence::{
ClockGateDecision, ClockQualityGate, ClockQualityScore, ClockRejectReason, CoherenceGate,
CoherenceState,
};
pub use fusion::{FusedEmbedding, FusionConfig, MultistaticArray, ViewpointEmbedding};
pub use geometry::{CramerRaoBound, GeometricDiversityIndex};