Skip to main content

dsfb_rf/
audit.rs

1//! Continuous Rigor audit report for the 4-stage verification pipeline.
2//!
3//! ## Purpose
4//!
5//! Each example in this crate runs the same 4-stage Continuous Rigor pipeline:
6//!
7//! ```text
8//! Stage I   — Physics-Only Baseline       : synthetic, zero impairment, verifies math
9//! Stage II  — Impairment Injection        : same signal + hardware/channel impairments
10//! Stage III — SigMF-Annotated Playback    : structurally representative public dataset
11//! Stage IV  — Audit Report                : predicted vs. ground-truth comparison
12//! ```
13//!
14//! This module provides the data types that consolidate Stage IV output:
15//! per-stage detection statistics, latency accounting, and one-line SBIR
16//! pitch metrics (false-alarm rate, lead-time advantage, 10⁻⁵ FA threshold).
17//!
18//! ## Design
19//!
20//! - `no_std`, `no_alloc`, zero `unsafe`
21//! - All fields are value types: no pointers, no heap references
22//! - Stage results carry `label: &'static str` for provenance traceability
23//! - `AuditReport` is `Copy` so it can be placed on the stack and returned
24//!   through FFI boundaries without heap allocation
25
26// ── Per-Stage Detection Statistics ────────────────────────────────────────
27
28/// Detection statistics for one stage of the Continuous Rigor pipeline.
29///
30/// Populated by the per-stage loops in each example.
31#[derive(Debug, Clone, Copy)]
32pub struct StageResult {
33    /// Human-readable stage label (e.g. `"Stage I: Physics Baseline"`).
34    pub label: &'static str,
35    /// Total observations processed in this stage (including calibration).
36    pub n_obs: u32,
37    /// Observations in the "calm" pre-event segment (for FA counting).
38    pub n_calm_obs: u32,
39    /// Policy::Review or Policy::Escalate events in the calm segment
40    /// (false alarms).
41    pub n_false_alarms: u32,
42    /// Policy::Review or Policy::Escalate events in the event / post-onset
43    /// segment (detections).
44    pub n_detections: u32,
45    /// Sample index k of the first detection (None if no detection).
46    pub first_detection_k: Option<u32>,
47    /// Lyapunov exponent λ at the first detection (None if no detection).
48    pub lambda_at_detection: Option<f32>,
49    /// Maximum |λ| observed in the event segment.
50    pub lambda_event_peak: f32,
51    /// Ground-truth onset sample index (from SigMF annotation or scenario model).
52    pub ground_truth_onset_k: u32,
53}
54
55impl StageResult {
56    /// Construct a zeroed-out result with a label and ground-truth onset.
57    pub const fn new(label: &'static str, ground_truth_onset_k: u32) -> Self {
58        Self {
59            label,
60            n_obs: 0,
61            n_calm_obs: 0,
62            n_false_alarms: 0,
63            n_detections: 0,
64            first_detection_k: None,
65            lambda_at_detection: None,
66            lambda_event_peak: 0.0,
67            ground_truth_onset_k,
68        }
69    }
70
71    /// False-alarm rate: n_false_alarms / n_calm_obs.
72    ///
73    /// Returns 0.0 if no calm observations.
74    pub fn false_alarm_rate(&self) -> f32 {
75        if self.n_calm_obs == 0 { return 0.0; }
76        self.n_false_alarms as f32 / self.n_calm_obs as f32
77    }
78
79    /// Lead-time advantage in samples: ground_truth − first_detection.
80    ///
81    /// Positive → DSFB detected BEFORE the ground-truth reference time.
82    /// Negative → DSFB detected AFTER (degraded performance or late onset).
83    /// Returns `None` if no detection was made.
84    pub fn lead_time_samples(&self) -> Option<i32> {
85        self.first_detection_k.map(|k| {
86            self.ground_truth_onset_k as i32 - k as i32
87        })
88    }
89
90    /// Lead-time expressed as milliseconds, given the sample rate in Hz.
91    pub fn lead_time_ms(&self, sample_rate_hz: f32) -> Option<f32> {
92        self.lead_time_samples().map(|lt| {
93            lt as f32 / sample_rate_hz * 1000.0
94        })
95    }
96
97    /// Whether this stage meets the SBIR 10⁻⁵ false-alarm threshold.
98    ///
99    /// At a calm segment of N observations, FA rate < 10⁻⁵ requires < N/10⁵
100    /// false alarms.  This method uses the observed rate as a proxy.
101    /// Formal FA validation requires large-N Monte Carlo beyond what a
102    /// single pipeline run provides — see paper §L5 for caveat.
103    pub fn meets_1e5_fa_threshold(&self) -> bool {
104        self.false_alarm_rate() < 1e-5
105    }
106}
107
108// ── Ground-Truth Annotation (SigMF-inspired) ──────────────────────────────
109
110/// A single ground-truth event annotation, modelled after SigMF core/annotation.
111///
112/// In a production pipeline this would parse a `.sigmf-meta` JSON file.
113/// Here it is hand-specified from the dataset documentation.
114#[derive(Debug, Clone, Copy)]
115pub struct SigMfAnnotation {
116    /// Descriptive label (e.g. `"spoofer_onset"`, `"regime_transition"`).
117    pub label: &'static str,
118    /// Sample index at which the event begins (relative to file start).
119    pub onset_sample: u32,
120    /// Sample index at which the event ends (0 = unknown).
121    pub end_sample: u32,
122    /// Annotation confidence from dataset provider (1.0 = high).
123    pub confidence: f32,
124}
125
126impl SigMfAnnotation {
127    /// Construct a precise, high-confidence annotation.
128    pub const fn precise(label: &'static str, onset: u32, end: u32) -> Self {
129        Self {
130            label,
131            onset_sample: onset,
132            end_sample: end,
133            confidence: 1.0,
134        }
135    }
136}
137
138// ── 4-Stage Audit Report ──────────────────────────────────────────────────
139
140/// Consolidated 4-stage Continuous Rigor audit report for one example run.
141///
142/// Produced at the end of every benchmark example.  Contains Stage I–III
143/// statistics plus Stage IV comparison metrics.
144///
145/// ## SBIR Pitch Keys
146///
147/// The fields most relevant to SBIR Phase II reviewers are:
148///
149/// - `stage_i.false_alarm_rate()` — should be 0.0 in clean synthetic
150/// - `stage_ii.false_alarm_rate()` — should be < 10⁻³ under full impairment
151/// - `stage_iii.lead_time_samples()` — positive = DSFB detects before nav/link failure
152/// - `observer_contract_holds` — true if no upstream mutations occurred
153/// - `unsafe_count` — always 0, enforced by `#![forbid(unsafe_code)]`
154#[derive(Debug, Clone, Copy)]
155pub struct AuditReport {
156    /// Example / dataset label.
157    pub dataset_label: &'static str,
158    /// Stage I: Physics-Only Baseline result.
159    pub stage_i: StageResult,
160    /// Stage II: Impairment-Injected result.
161    pub stage_ii: StageResult,
162    /// Stage III: SigMF Playback result.
163    pub stage_iii: StageResult,
164    /// Nominal sample rate of the dataset [Hz].
165    pub sample_rate_hz: f32,
166    /// Whether the observer contract (read-only, no upstream mutation) held.
167    /// Always `true` in this crate; recorded for provenance.
168    pub observer_contract_holds: bool,
169    /// Number of `unsafe` blocks in the crate: always 0.
170    pub unsafe_count: u32,
171    /// Non-claim statement: what this report does NOT prove.
172    pub non_claim: &'static str,
173}
174
175impl AuditReport {
176    /// Print a formatted audit report to the host console via `println!`.
177    ///
178    /// This method is `std`-only (gated at call-site in examples).
179    #[cfg(feature = "std")]
180    pub fn print(&self) {
181        extern crate std;
182        use std::println;
183
184        println!();
185        println!("┌─────────────────────────────────────────────────────");
186        println!("│  CONTINUOUS RIGOR AUDIT — {}", self.dataset_label);
187        let obs_status = if self.observer_contract_holds { "HOLDS" } else { "VIOLATED" };
188        println!("│  Sample rate: {:.0} Hz   Observer contract: {}   unsafe: {}",
189            self.sample_rate_hz, obs_status, self.unsafe_count);
190        println!("├─────────────────────────────────────────────────────");
191
192        for (idx, stage) in [&self.stage_i, &self.stage_ii, &self.stage_iii]
193            .iter().enumerate()
194        {
195            let stage_num = idx + 1;
196            let fa_rate = stage.false_alarm_rate();
197            let fa_flag = if fa_rate < 1e-5 { "✓ < 10⁻⁵" }
198                          else if fa_rate < 1e-3 { "⚠ < 10⁻³" }
199                          else { "✗ ≥ 10⁻³" };
200
201            println!("│");
202            println!("│  Stage {}  {}", stage_num, stage.label);
203            println!("│    Observations  : {} ({} calm)",
204                stage.n_obs, stage.n_calm_obs);
205            println!("│    False alarms  : {}  FA rate: {:.2e}  [{}]",
206                stage.n_false_alarms, fa_rate, fa_flag);
207            match stage.first_detection_k {
208                Some(k) => println!("│    Detections    : {}  First at k={}",
209                    stage.n_detections, k),
210                None => println!("│    Detections    : {}  First at k=NONE",
211                    stage.n_detections),
212            }
213            match stage.lead_time_samples() {
214                Some(lt) => println!("│    Lead time     : {:+} samples ({:+.1} ms)",
215                    lt, lt as f32 / self.sample_rate_hz * 1000.0),
216                None => println!("│    Lead time     : N/A"),
217            }
218            if let Some(lam) = stage.lambda_at_detection {
219                println!("│    λ at detect   : {:+.4}", lam);
220            }
221            println!("│    λ_event_peak  : {:+.4}", stage.lambda_event_peak);
222            println!("│    GT onset      : k={}", stage.ground_truth_onset_k);
223        }
224
225        println!("├─────────────────────────────────────────────────────");
226        println!("│  NON-CLAIM: {}", self.non_claim);
227        println!("└─────────────────────────────────────────────────────");
228        println!();
229    }
230}
231
232// ── Tests ─────────────────────────────────────────────────────────────────
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn stage_result_fa_rate_zero_calm_obs() {
240        let r = StageResult::new("test", 100);
241        assert_eq!(r.false_alarm_rate(), 0.0);
242    }
243
244    #[test]
245    fn stage_result_lead_time_none_when_no_detection() {
246        let r = StageResult::new("test", 500);
247        assert_eq!(r.lead_time_samples(), None);
248    }
249
250    #[test]
251    fn stage_result_lead_time_positive_early_detection() {
252        let mut r = StageResult::new("test", 500);
253        r.first_detection_k = Some(480);
254        assert_eq!(r.lead_time_samples(), Some(20));
255    }
256
257    #[test]
258    fn stage_result_lead_time_negative_late_detection() {
259        let mut r = StageResult::new("test", 500);
260        r.first_detection_k = Some(520);
261        assert_eq!(r.lead_time_samples(), Some(-20));
262    }
263
264    #[test]
265    fn stage_result_fa_rate_threshold() {
266        let mut r = StageResult::new("test", 100);
267        r.n_calm_obs = 10_000;
268        r.n_false_alarms = 0;
269        assert!(r.meets_1e5_fa_threshold());
270        r.n_false_alarms = 1; // 1/10000 = 1e-4 > 1e-5
271        assert!(!r.meets_1e5_fa_threshold());
272    }
273
274    #[test]
275    fn sigmf_annotation_precise() {
276        let ann = SigMfAnnotation::precise("onset", 1000, 2000);
277        assert_eq!(ann.onset_sample, 1000);
278        assert!((ann.confidence - 1.0).abs() < 1e-6);
279    }
280}