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}