Skip to main content

dsfb_gray/
report.rs

1//! # DSFB Report Generator
2//!
3//! Produces human-readable, deterministic, audit-traced gray failure
4//! analysis reports from scenario results.
5
6use crate::{GrammarState, ScenarioResult, CONTRACT_VERSION, CRATE_VERSION};
7
8/// Generate a plain-text report from scenario results.
9pub fn generate_report(result: &ScenarioResult) -> String {
10    let mut report = String::with_capacity(4096);
11    push_report_header(&mut report, result);
12    push_detection_summary(&mut report, result);
13    let grammar_counts = grammar_state_counts(result);
14    push_grammar_distribution(&mut report, result, grammar_counts);
15    push_detection_point_state(&mut report, result);
16    push_residual_trajectory(&mut report, result);
17    push_non_interference_contract(&mut report);
18    push_report_metadata(&mut report);
19    report
20}
21
22fn push_report_header(report: &mut String, result: &ScenarioResult) {
23    report.push_str("╔══════════════════════════════════════════════════════════════╗\n");
24    report.push_str("║         DSFB Gray Failure Detection Report                  ║\n");
25    report.push_str("║         Deterministic Structural Semiotics Engine           ║\n");
26    report.push_str("╚══════════════════════════════════════════════════════════════╝\n\n");
27    report.push_str(&format!("Scenario: {}\n", result.scenario_name));
28    report.push_str(&format!("Total Steps: {}\n", result.total_steps));
29    report.push_str(&format!(
30        "Injection Start: step {}\n",
31        result.injection_start
32    ));
33    report.push_str(&format!(
34        "Expected Reason Code: {:?}\n",
35        result.expected_reason_code
36    ));
37    match result.detected_reason_code {
38        Some(reason) => report.push_str(&format!("Detected Reason Code: {:?}\n\n", reason)),
39        None => report.push_str("Detected Reason Code: None\n\n"),
40    }
41}
42
43fn push_detection_summary(report: &mut String, result: &ScenarioResult) {
44    report.push_str("── Detection Summary ──────────────────────────────────────────\n\n");
45    if result.detected() {
46        push_detected_summary(report, result);
47    } else {
48        push_undetected_summary(report);
49    }
50    report.push_str(&format!(
51        "\n  Total Boundary Steps: {}\n",
52        result.total_boundary_steps
53    ));
54    report.push_str(&format!(
55        "  Total Violation Steps: {}\n",
56        result.total_violation_steps
57    ));
58}
59
60fn push_detected_summary(report: &mut String, result: &ScenarioResult) {
61    report.push_str("  ✓ FAULT DETECTED\n");
62    if let Some(step) = result.first_anomaly_step {
63        report.push_str(&format!("  First Anomaly:   step {}\n", step));
64    }
65    if let Some(step) = result.first_boundary_step {
66        report.push_str(&format!("  First Boundary:  step {}\n", step));
67    }
68    if let Some(step) = result.first_violation_step {
69        report.push_str(&format!("  First Violation: step {}\n", step));
70    }
71    match result.detection_delay_from_injection() {
72        Some(delay) => {
73            report.push_str(&format!(
74                "  Detection Delay: {} steps after injection\n",
75                delay
76            ));
77        }
78        None => report.push_str("  Detection Delay: pre-injection false alarm\n"),
79    }
80    let detection_lead = result
81        .detection_lead_time()
82        .map_or_else(|| "unknown".to_string(), |value| value.to_string());
83    report.push_str(&format!(
84        "  Detection Lead:  {detection_lead} steps before scenario end\n"
85    ));
86    report.push_str(&format!(
87        "  False Alarms:    {} (before injection)\n",
88        result.false_alarms_before_injection
89    ));
90}
91
92fn push_undetected_summary(report: &mut String) {
93    report.push_str("  ✗ FAULT NOT DETECTED\n");
94    report.push_str("  The DSFB observer did not transition to Boundary or Violation.\n");
95    report.push_str("  Consider: envelope too wide, persistence window too large,\n");
96    report.push_str("  or drift rate too low for the configured sensitivity.\n");
97}
98
99fn grammar_state_counts(result: &ScenarioResult) -> (usize, usize, usize) {
100    let admissible = result
101        .samples
102        .iter()
103        .filter(|sample| sample.grammar_state == GrammarState::Admissible)
104        .count();
105    let boundary = result
106        .samples
107        .iter()
108        .filter(|sample| sample.grammar_state == GrammarState::Boundary)
109        .count();
110    let violation = result
111        .samples
112        .iter()
113        .filter(|sample| sample.grammar_state == GrammarState::Violation)
114        .count();
115    (admissible, boundary, violation)
116}
117
118fn push_grammar_distribution(
119    report: &mut String,
120    result: &ScenarioResult,
121    (admissible, boundary, violation): (usize, usize, usize),
122) {
123    report.push_str("\n── Grammar State Distribution ─────────────────────────────────\n\n");
124    report.push_str(&format!(
125        "  Admissible: {} ({:.1}%)\n",
126        admissible,
127        admissible as f64 / result.samples.len() as f64 * 100.0
128    ));
129    report.push_str(&format!(
130        "  Boundary:   {} ({:.1}%)\n",
131        boundary,
132        boundary as f64 / result.samples.len() as f64 * 100.0
133    ));
134    report.push_str(&format!(
135        "  Violation:  {} ({:.1}%)\n",
136        violation,
137        violation as f64 / result.samples.len() as f64 * 100.0
138    ));
139}
140
141fn push_detection_point_state(report: &mut String, result: &ScenarioResult) {
142    let Some(det_step) = result.first_anomaly_step else {
143        return;
144    };
145    let Some(sample) = result.samples.iter().find(|sample| sample.step == det_step) else {
146        return;
147    };
148    report.push_str("\n── State at Detection Point ───────────────────────────────────\n\n");
149    report.push_str(&format!("  Residual: {:.4}\n", sample.residual));
150    report.push_str(&format!("  Drift:    {:.6}\n", sample.drift));
151    report.push_str(&format!("  Slew:     {:.6}\n", sample.slew));
152    report.push_str(&format!(
153        "  Value:    {:.4} (baseline: {:.4})\n",
154        sample.value, sample.baseline
155    ));
156}
157
158fn push_residual_trajectory(report: &mut String, result: &ScenarioResult) {
159    report.push_str("\n── Residual Trajectory (ASCII) ────────────────────────────────\n\n");
160    let step_size = (result.samples.len() / 60).max(1);
161    let max_r = result
162        .samples
163        .iter()
164        .map(|sample| sample.residual.abs())
165        .fold(0.0f64, f64::max)
166        .max(0.001);
167
168    for chunk in result.samples.chunks(step_size).take(60) {
169        report.push_str(&render_residual_chunk(chunk, max_r));
170    }
171}
172
173fn render_residual_chunk(chunk: &[crate::SampleRecord], max_r: f64) -> String {
174    let avg_r: f64 = chunk.iter().map(|sample| sample.residual).sum::<f64>() / chunk.len() as f64;
175    let bar_len = ((avg_r.abs() / max_r) * 40.0) as usize;
176    let state_char = match chunk.last().map(|sample| sample.grammar_state) {
177        Some(GrammarState::Admissible) => '·',
178        Some(GrammarState::Boundary) => '▸',
179        Some(GrammarState::Violation) => '█',
180        None => ' ',
181    };
182    let bar: String = std::iter::repeat_n(state_char, bar_len).collect();
183    format!("  {:>4} │{}\n", chunk[0].step, bar)
184}
185
186fn push_non_interference_contract(report: &mut String) {
187    report.push_str("\n── Non-Interference Contract ──────────────────────────────────\n\n");
188    report.push_str("  Contract Version: 1.0\n");
189    report.push_str("  All inputs accepted as immutable references (&ResidualSample).\n");
190    report.push_str("  No mutable reference to upstream system created.\n");
191    report.push_str("  Observer removal produces zero behavioral change.\n");
192}
193
194fn push_report_metadata(report: &mut String) {
195    report.push_str("\n── Report Metadata ────────────────────────────────────────────\n\n");
196    report.push_str(&format!("  DSFB Version:          {}\n", CRATE_VERSION));
197    report.push_str(&format!("  Contract Version:      {}\n", CONTRACT_VERSION));
198    report.push_str("  Invariant Forge LLC — riaan@invariantforge.net\n");
199}
200
201/// Generate CSV output from scenario results.
202pub fn generate_csv(result: &ScenarioResult) -> String {
203    let mut csv = String::with_capacity(result.samples.len() * 80);
204    csv.push_str("step,value,baseline,residual,drift,slew,grammar_state\n");
205    for s in &result.samples {
206        let state_str = match s.grammar_state {
207            GrammarState::Admissible => "Admissible",
208            GrammarState::Boundary => "Boundary",
209            GrammarState::Violation => "Violation",
210        };
211        csv.push_str(&format!(
212            "{},{:.6},{:.6},{:.6},{:.8},{:.8},{}\n",
213            s.step, s.value, s.baseline, s.residual, s.drift, s.slew, state_str
214        ));
215    }
216    csv
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::{
223        run_scenario, AdmissibilityEnvelope, ClockDriftScenario, ObserverConfig, WorkloadPhase,
224    };
225
226    #[test]
227    fn test_report_generation() {
228        let mut scenario = ClockDriftScenario::default_scenario();
229        let config = ObserverConfig {
230            persistence_window: 20,
231            hysteresis_count: 3,
232            default_envelope: AdmissibilityEnvelope::symmetric(
233                2.0,
234                0.1,
235                0.05,
236                WorkloadPhase::SteadyState,
237            ),
238            ..ObserverConfig::fast_response()
239        };
240        let result = run_scenario(&mut scenario, &config);
241        let report = generate_report(&result);
242        assert!(report.contains("DSFB Gray Failure Detection Report"));
243        assert!(report.contains("FAULT DETECTED"));
244    }
245
246    #[test]
247    fn test_csv_generation() {
248        let mut scenario = ClockDriftScenario::default_scenario();
249        let config = ObserverConfig {
250            persistence_window: 20,
251            hysteresis_count: 3,
252            default_envelope: AdmissibilityEnvelope::symmetric(
253                2.0,
254                0.1,
255                0.05,
256                WorkloadPhase::SteadyState,
257            ),
258            ..ObserverConfig::fast_response()
259        };
260        let result = run_scenario(&mut scenario, &config);
261        let csv = generate_csv(&result);
262        assert!(csv.starts_with("step,value,baseline,residual,drift,slew,grammar_state\n"));
263        let lines: Vec<&str> = csv.lines().collect();
264        assert_eq!(lines.len(), 201); // header + 200 data rows
265    }
266}