mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
8c24b8bdfe
* refactor(signal): de-magic motion.rs tuning constants (ADR-154 §7.4 #18) Lift the bare fusion weights, normalization scales, confidence-indicator weights, and adaptive-threshold clamp bounds in motion.rs out of the scoring functions into named, documented EMPIRICAL-DEFAULT consts. Values are bit-identical to the prior literals — this is cleanup, no behaviour change. Adds boundary/characterization tests pinning current behaviour: - motion_tuning_consts_unchanged_from_literals (consts == old literals) - doppler_component_saturates_at_full_scale (/100 then clamp(0,1)) - correlation_score_zero_below_n2_boundary (n<2 guard) - temporal_variance_zero_below_two_history (len<2 guard) - adaptive_threshold_engages_at_history_boundary (history 9 vs 10) Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): gesture.rs euclidean length guard + de-magic (ADR-154 §7.4 #12) - Add a debug_assert! to euclidean_distance documenting the same-dimension caller contract: zip() silently truncates on a length mismatch, so a mismatch is now loud in debug builds while the release operating path and output are unchanged. - De-magic the bare 1e-10 confidence epsilon into a documented const CONFIDENCE_SECOND_BEST_EPSILON (value unchanged). Tests pinning current behaviour: - confidence_epsilon_unchanged_from_literal - dtw_empty_sequence_is_infinite (n=0/m=0 boundary) - euclidean_distance_equal_length_is_l2 (same-dim contract) Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic longitudinal.rs drift thresholds (ADR-154 §7.4) Lift the bare drift-detection literals (7-day baseline, 2-sigma z-score, 3-day sustained, 7-day escalation, EMA alpha, cosine epsilon) into named, documented EMPIRICAL-DEFAULT consts encoding the module's Key Invariants. The duplicated `>= 7` in is_ready/is_ready_at now share one const. EMA alpha kept as the exact 0.05 literal (1.0 - 0.95_f32 is not bit-identical in f32). Values unchanged. Tests: - drift_consts_unchanged_from_literals - is_ready_at_day_boundary (day 6 vs 7) - cosine_similarity_zero_vector_is_zero (zero-norm guard) Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic division/zero-norm epsilons + boundary tests (ADR-154 §7.4) De-magic the bare division-guard epsilons in four modules into named, documented consts (values unchanged) and pin the previously-untested zero-norm / zero-variance / degenerate boundaries: - cross_room.rs: COSINE_SIMILARITY_EPSILON (1e-9) + test_cosine_similarity_zero_vector - multiband.rs: PEARSON_DENOMINATOR_EPSILON (1e-12) + pearson_correlation_zero_variance - intention.rs: LEAD_TIME_MIN_ACCEL (1e-10) + lead_time_zero_for_static_stream - hampel.rs: ZERO_MAD_EPSILON (1e-15) + test_zero_half_window_error + test_zero_mad_constant_window; documented hampel_filter # Errors Each module also gets a *_unchanged_from_literal const-pin test. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic rf_slam + attractor_drift constants (ADR-154 §7.4) rf_slam.rs: - NS_PER_DAY (86_400_000_000_000.0), MIGRATION_MIN_SPAN_DAYS (1e-9), and the fixed-map defaults (FIXED_MAP_ASSOC_RADIUS_M/MIN_SIGHTINGS/MIN_COHERENCE) lifted out of inline literals (values unchanged). - migration_zero_span_is_zero_rate pins the single-sighting zero-span guard. attractor_drift.rs: - METRIC_BUFFER_CAPACITY (365), STABLE_CENTER_WINDOW (10) de-magicked. - Documented the implicit recent.len()>=1 divide-safety in the PointAttractor branch (guaranteed by the count < min_observations guard). - analyze_min_observations_boundary pins the off-by-one boundary. Each module gets a *_consts_unchanged_from_literals pin test. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic coherence.rs variance floor + default decay (ADR-154 §7.4) Completes the M1 #9 de-magic for coherence.rs: the four bare 1e-6 variance-floor literals (update_reference floor + coherence_score/per_subcarrier_zscores epsilon) collapse to one VARIANCE_FLOOR const, and the inline 0.95 default decay becomes DEFAULT_EMA_DECAY. Values unchanged. Tests: - drift_consts_unchanged_from_literals extended (VARIANCE_FLOOR, DEFAULT_EMA_DECAY) - coherence_score_finite_with_zero_variance pins the floor's effect Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic calibration.rs thresholds + min-frames default (ADR-154 §7.4 #2) Lift the bare calibration literals into named EMPIRICAL-DEFAULT consts (values unchanged, bit-identical; calibration is off the Python proof path): - DEFAULT_MIN_FRAMES (600) — was repeated across all four tier constructors - AMP_STD_FLOOR (1e-12) z-score divisor floor - MOTION_AMP_Z_THRESHOLD (2.0) / MOTION_PHASE_DRIFT_THRESHOLD (π/6) — the two motion_flagged sites now share one definition - SUBTRACT_MIN_NORM (1e-30) baseline-subtraction guard Test calibration_consts_unchanged_from_literals pins all five and asserts every tier constructor shares DEFAULT_MIN_FRAMES. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic fusion_quality + temporal_gesture constants (ADR-154 §7.4) fusion_quality.rs: - CONTRADICTION_PENALTY (0.8) and CONTRADICTION_BOUND_HALFWIDTH (0.1) named. - no_contradiction_is_identity pins the n=0 boundary (penalty 0.8^0 = 1.0, zero-width bounds). temporal_gesture.rs: - CONFIDENCE_SECOND_BEST_EPSILON (1e-10, mirrors gesture.rs) and NORM_QUANTIZATION_SCALE (1000.0) named. Each module gets a *_consts_unchanged_from_literals pin test. Values unchanged. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-154): record Milestone-3 — §7.4 row #21-45 P3 backlog cleared Replace the lumped #21-45 backlog row with the enumerated M3 resolution: 22 magic constants de-magicked into named EMPIRICAL-DEFAULT consts (each pinned == prior literal), 6 boundary/characterization tests, ~4 doc-only, across 11 modules; not-real findings reported + skipped (unreachable attractor_drift div0, non-existent gesture thresholds, proof-path features.rs). Update residual P3 rows #2/#12/#17/#18 to RESOLVED, the deferred count (36 -> 0), the scope field, and the Horizon-ledger one-liner. §7.4 backlog fully cleared across M0-M3. CHANGELOG [Unreleased] entry added. Validation: signal lib --no-default-features 476/0/1; --features cir 476/0; workspace 3,275/0; Python proof PASS, hash f8e76f21...46f7a UNCHANGED. Co-Authored-By: claude-flow <ruv@ruv.net> --------- Co-authored-by: ruv <ruvnet@gmail.com>
547 lines
18 KiB
Rust
547 lines
18 KiB
Rust
//! Coherence Metric Computation (ADR-029 Section 2.5)
|
|
//!
|
|
//! Per-link coherence quantifies consistency of the current CSI observation
|
|
//! with a running reference template. The metric is computed as a weighted
|
|
//! mean of per-subcarrier Gaussian likelihoods:
|
|
//!
|
|
//! score = sum(w_i * exp(-0.5 * z_i^2)) / sum(w_i)
|
|
//!
|
|
//! where z_i = |current_i - reference_i| / sqrt(variance_i) and
|
|
//! w_i = 1 / (variance_i + epsilon).
|
|
//!
|
|
//! Low-variance (stable) subcarriers dominate the score, making it
|
|
//! sensitive to environmental drift while tolerant of body-motion
|
|
//! subcarrier fluctuations.
|
|
//!
|
|
//! # RuVector Integration
|
|
//!
|
|
//! Uses `ruvector-solver` concepts for static/dynamic decomposition
|
|
//! of the CSI signal into environmental drift and body motion components.
|
|
|
|
/// Errors from coherence computation.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum CoherenceError {
|
|
/// Input vectors are empty.
|
|
#[error("Empty input for coherence computation")]
|
|
EmptyInput,
|
|
|
|
/// Length mismatch between current, reference, and variance vectors.
|
|
#[error("Length mismatch: current={current}, reference={reference}, variance={variance}")]
|
|
LengthMismatch {
|
|
current: usize,
|
|
reference: usize,
|
|
variance: usize,
|
|
},
|
|
|
|
/// Invalid decay rate (must be in (0, 1)).
|
|
#[error("Invalid EMA decay rate: {0} (must be in (0, 1))")]
|
|
InvalidDecay(f32),
|
|
}
|
|
|
|
/// Drift profile classification for environmental changes.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DriftProfile {
|
|
/// Environment is stable (no significant baseline drift).
|
|
Stable,
|
|
/// Slow linear drift (temperature, humidity changes).
|
|
Linear,
|
|
/// Sudden step change (door opened, furniture moved).
|
|
StepChange,
|
|
}
|
|
|
|
/// Aggregate root for coherence state.
|
|
///
|
|
/// Maintains a running reference template (exponential moving average of
|
|
/// accepted CSI observations) and per-subcarrier variance estimates.
|
|
#[derive(Debug, Clone)]
|
|
pub struct CoherenceState {
|
|
/// Per-subcarrier reference amplitude (EMA).
|
|
reference: Vec<f32>,
|
|
/// Per-subcarrier variance over recent window.
|
|
variance: Vec<f32>,
|
|
/// EMA decay rate for reference update (default 0.95).
|
|
decay: f32,
|
|
/// Current coherence score (0.0-1.0).
|
|
current_score: f32,
|
|
/// Frames since last accepted (coherent) measurement.
|
|
stale_count: u64,
|
|
/// Current drift profile classification.
|
|
drift_profile: DriftProfile,
|
|
/// Accept threshold for coherence score.
|
|
accept_threshold: f32,
|
|
/// Whether the reference has been initialized.
|
|
initialized: bool,
|
|
}
|
|
|
|
impl CoherenceState {
|
|
/// Create a new coherence state for the given number of subcarriers.
|
|
pub fn new(n_subcarriers: usize, accept_threshold: f32) -> Self {
|
|
Self {
|
|
reference: vec![0.0; n_subcarriers],
|
|
variance: vec![1.0; n_subcarriers],
|
|
decay: DEFAULT_EMA_DECAY,
|
|
current_score: 1.0,
|
|
stale_count: 0,
|
|
drift_profile: DriftProfile::Stable,
|
|
accept_threshold,
|
|
initialized: false,
|
|
}
|
|
}
|
|
|
|
/// Create with a custom EMA decay rate.
|
|
pub fn with_decay(
|
|
n_subcarriers: usize,
|
|
accept_threshold: f32,
|
|
decay: f32,
|
|
) -> std::result::Result<Self, CoherenceError> {
|
|
if decay <= 0.0 || decay >= 1.0 {
|
|
return Err(CoherenceError::InvalidDecay(decay));
|
|
}
|
|
let mut state = Self::new(n_subcarriers, accept_threshold);
|
|
state.decay = decay;
|
|
Ok(state)
|
|
}
|
|
|
|
/// Return the current coherence score.
|
|
pub fn score(&self) -> f32 {
|
|
self.current_score
|
|
}
|
|
|
|
/// Return the number of frames since last accepted measurement.
|
|
pub fn stale_count(&self) -> u64 {
|
|
self.stale_count
|
|
}
|
|
|
|
/// Return the current drift profile.
|
|
pub fn drift_profile(&self) -> DriftProfile {
|
|
self.drift_profile
|
|
}
|
|
|
|
/// Return a reference to the current reference template.
|
|
pub fn reference(&self) -> &[f32] {
|
|
&self.reference
|
|
}
|
|
|
|
/// Return a reference to the current variance estimates.
|
|
pub fn variance(&self) -> &[f32] {
|
|
&self.variance
|
|
}
|
|
|
|
/// Return whether the reference has been initialized.
|
|
pub fn is_initialized(&self) -> bool {
|
|
self.initialized
|
|
}
|
|
|
|
/// Initialize the reference from a calibration observation.
|
|
///
|
|
/// Should be called with a static-environment CSI frame before
|
|
/// sensing begins.
|
|
pub fn initialize(&mut self, calibration: &[f32]) {
|
|
self.reference = calibration.to_vec();
|
|
self.variance = vec![1.0; calibration.len()];
|
|
self.current_score = 1.0;
|
|
self.stale_count = 0;
|
|
self.initialized = true;
|
|
}
|
|
|
|
/// Update the coherence state with a new observation.
|
|
///
|
|
/// Computes the coherence score, updates the reference template if
|
|
/// the observation is accepted, and tracks staleness.
|
|
pub fn update(&mut self, current: &[f32]) -> std::result::Result<f32, CoherenceError> {
|
|
if current.is_empty() {
|
|
return Err(CoherenceError::EmptyInput);
|
|
}
|
|
|
|
if !self.initialized {
|
|
self.initialize(current);
|
|
return Ok(1.0);
|
|
}
|
|
|
|
if current.len() != self.reference.len() {
|
|
return Err(CoherenceError::LengthMismatch {
|
|
current: current.len(),
|
|
reference: self.reference.len(),
|
|
variance: self.variance.len(),
|
|
});
|
|
}
|
|
|
|
// Compute coherence score
|
|
let score = coherence_score(current, &self.reference, &self.variance);
|
|
self.current_score = score;
|
|
|
|
// Update reference if accepted
|
|
if score >= self.accept_threshold {
|
|
self.update_reference(current);
|
|
self.stale_count = 0;
|
|
} else {
|
|
self.stale_count += 1;
|
|
}
|
|
|
|
// Update drift profile
|
|
self.drift_profile = classify_drift(score, self.stale_count);
|
|
|
|
Ok(score)
|
|
}
|
|
|
|
/// Update the reference template with EMA.
|
|
fn update_reference(&mut self, observation: &[f32]) {
|
|
let alpha = 1.0 - self.decay;
|
|
for ((r, v), &obs) in self
|
|
.reference
|
|
.iter_mut()
|
|
.zip(self.variance.iter_mut())
|
|
.zip(observation.iter())
|
|
{
|
|
let old_ref = *r;
|
|
*r = self.decay * old_ref + alpha * obs;
|
|
|
|
// Update variance with Welford-style online estimate
|
|
let diff = obs - old_ref;
|
|
*v = self.decay * *v + alpha * diff * diff;
|
|
// Ensure variance does not collapse to zero
|
|
if *v < VARIANCE_FLOOR {
|
|
*v = VARIANCE_FLOOR;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reset the stale counter (e.g., after recalibration).
|
|
pub fn reset_stale(&mut self) {
|
|
self.stale_count = 0;
|
|
}
|
|
}
|
|
|
|
/// Compute the coherence score between a current observation and a
|
|
/// reference template.
|
|
///
|
|
/// Uses z-score per subcarrier with variance-inverse weighting:
|
|
///
|
|
/// score = sum(w_i * exp(-0.5 * z_i^2)) / sum(w_i)
|
|
///
|
|
/// where z_i = |current_i - reference_i| / sqrt(variance_i)
|
|
/// and w_i = 1 / (variance_i + epsilon).
|
|
///
|
|
/// Returns a value in [0.0, 1.0] where 1.0 means perfect agreement.
|
|
pub fn coherence_score(current: &[f32], reference: &[f32], variance: &[f32]) -> f32 {
|
|
let n = current.len().min(reference.len()).min(variance.len());
|
|
if n == 0 {
|
|
return 0.0;
|
|
}
|
|
|
|
let epsilon = VARIANCE_FLOOR;
|
|
let mut weighted_sum = 0.0_f32;
|
|
let mut weight_sum = 0.0_f32;
|
|
|
|
for i in 0..n {
|
|
let var = variance[i].max(epsilon);
|
|
let z = (current[i] - reference[i]).abs() / var.sqrt();
|
|
let weight = 1.0 / (var + epsilon);
|
|
let likelihood = (-0.5 * z * z).exp();
|
|
weighted_sum += likelihood * weight;
|
|
weight_sum += weight;
|
|
}
|
|
|
|
if weight_sum < epsilon {
|
|
return 0.0;
|
|
}
|
|
|
|
(weighted_sum / weight_sum).clamp(0.0, 1.0)
|
|
}
|
|
|
|
/// Coherence score at/above which the environment is classified `Stable`
|
|
/// (ADR-154 §7.4 #9 — DATA-GATED). EMPIRICAL DEFAULT, not a calibrated cutoff:
|
|
/// a defensible value needs labelled stable/drifting environment traces. Pinned
|
|
/// by `classify_drift_*_boundary` so a future retune is a visible, tested change.
|
|
const DRIFT_STABLE_SCORE: f32 = 0.85;
|
|
|
|
/// Stale-frame count below which a coherence loss is treated as a transient
|
|
/// `StepChange` rather than a sustained `Linear` drift (ADR-154 §7.4 #9 —
|
|
/// DATA-GATED). EMPIRICAL DEFAULT pending labelled calibration.
|
|
const DRIFT_STEP_CHANGE_MAX_STALE: u64 = 10;
|
|
|
|
/// Variance floor (ADR-154 §7.4 — de-magicked): the online variance estimate
|
|
/// is never allowed to collapse below this, which keeps the inverse-variance
|
|
/// weight and the z-score divisor finite. Used as both the floor in
|
|
/// `update_reference` and the epsilon in `coherence_score` /
|
|
/// `per_subcarrier_zscores`. Value unchanged from the prior `1e-6` literals.
|
|
const VARIANCE_FLOOR: f32 = 1e-6;
|
|
|
|
/// Default EMA decay rate for the reference/variance update (ADR-154 §7.4 —
|
|
/// de-magicked from the inline `0.95` in `CoherenceState::new`). EMPIRICAL
|
|
/// DEFAULT; override via [`CoherenceState::with_decay`].
|
|
const DEFAULT_EMA_DECAY: f32 = 0.95;
|
|
|
|
/// Classify drift profile based on coherence history.
|
|
fn classify_drift(score: f32, stale_count: u64) -> DriftProfile {
|
|
if score >= DRIFT_STABLE_SCORE {
|
|
DriftProfile::Stable
|
|
} else if stale_count < DRIFT_STEP_CHANGE_MAX_STALE {
|
|
// Brief coherence loss -> likely step change
|
|
DriftProfile::StepChange
|
|
} else {
|
|
// Extended low coherence -> linear drift
|
|
DriftProfile::Linear
|
|
}
|
|
}
|
|
|
|
/// Compute per-subcarrier z-scores for diagnostics.
|
|
///
|
|
/// Returns a vector of z-scores, one per subcarrier.
|
|
pub fn per_subcarrier_zscores(current: &[f32], reference: &[f32], variance: &[f32]) -> Vec<f32> {
|
|
let n = current.len().min(reference.len()).min(variance.len());
|
|
(0..n)
|
|
.map(|i| {
|
|
let var = variance[i].max(VARIANCE_FLOOR);
|
|
(current[i] - reference[i]).abs() / var.sqrt()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Identify subcarriers that are outliers (z-score above threshold).
|
|
///
|
|
/// Returns indices of outlier subcarriers.
|
|
pub fn outlier_subcarriers(
|
|
current: &[f32],
|
|
reference: &[f32],
|
|
variance: &[f32],
|
|
z_threshold: f32,
|
|
) -> Vec<usize> {
|
|
let z_scores = per_subcarrier_zscores(current, reference, variance);
|
|
z_scores
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, &z)| z > z_threshold)
|
|
.map(|(i, _)| i)
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn perfect_coherence() {
|
|
let current = vec![1.0, 2.0, 3.0, 4.0];
|
|
let reference = vec![1.0, 2.0, 3.0, 4.0];
|
|
let variance = vec![0.01, 0.01, 0.01, 0.01];
|
|
let score = coherence_score(¤t, &reference, &variance);
|
|
assert!(
|
|
(score - 1.0).abs() < 0.01,
|
|
"Perfect match should give ~1.0, got {}",
|
|
score
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zero_coherence_large_deviation() {
|
|
let current = vec![100.0, 200.0, 300.0];
|
|
let reference = vec![0.0, 0.0, 0.0];
|
|
let variance = vec![0.001, 0.001, 0.001];
|
|
let score = coherence_score(¤t, &reference, &variance);
|
|
assert!(
|
|
score < 0.01,
|
|
"Large deviation should give ~0.0, got {}",
|
|
score
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_gives_zero() {
|
|
assert_eq!(coherence_score(&[], &[], &[]), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn state_initialize_and_score() {
|
|
let mut state = CoherenceState::new(4, 0.85);
|
|
assert!(!state.is_initialized());
|
|
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
|
assert!(state.is_initialized());
|
|
assert!((state.score() - 1.0).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn state_update_accepted() {
|
|
let mut state = CoherenceState::new(4, 0.5);
|
|
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
|
let score = state.update(&[1.01, 2.01, 3.01, 4.01]).unwrap();
|
|
assert!(
|
|
score > 0.8,
|
|
"Small deviation should be accepted, got {}",
|
|
score
|
|
);
|
|
assert_eq!(state.stale_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn state_update_rejected() {
|
|
let mut state = CoherenceState::new(4, 0.99);
|
|
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
|
let _ = state.update(&[10.0, 20.0, 30.0, 40.0]).unwrap();
|
|
assert!(state.stale_count() > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_initialize_on_first_update() {
|
|
let mut state = CoherenceState::new(3, 0.85);
|
|
let score = state.update(&[5.0, 6.0, 7.0]).unwrap();
|
|
assert!((score - 1.0).abs() < f32::EPSILON);
|
|
assert!(state.is_initialized());
|
|
}
|
|
|
|
#[test]
|
|
fn length_mismatch_error() {
|
|
let mut state = CoherenceState::new(4, 0.85);
|
|
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
|
let result = state.update(&[1.0, 2.0]);
|
|
assert!(matches!(result, Err(CoherenceError::LengthMismatch { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_update_error() {
|
|
let mut state = CoherenceState::new(4, 0.85);
|
|
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
|
assert!(matches!(state.update(&[]), Err(CoherenceError::EmptyInput)));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_decay_error() {
|
|
assert!(matches!(
|
|
CoherenceState::with_decay(4, 0.85, 0.0),
|
|
Err(CoherenceError::InvalidDecay(_))
|
|
));
|
|
assert!(matches!(
|
|
CoherenceState::with_decay(4, 0.85, 1.0),
|
|
Err(CoherenceError::InvalidDecay(_))
|
|
));
|
|
assert!(matches!(
|
|
CoherenceState::with_decay(4, 0.85, -0.5),
|
|
Err(CoherenceError::InvalidDecay(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn valid_decay() {
|
|
let state = CoherenceState::with_decay(4, 0.85, 0.9).unwrap();
|
|
assert!((state.score() - 1.0).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn drift_classification_stable() {
|
|
assert_eq!(classify_drift(0.9, 0), DriftProfile::Stable);
|
|
}
|
|
|
|
#[test]
|
|
fn drift_classification_step_change() {
|
|
assert_eq!(classify_drift(0.3, 5), DriftProfile::StepChange);
|
|
}
|
|
|
|
#[test]
|
|
fn drift_classification_linear() {
|
|
assert_eq!(classify_drift(0.3, 20), DriftProfile::Linear);
|
|
}
|
|
|
|
// ── ADR-154 §7.4 #9: drift-threshold characterization (DATA-GATED) ──────
|
|
// Pin the CURRENT empirical thresholds so a future labelled-data retune is a
|
|
// visible, tested change. These assert the decision boundaries, not that the
|
|
// values are "correct".
|
|
|
|
/// The named consts must equal the original bare literals (no value drift).
|
|
#[test]
|
|
fn drift_consts_unchanged_from_literals() {
|
|
assert_eq!(DRIFT_STABLE_SCORE, 0.85);
|
|
assert_eq!(DRIFT_STEP_CHANGE_MAX_STALE, 10);
|
|
// ADR-154 §7.4 M3: variance-floor + default-decay de-magic.
|
|
assert_eq!(VARIANCE_FLOOR, 1e-6_f32);
|
|
assert_eq!(DEFAULT_EMA_DECAY, 0.95_f32);
|
|
}
|
|
|
|
/// `coherence_score` stays finite and in [0,1] when a subcarrier reports
|
|
/// zero variance — the [`VARIANCE_FLOOR`] keeps the z-score divisor and the
|
|
/// inverse-variance weight finite. Pins the floor's effect.
|
|
#[test]
|
|
fn coherence_score_finite_with_zero_variance() {
|
|
let current = [1.0_f32, 2.0, 3.0];
|
|
let reference = [1.0_f32, 2.0, 3.0];
|
|
let zero_var = [0.0_f32, 0.0, 0.0];
|
|
let s = coherence_score(¤t, &reference, &zero_var);
|
|
assert!(s.is_finite() && (0.0..=1.0).contains(&s));
|
|
// Perfect agreement with floored variance -> ~1.0.
|
|
assert!((s - 1.0).abs() < 1e-3);
|
|
}
|
|
|
|
/// Stable score boundary: `>= 0.85` is Stable; just below flips to a
|
|
/// non-stable profile.
|
|
#[test]
|
|
fn classify_drift_stable_score_boundary() {
|
|
// exactly at threshold → Stable
|
|
assert_eq!(classify_drift(0.85, 0), DriftProfile::Stable);
|
|
// just below → not Stable (StepChange, since stale_count < 10)
|
|
assert_eq!(classify_drift(0.849, 0), DriftProfile::StepChange);
|
|
}
|
|
|
|
/// Stale-count boundary: `< 10` is StepChange, `>= 10` is Linear (when the
|
|
/// score is below the Stable cutoff).
|
|
#[test]
|
|
fn classify_drift_stale_count_boundary() {
|
|
// just below 10 → StepChange
|
|
assert_eq!(classify_drift(0.3, 9), DriftProfile::StepChange);
|
|
// exactly 10 → Linear
|
|
assert_eq!(classify_drift(0.3, 10), DriftProfile::Linear);
|
|
}
|
|
|
|
#[test]
|
|
fn per_subcarrier_zscores_correct() {
|
|
let current = vec![2.0, 4.0];
|
|
let reference = vec![1.0, 2.0];
|
|
let variance = vec![1.0, 4.0];
|
|
let z = per_subcarrier_zscores(¤t, &reference, &variance);
|
|
assert_eq!(z.len(), 2);
|
|
assert!((z[0] - 1.0).abs() < 1e-5);
|
|
assert!((z[1] - 1.0).abs() < 1e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn outlier_subcarriers_detected() {
|
|
let current = vec![1.0, 100.0, 1.0, 200.0];
|
|
let reference = vec![1.0, 1.0, 1.0, 1.0];
|
|
let variance = vec![1.0, 1.0, 1.0, 1.0];
|
|
let outliers = outlier_subcarriers(¤t, &reference, &variance, 3.0);
|
|
assert!(outliers.contains(&1));
|
|
assert!(outliers.contains(&3));
|
|
assert!(!outliers.contains(&0));
|
|
assert!(!outliers.contains(&2));
|
|
}
|
|
|
|
#[test]
|
|
fn reset_stale_counter() {
|
|
let mut state = CoherenceState::new(4, 0.99);
|
|
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
|
let _ = state.update(&[10.0, 20.0, 30.0, 40.0]).unwrap();
|
|
assert!(state.stale_count() > 0);
|
|
state.reset_stale();
|
|
assert_eq!(state.stale_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn reference_and_variance_accessible() {
|
|
let state = CoherenceState::new(3, 0.85);
|
|
assert_eq!(state.reference().len(), 3);
|
|
assert_eq!(state.variance().len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn coherence_score_with_high_variance() {
|
|
let current = vec![5.0, 6.0, 7.0];
|
|
let reference = vec![1.0, 2.0, 3.0];
|
|
let variance = vec![100.0, 100.0, 100.0]; // high variance
|
|
let score = coherence_score(¤t, &reference, &variance);
|
|
// With high variance, deviation is relatively small
|
|
assert!(
|
|
score > 0.5,
|
|
"High variance should tolerate deviation, got {}",
|
|
score
|
|
);
|
|
}
|
|
}
|