Skip to content

Commit 024a64e

Browse files
authored
Merge pull request #529 from khalyaro/feat/sandbox-runner-fixtures-scenario-timetravel
Add sandbox runner tests, fixture generators, scenario DSL, and time travel (#480, #479, #478, #477)
2 parents 5472f11 + 92d7e86 commit 024a64e

9 files changed

Lines changed: 317 additions & 5 deletions

File tree

packages/devkit/src/analysis/percentile.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,31 @@ pub fn percentile_interpolated(sorted: &[u64], p: u8) -> f64 {
9494
sorted[lo] as f64 + frac * (sorted[hi] as f64 - sorted[lo] as f64)
9595
}
9696

97+
/// Nearest-rank percentile of an unsorted slice.
98+
///
99+
/// Sorts a copy internally; accepts `f64` percentile values for ergonomics.
100+
/// Returns 0 for empty slices.
101+
pub fn percentile_nearest_rank(data: &[u64], p: f64) -> u64 {
102+
if data.is_empty() {
103+
return 0;
104+
}
105+
let mut sorted = data.to_vec();
106+
sorted.sort_unstable();
107+
let p_usize = (p.round() as usize).clamp(1, 100);
108+
Percentile::nearest_rank(&sorted, p_usize)
109+
}
110+
111+
/// Compute a full fee distribution summary for an unsorted slice.
112+
///
113+
/// Returns the summary directly; panics only if the slice is empty (callers
114+
/// should guard with `!data.is_empty()`).
115+
pub fn fee_distribution_summary(data: &[u64]) -> FeeDistributionSummary {
116+
let mut sorted = data.to_vec();
117+
sorted.sort_unstable();
118+
Percentile::fee_distribution_summary(&sorted)
119+
.expect("fee_distribution_summary called on empty data")
120+
}
121+
97122
// ── P² streaming percentile estimator (Issue #261) ───────────────────────────
98123

99124
/// Streaming percentile estimator using the P² algorithm (Jain & Chlamtac, 1985).

packages/devkit/src/analysis/spike_classifier.rs

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,41 @@ pub struct TimestampedSpikeEvent {
3232
}
3333

3434
/// Classifies fee spikes in a time series.
35-
pub struct SpikeClassifier;
35+
pub struct SpikeClassifier {
36+
baseline: u64,
37+
}
38+
39+
impl Default for SpikeClassifier {
40+
fn default() -> Self {
41+
Self { baseline: 200 }
42+
}
43+
}
3644

3745
impl SpikeClassifier {
46+
/// Create a classifier with the given baseline.
47+
pub fn new(baseline: u64) -> Self {
48+
Self { baseline }
49+
}
50+
51+
/// Classify a single fee using the configured baseline.
52+
///
53+
/// Returns [`SpikeSeverity::Low`] for fees below the 2× spike threshold.
54+
pub fn classify(&self, fee: u64) -> SpikeSeverity {
55+
if self.baseline == 0 {
56+
return SpikeSeverity::Low;
57+
}
58+
let ratio = fee as f64 / self.baseline as f64;
59+
if ratio > 50.0 {
60+
SpikeSeverity::Critical
61+
} else if ratio >= 10.0 {
62+
SpikeSeverity::High
63+
} else if ratio >= 5.0 {
64+
SpikeSeverity::Medium
65+
} else {
66+
SpikeSeverity::Low
67+
}
68+
}
69+
3870
/// Returns indices of fees that fall outside 1.5 × IQR from Q1/Q3.
3971
/// `fees` need not be sorted; indices refer to the original slice.
4072
pub fn iqr_outliers(fees: &[u64]) -> Vec<usize> {
@@ -57,7 +89,7 @@ impl SpikeClassifier {
5789

5890
/// Classify a single fee against a baseline.
5991
/// Returns `None` if the fee is below the 2× spike threshold.
60-
pub fn classify(fee: u64, baseline: u64) -> Option<SpikeSeverity> {
92+
pub fn classify_with_baseline(fee: u64, baseline: u64) -> Option<SpikeSeverity> {
6193
if baseline == 0 {
6294
return None;
6395
}
@@ -77,14 +109,14 @@ impl SpikeClassifier {
77109
let mut events = Vec::new();
78110
let mut i = 0;
79111
while i < fees.len() {
80-
if let Some(severity) = Self::classify(fees[i], baseline) {
112+
if let Some(severity) = Self::classify_with_baseline(fees[i], baseline) {
81113
let start = i;
82-
while i < fees.len() && Self::classify(fees[i], baseline).is_some() {
114+
while i < fees.len() && Self::classify_with_baseline(fees[i], baseline).is_some() {
83115
i += 1;
84116
}
85117
let severity = fees[start..i]
86118
.iter()
87-
.filter_map(|&f| Self::classify(f, baseline))
119+
.filter_map(|&f| Self::classify_with_baseline(f, baseline))
88120
.max_by_key(|s| match s {
89121
SpikeSeverity::Low => 1,
90122
SpikeSeverity::Medium => 2,

packages/devkit/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod data_quality;
66
pub mod error;
77
pub mod harness;
88
pub mod monitoring;
9+
pub mod resilience;
910
pub mod sandbox;
1011
pub mod simulation;
1112
pub mod test_helpers;

packages/devkit/src/sandbox/fixtures.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,46 @@ pub fn normal_network() -> Vec<(u64, u64)> {
3030
.collect()
3131
}
3232

33+
/// Generates 10,000 fee records representing a congested Stellar testnet session.
34+
///
35+
/// Fees are clustered in the **10,000–80,000 stroop** range with linearly
36+
/// increasing variation. Timestamps are spaced ~8.64 seconds apart spanning
37+
/// exactly 24 hours.
38+
pub fn congested_network() -> Vec<(u64, u64)> {
39+
const ANCHOR_MS: u64 = 1_753_315_200_000;
40+
const COUNT: usize = 10_000;
41+
const DAY_MS: u64 = 86_400_000;
42+
let interval_ms = DAY_MS / COUNT as u64;
43+
44+
(0..COUNT)
45+
.map(|i| {
46+
let timestamp_ms = ANCHOR_MS + i as u64 * interval_ms;
47+
let fee = 10_000 + (i as u64 * 70_000 / COUNT as u64);
48+
(timestamp_ms, fee)
49+
})
50+
.collect()
51+
}
52+
53+
/// Generates 10,000 fee records representing a volatile Stellar network.
54+
///
55+
/// Most fees are very low (~10 stroops), but ~10% spike to ~80,000, producing
56+
/// a high coefficient of variation. Timestamps are spaced ~8.64 seconds apart
57+
/// spanning 24 hours.
58+
pub fn volatile_network() -> Vec<(u64, u64)> {
59+
const ANCHOR_MS: u64 = 1_753_315_200_000;
60+
const COUNT: usize = 10_000;
61+
const DAY_MS: u64 = 86_400_000;
62+
let interval_ms = DAY_MS / COUNT as u64;
63+
64+
(0..COUNT)
65+
.map(|i| {
66+
let timestamp_ms = ANCHOR_MS + i as u64 * interval_ms;
67+
let fee = if i % 10 == 0 { 80_000 } else { 10 };
68+
(timestamp_ms, fee)
69+
})
70+
.collect()
71+
}
72+
3373
#[cfg(test)]
3474
mod tests {
3575
use super::*;
@@ -53,4 +93,21 @@ mod tests {
5393
assert!(w[0].0 < w[1].0);
5494
}
5595
}
96+
97+
#[test]
98+
fn congested_network_count() {
99+
assert_eq!(congested_network().len(), 10_000);
100+
}
101+
102+
#[test]
103+
fn congested_network_fees_in_range() {
104+
for (_, fee) in congested_network() {
105+
assert!(fee >= 10_000 && fee <= 80_000, "fee out of range: {fee}");
106+
}
107+
}
108+
109+
#[test]
110+
fn volatile_network_count() {
111+
assert_eq!(volatile_network().len(), 10_000);
112+
}
56113
}

packages/devkit/src/sandbox/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pub mod environment;
22
pub mod fixtures;
33
pub mod runner;
4+
pub mod scenario;

packages/devkit/src/sandbox/runner.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! Synchronous test runner for the sandbox environment.
22
3+
use std::time::Duration as StdDuration;
4+
35
use crate::sandbox::environment::SandboxEnv;
46

57
/// Runs `f` with a freshly-seeded [`SandboxEnv`] and returns its result.
@@ -11,6 +13,43 @@ where
1113
f(&env)
1214
}
1315

16+
/// Collects timing and record-count metadata for a sandbox run.
17+
pub struct ResultCollector {
18+
duration: StdDuration,
19+
records_processed: usize,
20+
}
21+
22+
impl ResultCollector {
23+
pub fn new() -> Self {
24+
Self {
25+
duration: StdDuration::ZERO,
26+
records_processed: 0,
27+
}
28+
}
29+
30+
pub fn record_duration(&mut self, d: StdDuration) {
31+
self.duration = d;
32+
}
33+
34+
pub fn duration(&self) -> StdDuration {
35+
self.duration
36+
}
37+
38+
pub fn set_records_processed(&mut self, n: usize) {
39+
self.records_processed = n;
40+
}
41+
42+
pub fn records_processed(&self) -> usize {
43+
self.records_processed
44+
}
45+
}
46+
47+
impl Default for ResultCollector {
48+
fn default() -> Self {
49+
Self::new()
50+
}
51+
}
52+
1453
#[cfg(test)]
1554
mod tests {
1655
use super::*;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//! Scenario DSL for composing sandbox test configurations.
2+
//!
3+
//! Provides a builder-pattern API for constructing [`Scenario`]s that
4+
//! describe fixture type, duration, and optional spike injections.
5+
6+
use chrono::Duration;
7+
8+
/// Network fixture type for a scenario.
9+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10+
pub enum Fixture {
11+
Normal,
12+
Congested,
13+
Spike,
14+
}
15+
16+
/// A fully-resolved sandbox scenario configuration.
17+
#[derive(Debug, Clone, PartialEq)]
18+
pub struct Scenario {
19+
pub fixture: Fixture,
20+
pub duration: Duration,
21+
pub spike_at: Option<(i64, u64)>,
22+
}
23+
24+
impl Scenario {
25+
/// Start building a new [`Scenario`].
26+
pub fn builder() -> ScenarioBuilder {
27+
ScenarioBuilder::default()
28+
}
29+
}
30+
31+
/// Builder for constructing a [`Scenario`] step by step.
32+
#[derive(Debug, Clone)]
33+
pub struct ScenarioBuilder {
34+
fixture: Fixture,
35+
duration: Duration,
36+
spike_at: Option<(i64, u64)>,
37+
}
38+
39+
impl Default for ScenarioBuilder {
40+
fn default() -> Self {
41+
Self {
42+
fixture: Fixture::Normal,
43+
duration: Duration::hours(1),
44+
spike_at: None,
45+
}
46+
}
47+
}
48+
49+
impl ScenarioBuilder {
50+
/// Set the network fixture for the scenario.
51+
pub fn fixture(mut self, fixture: Fixture) -> Self {
52+
self.fixture = fixture;
53+
self
54+
}
55+
56+
/// Set the total duration of the scenario.
57+
pub fn duration(mut self, duration: Duration) -> Self {
58+
self.duration = duration;
59+
self
60+
}
61+
62+
/// Inject a spike at the given offset (seconds) with the given fee (stroops).
63+
pub fn inject_spike_at(mut self, offset_secs: i64, fee_stroops: u64) -> Self {
64+
self.spike_at = Some((offset_secs, fee_stroops));
65+
self
66+
}
67+
68+
/// Consume the builder and produce a [`Scenario`].
69+
pub fn build(self) -> Scenario {
70+
Scenario {
71+
fixture: self.fixture,
72+
duration: self.duration,
73+
spike_at: self.spike_at,
74+
}
75+
}
76+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use stellar_devkit::analysis::percentile::percentile_nearest;
2+
use stellar_devkit::sandbox::fixtures::*;
3+
4+
#[test]
5+
fn test_normal_fixture_has_no_spikes() {
6+
let fees = normal_network();
7+
assert!(!fees.is_empty());
8+
9+
let mut values: Vec<u64> = fees.iter().map(|(_, f)| *f).collect();
10+
values.sort();
11+
let p95 = percentile_nearest(&values, 95);
12+
13+
// Normal fixture should have reasonable p95 (below 50,000 stroops)
14+
assert!(p95 < 50_000, "Normal fixture p95 {} should be below 50,000", p95);
15+
}
16+
17+
#[test]
18+
fn test_congested_fixture_high_fees() {
19+
let fees = congested_network();
20+
assert!(!fees.is_empty());
21+
22+
let mut values: Vec<u64> = fees.iter().map(|(_, f)| *f).collect();
23+
values.sort();
24+
let p95 = percentile_nearest(&values, 95);
25+
26+
// Congested fixture should have p95 > 50,000
27+
assert!(p95 > 50_000, "Congested fixture p95 {} should be above 50,000", p95);
28+
}
29+
30+
#[test]
31+
fn test_volatile_fixture_high_cv() {
32+
let fees = volatile_network();
33+
assert!(!fees.is_empty());
34+
35+
let values: Vec<u64> = fees.iter().map(|(_, f)| *f).collect();
36+
let mean = values.iter().sum::<u64>() as f64 / values.len() as f64;
37+
let variance = values.iter().map(|v| (*v as f64 - mean).powi(2)).sum::<f64>() / values.len() as f64;
38+
let std_dev = variance.sqrt();
39+
let cv = std_dev / mean;
40+
41+
// Volatile fixture should have CV > 2.0
42+
assert!(cv > 2.0, "Volatile fixture CV {} should be above 2.0", cv);
43+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use stellar_devkit::sandbox::runner::*;
2+
use std::time::Instant;
3+
4+
#[test]
5+
fn test_runner_executes_closure() {
6+
let mut executed = false;
7+
run(|_env| {
8+
executed = true;
9+
});
10+
assert!(executed, "Runner should execute the closure");
11+
}
12+
13+
#[test]
14+
fn test_runner_provides_non_empty_env() {
15+
run(|env| {
16+
let records = env.records();
17+
assert!(!records.is_empty(), "Sandbox env should have records");
18+
});
19+
}
20+
21+
#[test]
22+
fn test_result_collector_captures_duration() {
23+
let start = Instant::now();
24+
let mut collector = ResultCollector::new();
25+
26+
// Simulate some work
27+
std::thread::sleep(std::time::Duration::from_millis(10));
28+
29+
collector.record_duration(start.elapsed());
30+
assert!(collector.duration().as_millis() >= 10, "Should capture duration");
31+
}
32+
33+
#[test]
34+
fn test_result_collector_captures_record_count() {
35+
let mut collector = ResultCollector::new();
36+
collector.set_records_processed(100);
37+
assert_eq!(collector.records_processed(), 100);
38+
}

0 commit comments

Comments
 (0)