struktura 1.8.7

Time-series anomaly detection with no training data: detrended fluctuation analysis (DFA, Hurst exponent), a self-calibrating streaming monitor for sensors and telemetry, and C99 code generation for embedded and flight software. no_std.
Documentation
//! Human-readable investigation report from incident records.
//! Designed to be printed to a terminal or written to a Markdown file.
#![cfg(feature = "std")]

use crate::incident::Incident;
use crate::replay::ReplayDiff;

/// Render a list of incidents as a readable investigation report: one
/// section per incident (evidence, context, unresolved items), followed by
/// a summary line and, if anything is unresolved, a "Next checks" section.
pub fn investigation_report(
    incidents: &[Incident],
    channel_names: &[&str],
    recording_samples: usize,
) -> String {
    let mut out = String::new();

    for inc in incidents {
        out.push_str(&format!(
            "Incident #{} (ticks {}\u{2013}{}, duration {} samples):\n",
            inc.id,
            inc.start_tick,
            inc.end_tick,
            inc.duration()
        ));

        for e in &inc.evidence {
            let name = channel_names.get(e.channel).copied().unwrap_or("channel?");
            let ratio = if e.threshold != 0.0 {
                e.observed / e.threshold
            } else {
                f64::INFINITY
            };
            out.push_str(&format!(
                "  - {}: {:?} fired at tick {} (observed/threshold = {:.2}), {}\n",
                name, e.leg, e.tick, ratio, e.explanation
            ));
        }

        if !inc.context.is_empty() {
            out.push_str("  context:\n");
            for c in &inc.context {
                out.push_str(&format!("    tick {}: {}={}\n", c.tick, c.kind, c.value));
            }
        }

        if !inc.unresolved.is_empty() {
            out.push_str("  unresolved:\n");
            for u in &inc.unresolved {
                out.push_str(&format!("    - {}\n", u));
            }
        }

        if let Some(r) = &inc.resolution {
            out.push_str(&format!("  resolution: {}\n", r));
        }

        out.push('\n');
    }

    let mut channels_seen: Vec<usize> = Vec::new();
    for inc in incidents {
        for &c in &inc.channels_involved {
            if !channels_seen.contains(&c) {
                channels_seen.push(c);
            }
        }
    }
    let unresolved_total: usize = incidents.iter().map(|i| i.unresolved.len()).sum();

    out.push_str(&format!(
        "{} incidents, {} channels involved, {} items unresolved, {} samples analyzed\n",
        incidents.len(),
        channels_seen.len(),
        unresolved_total,
        recording_samples
    ));

    if unresolved_total > 0 {
        out.push_str("\nNext checks:\n");
        for inc in incidents {
            for u in &inc.unresolved {
                out.push_str(&format!("  - incident #{}: {}\n", inc.id, u));
            }
        }
    }

    out
}

/// Render a [`ReplayDiff`] as a readable comparison report: a summary line,
/// then details for every missed and new incident, then per-incident
/// timing deltas.
pub fn replay_report(
    diff: &ReplayDiff,
    old_incidents: &[Incident],
    new_incidents: &[Incident],
) -> String {
    let mut out = format!(
        "Replay comparison: {} matched, {} missed, {} new\n",
        diff.matched.len(),
        diff.missed.len(),
        diff.new_alarms.len()
    );

    if !diff.missed.is_empty() {
        out.push_str("\nMissed (saved incidents not reproduced):\n");
        for id in &diff.missed {
            if let Some(inc) = old_incidents.iter().find(|i| i.id == *id) {
                out.push_str(&incident_line(inc));
            }
        }
    }

    if !diff.new_alarms.is_empty() {
        out.push_str("\nNew (alarms not in the saved case):\n");
        for id in &diff.new_alarms {
            if let Some(inc) = new_incidents.iter().find(|i| i.id == *id) {
                out.push_str(&incident_line(inc));
            }
        }
    }

    if !diff.timing_deltas.is_empty() {
        out.push_str("\nTiming deltas:\n");
        for (id, delta) in &diff.timing_deltas {
            let sign = if *delta >= 0 { "+" } else { "" };
            out.push_str(&format!("  - incident #{}: {}{} ticks\n", id, sign, delta));
        }
    }

    let changed: Vec<_> = diff
        .evidence_changes
        .iter()
        .filter(|c| {
            c.added_evidence > 0
                || c.removed_evidence > 0
                || c.value_changes > 0
                || !c.channel_diff.is_empty()
        })
        .collect();
    if !changed.is_empty() {
        out.push_str("\nEvidence changes (matched incidents):\n");
        for c in changed {
            out.push_str(&format!(
                "  - incident #{}: +{} evidence, -{} evidence, new channels {:?}, {} value changes\n",
                c.incident_id, c.added_evidence, c.removed_evidence, c.channel_diff, c.value_changes
            ));
        }
    }

    if let Some((saved, current)) = &diff.fingerprint_mismatch {
        out.push_str(&format!(
            "\nWarning: recording.csv has changed since this case was saved (saved: {}, current: {})\n",
            saved, current
        ));
    }

    if let Some((res, dfa, cusum)) = diff.saved_thresholds {
        let (fres, fdfa, fcusum) = diff.fresh_thresholds;
        if (res - fres).abs() > 1e-9 || (dfa - fdfa).abs() > 1e-9 || (cusum - fcusum).abs() > 1e-9 {
            out.push_str(&format!(
                "\nSaved configuration (config.json): res_thr={:.4} dfa_thr={:.4} cusum_thr={:.4}\n\
                 Fresh configuration (this replay):  res_thr={:.4} dfa_thr={:.4} cusum_thr={:.4}\n",
                res, dfa, cusum, fres, fdfa, fcusum
            ));
        }
    }

    if !diff.threshold_diffs.is_empty() {
        out.push_str("\nSaved-configuration field differences (config.json vs this replay):\n");
        for d in &diff.threshold_diffs {
            out.push_str(&format!("  - {}\n", d));
        }
    }

    out
}

fn incident_line(inc: &Incident) -> String {
    format!(
        "  - incident #{} (ticks {}\u{2013}{}, channels {:?})\n",
        inc.id, inc.start_tick, inc.end_tick, inc.channels_involved
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::ContextEvent;
    use crate::incident::Evidence;
    use crate::monitor::Leg;

    fn incident_with_unresolved(id: u64) -> Incident {
        Incident {
            id,
            start_tick: 100,
            end_tick: 105,
            evidence: vec![Evidence {
                channel: 0,
                leg: Leg::LevelShift,
                tick: 100,
                observed: 6.0,
                threshold: 3.0,
                explanation: "level shift detected".to_string(),
            }],
            context: vec![ContextEvent::new(99, "mode", "cruise")],
            channels_involved: vec![0],
            resolution: None,
            unresolved: vec!["adaptation rolled back at tick 104".to_string()],
        }
    }

    #[test]
    fn investigation_report_includes_evidence_and_next_checks() {
        let incidents = vec![incident_with_unresolved(0)];
        let names = ["motor_current_A"];
        let report = investigation_report(&incidents, &names, 1000);

        assert!(report.contains("Incident #0"));
        assert!(report.contains("motor_current_A: LevelShift fired at tick 100"));
        assert!(report.contains("observed/threshold = 2.00"));
        assert!(report.contains("mode=cruise"));
        assert!(report.contains("1 incidents, 1 channels involved, 1 items unresolved"));
        assert!(report.contains("Next checks:"));
        assert!(report.contains("incident #0: adaptation rolled back at tick 104"));
    }

    #[test]
    fn investigation_report_skips_next_checks_when_nothing_unresolved() {
        let mut inc = incident_with_unresolved(0);
        inc.unresolved.clear();
        let report = investigation_report(&[inc], &[], 100);
        assert!(!report.contains("Next checks"));
    }

    #[test]
    fn replay_report_lists_missed_and_new() {
        let old = vec![incident_with_unresolved(0)];
        let mut new_inc = incident_with_unresolved(0);
        new_inc.id = 1;
        let new = vec![new_inc];
        let diff = ReplayDiff {
            matched: vec![],
            missed: vec![0],
            new_alarms: vec![1],
            timing_deltas: vec![],
            evidence_changes: vec![],
            ..Default::default()
        };
        let report = replay_report(&diff, &old, &new);
        assert!(report.starts_with("Replay comparison: 0 matched, 1 missed, 1 new"));
        assert!(report.contains("Missed"));
        assert!(report.contains("incident #0"));
        assert!(report.contains("New"));
        assert!(report.contains("incident #1"));
    }

    #[test]
    fn replay_report_shows_evidence_changes_for_matched_incidents() {
        use crate::replay::EvidenceChange;
        let old = vec![incident_with_unresolved(0)];
        let new = vec![incident_with_unresolved(0)];
        let diff = ReplayDiff {
            matched: vec![(0, 0)],
            missed: vec![],
            new_alarms: vec![],
            timing_deltas: vec![(0, 0)],
            evidence_changes: vec![EvidenceChange {
                incident_id: 0,
                added_evidence: 2,
                removed_evidence: 1,
                channel_diff: vec![3],
                ..Default::default()
            }],
            ..Default::default()
        };
        let report = replay_report(&diff, &old, &new);
        assert!(report.contains("Evidence changes"));
        assert!(report.contains("incident #0: +2 evidence, -1 evidence, new channels [3]"));
    }

    #[test]
    fn replay_report_omits_evidence_changes_section_when_nothing_changed() {
        let old = vec![incident_with_unresolved(0)];
        let new = vec![incident_with_unresolved(0)];
        let diff = ReplayDiff {
            matched: vec![(0, 0)],
            missed: vec![],
            new_alarms: vec![],
            timing_deltas: vec![(0, 0)],
            evidence_changes: vec![crate::replay::EvidenceChange {
                incident_id: 0,
                added_evidence: 0,
                removed_evidence: 0,
                channel_diff: vec![],
                ..Default::default()
            }],
            ..Default::default()
        };
        let report = replay_report(&diff, &old, &new);
        assert!(!report.contains("Evidence changes"));
    }

    /// Finding 5: when a case's saved config.json thresholds differ from
    /// this replay's fresh recalibration, the report must show both, side
    /// by side, so an engineer can see the drift.
    #[test]
    fn replay_report_shows_saved_vs_fresh_thresholds_when_they_differ() {
        let diff = ReplayDiff {
            saved_thresholds: Some((3.0, 3.0, 6.0)),
            fresh_thresholds: (3.5, 3.0, 6.0),
            ..Default::default()
        };
        let report = replay_report(&diff, &[], &[]);
        assert!(report.contains("Saved configuration"));
        assert!(report.contains("Fresh configuration"));
        assert!(report.contains("res_thr=3.0000"));
        assert!(report.contains("res_thr=3.5000"));
    }

    #[test]
    fn replay_report_omits_threshold_section_when_unchanged() {
        let diff = ReplayDiff {
            saved_thresholds: Some((3.0, 3.0, 6.0)),
            fresh_thresholds: (3.0, 3.0, 6.0),
            ..Default::default()
        };
        let report = replay_report(&diff, &[], &[]);
        assert!(!report.contains("Saved configuration"));
    }

    #[test]
    fn replay_report_shows_fingerprint_mismatch_warning() {
        let diff = ReplayDiff {
            fingerprint_mismatch: Some(("aaaa".to_string(), "bbbb".to_string())),
            ..Default::default()
        };
        let report = replay_report(&diff, &[], &[]);
        assert!(report.contains("recording.csv has changed"));
        assert!(report.contains("aaaa"));
        assert!(report.contains("bbbb"));
    }
}