#![cfg(feature = "std")]
use crate::incident::Incident;
use crate::replay::ReplayDiff;
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
}
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"));
}
#[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"));
}
}