mirror of
https://github.com/ruvnet/RuView
synced 2026-08-04 19:31:42 +00:00
refactor(beyond-sota): ADR-154 M3 — clear §7.4 P3 backlog (22 de-magic + 6 boundary tests, backlog 36→0) (#1057)
* 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>
This commit is contained in:
@@ -43,11 +43,22 @@ pub struct HampelResult {
|
||||
/// MAD = 0.6745 * σ → σ = MAD / 0.6745 = 1.4826 * MAD
|
||||
const MAD_SCALE: f64 = 1.4826;
|
||||
|
||||
/// Zero-MAD epsilon (ADR-154 §7.4 — de-magicked). When the estimated σ falls
|
||||
/// at/below this, the window is treated as constant (degenerate MAD): any
|
||||
/// deviation larger than this same epsilon flags the sample as an outlier.
|
||||
/// Empirical guard against an all-equal window, not a tuned operating point.
|
||||
const ZERO_MAD_EPSILON: f64 = 1e-15;
|
||||
|
||||
/// Apply Hampel filter to a 1D signal.
|
||||
///
|
||||
/// For each sample, computes the median and MAD of the surrounding window.
|
||||
/// If the sample deviates from the median by more than `threshold * σ_est`,
|
||||
/// it is replaced with the median.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`HampelError::EmptySignal`] if `signal` is empty.
|
||||
/// - [`HampelError::InvalidWindow`] if `config.half_window == 0` (a window of
|
||||
/// one sample has zero MAD and cannot estimate σ).
|
||||
pub fn hampel_filter(signal: &[f64], config: &HampelConfig) -> Result<HampelResult, HampelError> {
|
||||
if signal.is_empty() {
|
||||
return Err(HampelError::EmptySignal);
|
||||
@@ -75,13 +86,13 @@ pub fn hampel_filter(signal: &[f64], config: &HampelConfig) -> Result<HampelResu
|
||||
sigma_estimates.push(sigma);
|
||||
|
||||
let deviation = (signal[i] - med).abs();
|
||||
let is_outlier = if sigma > 1e-15 {
|
||||
let is_outlier = if sigma > ZERO_MAD_EPSILON {
|
||||
// Normal case: compare deviation to threshold * sigma
|
||||
deviation > config.threshold * sigma
|
||||
} else {
|
||||
// Zero-MAD case: all window values identical except possibly this sample.
|
||||
// Any non-zero deviation from the median is an outlier.
|
||||
deviation > 1e-15
|
||||
deviation > ZERO_MAD_EPSILON
|
||||
};
|
||||
|
||||
if is_outlier {
|
||||
@@ -233,4 +244,48 @@ mod tests {
|
||||
Err(HampelError::EmptySignal)
|
||||
));
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
||||
|
||||
/// De-magicked zero-MAD epsilon must equal the prior literal.
|
||||
#[test]
|
||||
fn zero_mad_epsilon_unchanged_from_literal() {
|
||||
assert_eq!(ZERO_MAD_EPSILON, 1e-15);
|
||||
assert_eq!(MAD_SCALE, 1.4826);
|
||||
}
|
||||
|
||||
/// `half_window == 0` is the documented invalid-window boundary; pins the
|
||||
/// previously-untested error path.
|
||||
#[test]
|
||||
fn test_zero_half_window_error() {
|
||||
let config = HampelConfig {
|
||||
half_window: 0,
|
||||
threshold: 3.0,
|
||||
};
|
||||
assert!(matches!(
|
||||
hampel_filter(&[1.0, 2.0, 3.0], &config),
|
||||
Err(HampelError::InvalidWindow)
|
||||
));
|
||||
// half_window = 1 is the smallest valid window.
|
||||
let ok = HampelConfig {
|
||||
half_window: 1,
|
||||
threshold: 3.0,
|
||||
};
|
||||
assert!(hampel_filter(&[1.0, 2.0, 3.0], &ok).is_ok());
|
||||
}
|
||||
|
||||
/// Zero-MAD (constant) window: a single deviating sample is flagged via the
|
||||
/// degenerate-MAD branch; a fully constant signal flags nothing.
|
||||
#[test]
|
||||
fn test_zero_mad_constant_window() {
|
||||
// Fully constant -> no outliers (deviation is 0, not > epsilon).
|
||||
let constant = vec![5.0; 20];
|
||||
let r = hampel_filter(&constant, &HampelConfig::default()).unwrap();
|
||||
assert!(r.outlier_indices.is_empty());
|
||||
// A single spike in an otherwise-constant signal -> flagged.
|
||||
let mut spiked = vec![5.0; 20];
|
||||
spiked[10] = 5.5;
|
||||
let r = hampel_filter(&spiked, &HampelConfig::default()).unwrap();
|
||||
assert!(r.outlier_indices.contains(&10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,66 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tuning constants (ADR-154 §7.4 #18 — de-magicked; EMPIRICAL DEFAULTS).
|
||||
//
|
||||
// These were previously bare literals inside the scoring functions. They are
|
||||
// lifted to named, documented consts so the implicit weighting becomes
|
||||
// explicit and a future retune is a visible, tested change. The values are
|
||||
// **unchanged** from the original literals — boundary/characterization tests
|
||||
// pin the current behaviour. None of these is calibrated against labelled
|
||||
// occupancy data; they are heuristic fusion weights.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Motion-score fusion weights when a Doppler component is present.
|
||||
/// `(variance, correlation, phase, doppler)` — sums to 1.0.
|
||||
const MOTION_WEIGHTS_WITH_DOPPLER: (f64, f64, f64, f64) = (0.3, 0.2, 0.2, 0.3);
|
||||
|
||||
/// Motion-score fusion weights with no Doppler component.
|
||||
/// `(variance, correlation, phase)` — sums to 1.0.
|
||||
const MOTION_WEIGHTS_NO_DOPPLER: (f64, f64, f64) = (0.4, 0.3, 0.3);
|
||||
|
||||
/// Doppler magnitude (Hz-ish, arbitrary units) that maps to a full-scale
|
||||
/// (1.0) Doppler motion component. Larger magnitudes saturate at 1.0.
|
||||
const DOPPLER_FULL_SCALE_MAGNITUDE: f64 = 100.0;
|
||||
|
||||
/// Reference variance that maps to a full-scale (1.0) heuristic motion score
|
||||
/// when no calibrated baseline is available. Empirical default.
|
||||
const VARIANCE_HEURISTIC_FULL_SCALE: f64 = 0.5;
|
||||
|
||||
/// Reference phase variance that maps to a full-scale (1.0) phase motion
|
||||
/// component. Empirical default.
|
||||
const PHASE_VARIANCE_FULL_SCALE: f64 = 0.5;
|
||||
|
||||
/// Blend weight between phase-variance and phase-coherence in the phase score.
|
||||
const PHASE_SCORE_VARIANCE_WEIGHT: f64 = 0.5;
|
||||
|
||||
/// Reference dynamic range that maps to a full-scale (1.0) amplitude-quality
|
||||
/// confidence indicator. Empirical default.
|
||||
const AMP_QUALITY_FULL_SCALE_RANGE: f64 = 2.0;
|
||||
|
||||
/// Confidence-indicator blend weights (`amplitude`, `phase`, `correlation`,
|
||||
/// `doppler`) — each is the fraction of total confidence that indicator
|
||||
/// contributes when present.
|
||||
const CONF_WEIGHT_AMPLITUDE: f64 = 0.3;
|
||||
const CONF_WEIGHT_PHASE: f64 = 0.3;
|
||||
const CONF_WEIGHT_CORRELATION: f64 = 0.2;
|
||||
const CONF_WEIGHT_DOPPLER: f64 = 0.2;
|
||||
|
||||
/// Minimum baseline floor added before dividing by the calibration baseline
|
||||
/// variance, preventing a divide-by-zero on an all-constant calibration.
|
||||
const BASELINE_VARIANCE_FLOOR: f64 = 1e-10;
|
||||
|
||||
/// Lower / upper clamp for the adaptive human-detection threshold
|
||||
/// (`mean + 1σ` of recent motion scores). Keeps the adaptive threshold inside
|
||||
/// a sane operating band. Empirical default.
|
||||
const ADAPTIVE_THRESHOLD_MIN: f64 = 0.3;
|
||||
const ADAPTIVE_THRESHOLD_MAX: f64 = 0.95;
|
||||
|
||||
/// Minimum history length before the adaptive threshold engages; below this
|
||||
/// the configured fixed threshold is used.
|
||||
const ADAPTIVE_THRESHOLD_MIN_HISTORY: usize = 10;
|
||||
|
||||
/// Motion score with component breakdown
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MotionScore {
|
||||
@@ -37,12 +97,11 @@ impl MotionScore {
|
||||
) -> Self {
|
||||
// Calculate weighted total
|
||||
let total = if let Some(doppler) = doppler_component {
|
||||
0.3 * variance_component
|
||||
+ 0.2 * correlation_component
|
||||
+ 0.2 * phase_component
|
||||
+ 0.3 * doppler
|
||||
let (wv, wc, wp, wd) = MOTION_WEIGHTS_WITH_DOPPLER;
|
||||
wv * variance_component + wc * correlation_component + wp * phase_component + wd * doppler
|
||||
} else {
|
||||
0.4 * variance_component + 0.3 * correlation_component + 0.3 * phase_component
|
||||
let (wv, wc, wp) = MOTION_WEIGHTS_NO_DOPPLER;
|
||||
wv * variance_component + wc * correlation_component + wp * phase_component
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -304,7 +363,7 @@ impl MotionDetector {
|
||||
// Calculate Doppler-based score if available
|
||||
let doppler_score = features.doppler.as_ref().map(|d| {
|
||||
// Normalize Doppler magnitude to 0-1 range
|
||||
(d.mean_magnitude / 100.0).clamp(0.0, 1.0)
|
||||
(d.mean_magnitude / DOPPLER_FULL_SCALE_MAGNITUDE).clamp(0.0, 1.0)
|
||||
});
|
||||
|
||||
let motion_score = MotionScore::new(
|
||||
@@ -355,11 +414,11 @@ impl MotionDetector {
|
||||
|
||||
// Normalize using baseline if available
|
||||
if let Some(baseline) = self.baseline_variance {
|
||||
let ratio = mean_variance / (baseline + 1e-10);
|
||||
let ratio = mean_variance / (baseline + BASELINE_VARIANCE_FLOOR);
|
||||
(ratio - 1.0).max(0.0).tanh()
|
||||
} else {
|
||||
// Use heuristic normalization
|
||||
(mean_variance / 0.5).clamp(0.0, 1.0)
|
||||
(mean_variance / VARIANCE_HEURISTIC_FULL_SCALE).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,7 +452,9 @@ impl MotionDetector {
|
||||
let coherence_factor = 1.0 - phase.coherence.abs();
|
||||
|
||||
// Combine factors
|
||||
let score = 0.5 * (mean_variance / 0.5).clamp(0.0, 1.0) + 0.5 * coherence_factor;
|
||||
let w = PHASE_SCORE_VARIANCE_WEIGHT;
|
||||
let score = w * (mean_variance / PHASE_VARIANCE_FULL_SCALE).clamp(0.0, 1.0)
|
||||
+ (1.0 - w) * coherence_factor;
|
||||
score.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
@@ -416,26 +477,27 @@ impl MotionDetector {
|
||||
let mut weight_sum = 0.0;
|
||||
|
||||
// Amplitude quality indicator
|
||||
let amp_quality = (features.amplitude.dynamic_range / 2.0).clamp(0.0, 1.0);
|
||||
confidence += amp_quality * 0.3;
|
||||
weight_sum += 0.3;
|
||||
let amp_quality =
|
||||
(features.amplitude.dynamic_range / AMP_QUALITY_FULL_SCALE_RANGE).clamp(0.0, 1.0);
|
||||
confidence += amp_quality * CONF_WEIGHT_AMPLITUDE;
|
||||
weight_sum += CONF_WEIGHT_AMPLITUDE;
|
||||
|
||||
// Phase coherence indicator
|
||||
let phase_quality = features.phase.coherence.abs();
|
||||
confidence += phase_quality * 0.3;
|
||||
weight_sum += 0.3;
|
||||
confidence += phase_quality * CONF_WEIGHT_PHASE;
|
||||
weight_sum += CONF_WEIGHT_PHASE;
|
||||
|
||||
// Correlation consistency indicator
|
||||
let corr_quality = (1.0 - features.correlation.correlation_spread).clamp(0.0, 1.0);
|
||||
confidence += corr_quality * 0.2;
|
||||
weight_sum += 0.2;
|
||||
confidence += corr_quality * CONF_WEIGHT_CORRELATION;
|
||||
weight_sum += CONF_WEIGHT_CORRELATION;
|
||||
|
||||
// Doppler quality if available
|
||||
if let Some(ref doppler) = features.doppler {
|
||||
let doppler_quality =
|
||||
(doppler.spread / doppler.mean_magnitude.max(1.0)).clamp(0.0, 1.0);
|
||||
confidence += (1.0 - doppler_quality) * 0.2;
|
||||
weight_sum += 0.2;
|
||||
confidence += (1.0 - doppler_quality) * CONF_WEIGHT_DOPPLER;
|
||||
weight_sum += CONF_WEIGHT_DOPPLER;
|
||||
}
|
||||
|
||||
if weight_sum > 0.0 {
|
||||
@@ -542,7 +604,7 @@ impl MotionDetector {
|
||||
|
||||
/// Calculate adaptive threshold based on recent history
|
||||
fn calculate_adaptive_threshold(&self) -> f64 {
|
||||
if self.motion_history.len() < 10 {
|
||||
if self.motion_history.len() < ADAPTIVE_THRESHOLD_MIN_HISTORY {
|
||||
return self.config.human_detection_threshold;
|
||||
}
|
||||
|
||||
@@ -555,7 +617,7 @@ impl MotionDetector {
|
||||
};
|
||||
|
||||
// Threshold is mean + 1 std deviation, clamped to reasonable range
|
||||
(mean + std).clamp(0.3, 0.95)
|
||||
(mean + std).clamp(ADAPTIVE_THRESHOLD_MIN, ADAPTIVE_THRESHOLD_MAX)
|
||||
}
|
||||
|
||||
/// Update baseline variance (for calibration)
|
||||
@@ -838,4 +900,127 @@ mod tests {
|
||||
let stats = detector.get_statistics();
|
||||
assert_eq!(stats.history_size, 10); // Should not exceed max
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4 #18: de-magic-constant + boundary characterization tests.
|
||||
// These pin CURRENT behaviour so a future retune is a visible, tested change.
|
||||
|
||||
/// The de-magicked tuning consts MUST equal the prior bare literals exactly
|
||||
/// (this milestone is cleanup — operating values are unchanged).
|
||||
#[test]
|
||||
fn motion_tuning_consts_unchanged_from_literals() {
|
||||
assert_eq!(MOTION_WEIGHTS_WITH_DOPPLER, (0.3, 0.2, 0.2, 0.3));
|
||||
assert_eq!(MOTION_WEIGHTS_NO_DOPPLER, (0.4, 0.3, 0.3));
|
||||
assert_eq!(DOPPLER_FULL_SCALE_MAGNITUDE, 100.0);
|
||||
assert_eq!(VARIANCE_HEURISTIC_FULL_SCALE, 0.5);
|
||||
assert_eq!(PHASE_VARIANCE_FULL_SCALE, 0.5);
|
||||
assert_eq!(PHASE_SCORE_VARIANCE_WEIGHT, 0.5);
|
||||
assert_eq!(AMP_QUALITY_FULL_SCALE_RANGE, 2.0);
|
||||
assert_eq!(CONF_WEIGHT_AMPLITUDE, 0.3);
|
||||
assert_eq!(CONF_WEIGHT_PHASE, 0.3);
|
||||
assert_eq!(CONF_WEIGHT_CORRELATION, 0.2);
|
||||
assert_eq!(CONF_WEIGHT_DOPPLER, 0.2);
|
||||
assert_eq!(BASELINE_VARIANCE_FLOOR, 1e-10);
|
||||
assert_eq!(ADAPTIVE_THRESHOLD_MIN, 0.3);
|
||||
assert_eq!(ADAPTIVE_THRESHOLD_MAX, 0.95);
|
||||
assert_eq!(ADAPTIVE_THRESHOLD_MIN_HISTORY, 10);
|
||||
// Fusion weights are a convex combination (sum to 1.0).
|
||||
let (wv, wc, wp, wd) = MOTION_WEIGHTS_WITH_DOPPLER;
|
||||
assert!((wv + wc + wp + wd - 1.0).abs() < 1e-12);
|
||||
let (wv, wc, wp) = MOTION_WEIGHTS_NO_DOPPLER;
|
||||
assert!((wv + wc + wp - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// Doppler component saturates at full scale (`/100.0` then clamp(0,1)).
|
||||
/// Pins behaviour at/just-below/just-above the full-scale magnitude.
|
||||
#[test]
|
||||
fn doppler_component_saturates_at_full_scale() {
|
||||
use crate::features::DopplerFeatures;
|
||||
use ndarray::Array1;
|
||||
let make = |mag: f64| DopplerFeatures {
|
||||
shifts: Array1::zeros(1),
|
||||
peak_frequency: 0.0,
|
||||
mean_magnitude: mag,
|
||||
spread: 0.0,
|
||||
};
|
||||
let detector = MotionDetector::default_config();
|
||||
// just below full scale -> < 1.0
|
||||
let mut features = create_test_features(0.5);
|
||||
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE - 1.0));
|
||||
let below = detector.analyze_motion(&features).score.doppler_component.unwrap();
|
||||
assert!(below < 1.0 && below > 0.98);
|
||||
// exactly full scale -> 1.0
|
||||
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE));
|
||||
let at = detector.analyze_motion(&features).score.doppler_component.unwrap();
|
||||
assert_eq!(at, 1.0);
|
||||
// above full scale -> clamped to 1.0
|
||||
features.doppler = Some(make(DOPPLER_FULL_SCALE_MAGNITUDE * 10.0));
|
||||
let above = detector.analyze_motion(&features).score.doppler_component.unwrap();
|
||||
assert_eq!(above, 1.0);
|
||||
}
|
||||
|
||||
/// `calculate_correlation_score` returns 0.0 for n<2 (the small-matrix
|
||||
/// guard) and a finite, clamped value for n>=2. Pins the n=1 boundary.
|
||||
#[test]
|
||||
fn correlation_score_zero_below_n2_boundary() {
|
||||
use crate::features::CorrelationFeatures;
|
||||
use ndarray::Array2;
|
||||
let detector = MotionDetector::default_config();
|
||||
let one = CorrelationFeatures {
|
||||
matrix: Array2::from_elem((1, 1), 1.0),
|
||||
mean_correlation: 0.0,
|
||||
max_correlation: 0.0,
|
||||
correlation_spread: 0.0,
|
||||
};
|
||||
assert_eq!(detector.calculate_correlation_score(&one), 0.0);
|
||||
let two = CorrelationFeatures {
|
||||
matrix: Array2::from_shape_fn((2, 2), |(i, j)| if i == j { 1.0 } else { 0.0 }),
|
||||
mean_correlation: 0.0,
|
||||
max_correlation: 0.0,
|
||||
correlation_spread: 0.0,
|
||||
};
|
||||
let s = detector.calculate_correlation_score(&two);
|
||||
assert!(s.is_finite() && (0.0..=1.0).contains(&s));
|
||||
}
|
||||
|
||||
/// `calculate_temporal_variance` returns 0.0 with fewer than 2 history
|
||||
/// entries, finite otherwise. Pins the len<2 boundary.
|
||||
#[test]
|
||||
fn temporal_variance_zero_below_two_history() {
|
||||
let mut detector = MotionDetector::default_config();
|
||||
assert_eq!(detector.calculate_temporal_variance(), 0.0); // 0 entries
|
||||
detector
|
||||
.motion_history
|
||||
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
|
||||
assert_eq!(detector.calculate_temporal_variance(), 0.0); // 1 entry
|
||||
detector
|
||||
.motion_history
|
||||
.push_back(MotionScore::new(0.1, 0.1, 0.1, None));
|
||||
assert!(detector.calculate_temporal_variance() > 0.0); // 2 entries
|
||||
}
|
||||
|
||||
/// The adaptive threshold engages only at/after `ADAPTIVE_THRESHOLD_MIN_HISTORY`
|
||||
/// history entries; below it falls back to the configured fixed threshold.
|
||||
/// Pins the history=9 (fixed) vs history=10 (adaptive) boundary.
|
||||
#[test]
|
||||
fn adaptive_threshold_engages_at_history_boundary() {
|
||||
let config = MotionDetectorConfig::builder()
|
||||
.adaptive_threshold(true)
|
||||
.human_detection_threshold(0.8)
|
||||
.history_size(50)
|
||||
.build();
|
||||
let mut detector = MotionDetector::new(config);
|
||||
// Push exactly 9 entries: still uses the fixed configured threshold.
|
||||
for _ in 0..(ADAPTIVE_THRESHOLD_MIN_HISTORY - 1) {
|
||||
detector
|
||||
.motion_history
|
||||
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
|
||||
}
|
||||
assert_eq!(detector.calculate_adaptive_threshold(), 0.8);
|
||||
// 10th entry: adaptive band kicks in, clamped to [MIN, MAX].
|
||||
detector
|
||||
.motion_history
|
||||
.push_back(MotionScore::new(0.5, 0.5, 0.5, None));
|
||||
let t = detector.calculate_adaptive_threshold();
|
||||
assert!((ADAPTIVE_THRESHOLD_MIN..=ADAPTIVE_THRESHOLD_MAX).contains(&t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,18 @@ use midstreamer_attractor::{AttractorAnalyzer, AttractorType, PhasePoint};
|
||||
|
||||
use super::longitudinal::DriftMetric;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal constants (ADR-154 §7.4 — de-magicked; values unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-metric ring-buffer capacity: one year of daily observations.
|
||||
const METRIC_BUFFER_CAPACITY: usize = 365;
|
||||
|
||||
/// Number of most-recent values averaged to estimate a point-attractor's
|
||||
/// stable centre. Empirical default — a short tail that tracks the latest
|
||||
/// converged level without over-smoothing.
|
||||
const STABLE_CENTER_WINDOW: usize = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,7 +244,7 @@ impl AttractorDriftAnalyzer {
|
||||
|
||||
let buffers = DriftMetric::all()
|
||||
.iter()
|
||||
.map(|&m| MetricBuffer::new(m, 365)) // 1 year of daily observations
|
||||
.map(|&m| MetricBuffer::new(m, METRIC_BUFFER_CAPACITY))
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
@@ -296,8 +308,12 @@ impl AttractorDriftAnalyzer {
|
||||
|
||||
match info.attractor_type {
|
||||
AttractorType::PointAttractor => {
|
||||
// Compute center as mean of last few values
|
||||
let recent = &values[values.len().saturating_sub(10)..];
|
||||
// Compute center as the mean of the last STABLE_CENTER_WINDOW
|
||||
// values. `recent` is non-empty here: the `count < min_needed`
|
||||
// guard above guarantees `values.len() >= min_observations >= 1`
|
||||
// before this branch, so `recent.len() >= 1` and the division
|
||||
// below cannot be a divide-by-zero.
|
||||
let recent = &values[values.len().saturating_sub(STABLE_CENTER_WINDOW)..];
|
||||
let center = recent.iter().sum::<f64>() / recent.len() as f64;
|
||||
BiophysicalAttractor::Stable { center }
|
||||
}
|
||||
@@ -563,4 +579,38 @@ mod tests {
|
||||
let dbg = format!("{:?}", a);
|
||||
assert!(dbg.contains("AttractorDriftAnalyzer"));
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
||||
|
||||
/// De-magicked internal constants must equal the prior inline literals.
|
||||
#[test]
|
||||
fn attractor_consts_unchanged_from_literals() {
|
||||
assert_eq!(METRIC_BUFFER_CAPACITY, 365);
|
||||
assert_eq!(STABLE_CENTER_WINDOW, 10);
|
||||
}
|
||||
|
||||
/// `analyze` returns InsufficientData strictly below `min_observations` and
|
||||
/// succeeds at exactly `min_observations`. Pins the off-by-one boundary
|
||||
/// (previously only the well-below case was tested) and, with it, the
|
||||
/// implicit `recent.len() >= 1` divide-safety in the PointAttractor branch.
|
||||
#[test]
|
||||
fn analyze_min_observations_boundary() {
|
||||
let cfg = AttractorDriftConfig {
|
||||
min_observations: 12,
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = AttractorDriftAnalyzer::new(7, cfg.clone()).unwrap();
|
||||
// One below the boundary -> InsufficientData.
|
||||
for i in 0..(cfg.min_observations - 1) {
|
||||
a.add_observation(DriftMetric::GaitSymmetry, 0.1 + i as f64 * 0.001);
|
||||
}
|
||||
assert!(matches!(
|
||||
a.analyze(DriftMetric::GaitSymmetry, 0),
|
||||
Err(AttractorDriftError::InsufficientData { needed: 12, have: 11 })
|
||||
));
|
||||
// Exactly at the boundary -> Ok (no panic, finite center if Stable).
|
||||
a.add_observation(DriftMetric::GaitSymmetry, 0.111);
|
||||
let report = a.analyze(DriftMetric::GaitSymmetry, 0).unwrap();
|
||||
assert_eq!(report.observation_count, 12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,30 @@ const VERSION: u8 = 1;
|
||||
const HEADER_LEN: usize = 16; // magic(4) + version(1) + tier(1) + reserved(2) + unix_s(8)
|
||||
const SUBCARRIER_RECORD_LEN: usize = 16; // 4 × f32
|
||||
|
||||
// ADR-154 §7.4 — de-magicked (values unchanged). The tuning thresholds below
|
||||
// are EMPIRICAL DEFAULTS pending labelled empty-vs-occupied calibration traces.
|
||||
|
||||
/// Default minimum frames for a baseline finalization (30 s @ 20 Hz). Shared by
|
||||
/// every tier constructor (`ht20`/`ht40`/`he20`/`he40`).
|
||||
const DEFAULT_MIN_FRAMES: u32 = 600;
|
||||
|
||||
/// Amplitude standard-deviation floor used as the z-score divisor in
|
||||
/// `deviation()`, guarding against a zero-variance baseline subcarrier.
|
||||
const AMP_STD_FLOOR: f32 = 1e-12;
|
||||
|
||||
/// `deviation()` flags motion when the median amplitude z-score exceeds this
|
||||
/// many σ. EMPIRICAL DEFAULT.
|
||||
const MOTION_AMP_Z_THRESHOLD: f32 = 2.0;
|
||||
|
||||
/// `deviation()` flags motion when the median phase drift exceeds this many
|
||||
/// radians (π/6 = 30°). EMPIRICAL DEFAULT.
|
||||
const MOTION_PHASE_DRIFT_THRESHOLD: f32 = std::f32::consts::PI / 6.0;
|
||||
|
||||
/// Minimum complex magnitude in `subtract_in_place` below which a bin is left
|
||||
/// untouched (a near-zero bin has no meaningful baseline to subtract and the
|
||||
/// `(norm - baseline)/norm` scaling would be ill-conditioned).
|
||||
const SUBTRACT_MIN_NORM: f64 = 1e-30;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PHY tier
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -103,11 +127,11 @@ pub struct CalibrationConfig {
|
||||
impl CalibrationConfig {
|
||||
/// HT20 defaults: 64 FFT, 52 active, 600 frame minimum (30 s @ 20 Hz).
|
||||
pub fn ht20() -> Self {
|
||||
Self { tier: PhyTier::Ht20, num_subcarriers: 64, num_active: 52, min_frames: 600, max_phase_variance: 0.3 }
|
||||
Self { tier: PhyTier::Ht20, num_subcarriers: 64, num_active: 52, min_frames: DEFAULT_MIN_FRAMES, max_phase_variance: 0.3 }
|
||||
}
|
||||
/// HT40 defaults: 128 FFT, 114 active.
|
||||
pub fn ht40() -> Self {
|
||||
Self { tier: PhyTier::Ht40, num_subcarriers: 128, num_active: 114, min_frames: 600, max_phase_variance: 0.3 }
|
||||
Self { tier: PhyTier::Ht40, num_subcarriers: 128, num_active: 114, min_frames: DEFAULT_MIN_FRAMES, max_phase_variance: 0.3 }
|
||||
}
|
||||
/// HE20 defaults: 256 FFT, **256 active** (record all delivered bins).
|
||||
///
|
||||
@@ -128,11 +152,11 @@ impl CalibrationConfig {
|
||||
/// `cir.rs` (`HE20_ACTIVE`), where the Φ sensing matrix genuinely needs it;
|
||||
/// the baseline recorder does not.
|
||||
pub fn he20() -> Self {
|
||||
Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 256, min_frames: 600, max_phase_variance: 0.3 }
|
||||
Self { tier: PhyTier::He20, num_subcarriers: 256, num_active: 256, min_frames: DEFAULT_MIN_FRAMES, max_phase_variance: 0.3 }
|
||||
}
|
||||
/// HE40 defaults: 512 FFT, 484 active.
|
||||
pub fn he40() -> Self {
|
||||
Self { tier: PhyTier::He40, num_subcarriers: 512, num_active: 484, min_frames: 600, max_phase_variance: 0.3 }
|
||||
Self { tier: PhyTier::He40, num_subcarriers: 512, num_active: 484, min_frames: DEFAULT_MIN_FRAMES, max_phase_variance: 0.3 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +288,7 @@ impl BaselineCalibration {
|
||||
for (ki, (c, baseline)) in y.iter().zip(self.subcarriers.iter()).enumerate() {
|
||||
let _ = ki;
|
||||
let amp = c.norm();
|
||||
let std = baseline.amp_variance.sqrt().max(1e-12_f32);
|
||||
let std = baseline.amp_variance.sqrt().max(AMP_STD_FLOOR);
|
||||
z_amp.push((amp - baseline.amp_mean) / std);
|
||||
let theta = c.arg();
|
||||
let drift = circular_distance(theta, baseline.phase_mean);
|
||||
@@ -273,7 +297,8 @@ impl BaselineCalibration {
|
||||
let amplitude_z_median = median_abs(&z_amp);
|
||||
let amplitude_z_max = z_amp.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
|
||||
let phase_drift_median = median_slice(&phase_drift);
|
||||
let motion_flagged = amplitude_z_median > 2.0 || phase_drift_median > std::f32::consts::PI / 6.0;
|
||||
let motion_flagged =
|
||||
amplitude_z_median > MOTION_AMP_Z_THRESHOLD || phase_drift_median > MOTION_PHASE_DRIFT_THRESHOLD;
|
||||
Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged })
|
||||
}
|
||||
|
||||
@@ -338,7 +363,7 @@ impl BaselineCalibration {
|
||||
for s in 0..n_streams {
|
||||
let c = frame.data[[s, ki]];
|
||||
let norm = c.norm();
|
||||
if norm > 1e-30 {
|
||||
if norm > SUBTRACT_MIN_NORM {
|
||||
let scale = ((norm - baseline_amp).max(0.0)) / norm;
|
||||
frame.data[[s, ki]] = num_complex::Complex64::new(c.re * scale, c.im * scale);
|
||||
}
|
||||
@@ -491,7 +516,8 @@ impl CalibrationRecorder {
|
||||
let amplitude_z_median = median_slice(&z_amp_abs);
|
||||
let amplitude_z_max = z_amp_abs.iter().copied().fold(0.0_f32, f32::max);
|
||||
let phase_drift_median = median_slice(&phase_drift);
|
||||
let motion_flagged = amplitude_z_median > 2.0 || phase_drift_median > std::f32::consts::PI / 6.0;
|
||||
let motion_flagged =
|
||||
amplitude_z_median > MOTION_AMP_Z_THRESHOLD || phase_drift_median > MOTION_PHASE_DRIFT_THRESHOLD;
|
||||
Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged })
|
||||
}
|
||||
|
||||
@@ -736,6 +762,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant pin test.
|
||||
|
||||
/// The de-magicked calibration constants MUST equal the prior literals, and
|
||||
/// every tier constructor MUST share the one DEFAULT_MIN_FRAMES default.
|
||||
#[test]
|
||||
fn calibration_consts_unchanged_from_literals() {
|
||||
assert_eq!(DEFAULT_MIN_FRAMES, 600);
|
||||
assert_eq!(AMP_STD_FLOOR, 1e-12_f32);
|
||||
assert_eq!(MOTION_AMP_Z_THRESHOLD, 2.0_f32);
|
||||
assert_eq!(MOTION_PHASE_DRIFT_THRESHOLD, std::f32::consts::PI / 6.0);
|
||||
assert_eq!(SUBTRACT_MIN_NORM, 1e-30_f64);
|
||||
for cfg in [
|
||||
CalibrationConfig::ht20(),
|
||||
CalibrationConfig::ht40(),
|
||||
CalibrationConfig::he20(),
|
||||
CalibrationConfig::he40(),
|
||||
] {
|
||||
assert_eq!(cfg.min_frames, DEFAULT_MIN_FRAMES);
|
||||
}
|
||||
}
|
||||
|
||||
// Binary magic / version check.
|
||||
#[test]
|
||||
fn binary_magic_and_version() {
|
||||
|
||||
@@ -79,7 +79,7 @@ impl CoherenceState {
|
||||
Self {
|
||||
reference: vec![0.0; n_subcarriers],
|
||||
variance: vec![1.0; n_subcarriers],
|
||||
decay: 0.95,
|
||||
decay: DEFAULT_EMA_DECAY,
|
||||
current_score: 1.0,
|
||||
stale_count: 0,
|
||||
drift_profile: DriftProfile::Stable,
|
||||
@@ -200,8 +200,8 @@ impl CoherenceState {
|
||||
let diff = obs - old_ref;
|
||||
*v = self.decay * *v + alpha * diff * diff;
|
||||
// Ensure variance does not collapse to zero
|
||||
if *v < 1e-6 {
|
||||
*v = 1e-6;
|
||||
if *v < VARIANCE_FLOOR {
|
||||
*v = VARIANCE_FLOOR;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ pub fn coherence_score(current: &[f32], reference: &[f32], variance: &[f32]) ->
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let epsilon = 1e-6_f32;
|
||||
let epsilon = VARIANCE_FLOOR;
|
||||
let mut weighted_sum = 0.0_f32;
|
||||
let mut weight_sum = 0.0_f32;
|
||||
|
||||
@@ -260,6 +260,18 @@ const DRIFT_STABLE_SCORE: f32 = 0.85;
|
||||
/// 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 {
|
||||
@@ -280,7 +292,7 @@ pub fn per_subcarrier_zscores(current: &[f32], reference: &[f32], variance: &[f3
|
||||
let n = current.len().min(reference.len()).min(variance.len());
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let var = variance[i].max(1e-6);
|
||||
let var = variance[i].max(VARIANCE_FLOOR);
|
||||
(current[i] - reference[i]).abs() / var.sqrt()
|
||||
})
|
||||
.collect()
|
||||
@@ -439,6 +451,23 @@ mod tests {
|
||||
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
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
//! # References
|
||||
//! - ADR-030 Tier 5: Cross-Room Identity Continuity
|
||||
|
||||
/// Denominator guard for cosine similarity (ADR-154 §7.4 — de-magicked):
|
||||
/// a product of norms below this is treated as a zero-norm vector ⇒ 0.0.
|
||||
const COSINE_SIMILARITY_EPSILON: f32 = 1e-9;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -359,12 +363,15 @@ impl CrossRoomTracker {
|
||||
}
|
||||
|
||||
/// Cosine similarity between two f32 vectors.
|
||||
///
|
||||
/// Returns `0.0` when either vector has (near-)zero norm — the product of
|
||||
/// norms falls below [`COSINE_SIMILARITY_EPSILON`] and the division is skipped.
|
||||
fn cosine_similarity_f32(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
||||
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let denom = norm_a * norm_b;
|
||||
if denom < 1e-9 {
|
||||
if denom < COSINE_SIMILARITY_EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
dot / denom
|
||||
@@ -623,4 +630,23 @@ mod tests {
|
||||
let sim = cosine_similarity_f32(&a, &b);
|
||||
assert!(sim.abs() < 1e-5);
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
||||
|
||||
/// De-magicked epsilon must equal the prior literal.
|
||||
#[test]
|
||||
fn cosine_epsilon_unchanged_from_literal() {
|
||||
assert_eq!(COSINE_SIMILARITY_EPSILON, 1e-9_f32);
|
||||
}
|
||||
|
||||
/// A zero-norm vector falls below the denominator epsilon ⇒ similarity 0.0.
|
||||
/// Previously untested (both existing tests use unit-norm vectors).
|
||||
#[test]
|
||||
fn test_cosine_similarity_zero_vector() {
|
||||
let zero = vec![0.0_f32; 4];
|
||||
let v = vec![1.0_f32, 2.0, 3.0, 4.0];
|
||||
assert_eq!(cosine_similarity_f32(&zero, &v), 0.0);
|
||||
assert_eq!(cosine_similarity_f32(&v, &zero), 0.0);
|
||||
assert_eq!(cosine_similarity_f32(&zero, &zero), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
|
||||
use super::QualityScored;
|
||||
|
||||
/// Multiplicative coherence penalty applied per recorded contradiction
|
||||
/// (ADR-154 §7.4 — de-magicked; EMPIRICAL DEFAULT). `n` contradictions scale
|
||||
/// coherence by `CONTRADICTION_PENALTY.powi(n)`.
|
||||
const CONTRADICTION_PENALTY: f32 = 0.8;
|
||||
|
||||
/// Confidence-bound half-width added per recorded contradiction (clamped so the
|
||||
/// interval stays within `[0, 1]`). EMPIRICAL DEFAULT.
|
||||
const CONTRADICTION_BOUND_HALFWIDTH: f32 = 0.1;
|
||||
|
||||
/// Identifies which sensing family produced a fused frame, so one
|
||||
/// [`QualityScore`] can be correlated across the signal-domain fuser
|
||||
/// (`multistatic.rs`) and the embedding-domain fuser (`viewpoint/fusion.rs`).
|
||||
@@ -113,7 +122,7 @@ impl QualityScore {
|
||||
/// streaming engine routes/gates on.
|
||||
#[must_use]
|
||||
pub fn penalized_coherence(&self) -> f32 {
|
||||
let penalty = 0.8_f32.powi(self.contradiction_flags.len() as i32);
|
||||
let penalty = CONTRADICTION_PENALTY.powi(self.contradiction_flags.len() as i32);
|
||||
(self.base_coherence * penalty).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +136,8 @@ impl QualityScored for QualityScore {
|
||||
// Width grows with the number of tolerated contradictions: each adds
|
||||
// ±0.1 of uncertainty around the penalized coherence, clamped to [0,1].
|
||||
let c = self.penalized_coherence();
|
||||
let half = (0.1 * self.contradiction_flags.len() as f32).min(c.min(1.0 - c));
|
||||
let half =
|
||||
(CONTRADICTION_BOUND_HALFWIDTH * self.contradiction_flags.len() as f32).min(c.min(1.0 - c));
|
||||
((c - half).max(0.0), (c + half).min(1.0))
|
||||
}
|
||||
}
|
||||
@@ -185,4 +195,24 @@ mod tests {
|
||||
assert!((0.0..=1.0).contains(&s));
|
||||
assert!(0.0 <= lo && lo <= hi && hi <= 1.0);
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
||||
|
||||
/// De-magicked penalty/bound consts must equal the prior literals.
|
||||
#[test]
|
||||
fn fusion_quality_consts_unchanged_from_literals() {
|
||||
assert_eq!(CONTRADICTION_PENALTY, 0.8_f32);
|
||||
assert_eq!(CONTRADICTION_BOUND_HALFWIDTH, 0.1_f32);
|
||||
}
|
||||
|
||||
/// Zero contradictions: penalty is `0.8^0 = 1.0` (coherence unchanged) and
|
||||
/// the confidence bounds collapse to a point. Pins the n=0 boundary.
|
||||
#[test]
|
||||
fn no_contradiction_is_identity() {
|
||||
let q = base();
|
||||
assert!(q.contradiction_flags.is_empty());
|
||||
assert!((q.penalized_coherence() - q.base_coherence).abs() < 1e-6);
|
||||
let (lo, hi) = q.confidence_bounds();
|
||||
assert!((hi - lo).abs() < 1e-6); // half-width is 0 with no contradictions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
//! - Sakoe & Chiba (1978), "Dynamic programming algorithm optimization
|
||||
//! for spoken word recognition" IEEE TASSP
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tuning constants (ADR-154 §7.4 — de-magicked; value unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum second-best DTW distance below which the relative-margin
|
||||
/// confidence formula `1 - best/second_best` would divide by a near-zero
|
||||
/// denominator. Below this we fall back to the `max_distance`-relative
|
||||
/// confidence. Empirical guard, not a tuned operating point.
|
||||
const CONFIDENCE_SECOND_BEST_EPSILON: f64 = 1e-10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -236,7 +246,10 @@ impl GestureClassifier {
|
||||
let recognized = best_dist <= self.config.max_distance;
|
||||
|
||||
// Confidence: how much better is the best match vs second best
|
||||
let confidence = if recognized && second_best_dist.is_finite() && second_best_dist > 1e-10 {
|
||||
let confidence = if recognized
|
||||
&& second_best_dist.is_finite()
|
||||
&& second_best_dist > CONFIDENCE_SECOND_BEST_EPSILON
|
||||
{
|
||||
(1.0 - best_dist / second_best_dist).clamp(0.0, 1.0)
|
||||
} else if recognized {
|
||||
(1.0 - best_dist / self.config.max_distance).clamp(0.0, 1.0)
|
||||
@@ -364,7 +377,24 @@ fn dtw_distance(seq_a: &[Vec<f64>], seq_b: &[Vec<f64>], band_width: usize) -> f6
|
||||
}
|
||||
|
||||
/// Euclidean distance between two feature vectors.
|
||||
///
|
||||
/// # Caller contract (ADR-154 §7.4 #12)
|
||||
/// `a` and `b` are expected to have the **same** dimension (`feature_dim`).
|
||||
/// The implementation `zip`s the two slices, so on a length mismatch it
|
||||
/// **silently truncates to the shorter vector** rather than erroring. Every
|
||||
/// in-tree caller (`dtw_distance` over a single classifier's templates)
|
||||
/// already enforces equal `feature_dim`, so a mismatch indicates a
|
||||
/// construction bug; a `debug_assert!` makes that loud in debug builds while
|
||||
/// keeping the release operating path (and its output) unchanged.
|
||||
fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
|
||||
debug_assert_eq!(
|
||||
a.len(),
|
||||
b.len(),
|
||||
"euclidean_distance: feature-vector length mismatch ({} vs {}) — \
|
||||
zip() would silently truncate; callers must use a uniform feature_dim",
|
||||
a.len(),
|
||||
b.len()
|
||||
);
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(x, y)| (x - y) * (x - y))
|
||||
@@ -688,4 +718,34 @@ mod tests {
|
||||
assert_eq!(GestureType::Circle.name(), "circle");
|
||||
assert_eq!(GestureType::Custom.name(), "custom");
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4 #12 + de-magic: boundary / characterization tests.
|
||||
|
||||
/// De-magicked confidence epsilon must equal the prior literal.
|
||||
#[test]
|
||||
fn confidence_epsilon_unchanged_from_literal() {
|
||||
assert_eq!(CONFIDENCE_SECOND_BEST_EPSILON, 1e-10);
|
||||
}
|
||||
|
||||
/// `dtw_distance` returns +inf when EITHER sequence is empty. Pins the
|
||||
/// n=0 / m=0 boundary (previously exercised only with n,m >= 3).
|
||||
#[test]
|
||||
fn dtw_empty_sequence_is_infinite() {
|
||||
let nonempty: Vec<Vec<f64>> = vec![vec![1.0], vec![2.0]];
|
||||
let empty: Vec<Vec<f64>> = vec![];
|
||||
assert!(dtw_distance(&empty, &nonempty, 3).is_infinite());
|
||||
assert!(dtw_distance(&nonempty, &empty, 3).is_infinite());
|
||||
assert!(dtw_distance(&empty, &empty, 3).is_infinite());
|
||||
}
|
||||
|
||||
/// `euclidean_distance` over equal-length vectors is the L2 norm of the
|
||||
/// difference. Pins the documented same-dimension caller contract (#12);
|
||||
/// the mismatch case is guarded by a debug_assert in debug builds and
|
||||
/// truncates in release — not exercised here to keep the test
|
||||
/// release/debug-agnostic.
|
||||
#[test]
|
||||
fn euclidean_distance_equal_length_is_l2() {
|
||||
assert!((euclidean_distance(&[1.0, 2.0, 2.0], &[0.0, 0.0, 0.0]) - 3.0).abs() < 1e-12);
|
||||
assert_eq!(euclidean_distance(&[], &[]), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Minimum acceleration magnitude (ADR-154 §7.4 — de-magicked) below which the
|
||||
/// lead-time estimate `t = (v_thresh - v) / a` would divide by a near-zero
|
||||
/// acceleration; below this the lead time is reported as 0.0.
|
||||
const LEAD_TIME_MIN_ACCEL: f64 = 1e-10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -233,7 +238,7 @@ impl IntentionDetector {
|
||||
let detected = self.sustained_count >= self.config.min_sustained_frames;
|
||||
|
||||
// Estimate lead time based on current acceleration and velocity
|
||||
let estimated_lead = if detected && accel_mag > 1e-10 {
|
||||
let estimated_lead = if detected && accel_mag > LEAD_TIME_MIN_ACCEL {
|
||||
// Time until velocity reaches threshold: t = (v_thresh - v) / a
|
||||
let remaining = (self.config.max_pre_movement_velocity - velocity_mag) / accel_mag;
|
||||
remaining.clamp(0.0, self.config.max_lead_time_s)
|
||||
@@ -508,4 +513,29 @@ mod tests {
|
||||
let sd = embedding_second_diff(&a, &b, &c, 1.0);
|
||||
assert!((sd[0] - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
||||
|
||||
/// De-magicked lead-time accel guard must equal the prior literal.
|
||||
#[test]
|
||||
fn lead_time_accel_const_unchanged_from_literal() {
|
||||
assert_eq!(LEAD_TIME_MIN_ACCEL, 1e-10);
|
||||
}
|
||||
|
||||
/// A static (zero-motion) embedding stream produces ~zero acceleration, so
|
||||
/// the lead-time estimate stays at the 0.0 sentinel rather than dividing by
|
||||
/// a near-zero acceleration. Pins the `accel_mag <= LEAD_TIME_MIN_ACCEL`
|
||||
/// branch behaviour.
|
||||
#[test]
|
||||
fn lead_time_zero_for_static_stream() {
|
||||
let config = make_config();
|
||||
let mut detector = IntentionDetector::new(config).unwrap();
|
||||
let mut last = None;
|
||||
for frame in 0..6_u64 {
|
||||
last = Some(detector.update(&static_embedding(), frame * 50_000).unwrap());
|
||||
}
|
||||
let signal = last.unwrap();
|
||||
assert!(signal.acceleration_magnitude < LEAD_TIME_MIN_ACCEL.max(1e-9));
|
||||
assert_eq!(signal.estimated_lead_time_s, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,38 @@
|
||||
|
||||
use crate::ruvsense::field_model::WelfordStats;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drift-detection thresholds (ADR-154 §7.4 — de-magicked; EMPIRICAL DEFAULTS).
|
||||
//
|
||||
// These encode the "Key Invariants" documented in the module header. They were
|
||||
// previously bare literals scattered through `update_daily`/`is_ready`. Lifting
|
||||
// them to named consts makes the policy explicit and a future retune a visible,
|
||||
// tested change. Values are unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum observation days before drift detection activates.
|
||||
const BASELINE_MIN_OBSERVATION_DAYS: u32 = 7;
|
||||
|
||||
/// EMA update weight applied to the embedding centroid each day (the new
|
||||
/// sample's weight; the centroid retains `1 - EMBEDDING_EMA_ALPHA` of its old
|
||||
/// value, i.e. a decay of 0.95). Kept as the literal `0.05` rather than
|
||||
/// `1.0 - 0.95_f32` to stay bit-identical (the f32 subtraction is not exactly
|
||||
/// 0.05).
|
||||
const EMBEDDING_EMA_ALPHA: f32 = 0.05;
|
||||
|
||||
/// Per-metric absolute z-score above which a day counts toward sustained drift.
|
||||
const DRIFT_ZSCORE_SIGMA: f64 = 2.0;
|
||||
|
||||
/// Consecutive drift days required before a drift report is emitted.
|
||||
const DRIFT_SUSTAINED_DAYS: u32 = 3;
|
||||
|
||||
/// Consecutive drift days at/above which monitoring escalates from `Drift`
|
||||
/// to `RiskCorrelation`.
|
||||
const DRIFT_ESCALATION_DAYS: u32 = 7;
|
||||
|
||||
/// Denominator guard for cosine similarity (zero-norm vectors ⇒ 0.0).
|
||||
const COSINE_SIMILARITY_EPSILON: f32 = 1e-9;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -226,7 +258,7 @@ impl PersonalBaseline {
|
||||
|
||||
/// Whether baseline has enough data for drift detection.
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.observation_days >= 7
|
||||
self.observation_days >= BASELINE_MIN_OBSERVATION_DAYS
|
||||
}
|
||||
|
||||
/// Update baseline with a daily summary.
|
||||
@@ -240,10 +272,10 @@ impl PersonalBaseline {
|
||||
self.observation_days += 1;
|
||||
self.updated_at_us = timestamp_us;
|
||||
|
||||
// Update embedding centroid with EMA (decay = 0.95)
|
||||
// Update embedding centroid with EMA (decay 0.95, alpha = 1 - 0.95)
|
||||
if let Some(ref emb) = summary.embedding_centroid {
|
||||
if emb.len() == self.embedding_centroid.len() {
|
||||
let alpha = 0.05_f32; // 1 - 0.95
|
||||
let alpha = EMBEDDING_EMA_ALPHA;
|
||||
for (c, e) in self.embedding_centroid.iter_mut().zip(emb.iter()) {
|
||||
*c = (1.0 - alpha) * *c + alpha * *e;
|
||||
}
|
||||
@@ -271,20 +303,20 @@ impl PersonalBaseline {
|
||||
|
||||
let idx = Self::metric_index(metric);
|
||||
|
||||
if z.abs() > 2.0 {
|
||||
if z.abs() > DRIFT_ZSCORE_SIGMA {
|
||||
self.drift_counters[idx] += 1;
|
||||
} else {
|
||||
self.drift_counters[idx] = 0;
|
||||
}
|
||||
|
||||
if self.drift_counters[idx] >= 3 {
|
||||
if self.drift_counters[idx] >= DRIFT_SUSTAINED_DAYS {
|
||||
let direction = if z > 0.0 {
|
||||
DriftDirection::Increasing
|
||||
} else {
|
||||
DriftDirection::Decreasing
|
||||
};
|
||||
|
||||
let level = if self.drift_counters[idx] >= 7 {
|
||||
let level = if self.drift_counters[idx] >= DRIFT_ESCALATION_DAYS {
|
||||
MonitoringLevel::RiskCorrelation
|
||||
} else {
|
||||
MonitoringLevel::Drift
|
||||
@@ -310,7 +342,7 @@ impl PersonalBaseline {
|
||||
|
||||
/// Check readiness at a specific observation day count (internal helper).
|
||||
fn is_ready_at(&self, days: u32) -> bool {
|
||||
days >= 7
|
||||
days >= BASELINE_MIN_OBSERVATION_DAYS
|
||||
}
|
||||
|
||||
/// Get current drift counter for a metric.
|
||||
@@ -545,12 +577,15 @@ impl EmbeddingHistory {
|
||||
}
|
||||
|
||||
/// Cosine similarity between two f32 vectors.
|
||||
///
|
||||
/// Returns `0.0` if either vector has (near-)zero norm — the product of norms
|
||||
/// falls below [`COSINE_SIMILARITY_EPSILON`], so the division is skipped.
|
||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
||||
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let denom = norm_a * norm_b;
|
||||
if denom < 1e-9 {
|
||||
if denom < COSINE_SIMILARITY_EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
dot / denom
|
||||
@@ -1017,4 +1052,40 @@ mod tests {
|
||||
assert!(*i < h.len());
|
||||
}
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
||||
|
||||
/// The de-magicked drift thresholds MUST equal the prior bare literals.
|
||||
#[test]
|
||||
fn drift_consts_unchanged_from_literals() {
|
||||
assert_eq!(BASELINE_MIN_OBSERVATION_DAYS, 7);
|
||||
assert_eq!(EMBEDDING_EMA_ALPHA, 0.05_f32);
|
||||
assert_eq!(DRIFT_ZSCORE_SIGMA, 2.0);
|
||||
assert_eq!(DRIFT_SUSTAINED_DAYS, 3);
|
||||
assert_eq!(DRIFT_ESCALATION_DAYS, 7);
|
||||
assert_eq!(COSINE_SIMILARITY_EPSILON, 1e-9_f32);
|
||||
}
|
||||
|
||||
/// `is_ready_at` pins the exact day-6 (not ready) / day-7 (ready) boundary
|
||||
/// independent of Welford state.
|
||||
#[test]
|
||||
fn is_ready_at_day_boundary() {
|
||||
let baseline = PersonalBaseline::new(1, 8);
|
||||
assert!(!baseline.is_ready_at(BASELINE_MIN_OBSERVATION_DAYS - 1)); // day 6
|
||||
assert!(baseline.is_ready_at(BASELINE_MIN_OBSERVATION_DAYS)); // day 7
|
||||
assert!(baseline.is_ready_at(BASELINE_MIN_OBSERVATION_DAYS + 1)); // day 8
|
||||
}
|
||||
|
||||
/// Cosine similarity returns 0.0 for a zero-norm vector (denominator below
|
||||
/// `COSINE_SIMILARITY_EPSILON`) and a finite value otherwise.
|
||||
#[test]
|
||||
fn cosine_similarity_zero_vector_is_zero() {
|
||||
let zero = [0.0_f32; 4];
|
||||
let v = [1.0_f32, 2.0, 3.0, 4.0];
|
||||
assert_eq!(cosine_similarity(&zero, &v), 0.0);
|
||||
assert_eq!(cosine_similarity(&v, &zero), 0.0);
|
||||
assert_eq!(cosine_similarity(&zero, &zero), 0.0);
|
||||
// identical non-zero vectors -> ~1.0
|
||||
assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,15 @@ fn compute_cross_channel_coherence(frames: &[CanonicalCsiFrame]) -> f32 {
|
||||
((mean_corr + 1.0) / 2.0).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// Denominator guard for the Pearson correlation (ADR-154 §7.4 — de-magicked):
|
||||
/// a product of standard deviations below this is treated as a zero-variance
|
||||
/// (constant) input ⇒ correlation 0.0.
|
||||
const PEARSON_DENOMINATOR_EPSILON: f32 = 1e-12;
|
||||
|
||||
/// Pearson correlation coefficient between two f32 slices.
|
||||
///
|
||||
/// Returns `0.0` for empty inputs or when either slice has (near-)zero
|
||||
/// variance (the denominator falls below [`PEARSON_DENOMINATOR_EPSILON`]).
|
||||
fn pearson_correlation_f32(a: &[f32], b: &[f32]) -> f32 {
|
||||
let n = a.len().min(b.len());
|
||||
if n == 0 {
|
||||
@@ -222,7 +230,7 @@ fn pearson_correlation_f32(a: &[f32], b: &[f32]) -> f32 {
|
||||
}
|
||||
|
||||
let denom = (var_a * var_b).sqrt();
|
||||
if denom < 1e-12 {
|
||||
if denom < PEARSON_DENOMINATOR_EPSILON {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@@ -439,4 +447,24 @@ mod tests {
|
||||
assert_eq!(cfg.window_us, 200_000);
|
||||
assert!((cfg.min_coherence - 0.3).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
||||
|
||||
/// De-magicked denominator epsilon must equal the prior literal.
|
||||
#[test]
|
||||
fn pearson_epsilon_unchanged_from_literal() {
|
||||
assert_eq!(PEARSON_DENOMINATOR_EPSILON, 1e-12_f32);
|
||||
}
|
||||
|
||||
/// A constant (zero-variance) input makes the denominator fall below the
|
||||
/// epsilon ⇒ correlation 0.0. Previously untested (existing tests use
|
||||
/// non-constant inputs).
|
||||
#[test]
|
||||
fn pearson_correlation_zero_variance() {
|
||||
let constant = vec![3.0_f32; 5];
|
||||
let varying = vec![1.0_f32, 2.0, 3.0, 4.0, 5.0];
|
||||
assert_eq!(pearson_correlation_f32(&constant, &varying), 0.0);
|
||||
assert_eq!(pearson_correlation_f32(&varying, &constant), 0.0);
|
||||
assert_eq!(pearson_correlation_f32(&constant, &constant), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,27 @@
|
||||
|
||||
use crate::ruvsense::field_model::WelfordStats;
|
||||
|
||||
/// Nanoseconds per day, for migration-rate (m/day) conversion (ADR-154 §7.4 —
|
||||
/// de-magicked from the inline `86_400_000_000_000.0` literal). 24·60·60·1e9.
|
||||
const NS_PER_DAY: f64 = 86_400_000_000_000.0;
|
||||
|
||||
/// Minimum observed span (in days) below which migration rate is reported as
|
||||
/// 0.0 — guards `cumulative_drift_m / span_days` against a near-zero span.
|
||||
const MIGRATION_MIN_SPAN_DAYS: f64 = 1e-9;
|
||||
|
||||
// ADR-154 §7.4: the v1 fixed-map defaults below were bare literals in
|
||||
// `fixed_map()`. They are EMPIRICAL DEFAULTS (ADR-143), unchanged.
|
||||
|
||||
/// Default association radius (m): a sighting within this of a reflector's
|
||||
/// running mean is folded into it; otherwise it seeds a new reflector.
|
||||
const FIXED_MAP_ASSOC_RADIUS_M: f64 = 0.5;
|
||||
|
||||
/// Default minimum sightings before a reflector counts as "persistent".
|
||||
const FIXED_MAP_MIN_SIGHTINGS: u64 = 20;
|
||||
|
||||
/// Default minimum tap coherence for a sighting to be admitted.
|
||||
const FIXED_MAP_MIN_COHERENCE: f32 = 0.6;
|
||||
|
||||
/// Classification of a discovered persistent reflector (mirrors ADR-139
|
||||
/// `AnchorKind`; kept local to avoid a crate dependency on the WorldGraph).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -102,8 +123,8 @@ impl PersistentReflector {
|
||||
if span_ns == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let span_days = span_ns as f64 / 86_400_000_000_000.0; // ns → days
|
||||
if span_days < 1e-9 {
|
||||
let span_days = span_ns as f64 / NS_PER_DAY; // ns → days
|
||||
if span_days < MIGRATION_MIN_SPAN_DAYS {
|
||||
return 0.0;
|
||||
}
|
||||
self.cumulative_drift_m / span_days
|
||||
@@ -145,9 +166,9 @@ impl RfSlam {
|
||||
pub fn fixed_map() -> Self {
|
||||
Self {
|
||||
reflectors: Vec::new(),
|
||||
assoc_radius_m: 0.5,
|
||||
min_sightings: 20,
|
||||
min_coherence: 0.6,
|
||||
assoc_radius_m: FIXED_MAP_ASSOC_RADIUS_M,
|
||||
min_sightings: FIXED_MAP_MIN_SIGHTINGS,
|
||||
min_coherence: FIXED_MAP_MIN_COHERENCE,
|
||||
discovery_enabled: false,
|
||||
}
|
||||
}
|
||||
@@ -298,4 +319,29 @@ mod tests {
|
||||
assert_eq!(anchors.len(), 1);
|
||||
assert_eq!(anchors[0].1, ReflectorClass::Wall);
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant + boundary characterization tests.
|
||||
|
||||
/// De-magicked constants must equal the prior inline literals.
|
||||
#[test]
|
||||
fn migration_consts_unchanged_from_literals() {
|
||||
assert_eq!(NS_PER_DAY, 86_400_000_000_000.0);
|
||||
assert_eq!(NS_PER_DAY, 24.0 * 60.0 * 60.0 * 1e9);
|
||||
assert_eq!(MIGRATION_MIN_SPAN_DAYS, 1e-9);
|
||||
assert_eq!(FIXED_MAP_ASSOC_RADIUS_M, 0.5);
|
||||
assert_eq!(FIXED_MAP_MIN_SIGHTINGS, 20);
|
||||
assert_eq!(FIXED_MAP_MIN_COHERENCE, 0.6_f32);
|
||||
}
|
||||
|
||||
/// A single sighting has first_ns == last_ns ⇒ zero span ⇒ migration rate
|
||||
/// 0.0 (pins the `span_ns == 0` / `span_days < MIGRATION_MIN_SPAN_DAYS`
|
||||
/// guard, and that such a reflector classifies as a Wall).
|
||||
#[test]
|
||||
fn migration_zero_span_is_zero_rate() {
|
||||
let mut slam = RfSlam::with_discovery(0.5, 1, 0.6);
|
||||
slam.observe(&obs([1.0, 2.0, 0.0], 12_345));
|
||||
let r = slam.persistent()[0];
|
||||
assert_eq!(r.migration_m_per_day(), 0.0);
|
||||
assert_eq!(r.classify(0.05, 1.0), ReflectorClass::Wall);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@ use midstreamer_temporal_compare::{ComparisonAlgorithm, Sequence, TemporalCompar
|
||||
|
||||
use super::gesture::{GestureConfig, GestureError, GestureResult, GestureTemplate};
|
||||
|
||||
/// Minimum second-best distance (ADR-154 §7.4 — de-magicked) below which the
|
||||
/// relative-margin confidence `1 - best/second_best` would divide by a
|
||||
/// near-zero denominator; below this we fall back to the `max_distance`-relative
|
||||
/// confidence. Mirrors the same guard in `gesture.rs`.
|
||||
const CONFIDENCE_SECOND_BEST_EPSILON: f64 = 1e-10;
|
||||
|
||||
/// Fixed-point scale used to quantize a frame's L2 norm to an i64 for the
|
||||
/// integer temporal comparator (norm·SCALE truncated). Empirical resolution.
|
||||
const NORM_QUANTIZATION_SCALE: f64 = 1000.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -192,7 +202,10 @@ impl TemporalGestureClassifier {
|
||||
let recognized = best_distance <= self.config.max_distance;
|
||||
|
||||
// Confidence based on margin between best and second-best
|
||||
let confidence = if recognized && second_best.is_finite() && second_best > 1e-10 {
|
||||
let confidence = if recognized
|
||||
&& second_best.is_finite()
|
||||
&& second_best > CONFIDENCE_SECOND_BEST_EPSILON
|
||||
{
|
||||
(1.0 - best_distance / second_best).clamp(0.0, 1.0)
|
||||
} else if recognized {
|
||||
(1.0 - best_distance / self.config.max_distance).clamp(0.0, 1.0)
|
||||
@@ -244,13 +257,13 @@ impl TemporalGestureClassifier {
|
||||
|
||||
/// Convert a feature sequence to a midstreamer `Sequence<i64>`.
|
||||
///
|
||||
/// Each frame's L2 norm is quantized to an i64 (multiplied by 1000)
|
||||
/// for use with the generic comparator.
|
||||
/// Each frame's L2 norm is quantized to an i64 (multiplied by
|
||||
/// [`NORM_QUANTIZATION_SCALE`]) for use with the generic comparator.
|
||||
fn to_sequence(frames: &[Vec<f64>]) -> Sequence<i64> {
|
||||
let mut seq = Sequence::new();
|
||||
for (i, frame) in frames.iter().enumerate() {
|
||||
let norm = frame.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||
let quantized = (norm * 1000.0) as i64;
|
||||
let quantized = (norm * NORM_QUANTIZATION_SCALE) as i64;
|
||||
seq.push(quantized, i as u64);
|
||||
}
|
||||
seq
|
||||
@@ -537,4 +550,14 @@ mod tests {
|
||||
let dbg = format!("{:?}", classifier);
|
||||
assert!(dbg.contains("TemporalGestureClassifier"));
|
||||
}
|
||||
|
||||
// -- ADR-154 §7.4: de-magic-constant pin test.
|
||||
|
||||
/// De-magicked confidence epsilon + quantization scale must equal the
|
||||
/// prior inline literals.
|
||||
#[test]
|
||||
fn temporal_gesture_consts_unchanged_from_literals() {
|
||||
assert_eq!(CONFIDENCE_SECOND_BEST_EPSILON, 1e-10);
|
||||
assert_eq!(NORM_QUANTIZATION_SCALE, 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user