Skip to content

placement: the reconstruct ladder, reseat, orbits, and instruments that stopped lying - #623

Open
edgehero wants to merge 29 commits into
drandyhaas:placementfrom
edgehero:pr2/placement-reconstruct-and-stress
Open

placement: the reconstruct ladder, reseat, orbits, and instruments that stopped lying#623
edgehero wants to merge 29 commits into
drandyhaas:placementfrom
edgehero:pr2/placement-reconstruct-and-stress

Conversation

@edgehero

Copy link
Copy Markdown
Contributor

Stacked on #616 — this branch includes #616's commits; the placement work is the 9 commits from "reconstruct: the ladder trusted any equal-cost move…" onward. After #616 merges this rebases to show only its own diff.

The placement engine work from perturbed-corpus runs 8–17, re-rooted onto the #522 layout (py_placer/, py_tools/), plus the stress rig that grades it.

Engine

  • Reconstruct ladder: the hole-pattern fit learns per-group families, mid-edge slots and positional at-seat; an orbit detector corroborates seats; an unevidenced equal-cost move is pruned rather than trusted (an equal gate tuple is no tie-break in an unevidenced move's favour); the frame tier keys on the netlist, not pin_count == 0; off-board damage witnesses come from a pad-centre census.
  • Reseat: a repair search that starts from the current pose knows nothing at 40 mm — place_seed --reseat lifts a far-from-home part and re-seats it on the fitted family orbit instead of nudging it, behind a 3-conjunct gate.
  • Instrument honesty: off-outline pad copper measured against the true Edge.Cuts outline per-pad (the placement half's top-priority gate — it converts one-for-one into unrouted nets); check_assembly publishes buildable/verdict/locked_contacts and keys its oracle memo on fill cost, not a path every step renames; check_reachability reserves exit 1 for CAGED and exits 2 when the question was not answerable; check_floorplan --require-rules refuses a vacuous pass; the emit-intent edge band is capped so 160 mm of damage cannot launder itself into "by the edge, by design".
  • Deadlines: every placement search takes --deadline (exit 7, summary guaranteed) through the krt_deadline module routing: boards graded at constants they contradict, and tools that reported success falsely #616 introduces.
  • converge + check_complete: the ledger grows lens-verdict grammar with a lens-vs-score contradiction refusal (a PASS beside the numbers that refute it is evidence about the verifier, not the board), commensurability guards, --exhausted <half> declarations with mandatory reasons, --score-file (a 49 kB score no longer dies on argv limits), and a four-code verdict; check_complete is the close-out instrument that fails closed — orphan stubs, weird copper, pad overlaps, and fab floors compared against the AUTHORED project so a board cannot grade itself against floors it rewrote.

Stress rig (tests/stress/)

stage_blind (the dose is checked against the material floor and redrawn — "damage applied" on a clipped dose voids the recovery half before it starts), fence_audit (content-based truth fence; this commit also repairs the file at tip, which is currently a SyntaxError — a #522 shim was inserted mid-expression at line 58), arm_report, qualify_subject ("is this board a usable perturbed subject" in seconds), strip_copper_only, calibrate_congestion_ratio (its first result refuted the threshold it was built to calibrate; the gate now reports instead of gating), and _truth/ staging throughout.

Live validation

This exact engine + rig closed a blind perturbed board end to end yesterday: piantor staged blind (kind sealed), placement half repaired a ±(72.6, 6.4) mm two-group exchange to all-gates-green with an independent verifier PASS, routing half took it to blocking 0 / DRC 0 at authored floors. The run's report and ledger are reproducible from the rig in this PR.

Testing

Targeted suites green on this branch: place_seed 37/37, reconstruct (0 errors; corpus-dependent checks skip), slot-fit, stage-blind dose 6/6, fence-and-channels 8/8, arm_report/verdict/check_complete suites, converge 27, render_placement 28, reachability. Five tests in this PR's set are deliberately red until the skill PR stacks on top (they pin board_score/loop-driver shares that land there) — each is named in its commit. Full run_all on the stack: 294 passed with every red pre-existing at tip on our test box (tip baseline measured; see #615 for why a fresh tip clone cannot run at all).

edgehero and others added 26 commits August 12, 2026 01:03
KiCad writes 0 into min_hole_to_hole / min_copper_edge_clearance for 'not
configured', and board_floor_knobs already treats a non-positive declared
value as unset -- but each of the four routing mains kept a private copy of
the raw read: board_constraint(...) if ... is not None else <default>, an
is-not-None test with no positivity guard. Eight sites (route.py,
route_diff.py, route_planes.py, repair_planes.py), so one declared floor got
two answers depending on which half of the loop asked.

Measured, on a board declaring min_copper_edge_clearance: 0.0 -- placement,
floorplan and render used 0.55 [fixed default] while route.py used a REAL 0.0
(printing 'using the board min_copper_edge_clearance 0.0mm', announcing a
declaration that was not one) and the plane tools dropped their 0.5 inset to
0.0 on the very comment saying they keep it 'only when the board declares no
edge rule of its own'. The GUI plane tab already guarded > 1e-9, so this also
closes a CLI/GUI divergence.

list_nets gains resolve_cli_floor (one resolver, _FLOOR_SOURCES provenance)
and the declared-0 guard in board_floor_knobs; the four mains route through
it. tests/test_board_floors.py pins the shared-resolver wiring by AST on all
four mains and runs each of them live on a declared-0.0 board (this file
re-creates the checks from the test upstream removed with drandyhaas#579, minus the
removed feature's blocks, under a new name).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
A board can declare min_hole_clearance in its .kicad_pro and KiCad grades
NPTH-to-copper at it -- but the router priced every NPTH obstacle at the flat
NPTH_TO_TRACK_CLEARANCE constant, and check_drc never consulted the key at
all. Measured: a real, kicad-cli-confirmed 0.0237 mm routing-introduced
violation; and neo6502 ships 3 NPTH at 0.2126/0.2263 mm against an authored
0.25, graded clean here because nothing read the declaration.

obstacle_map gains resolve_hole_clearance (board-first, cached, raise-only
via max-wrap) consumed by add_drill_hole_obstacles and plane_obstacle_builder
through the new routing_config.hole_clearance field; check_drc gains
--hole-clearance with the project auto-read as a third max() term; and
min_hole_clearance joins fix_kicad_drc_settings.FAB_FLOOR_KEYS so the
relaxation disclosure stops being blind to it. tests/test_npth_seed_floor.py
fails when the stamp is mutated back to the constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
The underpad escape spaced its drills at the flat HOLE_TO_HOLE_CLEARANCE
constant in all three comparison sites, whatever the board declared.
Measured on ulx3s (declares min_hole_to_hole 0.4): drill gap 0.3657 mm
before, 0.6000 mm after; a control board with no declaration is
byte-identical. The floor is read board-first and fab-wrapped (tighten-only),
after clamp_stats so the disclosure names what was used.

Also documents manage_vias' structural self-blindness where the next reader
will find it: both guards that gate a via append iterate pcb_data.vias only,
and vias_to_add is appended to but never read back, so two vias placed in one
call are never checked against each other. Stated as UNMEASURED -- filed as
an issue rather than patched here, because the honest fix wants a measured
fine-pitch reproduction first.

tests/test_bga_fanout_underpad.py: 3 of 5 checks fail on the unpatched tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
… laid

The escape candidate was clearance-tested against the board's PRE-EXISTING
copper only: run_output_conflict did not exist, so a candidate was never
tested against the vias and stubs THIS run had already emitted -- including
cross-side -- and a reused via was priced as if it added a new drill and new
copper (adds_via=True), losing reuse ties it should win.

The pinned regression: on kicad_files/routed_output.kicad_pcb U2 the fix
moves the outcome 29/13/31 -> 28/12/30 (escapes/vias/stubs). The original
headline symptom (39/46 rejected escapes) is honestly stated as NOT
reproduced -- the structural gap plus the regression pin are the evidence.
The hole-to-hole floor is board-first with the fab wrap, disclosed once.

tests/test_qfn_underpad.py carries the mutation checks: reverting either the
cross-side half or the pricing flips named checks red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…id not

_effective_geometry_floor took board_val if board_val is not None with no
positivity guard, while the plane-edge helper beside it already required
> 1e-9 -- so a board writing 0 for 'not configured' drilled at hole-to-hole
0.1 in the GUI where the CLI resolved 0.2, same board, same settings, under
--fab-overrides. Two lines close the divergence.

tests/gui_parity/test_geometry_floor_leak.py pins it under real KiCad python;
.gui-parity-checked gains a scoped addendum naming this PR's one GUI-relevant
change and the parity evidence (copper sets identical, 1307 segments, 144
vias).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…n as utf8

A slow test and a hung one cost different things, and both rendered as FAIL.
run_all now prints TIME with its own summary count and a note that a timeout
is not a failed assertion; children run with -X utf8 so a degree sign in a
test's output is not a crash of the runner's reader thread. run_utils gains
the check/evidence helpers the run-era suites share.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
argparse reads --nets -12V as a flag and dies with a usage error; a lap was
lost to exactly this. cli_banner.install_dash_hint hooks the parser error to
say the one thing the user needs: write --nets=-12V. Adopted where net names
arrive: the routing mains. tests/test_run14_dash_net_hint.py pins the hint
text and that a real parse still fails cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…success falsely

Two families land together because they share the same four files and the
second emits through the first.

Deadline (krt_deadline.py, new): an external kill leaves no summary and no
exit code of the tool's own, so a harness cannot tell killed-mid-write from
crashed. --deadline arms a cooperative budget: arm/stamp/seal, exit 7, a
guaranteed JSON_SUMMARY with complete:false on the way out. The wiring is one
closure into the cancel_check/progress_callback parameters batch_route,
batch_route_diff_pairs, create_plane and repair_planes already have; a
reserve band keeps clock for the end-of-run reconnect so a cancelled run
ships a real, gated board. place_route_loop treats exit 7 as a partial
result with a summary, not a failed step.

False success, per file, each measured:
- route.py: a --nets list where every name missed wrote a passthrough copy
  and exited 0 (run 11 lost two laps to boards KiCad refuses to load); CRLF
  in net names stripped; the summary stamps scope and names
  ripped_open_uncounted, so the LAST JSON_SUMMARY of a log (often the
  reconciliation subset's) cannot masquerade as the run's.
- route_diff.py: zero matched pairs exited 0; a partial pair with BOTH
  members incomplete counted 'successful' (engine-side -- the GUI reads
  the successful field raw).
- route_planes.py: 'GND:B.Cu' was accepted as a layer name and KiCad refused
  the written file -- engine-side validation now; the pad tally reaches the
  JSON summary, recording geometric_failed WITHOUT flipping status on it
  (under pours-first those are expected at the plane step; only failed_pads
  flips status).
- repair_planes.py: emits through krt_deadline so a partial repair says so
  instead of exiting 0 with a contradicting summary.

Pinned by tests/test_deadline.py and tests/test_run12_tools.py in the skill
PR that consumes these exits; the refusal behaviors are exercised by
tests/test_board_floors.py's live main runs in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…9/70 graded as 70/70

A zone naming a layer the board does not have was counted as copper, so a
disconnected board graded one net better than it was. The census now
cross-checks against board_info.copper_layers; a --nets scope that matches
nothing exits 2 instead of reporting a clean empty set; and when the oracle
never ran, the output says so instead of implying it agreed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
The support loop credited a via with support from copper on ANY layer it
touches; KiCad's via_dangling wants support on at least two. Rebuilt as
per-layer sets (a behavior superset of the old loop) and a new dangling-via
category, measured against kicad-cli on the board that exposed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
A pair with both members incomplete was still counted toward the custody
tally it had just been removed from. Ten guarded lines, exercised by the
existing tests/test_diff_pair_custody.py suite. (The drandyhaas#549 rule-pair
classification test also covers this area but depends on the skill-side
board_score, so its layout repair rides with that PR.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
Each chain step routes to a fresh output path, so the memo of an earlier
kicad-cli timeout never matched and the chain re-burned the full timeout at
every step -- measured, 240 s per step. The memo now keys on the fill-cost
signature of the board content. KICAD_CLI (env) is consulted FIRST instead
of after the unix candidates, and Windows discovery covers the installs the
old glob missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…pped'

merge_summaries took the LAST oracle_check, so a subset pass that skipped its
oracle erased an earlier pass's real KiCad disagreement. Oracle news now
merges worst-first, and complete:false is sticky across merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…documented None

refill_islands documents returning None and one caller called .items() on it
anyway -- the misattributed NoneType error. Guarded; the refill honors a
krt_deadline budget when one is armed (try/except import, degrades to the
stock timeout when the module is absent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…, 3-5mm from the region

find_open_space_point answers 'emptiest cell within 5mm of the anchor
centroid' against an obstacle map that excludes the plane net's own copper,
so clearance saturates and the strict first-maximum tie-break picks the far
corner of the search box in raw grid order; _try_route_between_regions then
seeds A* with a point nowhere near the region it should join.

Seeding on region material (centroid-ranked validity probe), a COINCIDENT
rule for one-point routes, and union-find over merged fill islands take
neo6502's GND from 15 of 41 region joins dead-by-construction to 0. Two
healthy boards re-run byte-identical; validity=None preserves the old path
verbatim. Pinned by tests/test_region_join_nearest_pair.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…r showed

The caption strip clipped at the image edge, so the oob count and a fab
blocker fell off the render silently. Captions wrap instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…wn scope

rescue_by_width tallied the whole board where it meant its own work, and
delivered_mm is kept as the minimum of the honest candidates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…anything with no pins

The reconstruct ladder gets its missing rungs: fit_family_orbits learns
per-group orbit families (with a guard matrix so a two-member "family" cannot
vote itself in), the hole-pattern fit learns mid-edge slots and positional
at-seat instead of corners only (the run-8 finding: a slot model that knew
only corners could not seat a mid-edge connector), and straddle-vs-displaced
classification stops charging a part that never left its seat.

Two honesty fixes ride the same rungs: A2 prunes unevidenced moves -- an
equal gate tuple is no tie-break in an unevidenced move's favour -- and A3
takes a damage_witnesses pad-centre census so "damaged" is measured, not
inferred. The frame tier keys on the netlist rather than pin_count == 0,
which had promoted every pinless part to structural.

Straddling files ride here per earliest-theme: legality.py carries
format_oob_clause/oob_pad_refs (also the instrument-honesty basis),
pose_score.py carries extra_locked_refs (--lock plumbing) plus the
cancel_check hook the deadline commit threads. place_reconstruct's reseat
stage calls seeder.reseat_scope, which lands in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…ng at 40 mm

seeder.reseat_scope is the lift-and-reseat engine: pick the scope up off the
board, then seat it from scratch at its net centroids, behind a 3-conjunct
gate so it never fires on a placement a nudge can fix. Repair searches start
from the part's CURRENT pose, which carries no information once the part is
tens of millimetres from where it belongs -- measured, cost grows sharply
with the displacement cap while a re-seat stays flat. place_seed grows
--reseat, and place_reconstruct's reseat stage (previous commit) previews
candidates in honest order instead of the order the search happened to visit.

Also here: seeder feeds --lock globs into the two entry points that re-parse
the board and build their own state -- --lock was honoured by five
reconstruct stages and silently ignored by the two that move the most parts.

Straddles noted: seeder.py and place_seed.py also carry their share of the
deadline threading (a later commit wires the rest); floorplan.py rides here
for empty_intent, and brings EDGE_BAND_SANITY_MM -- emit-intent stops
laundering 160 mm of damage into an "edge band" declaration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
… bounding box, not the outline

render_placement now measures off-outline pad copper against the true
Edge.Cuts outline, per pad -- the skill's top-priority gate (run 10: 11 such
parts produced ALL 13 unrouted nets), previously approximated by a bounding
box that blessed copper hanging off a notch. It grows a findings-in-words
panel, worst-N --view crops, --pair named diffs, --gate, and net_lists
missed-pattern disclosure so a glob that matched nothing says so.

The checkers stop lying by omission: check_assembly keys its oracle memo on
fill cost and reports buildable/verdict/locked_contacts in JSON with
board-first floors; check_reachability gets exit-code discipline (2 =
not-answerable, 1 reserved for CAGED, REF.PAD dot-resolution);
check_floorplan --require-rules refuses the vacuous pass. legality's
oob_pad_refs basis landed with the reconstruct commit; placement_state's
pile check goes through-pads so locked piles are no longer exempt;
lock_advisor corroborates orbits and demotes straddles; recovery adds
home_curve/per_part_median and dirty_net_ids.

fab_tiers rides here for the same theme: the escalation warning now says
that --via-size cannot prevent the clamp (raising it fires the clamp more
readily), instead of letting the user reach for the one lever that cannot
help.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…the sweep

Cooperative deadline threading (krt_deadline, exit 7) through every placement
search: quench, the portfolio generator, place_optimize and place_portfolio
all take a cancel_check and stop at a granularity of one candidate, instead
of running to completion under a harness that already gave up. pose_score's
rank_poses (hook landed with the reconstruct commit) documents the trap that
motivated it: --limit truncates the sorted RESULT while every candidate has
already paid a full cost evaluation -- a 30 mm / 0.25 mm ring set is 231,392
candidates and --limit 12 evaluated all of them.

seeder.py and place_seed.py carry their share of this threading in the
reseat commit (earliest-theme rule). place_portfolio's _replay_argv/_render
subprocess strings were hand-checked against the upstream drandyhaas#522 layout:
py_placer/place_portfolio.py and py_tools/render_placement.py under ROOT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…sured to be

converge.py grows the machinery a measured stop needs: a lens grammar with a
lens-vs-score contradiction check (DONE-EXHAUSTED can never sit beside a FAIL
lens), commensurability so laps of different halves are not compared as if
they were the same experiment, --exhausted with a mandatory reason,
--score-file, FAIL_COMPATIBLE_STOPS, and a verdict with 4 distinct exit
codes so a caller can tell refused from failed from done.

check_complete.py (new, repo root -- precedent check_rigid_consistency.py;
the loop driver prints it by that path 8x and D1 pins "runs as printed") is
the single is-this-board-actually-finished gate: DRC at the board's own
floor, connectivity, and --authored-from against a frozen baseline, exit 5
meaning NOT complete. Its floor keys come from fix_kicad_drc_settings'
FAB_FLOOR_KEYS (PR "board floors").

The suites ride along: test_converge, test_run9_verdict,
test_run9_check_complete, and test_broken_worklist -- the last pins
board_score's broken-net WORK LIST (which net, how many pieces, where the
stranded pads sit); a count with nothing behind it left `broken` at 14 for
two iterations while `unrouted` went 4 -> 0 on per-net detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…to GIF in silence

Two channels in the film chain said things the artifact contradicted.
Pillow's GIF writer collapses runs of byte-identical frames -- card holds and
the end hold are literal repeats -- so animate_route's "wrote N frames" was
the encoder's input, not the file: 377 printed, 348 delivered, reconciled
only by counting the GIF by hand. The writer now counts the artifact itself,
and make_film labels its own number "assembled" and defers to the writer's.

The other silence: a missing imageio-ffmpeg made _write_mp4 return False
without a word, producing a .gif where the caller asked for .mp4 -- the only
trace a `wrote ...` line with a different extension. The fallback now says
so, with the pip line to fix it. test_film_composition also stops asserting
"not a crash" without checking for one: exit 2 with a Traceback passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…audit is a SyntaxError this repairs

The perturbed-corpus rig, plus the repair of tests/stress/fence_audit.py at
upstream tip: a drandyhaas#522 sys.path shim was inserted mid-expression (:57-60), so
the file has been a SyntaxError since -- ast.parse now passes.

Three channels carried ground truth nobody meant to send. (1) fence_audit
compared POSES and never opened copper: a subject staged from a routed board
keeps the human routing, so track ends go on marking where each part belongs
-- run 14 shipped exactly that and the audit said CLEAN. It now checks
copper, baseline-relative, with an empty DEFAULT_ALLOW and byte-reads.
(2) perturb's writer rewrote (at) with six decimals for exactly the moved
footprints: 24 six-decimal forms of 235 on run 14's subject, 0 on untouched
boards -- the moved set was readable off the text while the docstring said
it was "disclosed nowhere". Every part now goes through one formatter, and
control_out moves the control board plus the perturb record OUTSIDE the work
dir, destination always printed. (3) stage_blind checks the dose against the
material floor and redraws instead of printing "damage applied" on a dose
clipped to one grid step.

New tools: arm_report (the recovery table as a tool: pad-space home, frozen
member list, degeneracy guard), qualify_subject ("is this a usable perturbed
subject" in seconds), strip_copper_only, calibrate_congestion_ratio (its
first result refuted the shipped 0.25 threshold -- the P-close gate now
reports instead of gating). grade_final/qualify_subject regex fixes,
perturb_batch _truth staging, and the RUNBOOK's "choosing a subject before
staging" section ride along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
The doctrine section the placement work keeps re-learning: the objective is
a board that ROUTES -- zero unrouted and zero broken -- not restoring parts
to the poses they had before. The perturbed rig grades on recovery/home,
which measure distance to the original pose; run 10 scored recovery -0.0014
and home 0/30 on a run that took a board from NOT BUILDABLE to buildable.
Lead with blocking, keep recovery as a diagnostic, treat the human original
as a benchmark to approach. Off-outline pad copper is the top-priority
defect (run 10: 11 such parts produced all 13 unrouted nets); scope searches
to the refs the gate names (2 freed parts: 63 s; whole-board sweeps: 10+ min
without touching them); prefer re-seating over nudging at tens of mm.

Whole-file restore also carries the Windows/Git Bash MSYS2_ARG_CONV_EXCL
section (net names are /-prefixed and get silently rewritten into Windows
paths: 61 nets passed, 4 survived) -- same provenance, noted rather than
split out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…he focus test pins the refusal-free behavior

Two test-only adaptations re-extracted from integration:

- test_place_reconstruct.py: test_ladder_order and
  test_reseat_measures_with_SCOPE_FILTERED_edge_bands did in-method
  sys.path.insert(0, ROOT) + flat imports (place_reconstruct, kicad_parser,
  placement, pose_score) -- pre-drandyhaas#522 paths. Both errored ModuleNotFoundError
  on the split layout. They now add the same three "# placement split" path
  lines the file's control-board test already carries. Was 2 errors +
  3 skips; now 0 errors (1 pass, 4 skips -- the absent wk/b2 corpus).

- test_431_render_placement.py: test_focus_without_summary_json_warns
  asserted the run-4 G7 refusal ("--focus emits nothing without
  --summary-json") that render_placement deliberately removed -- --focus now
  clusters legality findings into pocket panels instead of refusing.
  Renamed to test_focus_without_summary_json_clusters_legality_findings and
  pinned the new path: exit 0, no warning, pocket siblings written. Suite
  was 1 failed / 27 passed; now 28 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
edgehero and others added 3 commits August 13, 2026 12:07
…locking 0)

Measured twice in run 19: SW17+SW34+REF_PUCK_R all at (128.399, 61.001)
and the gate said `buildable (blocking 0)` -- the pair currency counts pad
INTERSECTIONS, and the rotated pads happened to interleave. A stack of
parts at one origin is unbuildable whatever the pads do, so it is now its
own blocking channel.

The grouping and the exoneration reuse placement_state's prior art
(assess_placement: round(coord,3) buckets, per-side partition where a
drilled part is on both sides, all-marker side-groups exonerated) rather
than re-deriving them; a bucket is a finding only when it holds >= 2
suspect NON-marker parts, so mouse-bites and graphics markers never flag.
One predicate variable `not_buildable` now feeds all three re-derivation
sites (verdict, JSON `buildable`, exit code) -- three inline copies of
`blocking or locked_contact` is how a new channel reaches two of them and
silently misses the third. JSON gains `coincident_origin_groups`
([{point, refs}], never fake N*(N-1)/2 pair entries) and scalar
`coincident_origins`; the findings block prints each group as refs @ (x, y).

loop_driver's L2 gate reads only the five L2_CHECKS keys, so the
`verdict`/`buildable` flip is what gates; no driver change. A run that
hits it legitimately uses --accept-residue with the residue named.

New script test tests/test_assembly_coincident_stack.py pins the run-19
shape exactly: a stack whose pads don't intersect (blocking 0) exits 4
NOT BUILDABLE with the group naming both refs; the unmodified board stays
exit 0; a marker pair moved onto one point is exonerated by the channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
Measured in run 19 cycle 1: every conjunct of LegalityContext.pads_ok is
relative to seed_baseline, and on a pile the seed pair carries huge
base.pad with pad_overlap and stack both True -- so ANY smaller
intersection passed the gate, producing the SW23/SW28<->U2 blocker pairs
on the shipped board.

Fix at the single choke point every consumer routes through
(seed_baseline, feeding both pads_ok and swap_pads_ok): __init__ buckets
every part's SEED pose by round(coord, 3); a bucket of >= 3 parts is
degenerate (the pile signature -- deliberately above 2, so run 18's
legitimate two-part by-design under-module R1<->U2 overlaps KEEP their
baseline license), and any pair touching a degenerate ref gets
ZERO_SHORTFALL: the pose must be absolutely clean toward that neighbor,
which is exactly the semantics a pile deserves.

Second gate, same shape: portfolio.score_candidate compared
pad_conflict_pairs against the seed baseline's count; above 50 the
baseline is degenerate, so the candidate is compared against 0 instead
and the reason string says so.

New unit test tests/test_pile_baseline_license.py pins both sides of the
>= 3 line: the piled part's baseline is ZERO_SHORTFALL and pads_ok
REFUSES an intersecting pose (mutation-checked: with the guard emptied
the same pose is accepted), while the 2-part designed overlap keeps its
license.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
…undecidable subject

Two riders on the recovery rig:

- qualify_subject.py wrote --json with a bare open(); run 19 pointed it
  into a not-yet-created directory and lost the qualification JSON after
  all the draws had been paid for. os.makedirs(dirname or '.',
  exist_ok=True) before the write.

- RUNBOOK's "Choosing a subject" section qualified the BOARD but said
  nothing about the damage KIND, and run 18 staged an undecidable one: on
  a grid-homed board, swap/translate recovery is byte-identical to truth
  BY CONSTRUCTION, so a perfect result and a truth-file LEAK produce the
  same bytes and the fence cannot tell them apart (run 18's undecidable
  LEAK verdict). New subsection 1b: prefer pile/scatter on grid-homed
  boards, or pre-declare the secondary tell before the damage is drawn;
  references KINDS at placement/perturb.py:56.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011o1wnFiHT1HEBnuVyJdjgs
@edgehero

Copy link
Copy Markdown
Contributor Author

Three commits added after opening, all from the run 19 exercise of exactly this PR's code (the blind piled board written up in discussion #118):

  • check_assembly now counts coincident-origin stacks as blocking: three parts at one coordinate graded buildable / blocking 0 twice in that run because their rotated pads happened to interleave. New JSON channel coincident_origin_groups, marker parts (mouse bites, mounting holes) exonerated, pinned by tests/test_assembly_coincident_stack.py.
  • The pile-degenerate seed no longer licenses pad intersections: every pads_ok conjunct is baseline-relative, and a pile baseline (huge base.pad, stack=True) accepted any smaller intersection — it produced real blocker pairs in the run. Guard at the seed_baseline choke point (a seed bucket holding 3+ parts returns ZERO_SHORTFALL), two-part by-design overlaps keep their license, pinned by tests/test_pile_baseline_license.py including a mutation check.
  • Riders: qualify_subject --json creates its output directory (the run lost a finished qualification to a missing dir), and the RUNBOOK gains a paragraph on choosing damage kinds the truth fence can actually adjudicate.

The larger placement-engine gaps the same run exposed are deliberately NOT patched here — filed as #628, #629, #630 with the measurements. Full learnings: https://github.com/edgehero/KiCadRoutingTools/blob/run19-assets/LEARNINGS.md

@drandyhaas

Copy link
Copy Markdown
Owner

Merged as 12 of 13 commits, pushed to placement as e3bf2581..774d8780. Closing in favour of that — thanks, the placement half of this is good work and the instrument-honesty theme is exactly right.

What landed

Cherry-picked with -x, authorship preserved, applied clean in order:

yours on placement
4c457b2dd f5cee8777 reconstruct: the ladder trusted any equal-cost move
bfc2ac812 046b43364 reseat: a repair search that starts from the current pose
203f052dc cc1bbe88e placement instruments: measured against a bounding box
724ddaa19 c9e2fed70 deadline: a placement search had no clock
84315259e e8c71ee5b converge + check_complete.py
38f49096e 7b3ded33b film: 377 frames over a GIF that holds 348
cd4530716 6a39fb972 stress rig: the truth fence had three open channels
28a42c4e6 8f7feecdd CLAUDE.md: what a placement run is FOR
b30fc7af8 4f1638e6b tests: reconstruct suite imports the #522 layout
f413026d3 77ca1885f check_assembly: three parts stacked at one origin
9fdbbf192 5d35a1157 legality: a pile seed licensed any smaller pad intersection
d78596b68 774d87808 stress rig: run 19 lost its qualification JSON

I checked the fence_audit.py claim rather than taking it: it really is a SyntaxError at line 58 on placement tip, and 6a39fb972 really does repair it. Good catch.

Reading the diff

For anyone finding this later: the PR page showed 29 commits / +13,661, but the real delta against placement was 13. #616's routing commits were already cherry-picked onto placement on 08-12, so the stack double-counted them. The number that matters is:

git log --oneline --cherry-pick --right-only origin/placement...<head>

What did not land: c90fe328 (diff_pair_custody)

This is the one commit held back, and it is byte-for-byte the commit that was dropped from #616 last week for cause.

The rule it adds — outcome == 'partial' and both members incomplete ⇒ demote from routed_diff_pairs — misfires on multipoint pairs whose extra pads are deliberately deferred to single-ended follow-up. tigard's /USB_D is exactly that shape: a coupled, DRC-clean trunk plus redundant J1-row pads that the flow defers on purpose. It takes test_tigard_usb_diff from 12/12 to 9/12 with copper unchanged, verified in both trees.

The commit message cites run-11 /USB_D reporting "1/1 routed" with both members in incomplete_members — but that is the designed flow, not the #514 defect wearing a new label. Member count cannot separate the two cases. The discriminator that can is "no coupled trunk copper kept": a pair with no surviving coupled trunk has nothing to credit and should be demoted; a pair with a clean trunk and deferred extra pads should keep its credit regardless of how many members read incomplete.

Worth recording what this commit does and does not reach, since it is easy to over- or under-estimate: member_audit_mismatch feeds only the routed/partial summary buckets and successful -= len(partial_diff_pairs). It does not change copper, and it does not change protected nets — note_protection_candidates reads routed_results directly and runs before the audit bucketing. So it is a reporting change. But it flips a board's recorded verdict, which moves A/B baselines and place_route_loop failure counts, which is why it needs to be right.

A fresh PR with the trunk-copper discriminator and a tigard regression test is very welcome.

Test state

14 targeted suites green. Four red, all accounted for and none caused by the picks:

  • test_convergeloop_driver.final_record_command missing
  • test_broken_worklist, test_run9_check_completeboard_score does not exist on this branch at all

Those three are the skill-PR-side dependencies your description names, so they will clear when that lands. The fourth, test_orbit_fit_noop, asserts >= 30 corpus boards against a local corpus of 29 — environmental, but a fixed threshold on a corpus count will keep doing this to whoever runs it next.

Both no-wx parity gates (test_manifest_plan_parity, test_cli_postpass_coverage) pass. The --deadline entry in IGNORE_FLAGS is the right call, and the comment explaining why is appreciated.

Unrelated find, since you are in this area

tests/stress/ab_replay_grade.py is a SyntaxError at line 241 on placement:

sys.path.insert(0, os.path.join(str(REPO / 'py_router')), 'py_placer'))

Same botched-#522-shim bug class you fixed in fence_audit.py. Broken at e3bf2581, still broken at this PR's head, fine on main. That is the primary no-LLM A/B replay tool the runbook points at, so placement currently cannot run it. Not yours to fix here — flagging it because it will bite the next replay on that branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants