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}