diff --git a/CLAUDE.md b/CLAUDE.md index d58b233..19c3f44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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
protocol::alpenglow — Rotor · Blokstor · Pool · Votor
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

` owns the event heap, nodes, `Network`, `FaultState`, RNG and trace. A `Protocol` @@ -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 diff --git a/README.md b/README.md index a126f99..f361d49 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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
alpenglow: Rotor · Blokstor · Pool · Votor
tower: PoH · Turbine · tower · fork choice"] + CORE --> TRACE["TraceEvent stream"] + TRACE --> CLI["sim-cli
run · inspect · replay · sweep"] + TRACE --> WASM["sim-wasm"] --> WEB["web UI
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 diff --git a/crates/sim-core/src/protocol/alpenglow/mod.rs b/crates/sim-core/src/protocol/alpenglow/mod.rs index 6acd450..a65a93a 100644 --- a/crates/sim-core/src/protocol/alpenglow/mod.rs +++ b/crates/sim-core/src/protocol/alpenglow/mod.rs @@ -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 = 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 = 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}, diff --git a/crates/sim-core/src/scenario.rs b/crates/sim-core/src/scenario.rs index f32afe5..4c74815 100644 --- a/crates/sim-core/src/scenario.rs +++ b/crates/sim-core/src/scenario.rs @@ -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")), diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..811afe4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,336 @@ +# Architecture + +How Solana Consensus Lab is put together, for two audiences: **contributors** changing the code, and +**reviewers** deciding whether the thing is credible. It is a map, not a specification — the rules of +each protocol live in [`protocols.md`](protocols.md), the engine↔UI bridge contract in +[`wasm-api.md`](wasm-api.md), the front-end internals in [`web/README.md`](../web/README.md), and the +project plan in [`grant-roadmap.md`](grant-roadmap.md). + +The whole system is three sentences: + +1. One **deterministic discrete-event simulator** in Rust runs a scenario under TowerBFT *or* + Alpenglow and emits a stream of trace events. +2. **Everything observable is that trace.** No side channel, no UI-side state that could disagree + with the simulation. +3. Three consumers read it: the **CLI**, the **property tests**, and the **web UI** (Rust compiled to + WebAssembly, driven frame by frame in a browser). + +All diagrams below are [Mermaid](https://mermaid.js.org), so GitHub renders them and they diff like +code. + + +## 1. The shape of the system + +```mermaid +flowchart LR + JSON["scenarios/*.json
plus defaults in scenario.rs"] --> SIM + subgraph CORE["sim-core — deterministic discrete-event engine"] + SIM["Simulator over a protocol
event heap · network · faults · RNG · trace"] + end + SIM --> TRACE["trace stream
Vec of Traced: timestamp + event"] + TRACE --> CLI["sim-cli
run · inspect · replay · sweep"] + TRACE --> MET["metrics.rs
counters folded from the trace"] + TRACE --> PROP["property tests
safety · liveness · determinism"] + TRACE --> BRIDGE["sim-wasm
JSON-in / JSON-out bridge"] + BRIDGE --> UI["web UI
worker · event buffer · controller · PixiJS + React"] +``` + +Consequences worth stating, because they constrain everything else: + +- The **UI never simulates**. It cannot draw a certificate that the engine did not emit, so the + animation cannot lie about the protocol. +- The **CLI and the browser run the same code** on the same scenario and seed and produce the same + trace, so a number in a lesson, a number in the README and a number in a bug report are the same + number. +- Adding a fault, a protocol rule or a visualization never requires touching more than one layer: + protocol rules live behind an event, the trace reaches every consumer at once. + +## 2. Repository map + +```mermaid +flowchart TB + subgraph CRATES["crates — Rust workspace"] + CORE["sim-core
engine · protocols · trace · metrics · scenario"] + WASM["sim-wasm
wasm-bindgen bridge, one Sim type"] + CLI["sim-cli
run · inspect · replay · sweep"] + end + subgraph WEB["web — React + PixiJS + Vite, no backend"] + ENG["engine
worker · client · controller · eventBuffer · breakpoint"] + STORE["store
zustand store · RunView reducer"] + REND["render
scene · particles · layout · interp"] + COMP["components
timeline · inspector · panels · lessons"] + URL["url
parse · format · share link"] + end + SC["scenarios/*.json
six builtin scenarios"] --> CORE + CORE --> WASM --> ENG + CORE --> CLI + ENG --> STORE --> COMP + ENG --> REND + STORE --> URL + COMP --> URL +``` + +| Path | What lives there | Notes | +|---|---|---| +| `crates/sim-core/src/sim.rs` | Event heap, `step`, `run_until`, `run_to_end`, faults, snapshot/restore | The engine; knows nothing about either protocol's rules | +| `crates/sim-core/src/protocol/` | `alpenglow/` (Votor, Pool, Rotor), `tower/` (PoH, Turbine, tower, fork choice), shared `blokstor.rs`, `dissemination.rs`, reference `ping.rs` | Protocol logic | +| `crates/sim-core/src/trace.rs` | `TraceEvent`, the event union | Add anything observable here | +| `crates/sim-core/src/scenario.rs` | JSON schema, defaults, `BUILTIN` | Every field has a default | +| `crates/sim-core/src/network.rs`, `stake.rs`, `rng.rs`, `time.rs`, `tx.rs` | Latency model, validator set + leader schedule, seeded RNG, microsecond time, hero-tx stages | | +| `crates/sim-core/tests/properties.rs` | proptests: safety, liveness, determinism | Checked-in regression seeds | +| `crates/sim-wasm/src/lib.rs` | `Sim` (new / runUntil / step / inspect / metrics / snapshot) | Mirrors `docs/wasm-api.md` | +| `crates/sim-cli/src/main.rs` | Headless entry point | CSV export for sweeps | +| `web/src/engine/` | Worker protocol, display clock, event buffer, breakpoints | The render loop | +| `web/src/store/` | zustand store + `RunView` (events → panel state) | | +| `web/src/render/` | PixiJS scene, pooled particles, ring layout, path interpolation | | +| `web/src/components/` | Timeline, Inspector, Transaction/Votor/Tower/Events/Metrics panels, lessons | | +| `web/src/url/` | Address-bar state, share links, scenario compression | | + +## 3. sim-core: the engine + +### 3.1 From a scenario to a running simulation + +`Simulator::new` turns JSON into a fixed world, then the queue is seeded. + +```mermaid +flowchart TB + S["Scenario"] --> V["ValidatorSpec::build
Pareto, uniform or explicit stakes, sorted descending"] + S --> N["Network::build
regions, latency matrix, jitter, egress serialization"] + S --> L["LeaderSchedule::generate
stake-weighted, leader_window slots each"] + S --> F["schedule_faults
offline · partition · delay · drop, resolved to timestamps"] + S --> Q["event queue seeded with
one Slot event per slot, fault events,
the hero tx at submit_at_ms"] + S --> H["Protocol::on_start for every node
genesis counts as ParentReady"] + V --> SIM["Simulator"] + N --> SIM + L --> SIM + F --> Q + Q --> SIM + H --> SIM +``` + +All randomness comes from `rng::Rng` (xoshiro256\*\*) forked per subsystem with fixed tags in +`Simulator::new`, so validator stakes, node placement, leader selection, fault target picking and +latency jitter never share a stream — and any one of them can be changed without reshuffling the +others. + +### 3.2 One step of the loop + +```mermaid +sequenceDiagram + participant Q as event heap + participant E as Simulator + participant P as protocol handler + participant C as Ctx + participant N as Network + Q->>E: pop earliest event (at, seq) + E->>E: now = at + E->>P: on_message / on_timer / on_slot / on_client_tx + P->>C: emit(trace event), send, broadcast, set_timer + C-->>E: collected actions + E->>N: transit(from, to, bytes, now) + fault check + N-->>E: arrive_at, or dropped + E->>Q: push Deliver at arrive_at + E->>E: trace.push(timestamp + event) +``` + +The heap is keyed by `(time, insertion sequence)`, so two events at the same microsecond always run in +a fixed order. Messages are *scheduled*, not delivered inline: `Network::transit` returns an arrival +time and the engine pushes a `Deliver` event, which is what makes latency, partitions, node outages +and bandwidth ordinary cases rather than special ones. + +### 3.3 The engine / protocol boundary + +Protocol code is a set of pure handlers over per-node state. All side effects are returned as data +and applied by the engine *after* the handler returns. + +```mermaid +flowchart LR + subgraph HANDLER["Protocol handler — pure over node state"] + H["on_message(node, from, msg, ctx)"] + end + subgraph CTX["Ctx"] + T["trace: events to emit"] + A["actions: send / broadcast / set_timer"] + R["rng, params, validators, leader schedule"] + end + subgraph ENGINE["Engine applies, after the handler returns"] + S["Send — latency, faults, bandwidth, then a Deliver event"] + B["Broadcast — one Send per peer"] + TM["Timer — pushed at now + delay"] + end + H --> T + H --> A + H --> R + A --> S + A --> B + A --> TM + S -.->|"Deliver event, a moment later"| HANDLER +``` + +Why it is built this way: + +- Faults, latency and bandwidth are applied in **one** place, so a protocol cannot accidentally + deliver a message instantly or ignore a partition. +- A protocol is testable without a network: call a handler, inspect the returned actions. +- `Protocol::ENGINE_SLOTS` says whether the engine's fixed slot clock means anything for that + protocol. TowerBFT uses it (PoH ticks); Alpenglow ignores it and paces itself from + `ParentReady + Δblock`. + +## 4. The two protocols + +Same engine, same trace, same UI. Only the rules differ. + +| | TowerBFT (mainnet today) | Alpenglow (SIMD-0326) | +|---|---|---| +| Reference | Agave `vote_state` | White paper v1.1, Algorithms 1–2 | +| Clock | PoH: fixed `slot_ms` slots, leader windows | No global clock: `ParentReady + i·Δblock` | +| Dissemination | Turbine: shreds through a stake-weighted tree | Rotor: one stake-weighted relay per shred, single hop | +| Votes | Vote *transactions* packed into blocks (~300 B) | Vote *messages*, never on chain (150 B) | +| `confirmed` | ⅔ of stake's latest votes on the block | Notarization certificate (60% Notarize) | +| `finalized` | 32 stacked votes push the block out of the tower → root | Fast-Finalization certificate (80% Notarize) or Finalization certificate (60% Finalize) | +| Liveness model | lockout doubling (2, 4, 8 … slots), 38% switch threshold | Δtimeout, Skip certificates, standstill recovery | +| Modules | `protocol/tower/` | `protocol/alpenglow/` | + +Shared code: `protocol/blokstor.rs` (shreds → block reconstruction, used by both) and +`protocol/dissemination.rs` (stake-weighted orderings, Turbine children, Rotor relay sampling, block +hashes). [`protocols.md`](protocols.md) documents the rules; this table only orients you. + +## 5. The trace contract + +`TraceEvent` in `crates/sim-core/src/trace.rs` is the API of the whole project. Families: + +| Family | Events | Consumed by | +|---|---|---| +| Engine | `slot_start`, `msg_sent`, `msg_dropped`, `node_offline`/`node_online`, `partition_start`/`partition_end` | canvas (particles, dividers), metrics, sweep CSV | +| Blocks | `block_produced`, `block_received`, `block_replayed` | timeline, panels, Particles | +| Alpenglow | `vote`, `certificate`, `pool_event`, `votor_flag`, `timeout` | Votor panel, lesson breakpoints | +| Tower | `tower_update`, `fork_choice` | lockout bars, fork tree | +| Commitment | `commitment` (processed / confirmed / finalized) | transaction stepper, panels | +| Hero tx | `tx_stage` — `submitted`, `forwarded_to_leader`, `included_in_block`, `propagating`, `replayed`, `voted`, `confirmed`, `finalized` | the whole transaction panel and most lessons | +| Diagnostics | `log` | events feed, standstill / retry narration | + +Rules for adding one: + +1. Emit it from the protocol with `ctx.emit(...)`. If it is observable, it belongs here. +2. Mirror it in `web/src/engine/types.ts` and in `docs/wasm-api.md`. +3. If a panel should show it, add a case to `web/src/store/runView.ts` (`applyEvents`); if a metric + should count it, add a case to `crates/sim-core/src/metrics.rs`. +4. If a lesson should stop on it, add a `Trigger` in `web/src/engine/breakpoint.ts`. + +## 6. Determinism, serialization, and why the UI can rewind + +Determinism is a hard invariant, not a nicety: + +- All randomness flows through `rng::Rng`, forked per subsystem with fixed tags. +- Nothing that affects behaviour iterates a `HashMap`; ordered containers only (`BTreeMap`/`BTreeSet`). +- Events are totally ordered by `(time, insertion sequence)`. +- The `Simulator` is fully serde-serializable, so `snapshot`/`restore` continues a run exactly. + +It is verified, not asserted: `both_protocols_are_deterministic` runs every builtin scenario under +both protocols twice and compares serialized traces; `snapshot_roundtrip_continues_identically` +covers serialization; `properties.proptest-regressions` is checked in so a found failure keeps failing +until fixed. + +The payoff is in the browser. Scrubbing backwards does **not** ship snapshots to the UI: the worker +throws the simulation away, builds a fresh one and fast-forwards to the target time +(`web/src/engine/controller.ts`, `seek`). That is only possible because the engine is deterministic, +and it is why compare mode, share links and lesson "Back" are exact rather than approximate. + +## 7. The web front end + +```mermaid +flowchart TB + subgraph MT["Main thread"] + CTRL["Controller — one display clock in a rAF loop"] + BUF["EventBuffer — time-indexed, drain up to displayTime"] + VIEW["RunView — events reduced into panel state"] + SCENE["PixiJS scene — pooled particles, ring layout"] + ST[("zustand store")] + REACT["React components — observe the store at 15 Hz"] + end + subgraph WA["Worker: alpenglow"] + SA["wasm Sim"] + end + subgraph WB["Worker: tower"] + SB["wasm Sim"] + end + CTRL -->|"advance(target + lookahead)"| SA + CTRL -->|"advance(target + lookahead)"| SB + SA -->|"events"| BUF + SB -->|"events"| BUF + BUF --> VIEW + BUF --> SCENE + VIEW --> ST --> REACT + CTRL -->|"reset(t): fresh Sim + runUntil(t)"| SA + CTRL -->|"reset(t): fresh Sim + runUntil(t)"| SB +``` + +- **One display clock, N workers.** Compare mode runs both protocols from the same scenario JSON and + seed against the same clock. The clock never outruns a worker — it stalls instead of skipping. +- **Nothing per frame goes through React.** The controller drains events into a derived `RunView` and + writes the store at most every 66 ms (≈15 Hz); the canvas is driven imperatively from the same + drain. A 25-node run emits ~100k events, so events are never rendered as DOM (the Events feed is + virtualised and capped at 400 rows). +- **The buffer is the agreement point.** Panels and canvas both read state produced by the same + `drain(displayTime)`, so "now" cannot disagree between them. +- **Breakpoints clamp the clock before it advances** (`engine/breakpoint.ts`): a lesson step names a + declarative trigger, the controller scans the buffered range for the first match and stops on its + exact timestamp — frame-exact at any speed, no rewind. + +[`web/README.md`](../web/README.md#architecture) owns the front-end detail; this is the boundary. + +## 8. Surfaces + +| Surface | Entry point | Use | +|---|---|---| +| CLI `run` | `cargo run -p sim-cli -- run --scenario happy-path --protocol both` | Hero-tx stages, certificates, message and byte counters | +| CLI `inspect` | `... inspect --scenario partition-heal --node 3 --at-ms 3000` | One validator's internal state at a point in time | +| CLI `replay` | `... replay --out trace.jsonl --until-ms 3000` | Summarize a recorded trace | +| CLI `sweep` | `... sweep --param faults.0.target.stake_pct=0..0.4:0.05 --seeds 10 --csv s.csv` | Parameter sweeps for research and bug reports | +| WASM | `Sim` in `crates/sim-wasm/src/lib.rs` | `new`, `runUntil`, `step`, `inspect`, `metrics`, `snapshot`/`restore`, `scenarioNames`, `builtinScenario`, `validateScenario` | +| Scenarios | `scenarios/*.json`, registered in `scenario.rs::BUILTIN` | Six builtins; the list ships inside the wasm and is also the UI picker | +| URL | `?scenario=…&mode=compare&seed=1&t=2700&node=3&tab=tower&lesson=…&step=…` | Shareable moments; custom scenarios compressed into `s=` | + +## 9. Test map + +| Layer | Protects | Command | +|---|---|---| +| Engine unit tests (`lib.rs`) | heap ordering, snapshot/restore, determinism of the reference protocol | `cargo test -p sim-core` | +| Protocol scenario tests (bottom of `protocol/*/mod.rs`) | the shipped claims: fast path, slow path under 25% offline, Skip certificates, no conflicting finality, the paper's 150 ms median | `cargo test -p sim-core` | +| Property tests (`tests/properties.rs`) | safety (no conflicting finalization), liveness (≤20% offline), determinism across every builtin scenario | `cargo test -p sim-core --test properties` | +| Web unit tests (`web/test/`) | event buffer, interpolation, tx stages, URL round trip, every breakpoint trigger, lesson registry | `cd web && bun run test` | +| Type check + bundle (`bun run build`) | the mirror of the Rust types in `web/src/engine/types.ts` is exact | `cd web && bun run build` | +| End-to-end (`web/e2e/`) | the app boots, the hero tx finalizes, a lesson runs end to end, a share link restores | `cd web && bun run e2e` | + +CI runs all of it, then builds the wasm first because the web build depends on it. + +## 10. Where to add things + +| I want to… | Touch | Gotcha | +|---|---|---| +| show something new | `trace.rs`, then mirror in `web/src/engine/types.ts` + `docs/wasm-api.md` | if a panel needs it, add an `applyEvents` case in `runView.ts` | +| add a scenario | `scenarios/*.json` + a `BUILTIN` entry, rebuild the wasm | measure the timings with the CLI before writing any copy | +| add a protocol parameter | `scenario.rs` (`Params`) with a `#[serde(default = …)]`, then read it through `ctx.params` | expose rules as parameters instead of simplifying them | +| add a fault | `scenario.rs::Fault` + `network.rs::schedule_faults`; enforcement already exists in `send` | keep `Target::StakePct` from overshooting a whale | +| add a lesson | `web/src/lessons/.ts` + `index.ts`, bump the lesson-count assertion in `web/test/lessons.test.ts` | triggers must actually fire — verify with `--events` in the CLI | +| add a panel | `components/panels/` reading a `RunView` field | panels read the store at 15 Hz; nothing per-frame | + +## 11. Scope and honest limits + +What this simulator is, stated plainly so nobody has to guess: + +- **Validator count.** 25 by default, against ~1,500 in the paper's simulations. Propagation is + simulated per message, so the *shape* of the latency distribution carries over but not its tail. +- **No Byzantine adversary yet.** Faults model crashes, partitions, delay and loss. Equivocation and + double voting — the 20% wall — are milestone M4 in [`grant-roadmap.md`](grant-roadmap.md). +- **No cryptography.** Certificates carry aggregated stake and are trusted on receipt; the 20% + double-sign argument is argued from thresholds, not from signature checks. +- **Execution time is a constant** (15 ms) rather than a function of the block's transactions. +- **The network is abstract**: a latency matrix with log-normal jitter and egress serialization, not a + packet-level model. Shred counts are deliberately small (8 per slice, 4 needed) so propagation is + visible in the animation rather than instantaneous. +- **Deterministic teaching values**: Δstandstill is 2 s instead of the paper's 10 s so partition + recovery is watchable; shred counts and validator count are small for the same reason. + +These are all parameterizable or milestone-scoped, and each is stated where it is defined — the point +of the exercise is that a reader can tell modelled rules from simplified ones. diff --git a/docs/protocols.md b/docs/protocols.md index c87eafd..ecd2a31 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -2,6 +2,7 @@ This is the reference for what the simulator actually does, written for instructors. Every rule below maps to code in `crates/sim-core/src/protocol/`. +For how the engine drives those rules, see [`architecture.md`](architecture.md). ## Shared stage: a transaction's journey @@ -131,3 +132,19 @@ Latencies are one-way medians between six regions (EU-central, EU-west, US-east, US-west, Tokyo, Singapore) with log-normal jitter (σ = 0.15) and 1 Gb/s egress serialization; execution takes 15 ms. Shred = 1228 bytes, Tower vote tx = 300 bytes, Alpenglow vote = 150 bytes, certificate = 1000 bytes. + +**Reading a finality number.** The white 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). This simulator can measure +the same thing per validator (`BlockReceived` → `Commitment{Finalized}` on the +same block): on the `ideal-fast` scenario the median validator finalizes 149 ms +after it receives the block, because that transaction rides the block's last +slice and so pays almost none of the Δblock assembly time. The `happy-path` +transaction lands in the first slice, waits for the whole 400 ms block and then +takes 451 ms. Both run on the same healthy cluster with no faults; only the +submission time differs. Paper values used as-is: Δblock = 400 ms, w = 4 blocks +per leader window, Δstandstill = 10 s (the teaching default is 2 s), 60% / 80% +certificate thresholds, and stake drawn from a Pareto distribution like +mainnet. Smaller than the paper's: 25 validators instead of ~1,500, and 8 shreds +per slice with 4 needed instead of Γ = 64 / γ = 32, so that dissemination is +visible. diff --git a/docs/wasm-api.md b/docs/wasm-api.md index 81a16ea..8fdef13 100644 --- a/docs/wasm-api.md +++ b/docs/wasm-api.md @@ -2,6 +2,7 @@ The web UI never simulates anything. It drives a WebAssembly build of `sim-core` through the `Sim` class below and renders the **trace events** it returns. +`docs/architecture.md` §7 shows where these calls sit in the render pipeline. ## `Sim` (wasm-bindgen, package `sim-wasm`, imported from `web/src/wasm/pkg`) diff --git a/scenarios/happy-path.json b/scenarios/happy-path.json index 7cff9aa..4c642bf 100644 --- a/scenarios/happy-path.json +++ b/scenarios/happy-path.json @@ -1,6 +1,6 @@ { "name": "happy-path", - "description": "25 validators across 6 regions, no faults. Watch one transaction: Alpenglow fast-finalizes it in ~150 ms after the block lands (80% notarize votes \u2192 fast-finalization certificate); TowerBFT optimistically confirms it in ~0.5 s and roots it only after 32 stacked votes (~12.8 s).", + "description": "25 validators across 6 regions, no faults. Watch one transaction: it is submitted at 250 ms and lands in the first slice of the next leader's block, so it first waits for that whole 400 ms block to be assembled. Alpenglow then fast-finalizes it 451 ms after inclusion (80% notarize votes \u2192 fast-finalization certificate). TowerBFT optimistically confirms it 513 ms after inclusion and roots it only after 32 stacked votes, 12.7 s in. ideal-fast is the same healthy cluster with the transaction riding the block's last slice: 150 ms, the white paper's median.", "seed": 1, "duration_ms": 16000, "validators": { diff --git a/scenarios/ideal-fast.json b/scenarios/ideal-fast.json new file mode 100644 index 0000000..48dd3d7 --- /dev/null +++ b/scenarios/ideal-fast.json @@ -0,0 +1,12 @@ +{ + "name": "ideal-fast", + "description": "Alpenglow's best case, and the number the white paper claims. Nothing is offline, there are no partitions and no packet loss, and the transaction is submitted at 550 ms so it rides the last slice of its block, paying almost none of the 400 ms it takes to assemble a block. Rotor distributes the block, 60% of the stake notarizes it, and the fast-finalization certificate (80% notarize votes) lands 150 ms later \u2014 min(\u03b480%, 2\u00b7\u03b460%) measured from the moment the block was distributed, the median the paper reports for randomly chosen leaders (Fig. 14). The median validator here finalizes 149 ms after it receives the block. TowerBFT confirms the same block at 887 ms and roots it only after 32 stacked votes, 12.4 s in. happy-path is the same healthy cluster, but there the transaction lands in the first slice and waits for the whole block, so it needs 450 ms.", + "seed": 1, + "duration_ms": 16000, + "validators": { + "count": 25 + }, + "hero_tx": { + "submit_at_ms": 550 + } +} \ No newline at end of file diff --git a/web/README.md b/web/README.md index cdd6b0b..4947f82 100644 --- a/web/README.md +++ b/web/README.md @@ -46,29 +46,30 @@ bun run e2e # Playwright (starts the dev server itself); al ## Architecture +```mermaid +flowchart TB + subgraph MT["Main thread — one rAF loop, nothing per frame goes through React"] + CTRL["Controller
displayTime += dt x speed, clamped to min(worker.now)
look-ahead: advance(t + 300 ms x speed) · poll inspect/metrics at 5 Hz"] + BUF["EventBuffer
drain(displayTime)"] + VIEW["RunView
applyEvents → panels"] + SCENE["Pixi scene
nodes · leader ring · dividers · pooled particles (≤6000)"] + STORE[("zustand store
TopBar · Inspector · Timeline · panels observe at ≤15 Hz")] + end + subgraph W["Web Worker per protocol — engine/worker.ts"] + WASM["wasm Sim(protocol, scenarioJson)
runUntil(t) → Traced[] · inspect(node) · metrics()
reset(t) = fresh Sim + runUntil(t)"] + end + CTRL --> BUF + BUF --> VIEW + VIEW --> STORE + BUF -->|"msg_sent → emit a particle"| SCENE + CTRL -->|"init · advance · step · reset · inspect · metrics"| WASM + WASM -->|"events, JSON"| BUF + WASM -.->|"inspect() · metrics()"| CTRL ``` -┌──────────────────────────── main thread ─────────────────────────────┐ -│ │ -│ React (zustand store) Controller (rAF loop, no React) │ -│ ┌──────────────┐ set() ≤15Hz ┌─────────────────────────────────┐ │ -│ │ TopBar │◀──────────────│ displayTime += dt × speed │ │ -│ │ Inspector │ │ clamp to min(worker.now) │ │ -│ │ Timeline │── seek/play ─▶│ per run: │ │ -│ └──────────────┘ │ buffer.drain(displayTime) │ │ -│ │ → applyEvents(view) (panels) │ │ -│ Pixi Scene(s) ◀── emit/update ─│ → scene.emit(msg_sent) │ │ -│ nodes · leader ring · dividers │ look-ahead: advance(t+300ms×s)│ │ -│ ParticleSystem (pooled, ≤6000) │ poll inspect()/metrics() 5 Hz │ │ -│ └────────────┬────────────────────┘ │ -└──────────────────────────────────────────────┼───────────────────────┘ - postMessage {init|advance|step|reset|inspect|metrics} - ▼ -┌──────────── Web Worker per protocol (engine/worker.ts) ──────────────┐ -│ wasm `Sim(protocol, scenarioJson)` │ -│ runUntil(t) → JSON Traced[] · inspect(node) · metrics() │ -│ reset(t): new Sim + runUntil(t) (engine is deterministic) │ -└──────────────────────────────────────────────────────────────────────┘ -``` + +One worker per protocol (`alpenglow`, `tower`); `engine/protocol.ts` is the exact `postMessage` +request/reply union. + Key ideas: @@ -102,6 +103,9 @@ Key ideas: before the first `configure()` and seeks to `t` once it resolves; `startUrlSync` mirrors the store back with a debounced `replaceState`, writing `t` only while the clock is paused. +The whole-project map (crates, engine loop, render pipeline, test map, limits) is +[`docs/architecture.md`](../docs/architecture.md); this section is the front end's own detail. + ## Lessons A lesson is a plain TypeScript object (`src/lessons/types.ts`) registered in