Skip to content

Commit 88d8d1d

Browse files
Eric Priceclaude
andcommitted
data(vardiff_sim): design doc + baseline characterization of VardiffState
- VARDIFF_SIMULATION_FRAMEWORK.md: design proposal documenting the framework's five metrics, assertion policy, simulation mechanism, and architectural rationale. Co-located with the crate it describes. - vardiff_baseline.toml: machine-readable baseline measurements of the classic VardiffState algorithm across the default 50-cell grid (5 share rates × 10 scenarios, 1000 trials each, base seed 0xDEADBEEFCAFEF00D). Consumed by the regression test in the sim crate. - vardiff_baseline.md: human-readable summary of the same data, organized by metric type for PR review. Notable findings surfaced by the baseline: - Convergence: solid across rates (100% at 30+ spm, 95% at 12 spm, 83% at 6 spm). p50 is ~10 minutes everywhere, dominated by the Phase 1 ×3/min ramp clamp. - Settled accuracy: follows 1/sqrt(N) cleanly. p99 error is 70% at 6 spm, 27% at 12, 15% at 30, 3% at 60, 0% at 120. Low-rate operation is statistically threadbare. - Steady-state jitter: small everywhere and ~0 above 30 spm. The algorithm's growing delta_time post-convergence narrows the effective noise band as 1/sqrt(N), producing accidental self- stabilization at high rates. - Reaction sensitivity DEGRADES with share rate — counterintuitive but mechanistic. The same property that produces low jitter at high rates (growing delta_time after a Phase 1 fire) produces sluggish response to step changes (post-step shares diluted by long pre-step history). At 60+ spm only 9-16% of trials react to a 50% drop within 5 minutes. This baseline is the reference point for evaluating any future algorithmic proposal. The regression test in the sim crate asserts each metric is within tolerance of these recorded values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6151a94 commit 88d8d1d

11 files changed

Lines changed: 1852 additions & 55 deletions

File tree

sv2/channels-sv2/sim/VARDIFF_SIMULATION_FRAMEWORK.md

Lines changed: 459 additions & 0 deletions
Large diffs are not rendered by default.

sv2/channels-sv2/sim/src/baseline.rs

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,11 @@ impl Cell {
140140
/// Stable, machine-readable identifier suitable for use as a key in the
141141
/// TOML output, in the form `spm_<RATE>.<scenario_key>`.
142142
pub fn key(&self) -> String {
143-
format!("spm_{}.{}", self.shares_per_minute as u32, self.scenario.key())
143+
format!(
144+
"spm_{}.{}",
145+
self.shares_per_minute as u32,
146+
self.scenario.key()
147+
)
144148
}
145149
}
146150

@@ -181,12 +185,7 @@ pub struct CellResult {
181185

182186
/// Runs a single cell: builds the scenario, runs `trial_count` trials with
183187
/// deterministic seeds, computes metric distributions, returns a `CellResult`.
184-
pub fn run_cell(
185-
cell: &Cell,
186-
trial_count: usize,
187-
base_seed: u64,
188-
cell_index: u64,
189-
) -> CellResult {
188+
pub fn run_cell(cell: &Cell, trial_count: usize, base_seed: u64, cell_index: u64) -> CellResult {
190189
let (config, schedule) = cell.scenario.build(cell.shares_per_minute);
191190

192191
let mut trials: Vec<Trial> = Vec::with_capacity(trial_count);
@@ -335,16 +334,56 @@ pub fn serialize_toml(
335334
));
336335
out.push_str(&format!("scenario = \"{}\"\n", result.scenario_key));
337336
out.push_str(&format!("convergence_rate = {}\n", result.convergence_rate));
338-
write_opt(&mut out, "convergence_p10_secs", result.convergence_p10_secs);
339-
write_opt(&mut out, "convergence_p50_secs", result.convergence_p50_secs);
340-
write_opt(&mut out, "convergence_p90_secs", result.convergence_p90_secs);
341-
write_opt(&mut out, "convergence_p95_secs", result.convergence_p95_secs);
342-
write_opt(&mut out, "convergence_p99_secs", result.convergence_p99_secs);
343-
write_opt(&mut out, "settled_accuracy_p10", result.settled_accuracy_p10);
344-
write_opt(&mut out, "settled_accuracy_p50", result.settled_accuracy_p50);
345-
write_opt(&mut out, "settled_accuracy_p90", result.settled_accuracy_p90);
346-
write_opt(&mut out, "settled_accuracy_p95", result.settled_accuracy_p95);
347-
write_opt(&mut out, "settled_accuracy_p99", result.settled_accuracy_p99);
337+
write_opt(
338+
&mut out,
339+
"convergence_p10_secs",
340+
result.convergence_p10_secs,
341+
);
342+
write_opt(
343+
&mut out,
344+
"convergence_p50_secs",
345+
result.convergence_p50_secs,
346+
);
347+
write_opt(
348+
&mut out,
349+
"convergence_p90_secs",
350+
result.convergence_p90_secs,
351+
);
352+
write_opt(
353+
&mut out,
354+
"convergence_p95_secs",
355+
result.convergence_p95_secs,
356+
);
357+
write_opt(
358+
&mut out,
359+
"convergence_p99_secs",
360+
result.convergence_p99_secs,
361+
);
362+
write_opt(
363+
&mut out,
364+
"settled_accuracy_p10",
365+
result.settled_accuracy_p10,
366+
);
367+
write_opt(
368+
&mut out,
369+
"settled_accuracy_p50",
370+
result.settled_accuracy_p50,
371+
);
372+
write_opt(
373+
&mut out,
374+
"settled_accuracy_p90",
375+
result.settled_accuracy_p90,
376+
);
377+
write_opt(
378+
&mut out,
379+
"settled_accuracy_p95",
380+
result.settled_accuracy_p95,
381+
);
382+
write_opt(
383+
&mut out,
384+
"settled_accuracy_p99",
385+
result.settled_accuracy_p99,
386+
);
348387
write_opt(&mut out, "jitter_p50_per_min", result.jitter_p50_per_min);
349388
write_opt(&mut out, "jitter_p90_per_min", result.jitter_p90_per_min);
350389
write_opt(&mut out, "jitter_p95_per_min", result.jitter_p95_per_min);
@@ -513,16 +552,17 @@ pub fn serialize_markdown(
513552
}
514553

515554
fn unique_rates(results: &[CellResult]) -> Vec<u32> {
516-
let mut rates: Vec<u32> = results
517-
.iter()
518-
.map(|r| r.shares_per_minute as u32)
519-
.collect();
555+
let mut rates: Vec<u32> = results.iter().map(|r| r.shares_per_minute as u32).collect();
520556
rates.sort_unstable();
521557
rates.dedup();
522558
rates
523559
}
524560

525-
fn find_cell<'a>(results: &'a [CellResult], spm: u32, scenario_key: &str) -> Option<&'a CellResult> {
561+
fn find_cell<'a>(
562+
results: &'a [CellResult],
563+
spm: u32,
564+
scenario_key: &str,
565+
) -> Option<&'a CellResult> {
526566
results
527567
.iter()
528568
.find(|r| r.shares_per_minute as u32 == spm && r.scenario_key == scenario_key)

sv2/channels-sv2/sim/src/bin/generate-baseline.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ use std::path::PathBuf;
3232
use std::time::Instant;
3333

3434
use vardiff_sim::baseline::{
35-
default_cells, run_baseline, serialize_markdown, serialize_toml,
36-
DEFAULT_BASELINE_SEED, DEFAULT_TRIAL_COUNT,
35+
default_cells, run_baseline, serialize_markdown, serialize_toml, DEFAULT_BASELINE_SEED,
36+
DEFAULT_TRIAL_COUNT,
3737
};
3838

3939
fn main() -> std::io::Result<()> {

sv2/channels-sv2/sim/src/metrics.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,10 @@ pub fn settled_accuracy_for_trial(trial: &Trial) -> Option<f64> {
193193
/// Distribution of settled accuracy errors across a set of trials. Trials with
194194
/// non-positive true hashrate are silently dropped.
195195
pub fn settled_accuracy_distribution(trials: &[Trial]) -> Distribution {
196-
let values: Vec<f64> = trials.iter().filter_map(settled_accuracy_for_trial).collect();
196+
let values: Vec<f64> = trials
197+
.iter()
198+
.filter_map(settled_accuracy_for_trial)
199+
.collect();
197200
Distribution::new(values)
198201
}
199202

@@ -247,7 +250,14 @@ pub fn jitter_distribution(
247250
) -> Distribution {
248251
let values: Vec<f64> = trials
249252
.iter()
250-
.filter_map(|t| jitter_for_trial(t, quiet_window_secs, settle_buffer_secs, min_settled_window_secs))
253+
.filter_map(|t| {
254+
jitter_for_trial(
255+
t,
256+
quiet_window_secs,
257+
settle_buffer_secs,
258+
min_settled_window_secs,
259+
)
260+
})
251261
.collect();
252262
Distribution::new(values)
253263
}
@@ -270,8 +280,10 @@ pub fn reaction_time_for_trial(
270280
react_window_secs: u64,
271281
) -> Option<u64> {
272282
let window_end = event_at_secs.saturating_add(react_window_secs);
273-
let first_post_event_fire: Option<&FireEvent> =
274-
trial.fires.iter().find(|f| f.at_secs > event_at_secs && f.at_secs <= window_end);
283+
let first_post_event_fire: Option<&FireEvent> = trial
284+
.fires
285+
.iter()
286+
.find(|f| f.at_secs > event_at_secs && f.at_secs <= window_end);
275287
first_post_event_fire.map(|f| f.at_secs - event_at_secs)
276288
}
277289

@@ -311,11 +323,7 @@ pub fn reaction_time_distribution(
311323
/// Identical to the `reaction_rate` component of [`reaction_time_distribution`];
312324
/// kept as a separate function because it's the metric the baseline tables
313325
/// report and the assertion policy operates on.
314-
pub fn reaction_sensitivity(
315-
trials: &[Trial],
316-
event_at_secs: u64,
317-
react_window_secs: u64,
318-
) -> f64 {
326+
pub fn reaction_sensitivity(trials: &[Trial], event_at_secs: u64, react_window_secs: u64) -> f64 {
319327
let (rate, _) = reaction_time_distribution(trials, event_at_secs, react_window_secs);
320328
rate
321329
}

sv2/channels-sv2/sim/src/regression.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,8 @@ pub fn parse_baseline_toml(input: &str) -> Result<BaselineDoc, ParseError> {
138138
.split_once('=')
139139
.ok_or_else(|| ParseError::MalformedLine(line_no + 1, line.to_string()))?;
140140
let key = key.trim().to_string();
141-
let value = parse_value(raw_value.trim()).ok_or_else(|| {
142-
ParseError::MalformedValue(line_no + 1, raw_value.trim().to_string())
143-
})?;
141+
let value = parse_value(raw_value.trim())
142+
.ok_or_else(|| ParseError::MalformedValue(line_no + 1, raw_value.trim().to_string()))?;
144143
match current_section.as_deref() {
145144
Some("meta") => {
146145
meta_kv.insert(key, value);
@@ -490,7 +489,10 @@ fn compare_max_mul(
490489
metric: metric.to_string(),
491490
baseline: b,
492491
current: c,
493-
tolerance: format!("{} (baseline was 0; current must be ≤ 0.01)", tolerance_desc),
492+
tolerance: format!(
493+
"{} (baseline was 0; current must be ≤ 0.01)",
494+
tolerance_desc
495+
),
494496
});
495497
}
496498
return;
@@ -700,7 +702,10 @@ convergence_rate = 0.95
700702
if !report.is_clean() {
701703
let mut msg = String::new();
702704
if !report.failures.is_empty() {
703-
msg.push_str(&format!("\n{} tolerance failures:\n", report.failures.len()));
705+
msg.push_str(&format!(
706+
"\n{} tolerance failures:\n",
707+
report.failures.len()
708+
));
704709
for d in &report.failures {
705710
msg.push_str(&format!(" {}\n", d));
706711
}

sv2/channels-sv2/sim/src/rng.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ impl XorShift64 {
2525
/// a non-zero constant since XorShift cannot have a zero state.
2626
pub fn new(seed: u64) -> Self {
2727
Self {
28-
state: if seed == 0 { 0xDEAD_BEEF_CAFE_F00D } else { seed },
28+
state: if seed == 0 {
29+
0xDEAD_BEEF_CAFE_F00D
30+
} else {
31+
seed
32+
},
2933
}
3034
}
3135

@@ -46,8 +50,8 @@ impl XorShift64 {
4650
pub fn next_f64(&mut self) -> f64 {
4751
// Take 53 bits of randomness (f64 mantissa precision).
4852
let bits = self.next_u64() >> 11; // 53 bits, range [0, 2^53)
49-
// Map [0, 2^53) → (0, 1): add 1 to numerator to exclude 0, divide by
50-
// (2^53 + 1) to keep result strictly < 1.
53+
// Map [0, 2^53) → (0, 1): add 1 to numerator to exclude 0, divide by
54+
// (2^53 + 1) to keep result strictly < 1.
5155
((bits as f64) + 1.0) / ((1u64 << 53) as f64 + 1.0)
5256
}
5357
}
@@ -167,7 +171,10 @@ mod tests {
167171
let mut rng = XorShift64::new(7);
168172
let rate = 0.5; // Exp(0.5) has mean 2.0
169173
let n = 100_000;
170-
let mean: f64 = (0..n).map(|_| sample_exponential(&mut rng, rate)).sum::<f64>() / n as f64;
174+
let mean: f64 = (0..n)
175+
.map(|_| sample_exponential(&mut rng, rate))
176+
.sum::<f64>()
177+
/ n as f64;
171178
// Standard error of mean for Exp(0.5) over n samples is 2 / sqrt(n) ≈ 0.0063.
172179
// 6-sigma envelope is ±0.04 — very forgiving.
173180
assert!(
@@ -213,7 +220,9 @@ mod tests {
213220
// Normal-approximation path: λ = 1000.0
214221
let mut rng = XorShift64::new(42);
215222
let n = 10_000;
216-
let sum: u64 = (0..n).map(|_| sample_poisson(&mut rng, 1000.0) as u64).sum();
223+
let sum: u64 = (0..n)
224+
.map(|_| sample_poisson(&mut rng, 1000.0) as u64)
225+
.sum();
217226
let mean = sum as f64 / n as f64;
218227
// SE of mean for Poisson(1000) over n=10k samples is sqrt(1000/10000) ≈ 0.32.
219228
// 5-sigma envelope is ±1.6.

sv2/channels-sv2/sim/src/trial.rs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,7 @@ pub fn run_trial<V: Vardiff>(
139139

140140
let mut rng = XorShift64::new(seed);
141141
let mut current_hashrate = config.initial_hashrate;
142-
let mut current_target =
143-
hashrate_to_target_safe(current_hashrate, config.shares_per_minute);
142+
let mut current_target = hashrate_to_target_safe(current_hashrate, config.shares_per_minute);
144143
let mut fires: Vec<FireEvent> = Vec::new();
145144

146145
let mut last_tick_at: u64 = 0;
@@ -200,8 +199,11 @@ pub fn run_trial<V: Vardiff>(
200199
/// realistic trials. Share rate is floored at 0.001 spm to prevent division
201200
/// by zero in the underlying conversion.
202201
fn hashrate_to_target_safe(hashrate: f32, shares_per_minute: f32) -> Target {
203-
hash_rate_to_target(hashrate.max(1.0) as f64, shares_per_minute.max(0.001) as f64)
204-
.expect("hash_rate_to_target with positive inputs should not fail")
202+
hash_rate_to_target(
203+
hashrate.max(1.0) as f64,
204+
shares_per_minute.max(0.001) as f64,
205+
)
206+
.expect("hash_rate_to_target with positive inputs should not fail")
205207
}
206208

207209
#[cfg(test)]
@@ -263,7 +265,10 @@ mod tests {
263265
.iter()
264266
.zip(t2.fires.iter())
265267
.all(|(a, b)| a.at_secs == b.at_secs);
266-
assert!(!same, "Two seeds produced identical fire timelines; RNG broken?");
268+
assert!(
269+
!same,
270+
"Two seeds produced identical fire timelines; RNG broken?"
271+
);
267272
}
268273

269274
#[test]
@@ -278,11 +283,7 @@ mod tests {
278283
// Halve hashrate at 15 min — algorithm should respond.
279284
let schedule = HashrateSchedule::step(1.0e15, 5.0e14, 15 * 60);
280285
let trial = run_trial(vardiff, clock, config, &schedule, 9001);
281-
let post_step_fires = trial
282-
.fires
283-
.iter()
284-
.filter(|f| f.at_secs > 15 * 60)
285-
.count();
286+
let post_step_fires = trial.fires.iter().filter(|f| f.at_secs > 15 * 60).count();
286287
assert!(
287288
post_step_fires >= 1,
288289
"Expected at least one fire after 50% load drop at 15 min; got {} post-step fires (total {})",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Vardiff baseline characterization — `VardiffState`
2+
3+
*Generated by `cargo run --release --bin generate-baseline` from the vardiff_sim crate. 1000 trials per cell, base seed `0xdeadbeefcafef00d`.*
4+
5+
## Convergence time (cold start: 10 GH/s → 1 PH/s)
6+
7+
| share/min | rate | p10 | p50 | p90 | p99 |
8+
| --- | --- | --- | --- | --- | --- |
9+
| 6 | 83.3% | 10m | 12m | 21m | 25m |
10+
| 12 | 95.4% | 10m | 10m | 20m | 25m |
11+
| 30 | 99.5% | 10m | 10m | 15m | 25m |
12+
| 60 | 100.0% | 10m | 10m | 10m | 20m |
13+
| 120 | 100.0% | 10m | 10m | 10m | 15m |
14+
15+
## Settled accuracy (stable load, post-convergence)
16+
17+
`|final_hashrate / true_hashrate - 1|` at trial end. Smaller is better.
18+
19+
| share/min | p10 | p50 | p90 | p99 |
20+
| --- | --- | --- | --- | --- |
21+
| 6 | 0.0% | 4.9% | 23.6% | 70.3% |
22+
| 12 | 0.0% | 0.0% | 12.3% | 26.9% |
23+
| 30 | 0.0% | 0.0% | 0.8% | 15.6% |
24+
| 60 | 0.0% | 0.0% | 0.0% | 3.1% |
25+
| 120 | 0.0% | 0.0% | 0.0% | 0.0% |
26+
27+
## Steady-state jitter (fires per minute)
28+
29+
Post-convergence rate of vardiff fires. Smaller is better — ideal is zero under stable load.
30+
31+
| share/min | p50 | p90 | p99 | mean |
32+
| --- | --- | --- | --- | --- |
33+
| 6 | 0.000 | 0.200 | 0.385 | 0.059 |
34+
| 12 | 0.000 | 0.077 | 0.217 | 0.019 |
35+
| 30 | 0.000 | 0.000 | 0.067 | 0.002 |
36+
| 60 | 0.000 | 0.000 | 0.000 | 0.000 |
37+
| 120 | 0.000 | 0.000 | 0.000 | 0.000 |
38+
39+
## Reaction time to a 50% drop (step at 15 min)
40+
41+
| share/min | reacted | p10 | p50 | p90 | p99 |
42+
| --- | --- | --- | --- | --- | --- |
43+
| 6 | 69.7% | 1m | 3m | 5m | 5m |
44+
| 12 | 54.8% | 1m | 3m | 5m | 5m |
45+
| 30 | 32.6% | 2m | 4m | 5m | 5m |
46+
| 60 | 16.3% | 3m | 5m | 5m | 5m |
47+
| 120 | 8.6% | 4m | 5m | 5m | 5m |
48+
49+
## Reaction sensitivity (P[fire within 5 min of step change])
50+
51+
| Δ% | 6 | 12 | 30 | 60 | 120 |
52+
| --- | --- | --- | --- | --- | --- |
53+
| -50% | 0.70 | 0.55 | 0.33 | 0.16 | 0.09 |
54+
| -25% | 0.44 | 0.23 | 0.08 | 0.00 | 0.00 |
55+
| -10% | 0.39 | 0.15 | 0.02 | 0.00 | 0.00 |
56+
| -5% | 0.40 | 0.15 | 0.02 | 0.00 | 0.00 |
57+
| +5% | 0.39 | 0.13 | 0.02 | 0.00 | 0.00 |
58+
| +10% | 0.42 | 0.17 | 0.03 | 0.00 | 0.00 |
59+
| +25% | 0.48 | 0.23 | 0.07 | 0.01 | 0.00 |
60+
| +50% | 0.64 | 0.47 | 0.32 | 0.22 | 0.29 |
61+

0 commit comments

Comments
 (0)