From 360f8183ea88847f24471e769a3f9e897d4ddc3d Mon Sep 17 00:00:00 2001 From: abdulrcrtw Date: Thu, 25 Jun 2026 15:10:07 +0100 Subject: [PATCH] feat(simulation): add CSV export, capacity pressure tests, and update benchmarks - Add export_csv function to serialise FeePoint sequences (#257) - Create integration tests for capacity pressure monotonicity (#254) - Update congestion benchmark to 1M inputs (#256) - Verify fee generation benchmark handles 100k points (#255) Closes #254 Closes #255 Closes #256 Closes #257 --- packages/devkit/benches/congestion_bench.rs | 16 +-- packages/devkit/src/simulation/fee_model.rs | 29 ++++ packages/devkit/tests/network_load.rs | 142 ++++++++++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 packages/devkit/tests/network_load.rs diff --git a/packages/devkit/benches/congestion_bench.rs b/packages/devkit/benches/congestion_bench.rs index a93a356..34deb1c 100644 --- a/packages/devkit/benches/congestion_bench.rs +++ b/packages/devkit/benches/congestion_bench.rs @@ -6,21 +6,21 @@ use stellar_devkit::simulation::congestion_predictor::{ fn bench_predict(c: &mut Criterion) { let mut group = c.benchmark_group("congestion_predictor"); - group.bench_function("predict_1000_calls", |b| { + group.bench_function("predict_1M", |b| { b.iter(|| { - for i in 0..1_000u64 { - let _ = CongestionPredictor::predict(i % 1000, 100 + i * 10); + for i in 0..1_000_000u64 { + let _ = CongestionPredictor::predict(i % 1000, 100 + (i * 10) % 10_000); } }) }); - group.bench_function("congestion_score_1000_calls", |b| { + group.bench_function("congestion_score_1M", |b| { b.iter(|| { - for i in 0..1_000u64 { + for i in 0..1_000_000u64 { let input = CongestionInput { - recent_fee_window: 100.0 + i as f64, - capacity_usage: (i % 100) as f64 / 100.0, - spike_count: (i % 10) as u32, + recent_fee_window: 100.0 + (i % 10_000) as f64, + capacity_usage: (i % 101) as f64 / 100.0, + spike_count: (i % 11) as u32, }; let _ = congestion_score(&input); } diff --git a/packages/devkit/src/simulation/fee_model.rs b/packages/devkit/src/simulation/fee_model.rs index cbb7c32..646a2c9 100644 --- a/packages/devkit/src/simulation/fee_model.rs +++ b/packages/devkit/src/simulation/fee_model.rs @@ -403,6 +403,35 @@ pub fn apply_timestamps(points: &mut [FeePoint], epoch_ms: u64, interval_ms: u64 } } +/// Serialise a slice of `FeePoint`s to a CSV string. +/// +/// The output contains a header row followed by one row per point: +/// `timestamp,fee_amount,ledger_sequence,is_spike` +/// +/// # Examples +/// +/// ``` +/// use stellar_devkit::simulation::fee_model::{FeePoint, export_csv}; +/// +/// let points = vec![ +/// FeePoint { timestamp: 0, fee: 100, ledger: 1, is_spike: false }, +/// FeePoint { timestamp: 5, fee: 500, ledger: 2, is_spike: true }, +/// ]; +/// let csv = export_csv(&points); +/// assert!(csv.starts_with("timestamp,fee_amount,ledger_sequence,is_spike")); +/// assert!(csv.contains("0,100,1,false")); +/// assert!(csv.contains("5,500,2,true")); +/// ``` +pub fn export_csv(points: &[FeePoint]) -> String { + let mut buf = String::with_capacity(points.len() * 48); + buf.push_str("timestamp,fee_amount,ledger_sequence,is_spike\n"); + for p in points { + use std::fmt::Write; + let _ = writeln!(buf, "{},{},{},{}", p.timestamp, p.fee, p.ledger, p.is_spike); + } + buf +} + /// A fee curve snapshot matching the Horizon `fee_stats` shape. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FeeCurve { diff --git a/packages/devkit/tests/network_load.rs b/packages/devkit/tests/network_load.rs new file mode 100644 index 0000000..8a8f402 --- /dev/null +++ b/packages/devkit/tests/network_load.rs @@ -0,0 +1,142 @@ +use stellar_devkit::simulation::fee_model::FeeCurve; +use stellar_devkit::simulation::network_load::{NetworkLoad, NetworkLoadConfig}; + +/// Assert that the fee curve's 50th percentile increases monotonically as +/// capacity_usage approaches 1.0. +#[test] +fn fee_percentiles_increase_with_pressure() { + let base_fee = 100; + let max_fee = 10_000; + let pressures = [0.0, 0.25, 0.5, 0.75, 1.0]; + let mut prev_p50 = 0u64; + for &pressure in &pressures { + let curve = FeeCurve::from_pressure(base_fee, max_fee, pressure, 1); + let p50: u64 = curve.fee_charged.p50.parse().unwrap(); + assert!( + p50 >= prev_p50, + "p50 decreased from {prev_p50} to {p50} at pressure {pressure}" + ); + prev_p50 = p50; + } +} + +/// Assert p99 also increases monotonically with pressure. +#[test] +fn fee_p99_increases_with_pressure() { + let base_fee = 100; + let max_fee = 10_000; + let pressures = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]; + let mut prev_p99 = 0u64; + for &pressure in &pressures { + let curve = FeeCurve::from_pressure(base_fee, max_fee, pressure, 1); + let p99: u64 = curve.fee_charged.p99.parse().unwrap(); + assert!( + p99 >= prev_p99, + "p99 decreased from {prev_p99} to {p99} at pressure {pressure}" + ); + prev_p99 = p99; + } +} + +/// Assert that every percentile in the fee curve is monotonic w.r.t. pressure. +#[test] +fn all_percentiles_monotonic_with_pressure() { + let base_fee = 100; + let max_fee = 10_000; + let pressures = [0.0, 0.3, 0.6, 1.0]; + + let mut prev: Vec = Vec::new(); + for &pressure in &pressures { + let curve = FeeCurve::from_pressure(base_fee, max_fee, pressure, 1); + let pct_values: Vec = [ + &curve.fee_charged.min, + &curve.fee_charged.p10, + &curve.fee_charged.p20, + &curve.fee_charged.p30, + &curve.fee_charged.p40, + &curve.fee_charged.p50, + &curve.fee_charged.p60, + &curve.fee_charged.p70, + &curve.fee_charged.p80, + &curve.fee_charged.p90, + &curve.fee_charged.p95, + &curve.fee_charged.p99, + &curve.fee_charged.max, + ] + .iter() + .map(|s| s.parse::().unwrap()) + .collect(); + + if !prev.is_empty() { + for (i, (&cur, &prev_val)) in pct_values.iter().zip(prev.iter()).enumerate() { + assert!( + cur >= prev_val, + "percentile at index {i} decreased from {prev_val} to {cur} when pressure moved to {pressure}" + ); + } + } + prev = pct_values; + } +} + +/// Assert that avg_pressure increases when max_tx is increased. +#[test] +fn avg_pressure_increases_with_max_tx() { + let low = NetworkLoadConfig { + min_tx: 10, + max_tx: 100, + ..Default::default() + }; + let high = NetworkLoadConfig { + min_tx: 10, + max_tx: 900, + ..Default::default() + }; + assert!( + high.avg_pressure() > low.avg_pressure(), + "avg_pressure should increase with max_tx" + ); +} + +/// Assert that simulated ledger pressure is monotonic in tx_count. +#[test] +fn simulated_ledger_pressure_increases_with_tx_count() { + let config = NetworkLoadConfig { + min_tx: 1, + max_tx: 1000, + ledger_capacity: 1000, + seed: Some(42), + ..Default::default() + }; + let mut sim = NetworkLoad::new(config); + let ledgers = sim.simulate(100); + + for window in ledgers.windows(2) { + // pressure must be monotonic in tx_count (same ledger capacity) + if window[1].tx_count > window[0].tx_count { + assert!( + window[1].pressure >= window[0].pressure, + "pressure decreased when tx_count increased" + ); + } + } +} + +/// Assert every ledger pressure is within [0.0, 1.0]. +#[test] +fn simulated_pressure_in_bounds() { + let config = NetworkLoadConfig { + seed: Some(7), + ..Default::default() + }; + let mut sim = NetworkLoad::new(config); + let ledgers = sim.simulate(200); + for l in &ledgers { + assert!( + (0.0..=1.0).contains(&l.pressure), + "pressure {} out of [0,1] at ledger {}", + l.pressure, + l.ledger_seq + ); + } +}