Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,21 @@ is the type check. CI (`.github/workflows/ci.yml`) runs cargo test → wasm buil

## Architecture

```
scenarios/*.json ─▶ sim-core (Rust DES) ─▶ Vec<Traced> trace events
protocol::alpenglow (Rotor · Blokstor · Pool · Votor)
protocol::tower (PoH · Turbine · TowerState · ForkTree)
├─▶ sim-cli (consensus-lab: run / inspect / replay / sweep)
└─▶ sim-wasm ─▶ web/ (Worker ▸ EventBuffer ▸ Controller ▸ PixiJS + React)
```mermaid
flowchart LR
SC["scenarios/*.json"] --> CORE["sim-core — deterministic Rust DES<br/>protocol::alpenglow — Rotor · Blokstor · Pool · Votor<br/>protocol::tower — PoH · Turbine · TowerState · ForkTree"]
CORE --> TRACE["Vec of Traced trace events"]
TRACE --> CLI["sim-cli — consensus-lab: run / inspect / replay / sweep"]
TRACE --> WASM["sim-wasm"] --> WEB["web — Worker ▸ EventBuffer ▸ Controller ▸ PixiJS + React"]
```

**Everything observable is a trace event.** `crates/sim-core/src/trace.rs`
defines `TraceEvent`; the CLI, `metrics.rs`, the property tests and the UI all
consume only this stream. If the UI needs to show something new, add a trace
event (and mirror it in `web/src/engine/types.ts` and `docs/wasm-api.md`),
never a side channel.
never a side channel. `docs/architecture.md` is the contributor map (Mermaid
diagrams, module table, test map, "where do I add X", and the honest limits of
the model) — keep it in step with this file's summary.

**Engine / protocol split** (`sim.rs` + `protocol/mod.rs`). `Simulator<P>` owns
the event heap, nodes, `Network`, `FaultState`, RNG and trace. A `Protocol`
Expand All @@ -86,11 +88,22 @@ rebuilds a fresh `Sim` and fast-forwards) both depend on it. Avoid `HashMap`
iteration order in anything that affects behavior (code uses `BTreeMap`/`BTreeSet`).
`Simulator` is fully serde-serializable for `snapshot`/`restore`.

**Scenarios** are JSON with defaults for every field (`scenario.rs`). The five
**Scenarios** are JSON with defaults for every field (`scenario.rs`). The six
in `scenarios/` are `include_str!`-embedded as `scenario::BUILTIN`, so they ship
inside the wasm too; adding one means adding the file and the `BUILTIN` entry
(then rebuild the wasm; `test/lessons.test.ts` reads the directory).

**Scenario timings are the contract.** A hero tx can only be voted on once its
block is *complete*, i.e. after the leader has produced all `slices_per_block`
slices across the whole Δblock (`slot_ms`, 400 ms = paper Table 10). So how
late the tx lands inside a block moves `included_in_block → finalized` by up to
a full slot, and the rest is δ80% of voting and certification. `happy-path`
submits at 250 ms and the tx lands in slice 0 → 451 ms; `ideal-fast` submits at
550 ms so it rides the last slice → 150 ms, the paper's Fig. 14 median (measured
after a block is distributed, §1.3). Both scenarios are seed-pinned and
`alpenglow::tests::ideal_fast_matches_the_papers_150ms_median` guards the
number, so re-tune copy only with the CLI, never by hand.

**Lessons and breakpoints** (`web/src/lessons/`, `web/src/engine/breakpoint.ts`).
A lesson is a TypeScript object: scenario/mode/seed plus steps, each with a
declarative `Trigger`. `Controller.setBreakpoint` scans the watched run's
Expand Down
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,20 @@ compressed in `s=`.
| Scenario | Alpenglow: inclusion → finalized | TowerBFT: inclusion → confirmed | TowerBFT: inclusion → rooted |
|---|---|---|---|
| happy-path | 451 ms (fast path, 80%) | 513 ms | 12.7 s |
| ideal-fast (tx rides the block's last slice) | 150 ms (fast path, 80%) | 187 ms | 12.4 s |
| offline-25pct | 563 ms (slow path, 60%+60%) | 493 ms | 17.8 s |
| leader-down (tx sent into a dead window) | 452 ms after the next live leader | 350 ms | 12.5 s |
| partition-heal (tx sent mid-partition) | 1.3 s after the heal (standstill re-broadcast, then 512 ms of consensus) | 495 ms | 12.7 s |
| twenty-twenty (21% offline, then a partition) | 462 ms (slow path) | 489 ms | not within 14 s |

`ideal-fast` and `happy-path` run on the same healthy cluster with the same 400 ms block time, so
the 300 ms between them is not consensus: in `happy-path` the transaction lands in the *first*
slice and waits for the leader to finish the block, in `ideal-fast` it is submitted at 550 ms and
rides the *last* slice, leaving only Votor — 60% notarize votes, then an 80% fast-finalization
certificate 150 ms later. That is min(δ80%, 2·δ60%) measured from the moment the block was
distributed, the median the Alpenglow white paper reports for randomly chosen leaders (§1.3,
Fig. 14); here the median validator finalizes 149 ms after it receives the block.

Also visible: Alpenglow's Skip certificates when a leader is down, the stall-then-resume through a 50/50 partition with **no conflicting finalization**, standstill recovery after a heal, and Tower's vote transactions (~400 per 16 s at 25 validators) consuming block space.

## Lessons
Expand Down Expand Up @@ -83,15 +92,15 @@ Teaching defaults keep shred counts small (8 per slice / FEC set, 4 needed) so p

## Architecture

```
scenarios/*.json ──▶ sim-core (Rust, deterministic DES) ──▶ TraceEvent stream
│ protocol::alpenglow (Rotor · Blokstor · Pool · Votor)
│ protocol::tower (PoH · Turbine · Tower · ForkTree)
├─▶ sim-cli (run / inspect / replay / sweep)
└─▶ sim-wasm ──▶ web/ (Worker ▸ event buffer ▸ PixiJS + React)
```mermaid
flowchart LR
SC["scenarios/*.json"] --> CORE["sim-core — deterministic Rust DES<br/>alpenglow: Rotor · Blokstor · Pool · Votor<br/>tower: PoH · Turbine · tower · fork choice"]
CORE --> TRACE["TraceEvent stream"]
TRACE --> CLI["sim-cli<br/>run · inspect · replay · sweep"]
TRACE --> WASM["sim-wasm"] --> WEB["web UI<br/>worker · event buffer · PixiJS + React"]
```

Everything the UI shows is derived from the trace, so the visualization can never disagree with the simulation. `docs/wasm-api.md` is the contract between the engine and the UI.
Everything the UI shows is derived from the trace, so the visualization can never disagree with the simulation. `docs/wasm-api.md` is the contract between the engine and the UI, and [`docs/architecture.md`](docs/architecture.md) is the full map — module by module, with diagrams of the engine loop and the render pipeline, where to add what, and an explicit list of what is modelled versus simplified.

## Fidelity notes

Expand Down
62 changes: 62 additions & 0 deletions crates/sim-core/src/protocol/alpenglow/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,68 @@ mod tests {
assert!(sim.nodes.iter().all(|n| n.highest_finalized_slot.is_some()));
}

/// The builtin "ideal-fast" scenario has to reproduce the white paper's headline number.
/// The paper measures finalization *after a block has been distributed* — min(δ80%, 2δ60%),
/// §1.3 — and reports a median of roughly 150 ms for randomly chosen leaders (Fig. 14). The
/// scenario's transaction rides the block's last slice, so inclusion and distribution
/// coincide; both the median node and the transaction must land on that number.
#[test]
fn ideal_fast_matches_the_papers_150ms_median() {
let json = crate::scenario::builtin("ideal-fast").expect("ideal-fast is a builtin scenario");
let (sim, tr) = run(json);
let m = Metrics::from_trace(&tr);
let hero_tx_ms = m.tx_stage_ms["finalized"] - m.tx_stage_ms["included_in_block"];
assert!(
(hero_tx_ms - 150.0).abs() < 10.0,
"hero tx took {hero_tx_ms} ms from inclusion to finality, expected ~150 ms"
);
assert!(m.certificates.get("fast_finalization").copied().unwrap_or(0) > 0, "expected the 80% fast path: {:?}", m.certificates);
assert_eq!(m.certificates.get("skip").copied().unwrap_or(0), 0, "ideal case must not skip a slot: {:?}", m.certificates);

// Median node: block in hand → block finalized, over every validator that saw the block.
let hero_slot = tr
.iter()
.find_map(|t| match &t.ev {
TraceEvent::TxStage { stage: TxStage::IncludedInBlock, slot: Some(s), .. } => Some(*s),
_ => None,
})
.expect("hero tx was never included");
let hero_hash = tr
.iter()
.find_map(|t| match &t.ev {
TraceEvent::BlockProduced { slot, hash, .. } if *slot == hero_slot => Some(*hash),
_ => None,
})
.expect("no block for the hero slot");
let mut got: BTreeMap<NodeId, (crate::time::SimTime, crate::time::SimTime)> = BTreeMap::new();
for t in &tr {
match &t.ev {
TraceEvent::BlockReceived { node, hash, .. } if *hash == hero_hash => {
got.entry(*node).or_insert((t.t, t.t)).0 = t.t;
}
TraceEvent::Commitment { node, hash, level: Commitment::Finalized, .. } if *hash == hero_hash => {
got.entry(*node).or_insert((t.t, t.t)).1 = t.t;
}
_ => {}
}
}
let mut deltas: Vec<f64> = got
.values()
.filter(|(recv, fin)| *fin > *recv)
.map(|(recv, fin)| crate::time::to_ms(fin - recv))
.collect();
deltas.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mid = deltas.len() / 2;
let median = (deltas[mid - 1] + deltas[mid]) / 2.0;
assert_eq!(got.len(), 25, "every validator should have seen the hero block");
assert!(
(median - 150.0).abs() < 25.0,
"median node finalized {median} ms after receiving the block, expected ~150 ms"
);
assert_consistent_finality(&tr);
assert!(sim.nodes.iter().all(|n| n.highest_finalized_slot.is_some()));
}

#[test]
fn offline_quarter_uses_slow_path() {
let (_, tr) = run(r#"{"name":"offline","duration_ms":8000,"validators":{"count":25},
Expand Down
1 change: 1 addition & 0 deletions crates/sim-core/src/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub const STAKE_UNITS: u64 = 1_000_000;
/// Scenarios shipped with the simulator (also embedded in the wasm build).
pub const BUILTIN: &[(&str, &str)] = &[
("happy-path", include_str!("../../../scenarios/happy-path.json")),
("ideal-fast", include_str!("../../../scenarios/ideal-fast.json")),
("offline-25pct", include_str!("../../../scenarios/offline-25pct.json")),
("leader-down", include_str!("../../../scenarios/leader-down.json")),
("partition-heal", include_str!("../../../scenarios/partition-heal.json")),
Expand Down
Loading
Loading