A current-state map of the kehVM board automation: how a hierarchical TOML design becomes
schematics, a placement, a routed PCB, and a DRC-clean artifact — deterministically,
from a single source of truth, with no in-tree mutation. The pipeline reaches
0 shorts / 0 unconnected from a clean checkout
via mise pipeline:zero.
See the worklog entry zero-state baseline and the placer/router engine page for how we got here, and SOTA EDA Survey for the gap list.
Read at build time from bench-results/*.json — the most recent
file wins. Same TOML + same code + same seed = same numbers.
12 bench artifacts on disk.
The pipeline output, rendered to GLB and dropped into site/public/pcb/kvm-board.glb.
Drag to rotate; auto-rotates on idle. Same artifact the homepage renders, embedded
here so "TOML in" to "geometry on a screen" is one continuous artifact and not two
screenshots.
The canonical entry point is mise run pipeline:zero,
which builds and runs target/release/pipeline
against hardware/design/kehvm.toml with output
written to /tmp/kvm-bench/<run-id>/ — never
into the source tree. Each round drops a structured result into
bench-results/<sha>-s<seed>-r<rounds>-i<iters>-<ts>.json.
The same TOML, same git SHA, and same seed produce byte-identical output and identical scores.
hardware/design/kehvm.toml 7 imported subcircuits + nets + stackup
hardware/constraints.toml fixed/edges/modules/groups + routing defaults
|
| designgraph::load (Rust) -> DesignGraph (single source of truth)
v
crates/pipeline run_pipeline(design, config) -- orchestrate.rs:1789
|
| for round in 0..rounds:
| seed = base_seed + round
| -----------------------------------------------------------
| 1. pre-placement footprint extents
| 2. build_placer_input (constraints, fixed rotations)
| 3. place_with_inflation crates/placer
| 4. apply_constraints (edges, module-pairs)
| 5. legalize + sa_refine + clearance_nudge
| 6. add_power_vias::add_per_pad_gnd_vias
| 7. route_design_v2_with_debug crates/router
| 8. apply_routing_result_to_design
| 9. write_finalized_routed_pcb + kicad-cli drc
| 10. score = shorts*100 + unconnected*10 + failures
| 11. if score < best: best = round; keep rotations
| else: revert rotations, tabu them
| 12. next round: weight failed nets in placer + reuse
| rescue/patch hints from this round's DRC
v
best round's PCB /tmp/kvm-bench/<run-id>/board.kicad_pcb
bench-results/<run-id>.json
crates/designgraph is a plain Rust crate with optional PyO3 bindings.
A DesignGraph is loaded from one or more TOML files — the kehVM design
uses 7 subcircuit TOMLs imported by hardware/design/kehvm.toml — and
held in memory by both the placer and router. There is no parallel pcbnew data path.
The graph carries: board (dimensions, stackup, net classes),
components (ref + footprint + value + optional position),
nets (name + pins as "sub.ref.pin" + class),
constraints (fixed, near, edges, modules, groups),
placement/routing (filled by pipeline),
and routing_constraints (typed router knobs).
The canonical placer for pipeline:zero is the Rust crate
crates/placer — Nesterov-accelerated gradient descent with
log-sum-exp HPWL, bell density, and constraint springs. Deterministic from the seed; no
CUDA non-determinism.
place → apply_constraints → legalize (push-apart with 500-iter cap) → sa_refine (5000 swap iterations) → clearance_nudge (0.12mm AABB push-apart).
scripts/kicad/placer/fast_placer.py still exists — it's the
PyTorch placer used by the older fast_loop.py/fast_converge.py
convergence loops. It's not on the canonical zero-state path. The Rust placer is what runs
when scores in this repo are quoted.
Per-GND-pad vias and plane-via drops are inserted before routing.
power_vias::plan_power_access_sites reserves
microvia sites for plane-backed pads so detailed routing has somewhere to drop into the
backing plane (In2.Cu = GND, In3.Cu = +3V3, In4.Cu = +1V2).
route_design_v2_with_debug in
crates/router/src/router.rs (~18k LOC) drives a continuous-coordinate
visibility graph stacked into a 3D product graph (planar VG per signal layer + via edges from
the stackup). A* search runs on this 3D graph; PathFinder-style negotiated congestion runs as
an outer loop where edge cost rises with history each pass. Up to ~14 cycles of inner work:
Pin-access oracle and recursive shove are the two pieces that make 0/0 reachable on this
board. Disabling the access oracle (ROUTER_ENDPOINT_ACCESS=0)
currently produces 199 shorts — it's load-bearing, not a bonus.
After canonicalize_vias, every net's segment endpoints are
grouped by quantized 1/10000 mm XY. For any
(net, xy) that touches two or more distinct signal layers but
has no via at that point, the smallest padstack from the stackup that spans the required
layer range is inserted. The vias list is re-canonicalized so duplicates collapse.
The router's per-net A* is supposed to emit a via at every layer transition. This pass catches the rescue/shove edge cases where a path was reflowed onto a different layer without re-emitting the connecting via — segments-only output passed the router's own success criteria, but KiCad DRC then reported the two stubs as "unconnected".
On a clean run it inserts 0 vias. On the zero-state baseline, the first attempt produced 3 unconnects — all the same shape — and this pass closed them. [worklog]
The orchestrator runs the placer + router per round (default 5, the zero-state benchmark uses 1). Each round:
run_drc runs kicad-cli pcb drc --refill-zones --format jsonscore_drc: shorts·100 + unconnected·10 + failurescompute_net_weights boosts placement weights for failed nets next roundsuggest_rotation_by_congestion are kept on improvement, reverted + tabu'd on regressionThe 5-round default exists because revert-on-regress + tabu does eventually beat single-shot (min 48 vs 65 unique opens at 3 rounds historically). The zero-state run uses rounds=1 because the goal is a deterministic baseline, not the best score.
Every pipeline:zero run drops a JSON file with
git_sha, config
(rounds/seed/max_iters/design/output paths), total_ms,
best_round, best_score, and
per-round { routes_ok, routes_failed, vias, score, elapsed_ms, drc: { shorts, unconnected } }.
This page reads them at build time via import.meta.glob
and shows the most recent file's stat strip up top. Old runs stay on disk so deltas
are visible across commits — they're the foundation for every "did X make it better"
claim in the worklog.
Continuous-mm coordinates throughout. Spatial indexes keep obstacle queries cheap, and rescue
validation checks trace envelopes so diagonal repairs do not slip through corner-case false positives.
Diff-pair coupling bias
lives inside astar_3d::search_biased as a soft reward —
empirically a no-op on this board's congestion regime, retained as the right shape for
looser channels. The recursive-shove transaction is the piece that brought the board to
0 shorts; the post-route validator is the piece that brought it to 0 unconnected.
crates/designgraph is the typed source of truth.
A DesignGraph is loaded from one or more TOML
files, held in memory by the placer and router, and exported to KiCad PCB s-expressions on
the way out. There is no pcbnew dependency. The Python placer's PyO3 bindings consume the
same struct.
board - name, dimensions, layers, stackup, net classescomponents - ref, footprint, value, optional positionnets - name, pins (Vec<String> of "sub.ref.pin"), classconstraints - fixed, near, edges, modules, groupsplacement - per-ref (x, y, rotation), filled by placerrouting - traces + vias, filled by routerviolations - DRC items, optionalrouting_constraints - typed router knobs (Option, defaults)
6-layer board, 102.7 × 91.4 mm. Per-GND-pad vias connect F.Cu pads to In2.Cu;
plane-via drops are pre-planned per +3V3/+1V2 pad
before detailed routing.
| Command | Path | Status |
|---|---|---|
| mise run pipeline:zero | Rust pcb-pipeline binary, TOML → /tmp board, no in-tree mutation, JSON bench artifact | Canonical |
| mise run kicad:pipeline | Same Rust binary, writes to hardware/kvm-carrier.kicad_pcb (mutates tree) | Live |
| mise run designgraph:route | Rust router only, from TOML, no placement update | Live |
| mise run kicad:fast-converge | fast_loop.py — Python convergence loop, PyTorch placer + Rust router shell-out + DRC feedback. Mutates tree. | Live · legacy |
| mise run kicad:build | gen → netlist → layout → route → DRC → render shell pipeline | Legacy |
| mise run kicad:route:auto | run_routing_pipeline.py driving external KiCadRoutingTools (V1 grid router) | Pre-Rust |
# Zero-state benchmark: TOML in, /tmp PCB out, JSON artifact, no tree mutation. mise run pipeline:zero 42 1 2000 # ^ ^ ^ # | | max placer iterations # | router rounds (per-round feedback loop) # seed (placer seed = base + round) # -> /tmp/kvm-bench/<sha>-s42-r1-i2000-<ts>/board.kicad_pcb # -> bench-results/<sha>-s42-r1-i2000-<ts>.json # # Same TOML + same git SHA + same seed = byte-identical PCB and identical scores.
Things that exist in the tree that probably should not, ranked by how much they confuse a
reader. Items here are not bugs in the canonical
pipeline:zero path; they are accumulated layers
that were never deleted after the path under them changed. Linked sections above point at
where the live equivalent lives.
fast_converge.py,
unified_pipeline.py, and
iterate.py have been deleted along with their
mise tasks. The canonical Python loop is now fast_loop.py;
the canonical Rust pipeline is pipeline:zero.
kicad:converge + kicad:converge:watch
mise tasks deleted; CLAUDE.md updated to point at
crates/pipeline/src/orchestrate.rs as the actual
feedback loop. No more dangling references.
run_routing_pipeline.py shells out to
.tools/KiCadRoutingTools binaries
(bga_fanout.py, route_planes.py,
route_diff.py, route.py).
That entire stack is the V1 grid router that the
router-comparison page
declared replaced. The Rust pipeline never invokes it. Removing it would also delete
install_router.sh and the
kicad10-parser.patch that pins it to KiCad 10.
The Rust placer in crates/placer is the canonical
one for pipeline:zero.
scripts/kicad/placer/fast_placer.py (PyTorch,
analytical gradients) is what fast_loop.py uses, and
placer/analytical.py is the older PyTorch autograd
version that only layout_board.py still imports. Two of three
are on the legacy path.
RoutingConstraints (designgraph) is the new typed
home, but router.rs still reads ~45 env vars
directly via std::env::var(...) —
ROUTER_*, KVM_*,
PATHFINDER_PASSES, RIPUP_LOG, etc.
apply_env_overrides only covers five of them. Migration is in
progress, not done.
Mixes BGA fanout, coarse-guide computation, A* node injection, twin-side diff-pair routing,
four kinds of patch passes, short repair, recursive shove, detailed-ECO rerouting, and
top-level orchestration. A natural split is per-pass modules
(bga_fanout, guides,
access_oracle, patch_*,
shove, orchestrator).
The "Sexpr migration cycle N" commit series (cycles 1-23 in this branch) replaces
regex-based KiCad parsers in Python with a real s-expression tree walker
(scripts/kicad/sexpr.py). Many call sites are
converted, some are not yet. This is in-flight, not cruft to delete.
DiffPairConstraints::coupling_strength default 0.35
produces byte-identical router output to 0.15 on this board. The bias is too small to flip A*
path selection at current congestion costs. Not deleted because it is the right shape for
boards with looser channels; flagged because the SOTA-EDA "closed: pair coupling" claim depends
on it and it is accurate to call this infrastructure landed, no measured effect.
RoutingConfig::default() uses
Stackup::synthetic_4layer(). The router binary
overwrites this from the design TOML before use, so production runs are correct, but tests
and call sites that forget to override get a stackup that does not match any real board.
With the design TOML now declaring stackup explicitly, the synthetic default is more trap
than fallback.
The 0/0 story: how mutating-input was masquerading as a routing regression, and the post-route via fix that closed the last 3 unconnects.
Per-tool comparison and the closed/next-gap inventory. Cross-references the same router architecture from the SOTA angle.
Why visibility graphs replaced grids. Now mostly history; V2 is what the canonical pipeline runs.