1use std::error::Error;
2use std::fs::{self, File};
3use std::io::{BufWriter, Write};
4use std::path::{Path, PathBuf};
5
6use chrono::{Duration, Utc};
7
8use crate::event::StructuralEvent;
9
10pub type DynError = Box<dyn Error>;
11
12#[derive(Clone, Debug)]
13pub struct RunManifestRow {
14 pub run_id: String,
15 pub timestamp: String,
16 pub config_hash: String,
17 pub crate_name: String,
18 pub crate_version: String,
19 pub n_events: usize,
20 pub n_channels: usize,
21 pub causal_window: usize,
22 pub tau_steps: usize,
23 pub shock_start: usize,
24 pub shock_end: usize,
25 pub beta: f64,
26 pub envelope_decay: f64,
27}
28
29#[derive(Clone, Debug)]
30pub struct ThresholdSweepRow {
31 pub run_id: String,
32 pub n_events: usize,
33 pub tau_threshold: f64,
34 pub reachable_count: usize,
35 pub reachable_fraction: f64,
36 pub edge_count: usize,
37 pub mean_out_degree: f64,
38 pub largest_component_fraction: f64,
39 pub component_entropy: f64,
40}
41
42#[derive(Clone, Debug)]
43pub struct TransitionSharpnessRow {
44 pub run_id: String,
45 pub n_events: usize,
46 pub tau_midpoint: f64,
47 pub drho_dtau: f64,
48 pub abs_drho_dtau: f64,
49}
50
51#[derive(Clone, Debug)]
52pub struct TimeLocalMetricsRow {
53 pub run_id: String,
54 pub tau_threshold: f64,
55 pub window_start: usize,
56 pub window_end: usize,
57 pub anchor_event: usize,
58 pub reachable_fraction: f64,
59 pub active_edge_count: usize,
60 pub mean_out_degree: f64,
61 pub regime_label: String,
62}
63
64#[derive(Clone, Debug)]
65pub struct GraphSnapshotRow {
66 pub run_id: String,
67 pub tau_threshold: f64,
68 pub src: usize,
69 pub dst: usize,
70 pub src_trust: f64,
71 pub dst_trust: f64,
72 pub compatible: bool,
73}
74
75#[derive(Clone, Debug)]
76pub struct ExportBundle {
77 pub manifest: RunManifestRow,
78 pub events: Vec<StructuralEvent>,
79 pub threshold_sweep: Vec<ThresholdSweepRow>,
80 pub transition_sharpness: Vec<TransitionSharpnessRow>,
81 pub time_local_metrics: Vec<TimeLocalMetricsRow>,
82 pub graph_snapshot_low: Vec<GraphSnapshotRow>,
83 pub graph_snapshot_critical: Vec<GraphSnapshotRow>,
84 pub graph_snapshot_high: Vec<GraphSnapshotRow>,
85 pub graph_snapshot_tau_020: Vec<GraphSnapshotRow>,
86 pub graph_snapshot_tau_030: Vec<GraphSnapshotRow>,
87 pub graph_snapshot_tau_040: Vec<GraphSnapshotRow>,
88}
89
90#[derive(Clone, Debug)]
91pub struct ExportOutcome {
92 pub timestamp: String,
93 pub run_dir: PathBuf,
94}
95
96pub fn prepare_output_dir(repo_root: &Path) -> Result<ExportOutcome, DynError> {
97 let output_root = repo_root.join("output-dsfb-srd");
98 fs::create_dir_all(&output_root)?;
99
100 let base = Utc::now();
101 for seconds in 0..120 {
102 let timestamp = (base + Duration::seconds(seconds))
103 .format("%Y%m%d-%H%M%S")
104 .to_string();
105 let run_dir = output_root.join(×tamp);
106 if !run_dir.exists() {
107 fs::create_dir_all(&run_dir)?;
108 return Ok(ExportOutcome { timestamp, run_dir });
109 }
110 }
111
112 Err("unable to allocate a unique timestamped output directory".into())
113}
114
115pub fn write_bundle(bundle: &ExportBundle, run_dir: &Path) -> Result<(), DynError> {
116 write_run_manifest(&run_dir.join("run_manifest.csv"), &bundle.manifest)?;
117 write_events(
118 &run_dir.join("events.csv"),
119 &bundle.manifest.run_id,
120 &bundle.events,
121 )?;
122 write_threshold_sweep(
123 &run_dir.join("threshold_sweep.csv"),
124 &bundle.threshold_sweep,
125 )?;
126 write_transition_sharpness(
127 &run_dir.join("transition_sharpness.csv"),
128 &bundle.transition_sharpness,
129 )?;
130 write_time_local_metrics(
131 &run_dir.join("time_local_metrics.csv"),
132 &bundle.time_local_metrics,
133 )?;
134 write_graph_snapshot(
135 &run_dir.join("graph_snapshot_low.csv"),
136 &bundle.graph_snapshot_low,
137 )?;
138 write_graph_snapshot(
139 &run_dir.join("graph_snapshot_critical.csv"),
140 &bundle.graph_snapshot_critical,
141 )?;
142 write_graph_snapshot(
143 &run_dir.join("graph_snapshot_high.csv"),
144 &bundle.graph_snapshot_high,
145 )?;
146 write_graph_snapshot(
147 &run_dir.join("graph_snapshot_tau_020.csv"),
148 &bundle.graph_snapshot_tau_020,
149 )?;
150 write_graph_snapshot(
151 &run_dir.join("graph_snapshot_tau_030.csv"),
152 &bundle.graph_snapshot_tau_030,
153 )?;
154 write_graph_snapshot(
155 &run_dir.join("graph_snapshot_tau_040.csv"),
156 &bundle.graph_snapshot_tau_040,
157 )?;
158
159 Ok(())
160}
161
162fn write_run_manifest(path: &Path, row: &RunManifestRow) -> Result<(), DynError> {
163 let line = format!(
164 "{},{},{},{},{},{},{},{},{},{},{},{},{}",
165 row.run_id,
166 row.timestamp,
167 row.config_hash,
168 row.crate_name,
169 row.crate_version,
170 row.n_events,
171 row.n_channels,
172 row.causal_window,
173 row.tau_steps,
174 row.shock_start,
175 row.shock_end,
176 fmt_f64(row.beta),
177 fmt_f64(row.envelope_decay),
178 );
179
180 write_lines(
181 path,
182 "run_id,timestamp,config_hash,crate_name,crate_version,n_events,n_channels,causal_window,tau_steps,shock_start,shock_end,beta,envelope_decay",
183 std::iter::once(line),
184 )
185}
186
187fn write_events(path: &Path, run_id: &str, events: &[StructuralEvent]) -> Result<(), DynError> {
188 let lines = events.iter().map(|event| {
189 format!(
190 "{},{},{},{},{},{},{},{},{},{},{}",
191 run_id,
192 event.event_id,
193 event.time_index,
194 event.channel_id,
195 fmt_f64(event.latent_state),
196 fmt_f64(event.predicted_value),
197 fmt_f64(event.observed_value),
198 fmt_f64(event.residual),
199 fmt_f64(event.envelope),
200 fmt_f64(event.trust),
201 event.regime_label.as_str(),
202 )
203 });
204
205 write_lines(
206 path,
207 "run_id,event_id,time_index,channel_id,latent_state,predicted_value,observed_value,residual,envelope,trust,regime_label",
208 lines,
209 )
210}
211
212fn write_threshold_sweep(path: &Path, rows: &[ThresholdSweepRow]) -> Result<(), DynError> {
213 let lines = rows.iter().map(|row| {
214 format!(
215 "{},{},{},{},{},{},{},{},{}",
216 row.run_id,
217 row.n_events,
218 fmt_f64(row.tau_threshold),
219 row.reachable_count,
220 fmt_f64(row.reachable_fraction),
221 row.edge_count,
222 fmt_f64(row.mean_out_degree),
223 fmt_f64(row.largest_component_fraction),
224 fmt_f64(row.component_entropy),
225 )
226 });
227
228 write_lines(
229 path,
230 "run_id,n_events,tau_threshold,reachable_count,reachable_fraction,edge_count,mean_out_degree,largest_component_fraction,component_entropy",
231 lines,
232 )
233}
234
235fn write_transition_sharpness(
236 path: &Path,
237 rows: &[TransitionSharpnessRow],
238) -> Result<(), DynError> {
239 let lines = rows.iter().map(|row| {
240 format!(
241 "{},{},{},{},{}",
242 row.run_id,
243 row.n_events,
244 fmt_f64(row.tau_midpoint),
245 fmt_f64(row.drho_dtau),
246 fmt_f64(row.abs_drho_dtau),
247 )
248 });
249
250 write_lines(
251 path,
252 "run_id,n_events,tau_midpoint,drho_dtau,abs_drho_dtau",
253 lines,
254 )
255}
256
257fn write_time_local_metrics(path: &Path, rows: &[TimeLocalMetricsRow]) -> Result<(), DynError> {
258 let lines = rows.iter().map(|row| {
259 format!(
260 "{},{},{},{},{},{},{},{},{}",
261 row.run_id,
262 fmt_f64(row.tau_threshold),
263 row.window_start,
264 row.window_end,
265 row.anchor_event,
266 fmt_f64(row.reachable_fraction),
267 row.active_edge_count,
268 fmt_f64(row.mean_out_degree),
269 row.regime_label,
270 )
271 });
272
273 write_lines(
274 path,
275 "run_id,tau_threshold,window_start,window_end,anchor_event,reachable_fraction,active_edge_count,mean_out_degree,regime_label",
276 lines,
277 )
278}
279
280fn write_graph_snapshot(path: &Path, rows: &[GraphSnapshotRow]) -> Result<(), DynError> {
281 let lines = rows.iter().map(|row| {
282 format!(
283 "{},{},{},{},{},{},{}",
284 row.run_id,
285 fmt_f64(row.tau_threshold),
286 row.src,
287 row.dst,
288 fmt_f64(row.src_trust),
289 fmt_f64(row.dst_trust),
290 row.compatible,
291 )
292 });
293
294 write_lines(
295 path,
296 "run_id,tau_threshold,src,dst,src_trust,dst_trust,compatible",
297 lines,
298 )
299}
300
301fn write_lines<I>(path: &Path, header: &str, lines: I) -> Result<(), DynError>
302where
303 I: IntoIterator<Item = String>,
304{
305 let file = File::create(path)?;
306 let mut writer = BufWriter::new(file);
307 writeln!(writer, "{header}")?;
308 for line in lines {
309 writeln!(writer, "{line}")?;
310 }
311 writer.flush()?;
312 Ok(())
313}
314
315fn fmt_f64(value: f64) -> String {
316 format!("{value:.8}")
317}