Skip to main content

dsfb_debug/
residual.rs

1//! DSFB-Debug: residual computation — paper §5.2 Equation (1).
2//!
3//! Computes the residual vector `r(k) = x(k) - x_hat(k)` where
4//! `x(k)` is the observed measurement and `x_hat(k)` is the
5//! healthy-window baseline reference (computed in `baseline.rs`).
6//!
7//! # Non-intrusion contract (type-enforced)
8//!
9//! Every function in this module accepts `&[f64]` slices only —
10//! immutable references with no `mut` qualifier. The Rust type
11//! system enforces at compile time that we cannot modify the
12//! upstream observation `x(k)` or the baseline reference `x_hat(k)`.
13//! This is the load-bearing observer-only guarantee of the engine
14//! (paper §1.x non-intrusion contract).
15//!
16//! # Numerical handling
17//!
18//! NaN inputs propagate to NaN residuals (no silent imputation at
19//! this layer); the `sign.rs` consumer detects NaN and stamps
20//! `was_imputed = true` on the resulting `SignalEvaluation`. This
21//! preserves the audit trail: the operator sees missing-data windows
22//! flagged, not silently zeroed.
23
24use crate::error::{DsfbError, Result};
25
26/// Compute the residual vector r(k) = x(k) - x_hat(k)
27///
28/// # Non-Intrusion Contract
29/// Both `observation` and `baseline` are shared immutable references.
30/// The result is written into the caller-owned `output` buffer.
31///
32/// # Missingness
33/// If `missing_mask[i]` is true, `output[i]` is set to 0.0 (mean imputation).
34/// The caller must track which signals were imputed for downstream processing.
35#[inline]
36pub fn compute_residuals(
37    observation: &[f64],
38    baseline: &[f64],
39    missing_mask: &[bool],
40    output: &mut [f64],
41) -> Result<()> {
42    let n = observation.len();
43    if baseline.len() != n || missing_mask.len() != n || output.len() != n {
44        return Err(DsfbError::DimensionMismatch {
45            expected: n,
46            got: baseline.len(),
47        });
48    }
49
50    let mut i = 0;
51    while i < n {
52        if missing_mask[i] {
53            // Missingness-aware: imputed signals contribute zero residual
54            output[i] = 0.0;
55        } else {
56            output[i] = observation[i] - baseline[i];
57        }
58        i += 1;
59    }
60    Ok(())
61}
62
63/// Compute the norm (absolute value for scalar, Euclidean for vector)
64/// of a single residual value.
65#[inline]
66pub fn residual_norm(r: f64) -> f64 {
67    if r < 0.0 { -r } else { r }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn test_residual_basic() {
76        let obs = [1.0, 2.0, 3.0];
77        let base = [0.5, 1.5, 3.0];
78        let mask = [false, false, false];
79        let mut out = [0.0; 3];
80        compute_residuals(&obs, &base, &mask, &mut out).expect("should succeed");
81        assert!((out[0] - 0.5).abs() < 1e-12);
82        assert!((out[1] - 0.5).abs() < 1e-12);
83        assert!((out[2] - 0.0).abs() < 1e-12);
84    }
85
86    #[test]
87    fn test_residual_missing() {
88        let obs = [999.0, 2.0];
89        let base = [0.0, 1.0];
90        let mask = [true, false];
91        let mut out = [0.0; 2];
92        compute_residuals(&obs, &base, &mask, &mut out).expect("should succeed");
93        assert!((out[0] - 0.0).abs() < 1e-12); // imputed → 0
94        assert!((out[1] - 1.0).abs() < 1e-12);
95    }
96
97    #[test]
98    fn test_dimension_mismatch() {
99        let obs = [1.0, 2.0];
100        let base = [1.0];
101        let mask = [false, false];
102        let mut out = [0.0; 2];
103        assert!(compute_residuals(&obs, &base, &mask, &mut out).is_err());
104    }
105}