#![cfg(feature = "std")]
use crate::autopilot::{AutoPilot, Event};
use crate::case::Case;
use crate::context::ContextTimeline;
use crate::incident::{Incident, IncidentBuilder};
use crate::monitor::{HybridMonitor, Leg, MonitorExport};
const TICK_TOLERANCE: i64 = 5;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct EvidenceChange {
pub incident_id: u64,
pub added_evidence: usize,
pub removed_evidence: usize,
pub value_changes: usize,
pub channel_diff: Vec<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct ReplayDiff {
pub matched: Vec<(u64, u64)>,
pub missed: Vec<u64>,
pub new_alarms: Vec<u64>,
pub timing_deltas: Vec<(u64, i64)>,
pub evidence_changes: Vec<EvidenceChange>,
pub fingerprint_mismatch: Option<(String, String)>,
pub saved_thresholds: Option<(f64, f64, f64)>,
pub fresh_thresholds: (f64, f64, f64),
pub threshold_diffs: Vec<String>,
pub config_valid: bool,
}
impl ReplayDiff {
pub fn summary(&self) -> String {
let max_delta = self
.timing_deltas
.iter()
.map(|(_, d)| *d)
.max_by_key(|d| d.abs());
let delta_text = match max_delta {
Some(d) if d >= 0 => format!("+{}", d),
Some(d) => format!("{}", d),
None => "0".to_string(),
};
let changed = self
.evidence_changes
.iter()
.filter(|c| c.added_evidence > 0 || c.removed_evidence > 0 || c.value_changes > 0)
.count();
let mut out = format!(
"{} matched, {} missed, {} new, {} evidence-changed, max timing delta {} ticks",
self.matched.len(),
self.missed.len(),
self.new_alarms.len(),
changed,
delta_text
);
if self.fingerprint_mismatch.is_some() {
out.push_str(", fingerprint mismatch");
}
if !self.threshold_diffs.is_empty() {
out.push_str(&format!(", {} config diff(s)", self.threshold_diffs.len()));
}
out
}
pub fn to_json(&self) -> String {
let mut out = String::from("{");
out.push_str("\"matched\":[");
for (i, (s, n)) in self.matched.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!("[{},{}]", s, n));
}
out.push_str("],\"missed\":[");
for (i, id) in self.missed.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!("{}", id));
}
out.push_str("],\"new_alarms\":[");
for (i, id) in self.new_alarms.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!("{}", id));
}
out.push_str("],\"timing_deltas\":[");
for (i, (id, d)) in self.timing_deltas.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!("[{},{}]", id, d));
}
out.push_str("],\"evidence_changes\":[");
for (i, c) in self.evidence_changes.iter().enumerate() {
if i > 0 {
out.push(',');
}
let channels = c
.channel_diff
.iter()
.map(|ch| ch.to_string())
.collect::<Vec<_>>()
.join(",");
out.push_str(&format!(
"{{\"incident_id\":{},\"added_evidence\":{},\"removed_evidence\":{},\"value_changes\":{},\"channel_diff\":[{}]}}",
c.incident_id, c.added_evidence, c.removed_evidence, c.value_changes, channels
));
}
out.push_str("],\"fingerprint_mismatch\":");
out.push_str(if self.fingerprint_mismatch.is_some() { "true" } else { "false" });
out.push_str(",\"threshold_diffs\":[");
for (i, d) in self.threshold_diffs.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!("\"{}\"", json_escape_diff(d)));
}
out.push_str("],\"config_valid\":");
out.push_str(if self.config_valid { "true" } else { "false" });
out.push('}');
out
}
}
fn json_escape_diff(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
c => out.push(c),
}
}
out
}
const IMPUTATION_REJECT_FRACTION: f64 = 0.2;
pub fn run_investigation(
cols: &[Vec<f64>],
baseline: usize,
timeline: &ContextTimeline,
) -> Result<(Vec<Incident>, MonitorExport, Vec<(usize, usize)>), String> {
let channels = cols.len();
if channels == 0 {
return Err("case recording has no channels".to_string());
}
let samples = cols[0].len();
if baseline >= samples {
return Err(format!(
"baseline_samples {} >= recording samples {}",
baseline, samples
));
}
let mut calib: Vec<Vec<f64>> = cols.iter().map(|c| c[..baseline].to_vec()).collect();
let mut imputation_counts: Vec<(usize, usize)> = Vec::with_capacity(calib.len());
for (ci, c) in calib.iter_mut().enumerate() {
let total = c.len();
let finite_count = c.iter().filter(|v| v.is_finite()).count();
if finite_count == 0 {
return Err(format!(
"channel {} has no finite values in the calibration window ({} samples)",
ci, total
));
}
let mut replaced = 0usize;
if finite_count < total {
let finite_sum: f64 = c.iter().filter(|v| v.is_finite()).sum();
let mean = finite_sum / finite_count as f64;
for v in c.iter_mut() {
if !v.is_finite() {
*v = mean;
replaced += 1;
}
}
let fraction = replaced as f64 / total as f64;
eprintln!(
"channel {}: {} of {} calibration values imputed with channel mean ({:.1}%)",
ci,
replaced,
total,
fraction * 100.0
);
if fraction > IMPUTATION_REJECT_FRACTION {
return Err(format!(
"channel {} has {} of {} calibration values ({:.1}%) imputed — \
exceeds the {:.0}% imputation rejection threshold",
ci,
replaced,
total,
fraction * 100.0,
IMPUTATION_REJECT_FRACTION * 100.0
));
}
}
imputation_counts.push((ci, replaced));
}
let monitor = HybridMonitor::calibrate(&calib)
.ok_or_else(|| "calibration failed on baseline window".to_string())?;
let export = monitor.export();
let mut autopilot = AutoPilot::new(monitor);
let mut builder = IncidentBuilder::default();
for t in baseline..samples {
let row: Vec<f64> = (0..channels).map(|c| cols[c][t]).collect();
let valid: Vec<bool> = (0..channels).map(|c| cols[c][t].is_finite()).collect();
for mut event in autopilot.push(&row, &valid) {
match &mut event {
Event::Alarm { tick, report, .. } => {
*tick = t as u64;
report.tick = t as u64;
}
Event::Quarantined { tick, .. }
| Event::AdaptationStarted { tick }
| Event::Recalibrated { tick } => *tick = t as u64,
Event::RolledBack { tick, guard_report } => {
*tick = t as u64;
guard_report.tick = t as u64;
}
}
if let Event::Alarm { report, .. } = &event {
builder.push_alarm(report);
}
builder.push_event(&event);
}
}
let mut incidents = builder.finalize();
crate::incident::attach_context(&mut incidents, timeline);
Ok((incidents, export, imputation_counts))
}
pub fn replay(
case: &Case,
baseline_override: Option<usize>,
) -> Result<(Vec<Incident>, ReplayDiff), String> {
let baseline = baseline_override.unwrap_or(case.manifest.baseline_samples);
let cols = case.recording()?;
let timeline = case.context()?;
let (new_incidents, export, _imputation_counts) = run_investigation(&cols, baseline, &timeline)?;
let saved_incidents = case.incidents()?;
let mut diff = diff_incidents(&saved_incidents, &new_incidents);
diff.fresh_thresholds = (export.res_thr, export.dfa_thr, export.cusum_thr);
let config_path = case.dir().join("config.json");
let has_schema_version = !case.manifest.schema_version.is_empty();
if config_path.exists() {
let config_json = case.config_json()?;
diff.saved_thresholds = crate::case::parse_config_monitor_thresholds(&config_json);
match crate::case::parse_config_monitor_export(&config_json) {
Some(saved_export) => {
diff.threshold_diffs = monitor_export_diffs(&saved_export, &export);
diff.config_valid = true;
}
None if has_schema_version => {
return Err(
"config.json exists but monitor_export is missing or corrupt".to_string()
);
}
None => {
eprintln!(
"warning: legacy case's config.json has no parseable monitor_export; skipping saved-configuration comparison"
);
}
}
} else {
eprintln!(
"warning: case has no config.json (legacy case format); skipping saved-configuration comparison"
);
}
if let Some(saved_hash) = &case.manifest.recording_hash {
let recording_path = case.dir().join("recording.csv");
if let Ok(bytes) = std::fs::read(&recording_path) {
let current_hash = crate::case::fingerprint_content(&bytes);
if ¤t_hash != saved_hash {
eprintln!(
"warning: recording.csv has changed since this case was saved (saved: {}, current: {})",
saved_hash, current_hash
);
diff.fingerprint_mismatch = Some((saved_hash.clone(), current_hash));
}
}
}
Ok((new_incidents, diff))
}
fn monitor_export_diffs(saved: &MonitorExport, fresh: &MonitorExport) -> Vec<String> {
let mut diffs = Vec::new();
for (field, s, f) in [
("res_thr", saved.res_thr, fresh.res_thr),
("dfa_thr", saved.dfa_thr, fresh.dfa_thr),
("cusum_thr", saved.cusum_thr, fresh.cusum_thr),
] {
if value_differs(s, f) {
diffs.push(format!("{}: saved={:.6} fresh={:.6}", field, s, f));
}
}
if saved.channels.len() != fresh.channels.len() {
diffs.push(format!(
"channel count: saved={} fresh={}",
saved.channels.len(),
fresh.channels.len()
));
return diffs;
}
for (ci, (s, f)) in saved.channels.iter().zip(fresh.channels.iter()).enumerate() {
for (field, sv, fv) in [
("ar_a", s.ar_a, f.ar_a),
("ar_b", s.ar_b, f.ar_b),
("ar_sd", s.ar_sd, f.ar_sd),
("alpha_mean", s.alpha_mean, f.alpha_mean),
("alpha_sd", s.alpha_sd, f.alpha_sd),
("mean", s.mean, f.mean),
("roll_thr", s.roll_thr, f.roll_thr),
] {
if value_differs(sv, fv) {
diffs.push(format!("channel {} {}: saved={:.6} fresh={:.6}", ci, field, sv, fv));
}
}
if s.max_run != f.max_run {
diffs.push(format!("channel {} max_run: saved={} fresh={}", ci, s.max_run, f.max_run));
}
if s.repeat_enabled != f.repeat_enabled {
diffs.push(format!(
"channel {} repeat_enabled: saved={} fresh={}",
ci, s.repeat_enabled, f.repeat_enabled
));
}
}
diffs
}
fn diff_incidents(saved: &[Incident], new: &[Incident]) -> ReplayDiff {
let mut matched = Vec::new();
let mut timing_deltas = Vec::new();
let mut evidence_changes = Vec::new();
let mut used_new = vec![false; new.len()];
for s in saved {
let mut best: Option<(usize, i64)> = None;
for (i, n) in new.iter().enumerate() {
if used_new[i] {
continue;
}
let delta = n.start_tick as i64 - s.start_tick as i64;
if delta.abs() > TICK_TOLERANCE {
continue;
}
let overlaps = s
.channels_involved
.iter()
.any(|c| n.channels_involved.contains(c));
if !overlaps {
continue;
}
let better = match best {
Some((_, bd)) => delta.abs() < bd.abs(),
None => true,
};
if better {
best = Some((i, delta));
}
}
if let Some((i, delta)) = best {
used_new[i] = true;
matched.push((s.id, new[i].id));
timing_deltas.push((s.id, delta));
evidence_changes.push(evidence_change(s.id, s, &new[i]));
}
}
let matched_saved: Vec<u64> = matched.iter().map(|(sid, _)| *sid).collect();
let missed = saved
.iter()
.map(|s| s.id)
.filter(|id| !matched_saved.contains(id))
.collect();
let new_alarms = new
.iter()
.enumerate()
.filter(|(i, _)| !used_new[*i])
.map(|(_, n)| n.id)
.collect();
ReplayDiff { matched, missed, new_alarms, timing_deltas, evidence_changes, ..Default::default() }
}
fn evidence_change(saved_id: u64, old: &Incident, new: &Incident) -> EvidenceChange {
let old_keys: Vec<(usize, Leg, u64)> =
old.evidence.iter().map(|e| (e.channel, e.leg, e.tick)).collect();
let new_keys: Vec<(usize, Leg, u64)> =
new.evidence.iter().map(|e| (e.channel, e.leg, e.tick)).collect();
let added_evidence = new_keys.iter().filter(|k| !old_keys.contains(k)).count();
let removed_evidence = old_keys.iter().filter(|k| !new_keys.contains(k)).count();
let value_changes = old
.evidence
.iter()
.filter(|oe| {
new.evidence.iter().any(|ne| {
(oe.channel, oe.leg, oe.tick) == (ne.channel, ne.leg, ne.tick)
&& (value_differs(oe.observed, ne.observed)
|| value_differs(oe.threshold, ne.threshold))
})
})
.count();
let mut channel_diff: Vec<usize> = new
.channels_involved
.iter()
.filter(|c| !old.channels_involved.contains(c))
.copied()
.collect();
channel_diff.sort_unstable();
EvidenceChange { incident_id: saved_id, added_evidence, removed_evidence, value_changes, channel_diff }
}
fn value_differs(a: f64, b: f64) -> bool {
let scale = a.abs().max(b.abs()).max(1.0);
(a - b).abs() > 1e-6 * scale
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ContextEvent;
use crate::incident::Evidence;
use crate::monitor::Leg;
fn incident(id: u64, start: u64, end: u64, channels: Vec<usize>) -> Incident {
Incident {
id,
start_tick: start,
end_tick: end,
evidence: vec![Evidence {
channel: channels.first().copied().unwrap_or(0),
leg: Leg::LevelShift,
tick: start,
observed: 4.0,
threshold: 3.0,
explanation: "test".to_string(),
}],
context: Vec::<ContextEvent>::new(),
channels_involved: channels,
resolution: None,
unresolved: Vec::new(),
}
}
#[test]
fn matches_within_tolerance_and_overlapping_channels() {
let saved = vec![incident(0, 100, 105, vec![0])];
let new = vec![incident(0, 103, 108, vec![0])];
let diff = diff_incidents(&saved, &new);
assert_eq!(diff.matched, vec![(0, 0)]);
assert_eq!(diff.timing_deltas, vec![(0, 3)]);
assert!(diff.missed.is_empty());
assert!(diff.new_alarms.is_empty());
assert_eq!(diff.evidence_changes.len(), 1);
assert_eq!(diff.evidence_changes[0].incident_id, 0);
assert_eq!(diff.evidence_changes[0].added_evidence, 1);
assert_eq!(diff.evidence_changes[0].removed_evidence, 1);
assert!(diff.evidence_changes[0].channel_diff.is_empty());
}
#[test]
fn beyond_tolerance_is_missed_and_new() {
let saved = vec![incident(0, 100, 105, vec![0])];
let new = vec![incident(0, 200, 205, vec![0])];
let diff = diff_incidents(&saved, &new);
assert!(diff.matched.is_empty());
assert_eq!(diff.missed, vec![0]);
assert_eq!(diff.new_alarms, vec![0]);
}
#[test]
fn no_channel_overlap_does_not_match() {
let saved = vec![incident(0, 100, 105, vec![0])];
let new = vec![incident(0, 101, 106, vec![1])];
let diff = diff_incidents(&saved, &new);
assert!(diff.matched.is_empty());
assert_eq!(diff.missed, vec![0]);
assert_eq!(diff.new_alarms, vec![0]);
}
#[test]
fn summary_formats_sign_and_counts() {
let diff = ReplayDiff {
matched: vec![(0, 0), (1, 1), (2, 2)],
missed: vec![3],
new_alarms: vec![],
timing_deltas: vec![(0, 2), (1, -1), (2, 5)],
evidence_changes: vec![],
..Default::default()
};
assert_eq!(
diff.summary(),
"3 matched, 1 missed, 0 new, 0 evidence-changed, max timing delta +5 ticks"
);
}
#[test]
fn summary_counts_only_evidence_changes_with_real_deltas() {
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: 0,
removed_evidence: 0,
..Default::default()
}],
..Default::default()
};
assert!(diff.summary().contains("0 evidence-changed"));
}
#[test]
fn to_json_is_well_formed() {
let diff = ReplayDiff {
matched: vec![(0, 1)],
missed: vec![2],
new_alarms: vec![3],
timing_deltas: vec![(0, -4)],
evidence_changes: vec![EvidenceChange {
incident_id: 0,
added_evidence: 1,
removed_evidence: 2,
channel_diff: vec![5],
..Default::default()
}],
..Default::default()
};
let json = diff.to_json();
assert!(json.starts_with('{') && json.ends_with('}'));
assert_eq!(json.matches('{').count(), json.matches('}').count());
assert_eq!(json.matches('[').count(), json.matches(']').count());
assert!(json.contains("\"evidence_changes\":[{\"incident_id\":0"));
assert!(json.contains("\"channel_diff\":[5]"));
}
#[test]
fn replay_reproduces_incident_count_on_rover_flow_recording() {
use crate::case::CaseConfig;
use crate::context::ColumnSchema;
use crate::telemetry_bench::{inject_fault, synth_spacecraft, CHANNELS};
let baseline = 1000;
let total = baseline + 2000;
let clean = synth_spacecraft(total, 42); let faulted = inject_fault(&clean, "regime_shift", 42);
let timeline = ContextTimeline::new();
let (original_incidents, monitor_export, _imputation) =
run_investigation(&faulted, baseline, &timeline).expect("investigates");
let mut full_rows: Vec<Vec<f64>> = Vec::with_capacity(total);
for t in 0..total {
full_rows.push((0..CHANNELS).map(|c| faulted[c][t]).collect());
}
let names: Vec<String> = (0..CHANNELS).map(|c| format!("ch{}", c)).collect();
let column_schema =
ColumnSchema::from_header(&names.iter().map(|s| s.as_str()).collect::<Vec<_>>());
let config = CaseConfig {
input_hash: crate::case::fingerprint_content(b"rover_flow test fixture"),
monitor_export,
column_schema,
imputation: vec![],
};
let dir = std::env::temp_dir().join("struktura_replay_test_rover_flow");
let _ = std::fs::remove_dir_all(&dir);
let case =
Case::save(&dir, &full_rows, &original_incidents, baseline, "rover_flow", &config, &timeline, &names)
.expect("saves");
let (new_incidents, diff) = replay(&case, None).expect("replays");
assert_eq!(new_incidents.len(), original_incidents.len());
assert!(diff.missed.is_empty());
assert!(diff.new_alarms.is_empty());
if !original_incidents.is_empty() {
assert!(original_incidents[0].start_tick >= baseline as u64);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn evidence_ticks_stay_recording_row_aligned_through_recalibration() {
use crate::telemetry_bench::{inject_fault, synth_spacecraft};
let baseline = 1000;
let total = baseline + 2000;
let clean = synth_spacecraft(total, 42);
let faulted = inject_fault(&clean, "regime_shift", 42);
let timeline = ContextTimeline::new();
let (incidents, _export, _imputation) =
run_investigation(&faulted, baseline, &timeline).expect("investigates");
let recalibrated =
incidents.iter().any(|inc| inc.context.iter().any(|c| c.kind == "recalibrated"));
assert!(recalibrated, "fixture did not trigger a recalibration");
for inc in &incidents {
for e in &inc.evidence {
assert!(
e.tick >= baseline as u64 && (e.tick as usize) < total,
"evidence tick {} out of recording range [{}, {})",
e.tick,
baseline,
total
);
}
}
let all_ticks: Vec<u64> =
incidents.iter().flat_map(|inc| inc.evidence.iter().map(|e| e.tick)).collect();
let mut sorted = all_ticks.clone();
sorted.sort_unstable();
assert_eq!(
all_ticks, sorted,
"evidence ticks are not monotonically non-decreasing through recalibration"
);
}
#[test]
fn run_investigation_rejects_calibration_window_with_zero_finite_values() {
let baseline = 200;
let samples = 300;
let mut good = vec![0.0f64; samples];
for (i, v) in good.iter_mut().enumerate() {
*v = (i as f64 * 0.01).sin();
}
let bad = vec![f64::NAN; samples];
let cols = vec![good, bad];
let timeline = ContextTimeline::new();
let err = run_investigation(&cols, baseline, &timeline).unwrap_err();
assert!(err.contains("channel 1"), "error should name the bad channel: {}", err);
assert!(err.contains("no finite values"), "error should say why: {}", err);
}
fn sine_channel(samples: usize) -> Vec<f64> {
(0..samples).map(|i| (i as f64 * 0.01).sin()).collect()
}
#[test]
fn run_investigation_imputes_single_nan_in_calibration_window() {
let baseline = 200;
let samples = 300;
let good = sine_channel(samples);
let mut one_nan = sine_channel(samples);
one_nan[50] = f64::NAN; let cols = vec![good, one_nan];
let timeline = ContextTimeline::new();
let (_incidents, export, _imputation) =
run_investigation(&cols, baseline, &timeline).expect("a single NaN must not fail calibration");
assert!(export.channels[1].alpha_mean.is_finite(), "imputed NaN must not propagate into calibration");
assert!(export.channels[1].ar_a.is_finite());
assert!(export.channels[1].mean.is_finite());
}
#[test]
fn run_investigation_imputes_single_inf_in_calibration_window() {
let baseline = 200;
let samples = 300;
let good = sine_channel(samples);
let mut one_inf = sine_channel(samples);
one_inf[50] = f64::INFINITY; let cols = vec![good, one_inf];
let timeline = ContextTimeline::new();
let (_incidents, export, _imputation) =
run_investigation(&cols, baseline, &timeline).expect("a single Inf must not fail calibration");
assert!(export.channels[1].alpha_mean.is_finite(), "imputed Inf must not propagate into calibration");
}
#[test]
fn evidence_change_counts_same_key_different_threshold_as_value_change() {
let mut old = incident(0, 100, 100, vec![0]);
old.evidence[0].threshold = 3.0;
let mut new = incident(0, 100, 100, vec![0]);
new.evidence[0].threshold = 5.0;
let change = evidence_change(0, &old, &new);
assert_eq!(change.added_evidence, 0);
assert_eq!(change.removed_evidence, 0);
assert!(change.value_changes > 0, "changed threshold at the same key must count as a value change");
}
#[test]
fn evidence_change_ignores_within_tolerance_float_noise() {
let mut old = incident(0, 100, 100, vec![0]);
old.evidence[0].threshold = 3.0;
let mut new = incident(0, 100, 100, vec![0]);
new.evidence[0].threshold = 3.0 + 1e-9;
let change = evidence_change(0, &old, &new);
assert_eq!(change.value_changes, 0, "sub-tolerance float noise must not count as a value change");
}
#[test]
fn replay_reports_no_fingerprint_mismatch_on_untouched_case_but_does_after_edit() {
use crate::case::CaseConfig;
use crate::context::ColumnSchema;
use crate::monitor::HybridMonitor;
let baseline = 200;
let total = baseline + 100;
let ch0 = sine_channel(total);
let recording: Vec<Vec<f64>> = (0..total).map(|t| vec![ch0[t]]).collect();
let timeline = ContextTimeline::new();
let calib = vec![ch0[..baseline].to_vec()];
let monitor = HybridMonitor::calibrate(&calib).expect("calibrates");
let config = CaseConfig {
input_hash: crate::case::fingerprint_content(b"original source file bytes, unrelated to recording.csv"),
monitor_export: monitor.export(),
column_schema: ColumnSchema::from_header(&["ch0"]),
imputation: vec![],
};
let dir = std::env::temp_dir().join("struktura_replay_test_fingerprint");
let _ = std::fs::remove_dir_all(&dir);
let case = Case::save(&dir, &recording, &[], baseline, "fingerprint", &config, &timeline, &["ch0".to_string()])
.expect("saves");
let (_incidents, diff) = replay(&case, None).expect("replays");
assert!(diff.fingerprint_mismatch.is_none(), "untouched case must not report a fingerprint mismatch");
let recording_path = case.dir().join("recording.csv");
let text = std::fs::read_to_string(&recording_path).expect("reads recording.csv");
let mut lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
let row_line_idx = 51; let original_val: f64 = lines[row_line_idx].parse().expect("row is a single float");
lines[row_line_idx] = (original_val + 100.0).to_string();
let edited = lines.join("\n") + "\n";
std::fs::write(&recording_path, &edited).expect("writes edited recording.csv");
let (_incidents2, diff2) = replay(&case, None).expect("replays after edit");
assert!(diff2.fingerprint_mismatch.is_some(), "edited recording.csv must report a fingerprint mismatch");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replay_reproduces_saved_context_at_the_same_ticks() {
use crate::case::CaseConfig;
use crate::context::{ColumnSchema, ContextEvent};
use crate::telemetry_bench::{inject_fault, synth_spacecraft, CHANNELS};
let baseline = 1000;
let total = baseline + 2000;
let clean = synth_spacecraft(total, 42);
let faulted = inject_fault(&clean, "regime_shift", 42);
let empty_timeline = ContextTimeline::new();
let (probe_incidents, _, _) =
run_investigation(&faulted, baseline, &empty_timeline).expect("investigates");
assert!(!probe_incidents.is_empty(), "fixture must raise at least one incident");
let anchor_tick = probe_incidents[0].start_tick;
let mut timeline = ContextTimeline::new();
timeline.push(ContextEvent::new(anchor_tick, "mode", "safe_hold"));
let (original_incidents, monitor_export, _imputation) =
run_investigation(&faulted, baseline, &timeline).expect("investigates");
assert!(
original_incidents.iter().any(|inc| inc.context.iter().any(|c| c.value == "safe_hold")),
"fixture must actually attach the context event to an incident"
);
let mut full_rows: Vec<Vec<f64>> = Vec::with_capacity(total);
for t in 0..total {
full_rows.push((0..CHANNELS).map(|c| faulted[c][t]).collect());
}
let names: Vec<String> = (0..CHANNELS).map(|c| format!("ch{}", c)).collect();
let config = CaseConfig {
input_hash: crate::case::fingerprint_content(b"context round trip fixture"),
monitor_export,
column_schema: ColumnSchema::from_header(&names.iter().map(|s| s.as_str()).collect::<Vec<_>>()),
imputation: vec![],
};
let dir = std::env::temp_dir().join("struktura_replay_test_context_roundtrip");
let _ = std::fs::remove_dir_all(&dir);
let case = Case::save(
&dir,
&full_rows,
&original_incidents,
baseline,
"context_roundtrip",
&config,
&timeline,
&names,
)
.expect("saves");
let loaded_timeline = case.context().expect("reads context.json");
assert_eq!(loaded_timeline.len(), timeline.len());
assert_eq!(loaded_timeline.active_at(anchor_tick).unwrap().value, "safe_hold");
let (replayed_incidents, _diff) = replay(&case, None).expect("replays");
assert!(
replayed_incidents
.iter()
.any(|inc| inc.context.iter().any(|c| c.tick == anchor_tick && c.value == "safe_hold")),
"replayed incidents must carry the same context event at the same tick as the original"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replay_reports_fingerprint_and_per_channel_threshold_diffs_in_json() {
use crate::case::CaseConfig;
use crate::context::ColumnSchema;
use crate::monitor::HybridMonitor;
let baseline = 200;
let total = baseline + 100;
let ch0 = sine_channel(total);
let recording: Vec<Vec<f64>> = (0..total).map(|t| vec![ch0[t]]).collect();
let timeline = ContextTimeline::new();
let calib = vec![ch0[..baseline].to_vec()];
let monitor = HybridMonitor::calibrate(&calib).expect("calibrates");
let config = CaseConfig {
input_hash: crate::case::fingerprint_content(b"config diff fixture"),
monitor_export: monitor.export(),
column_schema: ColumnSchema::from_header(&["ch0"]),
imputation: vec![],
};
let dir = std::env::temp_dir().join("struktura_replay_test_config_diff");
let _ = std::fs::remove_dir_all(&dir);
let case = Case::save(&dir, &recording, &[], baseline, "config_diff", &config, &timeline, &["ch0".to_string()])
.expect("saves");
let (_incidents, diff) = replay(&case, None).expect("replays");
assert!(diff.threshold_diffs.is_empty());
let json = diff.to_json();
assert!(json.contains("\"fingerprint_mismatch\":false"), "{}", json);
assert!(json.contains("\"threshold_diffs\":[]"), "{}", json);
let config_path = case.dir().join("config.json");
let text = std::fs::read_to_string(&config_path).expect("reads config.json");
let saved_alpha_mean = crate::case::parse_config_monitor_export(&text).unwrap().channels[0].alpha_mean;
let edited = text.replacen(
&format!("\"alpha_mean\":{}", saved_alpha_mean),
&format!("\"alpha_mean\":{}", saved_alpha_mean + 100.0),
1,
);
assert_ne!(text, edited, "test setup must actually change alpha_mean");
std::fs::write(&config_path, &edited).expect("writes edited config.json");
let (_incidents2, diff2) = replay(&case, None).expect("replays after config edit");
assert!(
diff2.threshold_diffs.iter().any(|d| d.contains("alpha_mean")),
"threshold_diffs must report the changed per-channel alpha_mean: {:?}",
diff2.threshold_diffs
);
assert!(diff2.to_json().contains("\"threshold_diffs\":[\""));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replay_tolerates_a_case_with_no_config_json() {
use crate::case::CaseConfig;
use crate::context::ColumnSchema;
use crate::monitor::HybridMonitor;
let baseline = 200;
let total = baseline + 100;
let ch0 = sine_channel(total);
let recording: Vec<Vec<f64>> = (0..total).map(|t| vec![ch0[t]]).collect();
let timeline = ContextTimeline::new();
let calib = vec![ch0[..baseline].to_vec()];
let monitor = HybridMonitor::calibrate(&calib).expect("calibrates");
let config = CaseConfig {
input_hash: crate::case::fingerprint_content(b"legacy fixture"),
monitor_export: monitor.export(),
column_schema: ColumnSchema::from_header(&["ch0"]),
imputation: vec![],
};
let dir = std::env::temp_dir().join("struktura_replay_test_legacy_no_config");
let _ = std::fs::remove_dir_all(&dir);
let case = Case::save(&dir, &recording, &[], baseline, "legacy", &config, &timeline, &["ch0".to_string()])
.expect("saves");
std::fs::remove_file(case.dir().join("config.json")).expect("removes config.json");
let (_incidents, diff) = replay(&case, None).expect("replay must tolerate a missing config.json");
assert!(diff.saved_thresholds.is_none());
assert!(diff.threshold_diffs.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn end_to_end_investigate_save_replay_with_context_and_damaged_data() {
use crate::case::CaseConfig;
use crate::context::ColumnSchema;
use crate::telemetry_bench::{synth_spacecraft, CHANNELS};
let baseline = 1000;
let total = 3000;
let fault_start = 2000;
let mut cols = synth_spacecraft(total, 7);
for t in 10..15 {
cols[1][t] = f64::NAN;
}
const FAULT_CHANNEL: usize = 2;
let window = &cols[FAULT_CHANNEL][..fault_start];
let mean: f64 = window.iter().sum::<f64>() / window.len() as f64;
let std: f64 =
(window.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / window.len() as f64).sqrt();
let stuck_value = mean + 6.0 * std.max(1e-6);
for v in cols[FAULT_CHANNEL].iter_mut().skip(fault_start) {
*v = stuck_value;
}
let mut timeline = ContextTimeline::new();
timeline.push(ContextEvent::new(1500, "mode", "science"));
let (incidents, monitor_export, imputation) =
run_investigation(&cols, baseline, &timeline).expect("investigates");
assert!(
incidents
.iter()
.any(|inc| inc.evidence.iter().any(|e| e.channel == FAULT_CHANNEL && e.tick as usize >= fault_start)),
"expected a channel-{}-incident at/after tick {}, got: {:?}",
FAULT_CHANNEL,
fault_start,
incidents
);
let ch1_imputed = imputation.iter().find(|(ch, _)| *ch == 1).map(|(_, n)| *n);
assert_eq!(ch1_imputed, Some(5), "imputation counts: {:?}", imputation);
let names: Vec<String> = (0..CHANNELS).map(|c| format!("ch{}", c)).collect();
let column_schema =
ColumnSchema::from_header(&names.iter().map(|s| s.as_str()).collect::<Vec<_>>());
let config = CaseConfig {
input_hash: crate::case::fingerprint_content(b"end to end fixture"),
monitor_export,
column_schema,
imputation,
};
let mut rows: Vec<Vec<f64>> = Vec::with_capacity(total);
for t in 0..total {
rows.push((0..CHANNELS).map(|c| cols[c][t]).collect());
}
let dir = std::env::temp_dir().join("struktura_replay_test_end_to_end");
let _ = std::fs::remove_dir_all(&dir);
let case = Case::save(&dir, &rows, &incidents, baseline, "end_to_end", &config, &timeline, &names)
.expect("saves");
assert!(case.dir().join("context.json").exists());
assert!(case.dir().join("schema.json").exists());
let (replayed_incidents, diff) = replay(&case, None).expect("replays");
assert!(diff.fingerprint_mismatch.is_none());
assert_eq!(replayed_incidents.len(), incidents.len());
assert_eq!(diff.matched.len(), incidents.len());
assert!(diff.missed.is_empty());
assert!(diff.new_alarms.is_empty());
assert!(diff.config_valid);
let config_path = case.dir().join("config.json");
let original_config = std::fs::read_to_string(&config_path).expect("reads config.json");
std::fs::write(&config_path, "{}").expect("overwrites config.json");
let err = replay(&case, None).unwrap_err();
assert!(err.contains("monitor_export"), "unexpected error: {}", err);
std::fs::write(&config_path, &original_config).expect("restores config.json");
let recording_path = case.dir().join("recording.csv");
let text = std::fs::read_to_string(&recording_path).expect("reads recording.csv");
let mut lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
let row_line_idx = 51; let mut fields: Vec<String> = lines[row_line_idx].split(',').collect::<Vec<_>>().iter().map(|s| s.to_string()).collect();
let original_val: f64 = fields[0].parse().expect("first field is a float");
fields[0] = (original_val + 100.0).to_string();
lines[row_line_idx] = fields.join(",");
let edited = lines.join("\n") + "\n";
std::fs::write(&recording_path, &edited).expect("writes edited recording.csv");
let (_incidents3, diff3) = replay(&case, None).expect("replays after recording edit");
assert!(diff3.fingerprint_mismatch.is_some(), "edited recording.csv must report a fingerprint mismatch");
let _ = std::fs::remove_dir_all(&dir);
}
}