17 — Advice files and a pluggable advisor, called only when the router is stuck¶
Status: Partly built
Part of this page describes code that exists; the rest is a plan. The page says which is which.
Part of the TraceMaker plan. Start at
../PLAN.md. Written 2026-10-14 for issue #26. Milestone M16. Status: stages 0–3 built; stage 4 (placement) is not. §8 holds what was measured, including what did not work.
1. Where a language model belongs¶
TraceMaker is the deterministic counterweight to an LLM's probabilistic approach (requirement N3, rule 2). A model still has a
place: the judgement calls a lattice search cannot make — which part to move when --cut-report proves the placement cannot
route, which nets matter, which layer a group should prefer. This document puts it there and nowhere else:
- Not inside the engine. The tree has no HTTP client, no TLS, no credentials; a non-repeatable call inside a run that must be byte-identical would break N3.
- Not a separate orchestrator. Users would learn two tools, and the engine would still need the report and hint interfaces, which are the real work.
- A replaceable advisor, called only when the deterministic route is stuck, talking to the engine through a file.
route board.kicad_pcb -o out.kicad_pcb --advisor "python3 advisors/tracemaker_advisor.py ..."
plain portfolio ──► all routed? ── yes ──► done (the advisor is never started)
│ no
▼
stuck report (JSON) ──► advisor process (stdin → stdout, time-limited) ──► advice (JSON)
│ strict parse + check against the board
▼
portfolio again: the unadvised variants + `variants` more, carrying the advice
│ the better result of the two runs is written,
▼ the advice is saved beside it (out.advice.json)
Three parts, each with one job:
| Part | Job | Where |
|---|---|---|
| Engine | Deterministic and offline. Writes a stuck report; reads an advice file (--advice FILE), typed and strictly checked, that can only change order, cost, and which attempts are made |
src/advice/, src/route/router.cpp, src/app/route_job.cpp |
| Advisor | Any executable: stuck report on stdin, advice on stdout. A person, a script, a local or cloud model | advisors/ (reference ones, standard library only) |
| The switch | --advisor CMD: plain route first; only if connections are left, consult, then route again with the advice. Without the flag nothing changes, byte for byte |
run_route_job |
2. What it may and may not do (the project's rules)¶
| Rule | How it holds |
|---|---|
| 1 Correctness | Advice never contains copper and never changes a rule's value. Every route still passes the exact check; regions are enforced by it (§4) |
| 2 Determinism | The model's answer is frozen in a file. board + options + seed + advice file gives byte-identical output at any thread count (tested). A live run is repeated by --advice out.advice.json |
| 4 Transactions / never worse | Advised attempts are extra portfolio variants after the unadvised ones (the next entries of the list, indices from 1000). The winner is the minimum of the portfolio's total order, a tie goes to the unadvised variant, and in --advisor mode the plain result stays unless the advised run's is better |
| 5 Budgets | Advice changes no budget. The advisor's wall time is not routing work (--advisor-timeout); each routing run gets the same --work/--time |
| 6 Rules | A missing, slow, failing or nonsensical advisor is a warning; the run goes on unadvised, exit code unchanged |
| Untrusted both ways | The report quotes net and part names from the board (a prompt-injection surface). Advice is typed data: no commands, no paths, unknown keys are errors. Sending a board to a cloud model is opt-in, the report says what it contains, and --advisor-redact sends aliases |
3. The stuck report (stage 0)¶
route --stuck-report FILE writes it for the run the advisor sees (or the final run, with no advisor); --advisor sends the same
JSON on stdin. It is built from the finished route by advice::build_stuck_report (src/advice/stuck_report.cpp). Fields:
| Field | Content |
|---|---|
tracemaker_stuck_report |
1 (the format version) |
task |
one paragraph telling an advisor what is asked and that names are data |
disclosure |
redacted, contains (kinds of data sent), does_not_contain (the board file, copper geometry beyond these lists, paths, keys, the advisor command) |
board |
name, outline box (mm), copper layers, counts of parts, pads, nets |
rules |
clearance, track width and minimum, via size and drill, copper-to-edge clearance |
result |
routed / connections, tracks, vias, seconds, the portfolio's variants and their counts, escape_rings (with --escape-report), cuts (with --cut-report) |
unrouted[] |
per open connection (up to max_unrouted, 40; unrouted_not_shown counts the rest): net; from / to (part, pad, x_mm, y_mm, layers; to is {"zone": true} for a plane); length_mm; reason; why (the router's own words; omitted when redacted); rips, fails; blockers (nets whose routed copper the last rejected route would have had to rip); region_mm (the two pads ±1.5 mm); crowding (routed copper by net in the region, vias and foreign pads in it) |
unrouted_by_reason, _by_net, _by_part |
counts, the largest 16 |
parts[] |
the parts at the open ends: kind (grid_array, multi_pin, small_ic, passive), side, pad count, position, number of open ends; library_id and value unless redacted |
previous_advice |
the advice already tried and what it gave (later rounds) |
advice_schema |
the hint types this engine reads, so an advisor adapts to the engine it talks to |
Reason codes (failure_reason_code, router.cpp): boxed_in (fixed copper encloses a pin), exact_check (the lattice path was
rejected by the exact check), rip_limit (the only route would rip a protected net, too many connections, or one already ripped too
often), ripped_not_rerouted, layer_limit, out_of_budget, rolled_back (a transaction was undone), not_attempted (the budget
ended first), no_legal_path. They are the class of the cause, matched from the router's sentences, and they nest: a boxed-in pin is
boxed_in whatever later attempts said.
The failure event (doc 13) now has region (the two pads ±1.5 mm, nm; it was [0,0,0,0]) and a new blocker_nets (net ids of
the connections the rejected route would have ripped). blockers (object ids) stays empty: doc 06's closed-set boundary analysis is
not built, and the victims are the cheap, true part of it.
Redaction. With --advisor-redact every net becomes N1…, every part P1… (in order of first use), value / library_id /
why are dropped, and the board name is board. The advice that comes back is mapped to the real names (advice::unalias) before it
is checked, and the saved file has the real names, so --advice repeats the run.
4. The advice file (stage 1)¶
{
"version": 1,
"note": "free text, logged, never acted on",
"variants": 4,
"first_nets": ["USB_DP", "USB_DM"],
"priority": [["U1.A1", "J2.3"]],
"layers": {"no_tracks": ["In2.Cu"], "cost": {"F.Cu": 1.5}},
"regions": [{"name": "north channel", "rect": [10, 10, 20, 14], "layers": ["F.Cu"], "reserve_for": ["NET1"], "vias": false}]
}
| Hint | Meaning | Existing mechanism it reuses |
|---|---|---|
first_nets |
these nets are routed first, also after restarts, and other nets do not rip them | RouterOptions::first_nets (--first-nets) |
priority |
these connections (pad to pad, either order; both pads on one net) are routed before the rest | RouterOptions::priority, which the knowledge base feeds and the command line does not |
layers.no_tracks |
no new tracks there; vias still pass | no_track_layers (--no-tracks-on) |
layers.cost |
a track on that layer costs this factor more (1 to 1000) | layer_cost_pm (--layer-cost) |
regions |
a rectangle or polygon (mm, the board file's axes) in which only reserve_for nets may put tracks and vias, or avoid_nets may not; per layer, tracks and/or vias |
a rule area plus disallow rules (doc 05 §34) |
variants |
advised variants to add (1 to 8; --advice-variants overrides) |
route_portfolio |
Regions are rules, not a new obstacle type. A region becomes an in-memory rule area (tmk:advice:N, no keep-out flags: it only
names a place) and disallow track via rules whose condition is A.intersectsArea('tmk:advice:N') && A.NetName != 'NET1' && …
("every net but these") or … && A.NetName == 'NET3' (one rule per avoided net), one rule per layer when layers are named. §34's
machinery then does what it does for a user's .kicad_dru: the lattice search treats the rule like fixed copper, and the exact check
before every commit, escapes, pair legs and clean-up enforces it. Two details matter. KiCad reads a && b || c as a && (b || c), so
no || is ever generated. And the rules and the area live only in the router's working copy (RouterOptions::extra_zones /
extra_rules, added in Router::Impl and Router::run): the caller's rules are not touched and the output board never contains them.
(The positional rules are judged outside the per-class obstacle caches, so net-scoped regions do not need the caches keyed by net.)
Strict reading (parse_advice, modelled on the rule overrides, doc 15 §6.3): an unknown key, a wrong type, a value out of range
(coordinates within ±10 m, cost 1–1000, variants 1–8), a duplicate, a region with no area or with both or neither of
reserve_for / avoid_nets, or more than 64 first_nets, 512 priority, 24 regions, a 64-point polygon is an error that names the
file and the entry (regions[2].rect: has no area); a syntax error gives line and column. check_advice_refs then checks names against
the board: unknown nets, layers and pads are errors with a "did you mean", priority pads must lie on one net, no_tracks may not cover
every copper layer, net names with a quote or backslash cannot be used in a region. Entries without effect (a region outside the outline,
a first_nets net with fewer than two pads) are warnings. tracemaker advice-check BOARD ADVICE runs both checks without routing.
(Errors name the file and the JSON path of the entry; the line is given for syntax errors only.)
Advised variants (route_portfolio). With advice, the portfolio is the variants that would run anyway, then variants more: the
next entries of the portfolio's list, the very variants a plain run with that many more would have added (same configurations, same
seeds), each carrying the hints. They are named <variant> + advice and have indices 1000 + k (route::kAdvisedIndex). So a plain run
with V + 4 variants is the exact control for an advised run with V: the only difference is the hints. (A first version advised
copies of variants 0, 2, 4, 6. A hint that does nothing then repeats work already done, while the control's extra variants bring new
seeds; on the held-out half the control beat it by 11 connections, §8.) Variant results do not depend on each other except through
portfolio rounds, so adding variants at the end leaves the unadvised ones as they were (tested), and the total order puts a tie on the
unadvised side. The knowledge base is never told about advised variants. route_result_better is the same order for comparing two
whole runs.
5. The advisor protocol (stage 3)¶
--advisor CMD: CMD is run with /bin/sh -c, in its own process group, with the stuck report on stdin; stdout must be exactly one
advice file (at most 1 MB), stderr is logged (first 2,000 characters). After --advisor-timeout (120 s) the group is killed. Only the
program name is logged and put in the summary (a command line may carry a token). The answer goes through parse_advice, unalias and
check_advice_refs; if anything fails the run says why (warning: the advisor's advice was refused: …) and goes on without advice.
The accepted advice is saved as <out>.advice.json (--advice-out changes it; with --advisor-rounds N > 1 also
<out>.advice.K.json), always with the real names.
--advisor-rounds N repeats the loop while connections are open, each time with the best result so far and previous_advice in the
report; every round routes from scratch with that round's advice alone, so the saved file of the last round repeats it.
Each routing run gets the full --work and --time. The advised run contains the unadvised variants again, so an --advisor run costs
about twice a plain one; --advice FILE alone costs one run with the extra variants.
6. Reference advisors (advisors/)¶
heuristic_advisor.py— no model. Strategiesstuck-first(nets of the open connections go first),corridor(a strip along the straight line of each open connection is reserved for its net, on the layer its start pad is on),mixed. It proves the hint channel (§8) and is the smallest example of the protocol.tracemaker_advisor.py— a language model through an OpenAI-compatible endpoint (llama-server, Ollama, vLLM, clouds) or the Anthropic Messages API. Standard library only. The key comes from the environment (TRACEMAKER_ADVISOR_API_KEY, elseOPENAI_API_KEY/ANTHROPIC_API_KEY), never from the command line. An endpoint that is not on this machine or its network is refused unless--allow-remoteis given; what is sent is printed to stderr first. The reply is parsed leniently (a code fence is tolerated), checked against the advice format, and sent back to the model once with the problems if it fails.test_advisors.py— both, against fake servers (no network, no model).- A coding agent needs no advisor process. The
tracemaker-routeskill (skills/, D84) has the agent read--stuck-report, write the advice file itself, check it withtracemaker advice-checkand route again with--advice; it puts that after more search (§8.1: the control wins) and says that the hint channel's measured gain is none. Whether a model's own hints do better is the open question of §8.1, not something the skill claims.
Benchmarks always run from recorded advice files (bench/advice_eval.py stores them), so results stay reproducible.
7. Options¶
| Option | Meaning |
|---|---|
--advice FILE |
use this advice (advised variants run next to the unadvised ones) |
--advice-variants N |
advised variants to add (1–8; default the file's, else 4) |
--advisor CMD |
consult CMD only if connections are left; route again with its advice |
--advisor-timeout S |
its time limit (default 120 s) |
--advisor-rounds N |
consult again with the advised result while connections are open (default 1) |
--advisor-redact |
aliases for nets and parts in the report |
--advice-out FILE |
where the advice that was used is saved (default <out>.advice.json) |
--stuck-report FILE |
write the stuck report |
tracemaker advice-check BOARD ADVICE [--json F] |
check an advice file against a board |
The summary (--json) gets an advice block: the file's hints and warnings, unadvised_best_routed / advised_best_routed /
winner_advised of the first run, and per advisor round the program, seconds, outcome or error, routed before and after, and whether the
advised result was kept. Viewer events: advice.hint for each hint, and stage events advisor begin/end.
8. What was measured¶
Raw data, the advice files used and the test outputs are in bench/records/m16/; the story of the
runs is in dev/m16-notes.md.
8.1 Does a hint channel help? (issue #26 stage 2)¶
The question is whether advice makes this router route more, before any model is connected. Board set: the 16 srj20 boards of fewer
than 70 connections that were still open after the 70 M portfolio run of M15 (bench/sets/srj20-m15fail4.txt: 8 + 8, even and odd index
as the tuning half and the held-out half). Configuration: --work 20000000 --variants 8 --threads 8 --no-kb --time 3600 (the breakout
portfolio, 4 rounds), bench/advice_eval.py, a rule-based advisor (advisors/heuristic_advisor.py). Counts are connections routed, summed
over the half (489 and 492 connections; no board completes in any arm).
| Arm | Even half (tuning) | Odd half (held out) |
|---|---|---|
| Plain, 8 variants (what advice must beat) | 430 | 439 |
| Control: plain, 12 variants (the advised run's compute without advice) | 431 | 450 |
Advice mixed (stuck nets first + a reserved strip along each open connection), first design (advised copies of variants 0, 2, 4, 6) |
431 | 439 |
Advice mixed, final design (advised variants are the next four of the list, the control's own) |
431 | 439 |
Advice stuck-first only (first design) |
430 | not run |
Advice corridor only (0.35 mm half width, vias allowed through; first design) |
431 | not run |
Advice corridor, 0.5 mm, other nets' vias allowed (first design) |
431 | not run |
Advice stuck-first + layer cost F.Cu x1.4 + 12 priority pairs (first design) |
430 | not run |
What this says:
- The gate is not met. The gate was "advised runs route more on a held-out half, and never fewer anywhere". "Never fewer" holds by
construction (no board did worse in any arm). "More" does not: on the held-out half advice routes exactly what the plain run does (439
and 439), on the tuning half it gains one connection on one board (
sample094, 54 to 55), the same total as the control, which gets it on other boards. No strategy tried on the tuning half beat the control. - Seeds beat these hints. The control, the same four extra variants without hints, gains 11 connections on the held-out half
(
sample17349 to 53,sample13658 to 61). The same variants with the hints gain nothing: on the held-out half no advised variant routed more than the best unadvised variant of its own run, and on five of the eight boards they routed fewer. The first design hid this, because its advised copies repeated work that was already done and so could not tell "the hints do nothing" from "the hints hurt". - Consistent with the repository's earlier evidence. M14 experiment E1 (doc 16): tscircuit's own finished routes as guides did not help either; the literature note puts language-model routing agents far below classical routers.
- These are the hints a rule can derive from the report (who is open, where, how crowded). A model might write better ones: judgement about which part to move or which nets matter is what the issue expects it to add, and the placement seams (stage 4) are where that would show. This measurement does not say it cannot; it says the channel gives no free gain, and that the stage 3 gate of the issue (proving the channel without a model first) is not met. Stage 3 is built because it was asked for, it is opt-in, and it cannot make a result worse.
An invalid first batch is worth recording because it nearly produced a false result: the driver did not pass --time, the default 120 s
wall limit cut variants short on a loaded machine, and the same command gave different plain counts in three experiments (one board: 54,
50, 54). Advice looked like +3 to +11 connections there. advice_eval.py now passes --time 3600, and every table above comes from runs
whose plain column agrees across experiments. (Lesson for any work-budget experiment: check that the arms' shared columns match.)
8.2 Checks¶
| Check | Result |
|---|---|
Options off: quick / planes (plain and --soft-zones) / mid against origin/main (49fbd3c), CPU-only builds, bench/compare_runs.py --expect-identical |
byte-identical: quick 30/30, planes 8/8 and 8/8, mid 11/11 (mid prints Gates: FAIL only because PR #28's judge calls three capped KiCad reports "unknown"; the baseline against itself prints the same) |
| Full test suite | 291 of 291 pass, release and CPU-only; the new tests and 11 router-heavy existing ones also pass under ASan + UBSan |
| Same advice file at 1, 2 and 4 threads, and twice: identical bytes | generated board (CLI test advice_cli, every run); a real srj20 board (sample100, 1, 2, 4 and 4 threads): one sha256 |
| Bad advice (unknown key, name, layer, syntax error, every layer without tracks): refused naming the file | unit tests and advice_cli |
| Advisor absent, failing, slow, rubbish, unknown key, unknown net: output board and exit code as without it, the reason in the summary | advice_cli (5 failure kinds and a timeout) |
| Advised result never fewer than unadvised | by construction; no board worse in the 8 advised experiments (64 board runs) |
| KiCad DRC adds no error on advised boards | 0 router-introduced errors on 10 advised boards checked (5 of each design; drc-advised.txt has the final design's) |
| A real model | not verified. The reference advisor was tested against fake OpenAI-compatible and Anthropic servers only. One live request to the shared llama-server on this machine ended in HTTP 503 (its engine stopped, exit -6), a second timed out at 150 s; no further attempts were made on a shared service |
9. Not built, and limits¶
- Stage 4 (placement). Advisor-proposed placements as
seedsofroutability_loopand proposed moves as ECO candidates (src/place/routable.hpp) are not built: they would be kept only if they route more, through seams that exist, and wait for §8's evidence that hints help at all. - Per-net layer preference and per-net guide corridors are new router features the issue asked to add only if the simple hints matter; §8.1 says they do not, so they were not added.
- A hint that picks an attempt ("run the relax-route-legalise profile", doc 05 §41.3) is the hint type the evidence of this repository
favours: boards that no ordering or reservation helped (
srj20039, 048, 071, 103) completed with--relax. It is not built: relax drives the whole portfolio (stages, rounds, legalisation) and is not a per-variant tweak, so it needs its own design. - A model that answers with many small, plausible hints costs only the advised variants' time; a model that answers with a region that fences a net out of its own pads can make an advised variant route less, which is why advised variants never replace unadvised ones.
- The report quotes at most 40 open connections; very large boards need
--advisor-roundsor a better summariser.