//! CRV (Coordinate Remote Viewing) signal-line integration for WiFi-DensePose. //! //! Maps the 6-stage CRV protocol from [`ruvector_crv`] to WiFi CSI sensing: //! //! | CRV Stage | WiFi-DensePose Mapping | //! |-----------|------------------------| //! | I (Gestalt) | CSI amplitude/phase pattern classification | //! | II (Sensory) | CSI feature extraction (roughness, spectral centroid, power, ...) | //! | III (Dimensional) | AP mesh topology as spatial graph | //! | IV (Emotional/AOL) | Coherence gate state as AOL detection | //! | V (Interrogation) | Differentiable search over accumulated CSI features | //! | VI (Composite) | MinCut person partitioning | //! //! # Entry Point //! //! [`WifiCrvPipeline`] is the main facade. Create one with [`WifiCrvConfig`], //! then feed CSI frames through the pipeline stages. use ruvector_crv::{ AOLDetection, ConvergenceResult, CrvConfig, CrvError, CrvSessionManager, GestaltType, GeometricKind, SensoryModality, SketchElement, SpatialRelationType, SpatialRelationship, StageIData, StageIIData, StageIIIData, StageIVData, StageVData, StageVIData, }; use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- // Domain types // --------------------------------------------------------------------------- /// An access point node in the WiFi mesh topology. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApNode { /// Unique identifier for this access point. pub id: String, /// Position in 2D floor-plan coordinates (metres). pub position: (f32, f32), /// Estimated coverage radius (metres). pub coverage_radius: f32, } /// A link between two access points. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApLink { /// Source AP identifier. pub from: String, /// Destination AP identifier. pub to: String, /// Measured signal strength between the two APs (0.0-1.0 normalised). pub signal_strength: f32, } /// Coherence gate state mapped to CRV AOL interpretation. /// /// The coherence gate from the viewpoint module produces a binary /// accept/reject decision. This enum extends it with richer semantics /// for the CRV pipeline. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CoherenceGateState { /// Clean signal line -- coherence is high, proceed normally. Accept, /// Mild AOL -- use prediction but flag for review. PredictOnly, /// Strong AOL -- pure noise, discard this frame. Reject, /// Environment shift detected -- recalibrate the pipeline. Recalibrate, } /// Result of processing a single CSI frame through Stages I and II. #[derive(Debug, Clone)] pub struct CsiCrvResult { /// Classified gestalt type for this frame. pub gestalt: GestaltType, /// Confidence of the gestalt classification (0.0-1.0). pub gestalt_confidence: f32, /// Stage I embedding (Poincare ball). pub gestalt_embedding: Vec, /// Stage II sensory embedding. pub sensory_embedding: Vec, } /// Thresholds for gestalt classification from CSI statistics. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GestaltThresholds { /// Variance threshold above which the signal is considered dynamic. pub variance_high: f32, /// Variance threshold below which the signal is considered static. pub variance_low: f32, /// Periodicity score above which the signal is considered periodic. pub periodicity_threshold: f32, /// Energy spike threshold (ratio of max to mean amplitude). pub energy_spike_ratio: f32, /// Structure score threshold for manmade detection. pub structure_threshold: f32, /// Null-subcarrier fraction above which the signal is classified as Water. pub null_fraction_threshold: f32, } impl Default for GestaltThresholds { fn default() -> Self { Self { variance_high: 0.15, variance_low: 0.03, periodicity_threshold: 0.5, energy_spike_ratio: 3.0, structure_threshold: 0.6, null_fraction_threshold: 0.3, } } } /// Configuration for the WiFi CRV pipeline. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WifiCrvConfig { /// Embedding dimensionality (passed to [`CrvConfig`]). pub dimensions: usize, /// Thresholds for CSI gestalt classification. pub gestalt_thresholds: GestaltThresholds, /// Convergence threshold for cross-session matching (0.0-1.0). pub convergence_threshold: f32, } impl Default for WifiCrvConfig { fn default() -> Self { Self { dimensions: 32, gestalt_thresholds: GestaltThresholds::default(), convergence_threshold: 0.6, } } } // --------------------------------------------------------------------------- // CsiGestaltClassifier (Stage I) // --------------------------------------------------------------------------- /// Classifies raw CSI amplitude/phase patterns into CRV gestalt types. /// /// The mapping from WiFi signal characteristics to gestalt primitives: /// /// - **Movement**: high variance + periodic (person walking) /// - **Land**: low variance + stable (empty room) /// - **Energy**: sudden amplitude spikes (door opening, appliance) /// - **Natural**: smooth gradual changes (temperature drift, slow fading) /// - **Manmade**: regular structured patterns (HVAC, machinery) /// - **Water**: many null/zero subcarriers (deep absorption) #[derive(Debug, Clone)] pub struct CsiGestaltClassifier { thresholds: GestaltThresholds, } impl CsiGestaltClassifier { /// Create a new classifier with the given thresholds. pub fn new(thresholds: GestaltThresholds) -> Self { Self { thresholds } } /// Classify a CSI frame into a gestalt type with confidence. /// /// Computes variance, periodicity, energy-spike, structure, and /// null-fraction metrics from the amplitude and phase arrays, then /// selects the best-matching gestalt type. /// /// Returns `(gestalt_type, confidence)` where confidence is in `[0, 1]`. pub fn classify(&self, amplitudes: &[f32], phases: &[f32]) -> (GestaltType, f32) { if amplitudes.is_empty() { return (GestaltType::Land, 0.0); } let variance = Self::compute_variance(amplitudes); let periodicity = Self::compute_periodicity(amplitudes); let energy_spike = Self::compute_energy_spike(amplitudes); let structure = Self::compute_structure(amplitudes, phases); let null_frac = Self::compute_null_fraction(amplitudes); // Score each gestalt type using priority-based gating. // // Evaluation order: // 1. Water (null subcarriers -- very distinctive, takes priority) // 2. Energy (sudden spikes) // 3. Movement (high variance + periodic) // 4. Land (low variance, stable) // 5. Natural (moderate variance, smooth) // 6. Manmade (structured -- suppressed when others are strong) let mut scores = [(GestaltType::Land, 0.0f32); 6]; // Water: many null subcarriers (highest priority). let water_score = if null_frac > self.thresholds.null_fraction_threshold { 0.7 + 0.3 * null_frac } else { 0.1 * null_frac }; scores[5] = (GestaltType::Water, water_score); // Energy: sudden spikes. let energy_score = if energy_spike > self.thresholds.energy_spike_ratio { (energy_spike / (self.thresholds.energy_spike_ratio * 2.0)).min(1.0) } else { 0.1 * energy_spike / self.thresholds.energy_spike_ratio.max(1e-6) }; scores[2] = (GestaltType::Energy, energy_score); // Movement: high variance + periodic. // Suppress when water or energy are strong indicators. let movement_suppress = water_score.max(energy_score); let movement_score = if variance > self.thresholds.variance_high && movement_suppress < 0.6 { 0.6 + 0.4 * periodicity } else if variance > self.thresholds.variance_high { (0.6 + 0.4 * periodicity) * (1.0 - movement_suppress) } else { 0.15 * periodicity }; scores[0] = (GestaltType::Movement, movement_score); // Land: low variance + stable. let land_score = if variance < self.thresholds.variance_low { 0.7 + 0.3 * (1.0 - periodicity) } else { 0.1 * (1.0 - variance.min(1.0)) }; scores[1] = (GestaltType::Land, land_score); // Natural: smooth gradual changes (moderate variance, low periodicity). // Structure score being high should not prevent Natural when variance // is in the moderate range and periodicity is low. let natural_score = if variance > self.thresholds.variance_low && variance < self.thresholds.variance_high && periodicity < self.thresholds.periodicity_threshold { 0.7 + 0.3 * (1.0 - periodicity) } else { 0.1 }; scores[3] = (GestaltType::Natural, natural_score); // Manmade: regular structured patterns. // Suppress when any other strong indicator is present. let manmade_suppress = water_score .max(energy_score) .max(movement_score) .max(natural_score); let manmade_score = if structure > self.thresholds.structure_threshold && manmade_suppress < 0.5 { 0.5 + 0.5 * structure } else { 0.15 * structure * (1.0 - manmade_suppress).max(0.0) }; scores[4] = (GestaltType::Manmade, manmade_score); // Pick the highest-scoring type. let (best_type, best_score) = scores .iter() .fold((GestaltType::Land, 0.0f32), |(bt, bs), &(gt, gs)| { if gs > bs { (gt, gs) } else { (bt, bs) } }); (best_type, best_score.clamp(0.0, 1.0)) } /// Compute the variance of the amplitude array. fn compute_variance(amplitudes: &[f32]) -> f32 { let n = amplitudes.len() as f32; if n < 2.0 { return 0.0; } let mean = amplitudes.iter().sum::() / n; let var = amplitudes.iter().map(|a| (a - mean).powi(2)).sum::() / (n - 1.0); var / mean.powi(2).max(1e-6) // coefficient of variation squared } /// Estimate periodicity via autocorrelation of detrended signal. /// /// Removes the linear trend first so that monotonic signals (ramps, drifts) /// do not produce false periodicity peaks. Then searches for the highest /// autocorrelation at lags >= 2 (lag 1 is always near 1.0 for smooth signals). fn compute_periodicity(amplitudes: &[f32]) -> f32 { let n = amplitudes.len(); if n < 6 { return 0.0; } // Detrend: remove the least-squares linear fit. let nf = n as f32; let mean_x = (nf - 1.0) / 2.0; let mean_y = amplitudes.iter().sum::() / nf; let mut cov_xy = 0.0f32; let mut var_x = 0.0f32; for (i, &a) in amplitudes.iter().enumerate() { let dx = i as f32 - mean_x; cov_xy += dx * (a - mean_y); var_x += dx * dx; } let slope = if var_x > 1e-12 { cov_xy / var_x } else { 0.0 }; let intercept = mean_y - slope * mean_x; let detrended: Vec = amplitudes .iter() .enumerate() .map(|(i, &a)| a - (slope * i as f32 + intercept)) .collect(); // Autocorrelation at lag 0. let r0: f32 = detrended.iter().map(|x| x * x).sum(); if r0 < 1e-12 { return 0.0; } // Search for the peak autocorrelation at lags >= 2. let mut max_r = 0.0f32; for lag in 2..=(n / 2) { let r: f32 = detrended .iter() .zip(detrended[lag..].iter()) .map(|(a, b)| a * b) .sum(); max_r = max_r.max(r / r0); } max_r.clamp(0.0, 1.0) } /// Compute energy spike ratio (max / mean). fn compute_energy_spike(amplitudes: &[f32]) -> f32 { let mean = amplitudes.iter().sum::() / amplitudes.len().max(1) as f32; let max = amplitudes.iter().cloned().fold(0.0f32, f32::max); max / mean.max(1e-6) } /// Compute a structure score from amplitude and phase regularity. /// /// High structure score indicates regular, repeating patterns typical /// of manmade signals (e.g. periodic OFDM pilot tones, HVAC interference). /// A purely smooth/monotonic signal (like a slow ramp) is penalised because /// "structure" in the WiFi context implies non-trivial oscillation amplitude. fn compute_structure(amplitudes: &[f32], phases: &[f32]) -> f32 { if amplitudes.len() < 4 { return 0.0; } // Compute successive differences. let diffs: Vec = amplitudes .windows(2) .map(|w| (w[1] - w[0]).abs()) .collect(); let mean_diff = diffs.iter().sum::() / diffs.len().max(1) as f32; let var_diff = if diffs.len() > 1 { diffs.iter().map(|d| (d - mean_diff).powi(2)).sum::() / (diffs.len() - 1) as f32 } else { 0.0 }; // Low variance of differences implies regular structure. let amp_regularity = 1.0 / (1.0 + var_diff); // Require non-trivial oscillation: mean diff must be a meaningful // fraction of the signal range. A slow ramp (tiny diffs) should not // score high on structure. let min_a = amplitudes.iter().cloned().fold(f32::MAX, f32::min); let max_a = amplitudes.iter().cloned().fold(f32::MIN, f32::max); let range = (max_a - min_a).max(1e-6); let diff_significance = (mean_diff / range).clamp(0.0, 1.0); // Phase regularity: how linear is the phase progression? let phase_regularity = if phases.len() >= 4 { let pd: Vec = phases.windows(2).map(|w| w[1] - w[0]).collect(); let mean_pd = pd.iter().sum::() / pd.len() as f32; let var_pd = pd.iter().map(|d| (d - mean_pd).powi(2)).sum::() / (pd.len().max(1) - 1).max(1) as f32; 1.0 / (1.0 + var_pd) } else { 0.5 }; let raw = amp_regularity * 0.6 + phase_regularity * 0.4; // Scale by diff significance so smooth/monotonic signals get low structure. (raw * diff_significance).clamp(0.0, 1.0) } /// Fraction of subcarriers with near-zero amplitude. fn compute_null_fraction(amplitudes: &[f32]) -> f32 { let threshold = 1e-3; let nulls = amplitudes.iter().filter(|&&a| a.abs() < threshold).count(); nulls as f32 / amplitudes.len().max(1) as f32 } } // --------------------------------------------------------------------------- // CsiSensoryEncoder (Stage II) // --------------------------------------------------------------------------- /// Extracts sensory-like features from CSI data for Stage II encoding. /// /// The mapping from signal processing metrics to sensory modalities: /// /// - **Texture** -> amplitude roughness (high-frequency variance) /// - **Color** -> frequency-domain spectral centroid /// - **Temperature** -> signal energy (total power) /// - **Sound** -> temporal periodicity (breathing/heartbeat frequency) /// - **Luminosity** -> SNR / coherence level /// - **Dimension** -> subcarrier spread (bandwidth utilisation) #[derive(Debug, Clone)] pub struct CsiSensoryEncoder; impl CsiSensoryEncoder { /// Create a new sensory encoder. pub fn new() -> Self { Self } /// Extract sensory impressions from a CSI frame. /// /// Returns a list of `(SensoryModality, descriptor_string)` pairs /// suitable for feeding into [`ruvector_crv::StageIIEncoder`]. pub fn extract( &self, amplitudes: &[f32], phases: &[f32], ) -> Vec<(SensoryModality, String)> { let mut impressions = Vec::new(); // Texture: amplitude roughness (high-freq variance). let roughness = self.amplitude_roughness(amplitudes); let texture_desc = if roughness > 0.5 { "rough coarse" } else if roughness > 0.2 { "moderate grain" } else { "smooth flat" }; impressions.push((SensoryModality::Texture, texture_desc.to_string())); // Color: spectral centroid (maps to a pseudo colour). let centroid = self.spectral_centroid(amplitudes); let color_desc = if centroid > 0.7 { "blue high-freq" } else if centroid > 0.4 { "green mid-freq" } else { "red low-freq" }; impressions.push((SensoryModality::Color, color_desc.to_string())); // Temperature: signal energy (total power). let energy = self.signal_energy(amplitudes); let temp_desc = if energy > 1.0 { "hot high-power" } else if energy > 0.3 { "warm moderate" } else { "cold low-power" }; impressions.push((SensoryModality::Temperature, temp_desc.to_string())); // Sound: temporal periodicity. let periodicity = CsiGestaltClassifier::compute_periodicity(amplitudes); let sound_desc = if periodicity > 0.6 { "rhythmic periodic" } else if periodicity > 0.3 { "hum steady" } else { "silent still" }; impressions.push((SensoryModality::Sound, sound_desc.to_string())); // Luminosity: phase coherence as SNR proxy. let snr = self.phase_coherence(phases); let lum_desc = if snr > 0.7 { "bright clear" } else if snr > 0.4 { "dim moderate" } else { "dark noisy" }; impressions.push((SensoryModality::Luminosity, lum_desc.to_string())); // Dimension: subcarrier spread. let spread = self.subcarrier_spread(amplitudes); let dim_desc = if spread > 0.7 { "vast wide" } else if spread > 0.3 { "medium regular" } else { "narrow compact" }; impressions.push((SensoryModality::Dimension, dim_desc.to_string())); impressions } /// Amplitude roughness: mean absolute difference normalised by signal range. /// /// High roughness means large sample-to-sample jumps relative to the /// dynamic range, indicating irregular high-frequency amplitude variation. fn amplitude_roughness(&self, amplitudes: &[f32]) -> f32 { if amplitudes.len() < 3 { return 0.0; } let min_a = amplitudes.iter().cloned().fold(f32::MAX, f32::min); let max_a = amplitudes.iter().cloned().fold(f32::MIN, f32::max); let range = (max_a - min_a).max(1e-6); let mean_abs_diff: f32 = amplitudes .windows(2) .map(|w| (w[1] - w[0]).abs()) .sum::() / (amplitudes.len() - 1) as f32; (mean_abs_diff / range).clamp(0.0, 1.0) } /// Spectral centroid: weighted mean of subcarrier indices. fn spectral_centroid(&self, amplitudes: &[f32]) -> f32 { let total: f32 = amplitudes.iter().sum(); if total < 1e-12 { return 0.5; } let weighted: f32 = amplitudes .iter() .enumerate() .map(|(i, &a)| i as f32 * a) .sum(); let centroid = weighted / total; let n = amplitudes.len().max(1) as f32; (centroid / n).clamp(0.0, 1.0) } /// Signal energy: mean squared amplitude. fn signal_energy(&self, amplitudes: &[f32]) -> f32 { let n = amplitudes.len().max(1) as f32; amplitudes.iter().map(|a| a * a).sum::() / n } /// Phase coherence: magnitude of the mean unit phasor. fn phase_coherence(&self, phases: &[f32]) -> f32 { if phases.is_empty() { return 0.0; } let n = phases.len() as f32; let sum_cos: f32 = phases.iter().map(|p| p.cos()).sum(); let sum_sin: f32 = phases.iter().map(|p| p.sin()).sum(); ((sum_cos / n).powi(2) + (sum_sin / n).powi(2)).sqrt() } /// Subcarrier spread: fraction of subcarriers above a threshold. fn subcarrier_spread(&self, amplitudes: &[f32]) -> f32 { if amplitudes.is_empty() { return 0.0; } let max = amplitudes.iter().cloned().fold(0.0f32, f32::max); let threshold = max * 0.1; let active = amplitudes.iter().filter(|&&a| a > threshold).count(); active as f32 / amplitudes.len() as f32 } } // --------------------------------------------------------------------------- // WifiCrvPipeline (main entry point) // --------------------------------------------------------------------------- /// Main entry point for the WiFi CRV signal-line integration. /// /// Wraps [`CrvSessionManager`] with WiFi-DensePose domain logic so that /// callers feed CSI frames and AP topology rather than raw CRV stage data. pub struct WifiCrvPipeline { /// Underlying CRV session manager. manager: CrvSessionManager, /// Gestalt classifier for Stage I. gestalt: CsiGestaltClassifier, /// Sensory encoder for Stage II. sensory: CsiSensoryEncoder, /// Pipeline configuration. config: WifiCrvConfig, } impl WifiCrvPipeline { /// Create a new WiFi CRV pipeline. pub fn new(config: WifiCrvConfig) -> Self { let crv_config = CrvConfig { dimensions: config.dimensions, convergence_threshold: config.convergence_threshold, ..CrvConfig::default() }; let manager = CrvSessionManager::new(crv_config); let gestalt = CsiGestaltClassifier::new(config.gestalt_thresholds.clone()); let sensory = CsiSensoryEncoder::new(); Self { manager, gestalt, sensory, config, } } /// Create a new CRV session for a room. /// /// The `session_id` identifies the sensing session and `room_id` /// acts as the CRV target coordinate so that cross-session /// convergence can be computed per room. pub fn create_session( &mut self, session_id: &str, room_id: &str, ) -> Result<(), CrvError> { self.manager .create_session(session_id.to_string(), room_id.to_string()) } /// Process a CSI frame through Stages I and II. /// /// Classifies the frame into a gestalt type, extracts sensory features, /// and adds both embeddings to the session. pub fn process_csi_frame( &mut self, session_id: &str, amplitudes: &[f32], phases: &[f32], ) -> Result { if amplitudes.is_empty() { return Err(CrvError::EmptyInput( "CSI amplitudes are empty".to_string(), )); } // Stage I: Gestalt classification. let (gestalt_type, confidence) = self.gestalt.classify(amplitudes, phases); // Build a synthetic ideogram stroke from the amplitude envelope // so the CRV Stage I encoder can produce a Poincare ball embedding. let stroke: Vec<(f32, f32)> = amplitudes .iter() .enumerate() .map(|(i, &a)| (i as f32 / amplitudes.len().max(1) as f32, a)) .collect(); let stage_i = StageIData { stroke, spontaneous_descriptor: format!("{:?}", gestalt_type).to_lowercase(), classification: gestalt_type, confidence, }; let gestalt_embedding = self.manager.add_stage_i(session_id, &stage_i)?; // Stage II: Sensory feature extraction. let impressions = self.sensory.extract(amplitudes, phases); let stage_ii = StageIIData { impressions, feature_vector: None, }; let sensory_embedding = self.manager.add_stage_ii(session_id, &stage_ii)?; Ok(CsiCrvResult { gestalt: gestalt_type, gestalt_confidence: confidence, gestalt_embedding, sensory_embedding, }) } /// Add AP mesh topology as Stage III spatial data. /// /// Each AP becomes a sketch element positioned at its floor-plan /// coordinates with scale proportional to coverage radius. Links /// become spatial relationships with strength from signal strength. /// /// Returns the Stage III embedding. pub fn add_mesh_topology( &mut self, session_id: &str, nodes: &[ApNode], links: &[ApLink], ) -> Result, CrvError> { if nodes.is_empty() { return Err(CrvError::EmptyInput("No AP nodes provided".to_string())); } let sketch_elements: Vec = nodes .iter() .map(|ap| SketchElement { label: ap.id.clone(), kind: GeometricKind::Circle, position: ap.position, scale: Some(ap.coverage_radius), }) .collect(); let relationships: Vec = links .iter() .map(|link| SpatialRelationship { from: link.from.clone(), to: link.to.clone(), relation: SpatialRelationType::Connected, strength: link.signal_strength, }) .collect(); let stage_iii = StageIIIData { sketch_elements, relationships, }; self.manager.add_stage_iii(session_id, &stage_iii) } /// Add a coherence gate state as Stage IV AOL data. /// /// Maps the coherence gate decision to AOL semantics: /// - `Accept` -> clean signal line (no AOL) /// - `PredictOnly` -> mild AOL (flagged but usable) /// - `Reject` -> strong AOL (noise, discard) /// - `Recalibrate` -> environment shift (AOL + tangible change) /// /// Returns the Stage IV embedding. pub fn add_coherence_state( &mut self, session_id: &str, state: CoherenceGateState, score: f32, ) -> Result, CrvError> { let (emotional_impact, tangibles, intangibles, aol_detections) = match state { CoherenceGateState::Accept => ( vec![("confidence".to_string(), 0.8)], vec!["stable environment".to_string()], vec!["clean signal line".to_string()], vec![], ), CoherenceGateState::PredictOnly => ( vec![("uncertainty".to_string(), 0.5)], vec!["prediction mode".to_string()], vec!["mild interference".to_string()], vec![AOLDetection { content: "mild coherence loss".to_string(), timestamp_ms: 0, flagged: true, anomaly_score: score.clamp(0.0, 1.0), }], ), CoherenceGateState::Reject => ( vec![("noise".to_string(), 0.9)], vec![], vec!["signal contaminated".to_string()], vec![AOLDetection { content: "strong incoherence".to_string(), timestamp_ms: 0, flagged: true, anomaly_score: 1.0, }], ), CoherenceGateState::Recalibrate => ( vec![("disruption".to_string(), 0.7)], vec!["environment change".to_string()], vec!["recalibration needed".to_string()], vec![AOLDetection { content: "environment shift".to_string(), timestamp_ms: 0, flagged: false, anomaly_score: score.clamp(0.0, 1.0), }], ), }; let stage_iv = StageIVData { emotional_impact, tangibles, intangibles, aol_detections, }; self.manager.add_stage_iv(session_id, &stage_iv) } /// Run Stage V interrogation on a session. /// /// Given a query embedding (e.g. encoding of "is person moving?"), /// probes the accumulated session data via differentiable search. pub fn interrogate( &mut self, session_id: &str, query_embedding: &[f32], ) -> Result { if query_embedding.is_empty() { return Err(CrvError::EmptyInput( "Query embedding is empty".to_string(), )); } // Probe all stages 1-4 with the query. let probes: Vec<(&str, u8, Vec)> = (1..=4) .map(|stage| ("csi-query", stage, query_embedding.to_vec())) .collect(); let k = 3.min(self.manager.session_entry_count(session_id)); if k == 0 { return Err(CrvError::EmptyInput( "Session has no entries to interrogate".to_string(), )); } self.manager.run_stage_v(session_id, &probes, k) } /// Run Stage VI person partitioning on a session. /// /// Uses MinCut to partition the accumulated session data into /// distinct target aspects -- in the WiFi sensing context these /// correspond to distinct persons or environment zones. pub fn partition_persons( &mut self, session_id: &str, ) -> Result { self.manager.run_stage_vi(session_id) } /// Find cross-session convergence for a room. /// /// Analyses all sessions targeting the given `room_id` to find /// agreement between independent sensing sessions. Higher convergence /// indicates that multiple sessions see the same signal patterns. pub fn find_cross_room_convergence( &self, room_id: &str, min_similarity: f32, ) -> Result { self.manager.find_convergence(room_id, min_similarity) } /// Get the number of entries in a session. pub fn session_entry_count(&self, session_id: &str) -> usize { self.manager.session_entry_count(session_id) } /// Get the number of active sessions. pub fn session_count(&self) -> usize { self.manager.session_count() } /// Remove a session. pub fn remove_session(&mut self, session_id: &str) -> bool { self.manager.remove_session(session_id) } /// Get the pipeline configuration. pub fn config(&self) -> &WifiCrvConfig { &self.config } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; // -- Helpers -- fn test_config() -> WifiCrvConfig { WifiCrvConfig { dimensions: 32, gestalt_thresholds: GestaltThresholds::default(), convergence_threshold: 0.5, } } /// Generate a periodic amplitude signal. fn periodic_signal(n: usize, freq: f32, amplitude: f32) -> Vec { (0..n) .map(|i| amplitude * (2.0 * std::f32::consts::PI * freq * i as f32 / n as f32).sin().abs() + 0.1) .collect() } /// Generate a constant (static) amplitude signal. fn static_signal(n: usize, level: f32) -> Vec { vec![level; n] } /// Generate linear phases. fn linear_phases(n: usize) -> Vec { (0..n).map(|i| i as f32 * 0.1).collect() } /// Generate random-ish phases. fn varied_phases(n: usize) -> Vec { (0..n) .map(|i| (i as f32 * 2.718).sin() * std::f32::consts::PI) .collect() } // ---- Stage I: Gestalt Classification ---- #[test] fn gestalt_movement_high_variance_periodic() { let classifier = CsiGestaltClassifier::new(GestaltThresholds::default()); let amps = periodic_signal(64, 4.0, 1.0); let phases = linear_phases(64); let (gestalt, conf) = classifier.classify(&s, &phases); assert_eq!(gestalt, GestaltType::Movement); assert!(conf > 0.3, "movement confidence should be reasonable: {conf}"); } #[test] fn gestalt_land_low_variance_stable() { let classifier = CsiGestaltClassifier::new(GestaltThresholds::default()); let amps = static_signal(64, 0.5); let phases = linear_phases(64); let (gestalt, conf) = classifier.classify(&s, &phases); assert_eq!(gestalt, GestaltType::Land); assert!(conf > 0.5, "land confidence: {conf}"); } #[test] fn gestalt_energy_spike() { let classifier = CsiGestaltClassifier::new(GestaltThresholds::default()); let mut amps = vec![0.1f32; 64]; amps[32] = 5.0; // large spike let phases = linear_phases(64); let (gestalt, conf) = classifier.classify(&s, &phases); assert_eq!(gestalt, GestaltType::Energy); assert!(conf > 0.3, "energy confidence: {conf}"); } #[test] fn gestalt_water_null_subcarriers() { let classifier = CsiGestaltClassifier::new(GestaltThresholds::default()); let mut amps = vec![0.5f32; 64]; // Set half the subcarriers to zero. for i in 0..32 { amps[i] = 0.0; } let phases = linear_phases(64); let (gestalt, conf) = classifier.classify(&s, &phases); assert_eq!(gestalt, GestaltType::Water); assert!(conf > 0.3, "water confidence: {conf}"); } #[test] fn gestalt_manmade_structured() { let classifier = CsiGestaltClassifier::new(GestaltThresholds { structure_threshold: 0.4, ..GestaltThresholds::default() }); // Perfectly regular alternating pattern. let amps: Vec = (0..64).map(|i| if i % 2 == 0 { 1.0 } else { 0.8 }).collect(); let phases = linear_phases(64); let (gestalt, conf) = classifier.classify(&s, &phases); assert_eq!(gestalt, GestaltType::Manmade); assert!(conf > 0.3, "manmade confidence: {conf}"); } #[test] fn gestalt_natural_smooth_gradual() { let classifier = CsiGestaltClassifier::new(GestaltThresholds { variance_low: 0.001, variance_high: 0.5, ..GestaltThresholds::default() }); // Slow ramp -- moderate variance, low periodicity, low structure. let amps: Vec = (0..64).map(|i| 0.3 + 0.005 * i as f32).collect(); let phases = varied_phases(64); let (gestalt, conf) = classifier.classify(&s, &phases); assert_eq!(gestalt, GestaltType::Natural); assert!(conf > 0.3, "natural confidence: {conf}"); } #[test] fn gestalt_empty_amplitudes() { let classifier = CsiGestaltClassifier::new(GestaltThresholds::default()); let (gestalt, conf) = classifier.classify(&[], &[]); assert_eq!(gestalt, GestaltType::Land); assert_eq!(conf, 0.0); } #[test] fn gestalt_single_subcarrier() { let classifier = CsiGestaltClassifier::new(GestaltThresholds::default()); let (gestalt, _conf) = classifier.classify(&[1.0], &[0.0]); // With a single value variance is 0 => Land. assert_eq!(gestalt, GestaltType::Land); } // ---- Stage II: Sensory Feature Extraction ---- #[test] fn sensory_extraction_returns_six_modalities() { let encoder = CsiSensoryEncoder::new(); let amps = periodic_signal(32, 2.0, 0.5); let phases = linear_phases(32); let impressions = encoder.extract(&s, &phases); assert_eq!(impressions.len(), 6); } #[test] fn sensory_texture_rough_for_noisy() { let encoder = CsiSensoryEncoder::new(); // Very spiky signal -> rough texture. let amps: Vec = (0..64) .map(|i| if i % 2 == 0 { 2.0 } else { 0.01 }) .collect(); let phases = linear_phases(64); let impressions = encoder.extract(&s, &phases); let texture = &impressions[0]; assert_eq!(texture.0, SensoryModality::Texture); assert!( texture.1.contains("rough") || texture.1.contains("coarse"), "expected rough texture, got: {}", texture.1 ); } #[test] fn sensory_luminosity_bright_for_coherent() { let encoder = CsiSensoryEncoder::new(); let amps = static_signal(32, 1.0); let phases = vec![0.5f32; 32]; // identical phases = high coherence let impressions = encoder.extract(&s, &phases); let lum = impressions.iter().find(|(m, _)| *m == SensoryModality::Luminosity); assert!(lum.is_some()); let desc = &lum.unwrap().1; assert!( desc.contains("bright"), "expected bright for coherent phases, got: {desc}" ); } #[test] fn sensory_temperature_cold_for_low_power() { let encoder = CsiSensoryEncoder::new(); let amps = static_signal(32, 0.01); let phases = linear_phases(32); let impressions = encoder.extract(&s, &phases); let temp = impressions.iter().find(|(m, _)| *m == SensoryModality::Temperature); assert!(temp.is_some()); assert!( temp.unwrap().1.contains("cold"), "expected cold for low power" ); } #[test] fn sensory_empty_amplitudes() { let encoder = CsiSensoryEncoder::new(); let impressions = encoder.extract(&[], &[]); // Should still return impressions (with default/zero-ish values). assert_eq!(impressions.len(), 6); } // ---- Stage III: Mesh Topology ---- #[test] fn mesh_topology_two_aps() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let nodes = vec![ ApNode { id: "ap-1".into(), position: (0.0, 0.0), coverage_radius: 10.0, }, ApNode { id: "ap-2".into(), position: (5.0, 0.0), coverage_radius: 8.0, }, ]; let links = vec![ApLink { from: "ap-1".into(), to: "ap-2".into(), signal_strength: 0.7, }]; let embedding = pipeline.add_mesh_topology("s1", &nodes, &links).unwrap(); assert_eq!(embedding.len(), 32); } #[test] fn mesh_topology_empty_nodes_errors() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let result = pipeline.add_mesh_topology("s1", &[], &[]); assert!(result.is_err()); } #[test] fn mesh_topology_single_ap_no_links() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let nodes = vec![ApNode { id: "ap-solo".into(), position: (1.0, 2.0), coverage_radius: 5.0, }]; let embedding = pipeline.add_mesh_topology("s1", &nodes, &[]).unwrap(); assert_eq!(embedding.len(), 32); } // ---- Stage IV: Coherence -> AOL ---- #[test] fn coherence_accept_clean_signal() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let emb = pipeline .add_coherence_state("s1", CoherenceGateState::Accept, 0.9) .unwrap(); assert_eq!(emb.len(), 32); } #[test] fn coherence_reject_noisy() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let emb = pipeline .add_coherence_state("s1", CoherenceGateState::Reject, 0.1) .unwrap(); assert_eq!(emb.len(), 32); } #[test] fn coherence_predict_only() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let emb = pipeline .add_coherence_state("s1", CoherenceGateState::PredictOnly, 0.5) .unwrap(); assert_eq!(emb.len(), 32); } #[test] fn coherence_recalibrate() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let emb = pipeline .add_coherence_state("s1", CoherenceGateState::Recalibrate, 0.6) .unwrap(); assert_eq!(emb.len(), 32); } // ---- Full Pipeline Flow ---- #[test] fn full_pipeline_create_process_interrogate_partition() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); // Process two CSI frames. let amps1 = periodic_signal(32, 2.0, 0.8); let phases1 = linear_phases(32); let result1 = pipeline.process_csi_frame("s1", &s1, &phases1).unwrap(); assert_eq!(result1.gestalt_embedding.len(), 32); assert_eq!(result1.sensory_embedding.len(), 32); let amps2 = static_signal(32, 0.5); let phases2 = linear_phases(32); let result2 = pipeline.process_csi_frame("s1", &s2, &phases2).unwrap(); assert_ne!(result1.gestalt, result2.gestalt); // Add mesh topology. let nodes = vec![ ApNode { id: "ap-1".into(), position: (0.0, 0.0), coverage_radius: 10.0 }, ApNode { id: "ap-2".into(), position: (5.0, 3.0), coverage_radius: 8.0 }, ]; let links = vec![ApLink { from: "ap-1".into(), to: "ap-2".into(), signal_strength: 0.8, }]; pipeline.add_mesh_topology("s1", &nodes, &links).unwrap(); // Add coherence state. pipeline .add_coherence_state("s1", CoherenceGateState::Accept, 0.85) .unwrap(); assert_eq!(pipeline.session_entry_count("s1"), 6); // Interrogate. let query = vec![0.5f32; 32]; let stage_v = pipeline.interrogate("s1", &query).unwrap(); // Should have probes for stages 1-4 that have entries. assert!(!stage_v.probes.is_empty()); // Partition. let stage_vi = pipeline.partition_persons("s1").unwrap(); assert!(!stage_vi.partitions.is_empty()); } #[test] fn pipeline_session_not_found() { let mut pipeline = WifiCrvPipeline::new(test_config()); let result = pipeline.process_csi_frame("nonexistent", &[1.0], &[0.0]); assert!(result.is_err()); } #[test] fn pipeline_empty_csi_frame() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let result = pipeline.process_csi_frame("s1", &[], &[]); assert!(result.is_err()); } #[test] fn pipeline_empty_query_interrogation() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let result = pipeline.interrogate("s1", &[]); assert!(result.is_err()); } #[test] fn pipeline_interrogate_empty_session() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let result = pipeline.interrogate("s1", &[1.0; 32]); assert!(result.is_err()); } // ---- Cross-Session Convergence ---- #[test] fn cross_session_convergence_same_room() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("viewer-a", "room-1").unwrap(); pipeline.create_session("viewer-b", "room-1").unwrap(); // Both viewers see the same periodic signal. let amps = periodic_signal(32, 2.0, 0.8); let phases = linear_phases(32); pipeline .process_csi_frame("viewer-a", &s, &phases) .unwrap(); pipeline .process_csi_frame("viewer-b", &s, &phases) .unwrap(); let convergence = pipeline .find_cross_room_convergence("room-1", 0.5) .unwrap(); assert!( !convergence.scores.is_empty(), "identical frames should converge" ); assert!(convergence.scores[0] > 0.5); } #[test] fn cross_session_convergence_different_signals() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("a", "room-2").unwrap(); pipeline.create_session("b", "room-2").unwrap(); // Very different signals. let amps_a = periodic_signal(32, 8.0, 2.0); let amps_b = static_signal(32, 0.01); let phases = linear_phases(32); pipeline .process_csi_frame("a", &s_a, &phases) .unwrap(); pipeline .process_csi_frame("b", &s_b, &phases) .unwrap(); let convergence = pipeline.find_cross_room_convergence("room-2", 0.95); // May or may not converge at high threshold; the key is no panic. assert!(convergence.is_ok()); } #[test] fn cross_session_needs_two_sessions() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("solo", "room-3").unwrap(); pipeline .process_csi_frame("solo", &[1.0; 32], &[0.0; 32]) .unwrap(); let result = pipeline.find_cross_room_convergence("room-3", 0.5); assert!(result.is_err(), "convergence requires at least 2 sessions"); } // ---- Session management ---- #[test] fn session_create_and_remove() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); assert_eq!(pipeline.session_count(), 1); assert!(pipeline.remove_session("s1")); assert_eq!(pipeline.session_count(), 0); assert!(!pipeline.remove_session("s1")); } #[test] fn session_duplicate_errors() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let result = pipeline.create_session("s1", "room-a"); assert!(result.is_err()); } // ---- Edge cases ---- #[test] fn zero_amplitude_frame() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let amps = vec![0.0f32; 32]; let phases = vec![0.0f32; 32]; let result = pipeline.process_csi_frame("s1", &s, &phases); // Should succeed (all-zero is a valid edge case). assert!(result.is_ok()); } #[test] fn single_subcarrier_frame() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let result = pipeline.process_csi_frame("s1", &[1.0], &[0.5]); assert!(result.is_ok()); } #[test] fn large_frame_256_subcarriers() { let mut pipeline = WifiCrvPipeline::new(test_config()); pipeline.create_session("s1", "room-a").unwrap(); let amps = periodic_signal(256, 10.0, 1.0); let phases = linear_phases(256); let result = pipeline.process_csi_frame("s1", &s, &phases); assert!(result.is_ok()); assert_eq!(result.unwrap().gestalt_embedding.len(), 32); } // ---- CsiGestaltClassifier helpers ---- #[test] fn compute_variance_static() { let v = CsiGestaltClassifier::compute_variance(&[1.0; 32]); assert!(v < 1e-6, "static signal should have near-zero variance"); } #[test] fn compute_periodicity_constant() { let p = CsiGestaltClassifier::compute_periodicity(&[1.0; 32]); // Constant signal: autocorrelation peak ratio depends on zero-variance handling. assert!(p >= 0.0 && p <= 1.0); } #[test] fn compute_null_fraction_all_zeros() { let f = CsiGestaltClassifier::compute_null_fraction(&[0.0; 32]); assert!((f - 1.0).abs() < 1e-6, "all zeros should give null fraction 1.0"); } #[test] fn compute_null_fraction_none_zero() { let f = CsiGestaltClassifier::compute_null_fraction(&[1.0; 32]); assert!(f < 1e-6, "no nulls should give null fraction 0.0"); } // ---- CsiSensoryEncoder helpers ---- #[test] fn spectral_centroid_uniform() { let encoder = CsiSensoryEncoder::new(); let amps = vec![1.0f32; 32]; let centroid = encoder.spectral_centroid(&s); // Uniform -> centroid at midpoint. assert!( (centroid - 0.484).abs() < 0.1, "uniform spectral centroid should be near 0.5, got {centroid}" ); } #[test] fn signal_energy_known() { let encoder = CsiSensoryEncoder::new(); let energy = encoder.signal_energy(&[2.0, 2.0, 2.0, 2.0]); assert!((energy - 4.0).abs() < 1e-6, "energy of [2,2,2,2] should be 4.0"); } #[test] fn phase_coherence_identical() { let encoder = CsiSensoryEncoder::new(); let c = encoder.phase_coherence(&[1.0; 100]); assert!(c > 0.99, "identical phases should give coherence ~1.0, got {c}"); } #[test] fn phase_coherence_empty() { let encoder = CsiSensoryEncoder::new(); let c = encoder.phase_coherence(&[]); assert_eq!(c, 0.0); } #[test] fn subcarrier_spread_all_active() { let encoder = CsiSensoryEncoder::new(); let spread = encoder.subcarrier_spread(&[1.0; 32]); assert!((spread - 1.0).abs() < 1e-6, "all active should give spread 1.0"); } #[test] fn subcarrier_spread_empty() { let encoder = CsiSensoryEncoder::new(); let spread = encoder.subcarrier_spread(&[]); assert_eq!(spread, 0.0); } }