Skip to main content

rvcsi_core/
validation.rs

1//! The validation pipeline (ADR-095 D6/D13).
2//!
3//! [`validate_frame`] is the only door between raw adapter output and anything
4//! downstream (DSP, events, the napi boundary, RuVector). It mutates a frame in
5//! place: on success it sets `validation` to `Accepted` or `Degraded` and fills
6//! `quality_score`; on a hard failure it returns a [`ValidationError`] and the
7//! caller quarantines the frame (when quarantine is enabled) or drops it.
8
9use serde::{Deserialize, Serialize};
10
11use crate::adapter::AdapterProfile;
12use crate::frame::{CsiFrame, ValidationStatus};
13
14/// Tunable bounds for the validation pipeline.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ValidationPolicy {
17    /// Minimum acceptable subcarrier count.
18    pub min_subcarriers: u16,
19    /// Maximum acceptable subcarrier count.
20    pub max_subcarriers: u16,
21    /// Plausible RSSI range, dBm (inclusive).
22    pub rssi_dbm_bounds: (i16, i16),
23    /// If `true`, a non-monotonic timestamp is a hard reject; if `false`, the
24    /// frame is marked [`ValidationStatus::Recovered`] and accepted.
25    pub strict_monotonic_time: bool,
26    /// If `true`, frames that fail a soft check become `Degraded` instead of
27    /// being rejected; if `false`, soft failures are rejected too.
28    pub degrade_instead_of_reject: bool,
29    /// Frames whose computed quality is below this become `Degraded`
30    /// (or rejected if `degrade_instead_of_reject` is false).
31    pub min_quality: f32,
32}
33
34impl Default for ValidationPolicy {
35    fn default() -> Self {
36        ValidationPolicy {
37            min_subcarriers: 1,
38            max_subcarriers: 4096,
39            rssi_dbm_bounds: (-110, 0),
40            strict_monotonic_time: false,
41            degrade_instead_of_reject: true,
42            min_quality: 0.25,
43        }
44    }
45}
46
47/// Computed usability confidence for a frame, in `[0.0, 1.0]`.
48///
49/// Starts at `1.0` and accrues multiplicative penalties for: out-of-range
50/// (but non-fatal) RSSI, near-zero amplitude (dead subcarriers), excessive
51/// amplitude spikes, and missing optional metadata that the profile implies
52/// should be present.
53#[derive(Debug, Clone, PartialEq)]
54pub struct QualityScore {
55    /// The final score.
56    pub value: f32,
57    /// Human-readable reasons it was reduced (empty when `value == 1.0`).
58    pub reasons: Vec<String>,
59}
60
61impl QualityScore {
62    fn full() -> Self {
63        QualityScore {
64            value: 1.0,
65            reasons: Vec::new(),
66        }
67    }
68
69    fn penalize(&mut self, factor: f32, reason: impl Into<String>) {
70        self.value = (self.value * factor).clamp(0.0, 1.0);
71        self.reasons.push(reason.into());
72    }
73}
74
75/// Why a frame was rejected (a hard failure).
76#[derive(Debug, Clone, PartialEq, thiserror::Error)]
77#[non_exhaustive]
78pub enum ValidationError {
79    /// The four parallel vectors disagree in length, or none match `subcarrier_count`.
80    #[error("vector length mismatch: i={i}, q={q}, amp={amp}, phase={phase}, subcarrier_count={sc}")]
81    LengthMismatch {
82        /// i_values length
83        i: usize,
84        /// q_values length
85        q: usize,
86        /// amplitude length
87        amp: usize,
88        /// phase length
89        phase: usize,
90        /// declared subcarrier_count
91        sc: usize,
92    },
93    /// Subcarrier count is outside `[policy.min, policy.max]` or not in the profile.
94    #[error("subcarrier count {count} not allowed (policy {min}..={max}, profile-allowed: {profile_ok})")]
95    SubcarrierCount {
96        /// the count
97        count: u16,
98        /// policy minimum
99        min: u16,
100        /// policy maximum
101        max: u16,
102        /// whether the profile's expected list allowed it
103        profile_ok: bool,
104    },
105    /// A non-finite (NaN / inf) value in one of the vectors.
106    #[error("non-finite value in '{vector}' at index {index}")]
107    NonFinite {
108        /// which vector
109        vector: &'static str,
110        /// index of the offending element
111        index: usize,
112    },
113    /// RSSI is so far out of range it's implausible (hard reject).
114    #[error("implausible RSSI {rssi} dBm (bounds {min}..={max})")]
115    ImplausibleRssi {
116        /// reported rssi
117        rssi: i16,
118        /// lower bound
119        min: i16,
120        /// upper bound
121        max: i16,
122    },
123    /// Timestamp went backwards and `strict_monotonic_time` is set.
124    #[error("non-monotonic timestamp: {ts} <= previous {prev}")]
125    NonMonotonicTime {
126        /// this frame's timestamp
127        ts: u64,
128        /// previous frame's timestamp
129        prev: u64,
130    },
131    /// Channel is not supported by the source profile.
132    #[error("channel {channel} not in source profile")]
133    UnsupportedChannel {
134        /// the channel
135        channel: u16,
136    },
137    /// Computed quality fell below `policy.min_quality` and degradation is disabled.
138    #[error("quality {quality} below minimum {min}")]
139    BelowMinQuality {
140        /// computed quality
141        quality: f32,
142        /// configured minimum
143        min: f32,
144    },
145}
146
147/// How implausibly far outside the bounds an RSSI must be before it's a hard
148/// reject rather than a quality penalty.
149const RSSI_HARD_MARGIN: i16 = 30;
150
151/// Validate `frame` against `profile` and `policy`, mutating it in place.
152///
153/// `prev_timestamp_ns` is the timestamp of the previous accepted frame in the
154/// same session (or `None` for the first frame); it is used for the
155/// monotonicity check.
156///
157/// On `Ok(())` the frame's `validation` is `Accepted` / `Degraded` /
158/// `Recovered` and `quality_score` is set. On `Err`, the frame's `validation`
159/// has been set to `Rejected` (so a caller that ignores the error still won't
160/// expose it) and the error explains why.
161pub fn validate_frame(
162    frame: &mut CsiFrame,
163    profile: &AdapterProfile,
164    policy: &ValidationPolicy,
165    prev_timestamp_ns: Option<u64>,
166) -> Result<(), ValidationError> {
167    // -- hard checks ---------------------------------------------------------
168    let sc = frame.subcarrier_count as usize;
169    if frame.i_values.len() != sc
170        || frame.q_values.len() != sc
171        || frame.amplitude.len() != sc
172        || frame.phase.len() != sc
173    {
174        frame.validation = ValidationStatus::Rejected;
175        return Err(ValidationError::LengthMismatch {
176            i: frame.i_values.len(),
177            q: frame.q_values.len(),
178            amp: frame.amplitude.len(),
179            phase: frame.phase.len(),
180            sc,
181        });
182    }
183
184    let profile_ok = profile.accepts_subcarrier_count(frame.subcarrier_count);
185    if frame.subcarrier_count < policy.min_subcarriers
186        || frame.subcarrier_count > policy.max_subcarriers
187        || !profile_ok
188    {
189        frame.validation = ValidationStatus::Rejected;
190        return Err(ValidationError::SubcarrierCount {
191            count: frame.subcarrier_count,
192            min: policy.min_subcarriers,
193            max: policy.max_subcarriers,
194            profile_ok,
195        });
196    }
197
198    for (name, v) in [
199        ("i_values", &frame.i_values),
200        ("q_values", &frame.q_values),
201        ("amplitude", &frame.amplitude),
202        ("phase", &frame.phase),
203    ] {
204        if let Some(idx) = v.iter().position(|x| !x.is_finite()) {
205            frame.validation = ValidationStatus::Rejected;
206            return Err(ValidationError::NonFinite {
207                vector: name,
208                index: idx,
209            });
210        }
211    }
212
213    if !profile.accepts_channel(frame.channel) {
214        frame.validation = ValidationStatus::Rejected;
215        return Err(ValidationError::UnsupportedChannel {
216            channel: frame.channel,
217        });
218    }
219
220    let (rssi_lo, rssi_hi) = policy.rssi_dbm_bounds;
221    if let Some(rssi) = frame.rssi_dbm {
222        if rssi < rssi_lo - RSSI_HARD_MARGIN || rssi > rssi_hi + RSSI_HARD_MARGIN {
223            frame.validation = ValidationStatus::Rejected;
224            return Err(ValidationError::ImplausibleRssi {
225                rssi,
226                min: rssi_lo,
227                max: rssi_hi,
228            });
229        }
230    }
231
232    let mut recovered_time = false;
233    if let Some(prev) = prev_timestamp_ns {
234        if frame.timestamp_ns <= prev {
235            if policy.strict_monotonic_time {
236                frame.validation = ValidationStatus::Rejected;
237                return Err(ValidationError::NonMonotonicTime {
238                    ts: frame.timestamp_ns,
239                    prev,
240                });
241            }
242            recovered_time = true;
243        }
244    }
245
246    // -- quality scoring (soft) ---------------------------------------------
247    let mut q = QualityScore::full();
248
249    if let Some(rssi) = frame.rssi_dbm {
250        if rssi < rssi_lo || rssi > rssi_hi {
251            q.penalize(0.6, format!("rssi {rssi} dBm outside [{rssi_lo},{rssi_hi}]"));
252        }
253    }
254
255    // dead subcarriers (amplitude ~ 0)
256    let dead = frame.amplitude.iter().filter(|a| **a < 1e-6).count();
257    if dead > 0 {
258        let frac = dead as f32 / sc.max(1) as f32;
259        q.penalize((1.0 - frac).max(0.05), format!("{dead}/{sc} dead subcarriers"));
260    }
261
262    // amplitude spikes (a single subcarrier >> the median magnitude)
263    if sc >= 3 {
264        let mut sorted: Vec<f32> = frame.amplitude.clone();
265        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
266        let median = sorted[sc / 2].max(1e-9);
267        let max = *sorted.last().unwrap();
268        if max > median * 50.0 {
269            q.penalize(0.7, format!("amplitude spike: max {max:.3} vs median {median:.3}"));
270        }
271    }
272
273    // implied-but-missing metadata
274    if frame.rssi_dbm.is_none() {
275        q.penalize(0.95, "missing rssi");
276    }
277
278    let status = if recovered_time {
279        ValidationStatus::Recovered
280    } else if q.value < policy.min_quality {
281        if policy.degrade_instead_of_reject {
282            ValidationStatus::Degraded
283        } else {
284            frame.validation = ValidationStatus::Rejected;
285            return Err(ValidationError::BelowMinQuality {
286                quality: q.value,
287                min: policy.min_quality,
288            });
289        }
290    } else if q.reasons.is_empty() {
291        ValidationStatus::Accepted
292    } else if policy.degrade_instead_of_reject {
293        // soft penalties but above the floor → still acceptable, just note them
294        ValidationStatus::Accepted
295    } else {
296        ValidationStatus::Accepted
297    };
298
299    frame.validation = status;
300    frame.quality_score = q.value;
301    frame.quality_reasons = q.reasons;
302    Ok(())
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::adapter::AdapterKind;
309    use crate::ids::{FrameId, SessionId, SourceId};
310
311    fn raw(sc: usize) -> CsiFrame {
312        CsiFrame::from_iq(
313            FrameId(0),
314            SessionId(0),
315            SourceId::from("t"),
316            AdapterKind::File,
317            1_000,
318            6,
319            20,
320            vec![1.0; sc],
321            vec![1.0; sc],
322        )
323    }
324
325    #[test]
326    fn clean_frame_is_accepted_with_perfect_quality() {
327        let mut f = raw(56).with_rssi(-55);
328        validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
329        assert_eq!(f.validation, ValidationStatus::Accepted);
330        assert_eq!(f.quality_score, 1.0);
331        assert!(f.quality_reasons.is_empty());
332        assert!(f.is_exposable());
333    }
334
335    #[test]
336    fn missing_rssi_is_a_minor_penalty_not_a_reject() {
337        let mut f = raw(56);
338        validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
339        assert_eq!(f.validation, ValidationStatus::Accepted);
340        assert!(f.quality_score < 1.0);
341        assert!(f.quality_reasons.iter().any(|r| r.contains("rssi")));
342    }
343
344    #[test]
345    fn length_mismatch_is_rejected() {
346        let mut f = raw(56);
347        f.q_values.pop();
348        let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
349        assert!(matches!(err, ValidationError::LengthMismatch { .. }));
350        assert_eq!(f.validation, ValidationStatus::Rejected);
351        assert!(!f.is_exposable());
352    }
353
354    #[test]
355    fn non_finite_is_rejected() {
356        let mut f = raw(4);
357        f.amplitude[2] = f32::NAN;
358        let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
359        assert!(matches!(err, ValidationError::NonFinite { vector: "amplitude", index: 2 }));
360    }
361
362    #[test]
363    fn subcarrier_count_must_match_profile() {
364        let mut f = raw(57); // ESP32 expects 64/128/192
365        let err = validate_frame(&mut f, &AdapterProfile::esp32_default(), &ValidationPolicy::default(), None).unwrap_err();
366        assert!(matches!(err, ValidationError::SubcarrierCount { count: 57, .. }));
367    }
368
369    #[test]
370    fn non_monotonic_time_is_recovered_when_lenient_rejected_when_strict() {
371        let mut f = raw(56).with_rssi(-50);
372        // lenient
373        validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), Some(2_000)).unwrap();
374        assert_eq!(f.validation, ValidationStatus::Recovered);
375        // strict
376        let mut g = raw(56).with_rssi(-50);
377        let policy = ValidationPolicy { strict_monotonic_time: true, ..Default::default() };
378        let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, Some(2_000)).unwrap_err();
379        assert!(matches!(err, ValidationError::NonMonotonicTime { .. }));
380    }
381
382    #[test]
383    fn dead_subcarriers_degrade_quality() {
384        let mut f = raw(10).with_rssi(-50);
385        for a in f.amplitude.iter_mut().take(8) {
386            *a = 0.0;
387        }
388        validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
389        assert!(f.quality_score < 0.5);
390        assert!(f.quality_reasons.iter().any(|r| r.contains("dead subcarriers")));
391    }
392
393    #[test]
394    fn very_low_quality_can_be_degraded_or_rejected() {
395        // 9/10 dead → quality ~0.1 < min_quality 0.25
396        let mk = || {
397            let mut f = raw(10).with_rssi(-50);
398            for a in f.amplitude.iter_mut().take(9) {
399                *a = 0.0;
400            }
401            f
402        };
403        let mut f = mk();
404        validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap();
405        assert_eq!(f.validation, ValidationStatus::Degraded);
406
407        let mut g = mk();
408        let policy = ValidationPolicy { degrade_instead_of_reject: false, ..Default::default() };
409        let err = validate_frame(&mut g, &AdapterProfile::offline(AdapterKind::File), &policy, None).unwrap_err();
410        assert!(matches!(err, ValidationError::BelowMinQuality { .. }));
411        assert_eq!(g.validation, ValidationStatus::Rejected);
412    }
413
414    #[test]
415    fn implausible_rssi_is_hard_reject() {
416        let mut f = raw(56).with_rssi(50); // way above 0 + margin
417        let err = validate_frame(&mut f, &AdapterProfile::offline(AdapterKind::File), &ValidationPolicy::default(), None).unwrap_err();
418        assert!(matches!(err, ValidationError::ImplausibleRssi { .. }));
419    }
420}