soldermaskdocs

Tests and tools

The suite

python tests/run.py            # every tests/test_*.py, one line each
python tests/run.py app jobs   # only files whose name contains a word
python tests/test_projects.py  # one file, verbose on failure

Each test file is a plain script that prints n/m passed and exits non-zero on failure. They run against throwaway directories (SOLDERMASK_PROJECTS, SOLDERMASK_CACHE, SOLDERMASK_CORPUS) and never touch real projects; none of them needs the model, Java or the network. run.py holds that: it counts the real corpus's index and the real projects directory before and after each file and fails the file if either grew, whatever the file printed -- a build writes its route to the corpus as a lesson, and a test that builds without redirecting it teaches the next model a fixture (one such example sat in the corpus from 7 to 16 Sep 2026; it is under .cache/corpus-quarantine-tests/).

They do need one thing that is not in git. test_symgraph.py, test_symbol_substitute.py and test_alternates.py read the vendored KiCad symbol library from .cache/kicad-symbols, which tools/fetch_symbols.py downloads. Without it they fail as though the parts were missing from the library — NE555D found fails — rather than saying there is no library, so a fresh checkout or a CI runner reports three broken tests and names the wrong cause. Fetch the symbols, or point SOLDERMASK_CACHE at a copy that has them.

FileCovers
test_bundle.pya project out and back in as one zip: versions, files, draft, chat and variations equal after the round trip; scratch and half-writes left behind; every refusal by name (a member off the layout, a path that escapes, a table version the zip lacks, a newer format) and that a refused zip leaves no directory; the API both ways including a bundle over the app's 8 MB body cap; and that a design.json carrying constraints keeps them into a fresh project, yields to a project's own, and is refused by field when malformed
test_projects.pyversions on disk: save, revert, fork, labels, diff, zip, migration from format 1, refusal of newer formats, trash, atomic writes
test_app.pypages render, the API reads and writes projects and versions, exports, 404s — and, because the workbench keeps its functions global on purpose and that makes one flat namespace across 5,600 lines, that workbench.js, schematic.js, projects.js and platforms.js declare each top-level name exactly once (the last two share the home page's scope). A second loadPins() is not an error in JavaScript; it is the first one silently replaced
test_jobs.pythe job queue and the job endpoints with the model stubbed
test_turns.pyone chat turn with the model and the pipeline scripted: what the model is told, a question builds nothing, a dirty build is revised once and kept only when it measures better, one stage on its own; and the thread on disk reads back whole — a revised turn is one entry with its first version folded in, a rebuild is the app's note and not the person's words, a turn that failed is remembered red with its reason and left out of what the model is told, and the project's lock is released after it
test_auth.pythe access rule, login, bearer, the QR path, lockout, proxies; headers, compression, caching
test_wsgi.pyrequests through Hypercorn's WSGI wrapper — the 204/304 case the Flask test client cannot see
test_fonts.pythe type on both surfaces, wiring not files: Libertad Mono declared once for every weight over JetBrains Mono, the code and drawing tokens on JetBrains whole (the face is unicase and has no block glyphs), no sheet or script naming a face first outside a token, the open files present; the licensed files reported, never required
test_release.pysemver, changelog parsing, release note, release bump (on copies; never git)
test_harness.py, test_repair.pythe pass rule, where it is applied, and the repair loop's judgement. passes() was reachable from the evaluator and from --repair N (off by default) and nowhere else, so no board anybody built was ever asked whether it passed; workspace.verdict() is that same rule reading the report the build already produces, and the tests hold the wiring rather than restate the rule — a clean board passes, a net the design asked for going missing fails and says which, an ERC error counts whether it arrives as a dict from board.json or an object from erc.check(), a pour DRC that did not run is not a pour DRC that passed, --no-route is not a failure, an incomplete net is unrouted — and, across four combinations, that verdict() and passes() return the same answer, because the moment they disagree the number a user sees and the number the harness measures have come apart; and the clause under dropped_nets: a connection dropped from a net that survived fails the rule, the verdict, the evaluator's row and its printed line, the why names the pin and elides past three, and dropped_connections_of() reads the count back off a stored board's gaps, survivors of a removed net included
test_judge_shipped.pyrouted copper is judged before it passes (25 Sep 2026): a board with nothing poured is checked by KiCad's DRC as shipped (workspace.judge_shipped), a track across another net's pad fails on the copper as shipped, a net KiCad finds unreached fails as unrouted, and without kicad-cli the board is not judged (passed: null) with a gap saying why; test_harness.py holds the rule's side -- an ERC that did not run and copper no DRC saw are "not judged", a failure among the checks that ran is still "failed"
test_alternates.py, test_availability.py, test_category.py, test_query_ladder.py, test_parts_count.py, test_symbol_substitute.pythe parts and symbol stages, each named after the regression it prevents; test_parts_count.py also holds that a footprint's row count is the shorter pad axis, so KiCad's vertical 1x2, pads in a column, is one row and a real 1x2 header fits it (15 Sep 2026: read as two rows, it fitted only parts stating no count, and an out-of-stock IDC socket was ordered for every 2- and 5-pin header of the suite)
test_jlcapi.pyJLCPCB's open API as a parts source, the transport stubbed: the Authorization header matches the vendor's own documented sample byte for byte; the string signed is method, path, timestamp, nonce, body, one a line; a 200 body's data comes back, a 403 is NotAuthorised with the trace id to quote, another code is a JlcApiError that is a PartsError, no credentials is NotConfigured, and offline never reaches the transport; a record in each of the three shapes the endpoints answered on 16 Sep 2026 -- a detail record, a dump row, a library row, held as the vendor sent them -- is a Part with the break covering one as its unit price and the breaks riding along in the mirror's string form, its category both levels joined so category_ok() reads a kind off it, a numeric code gets its C, a record with no code is not a part, and every name the three shapes use is in the field table; the parameters read as {name: value} with an empty one dropped; a parameter value is typed by its unit and never by its shape (10kΩ is 10000 resistance, 1.8V~3.3V is filed low end first, ±10% minus then plus, 600Ω@100MHz keeps its condition in the format, 0603 stays a string) and the vendor's names become the ones the pipeline reads (Current Rating → Rated Current); the dump follows lastKey until a page is empty and a limit stops it early, the library pages by cursor; a snapshot is the dump and then a detail record a code, resumable after the codes its partial file holds; ingest() writes shards, buckets, the attribute table, the search index and a manifest naming each with a hash, and jlcparts.by_lcsc() reads a part back with the vendor's package, joints, stock, price and basic flag, its category off the shard's name spelled as the mirror spells it, jlcparts.search() answers a text query from the vendor's words, the table carries Package, Basic/Extended and Manufacturer as identifiers and the parameters typed, and a manifest the API wrote is not refreshed from the mirror on the TTL; by_codes batches and keys by code; and authoritative() takes stock, price and the basic flag from the API, keeps the mirror's package, description and category, names its source, and falls to the mirror's lookup when the app is not granted the API
test_datasheets.pythe datasheet is read as the sheet wrote it and nothing is guessed (21 Sep 2026), no network and no model: two spellings of one pin are one pin (3V3(OUT) and 3V3_OUT; ~RST, /RST, RST#, nRST and RST_N; GPIO20, GP20, IO20 and P20; +IN and IN+; DO/IO1 and DO (IO1); RESET# and the RESET whose overbar pdftotext dropped; Anode and A) and two names are not (VREG_IN and VREG_VIN); the pages sent for transcription are the ones carrying the symbol's own pin names, so a long sheet's pin list outscores its registers and a short sheet goes whole; of two tables with the same numbers the part's own variant is chosen (a PCM2902E takes the PCM2902 table), then the catalogue's package (an INA219AIDR the SOIC table, not the SOT-23 that is its reverse), then the fewest numbers the symbol lacks; compare() sorts the numbers into agreeing, agreeing under another spelling, differing and one-sided; compare() also names a swap -- the sheet's name for a number is the symbol's name for another -- and marks both ends suspect, unless most of the table is swapped, which is another package's table; a rail (GND, IOVDD) on another number is never a swap, and a pad the symbol names only by its number is unnamed, not compared; and locate() finds a pin through the sheet's name, alias or function text and answers none to a name the sheet does not carry
test_resolve_fidelity.pythe part ordered is the part asked for, or the notes say why not (20 Sep 2026): every row a real catalogue row from the audit that found 29 silent substitutions on the failing replay boards -- a rating, tolerance or dielectric below the ask is refused and with no row at or above it there is no part; a passive is not ranked by text similarity; packages.standard_name() names SOT-563, 1210, SMB and the rest for the filter and package_mismatch(); right-angle against a vertical footprint and through-hole against SMD rank below; the exact number outranks a suffix variant (the Pico over the Pico W, BSS138 over BSS138K), a reel suffix is exact; an exact-number listing survives the category gate (the USBLC6-2SC6 filed under circuit protection); a search's family word (JST, tactile) that the chosen row does not carry is named
test_jlcparts.pythe local mirror of the catalogue, offline: known codes resolve, the typed lookups answer in their package, the index answers from any thread, every file kind the manifest names is read or named as unread; a refreshed manifest naming under half the cached one's components is refused as a build in progress, and with nothing cached a mirror naming under half of a whole build's refuses to answer -- by code, every lookup after, and by search, whose index never reads the manifest -- with a PartsError (so parts.py asks jlcsearch) where it used to answer None, while a catalogue JLCPCB's API wrote is its own size; the listing text corrects a package the attribute table got wrong and leaves one it agrees with, says nothing about, or names two for; and a cached file whose sha256 is not the manifest's is stale, decided once a process, never under a manifest rebuilt from the disk itself
test_series_led.pythe simplest board there is, and the two gates that took it apart: a two-terminal polarised part is resolved through the pad its own symbol calls the anode — before the raw-number test, and whether or not it has a symbol — so a tutorial LED keeps both its nets and the right way round, while a bare number on an IC's named pin is still refused; and a header footprint still constrains the part when JLCPCB cannot speak its name, so a two-pin battery clip cannot resolve to a 40-pin header longer than the board, without that geometry test catching the three-pad LED footprint that also looks like a header
test_assembly.py, test_ses.py, test_kicad.py, test_models3d.py, test_symgraph.pyfab files, the router round trip, the KiCad frame (and the Gerbers' via pad and drill are the board file's via, not 0.60 / 0.30 literals), 3D models, the schematic graph
test_sheet.pythe schematic sheet: on a board with every role on it, zero wires through a symbol body, zero text over text, zero wires over two inches, zero stacked symbols; supply pins are ports and never wires, adjacent ones share a port; a short two-pin net is a wire and a five-pin net is five labels; a pinned position is honoured to the mil and the LED under a moved resistor follows it; a doubled pad folds onto its pin, a tab pad with no pin is reported, a pad on two nets is said; the written .kicad_sch reads back by the module's own connectivity check as the board's netlist, and — with kicad-cli — KiCad's netlist of it is the design's net for net with 0 ERC errors
test_rules.pythe clearance is the board's: a 0.4 mm pitch pulls it to 0.15, a gap below the fab's floor stops there and names the footprint, one pad in two shapes and two pads on one net are not gaps, the DSN, the label and the .kicad_pro all carry it, and a board with nothing fine on it writes the DSN it always wrote, byte for byte; and one rule, three writers — for each router's rule the DSN, the A* command line and the project file carry the same track, clearance and via, every DRC floor sits under what the routers' geometry gives (hole clearance and hole to hole included), and ses.floor() raises a sub-floor wire to the judge's own track floor and counts it
test_electrical.py, test_ohms_law.py, test_width.pycopper sized for the current it carries rather than for the median: the IPC-2221 maths against published values, per-net widths and via counts, the LED/resistor/regulator/capacitor checks Ohm's law made writable — and, as much as any of those, that a board declaring nothing is routed exactly as it was before
test_congestion.py, test_incomplete.pythe placer's picture of the board is the board — pads, pins, courtyards and RUDY where they should be; a net with copper on some of its pins is named incomplete, pin by pin, where coverage() was blind; two wires are one piece by the distance their round ends reach and not by a square around them (a 0.1 × 0.1 mm step on a 0.127 mm track is open, 0.08 × 0.08 is joined, a via is reached at its radius), and close_joints() bridges a dangling end short of its own net by less than the rule, extends one stopped short of its pad, and leaves a gap of the rule or more open
test_corpus.py, test_routability.py, test_candidates.py, test_keepout.pythe DSN round trip to the byte and the width through IPC-2221 and back; the split is by board, the metric is right by hand, the numpy forward pass is torch's, the winner is judged on held-out boards; one candidate is one anneal, the incremental cost is the full cost, the retry loop keeps the least bad board and says why; copper stays out from under an antenna the footprint can locate -- the router's keepout, the rule area, and since 25 Sep 2026 the placer: a part under the antenna is charged on either side, the anneal on a crowded two-sided fixture leaves none there where the term switched off leaves three of eight, seating and centring do not bring a zone over a part, and the gap for one left there is written of the placement the build kept
test_body_keepout.pycopper stays out from under a connector's body (keepout.py, 25 Sep 2026): a connector, and a USB, card-socket or DC-jack package whatever its kind, gets one, an IC package named Type_C and a resistor do not, and a 2.54 mm header's pad cuts leave it none; a right-angle USB-C's region is inside its body, reaches the mouth, covers the path in front of its pad row and nothing between the row and the back edge, and holds no pad -- at each quarter turn and on the back, on the side it is mounted on and toward the way it opens; a through-hole jack's on the side its body sits on with its pins cut out; a body over the outline kept only on the board, and a part off the quarter turn given none and said; the DSN's structure keepouts and the .kicad_pcb's rule areas on that layer, tracks and vias not allowed, pads allowed, read back clean and never read as a fill; the pour allowed under the body only when the shell is on the poured net and no other pour reaches it there, and the notes saying which; the ground grid's vias and an island's stitch kept out. Then the real tools, each skipped and said when absent, on a USB-C at the top edge with a signal whose straight path runs under its shell: freerouting and the A* router each run it under the shell with the keepout taken out of what they read, and with it neither lays F.Cu copper or a via there and every net closes, the connector's own pads included; kicad-cli's DRC flags the control's copper as items_not_allowed and finds the kept-out board with no error
test_physarum.py, test_rna.py, test_pitch.pythe research instruments: the mould's block solve against a dense one at 1e-10 and its room model on hand-drawn boards; the fold; and the pitch counts the Mendicant paper reports beside the windows' 0.829 — edge-to-edge pad gaps, fine-pitch pins, density — on boards whose right answer can be read off the page
test_mould.pythe compiled loop is the numpy loop: on random grids of every kind the two callers draw (whole boards fed at a middle pin, windows escaping to a rim, with and without negotiation, a rotating feed, a track three edges wide, a sigmoid growth, odd sizes, one net) the conductivities agree to 1e-9, and one power rail grown alone (physarum.rail) agrees tube for tube and current for current; a batch of windows grown at once is the windows grown one at a time, bit for bit, on one thread or all; the wall of headers and its escape agree scalar for scalar, the integer counts exactly; a job whose shapes disagree is refused. Says so and passes when the core is not built
test_pic.pya photonic chip as tools/pic.py and pic_place.py see it, on chips drawn in code: the port transform is LiDAR's for N, FN, FS and S; two nets whose ends swap rows read as one crossing by both readings and two that keep their rows as none, and a waveguide passing a device at its own row as none; the layering finds three layers, a virtual node for a skipped stage and every sink on the last layer; the order search takes a crossing netlist to zero, fixed couplers stay put and the free devices are ordered to suit them; and the row projection keeps ordered targets, spreads a collapsed row about itself rather than up from it, and meets a swapped pair in the middle
test_fabrules.pyJLCPCB's published table on board.json (fabrules.py), each rule held on both sides of its figure with a board built by hand: board size and thickness, track width by copper weight, the via sizes refused and the ones that cost more, a component hole's ring against the absolute minimum and the recommendation (an oval read on its narrow side), a non-plated hole, a hole outside the plated range, pad holes and via holes too close edge to edge (a via hole touching a pad's included), a via of the pad's own net opening into it said with its depth and one of another net left to DRC, a via in a large exposed pad said as common practice and not blocking, SMD pads of different nets against the pad gap and the mask dam and pads of one net allowed to touch, a thin silk stroke (a filled shape has no width), silk near a pad, the label height, a part on the back needing Standard assembly and a panel under 70 x 70 mm, 0201 and 01005 parts, 0.4 / 0.37 / 0.3 mm pitch, a board too small to assemble singly; a blocking below-minimum finding a gap and the rest notes; the report naming its pages, the day they were read and what it cannot see; the board it read left as it was
test_fillet.pythe router's segments chained into tracks and the corners rounded: what may be joined and what may not (a T ends a track, a width change is a break, a net crossing layers is two paths), the arc tangent to both segments at the radius asked for or the largest that fits, a corner with no room left sharp rather than shrunk away, the arithmetic of the copper a fillet ADDS on the inside of a bend and the guard that keeps it the rule's clearance from every other net (a pad placed by search, the radius cut to clear, sharp at the floor, all three writers on the one arc, kicad-cli clean on the guarded board and not on the unguarded one), and the copper a fillet REMOVES at the vertex: a sharp V whose tip is the only contact with its own pad stays sharp, a deep tip whose legs still lie on the pad rounds, a gentle bend a neighbouring track hangs on rounds, a sharp V it hangs on stays
test_power_pour.pypower-net pours, opt-in, beside test_pour.py's ground: power_nets() is off unless the netlist or SOLDERMASK_POWER_POUR asks, 0 in the environment wins, and on it takes a rail typed power or named like one whose current wants a 0.5 mm track, highest current first -- not ground, not amps on a signal's name, not 0.8 A, not a rail declaring nothing -- or the nets named; a rail's zone is one, on the side most of its pads are on, the hull of its pads grown by the margin and inside the outline, written with a priority over ground's unwritten 0 and joined to its pads solid; two overlapping rails give the copper to the larger current, and the smaller one is moved to the other side where a pad of it is clear, or not poured and said, never stranded; the version's file replans the zones the judge filled; a back pad is stitched with the four 0.95 A vias 3 A needs, inside the zone, each stub sized for its share, a pad on the zone's own layer gets none, a ground island is stitched as it was, and the ground grid keeps out of the zone; which pads are islands is read off the copper (_net_islands), and the account puts every pad in the fill, stitched, routed or lost, with a narrow track and a router via on the rail as gaps; off, the zones, vias, row and note are the goldens captured before power pours existed, and the power code never runs; with kicad-cli, a pad a signal walls off on a scrap goes to the router with its anchor at the rail's width and KiCad finds the board clean; with Java too, freerouting routes the rest and every pad of the rail is joined
test_power_corridor.pya poured rail drawn where its current runs rather than over the hull of its pads (corridor.py, 25 Sep 2026), on a 40 x 24 mm board with a regulator's VO at one end and three 1 A loads spread out: power_shape() is the hull unless the netlist or SOLDERMASK_POWER_SHAPE asks, the environment wins, a word that is not a shape draws the hull and says so; the source is found by a VO/VOUT pin, a P-FET's drain, a fuse from a connector, a buck's inductor off its switch node, an input connector (the VIN pin among three, never a motor's flyback diode), and guessed and said when nothing names it; each load draws what it passes on (1 A), a buck Vout Iout / (Vin 0.9), a capacitor nothing, and unknown loads split the rail's current evenly, said as an assumption; the tree holds every pad in one piece inside the outline less the edge clearance, a fifth of the hull's area, its legs 3 A, 2 A, 1 A each at the width IPC-2221 asks and the trunk the widest; a USB-C body across the way is gone round by the tree and by the mould with no leg drawn over another; the mould's corridor is one piece, its widest tube the rail's 3 A; two rails: priority by current, the smaller kept off the larger, moved to the back where its corridor crosses none, and not poured -- said -- when every pad sits in the larger's corridor; kept on the front, crossing, when the back would leave its front pads no room for a stitching via, and the note names them (corridor.stranded(), the stitcher's own tests at plan time: PCBGolf's four VBUS rails, 25 Sep 2026); the keepout is other nets' pads cut out to the nearest edge (a pad beside it, not in it, is not cut), closed round the rail's own pads and opened only at those a router is handed; the filled board draws a zone a piece and no corridor rule area, the router's DSN and the A* router's board carry it; the judge, router and kicad-cli stubbed, never hands the rail to the router whole, the fallback included, only the pad the fill missed with its anchor, and the notes say it is a corridor, where it is fed and how wide; with the tools, freerouting and the A* router route a signal that crosses the trunk underneath, and KiCad fills every pad clean; the hull, switch on, is the bytes it was before corridors (goldens)
test_eeformat.pyEasyEDA's format read by this tree (eeformat.py, 26 Sep 2026, in place of easyeda2kicad): on payloads written by hand, a pad's centre, size and hole in millimetres and a track's points and an arc's path left in canvas units, a rectangle's stroke as its eighth field, cutouts kept, primitives it does not read passed over, a pad row cut short still read, the pad number the one printed beside the pin (the SPICE one only when there is none), the 3D model's offset from the head in mm with Y up and the outline's centre winning past 0.1 mm, no SVGNODE or a broken one no model; and, when easyeda2kicad is installed beside the tree, every payload in the EasyEDA cache through both readers -- the Package whole, the pin pairs and the model's uuid and offset identical (1,194 of 1,194 on 26 Sep 2026)
test_symbol_library.pywhat setup takes from the KiCad symbol archive (symbols.extract_library): the top level's libraries and their .dcm, never a legacy/ or obsolete/ copy over a current one (the fetcher before 26 Sep 2026 flattened them by basename in tar order), nothing that is not a library, nothing outside the directory
test_twins.pya pin its footprint draws as several pads (packages.TWIN, 25 Sep 2026): the EasyEDA reader keeps every pad of a repeated number and names the repeats <n>_<k> (it kept the first until then: a USB-C came through with one shell leg of four), twin_of() reads a repeat back to its pin and does not take a pad simply called EP_2 for one, join_twins() puts every repeat on its pin's net once and leaves a repeat already on a net (a board read from KiCad) where it is, and the .kicad_pcb and the DSN both carry the repeats on that net
test_prune.pycopper that joins nothing taken off (prune.py, 25 Sep 2026): on two 0805s, a stub into nothing goes segment by segment back to its pad, a track that runs over a pad on its way to a dead end keeps that pad's joint, a via reached on one layer goes and then the track that led only to it, a via chain from F to B to F stays whole, a poured net's via stays where no fill of its net is there to read (none given, or KiCad did not fill) and goes where the fill reaches it on one layer, a net carrying hand-drawn copper is never pruned. test_pour.py holds the judge around it, stubbed: the anchored re-route is kept when it leaves fewer connections missing than the first board and the fallback (pour.worse()), not only when the board comes out clean, and a net the fill reached no pad of hands its islands over to be joined to each other; the fallback's route runs in its own work directory beside the re-route's (pour.PARALLEL_FALLBACK) and the same board is kept either way
test_handroute.pycopper a person drew: what the constraint block will store and what it refuses by name, the mapping from constraints to the netlist (including a track whose net the design has since dropped, which is reported rather than carried), the DSN's protected wiring, the pour taking hand copper out with its own net, and -- against the real freerouting -- a hand-drawn detour that no router would choose coming back in the session file verbatim while the net is still joined
test_sides.pyparts on the back of the board: the mirror against the numbers measured from freerouting and kicad-cli, every geometry copy in the placer agreeing with the one function, opposite sides not in each other's way and a through-hole part in everyone's, the anneal using the back only where the front is out of room and never on a board held to one side, _Index.local() moving as _cost() moves through flips, a side surviving the corpus row, a pin, a DSN and a .kicad_pcb read back, the B layers of the Gerbers and the assembly file, the pin walk knowing a pad's layer -- and, with the tools installed, KiCad plotting a back pad where pad_position() says, freerouting reaching it on the back copper, and a crowded fixed board placed on both sides routing, pouring and passing DRC
test_packaging.pythe package works when it is not a checkout: the shape of an install is built in a temp directory -- the package with no pyproject.toml over it -- and asked where its data root, priors and router are, and whether git was consulted. Installed, home is the platform's data directory and not site-packages, the priors come from inside the package and hold the same library, the jar is looked for in the cache, and describe() reports no commit or branch rather than a stranger's. SOLDERMASK_HOME moves the data root without hiding the repository's priors, and SOLDERMASK_FREEROUTING_JAR moves the router and the label with it
test_cache_paths.pythe cache is where the caller says it is: all four cache directories follow config.cache_dir() rather than __file__, SOLDERMASK_CACHE wins over SOLDERMASK_HOME, and with neither set the cache is the repository's, as it always was
test_edits.pythe smaller unit: a part nobody mentioned survives an edit and remove is the only way anything leaves; an instruction aimed at a selection may alter or take away only what is in it and may always add; the design handed in is never modified, because a proposal that will be reviewed must not have already happened; the gate reports parts, nets, the ERC delta and the cost delta, and says them in words; and a proposal is not a version until it is accepted — with the model and the pipeline both stubbed, since what is under test is the gate
test_palette.pythe library as the palette and the board see it: a row carries what a block is made of, what it offers and how much of the corpus agreed; the ones that build are offered first and the ones that do not are listed anyway rather than hidden; searching narrows it and a search that matches nothing is empty; and adding a block builds a proposal that writes nothing until it is accepted — whose board a reloaded page can fetch until then, and not after
test_addpart.pya part a person picked, rather than one a model described: the catalogue read backwards into a kind (an LDO filed under “Power Management” is not an inductor, a switching diode is not a switch, and a bucket that says nothing gets no guess); a refdes allocated around the board rather than after it; the pick pinned by its LCSC code, in a field no model can write and an edit to that part does not unpin; and the claim the palette rests on — a design wired with the pin names it offered resolves with no gaps, on the pads it said they were. Then the API: rows carry what the pipeline can draw for them, a kind is never defaulted, and a net that does not exist is refused before anything is built
test_removepart.pya part somebody deleted, and what goes with it: a removal takes the part's pins off every net it was on and a net left with fewer than two pins goes too, named as removed rather than as changed — where before the design went on naming the refdes, resolve.py reported it as a "connection to an unresolved part", and the one-pin net was dropped there, silently, after the gate had already shown the change. Only this edit's removals, so a design that already carried a dangling pin builds the board it built yesterday. Then the turn, with the pipeline stubbed: the board that gets built is the board without the part, no model is asked, the reply names the nets that could not survive, a proposal is not a version and accepting is what makes one — and the refusals happen before anything is built, because a refdes nobody can point at is a bug in whatever asked and not a board to go and route
test_blocks.pythe block library and what composing means: every block names its parts, nets and ports and earned its place on more than one board; a block instantiates around the refdes a board already uses; connecting two blocks merges their nets into one carrying the pins of both, keeping the rail rather than flattening it; a port the board already has is that net rather than a second one wearing its name; and two of the same block can sit on one board
test_constraints.pya drag is a constraint: the block is validated rather than trusted and one bad entry refuses the whole of it; a design carries it and one without it writes the file it always wrote; the model cannot write it, by the schema and again by the commit; a drag is saved without making a version and is scoped to the version it was made against; and a pinned part comes out of a rebuild exactly where it was pinned, with the rest annealed around it
test_cli_build.pythe command line builds through workspace.realise(), once: the source imports it and never places or routes on its own; a build counts one realise() call and one placement; --seed, --no-pour, --no-route and --qty reach it; the output directory holds what came back; the verdict printed is the one realise() stamped; and a --project build is one build saved as printed rather than rebuilt inside commit(), against the project's own pin -- set as a draft on the version the way the app saves one -- with the pinned part where it was pinned, in the version and in the design written to --out. Then the stages on their own: --route-only is one realise() call with every part of the version handed in as fixed where it was and one candidate, the version says what it is, the saved design carries no part codes and the board records that its placement was given; --route-only without --from is refused; --sim-only makes no build and no version and its exit says whether there was a simulation
test_mcp.pythe pipeline as an MCP server (mcp.py): the transport in process through two pipes -- initialize echoes the client's protocol version when it is one we speak and answers our latest otherwise, ping, tools/list names the eleven tools each with a schema, an unknown method is -32601, an unknown tool -32602, an unknown resource -32002, a line that is not JSON -32700 and the next line still answered, a notification gets no reply, resources/read hands back DESIGN_SCHEMA itself, a call whose arguments miss the schema is a tool error the model can read, a progress token brings progress notifications, the result comes as text and as structured content and they are the same thing; then the tools called directly with the resolver, the stock sweep, the 3D fetch and the router stubbed as test_cli_build.py stubs them: a design refused by field and path with every fault named at once, a sound design resolved and priced with nothing placed, a bad constraints block refused as the app refuses it, place as one realise() and one placement with nothing routed and no fab output, a placement of the caller's scored with its coordinates untouched and two parts on one spot named as overlap, a placement short of a part or off a quarter turn or naming a ref the design lacks refused by ref, build as one realise() and one placement through the one router loop with the caller's placement pinned whole into the block the version records and the outline fixed, verdict reading it back, rebuild as a new version and route_only handing every part in as fixed and saving the version's own design rather than the copy the parts were pinned onto, a partial placement as pins, a version's file as a resource and a path that climbs out of the version refused; the catalogue for a chooser: a component carrying lcsc passes the check and one whose code is not a code is refused by path, search_parts with a kind is the resolver's own ladder for a component written that way with chosen from a one-part resolve and without a kind the text search with nothing chosen, a value without a kind refused, pins as resolve.pinout on the wire with the design line pinned and a missing code a refusal; a search result and a pinout as lines (tables.py) decoded back to every row, price, flag, chosen mark, design line and note, the wire carrying the lines without structuredContent beside them and format: json the JSON with it, a refusal staying JSON and marked as an error; and the real process, python -m soldermask.cli mcp spawned and spoken to over its pipes, every request answered and nothing but protocol on its stdout; and the server calls no model: every backend stubbed to raise, design_brief is the design stage's own system prompt and accepted schema with the prompt's constraints read, a bare-prompt build is refused at the schema and in the tool with nothing built, and backend is no longer an argument; and nothing inside it calls one either (SOLDERMASK_MODEL_CALLS=none, as the server starts, with the network allowed and a Jev key present so only the switch stands in the way): a pin table not on disk is not transcribed, fetched or cached, Jev is not available and a stored answer of its is not read, a design backend refuses as a missing key does, status says none and the instructions promise it; with the switch lifted, Jev and its stored answer are back and status and the instructions say so; the spawned server promises none though its environment allowed models, and SOLDERMASK_MCP_LOG holds one line a tool call with the arguments sent, the text returned, the seconds and the error flag
test_mcp_http.pythe MCP server over HTTP (mcp_http.py), run with model calls off as soldermask mcp --http starts: keys issued with the prefix and stored as a digest in a file that is the owner's alone, looked up, refused when wrong, absent, unprefixed or revoked, a second key under a live name refused, bad names refused, rows without the digest; the door -- no key is 401 with a Bearer challenge, a foreign Origin 403, a protocol version we do not speak 400 naming the ones we do, a body that is not JSON -32700 in a 400, one that is not a message -32600, GET 405; sessions -- a request before initialize 400, an unknown session 404, initialize 200 JSON with a session id and the protocol the client asked for, a notification 202 with no body, one key's session under another's bearer 404, DELETE ends one; the wire -- ping as JSON, a batch as a JSON array in order, an unknown method a JSON-RPC error in a 200, a tool call an event stream carrying the session id, a client that accepts only JSON given JSON; the tools with the resolver, the sweep, the 3D fetch and the router stubbed as test_mcp.py stubs them -- status naming the transport, the key and the key's own directory and not the cache, check_design's progress notification then its answer, a build as one realise() saved in that key's directory with progress on the caller's token counting from one, config.PROJECTS reset after the request, projects and resources/list seeing only their own, another key's board "no such resource", fab.zip the Gerbers, drill, BOM and CPL as a blob and no JSON, verdict carrying the same resources; the precedent's placement withheld under GPL-3.0 and no licence found and handed over under MIT, permissive() on the bank's spellings; the build slot -- a heavy tool with no slot says it is waiting, keeps the stream alive, gives up in the time it was given, a light tool runs meanwhile, the heavy one runs once the slot is free; the source -- Remote subclasses Server and calls no stage, the command line dispatches --http and keys; the real process spawned on a free port -- initialize over TCP with a session id, a tool call as an event stream whose first bytes arrive before the answer, a stale session 404, the banner; and keys add/list/revoke on the command line
test_front_half.pythe front half of the pipeline on what the 11 Sep 2026 suite failed on, one check per spelling or rule: a vendor-decorated pin name resolves by its token (PB0 in (PCINT0/CLKO/ICP1)PB0, PD0 in PD0-OSC_IN) and a token that is a number, a letter or a prefix never matches; a token on two pads is refused with both names; six spellings of an inverted pin find each other and NC is not C; an en-dash, KiCad's _, a separator inside a name and a polarity sign at either end are one pin; V+ finds VS+ and IN does not; a supply on several pins (GND ×4, VSS_1..3) is every one of them and a signal on several is still a choice; USB-C positions map to the standard and a connector asked by position finds its function, an IC is not asked; a two-pad switch and a two-pin crystal take 1/2 and a four-pad one does not; a connector's merged pads join their net and a group on two nets is left for the ERC; SOP-16 is SOIC-16; ranked() is best() and the rest; a named part is named, a value is not, and a substitute package still has to be the part; a single-row header is searched by its grid; a relay has no ground pin to miss, a split-rail amplifier returns to its negative rail, an untied GND pin is still the finding and a part with no symbol says it cannot tell; a rail fed through a switch, a FET or a diode is driven, a capacitor is not a source, and an untyped regulator output is not proof of nothing. Then what the local catalogue's first live day found (15 Sep 2026): a measurement (2.54mm, 100R, 4k7) is not a part number and is not searched on its own, where a JEDEC number (6N137, 1N4148) still is; a near number differs by letters only, so REF3020 is not REF3025 and an RS-485 transceiver is not the CAN one a digit longer; a value pick is held to the listing text, 22Ω is not 100Ω and a listing that states nothing is not refused; a search outage on the package-filtered lookup does not skip the unfiltered one; and an IDC or IC socket on a 2.54 pitch is not a header
test_wire.pythe wiring turn (wire.py), with no model and no catalogue: the pins offered are addressable() names, once each, a polarised two-terminal part as 1/2 rather than by pad; the strict schema has one property a part and one a pin, every pin required, additionalProperties: false at both levels; either form of answer becomes nets and under the relaxed form a pin the part was not shown is refused and said in unsupported; a net the answer leaves with one pin is kept so the build reports it and the verdict counts it; the draft's voltage and current fill what the answer leaves null; replace re-searches a part, puts the rejected code on its avoid list, leaves a pinned pick alone, and the resolver skips an avoided code; wire() with the call stubbed makes one call, a second after a replace and never a third, takes no call under only_if_needed when the draft resolved whole, falls to the relaxed schema when a backend refuses the strict one, and leaves the draft's design with error set when the call fails
test_evaluate.pythe evaluator builds through workspace.realise(), once a board, and scores the verdict it stamped: the source imports it and never places, routes or plots on its own, and the route helper it used to call is gone from repair.py; a scored board is one realise() call and one placement; the row's fields are the verdict's and the row passes exactly when it does, with a disagreement refused as a harness fault rather than counted; the Gerbers handed to gerbonara carry the build's copper; --repair builds the control the same way, in its own directory; a saved design is named by the prompt's place in the suite and a replay finds it there, falling back to sorted position for a directory from before the index and saying so; and a --tier run numbers its designs by the whole suite; and --rescore: a stored run re-read under today's rule from what it recorded, the hollow pass turning and the clean one standing, the first files kept beside the rewritten ones, a second rescore changing nothing, and nothing built
test_spice.pythe circuit simulated before it is placed: a resolved netlist becomes a deck with ground at node 0, passives from their values, the LED pointed the way the resolver's A/K say, a linear regulator as one behavioural source from its part number (with the numbers' provenance said), a two-pin switch closed and said, a part with no model named with its reason and a part on no net absent rather than said twice; the stimulus is the design's own -- a declared rail steps from zero, a declared current is a load, a regulator's rail is checked rather than sourced -- and no ground or no declared rail is refused with the reason; the raw-file reader reads both forms ngspice writes. With an engine: the fixture LDO settles where Ohm's law says and the LED draws what its resistor allows, a rail the regulator cannot reach and an LED past its rating are gaps, an LED on an unmodelled GPIO is not simulated and a dim one a note, a deck ngspice rejects comes back with the engine's reason, and the scope's file has the shape the page checks; without one every engine check is skipped and says why. Then realise_netlist() writes sim.json when handed a simulation and nothing when not
test_rack.pythe build's knobs as constraints: rules.seed, rules.pour, rules.precedent taken and refused by name; macros_of() picks the knobs out of a block and with_macros() writes a variation's over one, every knob it carries set and every one it does not carry cleared, the pins untouched; pour and precedent ride on the netlist; realise() takes its seed from the block when the caller gives none and the caller's when it does; a block that says no pour gets none and a note; one that says no precedent asks the corpus for nothing to seed from and still lists the precedents
test_router_choice.pywhich router goes first is the board's to choose: constraints.rules.router takes freerouting or krt and nothing else, a prompt naming one lands there, apply_constraints() carries it onto the netlist and -- not installed, or a net wider than the A* router lays -- hands the board to freerouting with a gap; the attempt loop with the A* router stubbed routes a board that asked for it first, stamps its label as that router's with no count of its own, asks freerouting for an opinion only when the walk finds a net short and keeps its copper when it reaches more pins, names the router on the report, uses a harness router as it always did, and says once when the A* router did not run; the poured path stamps the reduced netlist's label the same way and is not clean by the label's rule because nobody counted; a second opinion reads either way round; and, with the router installed, one real board through it
test_rubric_report.pythe reviewer's rules graded on every board the pipeline builds: the geometry rules read a placement and the layout rules read the router's copper and KiCad's fill, as the measures the tool takes of a person's board; a measure becomes a verdict against the shipped lines and the professional reference (assets/rubric.json carries both, and battle/ holds no second copy); a bad grade is one note and a good grade none, a rule another stage already reports as a gap is not said twice, a failure that is the pipeline's says so, and the coverage line counts what was graded against the 82; an unrouted board grades on geometry alone; and realise_netlist() puts the grades on the board and the notes beside it without the verdict reading a word of them
test_watch.pythe build placing, watched: placer.anneal()'s sampled callback reproduces the unwatched anneal move for move and ends on the placement returned; candidates(watch=) gives a watcher every seed's frames through the loop and, spawned, through the workers' pipe, the same frames either way, from one reader thread, and two watched runs on one pool at once each see only their own; workspace.anneal_messages() has the shape the page reads (the board, every part with whether it is pinned, every net as the parts it joins, a courtyard a part a frame, the parts on the back) and realise_netlist() opens, frames and ranks, sends nothing when every part is pinned, and builds the same board watched or not; the app's _Watch holds frames and lets them out batched, the ranking flushes them first, and the bus keeps none of it for a late joiner. Has a main guard because it spawns
test_spacing.pythe placer does not pack the board: the default air is the measured half millimetre on every board; on a board at 20% occupancy the anneal with the air forced to zero packs to a 0.00 mm median nearest-neighbour gap (the defect) and with the air leaves most of it, with no courtyard overlapping either way and every decoupling cap still at its pin, because a cap and its chip are one unit the air is not between; _Index.local() moves as _cost() moves with the air in both; _clash() with no air passed asks about the real courtyards, as seating does; and the room-derived value behind SOLDERMASK_SPACING_MM=auto follows the room, stops at its cap, is the root of the quadratic its docstring names, and is zero on a crowded board, which then anneals byte for byte as it did before the term
test_rubric.pythe placer's cost and the rubric's measures agree with each other: _cost() and _Index.local() charge the same (with an antenna keepout on the board too, through module moves and flips, and with the ESD term and the fine-pitch ring switched on, at the board's air and at none), the ESD term finds one line a guarded signal net and charges it to the connector alone, the ring holds a fine-pitch chip apart from a stranger and not from its decap, and at zero neither term is built and the anneal is the anneal it was; wirelength is the plain half-perimeter and decap distance the cap's pad to its pin; untouched() sets a board aside for a snapped rotation and not for parts on its back (the reader keeps sides since 10 Sep 2026); scalars() survives a diff-pair entry the DRC half merged onto a board with no differential nets; designer_of reads battle/reddit/ as it reads a battle; and the Reddit miner (tools/rubric_reddit.py) counts a reviewer's remark and not the poster's reply, the moderator's pasted checklist or their boilerplate sentence, with praise read as good unless the sentence turns
test_genome.pythe fixed-stride board record: every quarter turn, both sides, a coordinate outside the outline and one finer than the 0.1um quantum, a vendor footprint the vocabulary has never heard of, and a part with no package at all — which is the one that was broken, reading back as a missing package because the decoder asked the string what it meant instead of asking the record. Then the failures that must be loud: a coordinate off the end of its field, an angle that is not a quarter turn, and a blob written against a different kind/package vocabulary, refused rather than mis-read. Then the graph half: nets bipartite and weighted by pads, a net reaching one placed part dropped, geometric neighbours symmetric and self-excluded, and the invariant that would fail silently — a part the netlist carries and the placement does not leaves the edges and does not shift the nodes after it. Then the CSR file, sliced back into the graphs that went in, and refused on open if either the vocabulary or the record layout has moved
test_gnn.pythe graph judge: featurisation on boards made in the test (coordinates as fractions of the board, a vendor package folded to the spare index, the four edge columns, batches offset into one node table), the within-board metric on a case with a known answer, and the invariant everything else rests on — the numpy forward reproduces the logits of the torch network fit actually trained, on its own validation slice at the epoch it kept, to 1e-5; a graph scores the same alone and in a batch, the JSON round-trip, and a model file from another vocabulary refused; the Mendicant readings ride in the part and net columns and the crossings as net-to-net edges, features='base' zeroes them through the same path, and a graph from a file without them featurises as base; the pair objective ranks exactly the same-board (clean, failed) pairs, counted by hand, and refuses to run without boards to pair within. Skips the torch half, saying so, where torch is absent
test_fold.pythe fold on cases known by hand: two diagonals are two chords that interleave and displace one, two parallels nest, a one-point net is no net and shifts no index, a net never crosses itself, and crossings names the pair of nets and counts the chord pairs cut — the edge the graph judge reads
test_revision_pairs.pythe preference pairs mined from a board's git history, on a repository built in the test with the cases the pilot got wrong or never saw: five revisions of one file found through a rename, the path at each revision being the path git had then; of four adjacent pairs, the drop of a part and a commit that added copper but moved nothing both refused, and the two that moved or turned a part kept — the turn across the rename — with the licence and bank file riding along and a revision shared by two pairs stored once; a repository's pairs written before its own line so "done" means its pairs are on disk, a second run skipping what the first did; and the report's better/worse for a scalar being the measured sign of each change, with the exact sign test checked on known counts; then where a moved part went: toward its partner and toward the edge both read, no like gap without a like footprint, a row that exists before a move and not after, and the fixture's two pairs coming out with every hypothesis tallied and the move counted under its kind; and the weight fit on synthetic pairs whose preference is driven by decoupling — the pair that moved nothing dropped, the lower-is-better scalar signed so positive is better, decoupling taking the weight, and the held-out fit beating wirelength alone
test_deeppcb_board.pya board DeepPCB shares, read as ours, on a board written in the test: microns to millimetres with y turned up, a polygon, a rectangle, a path and a circle padstack each read to its box, a through hole told from a surface pad by the layers it lists, a pinless footprint dropped, GND ground and a class net power, pins split at the last dash, the plane nets and net classes carried beside the netlist, DeepPCB's own copper counted, a plane net taken out of the problem, and the via a plane owes each surface pin
test_pair_annotator.pythe pairwise annotator's parts that can be wrong quietly: the drawing a page is handed (a turned part's courtyard turned and its side kept, through-hole pads flagged, a three-pin net as two spanning-tree edges and a one-pin net as none, coordinates to a hundredth, and the page carrying drawings and never scalars); a person's later answer replacing their earlier and a skip not being a label, from a jsonl or a directory of documents; and the fit on synthetic choices driven by decoupling — the chosen side better on it and worse on grouping by the same count, wirelength even, decoupling weighted positive, held-out choices predicted, a ratio to wirelength refused while wirelength's own weight straddles zero, and two people on one pair counted for agreement
test_rubric_within.pythe within-board rubric test's arithmetic on known numbers: the AUC on a perfect, a reversed, a tied and a mixed order; the sign of a scalar under each direction; and which stored examples count as a board's anneals — not a short anneal, not the author's placement, not a timeout, not a disputed label, not one without a placement, not another clearance rule
test_exa_boards.py, test_board_formats.pythe board bank's front door: which URLs in a page are repositories, a candidate written once, a licence off its first lines, the fetch against a fake GitHub and against a repository on disk over git, a board's name never handed out twice, a held-out board refused by content. Then the formats kicad-cli imports: which paths are boards and which are panels, a repository the .kicad_pcb-only sweeps marked no-kicad while counting Eagle files in it a candidate again, the format read off a file's bytes rather than its suffix, the net table the importers leave out written back, a real Eagle board converted and read as two layers with its nets, --merge growing a bank without renaming what is already in it, and the four-layer bank: a table that lists more copper than the board carries read as the layers in use, --layers 4,6 admitting what the two-layer rule turns away and --skip-kept reading only that, its DSN declaring four layers for the router, and corpus.find_dsns() walking past a bank whose layers.json says so unless it is the directory asked for. Says so, and does not fail, when kicad-cli is absent
test_precedent.pya board somebody already built for this design, against an index written by hand: a part number is a token and a pitch, a size or a voltage is not; 10k reads as 10K and 10kΩ; a connector's position count is read from its name; the breakout that IS the design outranks the product that contains it and the keyboard that shares its regulator, and a board six times the size is not a candidate; the seed comes back keyed by the design's refs at the corpus board's coordinates, a human's pin wins, a fixed board size is kept; the switch and the bar withhold the seed without the listing; a missing index leaves the build as it was; and board.json carries the precedents
test_silk.py, test_insignia.pythe legend layer: every part labelled and no label on a pad; the maker's mark placed once and filled the same by the Gerber, the .kicad_pcb and the viewer, with its ISO 3098 lettering thick enough for a fab and every glyph a simple polygon
test_skeleton.pythe board's skeleton for a terminal (skeleton.py, style.py): the raster puts ink on the outline's four edges, a pad's edge and not its centre, along a wire, dimmer on a back pad and back copper, faintest on the pour, and nowhere outside the margin; a board from before the geometry keys (no shape, silk, court, via d), an unrouted one (ratsnest) and an empty one draw without error; png() is an RGBA PNG with the coverage as its alpha and a CRC a reader accepts; braille() is cols wide, braille or space, the outline one row of dots; style.inline_image is Kitty's chunked form (keys on the first chunk, q=2, m=1 until the last, the PNG whole) for Ghostty/Kitty/WezTerm, iTerm2's OSC 1337 for iTerm2, and nothing for a pipe, tmux, NO_COLOR or Terminal.app; style.label pads to the stage column by at least one space, or none at width 0
test_silk_outline.pythe part's drawing: the vendor's silk read at the unit each primitive arrives in (tracks raw canvas, circles mm), y flipped, arcs flattened to the curve, text and other layers left out, the pen floored at what a fab prints, the extent covering every primitive; generated silk clear of every pad and inside the courtyard on every package in the catalogue, a chip's two lines, a diode's cathode bar, pin 1's dot nearer pin 1 than any other pad; and the Gerber, the .kicad_pcb and board.json carrying the same strokes, a back part turned a quarter turn included

Browser checks

tools/snap.py loads any page in headless Chromium over the DevTools protocol, runs JavaScript, screenshots it and reports console errors — with nothing but the venv's websockets, no Node toolchain:

python tools/snap.py https://localhost:8443/p/sensor-rail/v004 out.png \
    --eval "setView('3d')" --eval "three.picks.length" --mobile

It finds the Playwright Chromium cache or SNAP_CHROME. Exit status is 1 when the page threw. Every screenshot in this manual was taken with it.

node tests/verify_3d.js path/to/inspector.html (needs playwright-core) measures the 3D scene of a standalone export — parts drawn, model versus primitive, heights — instead of looking at it.

python tests/verify_scene.py does the same for the copper view, over the DevTools protocol like snap.py and with no Node toolchain. It renders five boards — the widest built, the densest routed, a mid-size one, one carrying a congestion heatmap, and a poured board written by today's export.py with pad shapes, via sizes, body, courtyard and silkscreen strokes per part (the first four carry none of those keys and hold the picture a board built before 15 Sep 2026 still gets) — and in eight layer and selection states writes down every primitive drawn with its geometry, resolved paint and effective opacity, then compares that to tests/scene_golden.json.gz. The boards travel with it in tests/scene_boards.json.gz, because projects/ is the user's work and is not in git — --bundle rebuilds those fixtures from projects/ on disk, and naming a board.json on the command line runs that board instead. --update rewrites the golden; do that only when the picture is meant to change, and say in the same commit what changed.

The ratsnest is the one thing the golden does not own. It moved into the overlay and became a spanning tree rather than a star, so the recorded picture stops at the edge of it — the dashed lines are struck out of both sides, everything else must still match in order, and the lines themselves are judged against the definition instead: one per net, over that net's live pad positions, exactly one fewer than there are distinct positions, every end on a pad, and connecting all of them. That is a stronger claim than a golden could make.

The golden cannot carry what the scene refactor was actually for, so the same run asserts it directly: a layer toggle and a selection keep the same SVG node, a new board replaces it, a part lifts into the live group and drops back exactly where it was, pan and zoom mutate one attribute on one node and emit no children, hit tests answer from the spatial index, and — with real browser input rather than synthetic events — a wheel holds the board point under the pointer while a drag pans without selecting whatever it finished on. Two passes then dispatch real input rather than synthetic events. The mouse one drags a part across the board and checks what the gesture does to the document and to the picture: the part lifts into live, its copper ghosts and its ratsnest lights, the drop writes a pin (within a pixel and a half of where it was aimed — a dispatched pointer lands on whole pixels, and a pixel is worth more millimetres on a 100mm board than on a 25mm one) rather than moving anything in the built board, the mark appears, the ratsnest follows, the staleness bar comes up, the save goes out debounced as a PUT to the constraints endpoint, and one drag is one entry on the undo stack that restores exactly what was there. It also sets a pad down 0.4mm short of a pad it shares a net with and requires the snap to close the gap exactly, because snapping is electrical and the claim is that a pad lands on a pad it is wired to. A fourth drives the scope and the gate: that nothing selected means the whole board, that picking parts and a net names them as chips and draws every one of them chosen, that a chip drops one back out, that an instruction against a built board goes to /propose carrying the scope rather than to /chat, that the proposal is shown on the board rather than described, that discarding writes nothing and puts the board back, and that accepting is the only thing that makes a version — with fetch replaced by a recorder, so every request the page would make is inspectable and none of them leave. A third drags a symbol on the schematic sheet and requires the drop to write constraints.sheet, the symbol to end up somewhere it was not, the mark to appear, the wires to still reach it where it landed, and one undo entry to put it back — the sheet has no persistent scene, so this is also what checks that redrawing it whole per frame keeps the gesture. Two more passes cover routing by hand. The board one enters route mode, clicks a pad, a corner in open board and a pad on the same net, and checks the track that results: one track on the net of the pad it started from, at that net's own width, landing on both pads, every segment on an axis or at 45, drawn on the board and going off with its layer -- then that a flip lays a via and crosses to the other side, that undo takes the track and the via back as one entry, and, the part that matters, that the copper went out as a PUT to the constraints endpoint rather than staying a picture. It picks its own pads: the closest pair on one net whose three click points are all comfortably inside the window, with the corner far enough from every other pad of that net that the click lays a corner instead of finishing the track on one, because a test that picked otherwise would be measuring the viewport. The sheet one drags a net's trunk onto another lane and requires the same of it -- the lane held through the redraw, marked as a decision, saved as a constraint, and undone back to where the sheet had put it -- and then does it a second time, which is the whole point: the sheet rebound its pointerdown handler on every redraw while pointermove and pointerup kept watching the first closure's copy of the gesture, so every drag on the sheet worked exactly once per page load and then silently stopped. The old pass dragged once and passed for as long as it did.

A further pass covers picking things up and taking them away, and each of its claims is a complaint with a measurement behind it. The hover: a real mouse move over a part outlines that part and nothing else, a press takes the outline away because the question has been answered, and bare board answers with nothing — driven through elementFromPoint, because the board is full-bleed and the two floating panels sit on top of it, so a part can be drawn, be in the index, and still be somewhere a real pointer lands on a panel instead. The hit rule, asked on an index holding only what each question is about: a box far bigger than a small part and drawn after it does not win (which is what a 40-pin header's label did to the passive beside it), a part under twelve pixels on screen answers to a point outside its box up to that minimum and no further, and a part over it gets no margin at all — because bare board beside a chip has to stay bare board or a pan that starts there picks the chip up. The keyboard: an arrow moves the picked part exactly one grid square and shift-arrow ten, it is drawn where the pin says and pinned by having been nudged, and three keystrokes are three undo entries of which one undo takes back exactly one. The snap: a drag lands a pad on the pad it is wired to, and the same drag with alt lands exactly where the pointer asked. And the copper: a track you drew can be pointed at, is coloured picked by a render rather than only when the constraints change, says in the panel that it is yours, goes with one Del as one undo entry, comes back with one undo and goes out as a PUT to the constraints endpoint — while a track the router laid is not deleted at all, and both the key and the panel say why, since the next build derives it again and taking it off the screen would change the picture and not the board.

The touch pass runs with pinning off, as a standalone export does, and covers the view gestures: that the board owns its touches rather than losing them to a pointercancel two moves in, that one finger pans and two pinch about their midpoint, and that a tap still selects while neither gesture does.

The evaluator

python -m soldermask.evaluate runs the fixed prompt set through a backend and scores pass@1; see Repair loop and evaluation. eval*/ directories in the repository are previous runs and are ignored by git.

Other tools

  • tools/backfill_models.py [project] — add 3D models to versions built before models were part of a build.
  • tools/gitbook_summary.py — regenerate docs/SUMMARY.md from mkdocs.yml for GitBook.
  • import_footprints.py — build soldermask/assets/footprints.json from a KiCad footprint corpus.
  • mine_blocks.py — two miners, into priors_blocks.json. mine(root) reads .kicad_sch files for idiom — what tends to be drawn beside what, by proximity, because a schematic stores geometry and not a netlist — which is what feeds the prompt. export_library(root, out) reads .kicad_pcb files for structure: a board names the net on every pad, so a block comes out with real connectivity and real ports. Membership is decided by the circuit (every pin of a passive on a net the IC is also on) and confirmed by the layout (within 15 mm), because neither test alone is enough — connectivity lets a module that touches every net swallow every cap on the board, and proximity cannot tell a decoupling cap from the part next door.
  • tools/revision_pairs.py harvest | report — preference pairs from the git history of the boards the bank already holds. The bank was fetched at depth one; harvest clones each repository again without blobs, follows every board file through its history and renames, and keeps each adjacent pair of revisions by the same designer that kept the part set and moved a part — a label nobody had to be asked for, weak but signed: later was preferred. Written as it goes to .cache/revision_pairs.jsonl and resumable; the two revisions of a pair kept gzipped under .cache/revision_pairs/ so report measures them with the rubric as it is on the day. report asks the one question the pairs can answer: across pairs, does each rubric scalar move the way the rubric says is better, by the exact sign test — a scalar that cannot see what designers changed cannot be fitted to them. moves asks the parts that moved where they went — nearer a wired partner, a neighbour, a like part, the edge, a regulator, into a row — and the board whether it untangled, each a candidate for the scalar the rubric lacks. fit asks what weights would make the later revision score better as often as possible, held out by repository and reported per millimetre of wirelength against the placer's own constants; on the real pairs it cannot tell hundreds from nothing, which is the finding.
  • tools/rubric_within.py — can the rubric tell one board's own anneals apart, and does its order mean anything: on the corpus's stored anneal placements, for each scalar the fraction of boards whose anneals differ and the within-board range beside the between-board one; the within-board AUC against the router's clean where a board has both verdicts; and of all pairs of a board's anneals, how many the rubric decides and how many are trade-offs, the pairs a person's choice can price.
  • tools/rubric_reddit.py — what r/PrintedCircuitBoard's reviewers raise, counted per catalogue rule as the share of review threads in which a commenter other than the poster said it (a sentence lexicon, one pattern a rule, read at ~74% topical precision on 80 sampled sentences: the rank is the evidence), plus what they raise that no rule named; --fetch clones the repositories those threads link at the last commit before the post, --boards writes them as battle/reddit/ in the battles' shape so tools/rubric.py --calibrate grades them with the same code, --drafts boards writes every comment of those threads for a reader. A remark is a mention until a reader confirms it in review.json's confirmed; 14 were on 17 Sep 2026, 9/14 agree. Reads pitch/research/reddit/, writes .cache/rubric_reddit.json.
  • tools/pair_annotator.py select | fit — two placements of one board, a person choosing: the label that prices a rule. select picks trade-off pairs from the stored anneals (one a board, each differing scalar's gap at least half the median within-board range) and writes .cache/pairs/pairs.json (scalars and sides) and page.html (drawings only), which is published as an artifact with the db capability so every viewer's choices land in one store, one document a person and pair. fit reads the labels back — a directory of documents from the Artifact tool's read_db, or a jsonl — and reports which side won on each scalar, agreement between people, and the weights under which the chosen side scores higher, held out by board.
  • tools/crystal_ab.py — what placer.CRYSTAL_W does to the crystal, to the rest of the placement and to routing: held-out boards with a crystal wired to a chip annealed at a sweep of weights, every placement routed under both routers, the far-pin distance, decoupling, wirelength, courtyard clash and clean rate beside each other, paired McNemar against weight zero; written as it goes.
  • tools/deeppcb_board.py fetch | route — a board DeepPCB shares, read as one of ours through the app's public endpoint and routed with this tree's routers under KiCad's judge: the plane nets taken out of the problem as a plane takes them out of DeepPCB's, a via counted for every surface pin on such a net, the placement graded by the rubric, and DeepPCB's own via and track counts kept beside ours.

On this page