# Performance tuning notes

Running record of performance investigations: what was measured, where the time
goes, and the fix directions that follow. Add new findings as sections.

## Method

> **Timings come from criterion, 2026-09-21.** `crates/bench` holds the
> scenarios a user waits for — open, a frame, a frame at fit, a selection, a
> nudge, a drag frame, a drop — on the settled 50×50, each one kernel call as
> the frame loop makes it, over a container in memory so a commit pays for its
> rev. Two ways to run the same scenarios:
>
> - `cargo bench -p blockworx-bench` times them. `-- --save-baseline <name>`
>   before a change and `-- --baseline <name>` after compare the two with
>   confidence intervals; a number quoted here comes from this, never from a
>   single `Instant` around one call.
> - `cargo run --release -p blockworx-bench --example spans [scenario…]`
>   tallies each scenario's spans by nesting path, as a mean per call over 20
>   calls. That is *attribution*: the subscriber costs time of its own, so
>   quote criterion for totals and the tally for shares.
>
> The `#[ignore]`d timing tests below predate it and printed single samples
> with a span subscriber running; their numbers are indicative only.

> **The desktop recipes are history, 2026-09-18.** The egui shell went, and
> with it the `blockworx <document>` load path, the `--trace` flag and its
> subscriber (`docs/retire-egui-playbook.md`). Every bullet below but the first
> describes a build that no longer exists; they are kept because the findings
> cite them, and the numbers they produced stand as measured.
>
> **The instrument now is the browser's.** `tracing-web`'s `performance_layer`
> (`web/src/log.rs`) puts every instrumented span into the page's Performance
> timeline, so a DevTools CPU profile of a recorded interaction shows the spans
> and the wasm frames side by side; `dx build --release --keep-names --package
> blockworx-web` names those wasm functions, where the bundle
> `cargo xtask web build` ships does not. Timings accumulate over the page's
> life, so record a fresh profile per scenario. `cargo xtask autogen` and the
> `#[ignore]`d timing tests in the findings below are unaffected — they were
> never the shell's.

- Generate scale-test documents with `cargo xtask autogen scale <N> <out.json>`
  (an N×N grid of `Add` blocks, fully wired; `3N²+N` routes). See
  `docs/json-format.md`.
- Exercise the load/convert path headlessly: `blockworx <in.json>`.
  Loading re-routes every wire (schema stores no wire geometry), so this isolates
  the router.
- Profile a **release build with debug info**:
  `CARGO_PROFILE_RELEASE_DEBUG=true cargo build -p blockworx --release`, then
  `perf record -g --call-graph dwarf -o out.perf ./target/release/blockworx …`
  and `perf report -i out.perf --stdio`.
- For **real per-phase timing in the running app**, launch with `--trace`:
  `blockworx foo10.json --trace`. A `tracing` subscriber logs each instrumented
  span's busy time to stderr when it closes, nested `parent:child`. Filter with
  `RUST_LOG` (default `blockworx=info`). Instrumented spans: `frame` (one egui
  frame) → `tool_widget` → `render`, `hit_test_hover` / `hit_test_click`,
  `route_start`, and `route_update` → `router_rebuild`. A slow frame shows which
  child span holds the `time.busy` in seconds. `--trace` inits before the
  document opens, so `blockworx foo10.json --trace` prints the load-path
  breakdown too.

## Finding 1 — document load is O(routes²) in the router graph rebuild

### Measurements

Wall-clock to load/convert a grid (release):

| N   | routes (`3N²+N`) | load time |
|-----|------------------|-----------|
| 10  | 310              | 2.6 s     |
| 25  | 1900             | 96 s      |

Route count grows 6.1×, time grows 37× ⇒ implied exponent **1.99**: load time is
**quadratic in the number of routes**.

`perf` self-time for the N=10 load (10.4k samples):

| Symbol | Self % | What it is |
|---|---|---|
| `router::collect_intersections` | 26% | sweep-line finding wire/channel intersections |
| `BTreeMap::insert` | ~24% | building node/segment maps (in `collect_intersections` + `rebuild_graph`) |
| `RouterNG::update` | 17% | graph-rebuild orchestration + `normalize_collinear_segments` |
| slice sorting | ~15% | `events.sort()` inside `collect_intersections` |
| `dijkstra` | 0.5% | the actual pathfinding |

Pathfinding is **not** the bottleneck. ~85% of load time is spent *building* the
routing graph, not searching it.

`--trace` span timing corroborates this from the load path (N=10, debug build):
the single `route_update{routes=310}` span holds **311 nested `router_rebuild`
spans** — one full graph rebuild per route — accounting for ~97% of its busy
time. The graph is rebuilt from scratch once per route.

### Root cause

Load path: `finalize_load` → `route_update` (`src/widget/drawing.rs:1570`) builds
one router seeded from every block's channels, then loops the routes. Per route:

- `waypoint_path` → `path_find` calls `self.update()` before Dijkstra
  (`router/mod.rs:540`).
- `rip_and_reroute` → `add_existing_route` calls `self.update()` **again** to
  re-add the finished wire as occupancy (`waypoint_router.rs:118`).

`RouterNG::update()` (`router/mod.rs:362`) does a **full rebuild** whenever dirty:
it runs `collect_intersections` twice (lines 378, 404), re-normalizes every
segment, and rebuilds the whole petgraph. It is not incremental.

Finished wires stay in the router as segments (that is how later routes avoid
earlier ones), so the segment set each rebuild processes grows with each route:
route *k* rebuilds over base channels + *k−1* accumulated wires ⇒ Σ ≈ O(routes²).

Compounding constant: `collect_intersections`' `Scan` branch (`router/mod.rs:644`)
iterates over *all* active horizontal segments per vertical segment — O(V·H) per
rebuild on a dense grid.

### Fix directions (impact order)

1. **Don't rebuild the whole graph per route** (attacks the O(routes²) factor).
   Make `update()` incremental (re-intersect only newly added segments), or
   restructure `finalize_load` to build the graph once and only *augment* it per
   route instead of full-`update()`-ing twice per route.
2. **Drop the redundant second rebuild.** `add_existing_route` calling `update()`
   after every wire means two full rebuilds per route; defer occupancy
   re-integration to roughly halve rebuilds.
3. **Speed up `collect_intersections`** (lowers the constant). Range-query
   `active_h_segments` via `BTreeMap::range(start..=end)` instead of scanning all
   active entries; avoid re-sorting events from scratch each call.

The same `route_update` runs per drag/resize in the interactive editor, so these
wins apply to editing latency too, not just load.

### Resolved (fix direction 1)

Implemented via a **closed router**. `RouterNGBuilder::build_closed` seeds every
block/pin channel *plus* every route endpoint and waypoint up front, builds the
graph once, and returns a `ClosedRouter` whose geometry is frozen (no
segment/`update` methods exist on it — enforced by the type). Routing then never
rebuilds: `ClosedRouter::add_wire_cost` applies a completed route's `WIRE_COST`
occupancy by walking the wire's node-to-node graph edges and bumping their
weights in place (`edge_weight_mut`), which `successors` reads live. The load path
(`route_update_closed`, used by `finalize_load` and plain interactive commits)
builds one graph per block and routes all routes against it.

Result (N=10, `--trace`): `router_rebuild` drops from ~311 to **1**, and the
`route_update_closed` span from **2.59s → 110ms** (~24×) in release (debug load
~42s → ~1.4s). See `crates/router/src/lib.rs` (`ClosedRouter`),
`src/widget/waypoint_router.rs` (`waypoint_route`/`add_route_cost`), and the tests
in `src/widget/closed_router_tests.rs`. Fix directions 2–3 are subsumed (no
per-route rebuild remains).

### Resolved, part 2 — the drag/resize preview (B)

The drag/resize preview (`update_routes_dragging`/`_resizing`) had the same
per-route-rebuild bug in the *preview* loop: its "unchanged route" branch called
`add_existing_route` → `RouterNG::update()` — a full rebuild **per kept route** —
so a drag frame on the N=25 grid (1900 routes) did ~1900 rebuilds ≈ **~95s/frame**
(observed: dragging a comment box froze the app). `route_update_closed` was
generalized to take `overrides` + the group-drag `waypoint_preview` (with
`reroute_preview_closed` and up-front seeding of offset waypoints), and the old
`route_update`/`rip_and_reroute`/`reroute_preview` were removed. Now the preview
builds the graph **once per frame**.

Result (N=25, headless one-frame measurement): drag-preview frame time
**~95s → ~49ms** (~1900×) in release. Only live `RouteTool` drawing (incremental
waypoint geometry) still uses the mutable `RouterNG`.

Separately, **annotation shapes don't reroute at all**: comments/text/symbols
aren't in the routing graph, so `MoveBlock`/`ResizeBlock`/`MultiSelect` now gate
their `update_routes*` calls on `shape_affects_routing` (`Rect`/`Port` only) —
dragging a comment does zero routing work.

### Resolved, part 3 — live RouteTool drawing

`RouteTool` pathfound each frame against `scratch_router`, which looped existing
routes calling `add_existing_route` → `RouterNG::update()` — the same per-route
rebuild. On N=25 every frame (even idle hover) cost ~95s, so the tool was
unusable. Replaced with `Drawing::scratch_closed_router`: build the graph once
(obstacles + existing routes' endpoints/waypoints + the in-progress route's
seeds), apply existing occupancy in place, and route the in-progress leg with
`waypoint_route`. The preview is also skipped entirely on idle/hover frames.

Result (N=25, headless one-frame measurement): RouteTool preview frame
**~95s → ~44ms** in release. `debug_marks` moved to the closed router too.

With this, the mutable `RouterNG` path is fully retired from production: the old
`route_update`/`scratch_router`/`build_router_for`/`WaypointRouter` trait/
`add_subpath_cost` are gone, and `RouterNGBuilder::build` (the mutable
constructor) is now `#[cfg(test)]`-only, used just by the router's own unit
tests. All production routing goes through `ClosedRouter`.

## Finding 2 — selecting a shape reroutes the whole sheet (redundantly)

### Symptom

On the N=10 grid, a single click to select a block takes ~2.6s to "activate".

### Measurement (`--trace`, interactive click)

The click frame's own hit-test is trivial; the cost is a reroute fired *outside*
the tool:

```
frame:tool_widget:hit_test_click: close  time.busy=425µs      ← hit test is fine
frame:route_update{routes=310 ripup=0}:router_rebuild: …8ms…  ← ×310
frame:route_update{routes=310 ripup=0}: close  time.busy=2.59s ← the cost
frame: close  time.busy=2.59s
```

`ripup=0` and the `frame` (not `frame:tool_widget`) parent show nothing was
edited — the reroute is spurious.

### Root cause

`App::ui`'s action handler reroutes on *every* tool switch (`src/app.rs`, the
`Action::SwitchTool` arm):

```rust
Some(Action::SwitchTool(next_tool)) => {
    let mut drawing = Drawing::new(&mut self.state.document, &self.state.path);
    drawing.update_routes(&[]);   // full whole-sheet reroute
    self.tool = next_tool;
}
```

A selecting click returns `Action::SwitchTool(ResizeBlock::Selected)`, so
selection pays a full `update_routes` (Finding 1's O(routes²) path) despite
changing no geometry.

### Fix directions

1. **Don't reroute on a plain tool switch.** Geometry-mutating tools already call
   `update_routes` at their own commit points (move/resize/edit-route/flip/…), so
   the switch-time reroute is a redundant safety net for the common
   select/resize/route-selected switches. Drop it, or gate it on an
   `Action::SwitchTool { reroute: bool }` set only by the paths that actually left
   geometry dirty.
2. Finding 1's incremental-router work also removes the pain here, but (1) is a
   one-line, high-leverage fix that makes selection O(1) instead of O(routes²).

Hit-testing itself is not a bottleneck (~0.4ms/click); the label
`text_size` calls hit egui's galley cache as expected.

### Partially resolved (via Finding 1)

The redundant reroute on `SwitchTool` still fires, but `update_routes` now runs
the closed path, so the per-select cost fell from ~2.6s to ~110ms on N=10 — no
longer perceptible. Fix direction 1 (not rerouting on a plain tool switch at all)
is still worth doing to make selection truly O(1).

## Finding 3 — the ClosedRouter frame is ~78% graph rebuild (profiling before incremental work)

### Method

Repeatable benchmarks live in `crates/editor/src/widget/closed_router_tests.rs`
(ignored; `--release --ignored --nocapture`, `BENCH_FRAMES` overridable):
`route_tool_preview_frame_time_n25` times one interactive frame
(`Drawing::scratch_closed_router` on the N=25 grid — build the closed graph once +
apply occupancy for all 1900 existing routes), `drag_preview_frame_time_n25` the
drag path, `scale_10x10_loads_quickly` the load. Coarse phase spans
(`closed_build` / `closed_route_loop` / `closed_crossings`, plus `router_rebuild`)
are still in the tree; since 2026-09-18 they are read off a browser CPU profile
rather than `--trace`, or with a subscriber of the bench's own.

Profiled the frame with `perf` (release + debuginfo, ~39.5k samples over 200
frames):
`perf record -g --call-graph dwarf <test-bin> route_tool_preview_frame_time_n25 --ignored`.

### Frame cost (N=25, ~45ms/frame) — perf self-time

| Function | Self % | Phase |
|---|---|---|
| `collect_intersections` | 27.6% | build (sweep-line intersections) |
| `RouterNG::update` | 15.0% | build (orchestration + normalize) |
| `seed_horiz_channel` | 13.6% | build (channel seeding) |
| `seed_vert_channel` | 9.8% | build (channel seeding) |
| iterator folds / sort | ~12% | build (segment processing) |
| `ClosedRouter::add_wire_cost` | 2.0% | **occupancy** |
| `dijkstra` + heap + `successors` | ~3.5% | **routing** |
| allocator / misc | ~15% | — |

Roughly **~78% of the frame is rebuilding the graph from scratch**; the work that
actually changes frame-to-frame — in-place occupancy (~2%) and pathfinding the
one in-progress leg (~3.5%) — is negligible. The closed design's in-place
occupancy is doing its job; the cost is purely the from-scratch geometry rebuild
of a graph that is ~identical each frame (one block moved).

For contrast, the **load** path (`--trace`, all 1900 routes re-solved) is the
opposite shape: `closed_build` 50ms, `closed_route_loop` (1900 dijkstra solves)
**1.02s**, `closed_crossings` 4.5ms — there, routing dominates because every route
is solved once (unavoidable), not the build.

### Implications for incremental work

- The frame rebuilds the whole graph though only one block's geometry changed —
  so an **incremental/persistent graph** (re-seed and re-intersect only the moved
  region) targets that ~78% directly.
- Two hot spots stand out even short of full incrementality:
  1. **Channel seeding (~23%)**: each of ~3800 seed points calls
     `seed_horiz_channel`/`seed_vert_channel`, which scan **all** blocks to find
     the open channel extent — O(seeds × blocks) ≈ 3800×625 per frame. A spatial
     index over blocks (or caching per-coordinate extents) would cut this.
  2. **`collect_intersections` (~28%)**: a full sweep over all segments each
     frame; incremental intersection maintenance would avoid re-sweeping unchanged
     geometry.
- Occupancy and routing need no work — leave them.

### Resolved — channel seeding (hot spot #1)

`seed_horiz_channel`/`seed_vert_channel` scanned all blocks per seed. Since the
geometry is closed once all blocks are added, a per-axis block index is built
once (`BlockAxisIndex` in `crates/router/src/lib.rs`, the same `BTreeMap<Coord, Vec<_>>`
shape as the segment maps): `by_y[y]` / `by_x[x]` list the blocks whose
one-expanded extent contains that coordinate, so each seed iterates only the
blocks in its row/column band instead of all of them. The clip math is unchanged
(the index yields exactly the blocks that passed the old span gate), so routing
output is identical — the full suite still passes.

Result (N=25 frame, perf self-time): `seed_horiz_channel` 13.6% → **1.7%**,
`seed_vert_channel` 9.8% → **1.1%** (~8–9× each); frame ~47ms → **~35ms**. The
remaining frame cost is now `collect_intersections` (~37%) + `RouterNG::update`
(~17%) — hot spot #2, the from-scratch intersection sweep, which needs either a
faster sweep or the persistent/incremental graph (future C).

### Resolved — collect_intersections inner loop (hot spot #2, part 1)

`collect_intersections`' sweep Scan branch scanned *all* active horizontal
segments per vertical segment and range-tested each. Counters
(`#[cfg(test)]` `ci_stats`, driven by `collect_intersections_stats_n25`) on one
N=25 build measured **6,115,170 inner-loop iterations producing only 87,774
intersections — 69.7× wasted work** (~98.6% of tests fell outside the vertical
segment's span). `active_h_segments` is a `BTreeMap<CoordY, usize>` keyed by `y`
holding only count-positive entries, so the scan+filter was replaced by a range
query `active_h_segments.range(start..=end)` — yielding exactly the intersecting
segments. Output is identical (same intersection set; suite passes).

Result: inner-loop iterations **6.12M → 87.8k** (waste 69.7× → **1.0×**);
`collect_intersections` self-time **37% → 16%**; N=25 frame **~35ms → ~26.6ms**.

Frame progression (N=25 RouteTool preview): closed router ~47ms → block index
~35ms → range query **~26.6ms**. The remaining build cost is now
`RouterNG::update` orchestration (~25%: the two-pass normalize / re-segment /
`rebuild_graph`) and `collect_intersections`' event build + sort — both consequences
of the **two-call** structure (`update` runs `collect_intersections` twice and
`normalize_collinear_segments` four times), the next opportunity.

### Resolved — drop the redundant second intersection sweep (hot spot #2, part 2)

`update()` ran a two-pass planarization: pass 1 normalizes the raw seeded
segments and runs `collect_intersections` (call #1) to find crossings; pass 2
injects a zero-length marker at each crossing, re-normalizes (which splits every
segment at those crossings, so each crossing becomes a segment endpoint), then
ran `collect_intersections` AGAIN (call #2) and unioned with all segment
endpoints. Since splitting made every crossing an endpoint, call #2's result is
already contained in the endpoint set — it was pure recomputation, and the
*expensive* one (it swept the finely-split segments: ~91k events vs ~3.5k for
call #1).

Dropped call #2: the final node set is now just the endpoints of the split
segments. Verified byte-identical routed geometry (FNV-1a fingerprint over all
route edges) before/after on the N=10 grid AND demo.kdl (waypoints, T-junctions,
nested blocks); the `ci_stats` `calls` counter went 2 → 1; full suite passes.

Result (N=25 frame): **~26.6ms → ~17.5ms**.

Frame progression (N=25 RouteTool preview): closed router ~47ms → block index
~35ms → range query ~26.6ms → drop-2nd-sweep **~17.5ms** — ~2.7× over the naive
closed build. The remaining ~17ms is `update`'s two *normalize* passes (merge
then split, per axis) + `rebuild_graph` + the single `collect_intersections` +
sort; further wins need the persistent/incremental graph (future C) that avoids
rebuilding a near-identical graph every frame.

## Finding 4 — RouteTool: cache the router across a draw gesture

While the RouteTool is drawing, the routing graph is fixed (obstacles + existing
routes don't change; the in-progress route isn't committed). Only the cursor
(`head`) moves per frame, and adding a waypoint is the one event that changes the
graph's inputs. But `update_preview` rebuilt the whole `ClosedRouter` every frame
(`scratch_closed_router`, ~17ms on N=25).

Fix (`route_tool.rs`): build the router once when a route starts, cache it on the
tool keyed by `(start anchor, waypoint count)`, and route each frame against the
cache — rebuilding only when a waypoint is added (or a new route starts; the
cache is dropped when the gesture ends, covering commit changing the set of
existing routes). The moving `head` is not seeded, so its trailing leg uses the
existing L-path fallback (correct rubber-band preview). Preview routing is
read-only (`waypoint_route(.., apply_self_cost=false)`) so the cache never
accumulates the in-progress route's own occupancy between frames. Commit is
unchanged (a full build with the finish seeded), so committed geometry is
byte-identical (`scale_load_geometry_fingerprint` unchanged; suite passes; a
`cached_preview_matches_fresh_build_and_stays_pristine` test proves the cache
routes identically to a fresh build for every cursor position and isn't corrupted
across frames).

Result (N=25): the ~17ms per-frame **build is eliminated**. Per-frame cost is now
just routing: **~free** for the common case (0 waypoints → the head leg is a
fallback), and for a multi-waypoint route the committed `start→waypoint` legs
(~8ms for a long leg here). Rebuild is paid once at route-start and once per
waypoint added. Does **not** address the block-move case (still a full rebuild per
frame). Natural follow-up: the committed legs don't change while only the head
moves, so caching that sub-path and recomputing only the head fallback would make
every preview frame ~free regardless of route length.

### Resolved — cache the fixed legs too (every preview frame ~free)

Since only the cursor moves per frame, the `start → … → last waypoint` legs are
invariant until a waypoint is added. Split `waypoint_route` into `route_fixed_legs`
(the committed portion, computed once with the router) and `route_to_head` (append
the trailing head leg, the only per-frame work — the head is not a graph node so
it's the L-path fallback). The RouteTool caches the `FixedLegs` alongside the
router. Verified equal to the monolithic route by the
`cached_preview_matches_fresh_build_and_stays_pristine` test.

Result (N=25, a route with a distant waypoint): per-frame preview **~8ms → ~170ns**
— routing cost no longer depends on route length. The full-build cost is paid once
at route-start and once per waypoint added; every intervening frame is ~free.

## Finding 5 — crossings (hop) detection: O(E²) → sweep-line

`compute_crossings` (`auto_route.rs`) detected route hops with a double loop over
every horizontal edge × every vertical edge — O(H·V). It's called once per
`route_update_closed`, i.e. every frame during a block/port drag. It never showed
up in the RouteTool profiles because `scratch_closed_router` (the RouteTool path)
doesn't call it, and in the load trace it was 4.5ms buried under the 1.02s route
loop — but on a drag frame it's real and grows quadratically:

  N=10  218K pairs  113µs      N=25  8.3M pairs  3.82ms      N=35  31.5M pairs  14.24ms

Replaced with an x-sweep (mirrors the router's `collect_intersections`): horizontal
edges are active over `[x_lo, x_hi]`; each vertical edge range-queries the active
horizontals whose row is strictly inside its y-span (`BTreeMap<y, Vec<idx>>`), with
an explicit strict-x test so the result is independent of event tie-ordering.
O((H+V)·log + crossings). Byte-identical output — verified by
`sweep_crossings_match_brute_force` (equals the O(N²) reference as a sorted set per
route) and an unchanged crossings fingerprint on demo.kdl.

  N=10  77µs (1.5x)     N=25  580µs (6.6x)     N=35  1.55ms (9.2x)

The speedup widens with size (time now tracks edge count, not the pair product).

## Finding 6 — the paint path regenerates the whole scene every frame (no viewport culling)

A separate investigation from Findings 1–5 (which were all *routing*). This one is
the **paint path** — the per-frame CPU work that turns the current level's shapes
into egui geometry — and idle/interaction responsiveness when *no* routing runs.

### Method

- New headless paint bench: `src/widget/render_bench.rs` (ignored;
  `cargo test --release <name> -- --ignored --nocapture`, `BENCH_FRAMES`
  overridable). It runs a **real egui pass** (`Context::run_ui` + `Context::tessellate`)
  over the N×N scale grid at a controlled `(zoom, translation)`, with the app's
  real fonts installed, and reports where a frame's paint time goes:
  - `generate` — our render closure (`display::render` → `DrawingPasses::draw`):
    world→screen transform, text galley layout, and building the `Shape` list.
    **No culling** — every shape is emitted.
  - `tessellate` — epaint turning those shapes into triangles. This stage *does*
    clip-cull shapes fully outside the viewport.
  - counts: shapes emitted, how many fall fully outside the viewport (`cullable`),
    text shapes, tessellated vertices.
  A **cold** pass (fresh `Context`, empty galley cache) vs a **warm** pass
  (galleys cached) isolates text-layout cost — the cost paid every frame while
  *zooming*, since a zoom change is a new font size and thus a cache miss.
  `paint_frame_breakdown_n50`/`_n25` print the table; `paint_pass_attribution_n50`
  prints per-pass busy time.
- New per-pass `tracing` spans in `DrawingPasses::draw`
  (`pass_bg_symbols`/`pass_comments`/`pass_blocks`/`pass_ports`/`pass_routes`/
  `pass_texts`/`pass_fg_symbols`), so the same breakdown is visible under `--trace`
  on the live app (zoom in vs out and compare) — the preferred methodology.

> **`render_bench` is retired 2026-09-18**, with the egui tessellation it
> measured; the `tessellate` column and the vertex counts below have no
> counterpart in the browser, where `replay` makes Canvas 2D calls instead. The
> per-pass spans are still in `crates/editor/src/widget/scene.rs` (`pass_areas`,
> `pass_block_bodies`, `pass_icons`, `pass_block_pins`, `pass_ports`,
> `pass_routes`, `pass_texts` …) and now read off the Performance timeline
> rather than `--trace`: zoom in and out under a CPU profile and compare. What
> carried over is the shape of the finding — no culling, and text layout paid
> again at every zoom step — not the numbers.

### Measurements

50×50 grid (2500 blocks), viewport 1600×1000:

| scenario           | zoom  | gen(cold) | gen(warm) | tessel | shapes | cullable   | text  | verts  |
|--------------------|-------|-----------|-----------|--------|--------|------------|-------|--------|
| fit (all visible)  | 0.029 | 16.0ms    | 12.7ms    | 11.2ms | 55550  | 0 (0%)     | 25200 | 418278 |
| zoom 1.0 (center)  | 1.000 | 17.1ms    | 12.4ms    | 4.7ms  | 55550  | 55534 (100%)| 25200 | 2768   |
| zoom 2.0 (center)  | 2.000 | 17.5ms    | 12.6ms    | 5.0ms  | 55550  | 55537 (100%)| 25200 | 2456   |

25×25 grid (625 blocks): gen(warm) ~2.6ms at every zoom (14025 shapes). Scaling
25→50 (4× blocks) grows gen ~4.8× — **`generate` is O(total shapes), independent
of zoom**.

Per-pass (50×50, fit, warm frame, `render` total 12.2ms):

| pass          | busy   | share |
|---------------|--------|-------|
| `pass_blocks` | 9.12ms | 75%   |
| `pass_routes` | 2.90ms | 24%   |
| `pass_ports`  | 132µs  | 1%    |
| everything else | <3µs | ~0%   |

(`pass_texts` is the standalone text-box layer, empty on this grid; the grid's
25200 text galleys are pin names / titles / type labels drawn *inside*
`pass_blocks`, and route labels inside `pass_routes`.)

### Root cause

`DrawingPasses::draw` (`src/widget/scene.rs`) iterates **every** shape and route
on the level with no viewport test, and `Painter` (`crates/egui/src/painter.rs`)
transforms world→screen and pushes each shape — laying out each text galley —
**unconditionally**. egui clips at tessellation, so a shape fully outside the
viewport costs ~nothing to *tessellate/draw* — but everything upstream (the
transform, the `Shape` allocation, and especially `Painter::text`'s galley layout)
has already run. Zoomed in, **99.97% of that generate work is discarded** by the
clip.

### Implications (this is why "zoomed in isn't snappier")

- `generate` is O(total shapes), not O(visible), so a zoomed-in frame does the
  **same ~12.5ms** as a zoomed-out frame — the user's exact complaint. The
  viewport clip only saves the *tessellate* stage (11.2ms → 4.7ms).
- A 50×50 idle frame is ~17ms zoomed in (12.5 gen + 4.7 tess) and ~24ms zoomed
  out (12.7 gen + 11.2 tess) — i.e. ~40–60fps, matching the observed sluggishness.
- Text layout is ~3.4ms of the block pass on a **cache miss** (cold−warm), i.e.
  the *extra* per-frame cost while actively zooming/panning (every zoom = new font
  size = miss).
- Blocks are 75% of generate, routes 24%.

### Fix directions (impact order)

1. **Viewport-cull the generate stage** — the big win for zoomed-in
   responsiveness. Thread the visible world rect into `DrawingPasses::draw` and
   skip any block/route/shape whose bounds (expanded by stroke width + a label
   margin — titles sit above the rect, pin names beside it; routes can extend past
   their endpoints, so use the route's own bbox) don't intersect it. A plain
   linear filter is already a huge win (a rect test per shape vs 12.5ms of layout):
   expected zoomed-in `generate` ~12.5ms → **O(visible) ≈ µs**, making zoom-in
   snappy. The interactive previews (drag/marquee/route) share `DrawingPasses`, so
   they benefit too. **Cheapest, highest-leverage — do first.**
2. **Level-of-detail at wide zoom** — for the fit/zoomed-out case, where culling
   can't help (everything *is* visible) and the user has said fidelity can be
   traded for speed:
   - **Skip text below a legibility threshold.** At zoom 0.029 the text is <1px
     and unreadable, yet 25200 galleys are laid out and tessellated (most of the
     418k verts, most of the 11ms tessellate, and the cold−warm ~4ms). Gate text
     emission on `font.size * zoom >= ~5px`. Cuts both `generate` (layout) and
     `tessellate` sharply at wide zoom.
   - Optionally simplify blocks below a pixel size (drop pin stubs / rounding /
     type label).
3. **Spatial index for culling** — only if the linear filter in (1) becomes the
   floor (it makes `generate` O(total)-filter, not O(visible)). A uniform grid or
   R-tree over shape bounds returns the visible set directly. Mirrors the router's
   `BlockAxisIndex`/`BTreeMap` approach (Finding 3).
4. **(Low priority) The text path lays out each row twice** — `Painter::text_wrapped`
   lays out the whole galley once for bounds, then `layout_no_wrap` per row for the
   baseline-corrected draw. Single-pass or caching would cut the text constant, but
   (1)+(2) subsume most of it.

**Threading** (the other hypothesis): generate is single-threaded, but after (1)+(2)
the visible/legible work is small, so parallelizing helps only the zoomed-out case
and fights egui's non-`Send` shape accumulation. Defer until (1)+(2) land and
re-measure — likely unnecessary.

### Resolved — viewport culling + hit-test index (one shared spatial index)

Both the render broad phase and hit-testing were the same O(all-objects) scan, so
one structure serves both: an `rstar` R*-tree over the current level's hittables
(blocks/ports/texts/symbols/comments + routes), keyed by a coarse Painter-free
box (`Bounded::bounds`, `src/render/bounds.rs` — `gui_rect` + a char-count label
estimate, never a galley). The index (`src/widget/spatial.rs`, `SpatialIndex`)
is cached on `App`, rebuilt on coarse triggers (any edit action, pointer-down, or
level change; idle/hover frames reuse it — a full 50×50 rebuild is ~2ms over
**10,150 entries**, one per logical object, not per egui shape). The per-frame
`Drawing` borrows it (`new_indexed`); render culls each layer to the on-screen set
(`DrawingPasses::draw` via `Renderer::visible_world_bounds`), and the `_at_pos`
hit-testers iterate index candidates instead of `shapes()`/`auto_routes()`, keeping
their exact fine-phase tests. Backends without an index (SVG export, tests) fall
back to the full scan, so their output is unchanged.

Result (50×50, `paint_frame_breakdown_n50` / `hit_test_time_n50`):

| metric | before | after |
|---|---|---|
| zoomed-in `generate` (zoom 1–2) | ~12.2ms | **~0.19ms** (~65×) |
| egui shapes emitted zoomed-in | 55,550 | **~15** |
| `shape_at_pos` over the grid | 28.8µs | **0.63µs** (~46×) |

Correctness: `index_matches_linear_hit_tests` verifies the indexed and linear
`_at_pos` queries agree at **193,617** probe points (route candidates are re-sorted
to `auto_routes()` order so wire-crossing tie-breaks match). Full suite green; the
wasm build compiles with `rstar`.

Caveat — the **fit / fully-zoomed-out** case (everything on screen, nothing to
cull) is ~1.6ms slower from the index query + membership overhead. That view is
tessellation-bound (~26ms) regardless; it's the target of the **level-of-detail**
work (Finding 6, fix direction 2: skip sub-legible text at wide zoom), still
outstanding.

## Finding 8 — a nudge's 468 ms is mostly *re-deriving* what the rider just solved (2026-09-18)

> Two fixes found by the profile below took the same nudge to **200 ms**: the
> router's node map (a `BTreeMap` that wanted hashing) and `Obstacles::hugs`
> (a linear scan that wanted the index beside it). Neither touched the
> structural finding this is titled for — the sheet is still reconstructed
> twice per commit.

Measured on the settled 50×50 (7,550 wires, every corner stored), release,
native, through `a_nudge_on_the_settled_grid_is_timed`. One block nudged one
cell; **no wire's geometry needs to change**.

| Phase | Time | |
|---|---|---|
| `present_document` | **329 ms** | re-derives every wire from scratch |
| ↳ `router_rebuild` | 47 ms | *inside* the above |
| `route_update_closed` (the solve rider) | **108 ms** | |
| ↳ `closed_build` (seeds = 31,814) | 56 ms | of which `router_rebuild` 46 ms |
| ↳ `closed_route_loop` | 35 ms | |
| ↳ `closed_crossings` | 7 ms | |
| `write_rev` | 8 ms | serialize 4.7, gzip 2.5 — 2.2 MB JSON → 231 KB |
| `tool_widget` (the frame's draw) | 5.6 ms | |
| `fold` (2 ops) | 0.5 ms | |

**The rider is not the hot spot.** It is 23% of the nudge; the re-derivation
after it is 70%. The snapshot write — the thing a whole-document-per-commit
format might be expected to cost — is under 2%.

The shape of the waste is exact: the rider solves every wire into a *scratch*
presentation (`Presentation::scratch` clones the geometry and stamps it
current), promotes the corners, and the scratch is dropped. The commit then
mints a new `DocStamp`, so `refresh_routes` sees `routes_stamp != doc.stamp()`
and reconstructs all 7,550 wires — building the closed router a second
time. Two whole-sheet passes, and the second one recomputes what the first
already had.

Note `fold{ops=2}`: the commit carried **two** ops. Every other wire was
promoted, found identical, and filtered by `commit_route_edit`'s
`if edit.waypoints != route.waypoints`. So the work is done for 7,550 wires
to write 2 — the filter saves the *log*, not the time.

Within the rider, the router *build* (56 ms) costs more than the routing
(35 ms). Seeds are 31,814: two endpoints per wire plus every stored waypoint.

### Inside `RouterNG::update` (the `router_rebuild` span), 45 ms

| Phase | Time | Size |
|---|---|---|
| `normalize` | 2.6 ms | 356 horizontal rows, 256 vertical |
| `intersections` | 5.1 ms | the sweep |
| `resegment` | 7.1 ms | 90,536 crossings injected as zero-length markers |
| `endpoints` | 8.3 ms | the node set, off the split segments |
| **`rebuild_graph`** | **22.0 ms** | **101,761 nodes** |

`rebuild_graph` was half the build. Splitting it located the cost exactly:

| | before | after |
|---|---|---|
| `add_nodes` (101,761) | 5.3 ms | 2.1 ms |
| `add_edges` (186,684) | 17.2 ms | 3.4 ms |
| `rebuild_graph` | 22.6 ms | **5.5 ms** |
| `router_rebuild` | 46 ms | **29 ms** |
| the whole nudge | 468 ms | **417 ms** |

**It was the map, not the graph.** `node_to_index` was a
`BTreeMap<Point, NodeIndex>` looked up *twice per segment* — 372k lookups
into a 101k-entry tree, ~17 comparisons and a pointer chase each — which is
why the edge loop cost three times the node loop. It is now an `FxHashMap`:
the key is two coordinates, nothing here is reachable by an attacker, and the
map is never iterated, so ordering was not load-bearing and determinism is
untouched.

**`Graph::with_capacity` does not help**, tested for: `add_nodes` measures
1.8–2.8 ms with or without it, over three runs each. `add_node` pushes to a
`Vec` with amortized doubling, whose whole reallocation cost is a couple of
memcpys — next to nothing beside the hash insert on the same line. Reserving
is the right instinct and the wrong bottleneck, so it is not in the tree.

The nodes are a `BTreeSet`, so they are genuinely distinct — there is no
duplicate-node bug inflating the count.

### Inside `present_document` (312 ms), and the linear scan in it

| Phase | before | after |
|---|---|---|
| `anchors` | 5.7 ms | 6.1 ms |
| `obstacles` | 2.0 ms | 2.0 ms |
| **`straighten`** (7,550 wires) | **238 ms** | **13.7 ms** |
| `route_deferred` (102 of them) | 57 ms | 46 ms |
| `crossings` | 7.7 ms | 7.9 ms |
| `present_document` | 312 ms | **76 ms** |

`straighten` was three quarters of the re-derivation, which is surprising for
the phase that is supposed to be the *cheap* one: it draws a leg directly when
the leg is axis-aligned and unobstructed, and hands the rest to the router. Its
cost is the obstacle test, and one half of that test had no index.

`Obstacles` keeps a row and a column occupancy index, and `blocked` queries it
— that index exists because a linear scan there "showed up as ~20 s of load
overhead at scale", as its own comment records. `hugs` did not use it, and said
why:

> Unlike `blocked`, a hugging wire is adjacent to — not overlapping — the
> block, so the row/col occupancy index doesn't apply; every rect is tested.

Adjacency is not outside an index, only offset from it. A rect with an edge
within `ROUTE_GUTTER` of row *y* spans one of the rows `y-gutter ..= y+gutter`,
so it sits in one of *their* buckets: the query is the same range query, a
gutter wider on each side. A rect spanning several of those rows is tested more
than once, which costs less than deduplicating would.

The arithmetic that was there before: 7,550 routes × ~3 legs × ~2,500 rects ≈
**56 million** rect tests, which is the 238 ms. **`straighten` 238 → 13.7 ms,
17×**, and with the node map above, the whole nudge **468 → 200 ms**.

### The ~100 standing deferrals were a broken fixture, and they were why the
### lattice was built twice

`reconstruct_routes`' phase 2 is gated on `!deferred.is_empty()`, and on the
settled 50×50 *every* pass after the settle deferred exactly 100 wires — all of
them the sheet's `in` wires. Per-pass tallies, by the clause that refused each
leg:

```
pass (deferred 5100): {'skew diagonal': 5100}      ← the settle, no corners stored yet
pass (deferred  100): {'blocked horizontal': 100}  ← and then the same 100, every pass
pass (deferred  100): {'blocked horizontal': 100}
pass (deferred  102): {'hugs horizontal': 100, 'hugs vertical': 2}   ← the nudge
```

Not the stored waypoints. **The start anchor of all 100 was the same point**,
`(1,1)`, inside `block_0_0` at `(0,0)..(8,8)` — 50 distinct ports (`p1`…`p50`)
all resolving to one inaccessible spot, while the `sum` wires resolved
distinctly and straightened fine:

```
PROBE DEFER r99  name="in"  from=p50  to=p9901 start=(1, 1)  end=(-1, 786) start_ok=false
PROBE fine  r101 name="sum" from=p103 to=p105  start=(10, 2) end=(15, 2)   start_ok=true
```

**The cause was the fixture, not the router.** A scope draws its own boundary
ports as free-standing shapes placed by `Pin::rect` — whose own doc comment says
it is *"the port body's placement inside the block's own interior view — a
different scope from `slot`, which places the pin on the block-as-child."*
`fixtures/scale.rs` set `slot` and left `rect` at its default, so all 100
boundary ports stacked at the origin, under `block_0_0`. Placing them in the
sheet's margins, level with the row each serves, fixes it.

| | before | after |
|---|---|---|
| deferrals per pass after the settle | 100–102 | **0** |
| `present_document` | 76 ms | **27 ms** (no router built at all) |
| the nudge | 197 ms | **~140 ms** |

**And it retires the "built twice per commit" finding this section opened with.**
That second build existed only to route the deferred wires. With none deferred,
`present_document` skips phase 2 and the lattice is built **once** — in the
rider's `closed_build`.

What survives as a latent difference, unexercised now but still in the code:
`reconstruct_route` drops inaccessible corners **only when routing**
(`if router.is_some() && !obstacles.accessible(pos)`), so a corner inside a block
would still straighten as `blocked` forever while the router silently deleted it.
No wire in the tree has one any more. Also unconfirmed: `seed_horiz_channel`
expands each blocking rect by one before clipping while `seed_vert_channel` does
not, so the router may open a channel where `hugs_wire` refuses a leg.

### Where the nudge's 67 ms goes (2026-09-20)

Almost none of it is routing. The pathfinding a nudge does is **0.07 ms**.

| | |
|---|---|
| `dispatch` | **31 ms** |
| ↳ the rider (`route_update_closed`) | 12.1 — gathering endpoints and seeds for all 7,550 wires (~8), crossings over all 7,550 (3.8), **solve 0.07** |
| ↳ `write_rev` | 7.9 — serialize 2.2 MB of JSON (4.9) + gzip (2.5) |
| ↳ `emit_move` | 3.2 — the collision check and the ops |
| ↳ `fold{ops=1}` | 0.7 |
| ↳ store and history bookkeeping | ~7 |
| `present_document` | **25.9** — `straighten` 13.3, `anchors` 5.7, `crossings` 3.8, `obstacles` 2.0 |
| `canvas_frame` (the draw) | 4.6 |
| kernel overhead (`observe`, the command set) | ~6 |

**What is left is per-wire work over the whole sheet, done because one block
moved.** Three separate passes walk all 7,550 wires — the rider's gathering and
its crossings, and `present_document`'s straighten, anchors and crossings —
for an edit that changed nine of them. Scoping those to the region, as the solve
already is, is what remains; under it sits `write_rev`'s 7.9 ms, which is the
floor while every commit writes a whole-document snapshot.

### Where the nudge's ~140 ms went

| Phase | Time |
|---|---|
| `route_update_closed` (the rider) | 86.6 ms |
| ↳ `closed_build` | 43.1 ms, of which `router_rebuild` 32.5 |
| ↳ `closed_route_loop` | 30.3 ms |
| ↳ `closed_crossings` | 4.0 ms |
| `present_document` | 27.0 ms |
| ↳ `straighten` | 14.1 ms |
| ↳ `anchors` / `crossings` / `obstacles` | 5.9 / 3.8 / 2.0 ms |
| `write_rev` | 8.3 ms |
| `tool_widget` (the draw) | 4.8 ms |
| `fold{ops=1}` | 1.1 ms |

One lattice build, and two per-wire passes over all 7,550 wires. The design for
what is left is `docs/background-router-playbook.md`.

### Where the 200 ms went (before the fixture fix)

| Phase | Time |
|---|---|
| `route_update_closed` (the rider) | 92 ms |
| ↳ `closed_build` | 43 ms |
| ↳ `closed_route_loop` | 32 ms |
| ↳ `closed_crossings` | 7.4 ms |
| `present_document` | 76 ms |
| ↳ `route_deferred` | 46 ms |
| ↳ `straighten` | 13.7 ms |
| `write_rev` | 7.7 ms |
| `tool_widget` | 5.7 ms |

Both halves are now dominated by building the same router twice.

**And this runs twice per commit, on identical input.** Both builds report
the same 356/256 rows, the same 90,536 crossings and the same 101,761 nodes:
once inside the rider's `closed_build`, once inside `present_document`.
Neither knows the other did it.

Reproduce:
`cargo test --release -p blockworx-kernel a_nudge_on_the_settled_grid_is_timed -- --ignored --nocapture`

## Finding 7 — view-at-rev is cheap; the D12 state stamp at load is not

> **Retired 2026-09-05 by P5** (`docs/log-vs-snapshot.md` S3): there is no log
> to replay and no folded-state stamp to recompute. Both costs below are gone,
> and `Verify::{Sampled, Full}` and `STAMP_SAMPLE` with them. Kept as the
> evidence the format was re-decided on — Finding 9 is what replaced it.

Measured for Phase 4 (the time machine, F7) on a synthetic container: the
`fixtures/block50.json` grid lowered into a container, plus 300 one-op move
commits after it — 301 records, 2501 blocks, a 7.4 MB `log.jsonl`. Release
build; the `fold_at_rev` span in `src/app.rs` reports the fold under `--trace`.

| step | time |
|---|---|
| prefix fold to rev 1 (the whole seed commit) | 3.6 ms |
| prefix fold to rev 150 | 27 ms |
| prefix fold to rev 300 | 52 ms |
| **verified replay of all 301 records at open** | **16.6 s** |

**View-at-rev needs no cache.** Scrubbing the whole 300-commit history one rev
at a time costs tens of milliseconds per pick on a 2500-block document, and a
rev is picked by hand rather than per frame. D4's "fold per selection,
snapshots deferred" stands; nothing here argues for building a snapshot record.

**The cost is `Document::content_hash` per record.** Folding all 301 commits
takes ~52 ms, and parsing 7.4 MB of JSON is a fraction of a second — so
essentially all of the 16.6 s replay is D12's folded-state stamp, which
re-serializes the *whole* document (ciborium over every entity, sorted) once
per record. That is the O(document)-per-commit cost D12 named and deferred a
decision on, and this is the evidence it asked for: on a big document the
stamp, not the fold, is what makes a cold open slow, and it grows as
`records × document size`. The escalation D12 already reserves — stamp every
Nth record plus every save, and verify the rest by chain alone — would cut
this by that factor with no format change (the field stays; some records
simply carry the previous stamp's rev). Not done here: it is a Phase 2
decision reopened on Phase 4's evidence, and it belongs with whoever owns the
durable format next.

### Resolved — verification became sampled; the format did not move

The escalation D12 reserved, taken on the evidence above. **Writers are
unchanged**: every record still carries its `state`, and `src/store/goldens/log.jsonl`
is byte-identical — what changed is how much of it a *load* recomputes
(`Verify::{Sampled, Full}`, `src/store/replay.rs`). Every chain link is still
checked on every record; the state stamp is recomputed on the **head** (the
document the session opens on) and on every `STAMP_SAMPLE`th record — 32.
`blockworx verify <container.bwx>` is the fsck: `Verify::Full`, every stamp,
non-zero exit with a spanned report on any fault. `blockworx log` reads
history, so it samples like the editor.

Same container (301 records, 2501 blocks, 7.5 MB log), release build:

| step | before | after |
|---|---|---|
| `Store::open` (cold, whole log) | 16.6 s | **0.84 s** (~20×) |
| `blockworx log` | 16.6 s | **0.80 s** |
| `blockworx verify` (every stamp) | — | 16.4 s |

Where the remaining 0.84 s goes: one stamp on this document costs ~54 ms, and
sampling at 32 leaves 10 of them (~0.54 s). With stamping disabled entirely the
same open is **0.37 s** — parse, fold, and the two stamps that are always
checked. So the open is *still* stamp-dominated, and `STAMP_SAMPLE` is the dial:
it trades open time against how tightly a drift report can be blamed. It stays
at 32 because the numbers above are already under a second and a wider spacing
buys ~0.4 s for a much coarser answer.

The cost of the trade, stated honestly rather than hidden: a sampled load can no
longer name the record a drift began at, only that it is *at or before* the
sample that failed (`Fault::Drift` carries `Blame::{ThisRecord, AtOrBefore}`,
and the message names the last rev whose stamp was recomputed). This is not a
hole in D12's coverage of real fold drift — a fold that changes changes every
stamp after it, so it reaches the head, which is always checked. What sampling
misses is a *single* hand-edited `state` field sitting between two samples;
`blockworx verify` is what finds that, and `src/store/tests.rs` asserts both
halves of the trade as tests.

## Finding 8 — a rev file costs 17 ms on the 2,500-block document

> **The gate half is retired 2026-09-05 by P5**: the log is gone, so there is
> nothing to compare a rev file against and the debug re-fold below no longer
> runs. The write cost stands and is still the reproducer named here.

Measured 2026-09-05 for P3's dual-write (`docs/log-vs-snapshot.md` S4), release
build, on `fixtures/block50.json` — 2,501 blocks, 20,160 entities — written as
one rev by `src/store/revs.rs::write`. The reproducer is the `#[ignore]`d
`writing_the_biggest_fixture_is_timed`:

```console
$ cargo test --release --lib -- --ignored --nocapture writing_the_biggest_fixture
block50 rev write: 16.7ms, 123885 bytes
```

Serialize (compact `serde_json`) + zstd −1 + an atomic write with two fsyncs:
**16.7 ms, 121 KB**. §3 projected "roughly 10 ms of write cost per commit,
which is under the fsync it already needs"; the measured number is the same
order and confirms the conclusion — this is the largest document in the tree,
and a realistic diagram (`fixtures/demo.json`, 21 KB) is three orders of
magnitude smaller. No dial to turn: §14.2 already weighed −9 (a fifth fewer
bytes for five times the time) and took −1.

The cost that is *not* the write: while the log was still authoritative, a
**debug** build re-folded the whole log on every open to compare it against the
rev files (P3's correctness gate). Measured on `store::tests::persistent_undo`,
whose proptests open containers repeatedly: 35 s → 62 s. P5 deleted the log, so
the gate has nothing left to compare and the same suite is back to **17 s**.

## Finding 9 — opening a container is one manifest and one rev file

Measured 2026-09-05 for P5 (`docs/log-vs-snapshot.md` S3), release build, on the
container Finding 7 was measured on: `fixtures/block50.json` seeded as rev 1 —
2,501 blocks, 20,160 entities — plus 300 one-op move commits after it. 301 rows.
The reproducer is the `#[ignore]`d `store::tests::measured`:

```console
$ cargo test --release --lib -- --ignored --nocapture measured
block50 rev write: 17.2ms, 123885 bytes
block50 open: 22.7ms over 301 rows (100590 bytes of manifest)
```

| step | log (Finding 7) | manifest + revs |
|---|---|---|
| cold open, fully verified | 16.6 s | — |
| cold open, sampled | 0.84 s | — |
| cold open, unstamped | 0.37 s | **22.7 ms** |
| view at rev 300 | 52 ms | one file read |
| `blockworx verify` | 16.4 s | blake3 over 301 rev files |

**37× faster than the fastest thing the log could do, and it verifies more.**
The open reads a 98 KB `manifest.jsonl`, hashes every row to check D12's chain,
reads `revs/000301.json.zst` and re-attaches its payloads. Nothing folds, and
nothing re-serializes a document to hash it: the state digest is the blake3 of
bytes somebody else already wrote, so it costs a read rather than a
`Document::content_hash`. `Verify::{Sampled, Full}`, `STAMP_SAMPLE` and the dial
Finding 7 argued about are all deleted — there is nothing left to sample.

What the open does *not* check, stated so it is not assumed: only the **head**
rev file is hashed against its row. An older rev whose bytes were tampered with
is a `blockworx verify` finding, and an undo to it refuses
(`Refusal::Unreachable`) rather than showing the wrong document.

## Finding 10 — where the 50×50 stands, measured (2026-09-21)

The first criterion run (`settled_50x50`, baseline `p10`, after the regional
router's P10), native release, 10 samples each:

| scenario | mean | what it is |
|---|---|---|
| open | **37.7 ms** | the first frame: every wire re-derived from its corners |
| frame | **4.9 ms** | an idle frame at the opening zoom |
| frame_at_fit | **12.7 ms** | the same with all 2,500 blocks and 7,550 wires in view |
| select | **9.9 ms** | a selection change and the frame that draws it |
| nudge | **49.7 ms** | a nudge of one block and the frame after it |
| drag_frame | **21.4 ms** | one pointer move while dragging a block two cells |
| drop | **92.6 ms** | the release that ends that drag, and the frame after |

Native. The browser was last found at rough parity with native (todo.md,
2026-09-15), but these are not browser numbers, and the canvas replay — ~150 ms
at fit, measured then — is in none of them: the kernel stops at the display
list.

### Where it goes (the `spans` tally)

Every scenario is now traced to within 0.6 ms of its wall time (the spans were
added for this; with no subscriber they cost nothing measurable — criterion
against `p10` showed no regression). Nothing below is routing.

- **Re-indexing the whole document per authored op.** `Gesture::author`
  re-folds the gesture and rebuilds `DocIndex` (`restage` → `doc_index`,
  ~3.2 ms) *every time an op is authored*. The drop's
  `trim_partial_route_approaches` authors six times and pays `doc_index` five
  times — **~14 ms of index rebuilds, 23 ms in all**. *Fixed 2026-09-21*: the
  drop's trims are one authoring through the group move's own emitter
  (`push_route_riders`, with no offset), so they restage once — the trim
  23 → 5 ms, the drop **92.6 → 71.1 ms** (criterion against `p10`, −23%). A nudge pays it for the
  move, again for the rider, and a third time when `end_gesture` views the
  committed document (~3.7 ms).
- **The rev snapshot**: `submit` → `write_rev` ~8.2 ms per commit (serialize
  ~4.9, gzip ~2.5).
- **The spatial index rebuilt from scratch** in the frame after every commit
  (`spatial_index` ~6 ms).
- **The rider's own overhead**: `seal` ~11 ms in a nudge, of which the solve
  (`route_update_closed`) is ~2.9. The rest is `foreground` ~3.6,
  `rider_scratch` (cloning every wire's geometry) ~1.4, `pin_accents` ~0.7 and
  `scope_route_ids` walks.
- **The scene draws as if culling came after construction**: an idle frame at
  the opening zoom, a handful of blocks on screen, spends ~1.1 ms each in
  `pass_block_bodies`, `pass_block_pins` and `pass_standing_conflicts` — the
  cost of the sheet, not of the view. Not yet confirmed as the cause.
- **`scope_route_ids` is re-derived everywhere** — ~0.5 ms a time (a stable
  sort of 7,550 ids), several times per call: in the index build, the scene,
  the foreground, the rider, the reconstruction.
- **The selected-block tool is just the scene**: `widget[ResizeBlock]` ~4.4 ms
  a frame is its scene draw; nothing of its own.
- **The drag frame**: `preview[MoveBlock]` ~14 ms, of which routing ~9.6 and
  `foreground` ~3.2; then the scene ~4.6 and `shape_drag_guides` ~1.9.
- **The navigator** (`view` → `nav_tree`) ~0.7 ms every frame.
- **Opening**: the first `current_lock` borrows a drawing, which reconstructs
  the document (`present_document` ~21 ms: straighten 12, anchors 6), then the
  first frame's index build ~7 ms.
- `open` is the noisiest scenario (a fresh 2 MB session per iteration): one
  run read +26% against `p10` with a 40–58 ms interval, and alone −4%.
