//! Comprehensive Unit Tests for CUSUM Feature Extraction (Wave D Phase 3, Agent D13) //! //! This test suite validates CUSUM-based regime features (indices 201-210, 10 features): //! 1. **S+ Normalized** (201): Positive CUSUM sum, clamped to [0.0, 1.5] //! 2. **S- Normalized** (202): Negative CUSUM sum, clamped to [0.0, 1.5] //! 3. **Break Frequency** (203): Breaks per 20-bar rolling window //! 4. **Positive Break Count** (204): Count in rolling window //! 5. **Negative Break Count** (205): Count in rolling window //! 6. **Average Break Intensity** (206): Mean magnitude of breaks //! 7. **Time Since Last Break** (207): Bars since last detection, normalized //! 8. **Drift Ratio** (208): S+ / (S+ + S- + 1e-10) //! 9. **Volatility of CUSUM** (209): Std dev of S+ over 20 bars //! 10. **Detection Proximity** (210): min(S+, S-) / threshold //! //! ## Test Coverage //! - ✅ Initialization (5 tests): Constructor, cold start, default values //! - ✅ Normalization (5 tests): S+ bounds, S- bounds, clamp at 1.5x threshold //! - ✅ Break detection (5 tests): Single break, consecutive breaks, direction tracking //! - ✅ Frequency tracking (5 tests): Window overflow, empty window, partial fill //! - ✅ Count tracking (5 tests): Positive/negative separation, rolling window //! - ✅ Intensity/drift (5 tests): Extreme values, zero volatility, ratio calculation //! //! ## TDD Methodology //! Tests written FIRST, implementation follows. // ==================== CATEGORY 1: INITIALIZATION TESTS (5 tests) ==================== #[test] fn test_cusum_features_new_constructor() { // Test: Constructor initializes with correct parameters let features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // All features should be zero at initialization let result = features.current_features(); assert_eq!(result.len(), 10, "Should return exactly 10 features"); assert_eq!(result[0], 0.0, "Feature 201 (S+) should be 0.0 at init"); assert_eq!(result[1], 0.0, "Feature 202 (S-) should be 0.0 at init"); assert_eq!( result[2], 0.0, "Feature 203 (break frequency) should be 0.0 at init" ); assert_eq!( result[3], 0.0, "Feature 204 (positive break count) should be 0.0 at init" ); assert_eq!( result[4], 0.0, "Feature 205 (negative break count) should be 0.0 at init" ); assert_eq!( result[5], 0.0, "Feature 206 (average break intensity) should be 0.0 at init" ); assert_eq!( result[6], 0.0, "Feature 207 (time since last break) should be 0.0 at init" ); assert_eq!( result[7], 0.5, "Feature 208 (drift ratio) should be 0.5 at init (neutral)" ); assert_eq!( result[8], 0.0, "Feature 209 (CUSUM volatility) should be 0.0 at init" ); assert_eq!( result[9], 0.0, "Feature 210 (detection proximity) should be 0.0 at init" ); } #[test] fn test_cusum_features_cold_start_stability() { // Test: Features remain stable during cold start (first 20 bars) let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed 5 bars of neutral data (within drift allowance) for _ in 0..5 { let result = features.update(0.2); // Small positive value // All features should remain near zero during cold start assert!(result[0] <= 0.1, "S+ should remain small during cold start"); assert!(result[1] <= 0.1, "S- should remain small during cold start"); assert_eq!( result[2], 0.0, "Break frequency should be 0 during cold start" ); } } #[test] fn test_cusum_features_default_values_within_bounds() { // Test: All features start within valid bounds let features = RegimeCUSUMFeatures::new(100.0, 10.0, 0.5, 5.0); let result = features.current_features(); // Verify all features are within valid ranges assert!( result[0] >= 0.0 && result[0] <= 1.5, "Feature 201 (S+) out of bounds" ); assert!( result[1] >= 0.0 && result[1] <= 1.5, "Feature 202 (S-) out of bounds" ); assert!( result[2] >= 0.0 && result[2] <= 1.0, "Feature 203 (frequency) out of bounds" ); assert!( result[3] >= 0.0, "Feature 204 (positive count) should be non-negative" ); assert!( result[4] >= 0.0, "Feature 205 (negative count) should be non-negative" ); assert!( result[7] >= 0.0 && result[7] <= 1.0, "Feature 208 (drift ratio) out of bounds" ); } #[test] fn test_cusum_features_parameter_validation() { // Test: Constructor handles edge case parameters let features1 = RegimeCUSUMFeatures::new(0.0, 0.0, 0.5, 5.0); // Zero std let features2 = RegimeCUSUMFeatures::new(0.0, -1.0, 0.5, 5.0); // Negative std // Should not panic, should clamp std to minimum value (1e-10) let result1 = features1.current_features(); let result2 = features2.current_features(); assert!( result1.iter().all(|&x| x.is_finite()), "Features should be finite with zero std" ); assert!( result2.iter().all(|&x| x.is_finite()), "Features should be finite with negative std" ); } #[test] fn test_cusum_features_reset_behavior() { // Test: Reset clears all state correctly let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Accumulate some state for _ in 0..10 { features.update(2.0); // Large positive values } // Reset features.reset(); // Verify reset state let result = features.current_features(); assert_eq!(result[0], 0.0, "S+ should be reset to 0.0"); assert_eq!(result[1], 0.0, "S- should be reset to 0.0"); assert_eq!(result[2], 0.0, "Break frequency should be reset to 0.0"); assert_eq!( result[3], 0.0, "Positive break count should be reset to 0.0" ); assert_eq!( result[4], 0.0, "Negative break count should be reset to 0.0" ); } // ==================== CATEGORY 2: NORMALIZATION TESTS (5 tests) ==================== #[test] fn test_cusum_s_plus_normalization() { // Test: S+ normalizes correctly and stays within [0.0, 1.5] let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed values that trigger S+ accumulation (threshold = 5.0) for i in 0..10 { let result = features.update(2.0); // Above mean, triggers S+ accumulation // Feature 201 (S+) should be normalized: S+ / threshold, clamped at 1.5 assert!( result[0] >= 0.0 && result[0] <= 1.5, "Iteration {}: S+ normalized out of bounds: {}", i, result[0] ); // S+ should increase monotonically until clamped if i > 0 { // Skip exact comparison due to max(0, ...) logic } } } #[test] fn test_cusum_s_minus_normalization() { // Test: S- normalizes correctly and stays within [0.0, 1.5] let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed values that trigger S- accumulation for i in 0..10 { let result = features.update(-2.0); // Below mean, triggers S- accumulation // Feature 202 (S-) should be normalized: S- / threshold, clamped at 1.5 assert!( result[1] >= 0.0 && result[1] <= 1.5, "Iteration {}: S- normalized out of bounds: {}", i, result[1] ); } } #[test] fn test_cusum_clamp_at_1_5x_threshold() { // Test: Normalization clamps at 1.5x threshold (max 1.5 after normalization) let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed extreme values to exceed threshold for _ in 0..20 { let result = features.update(5.0); // Very large positive value // S+ normalized should never exceed 1.5 assert!( result[0] <= 1.5, "S+ normalized should clamp at 1.5, got {}", result[0] ); } } #[test] fn test_cusum_normalization_with_small_threshold() { // Test: Normalization works correctly with small thresholds let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.25, 1.0); // h = 1.0 let result = features.update(1.5); // Single large spike // With h = 1.0, S+ should normalize quickly assert!( result[0] >= 0.0 && result[0] <= 1.5, "S+ normalized out of bounds with small threshold" ); assert!( result[0] > 0.5, "S+ should accumulate significantly with large spike" ); } #[test] fn test_cusum_normalization_symmetry() { // Test: S+ and S- normalization is symmetric let mut features_pos = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); let mut features_neg = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed symmetric values for _ in 0..5 { features_pos.update(2.0); // Positive features_neg.update(-2.0); // Negative } let result_pos = features_pos.current_features(); let result_neg = features_neg.current_features(); // S+ for positive should match S- for negative (within tolerance) let tolerance = 0.1; assert!( (result_pos[0] - result_neg[1]).abs() < tolerance, "Normalization should be symmetric: S+={} vs S-={}", result_pos[0], result_neg[1] ); } // ==================== CATEGORY 3: BREAK DETECTION TESTS (5 tests) ==================== #[test] fn test_cusum_single_break_detection() { // Test: Detects a single structural break let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed values to trigger a break (threshold = 5.0, drift = 0.5) // Need S+ > 5.0: (value - 0.0)/1.0 - 0.5 accumulated for _ in 0..10 { let result = features.update(3.0); // z = 3.0, net = 2.5 per bar // After ~2-3 bars, should detect break (2.5 * 2 = 5.0) } // Break frequency (Feature 203) should be > 0 after detection let result = features.current_features(); assert!( result[2] > 0.0, "Break frequency should increase after detection, got {}", result[2] ); } #[test] fn test_cusum_consecutive_breaks() { // Test: Tracks consecutive breaks correctly let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Lower threshold let mut break_count = 0; // Trigger multiple breaks for i in 0..20 { let value = if i < 5 { 5.0 } else if i < 10 { -5.0 } else { 5.0 }; let result = features.update(value); // Check if Feature 203 (break frequency) increased if result[2] > break_count as f64 / 20.0 { break_count += 1; } } // Should detect multiple breaks (at least 2) let result = features.current_features(); assert!( result[2] >= 0.1, "Should detect at least 2 breaks in 20 bars, got frequency {}", result[2] ); } #[test] fn test_cusum_break_direction_tracking() { // Test: Correctly distinguishes positive vs negative breaks let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Trigger positive break for _ in 0..5 { features.update(5.0); } let result = features.current_features(); // Feature 204 (positive break count) should be > 0 // Feature 205 (negative break count) should be 0 assert!( result[3] > 0.0, "Positive break count should increase, got {}", result[3] ); assert_eq!( result[4], 0.0, "Negative break count should be 0, got {}", result[4] ); } #[test] fn test_cusum_no_false_positives_with_noise() { // Test: Does not detect breaks with random noise within drift allowance let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed random noise within drift allowance (±0.3) let noise = vec![0.1, -0.2, 0.3, -0.1, 0.2, -0.3, 0.1, -0.2, 0.3, -0.1]; for &value in &noise { features.update(value); } let result = features.current_features(); // Break frequency should be 0 (no false positives) assert_eq!( result[2], 0.0, "Should not detect breaks with small noise, got frequency {}", result[2] ); } #[test] fn test_cusum_break_after_reset() { // Test: Break detection works correctly after reset let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Trigger first break for _ in 0..5 { features.update(5.0); } // Reset features.reset(); // Trigger second break for _ in 0..5 { features.update(-5.0); } let result = features.current_features(); // Should detect new break after reset assert!( result[2] > 0.0, "Should detect break after reset, got frequency {}", result[2] ); assert!( result[4] > 0.0, "Should detect negative break after reset, got count {}", result[4] ); } // ==================== CATEGORY 4: FREQUENCY TESTS (5 tests) ==================== #[test] fn test_cusum_frequency_window_overflow() { // Test: Rolling window correctly removes old breaks let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Trigger break at bar 1 for _ in 0..5 { features.update(5.0); } // Feed 20 more bars of neutral data for _ in 0..20 { features.update(0.0); } let result = features.current_features(); // Frequency should drop (old break fell out of 20-bar window) assert!( result[2] <= 0.05, "Old breaks should fall out of window, got frequency {}", result[2] ); } #[test] fn test_cusum_frequency_empty_window() { // Test: Frequency is 0.0 when window is empty let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed 20 bars of neutral data (no breaks) for _ in 0..20 { features.update(0.1); } let result = features.current_features(); // Frequency should be exactly 0.0 assert_eq!( result[2], 0.0, "Empty window should have frequency 0.0, got {}", result[2] ); } #[test] fn test_cusum_frequency_partial_fill() { // Test: Frequency calculation with partially filled window let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Trigger break at bar 3 for i in 0..5 { features.update(if i < 3 { 5.0 } else { 0.0 }); } let result = features.current_features(); // Frequency = breaks / min(bars, window_size) // Should be 1 break / 5 bars = 0.2 (if window_size >= 5) assert!( result[2] >= 0.1 && result[2] <= 0.5, "Partial window frequency out of range, got {}", result[2] ); } #[test] fn test_cusum_frequency_multiple_breaks_in_window() { // Test: Correctly counts multiple breaks in window let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 2.0); // Low threshold // Trigger 3 breaks in 15 bars (alternating regime) for i in 0..15 { let value = if i % 5 < 3 { 5.0 } else { -5.0 }; features.update(value); } let result = features.current_features(); // Frequency should reflect multiple breaks (at least 2/15 = 0.13) assert!( result[2] >= 0.1, "Should detect multiple breaks, got frequency {}", result[2] ); } #[test] fn test_cusum_frequency_normalization_bounds() { // Test: Frequency never exceeds 1.0 (100%) let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 1.0); // Very low threshold // Trigger many breaks for i in 0..30 { let value = if i % 2 == 0 { 5.0 } else { -5.0 }; features.update(value); } let result = features.current_features(); // Frequency should never exceed 1.0 assert!( result[2] <= 1.0, "Frequency should be capped at 1.0, got {}", result[2] ); } // ==================== CATEGORY 5: COUNT TESTS (5 tests) ==================== #[test] fn test_cusum_positive_negative_count_separation() { // Test: Positive and negative counts are tracked separately let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Trigger 2 positive breaks for _ in 0..3 { features.update(5.0); } features.update(0.0); // Reset accumulation for _ in 0..3 { features.update(5.0); } features.update(0.0); // Trigger 1 negative break for _ in 0..3 { features.update(-5.0); } let result = features.current_features(); // Positive count should be > negative count assert!( result[3] > result[4], "Positive count ({}) should exceed negative count ({})", result[3], result[4] ); } #[test] fn test_cusum_count_rolling_window() { // Test: Counts use rolling 20-bar window let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Trigger positive break for _ in 0..5 { features.update(5.0); } let result_early = features.current_features(); let early_count = result_early[3]; // Feed 20 more neutral bars for _ in 0..20 { features.update(0.0); } let result_late = features.current_features(); // Count should decrease as break leaves window assert!( result_late[3] <= early_count, "Count should decrease as breaks leave window: {} -> {}", early_count, result_late[3] ); } #[test] fn test_cusum_count_increments_correctly() { // Test: Count increments by 1 for each break let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 2.5); let initial_count = features.current_features()[3]; // Trigger single positive break for _ in 0..4 { features.update(4.0); } let result = features.current_features(); // Count should increase assert!( result[3] > initial_count, "Count should increase after break: {} -> {}", initial_count, result[3] ); } #[test] fn test_cusum_count_zero_after_window_clear() { // Test: Counts drop to zero after window clears let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Trigger break for _ in 0..5 { features.update(5.0); } // Feed 21 bars of neutral data (clear 20-bar window) for _ in 0..21 { features.update(0.0); } let result = features.current_features(); // Both counts should be 0 assert_eq!( result[3], 0.0, "Positive count should be 0 after window clear, got {}", result[3] ); assert_eq!( result[4], 0.0, "Negative count should be 0 after window clear, got {}", result[4] ); } #[test] fn test_cusum_count_with_rapid_breaks() { // Test: Counts handle rapid consecutive breaks let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 2.0); // Rapid alternating breaks for i in 0..10 { let value = if i % 2 == 0 { 5.0 } else { -5.0 }; for _ in 0..3 { features.update(value); } } let result = features.current_features(); // Both counts should be > 0 assert!( result[3] > 0.0, "Positive count should increase with rapid breaks" ); assert!( result[4] > 0.0, "Negative count should increase with rapid breaks" ); // Total count should be reasonable (< 20) let total_count = result[3] + result[4]; assert!( total_count <= 20.0, "Total count should be <= window size, got {}", total_count ); } // ==================== CATEGORY 6: INTENSITY/DRIFT TESTS (5 tests) ==================== #[test] fn test_cusum_intensity_extreme_values() { // Test: Average break intensity tracks magnitude let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 3.0); // Trigger break with extreme magnitude for _ in 0..10 { features.update(10.0); // Very large shift } let result = features.current_features(); // Feature 206 (average break intensity) should be high assert!( result[5] > 1.0, "Average break intensity should be high with extreme values, got {}", result[5] ); } #[test] fn test_cusum_zero_volatility_edge_case() { // Test: Handles zero volatility gracefully let mut features = RegimeCUSUMFeatures::new(100.0, 1e-10, 0.5, 5.0); // Feed constant values for _ in 0..10 { features.update(100.0); } let result = features.current_features(); // Should not produce NaN or Inf assert!( result.iter().all(|&x| x.is_finite()), "Features should be finite with zero volatility" ); } #[test] fn test_cusum_drift_ratio_calculation() { // Test: Drift ratio (S+ / (S+ + S-)) is correct let mut features_pos = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 10.0); let mut features_neg = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 10.0); // Pure positive drift for _ in 0..5 { features_pos.update(2.0); } // Pure negative drift for _ in 0..5 { features_neg.update(-2.0); } let result_pos = features_pos.current_features(); let result_neg = features_neg.current_features(); // Feature 208 (drift ratio) assert!( result_pos[7] > 0.8, "Positive drift ratio should be high, got {}", result_pos[7] ); assert!( result_neg[7] < 0.2, "Negative drift ratio should be low, got {}", result_neg[7] ); } #[test] fn test_cusum_volatility_tracking() { // Test: Feature 209 (volatility of CUSUM) tracks S+ variability let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 10.0); // Feed alternating values to create volatility for i in 0..20 { let value = if i % 2 == 0 { 1.5 } else { 0.5 }; features.update(value); } let result = features.current_features(); // Feature 209 should be > 0 (S+ varies) assert!( result[8] >= 0.0, "CUSUM volatility should be non-negative, got {}", result[8] ); } #[test] fn test_cusum_detection_proximity() { // Test: Feature 210 (detection proximity) reflects distance to threshold let mut features = RegimeCUSUMFeatures::new(0.0, 1.0, 0.5, 5.0); // Feed values to approach threshold (but not exceed) for _ in 0..3 { features.update(1.5); // Net: 1.0 per bar, total S+ ~3.0 } let result = features.current_features(); // Feature 210: min(S+, S-) / threshold = 3.0 / 5.0 = 0.6 assert!( result[9] >= 0.0 && result[9] <= 1.0, "Detection proximity should be in [0, 1], got {}", result[9] ); assert!( result[9] > 0.3, "Detection proximity should reflect nearness to threshold" ); } // ==================== HELPER STRUCT (to be implemented) ==================== /// RegimeCUSUMFeatures - Feature extractor for CUSUM-based regime statistics /// /// This struct will be implemented in ml/src/features/regime_cusum_features.rs /// /// Expected API: /// - `new(mean, std, drift, threshold)` -> Self /// - `update(value)` -> [f64; 10] (returns all 10 features) /// - `current_features()` -> [f64; 10] /// - `reset()` -> clears state #[derive(Debug, Clone)] struct RegimeCUSUMFeatures { // CUSUM detector (reuse from ml::regime::cusum) detector: ml::regime::cusum::CUSUMDetector, // Rolling window for break tracking (20 bars) break_history: std::collections::VecDeque<(bool, String, f64)>, // (detected, direction, magnitude) window_size: usize, // State tracking s_plus_history: std::collections::VecDeque, bars_since_last_break: usize, // Configuration threshold: f64, } impl RegimeCUSUMFeatures { fn new(mean: f64, std: f64, drift: f64, threshold: f64) -> Self { Self { detector: ml::regime::cusum::CUSUMDetector::new(mean, std, drift, threshold), break_history: std::collections::VecDeque::with_capacity(20), window_size: 20, s_plus_history: std::collections::VecDeque::with_capacity(20), bars_since_last_break: 0, threshold, } } fn update(&mut self, value: f64) -> [f64; 10] { // Update CUSUM detector let break_event = self.detector.update(value); // Track break event let detected = break_event.is_some(); if detected { let event = break_event.unwrap(); self.break_history .push_back((true, event.direction.clone(), event.magnitude)); self.bars_since_last_break = 0; } else { self.break_history.push_back((false, String::new(), 0.0)); self.bars_since_last_break += 1; } // Maintain rolling window if self.break_history.len() > self.window_size { self.break_history.pop_front(); } // Get current CUSUM sums let (s_plus, s_minus) = self.detector.get_current_sums(); self.s_plus_history.push_back(s_plus); if self.s_plus_history.len() > self.window_size { self.s_plus_history.pop_front(); } self.compute_features(s_plus, s_minus) } fn current_features(&self) -> [f64; 10] { let (s_plus, s_minus) = self.detector.get_current_sums(); self.compute_features(s_plus, s_minus) } fn reset(&mut self) { self.detector.reset(); self.break_history.clear(); self.s_plus_history.clear(); self.bars_since_last_break = 0; } fn compute_features(&self, s_plus: f64, s_minus: f64) -> [f64; 10] { // Feature 201: S+ normalized [0, 1.5] let s_plus_norm = (s_plus / self.threshold).min(1.5); // Feature 202: S- normalized [0, 1.5] let s_minus_norm = (s_minus / self.threshold).min(1.5); // Feature 203: Break frequency (breaks per window) let break_count = self.break_history.iter().filter(|(d, _, _)| *d).count() as f64; let break_frequency = break_count / self.window_size.max(1) as f64; // Feature 204: Positive break count let pos_count = self .break_history .iter() .filter(|(d, dir, _)| *d && dir == "positive") .count() as f64; // Feature 205: Negative break count let neg_count = self .break_history .iter() .filter(|(d, dir, _)| *d && dir == "negative") .count() as f64; // Feature 206: Average break intensity let intensities: Vec = self .break_history .iter() .filter(|(d, _, _)| *d) .map(|(_, _, mag)| mag.abs()) .collect(); let avg_intensity = if intensities.is_empty() { 0.0 } else { intensities.iter().sum::() / intensities.len() as f64 }; // Feature 207: Time since last break (normalized by window size) let time_since_break = (self.bars_since_last_break as f64 / self.window_size as f64).min(1.0); // Feature 208: Drift ratio S+ / (S+ + S- + 1e-10) let drift_ratio = s_plus / (s_plus + s_minus + 1e-10); // Feature 209: Volatility of CUSUM (std dev of S+ over window) let s_plus_vol = if self.s_plus_history.len() > 1 { let mean = self.s_plus_history.iter().sum::() / self.s_plus_history.len() as f64; let variance = self .s_plus_history .iter() .map(|&x| (x - mean).powi(2)) .sum::() / self.s_plus_history.len() as f64; variance.sqrt() } else { 0.0 }; // Feature 210: Detection proximity min(S+, S-) / threshold let detection_proximity = s_plus.min(s_minus) / self.threshold; [ s_plus_norm, // 201 s_minus_norm, // 202 break_frequency, // 203 pos_count, // 204 neg_count, // 205 avg_intensity, // 206 time_since_break, // 207 drift_ratio, // 208 s_plus_vol, // 209 detection_proximity, // 210 ] } }