Skip to main content

phago_runtime/
bench.rs

1//! Benchmark harness for running and comparing colony simulations.
2//!
3//! Provides a standard framework for running timed simulations,
4//! collecting snapshots at regular intervals, and comparing metrics
5//! across multiple runs with different configurations.
6
7use crate::colony::{Colony, ColonySnapshot};
8use crate::metrics::{ColonyMetrics, compute_from_snapshots};
9use phago_core::types::Tick;
10use serde::Serialize;
11use std::time::Instant;
12
13/// A single benchmark run capturing timeline data.
14#[derive(Debug, Clone, Serialize)]
15pub struct BenchmarkRun {
16    pub name: String,
17    pub ticks: u64,
18    pub snapshots: Vec<ColonySnapshot>,
19    pub metrics_timeline: Vec<(Tick, ColonyMetrics)>,
20    pub wall_time_ms: u64,
21}
22
23/// A suite of benchmark runs for comparison.
24pub struct BenchmarkSuite {
25    pub runs: Vec<BenchmarkRun>,
26}
27
28/// Configuration for a benchmark run.
29pub struct BenchmarkConfig {
30    pub name: String,
31    pub ticks: u64,
32    /// Take a snapshot every N ticks.
33    pub snapshot_interval: u64,
34    /// Compute metrics every N ticks.
35    pub metrics_interval: u64,
36}
37
38impl Default for BenchmarkConfig {
39    fn default() -> Self {
40        Self {
41            name: "default".to_string(),
42            ticks: 200,
43            snapshot_interval: 10,
44            metrics_interval: 50,
45        }
46    }
47}
48
49impl BenchmarkConfig {
50    pub fn new(name: &str, ticks: u64) -> Self {
51        Self {
52            name: name.to_string(),
53            ticks,
54            ..Default::default()
55        }
56    }
57
58    pub fn with_snapshot_interval(mut self, interval: u64) -> Self {
59        self.snapshot_interval = interval;
60        self
61    }
62
63    pub fn with_metrics_interval(mut self, interval: u64) -> Self {
64        self.metrics_interval = interval;
65        self
66    }
67}
68
69/// Run a benchmark on a colony with the given configuration.
70///
71/// The colony should already have documents ingested and agents spawned.
72/// This function runs the simulation and collects data.
73pub fn run_benchmark(colony: &mut Colony, config: &BenchmarkConfig) -> BenchmarkRun {
74    let mut snapshots = Vec::new();
75    let mut metrics_timeline = Vec::new();
76
77    // Initial snapshot
78    snapshots.push(colony.snapshot());
79
80    let start = Instant::now();
81
82    for tick_num in 1..=config.ticks {
83        colony.tick();
84
85        if tick_num % config.snapshot_interval == 0 {
86            snapshots.push(colony.snapshot());
87        }
88
89        if tick_num % config.metrics_interval == 0 {
90            let metrics = compute_from_snapshots(colony, &snapshots);
91            metrics_timeline.push((tick_num, metrics));
92        }
93    }
94
95    let wall_time = start.elapsed();
96
97    // Final metrics
98    let final_metrics = compute_from_snapshots(colony, &snapshots);
99    metrics_timeline.push((config.ticks, final_metrics));
100
101    BenchmarkRun {
102        name: config.name.clone(),
103        ticks: config.ticks,
104        snapshots,
105        metrics_timeline,
106        wall_time_ms: wall_time.as_millis() as u64,
107    }
108}
109
110impl BenchmarkSuite {
111    pub fn new() -> Self {
112        Self { runs: Vec::new() }
113    }
114
115    pub fn add_run(&mut self, run: BenchmarkRun) {
116        self.runs.push(run);
117    }
118
119    /// Compare final metrics across all runs.
120    pub fn compare(&self) -> ComparisonTable {
121        let rows: Vec<ComparisonRow> = self
122            .runs
123            .iter()
124            .map(|run| {
125                let final_metrics = run
126                    .metrics_timeline
127                    .last()
128                    .map(|(_, m)| m.clone());
129
130                ComparisonRow {
131                    name: run.name.clone(),
132                    ticks: run.ticks,
133                    wall_time_ms: run.wall_time_ms,
134                    graph_nodes: final_metrics
135                        .as_ref()
136                        .map(|m| m.graph_richness.node_count)
137                        .unwrap_or(0),
138                    graph_edges: final_metrics
139                        .as_ref()
140                        .map(|m| m.graph_richness.edge_count)
141                        .unwrap_or(0),
142                    density: final_metrics
143                        .as_ref()
144                        .map(|m| m.graph_richness.density)
145                        .unwrap_or(0.0),
146                    clustering: final_metrics
147                        .as_ref()
148                        .map(|m| m.graph_richness.clustering_coefficient)
149                        .unwrap_or(0.0),
150                    avg_degree: final_metrics
151                        .as_ref()
152                        .map(|m| m.graph_richness.avg_degree)
153                        .unwrap_or(0.0),
154                    shared_term_ratio: final_metrics
155                        .as_ref()
156                        .map(|m| m.transfer.shared_term_ratio)
157                        .unwrap_or(0.0),
158                    gini: final_metrics
159                        .as_ref()
160                        .map(|m| m.vocabulary_spread.gini_coefficient)
161                        .unwrap_or(0.0),
162                }
163            })
164            .collect();
165
166        ComparisonTable { rows }
167    }
168
169    /// Export all runs to CSV format.
170    pub fn to_csv(&self) -> String {
171        let table = self.compare();
172        let mut csv = String::new();
173        csv.push_str("name,ticks,wall_time_ms,nodes,edges,density,clustering,avg_degree,shared_term_ratio,gini\n");
174        for row in &table.rows {
175            csv.push_str(&format!(
176                "{},{},{},{},{},{:.4},{:.4},{:.2},{:.4},{:.4}\n",
177                row.name,
178                row.ticks,
179                row.wall_time_ms,
180                row.graph_nodes,
181                row.graph_edges,
182                row.density,
183                row.clustering,
184                row.avg_degree,
185                row.shared_term_ratio,
186                row.gini,
187            ));
188        }
189        csv
190    }
191}
192
193impl Default for BenchmarkSuite {
194    fn default() -> Self {
195        Self::new()
196    }
197}
198
199/// Comparison table across benchmark runs.
200#[derive(Debug, Clone, Serialize)]
201pub struct ComparisonTable {
202    pub rows: Vec<ComparisonRow>,
203}
204
205/// A single row in the comparison table.
206#[derive(Debug, Clone, Serialize)]
207pub struct ComparisonRow {
208    pub name: String,
209    pub ticks: u64,
210    pub wall_time_ms: u64,
211    pub graph_nodes: usize,
212    pub graph_edges: usize,
213    pub density: f64,
214    pub clustering: f64,
215    pub avg_degree: f64,
216    pub shared_term_ratio: f64,
217    pub gini: f64,
218}
219
220impl ComparisonTable {
221    /// Print a formatted comparison table to the terminal.
222    pub fn print(&self) {
223        println!("┌{:─<20}┬{:─<7}┬{:─<10}┬{:─<7}┬{:─<7}┬{:─<8}┬{:─<10}┬{:─<9}┐",
224            "", "", "", "", "", "", "", "");
225        println!("│{:<20}│{:>7}│{:>10}│{:>7}│{:>7}│{:>8}│{:>10}│{:>9}│",
226            " Run", " Nodes", " Edges", " Dense", " Clust", " AvgDeg", " Shared%", " Gini");
227        println!("├{:─<20}┼{:─<7}┼{:─<10}┼{:─<7}┼{:─<7}┼{:─<8}┼{:─<10}┼{:─<9}┤",
228            "", "", "", "", "", "", "", "");
229        for row in &self.rows {
230            println!("│{:<20}│{:>7}│{:>10}│{:>7.3}│{:>7.3}│{:>8.2}│{:>9.1}%│{:>9.3}│",
231                row.name,
232                row.graph_nodes,
233                row.graph_edges,
234                row.density,
235                row.clustering,
236                row.avg_degree,
237                row.shared_term_ratio * 100.0,
238                row.gini,
239            );
240        }
241        println!("└{:─<20}┴{:─<7}┴{:─<10}┴{:─<7}┴{:─<7}┴{:─<8}┴{:─<10}┴{:─<9}┘",
242            "", "", "", "", "", "", "", "");
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::corpus::Corpus;
250    use phago_agents::digester::Digester;
251    use phago_core::types::Position;
252
253    #[test]
254    fn benchmark_run_produces_data() {
255        let mut colony = Colony::new();
256        // Use inline corpus (fixed 20 docs) for deterministic test timing
257        let corpus = Corpus::inline_corpus();
258        corpus.ingest_into(&mut colony);
259        colony.spawn(Box::new(Digester::new(Position::new(0.0, 0.0)).with_max_idle(80)));
260
261        let config = BenchmarkConfig::new("test", 20)
262            .with_snapshot_interval(5)
263            .with_metrics_interval(10);
264        let run = run_benchmark(&mut colony, &config);
265
266        assert_eq!(run.ticks, 20);
267        assert!(!run.snapshots.is_empty());
268        assert!(!run.metrics_timeline.is_empty());
269        assert!(run.wall_time_ms < 10_000); // should be fast
270    }
271
272    #[test]
273    fn suite_comparison_works() {
274        let mut suite = BenchmarkSuite::new();
275
276        let mut colony1 = Colony::new();
277        colony1.spawn(Box::new(Digester::new(Position::new(0.0, 0.0)).with_max_idle(80)));
278        let run1 = run_benchmark(&mut colony1, &BenchmarkConfig::new("empty", 10));
279        suite.add_run(run1);
280
281        let csv = suite.to_csv();
282        assert!(csv.contains("empty"));
283    }
284}