//! Time operation inversion against captured commits. //! //! Deliberately not criterion: the interesting number is the per-commit //! latency distribution over a fixed corpus, not a throughput estimate with //! confidence intervals over one synthetic input. use std::collections::HashMap; use std::path::Path; use std::time::{Duration, Instant}; use hubble_commit::{Cid, OpOrder, invert_ops}; use crate::corpus::{self, Case}; struct Prepared<'a> { case: &'a Case, blocks: HashMap, } fn percentile(sorted: &[Duration], q: f64) -> Duration { let idx = ((sorted.len() as f64 * q) as usize).min(sorted.len().saturating_sub(1)); sorted[idx] } fn describe(label: &str, mut samples: Vec) { if samples.is_empty() { println!(" {label}: no samples"); return; } samples.sort_unstable(); let total: Duration = samples.iter().sum(); let mean = total / samples.len() as u32; println!( " {label:<18} p50 {:>7.1}µs p90 {:>7.1}µs p99 {:>7.1}µs max {:>8.1}µs mean {:>7.1}µs → {:>8.0}/s", percentile(&samples, 0.50).as_secs_f64() * 1e6, percentile(&samples, 0.90).as_secs_f64() * 1e6, percentile(&samples, 0.99).as_secs_f64() * 1e6, samples.last().map(|d| d.as_secs_f64()).unwrap_or(0.0) * 1e6, mean.as_secs_f64() * 1e6, 1.0 / mean.as_secs_f64(), ); } fn histogram(label: &str, values: &[usize]) { if values.is_empty() { return; } let mut sorted = values.to_vec(); sorted.sort_unstable(); let sum: usize = sorted.iter().sum(); println!( " {label:<18} min {} p50 {} p90 {} max {} mean {:.1}", sorted[0], sorted[sorted.len() / 2], sorted[sorted.len() * 9 / 10], sorted[sorted.len() - 1], sum as f64 / sorted.len() as f64, ); } pub fn run(path: &Path, rounds: usize) -> Result<(), String> { let cases = corpus::load(path).map_err(|e| format!("loading {}: {e}", path.display()))?; if cases.is_empty() { return Err(format!("{} has no cases", path.display())); } let prepared: Vec = cases .iter() .map(|case| Prepared { case, blocks: case.block_map(), }) .collect(); println!("{} commits from {}", cases.len(), path.display()); histogram( "ops/commit", &cases.iter().map(|c| c.ops.len()).collect::>(), ); histogram( "mst blocks", &cases.iter().map(|c| c.blocks.len()).collect::>(), ); histogram( "mst bytes", &cases .iter() .map(|c| c.blocks.iter().map(Vec::len).sum()) .collect::>(), ); // one untimed pass, both to warm caches and to fail loudly if the corpus // does not actually invert for p in &prepared { let got = invert_ops(&p.blocks, p.case.root, &p.case.ops, OpOrder::AsGiven) .map_err(|e| format!("corpus case does not invert: {e}"))?; if got != p.case.prev { return Err("corpus case inverts to the wrong root".into()); } } // how does it scale? bucket by operation count and by tree size, so the // slope is visible rather than inferred from one aggregate number let mut by_ops: std::collections::BTreeMap<&str, Vec> = Default::default(); let mut by_blocks: std::collections::BTreeMap<&str, Vec> = Default::default(); for _ in 0..rounds { for p in &prepared { let start = Instant::now(); let _ = invert_ops(&p.blocks, p.case.root, &p.case.ops, OpOrder::AsGiven); let took = start.elapsed(); let ops = p.case.ops.len(); by_ops .entry(match ops { 1 => "ops 1", 2..=3 => "ops 2-3", 4..=7 => "ops 4-7", _ => "ops 8+", }) .or_default() .push(took); let blocks = p.case.blocks.len(); by_blocks .entry(match blocks { 0..=5 => "blocks 1-5", 6..=9 => "blocks 6-9", 10..=14 => "blocks 10-14", _ => "blocks 15+", }) .or_default() .push(took); } } println!("\n scaling:"); for (label, samples) in by_ops.into_iter().chain(by_blocks) { let n = samples.len() / rounds.max(1); describe(&format!("{label} (n={n})"), samples); } println!(); for order in [OpOrder::AsGiven, OpOrder::HighestKeyFirst] { let mut samples = Vec::with_capacity(prepared.len() * rounds); for _ in 0..rounds { for p in &prepared { let start = Instant::now(); let got = invert_ops(&p.blocks, p.case.root, &p.case.ops, order); samples.push(start.elapsed()); if !matches!(got, Ok(root) if root == p.case.prev) { return Err(format!("case failed under {order:?}")); } } } describe(&format!("{order:?}"), samples); } Ok(()) }