diff --git a/Cargo.lock b/Cargo.lock index 856e6de..f6b972d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,6 +402,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -616,6 +629,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1292,6 +1311,19 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "infer" version = "0.2.3" @@ -1571,6 +1603,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "once_cell" version = "1.21.3" @@ -1750,6 +1788,12 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -2573,7 +2617,9 @@ name = "stellar-devkit" version = "0.1.0" dependencies = [ "axum", + "clap", "criterion", + "indicatif", "rand 0.8.5", "serde", "serde_json", @@ -3006,6 +3052,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode_categories" version = "0.1.1" @@ -3191,6 +3243,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3287,6 +3349,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/packages/devkit/src/cli/benchmark.rs b/packages/devkit/src/cli/benchmark.rs index 52cb660..67db741 100644 --- a/packages/devkit/src/cli/benchmark.rs +++ b/packages/devkit/src/cli/benchmark.rs @@ -1,4 +1,4 @@ -/// Runs benchmarks against the fee tracker pipeline. +/// Runs benchmarks against the fee tracker pipeline. pub struct Benchmark; impl Benchmark { @@ -27,7 +27,12 @@ impl Benchmark { /// Run all analysis benchmarks and print a summary table. pub fn run_all(fees: &[f64], window: usize, alpha: f64) { println!("=== Benchmark Results ==="); - println!("Input: {} data points, window={}, alpha={}", fees.len(), window, alpha); + println!( + "Input: {} data points, window={}, alpha={}", + fees.len(), + window, + alpha + ); println!(); Self::compare_spike(fees, window, alpha); } @@ -42,4 +47,4 @@ mod tests { let fees: Vec = (1..=10).map(|x| x as f64 * 100.0).collect(); Benchmark::run_all(&fees, 3, 0.3); } -} \ No newline at end of file +} diff --git a/packages/devkit/src/cli/export.rs b/packages/devkit/src/cli/export.rs index 0eedccc..a7f7815 100644 --- a/packages/devkit/src/cli/export.rs +++ b/packages/devkit/src/cli/export.rs @@ -1,9 +1,7 @@ -use crate::simulation::fee_model::FeePoint; +use crate::simulation::fee_model::FeePoint; use std::fmt::Write as FmtWrite; use std::path::{Path, PathBuf}; -use crate::simulation::fee_model::FeePoint; - /// Arguments for the `export` subcommand. pub struct ExportArgs { /// Source SQLite database path. @@ -32,7 +30,7 @@ pub enum Window { } impl Window { - pub fn from_str(s: &str) -> Option { + pub fn parse(s: &str) -> Option { match s { "1h" => Some(Self::OneHour), "6h" => Some(Self::SixHours), @@ -58,7 +56,7 @@ pub struct Export; impl Export { /// Serialize fee points to CSV. /// Filter points by window relative to the latest timestamp. - pub fn filter_window<'a>(points: &'a [FeePoint], window: Window) -> &'a [FeePoint] { + pub fn filter_window(points: &[FeePoint], window: Window) -> &[FeePoint] { match window.cutoff_seconds() { None => points, Some(secs) => { @@ -111,8 +109,18 @@ mod tests { fn pts() -> Vec { vec![ - FeePoint { timestamp: 0, fee: 100, ledger: 1, is_spike: false }, - FeePoint { timestamp: 7200, fee: 200, ledger: 2, is_spike: true }, + FeePoint { + timestamp: 0, + fee: 100, + ledger: 1, + is_spike: false, + }, + FeePoint { + timestamp: 7200, + fee: 200, + ledger: 2, + is_spike: true, + }, ] } @@ -128,8 +136,15 @@ mod tests { fn window_all_keeps_all() { let p = pts(); assert_eq!(Export::filter_window(&p, Window::All).len(), 2); + } + fn sample() -> Vec { - vec![FeePoint { timestamp: 1000, fee: 100, ledger: 1, is_spike: false }] + vec![FeePoint { + timestamp: 1000, + fee: 100, + ledger: 1, + is_spike: false, + }] } #[test] @@ -144,4 +159,4 @@ mod tests { assert!(json.starts_with('[')); assert!(json.ends_with(']')); } -} \ No newline at end of file +} diff --git a/packages/devkit/src/cli/mod.rs b/packages/devkit/src/cli/mod.rs index d8ea2a6..6e27d8b 100644 --- a/packages/devkit/src/cli/mod.rs +++ b/packages/devkit/src/cli/mod.rs @@ -2,6 +2,8 @@ pub mod benchmark; pub mod export; pub mod replay; +use clap::{Parser, Subcommand}; + /// Arguments for the `simulate` subcommand. pub struct SimulateArgs { /// Base fee floor in stroops. @@ -30,6 +32,8 @@ impl SimulateArgs { self.duration, self.base_fee, self.spike_prob ); } +} + /// Arguments for the `mock` subcommand. pub struct MockArgs { /// Scenario to load (e.g. "normal", "congested", "spike"). @@ -55,7 +59,7 @@ impl MockArgs { self.port, self.scenario ); } -use clap::{Parser, Subcommand}; +} /// Developer toolkit for the Stellar fee tracker. #[derive(Parser)] diff --git a/packages/devkit/src/cli/replay.rs b/packages/devkit/src/cli/replay.rs index ef0cd5e..122b156 100644 --- a/packages/devkit/src/cli/replay.rs +++ b/packages/devkit/src/cli/replay.rs @@ -8,6 +8,24 @@ pub struct ReplayArgs { pub db: PathBuf, /// Show a progress bar during replay. pub progress: bool, + /// Playback speed multiplier (1.0 = real-time). + pub speed: f32, + /// Start of the replay window (ISO-8601 timestamp). + pub from: Option, + /// End of the replay window (ISO-8601 timestamp). + pub to: Option, +} + +impl Default for ReplayArgs { + fn default() -> Self { + Self { + db: PathBuf::from("stellar_fees.db"), + progress: false, + speed: 1.0, + from: None, + to: None, + } + } } impl ReplayArgs { @@ -27,27 +45,10 @@ impl ReplayArgs { bar.inc(1); } bar.finish_with_message("replay complete"); -use clap::Args; - -/// Arguments for the `replay` subcommand. -#[derive(Args)] -pub struct ReplayArgs { - /// Path to the SQLite database file. - pub db: PathBuf, - /// Playback speed multiplier (1.0 = real-time). - #[arg(long, default_value = "1.0")] - pub speed: f32, - /// Start of the replay window (ISO-8601 timestamp). - #[arg(long)] - pub from: Option, - /// End of the replay window (ISO-8601 timestamp). - #[arg(long)] - pub to: Option, -} + } -impl ReplayArgs { /// Replays fee records filtered by the given time window. - pub fn run(&self) { + pub fn run_windowed(&self) { eprintln!( "Replaying from {} at {:.1}x speed, window {:?}..{:?}", self.db.display(), @@ -55,26 +56,10 @@ impl ReplayArgs { self.from, self.to ); - /// Path to the SQLite database file containing recorded fee data. - pub db: PathBuf, - /// Playback speed multiplier (1.0 = real-time, 10.0 = 10x faster). - #[arg(long, default_value = "1.0")] - pub speed: f32, -} - -impl ReplayArgs { - /// Replays fee records at the specified speed multiplier. - pub fn run(&self) { - eprintln!( - "Replaying from {} at {:.1}x speed", - self.db.display(), - self.speed - ); -} + } -impl ReplayArgs { /// Replays fee records from the database to stdout as a JSON stream. - pub fn run(&self) { + pub fn run_json(&self) { eprintln!("Replaying fee records from {}", self.db.display()); println!("[]"); } diff --git a/packages/devkit/src/harness/scenarios/congested.json b/packages/devkit/src/harness/scenarios/congested.json index 26dfea4..0a9aa7f 100644 --- a/packages/devkit/src/harness/scenarios/congested.json +++ b/packages/devkit/src/harness/scenarios/congested.json @@ -1,41 +1,41 @@ { "scenario": "congested", - "description": "High-load network: p95 > 100,000 stroops with multiple fee spikes", + "description": "High-load network: mode=50000, p50=45000, p90=90000, p95=150000, p99=250000", "fee_stats": { "last_ledger": "1001", "last_ledger_base_fee": "100", "ledger_capacity_usage": "0.97", "fee_charged": { - "max": "500000", + "max": "300000", "min": "100", - "mode": "150000", - "p10": "10000", - "p20": "25000", - "p30": "50000", - "p40": "75000", - "p50": "90000", - "p60": "100000", - "p70": "120000", - "p80": "150000", - "p90": "200000", - "p95": "350000", - "p99": "500000" + "mode": "50000", + "p10": "5000", + "p20": "15000", + "p30": "25000", + "p40": "35000", + "p50": "45000", + "p60": "60000", + "p70": "75000", + "p80": "85000", + "p90": "90000", + "p95": "150000", + "p99": "250000" }, "max_fee": { - "max": "1000000", + "max": "500000", "min": "100", - "mode": "200000", - "p10": "15000", - "p20": "40000", - "p30": "70000", - "p40": "100000", - "p50": "150000", - "p60": "200000", - "p70": "250000", - "p80": "300000", - "p90": "400000", - "p95": "600000", - "p99": "1000000" + "mode": "60000", + "p10": "8000", + "p20": "20000", + "p30": "35000", + "p40": "50000", + "p50": "65000", + "p60": "80000", + "p70": "100000", + "p80": "120000", + "p90": "150000", + "p95": "250000", + "p99": "400000" } } } diff --git a/packages/devkit/src/harness/scenarios/high_variance.json b/packages/devkit/src/harness/scenarios/high_variance.json new file mode 100644 index 0000000..7893eca --- /dev/null +++ b/packages/devkit/src/harness/scenarios/high_variance.json @@ -0,0 +1,41 @@ +{ + "scenario": "high_variance", + "description": "Wide spread: extremely mixed fee bids in a single ledger", + "fee_stats": { + "last_ledger": "1004", + "last_ledger_base_fee": "100", + "ledger_capacity_usage": "0.60", + "fee_charged": { + "max": "600000", + "min": "100", + "mode": "100", + "p10": "100", + "p20": "100", + "p30": "200", + "p40": "500", + "p50": "5000", + "p60": "15000", + "p70": "35000", + "p80": "60000", + "p90": "80000", + "p95": "200000", + "p99": "500000" + }, + "max_fee": { + "max": "700000", + "min": "100", + "mode": "100", + "p10": "100", + "p20": "100", + "p30": "300", + "p40": "1000", + "p50": "8000", + "p60": "20000", + "p70": "50000", + "p80": "90000", + "p90": "120000", + "p95": "300000", + "p99": "600000" + } + } +} diff --git a/packages/devkit/src/harness/scenarios/recovery.json b/packages/devkit/src/harness/scenarios/recovery.json index 74613e7..8abf4eb 100644 --- a/packages/devkit/src/harness/scenarios/recovery.json +++ b/packages/devkit/src/harness/scenarios/recovery.json @@ -1,6 +1,6 @@ { "scenario": "recovery", - "description": "Post-spike normalisation: fees declining from high back to baseline", + "description": "Post-spike decline: fees dropping from elevated back toward baseline", "fee_stats": { "last_ledger": "1003", "last_ledger_base_fee": "100", diff --git a/packages/devkit/src/harness/scenarios/spike.json b/packages/devkit/src/harness/scenarios/spike.json index 99baa7b..010edd9 100644 --- a/packages/devkit/src/harness/scenarios/spike.json +++ b/packages/devkit/src/harness/scenarios/spike.json @@ -1,12 +1,12 @@ { "scenario": "spike", - "description": "Sudden single spike: one ledger with fees 10x above baseline, then normal", + "description": "Single spike event: one high-fee transaction skewing the distribution", "fee_stats": { "last_ledger": "1002", "last_ledger_base_fee": "100", "ledger_capacity_usage": "0.99", "fee_charged": { - "max": "10000", + "max": "400000", "min": "100", "mode": "100", "p10": "100", @@ -18,11 +18,11 @@ "p70": "100", "p80": "100", "p90": "1000", - "p95": "5000", - "p99": "10000" + "p95": "219192", + "p99": "305883" }, "max_fee": { - "max": "10000", + "max": "500000", "min": "100", "mode": "100", "p10": "100", @@ -33,9 +33,9 @@ "p60": "100", "p70": "200", "p80": "500", - "p90": "2000", - "p95": "7000", - "p99": "10000" + "p90": "5000", + "p95": "280000", + "p99": "400000" } } } diff --git a/packages/devkit/src/test_helpers/mod.rs b/packages/devkit/src/test_helpers/mod.rs index b601a87..cd3c835 100644 --- a/packages/devkit/src/test_helpers/mod.rs +++ b/packages/devkit/src/test_helpers/mod.rs @@ -1,8 +1,10 @@ -use crate::simulation::fee_model::{FeeModel, FeeModelConfig}; -use crate::types::FeeRecord; +use crate::simulation::fee_model::{FeeModel, FeeModelConfig, FeePoint}; + +use rand::rngs::SmallRng; +use rand::{Rng, SeedableRng}; /// Returns a deterministic fee sequence of `count` records seeded by `seed`. -pub fn make_fee_sequence(count: usize, seed: u64) -> Vec { +pub fn make_fee_sequence(count: usize, seed: u64) -> Vec { let config = FeeModelConfig { seed: Some(seed), ..Default::default() @@ -11,7 +13,7 @@ pub fn make_fee_sequence(count: usize, seed: u64) -> Vec { } /// Returns a fee sequence where every record is flagged as a spike. -pub fn make_spike_sequence(count: usize) -> Vec { +pub fn make_spike_sequence(count: usize) -> Vec { let config = FeeModelConfig { spike_probability: 1.0, seed: Some(0), @@ -21,7 +23,7 @@ pub fn make_spike_sequence(count: usize) -> Vec { } /// Returns a fee sequence with no spikes (baseline load only). -pub fn make_baseline_sequence(count: usize) -> Vec { +pub fn make_baseline_sequence(count: usize) -> Vec { let config = FeeModelConfig { spike_probability: 0.0, seed: Some(1), @@ -29,10 +31,6 @@ pub fn make_baseline_sequence(count: usize) -> Vec { }; FeeModel::new(config).generate(count, 0) } -//! Test helpers: deterministic fee sequence generator and SQLite fixture builder. - -use rand::{Rng, SeedableRng}; -use rand::rngs::SmallRng; /// Generates a deterministic fee sequence from a seed for repeatable tests. pub struct FeeGenerator { @@ -42,12 +40,16 @@ pub struct FeeGenerator { impl FeeGenerator { /// Create a generator with the given seed. pub fn new(seed: u64) -> Self { - Self { rng: SmallRng::seed_from_u64(seed) } + Self { + rng: SmallRng::seed_from_u64(seed), + } } /// Generate `n` fee values in the range [min_fee, max_fee]. pub fn generate(&mut self, n: usize, min_fee: u64, max_fee: u64) -> Vec { - (0..n).map(|_| self.rng.gen_range(min_fee..=max_fee)).collect() + (0..n) + .map(|_| self.rng.gen_range(min_fee..=max_fee)) + .collect() } /// Generate a flat sequence of `n` identical fees (useful for baseline tests). @@ -58,21 +60,21 @@ impl FeeGenerator { /// A simple in-memory fee record for fixture use. #[derive(Debug, Clone, PartialEq)] -pub struct FeeRecord { +pub struct FixtureFeeRecord { pub timestamp: u64, pub fee_amount: u64, pub ledger_sequence: u64, pub tx_hash: String, } -/// Builds a vec of FeeRecord fixtures for testing. +/// Builds a vec of FixtureFeeRecord fixtures for testing. pub struct FixtureBuilder; impl FixtureBuilder { /// Build `n` sequential fee records starting at `base_timestamp`. - pub fn build(n: usize, base_timestamp: u64, base_fee: u64) -> Vec { + pub fn build(n: usize, base_timestamp: u64, base_fee: u64) -> Vec { (0..n) - .map(|i| FeeRecord { + .map(|i| FixtureFeeRecord { timestamp: base_timestamp + i as u64, fee_amount: base_fee, ledger_sequence: 1000 + i as u64, @@ -97,6 +99,9 @@ mod tests { let records = FixtureBuilder::build(3, 1000, 100); assert_eq!(records[0].timestamp, 1000); assert_eq!(records[2].timestamp, 1002); + } + + #[test] fn same_seed_produces_same_sequence() { let a = FeeGenerator::new(42).generate(10, 100, 1000); let b = FeeGenerator::new(42).generate(10, 100, 1000); diff --git a/packages/devkit/tests/cli_export.rs b/packages/devkit/tests/cli_export.rs index 4b10b94..22c8c6b 100644 --- a/packages/devkit/tests/cli_export.rs +++ b/packages/devkit/tests/cli_export.rs @@ -3,7 +3,10 @@ use stellar_devkit::simulation::fee_model::{FeeModel, FeeModelConfig}; #[test] fn export_csv_has_correct_header() { - let config = FeeModelConfig { seed: Some(1), ..Default::default() }; + let config = FeeModelConfig { + seed: Some(1), + ..Default::default() + }; let mut model = FeeModel::new(config); let points = model.generate(5, 0); let csv = Export::to_csv(&points); @@ -15,7 +18,10 @@ fn export_csv_has_correct_header() { #[test] fn export_csv_row_count_matches_input() { - let config = FeeModelConfig { seed: Some(2), ..Default::default() }; + let config = FeeModelConfig { + seed: Some(2), + ..Default::default() + }; let mut model = FeeModel::new(config); let points = model.generate(10, 0); let csv = Export::to_csv(&points); @@ -30,7 +36,10 @@ fn export_csv_row_count_matches_input() { #[test] fn export_csv_columns_are_parseable() { - let config = FeeModelConfig { seed: Some(3), ..Default::default() }; + let config = FeeModelConfig { + seed: Some(3), + ..Default::default() + }; let mut model = FeeModel::new(config); let points = model.generate(1, 1_000); let csv = Export::to_csv(&points); diff --git a/packages/devkit/tests/cli_replay.rs b/packages/devkit/tests/cli_replay.rs index efa1599..17a57df 100644 --- a/packages/devkit/tests/cli_replay.rs +++ b/packages/devkit/tests/cli_replay.rs @@ -2,7 +2,10 @@ use stellar_devkit::simulation::fee_model::{FeeModel, FeeModelConfig}; #[test] fn replay_generates_expected_record_count() { - let config = FeeModelConfig { seed: Some(42), ..Default::default() }; + let config = FeeModelConfig { + seed: Some(42), + ..Default::default() + }; let mut model = FeeModel::new(config); let records = model.generate(100, 0); assert_eq!( @@ -35,7 +38,10 @@ fn replay_records_have_sequential_timestamps() { #[test] fn replay_is_deterministic_with_same_seed() { let make = || { - let cfg = FeeModelConfig { seed: Some(77), ..Default::default() }; + let cfg = FeeModelConfig { + seed: Some(77), + ..Default::default() + }; FeeModel::new(cfg).generate(100, 0) }; let a = make(); diff --git a/packages/devkit/tests/percentile_prop.rs b/packages/devkit/tests/percentile_prop.rs index 83e981d..bb50a4a 100644 --- a/packages/devkit/tests/percentile_prop.rs +++ b/packages/devkit/tests/percentile_prop.rs @@ -1,4 +1,4 @@ -//! Property-style tests for percentile functions. +//! Property-style tests for percentile functions. //! Verifies invariants on arbitrary fee sequences without external proptest crate. use stellar_devkit::analysis::percentile::Percentile; @@ -49,4 +49,4 @@ fn interpolation_p50_of_two_elements_is_midpoint() { let mid = Percentile::linear_interpolation(&data, 50); assert_eq!(mid, (a + b) / 2); } -} \ No newline at end of file +} diff --git a/packages/devkit/tests/spike_prop.rs b/packages/devkit/tests/spike_prop.rs index 7d306c3..494b9b2 100644 --- a/packages/devkit/tests/spike_prop.rs +++ b/packages/devkit/tests/spike_prop.rs @@ -1,4 +1,4 @@ -//! Property-style tests for spike classifier — no false positives on flat sequences. +//! Property-style tests for spike classifier — no false positives on flat sequences. use stellar_devkit::analysis::spike_classifier::SpikeClassifier; @@ -45,4 +45,4 @@ fn spike_detected_when_fee_exceeds_threshold() { let fees = vec![100u64, 200, 100]; let events = SpikeClassifier::detect(&fees, 100); assert!(!events.is_empty()); -} \ No newline at end of file +}