1use std::cmp::Ordering;
2use std::path::PathBuf;
3
4use crate::config::{compute_run_id, SimulationConfig, CRATE_NAME, CRATE_VERSION};
5use crate::export::{
6 prepare_output_dir, write_bundle, DynError, ExportBundle, GraphSnapshotRow, RunManifestRow,
7 ThresholdSweepRow, TimeLocalMetricsRow, TransitionSharpnessRow,
8};
9use crate::graph::{
10 build_candidate_graph, collect_active_edges, compute_graph_stats, compute_graph_stats_in_range,
11 CandidateGraph,
12};
13use crate::metrics::{
14 discrete_derivative, low_high_thresholds, nearest_threshold, window_ranges, window_regime,
15};
16use crate::signal::generate_events;
17
18const FINITE_SIZE_SWEEP: [usize; 4] = [250, 500, 1_000, 2_000];
19const SNAPSHOT_TAU_020: f64 = 0.20;
20const SNAPSHOT_TAU_030: f64 = 0.30;
21const SNAPSHOT_TAU_040: f64 = 0.40;
22
23#[derive(Clone, Debug)]
24pub struct GeneratedRun {
25 pub run_id: String,
26 pub config_hash: String,
27 pub timestamp: String,
28 pub output_dir: PathBuf,
29}
30
31pub fn run_simulation(config: SimulationConfig) -> Result<GeneratedRun, DynError> {
32 config
33 .validate()
34 .map_err(|message| -> DynError { message.into() })?;
35
36 let run_id = compute_run_id(&config);
37 let config_hash = config.config_hash();
38 let primary_events = generate_events(&config);
39 let primary_graph = build_candidate_graph(&primary_events, &config);
40 let thresholds = config.tau_thresholds();
41 let (tau_low, tau_high) = low_high_thresholds(&thresholds);
42 let tau_020 = nearest_threshold(&thresholds, SNAPSHOT_TAU_020);
43 let tau_030 = nearest_threshold(&thresholds, SNAPSHOT_TAU_030);
44 let tau_040 = nearest_threshold(&thresholds, SNAPSHOT_TAU_040);
45
46 let mut sweep_sizes = FINITE_SIZE_SWEEP.to_vec();
47 if !sweep_sizes.contains(&config.n_events) {
48 sweep_sizes.push(config.n_events);
49 sweep_sizes.sort_unstable();
50 }
51
52 let mut threshold_sweep = Vec::new();
53 let mut transition_sharpness = Vec::new();
54 let mut primary_critical_threshold = thresholds[thresholds.len() / 2];
55
56 for &n_events in &sweep_sizes {
57 let sized_config = if n_events == config.n_events {
58 config.clone()
59 } else {
60 config.scaled_for_n_events(n_events)
61 };
62 sized_config
63 .validate()
64 .map_err(|message| -> DynError { message.into() })?;
65
66 let (events, graph) = if n_events == config.n_events {
67 (primary_events.clone(), primary_graph.clone())
68 } else {
69 let events = generate_events(&sized_config);
70 let graph = build_candidate_graph(&events, &sized_config);
71 (events, graph)
72 };
73
74 let sweep_rows = build_threshold_sweep_rows(&run_id, &thresholds, &events, &graph);
75 let transition_rows = build_transition_rows(&run_id, n_events, &sweep_rows);
76
77 if n_events == config.n_events {
78 primary_critical_threshold =
79 select_critical_threshold(&transition_rows, primary_critical_threshold);
80 }
81
82 threshold_sweep.extend(sweep_rows);
83 transition_sharpness.extend(transition_rows);
84 }
85
86 let time_local_metrics = build_time_local_rows(
87 &run_id,
88 &config,
89 &primary_events,
90 &primary_graph,
91 &[tau_low, primary_critical_threshold, tau_high],
92 );
93 let graph_snapshot_low =
94 build_graph_snapshot_rows(&run_id, tau_low, &primary_events, &primary_graph);
95 let graph_snapshot_critical = build_graph_snapshot_rows(
96 &run_id,
97 primary_critical_threshold,
98 &primary_events,
99 &primary_graph,
100 );
101 let graph_snapshot_high =
102 build_graph_snapshot_rows(&run_id, tau_high, &primary_events, &primary_graph);
103 let graph_snapshot_tau_020 =
104 build_graph_snapshot_rows(&run_id, tau_020, &primary_events, &primary_graph);
105 let graph_snapshot_tau_030 =
106 build_graph_snapshot_rows(&run_id, tau_030, &primary_events, &primary_graph);
107 let graph_snapshot_tau_040 =
108 build_graph_snapshot_rows(&run_id, tau_040, &primary_events, &primary_graph);
109
110 let output = prepare_output_dir(&SimulationConfig::repo_root())?;
111 let manifest = RunManifestRow {
112 run_id: run_id.clone(),
113 timestamp: output.timestamp.clone(),
114 config_hash: config_hash.clone(),
115 crate_name: CRATE_NAME.to_string(),
116 crate_version: CRATE_VERSION.to_string(),
117 n_events: config.n_events,
118 n_channels: config.n_channels,
119 causal_window: config.causal_window,
120 tau_steps: config.tau_steps,
121 shock_start: config.shock_start,
122 shock_end: config.shock_end,
123 beta: config.beta,
124 envelope_decay: config.envelope_decay,
125 };
126
127 let bundle = ExportBundle {
128 manifest,
129 events: primary_events,
130 threshold_sweep,
131 transition_sharpness,
132 time_local_metrics,
133 graph_snapshot_low,
134 graph_snapshot_critical,
135 graph_snapshot_high,
136 graph_snapshot_tau_020,
137 graph_snapshot_tau_030,
138 graph_snapshot_tau_040,
139 };
140
141 write_bundle(&bundle, &output.run_dir)?;
142
143 Ok(GeneratedRun {
144 run_id,
145 config_hash,
146 timestamp: output.timestamp,
147 output_dir: output.run_dir,
148 })
149}
150
151fn build_threshold_sweep_rows(
152 run_id: &str,
153 thresholds: &[f64],
154 events: &[crate::event::StructuralEvent],
155 graph: &CandidateGraph,
156) -> Vec<ThresholdSweepRow> {
157 thresholds
158 .iter()
159 .copied()
160 .map(|tau_threshold| {
161 let stats = compute_graph_stats(graph, events, tau_threshold, 0);
162 ThresholdSweepRow {
163 run_id: run_id.to_string(),
164 n_events: events.len(),
165 tau_threshold,
166 reachable_count: stats.reachable_count,
167 reachable_fraction: stats.reachable_count as f64 / events.len() as f64,
168 edge_count: stats.edge_count,
169 mean_out_degree: stats.mean_out_degree,
170 largest_component_fraction: stats.largest_component_fraction,
171 component_entropy: stats.component_entropy,
172 }
173 })
174 .collect()
175}
176
177fn build_transition_rows(
178 run_id: &str,
179 n_events: usize,
180 sweep_rows: &[ThresholdSweepRow],
181) -> Vec<TransitionSharpnessRow> {
182 let points: Vec<(f64, f64)> = sweep_rows
183 .iter()
184 .map(|row| (row.tau_threshold, row.reachable_fraction))
185 .collect();
186
187 discrete_derivative(&points)
188 .into_iter()
189 .map(
190 |(tau_midpoint, drho_dtau, abs_drho_dtau)| TransitionSharpnessRow {
191 run_id: run_id.to_string(),
192 n_events,
193 tau_midpoint,
194 drho_dtau,
195 abs_drho_dtau,
196 },
197 )
198 .collect()
199}
200
201fn select_critical_threshold(rows: &[TransitionSharpnessRow], fallback: f64) -> f64 {
202 rows.iter()
203 .max_by(|left, right| {
204 left.abs_drho_dtau
205 .partial_cmp(&right.abs_drho_dtau)
206 .unwrap_or(Ordering::Equal)
207 })
208 .map(|row| row.tau_midpoint)
209 .unwrap_or(fallback)
210}
211
212fn build_time_local_rows(
213 run_id: &str,
214 config: &SimulationConfig,
215 events: &[crate::event::StructuralEvent],
216 graph: &CandidateGraph,
217 thresholds: &[f64],
218) -> Vec<TimeLocalMetricsRow> {
219 let mut rows = Vec::new();
220
221 for &tau_threshold in thresholds {
222 for (window_start, window_end_exclusive) in window_ranges(config) {
223 let anchor_event = window_start;
224 let stats = compute_graph_stats_in_range(
225 graph,
226 events,
227 tau_threshold,
228 anchor_event,
229 (window_start, window_end_exclusive),
230 );
231 let window_len = window_end_exclusive - window_start;
232 rows.push(TimeLocalMetricsRow {
233 run_id: run_id.to_string(),
234 tau_threshold,
235 window_start,
236 window_end: window_end_exclusive - 1,
237 anchor_event,
238 reachable_fraction: stats.reachable_count as f64 / window_len as f64,
239 active_edge_count: stats.edge_count,
240 mean_out_degree: stats.mean_out_degree,
241 regime_label: window_regime(events, window_start, window_end_exclusive)
242 .as_str()
243 .to_string(),
244 });
245 }
246 }
247
248 rows
249}
250
251fn build_graph_snapshot_rows(
252 run_id: &str,
253 tau_threshold: f64,
254 events: &[crate::event::StructuralEvent],
255 graph: &CandidateGraph,
256) -> Vec<GraphSnapshotRow> {
257 collect_active_edges(graph, events, tau_threshold, None)
258 .into_iter()
259 .map(|edge| GraphSnapshotRow {
260 run_id: run_id.to_string(),
261 tau_threshold,
262 src: edge.src,
263 dst: edge.dst,
264 src_trust: events[edge.src].trust,
265 dst_trust: events[edge.dst].trust,
266 compatible: edge.compatible,
267 })
268 .collect()
269}