Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
# Placement Optimization for Routability
Research notes and design proposal for improving component placement with the
goal of improving autorouter success. Written June 2026, based on a survey of
~40 years of placement literature plus the current commercial and academic
landscape. All sources are linked in the [references](#references-and-further-reading).
**Short version:** from-scratch autoplacement has failed for a well-understood
reason that has nothing to do with optimization power, while perturbative
refinement of a human (or AI) seed placement is precisely the formulation that
works — and most of the machinery it needs already exists in this project
(the `placement/` package — quench.py / groups.py / legality.py — `rust_placer`, and the router itself).
## Why from-scratch autoplacement fails
The killer isn't the algorithm — it's **constraint capture**. The constraints
that govern placement live in mechanical CAD, datasheets, and the engineer's
head, not in the board file:
- enclosure fit, button/connector positions, mounting holes (MCAD)
- thermal spreading and rework access
- EMI zoning: noisy switchers vs sensitive analog
- datasheet intent: switching-regulator hot-loop area must be minimized as the
*primary* objective for those 4–5 parts (TI SNVA021, ADI AN-1119 both say
"copy the manufacturer's layout") — a wirelength-minimizing placer has no
concept of "minimize this loop's enclosed area"
- assembly direction, pick-and-place orientation conventions, test-point access
- plain designer intent (even schematic ordering of parallel parts silently
communicates intended board order)
Altium's own product manager conceded that routing constraints are easy to
model but placement constraints "can be driven by mechanical considerations…
ergonomic issues… heat dissipation" and that modeling them "introduces
significant overheads" — and Altium
[deleted their autoplacer at v18](https://www.altium.com/documentation/altium-designer/autoplacer-cmd-runautoplacerrunautoplacer-ad?version=17.1)
(2017) after decades of "produces garbage" feedback. The constraint-entry
death spiral: by the time you've configured the rooms/keepouts/rules the tool
needs to do well, you've already placed the board by hand.
The structural differences from ASIC placement (which *is* solved) compound
this:
- **~50–1,000 wildly heterogeneous parts** (0402 next to a BGA) vs millions of
uniform standard cells in rows; the smoothed density fields that modern
analytical placers depend on are only meaningful at large N
- **arbitrary rotations and two board sides** — standard-cell placers optimize
(x, y) only; orientation is a second-class patch even in research placers
- **2–8 routing layers vs 10+ metal layers**, so placement mistakes can't be
routed around — which specific pin pairs cross matters more than aggregate
congestion
- **no amortization**: each board's constraint set is unique, unlike a cell
library reused across millions of instances
The flagship academic result, **Cypress** (Cornell/NVIDIA, ISPD 2025 best
paper), states this directly: stock VLSI analytical placers like RePlAce
"fail to find a routable placement for multilayer PCB designs with components
of diverse sizes."
## What the classical toolbox offers
Four families dominate the literature; their PCB fit differs sharply:
| Family | Canonical work | PCB fit |
|---|---|---|
| Simulated annealing | TimberWolf (1985), VPR (1997), SA-PCB (2019) | **Excellent.** Derivative-free, so rotations, side flips, polygon overlap, and arbitrary constraints are just move-legality checks. Abandoned in VLSI for *scalability*, never quality — irrelevant at N ≈ 100. |
| Force-directed | Quinn & Breuer 1979 (written *for* PCBs), Kraftwerk | Good for fast initial seeds; collapses clusters into overlap without density machinery that assumes large N. |
| Min-cut partitioning | Fiduccia–Mattheyses 1982, Capo | Useful as a clustering prior (functional blocks → regions; top/bottom side assignment = one cut); not a placer for continuous coordinates. |
| Analytical/gradient | GORDIAN → ePlace → DREAMPlace → Cypress | Documented to fail on PCBs without Cypress-level surgery (per-side density maps, orientation-aware wirelength, GPU, Bayesian hyperparameter search). Wrong tool at this scale. |
TimberWolf's move set — single-part **displace** within a window that shrinks
with temperature, **pairwise exchange**, **orientation change** — is still the
canon 40 years later, and is exactly the "nudge, rotate, swap" set proposed
below. VPR's adaptive schedule is the standard tuning recipe: initial
temperature from the cost standard deviation over random moves, cooling rate
chosen to hold move-acceptance near **0.44** (Lam's result), moves per
temperature ∝ N^(4/3), exit when T < 0.005·cost/N_nets. VPR also ships the
exact UX precedent: load an existing placement file and run
`--place_quench_only` — a zero-temperature quench for when "the initial
placement is already good."
## Why perturbative refinement is the validated niche
This is the strongest finding across all research threads:
- **Detailed placement is an entire ASIC sub-discipline doing exactly this.**
After global placement and legalization, placers run local-move refinement
on a legal placement. The canonical move set (FastDP, ICCAD 2005):
- *global swap*: compute a cell's **optimal region** — the median of its
nets' bounding boxes (Goto 1981), an O(pins) computation that answers
"where would this part ideally sit?" — then swap it with a cell or gap
near that region if Δcost < 0
- *local reordering*: exhaustively permute k ≈ 3 consecutive cells in a
sliding window
- *single-row clustering / Abacus-style DP*: with ordering fixed, slide
cells within a row to optimal offsets by dynamic programming — directly
applicable to a line of passives along an IC edge
- *mirroring/flipping*: orientation-only pass; in macro placement, flipping
alone is worth an average **7.9% HPWL** (DAS-MP 2025)
Typical total recovery from local refinement: **5–10% wirelength**, with
each move family worth roughly 1–8%.
- **Refine-from-seed empirically beats from-scratch** at PCB-like scale
(tens–hundreds of rotatable blocks):
- MaskRegulate (NeurIPS 2024) recast RL placement as *refining* a finished
placement one macro per step, with reward = 0.7·wirelength +
0.3·regularity; vs from-scratch MaskPlace it gained 17.1% routed
wirelength and **73%/39% horizontal/vertical congestion-overflow
reduction**.
- WireMask-BBO (NeurIPS 2023) fine-tunes any existing placement by
black-box optimization — up to 50% HPWL improvement from a seed.
- The Kahng/Cheng re-evaluations of Google's Nature 2021 RL chip placement
found that properly tuned parallel SA with moves {move, swap, shift,
mirror, shuffle} **beat the RL placer at lower runtime**, and that the RL
results depended heavily on the quality of the (commercial) *seed*
placement — backhanded but strong evidence for "good seed + local
optimization."
- **A human seed sidesteps constraint capture entirely.** Bound each part's
displacement to a small radius of its original position and keep clusters
intact, and the optimizer inherits every unmodeled constraint for free —
the human already satisfied them. This is structurally why the *accepted*
placement automations (Cadence Place Replicate, decap fanout under BGAs)
all operate inside human-specified intent. Cypress itself scopes to
"noncritical components" with critical parts fixed (and supports partial
locks: position fixed, rotation free).
- **Reviewability fits the trust culture.** Practitioners accept co-creation
tools and reject full automation ("never trust the autorouter"). Output
that is visibly "your placement, nudged" can be reviewed in minutes.
## What to optimize
The existing scorer in the `placement/` package / `rust_placer` — **airwire
length + crossing penalty** — is already the right objective family.
**Crossings are the key PCB-specific signal.** Cypress's central technical
argument: with only 2–6 layers and free-angle routing, *which specific pin
pairs cross* matters more than aggregate bin congestion. They show two
placements with identical RUDY congestion scores where one has a routing
conflict and one doesn't. Their cost: decompose each net into source–sink pin
pairs (star model); for each same-layer segment pair compute the intersection
parameters (t, u) in closed form; smooth the binary "crosses" indicator with a
bell function so it's differentiable: `NC = Σ B(t−0.5)·B(u−0.5)`. Our discrete
crossing counter is the non-differentiable version of the same quantity —
fine, since SA doesn't need gradients. The theory behind crossing counts is
topological routing (SURF, DAC 1991): zero airwire crossings on a layer means
a planar ratsnest, i.e. routable on that layer in the topological sense; every
crossing costs a via pair or a detour. Because a crossing is a conflict
*between two nets*, the natural way to prioritize one net is to price each
crossing by the larger of the two nets' weights: the cost of a conflict then
tracks the most important net it obstructs.
For reference, RUDY (the standard VLSI congestion proxy) smears each net's
expected wire area uniformly over its bounding box —
`d_n = (HPWL_n · pitch) / (w_n · h_n)` accumulated onto a grid. It's O(1) to
update incrementally and worth keeping as a *tiebreaker*, but not as the
primary objective, for the reason above.
Two additions the literature argues for:
1. **A spreading/whitespace term.** An open-source SA placer benchmarked
against Quilter found the gap wasn't wirelength — Quilter won
route-completion (99.4% vs 87–90%) by *spreading parts apart for fanout
room*. Pure wirelength minimization packs too tight and strangles the
router. The author's conclusion: "a good placement is one the router can
finish, which is not something you can compute directly from the
placement." A pad-density map or per-component halo (AutoDMP's macro-halo
trick) fixes this. Pin density also captures the real bottleneck at
BGA/connector escapes.
2. **Trial-routing as the arbiter.** The fidelity hierarchy used by everything
serious — TimberWolf stage 3 (1985) accepted placement moves only if they
reduced global-route track counts; Cypress *evaluates* with FreeRouting
(routability = routed pin pairs / total pin pairs); Quilter scores complete
candidates by route completion + DRC. The pattern: cheap proxies per-move
(microseconds), real routing per-candidate (seconds–minutes) to rank
finalists. This project's structural advantage: the literature validates
with FreeRouting; we have a faster router in-house, so "fraction of pin
pairs routed" becomes a measurable, optimizable number.
### Corridor cut length: a good measurement and a bad objective
*(A negative result, kept because the reasoning that produced it was
plausible and wrong, and the next person will have the same idea.)*
When the intent declares `health.bus_corridors`, we can measure the **length**
each foreign airwire cuts through a bus's lane instead of counting that it
does. The argument for putting that in the objective went: `foreign_crossings`
is a count, a count is piecewise constant in pose, its gradient is zero almost
everywhere, so a greedy descent sees a cliff instead of a slope; the chord
length is piecewise linear, so every millimetre of a move gets priced.
The first half is right. The second half is right about the *shape* of the
function and wrong about what it buys. Three measurements, in the order they
were made, because the order is the lesson:
**1. The chord of a fully-traversing wire is position-invariant.** It is
`w/sin θ` wherever along the lane the wire crosses. A nudge slides the crossing
point and changes the toll not at all; the term responds only to *angle*, and a
3 mm move barely turns a 40 mm airwire. Pinned in
`tests/test_corridor_diagnostics.py`.
**2. The parts that block a corridor are not the parts whose airwires cut it.**
Ten parts intrude into ulx3s's SDRAM corridor; with the term at weight 20, zero
of them move differently. They are decaps, and their nets are power rails —
which the fanout cut correctly drops. Body obstruction and airwire crossing are
two different measurements, and only `corridor_intrusions` was ever measuring
the first.
**3. It does change the placement — and whether the gain survives depends on a
layer that shipped after this was first measured.** The first A/B reported the
term completely inert, and *that run was wrong*: it had been pointed at
`sdram_*`, a merged glob whose `cover` is 0.46, i.e. a phantom corridor. Re-run
against `SDRAM_A*` and `SDRAM_D*` declared separately, the term moves parts on
every board.
What the independent grade *says* has since reversed on ulx3s, and the reversal
went unnoticed because the numbers lived in prose rather than in a file anything
re-ran (#694). Re-measured with the same probe at the commit that recorded the
row and at the current tip:
| ulx3s | crossings | hpwl | `bus_foreign_crossings` (re-derived) | intent errors |
|---|---|---|---|---|
| re-measured at `82dbf662` | 2417 → 2390 | 7512.72 → **7634.27** | 62 → **63** | 15 → 13 |
| re-measured at `81a3c193` | 2477 → 2407 | 7437.99 → 7416.23 | 62 → **55** | 14 → **17** |
(The first row is a re-measurement, not a transcript: the recorded `why` gave
hpwl as "7512 → 7634" and never recorded intent errors at all.)
**At the current tip the direction is decided by the hard pad+drill legality
layer** (`quench(pad_legality=...)`, default on, introduced in `be97da7e` —
which is *not* an ancestor of the commit that recorded the row). Force it off
and ulx3s goes back the recorded way on every signal: `bus_foreign_crossings`
62 → 63, hpwl 7437.99 → 7548.02, intent errors 14 → 12, with the OFF arm
byte-identical either way — so the layer is not moving the baseline placement,
it is redirecting the corridor-priced descent. On orangecrab and coldfire the
same toggle leaves both written boards byte-identical, so this is a
ulx3s-specific interaction, not a global one.
**That is a sufficient cause TODAY, not the historical one.** The OFF arm also
moved between the two commits (crossings 2417 → 2477, hpwl 7512.72 → 7437.99),
so other changes moved the baseline placement too, and disabling the layer
restores the direction but not the values. The historical attribution cannot be
settled by this method at all: `corridor_weight` does not exist on the branch
that introduced the layer, so the two cannot be measured together at that point.
The `corridor-orangecrab` row blamed the same layer for its own earlier flip;
that claim is likewise unestablished — the layer is simply inert on that board
now, which says nothing about what the code looked like then.
**The mechanism argument still stands:** the optimizer minimises the cut against
corridors frozen at construction, but the corridor is *defined by the pads of
the bus*, so when parts move the corridor moves with them and the grader's
re-derived rectangle is not the one that was minimised. Freezing is still
required — an unfrozen corridor makes the objective non-stationary — so the
term is caught between two necessities. What the re-measurement changes is the
*claim about the outcome*, not the reasoning about the model.
**The term remains NOT adopted, and now for a stated reason.** At HEAD all three
boards improve the signal the term exists to improve, and both guards improve on
all three. ulx3s still marks REGRESS, because the ON arm raises
`zone_containment` from 4 to 7 — the only error rule that moves. It is a net +3,
not three extra offenders: six members start violating (`C35 C63 C65 C68 C71
C72`) and three stop (`C39 C43 C69`), which is a rearrangement that costs
containment, not a local slip. So the
term fails the harness's own rule — improve on ≥ N−1 boards **and** regress on
none — by buying its signal with containment.
**Those are the only A/B measurements this section states, and only because
they are the before/after of the finding itself — each labelled with the commit
it was measured at.** The live numbers are in
`tests/placement_ab_baseline.json`, which `tests/test_placement_ab.py`
re-measures and compares on every run, reporting a reversed direction
(`INVERTED`) apart from a moved value (`DRIFT`). This section used to carry a
three-row table in which 15 of 18 numbers had gone stale, next to a `--help`
text that had gone stale differently.
Worth keeping from run 3: **a term that helps on one board of three is not a
term**, and the row that disagrees is the one to keep. But note which row that
is has changed: `corridor-coldfire` was the lone dissenter when this was
written, and today the dissenter is `corridor-ulx3s`. All three rows stay in
the table — dropping the disagreeing row is how a one-in-three result becomes
folklore about a term that "works", and *which* board disagrees is not a stable
property of a term.
So the cut ships as a **diagnostic**, not as an objective term:
- `check_floorplan --intent --health` reports `cut_mm` per corridor. It is
strictly the better of the two numbers to *read* — it prices obliqueness, and
on two layers it is the geometric lower bound on reference-plane copper the
crossing removes, which is exactly what `check_impedance.py`'s void-run
counter grades later.
- Alongside it, `cover`: the fraction of a bus's own pads that actually sit at
the corridor's endpoints. `corridors_from_intent` will build a confident
rectangle for any set of nets, including six identical motor channels
scattered over a board whose endpoint centroids average to the middle of
nothing (splitflap `OUT_*`: 24 nets, cover 0.0). Below `CORRIDOR_MIN_COVER`
the corridor is reported as `bus_corridors_phantom` so it can be disbelieved
rather than silently graded. Related trap from the same survey: **do not
merge sub-buses.** `SDRAM_*` scores cover 0.62 where `SDRAM_A*` and
`SDRAM_D*` separately score 1.0, because address and data leave the part on
different faces and the average lands between them.
- `--corridor-weight` remains, default 0.0, flagged experimental in its
`--help` — which states the mechanism and points at
`tests/placement_ab_baseline.json` rather than quoting counts, because the
counts it used to quote went stale twice. It is kept rather than deleted
because the kernel is exact and tested, and because a future move set that
can relocate a part *across* a bus is exactly the regime where the term would
bite.
Two design points that were right and are worth keeping if anyone revisits it:
- **The corridors are frozen at `QuenchState.__init__` and never rebuilt.**
`_cluster_ends` derives endpoints from live pad positions, so a corridor that
followed its parts would make the cost of a pose depend on *when* it was
evaluated — an accepted gain would not match the recomputed total and the
descent could cycle. Freezing makes that impossible rather than avoided by
discipline.
- **The check must not be the model.** `check_floorplan --intent --health`
re-derives corridors from the *final* poses, so it cannot be gamed by the
frozen rectangles the optimizer minimised against. That independence is what
let the A/B return "improved nothing" instead of a flattering self-report.
### Metric cheat-sheet
| Metric | Cost per move | Routability signal at PCB scale | Verdict |
|---|---|---|---|
| HPWL / airwire length | O(1) incremental | Necessary, far from sufficient (exact only for 2–3 pin nets; congestion-blind) | Always include |
| Airwire pin-pair crossings | O(moved part's segments) | Literature proxy (Cypress, NS-Place), grounded in planarity theory. **In this repo: measured against distance-to-truth (r = +0.78) and against `vias`, and since #703 against routed `blocking` too — where it FAILS its sign rule on the full sample (5 boards right, 1 wrong, median rho = +0.515) and PASSES it (6/0) once optimizer-made placements are excluded, so neither arm is reported as the answer. #789 then measured whether its ORDER agrees with the routed order: not shown to agree, N=5.** See `docs/placement-predictors.md` | Primary second objective |
| Pad/pin density map | ~free | Captures BGA/connector escape limits and fanout room | Cheap secondary term |
| RUDY congestion map | O(1) incremental rect update | Misses pin-pair conflicts on few-layer boards (Cypress Fig. 3) | Tiebreaker only |
| Steiner trees (FLUTE) | ~10× HPWL | Marginal over HPWL — PCB nets are mostly 2-pin | Skip |
| ML routability predictor | ms inference | Beats proxies (MIT thesis: NN trained on 75k *routed* placements) but needs per-board-family training data | Only if labels are cheap |
| Actual routing | seconds–minutes per candidate | Ground truth | Rank finalists, not per-move |
## Proposed shape of a `place_optimize.py`
Seeded SA — or a plain greedy quench first (VPR's `--place_quench_only`
precedent), which is simpler and may capture most of the value:
- **Moves** (the TimberWolf/Kahng-Cheng canon, adapted to PCB):
- *nudge*: translate within a window that shrinks as temperature drops;
optionally bias toward the part's optimal region (median of connected
nets' bounding boxes — O(pins) to compute)
- *rotate*: 0/90/180/270, relative to the part's own placement angle (a
45°-placed part rotates on the 45/135/225/315 lattice, so deliberate
non-orthogonal placement is preserved rather than snapped to the axes)
- *swap*: same-footprint pairs (footprint-identical R/C swaps are free
wins; mixed-size swaps are usually illegal anyway, so restrict by
footprint compatibility)
- *side flip* (optional; mirrored courtyard): treated as a first-class move
in recent PCB literature. **Still not implemented as a move** — and that
sentence wants reading carefully, because half of what used to block it is
gone. Board side *is* modelled by the clearance/halo terms (#456): a part
occupies its own side with its courtyard and the far side only with its
drilled-pad box, so cross-side parts no longer collide or repel. And since
**#714** the WRITER can emit a real flip: `write_placed_output` mirrors a
footprint to the other face on request, held to pcbnew's own
`FOOTPRINT.Flip` node for node, so `perturb` can stage a `layer_flip`
damage kind. What is missing is the SEARCH — `_Part.side` is assigned once
at construction and no move signature carries it, so nothing in the
optimizer *chooses* a face. That is #836, and it was gated on its own
pre-registered measurement, because `reseat.py` is already side-aware and
the open question is how many parts a flip helps that a re-seat does not.
**That measurement has been run, and the answer is DO NOT BUILD IT.**
`tests/measure_836_flip_vs_reseat.py`, numbers in
`tests/836_flip_vs_reseat_baseline.json`, thresholds committed before the
results. Three things it found, in order of weight:
1. **The routed arbiter says the flips do not pay.** Of the 8 parts the
screen called EXCLUSIVE — helped by a flip and by no same-side move
within reach — 1 improved, **4 got worse** and 3 did not move, every
one of them on the last rung (vias). `E_material`, the count improving
any rung above it, is **0**. The arbiter discriminates: of 8 control
parts flipped and routed identically, 6 moved the ladder.
2. **#836's premise about the objective is backwards, and this is the
correction that matters most.** It says the objective cannot price a
flip. Measured: `_halo_pair_penalty` returns 0.0 for a cross-side SMD
pair, `candidate_valid` skips cross-side pairs entirely, and `align`
has no side filter at all — so **207 of ulx3s's 226 movable parts and
237 of glasgow_revC's 243** have their whole same-side halo charge
zeroed by a flip. A greedy quench handed this move and this objective
would flip nearly every part on the board. The danger was never that
the search could not see the move; it is that it could only see the
upside.
3. **The screen agrees for a second reason.** `watchy` has no back-side
pad copper, so every flip on it lands in an empty face; its 1.19%
EXCLUSIVE rate is the artefact rate, and only 1 of the 3 primaries
beats it.
Said plainly: the pre-registered CLOSE arm asked for `E <= 1` and E was 8,
so this rests on the routed arm rather than on the screen count, and both
numbers are published so a reader can disagree with the reading rather
than with the data. `tests/test_836_flip_census.py` re-derives the two
mechanisms in five seconds and fails naming #836 if an engine change ever
makes a flip cost something — that test is the reopening signal.
- *rigid-group moves*: an IC plus its decoupling caps moves as one
super-component. **Translation is implemented** (`--group-by`, #459);
rigid *rotation* of a block is not. Blocks come from KiCad `(group ...)`,
the schematic sheet path, net-name prefixes, or decap-to-IC tethering —
see `placement/groups.py`. Off by default. The block is capped so every
member stays within `--max-displacement` of its own seed, which keeps the
neighbour-list pruning exact; the unbounded block relocation #459 also
describes (an 80mm move) is separate, unimplemented work.
- **Constraints by construction, not penalty** (the discipline from analog-IC
placement and Synopsys ICC relative-placement groups: encode constraints
into the move generator so every perturbation is feasible, rather than
penalizing violations):
- respect `locked` flags (already parsed by `placement/parser.py`)
- `--max-displacement` radius from the seed position — enforced for every
move type, swaps included — so output reads as "your placement, nudged"
and inherited human constraints survive
- courtyard non-overlap via the existing rect machinery — side-aware, and
against the real Edge.Cuts outline rather than an inset of the bounding box
(`placement/legality.py`, #456). A part sitting off the board is not frozen:
it may move strictly back toward the board. An overlapping one still may
only move to a fully legal pose.
- decaps tethered to their IC. **Implemented as a grouping source**
(`--group-by decap`, #459): a 2-pad cap within 5mm of an IC's bounding box
AND sharing a net with it. Both halves matter — across the corpus a cap sits
0.0-2.6mm (median) from the nearest IC and shares a net with it 93-100% of
the time, and the net check is what rejects the rest (13 of ulx3s' 70). It
makes the block move together; it is not a constraint on the per-part nudge.
- rotation disabled per-class where assembly conventions matter
(`--no-rotate`, which also restricts same-footprint swaps to equal-angle
pairs, since a swap exchanges full poses)
- **Cost**: Δ(crossings)·penalty + Δ(airwire length) + density/halo term —
all incrementally updatable (only airwires touching the moved part's nets
change). Mostly already in `rust_placer`; extending the scorer to evaluate
a *perturbation* rather than a candidate position is a modest change, and
it's a separate crate from the router.
- **Schedule**: VPR-style adaptive — T_init from cost σ over random moves,
hold acceptance near 0.44, moves/temperature ∝ N^(4/3) (trivial at N ≈
100), exit at T < 0.005·cost/N_nets. Or skip all of it and quench.
- **Validation loop**: after optimization, run `route.py` on before/after and
report completion % and failed-net count. The Cypress benchmark suite (10
open boards, 41–476 components, with KiCad converters) gives ready-made
test cases beyond our own boards.
### Role of AI
Two distinct roles, kept separate:
- *Seed generation*: the existing `place_components_initially` (greedy
constructive: descending pin count, connectivity-weighted centroid target,
4 rotations, crossings+length scoring) is plausible for simple boards; keep
`place.py` as a seed generator and fallback, not the headline feature.
- *Constraint extraction* — the more interesting angle: the project already
has skills doing per-component datasheet lookup (`analyze-power-nets`,
`identify-diff-pairs`). The same pattern could emit a placement-constraint
file: which parts are decaps and whose IC they tether to, which clusters
are switcher hot-loops that must move as rigid groups or not at all, which
connectors should be locked. That directly attacks the constraint-capture
problem that killed every previous autoplacer, and feeds the optimizer
exactly the lock/tether/group inputs it needs. (Quilter's "circuit
comprehension" — auto-detect bypass caps, diff pairs, power nets, then have
the user verify — is the commercial version of this idea.)
## What to avoid
- **RL**: the startups (Quilter, DeepPCB, Flux) bet on it, but the best
peer-reviewed result (Cypress) is non-RL; Quilter takes hours per board
with hard size limits (<1,000 pins, ~100 components, 2.6 h for a
176-component board as independently tested in 2024); and the Google
chip-RL saga showed well-tuned SA matches or beats it given equal compute.
We also have no training data.
- **Analytical/gradient placement** (DREAMPlace-style): needs Cypress-level
engineering plus GPU plus multi-objective Bayesian hyperparameter search to
work on PCBs — itself an admission that gradient methods are
tuning-fragile at this scale. Wrong tool for N ≈ 100.
- **From-scratch placement of complex boards**: see constraint capture above.
- One framing note from the practitioner forums: the word "autoplacer" is
culturally radioactive — "placement optimizer" that visibly preserves the
user's layout is the framing people accept.
## First experiment
Cheap and decisive: take a hand-placed board, run a greedy quench with just
nudge+rotate+swap moves against the existing crossings+length scorer, and
measure route completion before/after with `route.py`. That one number tells
us whether the whole direction is worth building out. If the proxy improves
but route completion doesn't, add the spreading term before anything else —
that's the documented failure mode of wirelength-driven placement.
## Experiment results (June 2026)
Implemented as `place_optimize.py` + `placement/quench.py`: greedy quench
(nudge within `--max-displacement` of the seed, 90° rotations with correct
pad-angle rewriting, displacement-capped same-footprint swaps), cost =
`length_weight`·airwire
length + `crossing_penalty`·crossings + pin-count-scaled halo + soft edge
margin. Both airwire terms take optional per-net weights: a net's length is
multiplied by its weight, and each crossing is priced at the larger of the
two crossing nets' weights, so an unweighted board is untouched
(max(1, 1) = 1). Test board: `interf_u` (25 parts, PGA120 + bus connectors, 2 layers),
pipeline `route_planes` → `bga_fanout U9` → `route.py` with the
`tests/test_interf_u.py` arguments. Router iterations ≈ effort; vias and
completion are the quality metrics.
| placement | single-ended | multipoint pads | vias | router iterations |
|---|---|---|---|---|
| hand (KiCad demo) | 108/108 | 80/80 | 136 | 2.3 M |
| hand + quench, default weights | 106/108 (2 fail) | 80/80 | 150 | 3.1 M |
| hand + quench, strong halo | 100% of attempted | — | 157 | 6.7 M |
| hand + quench, crossing-focused¹ | 108/108 | 80/80 | 135 | 2.6 M |
| `place.py` from-scratch seed | 106/108 (2 fail) | 65/77 (12 fail) | 202 | 1 141 M |
| from-scratch seed + quench¹ | 108/108 | 71/80 (9 fail) | 240 | 364 M |
¹ `--length-weight 0.3 --crossing-penalty 30 --halo-weight 10 --halo-coef 0.5 --edge-halo 3`
**Conclusions:**
1. **The hand placement is dramatically better than from-scratch constructive
placement** — 500× less router effort, no failures. The constraint-capture
story is real even on a 25-part board: the human's bus-flow arrangement
(BUS1 → buffers → PGA → RAM) is what makes it routable, and its
"suboptimal" wirelength is buying that structure.
2. **Quenching an already-good seed is neutral at best.** Proxy improvements
(crossings −13%, length −12%) did not translate: default weights *caused*
2 failures, strong-halo variants tripled router effort. The
crossing-focused parameter set merely matched the hand placement. On a
board with no completion headroom there is nothing for the proxy to win,
and chasing it perturbs structure the proxies can't see.
3. **Quenching a mediocre seed genuinely helps**: from-scratch + quench fixed
both single-ended failures, cut multipoint failures 12 → 9, and reduced
router effort 3× (1 141 M → 364 M iterations). Refinement works exactly
where the literature says it does — when there is headroom.
4. **Proxy–routability correlation is weak**, confirming the MIT-thesis
finding: the variant with the *best* crossing reduction (length weight 0)
failed 2 nets. Any production version of this tool should rank candidate
placements by an actual trial route (our router does this board in ~1–2 s
of routing time), not by the proxy alone.
**Practical upshot:** ship the quench as a *repair* tool for rough/generated
placements (imported or auto-generated layouts), not as a polish pass on
careful hand placements. (Based on these results, the from-scratch
constructive placer — `place.py` and the `rust_placer` scoring crate — was
subsequently removed from the repo; it survives in git history.) The next-step experiments are (a)
router-in-the-loop candidate ranking, since single routes are cheap, and
(b) a proxy that models *escape/fanout room* around high-pin-count parts
explicitly rather than via the generic halo.
### Second board: kit-dev-coldfire-xilinx_5213 (160 parts, 4 layers)
Setup: `tests/test_kit_route.py` signal-stage arguments; connectors/headers
locked via `--lock`; plane-routed nets excluded from airwire scoring via
`--ignore-nets GND +3.3V` (both options added for this experiment). All
quench runs use the crossing-focused weights (`--length-weight 0.3
--crossing-penalty 30`).
| variant | single-ended | multipoint | vias | router iterations | route time |
|---|---|---|---|---|---|
| hand (KiCad demo) | 204/207 (3 fail) | 227/227 | 346 | 122 M | 45 s |
| quench, 3 mm cap, modest halo | 205/207 (2 fail) | 235/236 (1 fail) | 346 | **24.6 M** | 13 s |
| quench, 5 mm cap, modest halo | 204/207 (3 fail) | 223/227 (4 fail) | 331 | 22.4 M | 10 s |
| quench, 10 mm cap, strong halo | **192/207 (15 fail)** | 222/226 (4 fail) | 334 | 235 M | 97 s |
**Dose–response is the story.** At a 3 mm displacement cap the quench matches
the hand placement's completion while cutting router effort **5×** (122 M →
24.6 M iterations) — on this denser board the crossing reduction (−11%)
translates into real router savings, unlike on interf_u. At 5 mm it's
marginally worse on completion but keeps the effort win. At 10 mm with strong
halos, 149 of 150 movable parts moved and the placement collapsed: 12 of the
15 new failures were `/xilinx/XIL_D*` — the quench had destroyed the data-bus
corridor between the Xilinx and the MCU, exactly the macro structure the
crossing/length/halo proxies cannot see. (Failed-net identities vary between
runs near the noise floor; the iteration counts are the robust signal.)
Refined conclusions:
- `--max-displacement` is the dominant safety knob: small caps keep the
human's macro structure (bus corridors, cluster geometry) intact by
construction, which is the entire value of seeding from a human placement.
~3 mm was the sweet spot on both boards tested. The cap is now airtight:
no move type — swaps included — can take a part beyond it, and
`--swap-max-displacement` can tighten (never exceed) it for swaps
specifically. The router-in-the-loop widening below widens **nudges only**:
the swap cap holds at its base value for the whole run.
- The big halos backfire on dense boards: with `--halo-coef 0.5` the
144-pin Xilinx demands a 6.5 mm halo that a dense board cannot satisfy, so
the halo gradient dominates everything and scatters the layout. Modest
halos (`--halo-coef 0.15`) only fire where parts are genuinely cramped.
- Lock connectors (`--lock`) and exclude plane-routed power nets
(`--ignore-nets`) — both matter for honest objectives on real boards.
### Router-in-the-loop repair (`place_route_loop.py`)
The proxy-only results above motivated closing the loop: use the *router's
own failure diagnostics* to decide what to move. Each round:
1. Route the board; parse the run's JSON summaries and the frontier blocking
analysis (which nets wall off each failed route). Note the plural:
`route.py` runs an end-of-run reconciliation pass whenever the first pass
left failures, and prints a second summary scoped to the retried nets.
The failure lists in that last summary are the still-open set, while
router effort adds across both passes.
2. Build the movable set: parts owning pads of the **failed nets** (move the
endpoint out of the congested pocket) and of the **blocker nets** (move
the anchor so the blocking wall re-routes) — excluding high-pin-count
parts (`--max-target-pins 40`: moving a resistor that anchors a blocker
is low-risk; dragging a 144-pin QFP is how placements get destroyed).
`--target-select diagnosis` (#553) replaces this step; `pins`, the
default, is the rule just described and is unchanged. The complaint it
answers is that the pin cap is a proxy that picks wrong exactly when it
matters — *the part that needs to move is never a passive*, so the cap
excludes the IC whose position is the problem and offers the passives that
are not. The diagnosis instead ranks blocks and loose parts on three
signals and takes the round-robin union of their top-k:
| signal | what it is | what backs it |
|---|---|---|
| `block_displacement` | how far a block sits from the centroid of what it connects to | mechanism only — the 80 mm magnetics case, never measured against a routed outcome |
| `blocker_cells` | the frontier cells the router attributed to nets this candidate owns | not a predictor at all: the router already failed and already said what blocked it |
| `legality_pairs` | pad-clearance, body-overlap and courtyard-blocking pairs incident on the candidate | the only measured one — see `placement-predictors.md` |
There is deliberately **no combined score**: no measured exchange rate
exists between millimetres, blocked cells and defect pairs, and a rank sum
would assert the three are equally informative, which is measured-false.
The three rank independently and are swept round-robin.
**No measurement shows `diagnosis` routes better than `pins`, and the one
study that was built to support it came back NULL.**
`tests/stress/diagnosis_recall.py` displaces a known block and asks whether
the ranking concentrates on it, paired against the SAME ranking on the
UNDAMAGED board. Evidence arms: `swap` median delta **+0.135** (4 cells up,
2 down), `wrong_side` **+0.000** (2 up, 3 down). The pairing is the point --
the ranking scores just as well on the pristine board wherever the
perturber picks the block the ranking would have picked anyway. The flag
ships default-off on that basis, and the study is kept as a recorded
negative rather than deleted. The signal
`foreign_crossings`, which #553 also named, is **not** included; the three
reasons are in `py_placer/placement/diagnosis.py`, and none of them is
"#703 measured it and it failed" — #703 measured `crossings`, a different
quantity.
2b. `--relocate` (#554, DEFAULT OFF) proposes ONE bounded block relocation
*before* the quench, and writes it as its own board so the quench can then
refine the new pose. Where the quench's rigid block translate (#538) caps
every member at `--max-displacement` from its own seed and freezes everybody
else, this lets the neighbours yield — with their relative order held as a
hard constraint, and their total displacement minimised.
The solve is a difference-constraint system: one variable per rigid unit per
axis holding a *shift* from the incumbent, one constraint per interacting
pair in the axis it is currently more separated on, every requirement clamped
to `min(clearance, what the pair already has)` so `s = 0` — the board as
handed over — is always feasible. The block's travel is the envelope's own
upper bound, and Bellman-Ford's predecessor chain is the **explanation**:
a named chain of parts and gaps ending at the wall or the locked part that
stopped it, which is what #459 asked the constraint graph to be. The corridor
is an *output* — the units that had to yield — so there is no radius knob.
| | |
|---|---|
| `--relocate-block NAME` | relocate a named derived block instead of the most displaced one |
| `--relocate-refs GLOB…` | relocate an explicit ref set, bypassing derivation. This is what separates "the relocation worked" from "the diagnosis picked right" |
| `--relocate-max-corridor MM` | refuse a dose whose neighbours must travel more than this in total, and try a shorter one |
**What is measured, and what is not.** The mechanism holds: over the 24
measurable blocks on the 9 boards that have one, letting neighbours yield
bought ≥ 1 mm more travel than freezing them on 11, spanning 6 of those
boards, max 16.66 mm (`relocation_reach.py`, and its `frozen` arm is the
*same solve* with everything else pinned, so the two differ in exactly one
thing). The scale is worth reading beside it: the MEDIAN cell reaches
1.03 mm against a median want of 10.36 mm.
**The routed A/B has now run, and it does not support the feature**
(`block_relocation_study.py`): 3 of 3 evidence cells recovered damage,
median delta +0.57 — but over 2 boards where the acceptance rule counts 3,
and `loop@allon` reached a strictly better routed result on 2 of those 3.
`relocate_efficacy` carries that verdict; reach is not routability.
Three limits that decide whether it can help you at all:
- **It never fires on a board that already routes.** The loop stops at
`failures == 0` before round 1, and `--target-nets` adds to the target list
without adding to `failures`, so it does not lift that stop.
- **`--group-by auto` derives no block on five of the six boards this repo
grades placement on.** Use `auto,netprefix,decap`, or name the refs.
- **The one tracked board where every precondition holds** —
kit-dev-coldfire, the only one that fails at its authored placement — is
*already* taken from 3 failed nets to 0 by the shipped loop (at a cap
widened to 6.75 mm by round 4 — see the table above).
And one property that is easy to assume and false: the constraint graph is
**not** a conservative model of legality. A gap is Euclidean while a
constraint is per-axis, so a pair the graph is satisfied with can be driven
below clearance (measured on watchy, glasgow_revC and ulx3s). What makes the
pass safe is the exact re-check, which re-measures every moved part with the
real gate and refuses the dose outright — nothing is applied on the solve's
word.
3. Micro-quench only those parts, with the failed nets weighted 3×
(`--failed-net-weight`) — both their airwire *length* and any *crossing*
they take part in, the latter priced at the larger of the two nets'
weights. Weighting length alone was near-inert: at the loop's own knobs
the crossing term is ~96% of the objective, so scaling a length
coefficient from 0.3 to 0.9 against a crossing coefficient of 30 moved a
typical failed net by less than the cost of a fifth of one crossing.
4. Re-route. Accept only if (failures, then iterations) improves; otherwise
revert and widen the displacement cap 1.5× for the next attempt — the
nudge radius only. The swap cap stays at its base value, so a widened
round cannot become a long-range swap.
Result on kit-dev-coldfire from the hand placement (recorded before #458;
`failures` was then the pre-reconciliation count and `router iterations` the
first pass only, so both columns would read slightly differently today: fewer
failures where the reconciliation pass recovers a net, and more iterations
because that pass's own effort is now counted):
| round | action | failures | router iterations | vias |
|---|---|---|---|---|
| 0 | initial route | 3 | 122.4 M | 346 |
| 1 | moved 35 small parts near failed/blocker nets | 2 | 90.6 M | 360 | accepted |
| 2–3 | candidates worse (4, 3 failures) | — | — | — | rejected, cap widened |
| 4 | retry at 6.75 mm cap | **0** | **25.7 M** | 382 | accepted |
**The loop fully repaired the hand placement — 3 → 0 failed nets and 4.8×
less router effort — by moving only resistors/caps/jumpers** (49 parts
total; the ICs and connectors never moved). The reject-and-revert mechanism
did real work: two of four candidate placements made things worse and were
discarded, which is exactly the proxy-blindness the loop exists to catch.
Costs: +36 vias. (This run predates the swap displacement cap — one resistor
traveled 32 mm via an uncapped swap, which is what #430 fixed. Swaps are now
capped like every other move, and the loop holds that cap at its base value
while it widens the nudge radius, #458.) DRC: the same PAD-SEGMENT
micro-overlap artifact the router produces on the baseline (16 occurrences)
appears somewhat more often on the repaired board (30).
This validates the core hypothesis of the whole investigation: **proxies
propose, the router disposes.** Wall-clock cost was ~5 routing runs
(~4 minutes total on this board).
## The portfolio: diversity without giving up determinism
Everything above converges on ONE answer: the quench is a zero-temperature
greedy descent, deliberately de-randomized (#457), so the same board and
knobs produce the same placement byte for byte. That is the right property
for reproducibility and exactly the wrong one for exploring — a re-run can
never say "here is a different arrangement worth considering".
`place_portfolio.py` injects diversity at the SEED instead of un-suppressing
it in the engine, which keeps both properties at once:
- Each candidate is a legal perturbation of the input placement — `jitter`
(seeded disc offsets of the free parts), `poses` (rotation variants of the
highest-pin free parts, pruned by `pair_order.ref_inversions` so a
rotation that provably raises the forced-crossing floor is never even
quenched), `swap` (position exchanges inside a declared block, the move
the quench's own displacement-capped swap phase cannot reach).
- Every candidate is then quenched by the ORDINARY engine — `quench.py` is
not modified, and a default `place_optimize.py` run is bit-identical with
the portfolio in the tree.
- Randomness is scoped, never ambient: candidate i draws from
`random.Random(f"{seed}:{i}:{strategy}")`, so the portfolio is a pure
function of (board, knobs, seed) and any single candidate replays alone
via `--only i`. `tests/test_portfolio_determinism.py` pins this across
PYTHONHASHSEED values, test_457-style.
- Ranking is a lexicographic tuple of numbers this document already
establishes as trustworthy — crossings, the inversion lower bound, hpwl,
the floorplan health signals, displacement — and the top candidates are
probe-ROUTED (`--route-top`, default 2), because proxies propose and the
router disposes applies to a slate exactly as it applies to a single
repair.
### The same bound as an objective term (#893)
`--facing-weight` puts `pair_order`'s inversion count into `quench.total_cost`
directly, instead of using it only to prune seeds as `poses` does above. It is
**0.0 by default**, and this document is why: everything below says a proxy
added to this objective has repeatedly failed to translate. A lower bound is a
better citizen than a correlational proxy — improving it cannot be gamed — but
that is an argument for measuring, not a measurement.
What it buys over `--orient-weight`, the other rotation-aware term: that one
scores DIRECTION (pads pointing at a net's centroid) and is blind to ORDER. Two
parts can point their pads straight at each other with every net crossed, which
is exactly run 5's U3 — a 180° rotation took the same nets from 4/7 routed to
7/7 while airwire lengths barely moved.
What it buys over `place_portfolio --strategies poses`, which already explores
rotations pruned by this bound: the portfolio prunes CANDIDATE SEEDS and then
quenches each with an unmodified objective, so a rotation that would only pay
off after the quench settles is never proposed; the weight makes the ordinary
move loop able to turn a part mid-descent. They are complementary, and the
portfolio remains the cheaper first thing to try.
The perturb-then-descend shape is classical basin hopping (Wales & Doye) —
the "extend the scorer to evaluate a perturbation" note in the SA section
above, finally built, with the acceptance step replaced by an explicit
ranked presentation to the user.
`place_seed.py` is the same idea one step earlier: for a board with NO
placement yet, a declared floorplan intent (zones, edge bands, locks, decap
rules — `docs/floorplan-intent.md`) carries exactly the unmodeled
constraints whose absence makes naive from-scratch placement fail (see "Why
from-scratch autoplacement fails" above). The seeder turns the intent's
constructs into a legal, seeded initial placement, grades its own output
against the same intent, and hands the result to the portfolio. The
from-scratch verdict stands: unaided is still out of scope; *aided by a
declared intent* is now a supported path.
## Roadmap: placement science after run 7 (August 2026)
Run 7 (the first clean-slate run whose placement this stack generated) and
the discussion-#118 thread converged on the same diagnosis: the candidate
score is built from signal proxies while several measured placement
failures live elsewhere. Ordered by cost-to-value, cheapest first; item 1
is implemented, the rest are documented targets.
1. **Plane fragility in the candidate score — implemented** (`--plane-score`
on `place_portfolio.py`, backed by `plane_score.py`). Quench and
portfolio were plane-blind while the router-side #424 machinery already
prices exact fill damage. The score pours the declared plane nets on a
scratch copy (KiCad ZONE_FILLER refill), and folds (islands, neck sum)
into `rank_key` — islands before hpwl, neck sum after it. Trust it only
after calibration on boards with a measured probe order (run 7's seed
archive is the first known-answer case). It takes no wall-clock budget
(#713): the terms are stripped only for a cause every candidate shares, and
a per-candidate failure makes the run refuse rather than rank on a
different key set.
2. **Channel occupancy as a nonlocality proxy.** *(partly done —
`--corridor-weight`, below. What remains is occupancy against a capacity,
which needs the escape-lane supply model; the cut term prices damage, not
fullness.)* A candidate that fills a corridor past capacity fails ROUTING
nonlocally — the failure surfaces on whatever net routes last, far from the
part that caused it (run 7's west-fan capacity finding is exactly this
shape).
2b. **Constrained-part re-seating** (`placement/reseat.py`). *Done.* The
observation it comes from: parts under a proximity rule are **locked**,
because the quench has no proximity term and will otherwise walk a different
member past the limit every run — lock one and the next moves. That works,
and it freezes exactly the parts whose re-seating produced most of run 8's
placement wins.
The move that removes the need for both the locks and a proximity cost term:
**make the proximity rule the definition of the slot pool.** Slots are
generated on rings around the anchor's relevant *pins* (not its centre — that
would send a decap to the middle of the die), so every candidate satisfies
the constraint by construction and there is nothing to price.
What remains is an assignment problem, and it is exactly additive: each
member is scored with every other member's pads overridden to an **empty
list**, which removes them from the airwire model. No member–member term ⇒
Hungarian-legal. The constant the rows share (the rest of the board's
contribution to the same nets) is uniform across the matrix, and Hungarian is
invariant to adding a constant everywhere. `scipy.linear_sum_assignment` when
present, deterministic greedy otherwise — KiCad's bundled Python has no
scipy and this runs on the same paths.
Three properties that make it safe rather than merely clever: identity is
always in the pool, so "leave it alone" is a possible answer; **acceptance is
on the exact cluster objective**, re-evaluated with every member in place, so
a lying surrogate can waste time but can never ship a worse board; and there
is no RNG anywhere.
**The limit, stated rather than left to be discovered:** the objective is the
cluster's airwire cost, so the cluster must carry a net worth scoring. The
only tether source in-repo is `decap_tethers`, and a decoupling cap's two
nets are *rails* — which the fanout cut correctly drops, because scoring a
pose against a 96-part GND MST measures distance from the middle of the
board. Those clusters report that and are left alone, which is right: where a
decap sits is governed by its pin, and the slot pool already guarantees that.
The mechanism earns its keep on clusters carrying **signal** nets (series
terminations, filter networks, a part tethered to a zone), and it is generic
over `(member, anchor, radius)`, so a new tether source needs no change here.
Two traps, both found by writing the tests rather than by reasoning:
Hungarian returns distinct slot *indices*, which is not the same as
non-overlapping courtyards, so members are seated one at a time with an
explicit check against the poses already assigned (`state.parts` still holds
the old ones) and the bumps are reported as `repairs` rather than hidden. And
snapping a ring to the placement grid moves a point by up to half a cell per
axis, so slots generated *at* the radius land outside it — the pool shrinks
by the snap diagonal, or the by-construction claim is false at exactly the
outer ring where a re-seat wants to look. Since #708 that snap is taken on
the offset **from the anchor**, not on the absolute point, so the pool
inherits the anchor's own phase instead of a lattice through board origin;
the shrink argument is unchanged, because the snap error per axis is still
half a cell.
3. **The #411 undo-a-known-good-placement harness — BUILT.** See "What the
#411 harness measured" below. `placement/perturb.py` manufactures the bad
seed, `placement/recovery.py` grades it against the original, and
`tests/stress/perturb_batch.py` walks the skill's Step 0 ladder over the
result. The first two batches are in; the headline is that no arm in the
toolbox recovers a displaced floorplan, and that this is a missing objective
term rather than a tuning problem.
4. **Per-component best-location heatmap** (the #118 ask): for a chosen
part, render the board as a heatmap of the candidate score over
positions (with matching JSON), so a human can SEE why the optimizer
wants a part somewhere — and where the score is flat, which is where
declared intent has to carry the decision.
5. **Part-class rule table as seeder configuration.** #118's taxonomy:
different part classes obey different placement logic (decap ≠ connector
≠ crystal ≠ series termination). The seeder hardcodes a version of this;
making it a declared table would let a repo tune class behavior without
engine edits.
## What the #411 harness measured
*(First results, August 2026. Boards: `tigard`, `splitflap_driver` and
`glasgow_revC` — the last two are stress set-1 members. Ground truth is each
board's own shipped placement; `recovery = 1 − d_after/d_applied_dose` in
pad-space RMS, so 1.0 is a full recovery, 0 inert, negative worse than the
perturbed board. Scoreboard: `$STRESS_DIR/perturb/scoreboard.jsonl`,
append-only, keyed by `code_version`.)*
**Nothing recovers. Every proxy says otherwise.**
| arm | recovery (3–6 cells) | median | crossings improved | routing failures improved |
|---|---|---|---|---|
| `place_optimize --max-displacement 3` (Step 0c's command) | −0.062 … +0.023 | −0.005 | 6/6 | — |
| `place_optimize` at a cap matched to the dose | −0.215 … +0.301 | −0.087 | 6/6 | — |
| `place_route_loop` shipped | −0.001 … +0.029 | +0.001 | 3/3 | 3/3 |
| `place_route_loop --target-nets` | −0.107 … +0.047 | +0.001 | 3/3 | 3/3 |
| `place_route_loop` cap matched, pin gate lifted, blocks on | −0.689 … +0.161 | −0.052 | 3/3 | 3/3 |
`R_pose(recovery ≥ 0.5)` is **None for every arm at every dose tested.**
Two findings follow, and they are different in kind.
**The quench optimises away from the answer.** Crossings fell in 6 of 6 cells
(median −18.5% at the prescribed cap, −33.1% at a capable one) while
displacement-to-original improved in 1 of 6. Three arms *beat the human
placement* on crossings while sitting further from it than the perturbed board
they started from — tigard/swap 267 vs 276, splitflap/translate 55 vs 123,
glasgow/translate 563 vs 785. Overlap falls in every cell too, so without a
pose metric this reads as success on every legality and crossing number
available. This is the document's own "proxies propose, the router disposes",
measured against ground truth instead of argued from anecdote — and the cause is
structural: **the objective has no displacement-from-seed term, so nothing pulls
a part back**, and on a wrong floorplan the crossing gradient points away.
**The loop compensates rather than recovers, and that is not a defect.**
`loop@allon` took tigard from 13 routing failures to 2 — the best-routing board
in the experiment — at a recovery of −0.052; on glasgow it took 19 → 11 while
moving parts 69% further out. The loop makes a wrong floorplan routable *in
place*. Nothing in its objective rewards placement fidelity, so `compensated` is
a first-class verdict here, not a shortfall.
That also settles #411's own stated prediction — *"at block scale
`--max-target-pins 40` recovers none of them, because the part that needs to
move is never a passive"* — as **right about the outcome, wrong about the
mechanism**. Lifting the gate improved routing enormously (13→12 becomes 13→2)
and left recovery at −0.052. Removing a filter on *which* parts may move cannot
produce recovery when no term rewards moving them back.
Three smaller results worth keeping:
- **A loop cannot see a floorplan that is wrong but still routable.** A scoped
route of a perturbed board returned `failures=0`, so the loop printed "No
failures left - stopping" at `rounds_run=0` and returned its input byte for
byte. Its move candidates come only from failed and blocking nets, so the pin
gate was never even consulted. `--target-nets` is the documented answer and it
works: on both smaller boards the targeted arm beat the shipped one on the
router's own terms (tigard 13→8 vs 13→12; splitflap 10→5 vs 10→9).
- **A rigid block translate barely moves on a packed board.** ~~Feasible doses
before a member leaves the outline: 1.40 mm for coldfire's 21-part sheet
block, 5.10 mm for glasgow's 67-part anchor unit~~ — **those two numbers are
superseded and were wrong by up to 59×.** They predate `perturb`'s
four-direction fallback (`perturb.py:464-473`), which was added *because*
coldfire clipped to 0.0 in every direction; re-probed with it, the same board
and the same block give **82.27 mm**, and glasgow **13.62 mm**. The headline
claim survives in a weaker form — a shipped board has little room *outward* —
but "the block-scale ladder is a per-(board, unit) property, not a constant"
is the part that held. `swap`, which exchanges two units and changes no net
area, reaches 14–66 mm.
**And the direction matters as much as the number.** Both of those are travel
*away* from the block's connectivity target, which is what a damage rig wants.
A relocation goes the other way, and #554 measured that separately: toward the
target, with every neighbour frozen, a block travels a **median 0.00 mm against
a median want of 10.36 mm**, over the 24 measurable blocks on the 9 boards that
have one
(`tests/stress/relocation_reach.py`). Letting the neighbours yield in preserved
relative order buys ≥ 1 mm more on 11 of those 24, over 6 boards, up to
16.66 mm. That is the mechanism #554 rests on, and it is *not* a claim that a
relocated board routes better — see `--relocate` below.
- **Cost goes as `(cap/step)²`, so a bigger cap is not a slower run.**
`loop@allon` searched a 7.9× larger radius and ran **4.6× faster** than
`loop@shipped` (185 s vs 846 s) because `--step` scaled with the cap. Step 0c
prescribes `--max-displacement` and never mentions `--step`.
**What this says about Step 0c's acceptance rule.** It accepts on
`crossings_after ≤ crossings_before` and `hpwl_after ≤ hpwl_before`. On
splitflap/swap both improve while the board is 91% un-recovered, so the rule
green-lights every run above. Two numbers produced by the optimizer cannot
adjudicate a property the optimizer has no term for — which is the same lesson
this document already records for the decap case, now with a second instance.
## References and further reading
### PCB-specific placement research
- [Cypress: VLSI-Inspired PCB Placement with GPU Acceleration](https://www.csl.cornell.edu/~zhiruz/pdfs/cypress-ispd2025.pdf) (Zhang et al., ISPD 2025 best paper) — net-crossing objective, per-side density maps, lock-critical-parts scoping, open benchmarks with KiCad converters ([code](https://github.com/NVlabs/Cypress), [ACM](https://dl.acm.org/doi/10.1145/3698364.3705346))
- [SA-PCB](https://github.com/The-OpenROAD-Project/SA-PCB) (UCSD/OpenROAD, ~2019) — open-source SA PCB placer: polygon-exact overlap via Boost geometry, 90°/45°/free rotation, TimberWolf cooling
- [NS-Place: Net Separation-Oriented PCB Placement via Margin Maximization](https://arxiv.org/pdf/2210.14259) (2022) — SVM-like net-hull separation; −25% routed WL, −50% vias, −79% DRVs vs wirelength-minimal placement
- [Quinn & Breuer, "A force directed component placement procedure for printed circuit boards"](https://ieeexplore.ieee.org/document/1084652/) (IEEE TCAS 1979) — the founding force-directed placement paper, written for PCBs
- [Abboud, Grötschel & Koch, "Mathematical methods for physical layout of printed circuit boards"](https://link.springer.com/article/10.1007/s00291-007-0080-9) (OR Spectrum 2008) — survey of exact/heuristic PCB layout models
- [Reade, MIT M.Eng thesis](https://dspace.mit.edu/handle/1721.1/129238) (2020) — NN routability predictor trained on 75k placements labeled by actually routing them; "wirelength and crossings only correlate roughly with routability"
- [Crocker, MIT M.Eng thesis: Physically Constrained PCB Placement with Deep RL](https://dspace.mit.edu/handle/1721.1/139247) (2021)
- [RL_PCB](https://github.com/LukeVassallo/RL_PCB) (Vassallo, DATE 2024; [thesis](https://www.lukevassallo.com/wp-content/uploads/2023/09/automated_pcb_component_placement_using_rl_msc_thesis_v2_1_lv.pdf)) — RL that learns *local placement moves*; closest published work to a learned perturbative refiner
- [DAC 2024 LBR: Modern Automatic PCB Placement with Complex Constraints](https://dl.acm.org/doi/10.1145/3649329.3663495) and an [analytical fine-tuning follow-up](https://www.sciencedirect.com/science/article/abs/pii/S016792602500224X) (2025) — SA-based pad-alignment fine-tuning that improves local routing space
- [Sutherland & Oestreicher, "How Big Should a Printed Circuit Board Be?"](https://ieeexplore.ieee.org/document/1672352/) (IEEE Trans. Computers 1973) — classic cut-line wiring-capacity bound
### Classical placement (ASIC/FPGA) — the algorithm toolbox
- [TimberWolf](https://janders.eecg.utoronto.ca/1387_2015/readings/timberwolf.pdf) (Sechen & Sangiovanni-Vincentelli, JSSC 1985) — origin of the displace/swap/rotate SA move set, shrinking displacement window, and routing-in-the-loop refinement stage
- [VPR](https://www.eecg.toronto.edu/~vaughn/papers/fpl97.pdf) (Betz & Rose, FPL 1997; [docs](https://docs.verilogtorouting.org/en/latest/vpr/command_line_usage/)) — adaptive annealing schedule (target acceptance ≈ 0.44); `--place_quench_only` is the exact "polish an existing placement" UX
- [Lam & Delosme cooling schedule](https://www.researchgate.net/publication/221433579_An_Efficient_Simple_Cooling_Schedule_for_Simulated_Annealing) (1988) — source of the 0.44 acceptance-rate target
- [FastDP](https://home.engineering.iastate.edu/~cnchu/pubs/c30.pdf) (Pan, Viswanathan & Chu, ICCAD 2005) — canonical detailed-placement move set: global swap, vertical swap, local reordering, single-segment clustering
- [Abacus legalization](https://www.semanticscholar.org/paper/Abacus:-fast-legalization-of-standard-cell-circuits-Spindler-Schlichtmann/b7c0656875460a88616342fa9ab55da9496bd22f) (ISPD 2008) — minimal-displacement legalization via per-row dynamic programming
- [Mongrel optimal interleaving](https://users.ece.utexas.edu/~dpan/EE382V_PDA/papers/iccad00_mongrel.pdf) (ICCAD 2000) — DP window reordering
- [ABCDPlace](https://yibolin.com/publications/papers/ABCDPLACE_TCAD2020_Lin.pdf) (TCAD 2020) — batched/parallel detailed placement (independent-set matching, global swap, reordering)
- [GORDIAN](https://janders.eecg.utoronto.ca/1387/readings/gordian.pdf) (ICCAD 1988) — quadratic/analytical placement ancestor
- [ePlace](https://cseweb.ucsd.edu/~jlu/papers/eplace-todaes14/paper.pdf) (TODAES 2015) and [DREAMPlace](https://research.nvidia.com/sites/default/files/pubs/2019-06_DREAMPlace:-Deep-Learning/54_1_Lin_DREAMPLACE.pdf) (DAC 2019) — modern electrostatic/GPU analytical placement (what Cypress builds on; overkill at PCB scale)
- [DAS-MP dataflow-aware flipping](https://arxiv.org/html/2505.16445) (2025) — orientation moves alone worth avg 7.9% HPWL in macro placement
- [Markov, Hu & Kim, "Progress and Challenges in VLSI Placement Research"](https://users.soe.ucsc.edu/~pang/200/f18/papers/2018/ProCha.pdf) (Proc. IEEE 2015) — the field survey; documents that SA was abandoned for scalability, not quality
- [Synopsys ICC relative-placement groups](http://s3-us-west-2.amazonaws.com/valpont/uploads/20151120031409/icc_study_notes.pdf) and [analog symmetry-island B*-trees](https://dl.acm.org/doi/abs/10.1109/TCAD.2009.2017433) — constraints-by-construction precedents (super-cells, feasibility guaranteed by the representation)
### Routability metrics
- [RUDY](https://past.date-conference.com/proceedings-archive/2007/DATE07/PDFFILES/08.7_1.PDF) (Spindler & Johannes, DATE 2007) — standard VLSI congestion proxy, `d_n = (HPWL_n·pitch)/(w_n·h_n)` per net bounding box; see Cypress §4.2.1 for why it underperforms on few-layer PCBs
- [RISA net weighting](https://link.springer.com/chapter/10.1007/0-387-48550-3_2) (Cheng, ICCAD 1994) — pin-count-aware HPWL weighting for congestion
- [FLUTE](https://home.engineering.iastate.edu/~cnchu/pubs/j29.pdf) (Chu & Wong, TCAD 2008) — fast Steiner-tree wirelength (marginal gain over HPWL for mostly-2-pin PCB nets)
- [SimPLR](https://web.eecs.umich.edu/~imarkov/pubs/conf/iccad11-simplr.pdf) (ICCAD 2011) — global-router-in-the-placement-loop
- [RouteNet](https://zhiyaoxie.com/files/18_RouteNet.pdf) (ICCAD 2018) — ML DRC-hotspot prediction from placement features
- [SURF rubber-band topological routing](https://dl.acm.org/doi/pdf/10.1145/127601.127622) (DAC 1991) — the theoretical grounding for crossing counts: zero crossings = planar = single-layer routable
### Refinement-from-seed evidence
- [MaskRegulate](https://arxiv.org/html/2412.07167) (NeurIPS 2024) — "RL as macro regulator, not placer": refining a finished placement beats from-scratch; 73% congestion-overflow reduction
- [WireMask-BBO](https://openreview.net/forum?id=hoyL1Ypjoo) (NeurIPS 2023; [code](https://github.com/lamda-bbo/WireMask-BBO)) — black-box fine-tuning of any existing placement, up to 50% HPWL improvement
- [Kahng/Cheng re-evaluation of Google RL placement](https://arxiv.org/pdf/2302.11014) (ISPD 2023), [Markov, "The False Dawn"](https://arxiv.org/html/2306.09633v10), and [CACM coverage](https://cacm.acm.org/research/reevaluating-googles-reinforcement-learning-for-ic-macro-placement/) — well-tuned SA matches/beats RL; RL gains traced largely to seed quality. Original: [Mirhoseini et al., Nature 2021](https://www.nature.com/articles/s41586-021-03544-w); rebuttal: [That Chip Has Sailed](https://arxiv.org/abs/2411.10053)
### Commercial landscape and practitioner context
- [Quilter](https://www.quilter.ai/) ([technology](https://www.quilter.ai/product/technology)) — RL placement+routing with router/DRC/physics scoring in the loop; hours per board, hard size limits as of 2024
- [DeepPCB / InstaDeep](https://deeppcb.ai/) ([placement launch, 2025](https://deeppcb.ai/2025/04/15/ai-powered-pcb-placement-by-deeppcb/)) — RL self-play placement and routing
- [Cadence Allegro X AI](https://www.cadence.com/en_US/home/company/newsroom/press-releases/pr/2023/cadence-introduces-allegro-x-ai-accelerating-pcb-design-with.html) (2023) — MCTS-based generative placement
- [Altium Autoplacer docs (last version, 17.1)](https://www.altium.com/documentation/altium-designer/autoplacer-cmd-runautoplacerrunautoplacer-ad?version=17.1) — deprecated at v18
- [tinycomputers.io: What a commercial PCB placer does that my open-source one can't](https://tinycomputers.io/posts/what-a-commercial-pcb-placer-does-that-my-open-source-one-cant.html) — open-source SA vs Quilter; spreading for fanout room beats wirelength minimization
- [edaboard: why autoplacement doesn't work in Altium](https://www.edaboard.com/threads/why-autoplacement-dont-work-in-altium-designer.354093/) — practitioner consensus on constraint capture
- [Engineer Live: The future of PCB design automation](https://www.engineerlive.com/content/future-pcb-design-automation) — Altium PM on why placement constraints can't be modeled in ECAD
- [TI SNVA021](https://www.ti.com/lit/pdf/snva021) and [ADI AN-1119](https://www.analog.com/en/resources/app-notes/an-1119.html) — switching-regulator layout intent that lives in datasheets, not netlists
- [HN: tscircuit autorouter discussion](https://news.ycombinator.com/item?id=43499992) and [JITX discussion](https://news.ycombinator.com/item?id=39771983) — autorouter/autoplacer trust culture
- [Cypress benchmark suite](https://github.com/NVlabs/Cypress) — the only open PCB placement benchmark set (10 boards, 41–476 components)
## Update: hard pad+drill legality (default on)
The quench, seeder and portfolio now share a pad+drill legality layer
(`placement/legality.py`: `PartPads`/`LegalityContext`/`grade_pad_legality`) —
courtyard-only gating is history (`--courtyard-only` restores it for A/B).
New repair entry points: `place_seed --repair` (violation-driven,
minimal-move) and `place_reconstruct.py` (structural reconstruction with an
exact assignment solve). Anti-churn: `--min-gain-per-mm`. Mounting holes are
frozen by default (`--move-unconnected` frees them). Full design notes and
measured acceptance numbers: `placement/README.md`.
8b2a3278aThis file is pinned to the selected release. Download the original to open it in your design tool.