Live Agent working · engine-01 Placer/router engine positioning
ARCH-01 - Pipeline Blueprint

Pipeline Architecture

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.

Latest Zero-State Run
git 81e359d · seed 44 · 1r × 500it
0
DRC Shorts
0
Unconnected
34
Routes OK
19
Routes Failed
8
Vias
460s
Wall Clock

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.

Board Summary

6
Layers
3
Signal
3
Planes
~18k
Router LOC
7
Subcircuits
102.7×91.4
Board mm

Live Board

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.

kehVM PCB
Loading 3D...

Canonical Pipeline (Rust, deterministic)

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
01DesignGraph load — typed source of truth designgraph crate

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).

02Placement — Rust analytical placer crates/placer

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.

placeapply_constraintslegalize (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.

03Pre-route via planning add_power_vias

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).

04Routing — visibility graph + Pathfinder + recursive shove crates/router

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:

  1. Plane map + obstacle inflation (pads inflated by max(trace,power)/2 + clearance)
  2. Power access sites planned per plane-backed pad
  3. Coarse global guide reservations seed first pass
  4. BGA fanout (microvia padstack, diff-pair symmetry, BGA_ESCAPE clearance class)
  5. route_all_nets_3d — Pathfinder loop, N iterations
  6. Endpoint radial-access oracle + row-aware filtering (cycle 14)
  7. Diff-pair offset twin via replication (cycle 5)
  8. Targeted ripup + recursive shove with PNS-style legality validation (cycle 7) and rollback
  9. failure_record_repair retries with pruned obstacles
  10. guided / direct / net-component patch hints from prior round's DRC
  11. Short-hint via and track drop
  12. detailed_eco_router with transactional rollback
  13. post-route layer-transition via validator (NEW, c6469c2)
  14. canonicalize_vias + refresh_fragment_index

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.

04bPost-route via validator (defense-in-depth, c6469c2) add_missing_layer_transition_vias

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]

BEFORE — segments only F.Cu stub B.Cu stub no via — DRC: unconnected AFTER — validator via inserted (smallest spanning padstack)
05Per-round DRC feedback feedback.rs

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 json
  • score_drc: shorts·100 + unconnected·10 + failures
  • compute_net_weights boosts placement weights for failed nets next round
  • Rotation hints from suggest_rotation_by_congestion are kept on improvement, reverted + tabu'd on regression
  • DRC items become guided/direct/net-component patch hints fed back into the next router invocation

The 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.

06Bench artifact (deterministic primitive) bench-results/*.json

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.

Router Loop (Visibility Graph + A* + Pathfinder)

PATHFINDER NEGOTIATION (outer loop, N iterations, p_fac escalates) PER-NET COMMIT (sequential, ordered by difficulty) Visibility Graph 3D · per-layer + via edges A* Search astar_3d::search_biased Validate geometry · clearance · DRC Commit routes ∪ vias Targeted Ripup → Recursive Shove (PNS legality) remove blocker · reroute · rollback transaction if worse reject congestion history → edge cost ↑ next iteration

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.

Data Model

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.

DesignGraph fields
  • board - name, dimensions, layers, stackup, net classes
  • components - ref, footprint, value, optional position
  • nets - name, pins (Vec<String> of "sub.ref.pin"), class
  • constraints - fixed, near, edges, modules, groups
  • placement - per-ref (x, y, rotation), filled by placer
  • routing - traces + vias, filled by router
  • violations - DRC items, optional
  • routing_constraints - typed router knobs (Option, defaults)
Stackup (kehvm.toml)
  • F.Cu signal · top
  • In1.Cu signal · inner
  • In2.Cu GND plane
  • In3.Cu +3V3 plane
  • In4.Cu +1V2 plane
  • B.Cu signal · bottom

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.

Entry Points

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

Reproduce

# 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.

Open Flags & Cruft

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.

FLAG 01 - Three legacy orchestrators · CLOSED 2026-04-28

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.

FLAG 02 - kicad:converge / convergence.py · CLOSED 2026-04-28

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.

FLAG 03 - kicad:route:auto still drives the pre-Rust external router

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.

FLAG 04 - Two analytical placers in scripts/kicad/placer/

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.

FLAG 05 - 45 env-var knobs in router.rs alongside the typed config

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.

FLAG 06 - router.rs is one 18k-line file

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).

FLAG 07 - Sexpr migration mid-flight (intentional churn)

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.

FLAG 08 - Diff-pair coupling bias = no-op on this board

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.

FLAG 09 - Synthetic 4-layer stackup default in RoutingConfig

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.

Where to Read More

WL · 2026-04-28
Zero-State Baseline

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.

EDA-01
SOTA Survey + Gap List

Per-tool comparison and the closed/next-gap inventory. Cross-references the same router architecture from the SOTA angle.

RT-01
V1 vs V2 Router

Why visibility graphs replaced grids. Now mostly history; V2 is what the canonical pipeline runs.