AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
1984b40
How the measurements work
For anyone changing the copper tracing or the router. Each section gives what was tried, what happened and why the code is the way it is.
A second board: what was tuned on one
Tried on the Raspberry Pi Compute Module 5 from public photos, which differ from the Driver photos in every way that matters: mask luma 130 against 44 and 64, flat studio light against a falloff of half across the board, no rows of holes, parts on both faces, and 24 px/mm against 46.6. This is what broke and what replaced it. Nothing here moves the Driver figures (status.md), because each new reading sits behind something the photo itself shows.
The photos. Two sets, not one board. top-everypin prints "2024 R4" and top-cnx "2024 R5". The bottoms differ too: "FCC ID: 2ABCB-CM5" and "Proant AB" against "2ABCB-RPICM5" and "Abracon", and date codes 2324 and 4024. Copper lines up between the sets on 98 percent of plain 2.5 mm tiles (median correlation 0.64 and 0.58) after a shift of 0.15 to 0.25 mm, and the tiles that do not are parallax on tall connectors in the angled shots, so what the photos show of the outer copper is the same design. They were still kept apart. The run uses everypin: its edges are 1.2 photo pixels wide where CNX's are 3.1, so its thin tracks stand 30 to 49 grey levels off the laminate against 6 to 11. CNX has more pixels and less resolution. Each pair is one layout: through features correlate at 0.228 mirrored the right way against 0.037 the wrong way, and the faces lie on each other at 0.021 mm RMS. Whether each pair is one physical unit, photos alone cannot say.
The mask is measured. prep::MaskTone reads the peak of the hue histogram, and the value, luma and G - R of the pixels around it, from each picture. The mask's brightest value is 1.1 times its 98th percentile, never under the 0.62 where mint plastic begins, so a dark mask reads exactly as before. photomat::classify multiplies thresholds that depend on how bright the mask photographs by exposure() (luma over 64) and chroma() (G - R over 45), and leaves those on black, white and gold alone, because those colours do not depend on the board. Both factors are 1 within a quarter of the Driver's values. On a bright mask the print's core does not lean blue as it does on the Driver's, and its own median becomes the bar.
Which faces have parts is measured too, by the share of the face that is moulding or ceramic bodies (photomat::carries_parts): 14 percent on the Driver top, 0.16 on its bottom, 3.7 on the CM5 bottom. The line is 1 percent.
Framing through four holes. scale --holes 48x33 --board 55x40 --source "...". Four points give scale and perspective together, so each photo gets its own homography and the corners it implies replace the fitted ones in prep.json. Fitted corners were 0.14 to 0.27 mm off. Each hole is read from its gold ring's outer edge with a trimmed circle fit, because an angled view moves the opening's centre (0.11 to 0.12 mm here, 0.34 mm on CNX), and silk that runs into a ring makes the plain metal mask lose the hole, so prep also keeps a gold-only list. Px/mm differed by axis by 1.2 percent on everypin, which a single scale would have left as a warp. A symmetric hole pattern cannot say how the board was turned over, so the flip comes from what shows through both faces (board::flip_by_detail), with --flip for when that is not decisive. Where tiles correlate between faces, a quadratic through 21 of them registers the bottom to the top.
Flat light. Local levels exist to follow the light. Where the light does not move they follow the copper, and a wide field of laminate with thin tracks through it splits into its own two classes and reads as copper. trace::light_spread measures how far the copper level moves over 8 mm tiles: 0.32 and 0.56 on the Driver, 0.18 and 0.11 on everypin, with the line at 0.2. Below it the levels are one plane through the plain mask, started from the two modes of the green histogram (178 and 200, 181 and 204), with the long dark tail of shadows left out. Fitted as before, that tail took the laminate level to 142 and left nothing reading as laminate. The dim-patch rule in settle is off below the line too, since there is no poor light to excuse a dim patch.
Over-sharpening. The photo digs a dark band round anything bright, 25 percent deep and 5 frame pixels wide on the CM5. It read as gap, and it set the scale of every line threshold, which depend on the 99th percentile of the response, so real gaps fell under them. trace::sharpening_halo measures it from the median green against distance from hidden pixels, and treats it as unseen. It counts only if it dips at least 10 percent and recovers within 8 px. The Driver top dips too, but over 13 px, which is a cast shadow, so it is left alone.
Metal parts. On a face with parts, pale neutral pixels were transparent as silk. A lid and a can are also pale and neutral, so copper ran straight through them and joined the whole face into one island. Pale pixels are now print only below 1.2 mm of solid width.
Vias under frontal light. Neither face has a dome highlight: the shading agreement is 0.11 and 0.21, so domes rightly says flat. A via still shows, as a dark pit about 0.18 mm wide with a light ring from sharpening. domes::read_pits measures every dark spot and keeps round ones in mask, of the face's own median size. On their own those are not enough: on the Driver top the same test passes 684 specks at 0.11 mm. Real through vias line up with the other face and grain does not, so a face's pits count only if at least a fifth have something on the other face within 0.1 mm and that is five times what the same count finds with the other face mirrored (domes::pits_confirmed). The Driver top finds 3 of 684, under its chance level of 9. The CM5 top finds 242 of 358 against 14. Pit width is the hole's dimple, not the pad. size_vias tried to read pads from the fall of brightness round each via, and its 0.32 to 1.17 mm spread is not one pad size, so it now refuses a spread above double and sizes stay as read.
The scorer. copper_read failed in the same two ways. Otsu split inside the dark tail, and the envelope sat on the laminate wherever a window was mostly laminate, so everything scored as copper. When a bright photo's plain mask has two modes, the split is half way between them against the face's own level. That threshold is shared with the reader, so this copper score is less independent than the Driver's: it knows nothing of gap lines, and it does respond to a 0.25 mm shift (0.72 to 0.62, 0.76 to 0.58), but it cannot show a wrong level. via_report looks for small dark spots on a bright mask, where a bright-blob test finds nothing.
Smaller things a second board needed. build writes bottom-side parts, flipped as KiCad flips them. copper trace starts from the outline scale measured when nobody sketched a copper.json. check <step> --skip-earlier "<why>" runs one check without the part-naming steps before it, logs that, and does not pass the step. A corner radius under 8 px no longer hides the whole face.
Still from one board. rounded_board assumes a 2.6 mm corner. PIT_* bars are from everypin tile set A. Exposure factors are a straight line through one bright board. parts package sizes assume bodies read as moulding or tan, and bleached ceramics on a bright mask do not; closing flecked metal on a bright mask also turns large print into candidates.
Finding and sizing parts on a photo the reader has not seen
The chip passive reader carried the CM5 photos' colour numbers, and found 15 of the Driver's 90 chip parts and 7 of 40 on the left half of board 10. Three things changed (2026-11-10).
Each photo's own levels. tone.rs finds the board surface as the photo's dominant hue (within 12 OpenCV hue units, above half of its own 10th-percentile saturation, no brighter than 1.1 times its own 98th-percentile value) and the bright level as the median of the brighter group of neutral pixels off the board. passives::calibrate then reads tan bodies strictly (clear ends on both sides, a size close to a package) at six saturation floors with a wide hue window. Each distinct body that passes gives its own hue, saturation and value and those of the solder beyond its ends. With five or more, the photo is read at levels set from them: the hue window is theirs with 6 units to spare, the saturation floor lies 0.85 of the way from the ends' median to the bodies' lower quartile, a body is at least 0.77 of the median body's value, an end 0.6 of the median end's, and a dark body is whatever is darker than a tan one at the bodies' own hue. With fewer, the CM5's numbers are used, scaled by the photo's bright level, and the candidates say so (levels: scaled, a fifth less sure). A second reading at the scaled numbers runs behind the first and only adds where the first found nothing. The factors were chosen on the CM5, the Driver and the left half of board 10; nothing else was tried on the right half before it was scored.
What this does not reach: a resistor whose body is neutral dark with pale ends (the Driver's, board 10's and board 4's). Its body has no hue to calibrate on, a sharp photo shows it speckled, and its white substrate shows along all four sides, so the end test finds ends across it as well as along it. Five other readers were tried and dropped the same evening: a threshold sweep (found more, invented more than it found on the CM5), a fit of each package's outline to bright-dark-bright (drowned in 0201 matches), islands of not-board split by Otsu's threshold on whiteness (the solder fringe joins the body), islands with a whiteness profile (sizes one class large), and a run-length measure through a given centre (71 percent right on board 10, 33 percent on the CM5).
Every candidate is rated. candconf.rs gives each candidate a confidence by rule: what kind of body it is, whether it fits a package, how large it is, and whether it sits on a plated hole's pad, at the board edge or inside a body five times its size. The rules were laid out on the left half of board 10 and are not probabilities. parts init takes those rated 0.5 or more.
A chip's size follows its body. parts snap used to move a chip part to the next size down as soon as the smaller pads fitted the photo's metal as well, and a smaller pair of pads fits inside any larger patch of solder. The working image is always 40 px/mm, so the sharper photo did not reach the fit as pixels; it reached it as wider, cleaner solder. Now the part's own body, read on the photo by the same reader, settles the size, provided the pads of that size fit no worse by 0.08; where no body reads, pads alone change a size only on a fit 0.15 better, either way. Each chip part's sizeCheck says what was read.
A third board: levels from edges, print, and any mask colour
Tried on a small USB hub board (two whole-board close-ups, 76.8 px/mm, green mask, parts on the top only) and on close-up tiles of a red and a blue board. The baseline read the hub's top as one island holding 192 of 228 pins joined on that face, and check copper passed. The reader was developed on the hub's top; its bottom and the other boards were looked at afterwards.
Why one island. The gaps were never the problem. The top is mostly laminate with thin tracks, under a light that casts shadows beside every part. The two-class fit split shadow from laminate, so the laminate took the copper level (118 where the tracks read 180) and every track, pad and pour joined through it.
Levels checked against step edges (edgelevels.rs). A copper edge is a sharp step whatever share of the face each class has. Every sharp edge clear of parts and print is measured: the brightness 2 to 7 px out on each side, upper quartile on the bright side and lower on the dark. Edges must be sharp (half the step within 4 px of the crest, which drops the penumbra of cast shadows) and run on for 0.5 mm (which drops specks). The step that occurs most often is copper over laminate, and edges within 35 percent of it are kept. agreement counts how many of them have both sides in one class under the fitted levels: 0.77 in the copper class on the hub top and 0.58 on the CM5 top, against a larger share of 0.19 and 0.29 on the Driver top and bottom and 0.20 on the CM5 bottom. Over half, and the levels are replaced by the edges' own two sides, averaged over the finest of 12, 6 and 3 mm windows that holds 60 samples. Under half, nothing changes, so the Driver and the CM5 bottom keep their levels. The CM5 top's are replaced: it goes from 102 kept islands to 299, tracks beside the processor that were one pour come apart, and its copper score moves from 0.702 to 0.670 (precision 0.79 to 0.92, recall 0.85 to 0.72) on a scorer that shares its threshold with the old levels. Both figures are written per face to detect/copper-island-accounting.json (levelsAgainstEdges, levelsFromEdges, copperShare).
Print (trace.rs, printgaps.rs). Two errors. A printed line's blurred rim is brighter than laminate and not pale, so it read as copper and every line of print on laminate became a track. What photomat calls silk is now never read, with a 2 px rim, and never counts as the lit edge of copper. And a gap that runs under print closed there, because the pixels under the print are nearer the copper on each side: gaps_under walks each gap mouth straight on under the print, and where the walk comes out on laminate within 2 mm the walked pixels are gap. A gap that bends under print, or runs under print wider than 2 mm, is still lost.
Which faces carry parts. photomat::carries_parts counts dark and tan blobs. Brass standoffs, their shadows and a strip of table passed it on the hub's bare bottom (2.4 percent against the 1 percent line), so the frame of its printed table read as solder. face_carries_parts asks parts.json once the run has placed parts and the photo only before that.
Mask colour (maskcolour.rs). The peak of the hue histogram of coloured pixels is the mask's hue. In the green bracket (65 to 200 degrees) everything is as it was, to the letter. Otherwise copper is read in the brightest channel whose median over the mask is at most 160: the mask's own channel sits where the camera's tone curve is flat (blue board: step 0.06 to 0.11 in blue against 0.24 to 0.44 in green; red board: red clipped over 16 to 18 percent of the mask). "Red over green", which meant gold, tan or a part, becomes a warm hue at least 20 degrees off the mask's. photomat's G - R plane is rebuilt as 100 times saturation at the mask's hue, -60 for a warm colour off it and 0 otherwise, which puts mask, gold and neutral where the Driver photos had them. This was looked at on four red and four blue tiles and is not scored: no red or blue board has been run through the flow.
Whole faces of the red and the blue board (maskread.rs, 2026-11-10). Boards 7 (red) and 8 (blue) were taken from their photos through prep, scale by rulers and copper trace, with no parts placed. Three things changed, none of which touches a green mask:
- The channel is measured. The rule above put the red board on green or blue, whose median over the mask is 30 to 50 of 255: colour noise there is as large as the step from laminate to copper. For each channel the reader now takes the structure the plain mask shows (5th to 95th percentile of log brightness between 2 and 12 px of smoothing) over that channel's grain, and moves off the rule's channel only when another scores 1.5 times better. Board 7 reads in red (1.73 and 1.45 against 0.95 and 0.58 for the rule's channel); board 8 stays in green. Island fragments before joining fell from 17,384 to 4,580 on the red top and from 13,260 to 2,654 on its bottom.
- A colour that cannot be the mask's is a part. A black chip under a blue-white light reads dark teal, saturation 0.5, and passed the test for a dark body ("value under 0.30 and saturation under 0.42"), so it was traced as copper. On a mask that is not green, a coloured pixel 60 degrees or more from the mask's hue is not board.
- Levels far wider than the edges step. A face that is nearly all pour gives the two-class fit almost no laminate. When the fitted levels lie more than 1.6 times as far apart as the face's own copper edges step, they are taken from the edges (
edgelevels), on masks that are not green only.
Judged by eye on places chosen before the reader's answer was looked at (15 random places on plain mask per face, and 10 on thin dark lines per face; the red top was the development face): the red bottom agrees at 12 of 12 random places (8 of 12 before) and 7 of 9 dark-line places (6 of 9 before); the blue bottom at 11 of 13 and 9 of 9, the blue top at 8 of 13 and 1 of 4, both unchanged. Places where the eye could not tell are left out of each count. The sample is small and nearly all copper, because both boards are flooded with pour.
Still wrong: on the red top, the fine tracks under the brightest light are not separated (the red channel sits on the camera's shoulder there and shows a clearance as a step of 0.06 in log brightness); the blue top breaks its pour into laminate patches at 5 of 13 places, under glare and a speckled mask; where the mosaic was filled from the overview nothing can be read. scale decides which way the board was turned over from through features when no hole agrees (flipdecide.rs); on these two boards the features do not correlate either, and --flip has to be given.
check copper (copperchecks.rs) now fails when one island holds over two thirds of a face's joined pins (hub baseline 0.84; largest honest share so far 0.51, the Driver bottom's ground pour), when one island joins over two thirds of the pins of a part with 16 or more, when over 95 percent of the visible face reads as copper, or when the levels miss the photo's step edges after replacement. An island with a third of the pins on three or more named nets gets a hint.
Still wrong on the hub board. Its bottom right, laminate with a few thin tracks, still reads as one ragged pour: the levels there pass the edge test over the face as a whole and are wrong locally. Rings of header holes come out as several small islands each. Print under a part's shadow is not found as print. On the red tile grain and scratches fragment the pours.
Pads join their copper, and a track goes on in line
After the levels were fixed the hub board's tracks came apart, and so did every pad from its track: 63 of 258 top pads joined anything. The reader sees mask-covered copper. A pad is exposed metal under a part's end, with a fillet, a glint and often the part's shadow, none of which is read, so the copper stopped 4 to 11 px short of each pad and a join depended on a ring test round the pad.
The sample. 40 top pads of the hub board, the pad nearest each cell of an 8 by 5 grid, shown on photo crops only (scratch/b10/sample/), and for each the neighbouring pad, via or track its copper visibly reaches: 31 joins on 28 pads, the other 12 showing nothing that could be followed. Even-numbered pads were used to develop, odd ones to report. A join counts when copper trace puts the pad's pin on the island found within 0.2 mm of the target.
padjoin.rs. Each pad is a seed: the footprint's pad as placed, or a blob of exposed metal on a face with no parts placed yet. A seed grows through whatever the photo cannot show and stops at seen laminate or a gap line, 0.3 mm at most (REACH_PX), each pixel going to the nearest seed. The islands it meets are the pad's copper: they become one island with the pad's own area and a 3 px path. Shadow does not stop the growth: a pixel 0.3 of the separation below the laminate level is in shadow, not laminate. Two rules came from the first try, which shorted 32 two-pad parts and four pins of the hub chip through strips of copper that pass both pads: when two pads of one part reach the same island, the nearer keeps it if the other is 0.15 mm further off, and otherwise neither does, except two pins of a chip that both lie on it. islands::pad_island asks first which island covers the pad.
trackbridge.rs. Two track pieces are joined when one lies straight ahead of the other's end within 0.5 mm (MAX_BRIDGE_PX, shorter than the smallest two-pad part, so a bridge cannot span a part), has the same width, both run straight for 0.75 mm, both stand 0.4 of the level separation above both flanks, no gap line lies between, and the break does not read as laminate for 0.1 mm. Without the flank and length tests 21 of 24 bridges drawn at random joined hole rings, print rims and streaks. With them the hub board's top gets 2 bridges: its tracks were broken at pads, not along their length, so this step does little there.
No chain of pads may put the two ends of a two-pad part on one island either: each island carries the pads joined to it, and a join that would bring a pad and its mate together is refused.
Figures. Hub board top, joins reproduced: 6 of 17 before and 13 of 17 after on the development half, 3 of 14 before and 13 of 14 after on the reporting half. Top pads joined to anything 63 to 248 of 258, largest island's share of them 0.08 to 0.03, two-pad parts with both ends on one island 0. Driver and CM5 copper scores moved by under 0.01.
A held-out board, and what it showed. Board 04 (a stitched face at about 40 px/mm, green, no parts placed, so the seeds were blobs of metal) was looked at only after the above. Its pours are separated by gaps half a millimetre wide that the reader finds in most places and loses in some, so one island already held 30 percent of the crop before this round and holds 34 after, with 17 of 40 sampled pads on it. The 8 joins labelled there score 2 before and 4 after, and neither figure means anything, because both go through that island. check copper cannot see it either: with no parts placed there are no pins to count. Forcing the levels from the edges on that face (its fitted levels put 0.24 of its copper edges wholly in copper, under the one half that triggers) brings the island to 16 percent, and doing the same to the Driver bottom costs it 0.10 of its copper score and 227 vias, so the trigger stays where it is. Whole stitched faces of the red and the blue board do not read at all: the red board's chip reads as copper, and 42 percent of the blue face reads as pale.
Not done. Pours in the shadow of tall parts are still lost: both levels fall together there and the copper reads as laminate. Only pads reach across shadow. Seeds from metal blobs, used when no parts are placed, have no part to check a join against.
Stitched faces: grain, levels place by place, and a face that is not read
Three stitched faces failed on 2026-11-10 after whole-board photos read: test board 1's bottom (one island with 117 of 148 joined pins), board 4's top (one "GND" holding both pads of two diodes) and board 1's top. What was found, face by face, and what changed (locallevels.rs, faceread.rs, 2026-11-11).
Board 1 bottom: the edge test was blind. The left half is laminate with thin tracks, the right half pours. The fit put the copper level on the laminate over the left half, as on the hub board. The edge test should have caught it and did not: the dim, soft ends of the tiles carry sensor grain whose crests join into one network, the run test keeps them, and they outnumber the copper edges, so the commonest step was 0.06 where copper over laminate steps 0.15 to 0.35. Now the commonest step is also taken among edges that go on in line (the crest is found again 6 px along, both ways, facing the same way), and when the two differ by more than one bin the straight edges are the face's edges (without_grain). A face where the two agree keeps every edge as before. Board 1's bottom then reads modal step 0.163 with 77 percent of its edges wholly in copper, and its levels are replaced. Board 4's bottom moved from 0.087 to 0.163 the same way.
Each place against the edges near it (correct). A face can pass as a whole and be wrong over part of itself. The shares of edgelevels::agreement are also counted in a 6 mm window round every place, and where a majority of the edges near a place lie wholly in one class the levels there come from the edges, blended over half a window. On the faces run so far this fired only over a fraction of a percent of the hub board's bottom.
Under a moving light, by the side of the nearest edge (levels_by_side). With the levels from window means of the edges, the bare laminate under the light's hot spot on board 1's bottom, 5 mm from any edge, read brighter than the copper level and joined three pours. Every place lies on the bright or the dark side of its nearest step edge, so its own brightness, moved half the edges' step towards the middle, is a sample of the mid level where it is; those samples are averaged over 1.5 mm. One mid level, not a level per side: with a level per side a false edge on bare laminate gave its few "bright side" places a copper level of their own and split the grain in two (first try, median separation 0.08). Edges within 0.5 mm of holes, parts and metal are left out (shadow rims), and so are places that lie along an edge's line past its end. Used only where the light moves (FLAT_LIGHT) and where at least 0.75 of the edges straddle their own window means (SIDES_NEED): on board 4's top, where 0.56 do, it turned 4 of 13 labelled places of pour into laminate.
The scale of the line thresholds is the 99th percentile of the response on plain board, 0.4 mm clear of parts and print, not of the whole face: fabric tape on board 1's bottom put it at 14.0 where the board reads 4.1.
A face that cannot be read (faceread.rs). Before anything is written, a face is taken out when the trace itself shows the failure: one island holds over two thirds of its joined pins, over 95 percent of it reads as copper, or ten or more and over half of its two-pad parts have both pads on one island. The copper is measured again without that face, detect/copper-island-accounting.json carries unread with the reason, copper trace and check copper say so, and the check passes: the pins on that face join nothing, which is a missing net and not a wrong one. For one build a fourth reason stood beside these: levels taken from the face's edges that under two thirds of those edges straddle. It came from board 4's top (0.56) and emptied two faces that read: board 9's bottom (0.59, islands right at 24 of 25 labelled places) and board 5's top (0.66). An edge figure cannot tell a face that reads from one that does not, so it is now a warning on the face in the accounting and a hint, and the reading is kept.
Most pins of a large part on one island (over two thirds of a part with 16 or more, the line check copper fails a run on) join nothing either, and are listed as partPinsRefused; the rest of the face stands.
Two pads of one two-pad part on one island join nothing (pairs_on_one_island), and are listed as padPairsRefused. The hub board's top had 18 such parts.
Drills. layers trace moved a through pad's anchor, and so its drill, to a point inside the pad's photographed outline whenever no photographed hole was matched. The anchor now stays at the hole, or at the library's place when no hole was found, and the outline is taken only if it holds that place.
Measured on by-eye labels made by a reader that saw photo crops only: 30 random places on plain mask and 24 random pairs per face, with "cannot tell" left out, so the counts are small. Places right, before and after: board 1 bottom 12 of 17 and 15 of 17 (bare board 3 of 8 and 8 of 8); board 1 top 17 of 21 and 20 of 21; board 4 bottom 12 of 14 and 13 of 14, pairs 10 of 13 and 12 of 13; board 4 top 15 of 18 before, not read after. Largest island's share of joined pins on board 1's bottom: 0.79 to 0.10.
Held out (looked at only after the above was fixed). Board 2's top, where the unchanged reader put 286 of 558 joined pins on one island (58 of the 80 pins of the processor) and 40 two-pad parts on one island: the grain test moved its modal step from 0.11 to 0.24, its levels were corrected place by place, and the largest island holds 46 of 526 pins (0.09), with the tracks beside the processor read one by one; the processor's top row of pins is still one island with the pour above it. No labels were made for board 2. Board 9's top: 11 of 20 labelled places right before and after, so nothing gained there. Board 9's bottom: see the misfire above.
Still wrong. Board 1's bottom: two of its three large pours are one island, because the lane between them is lost for 2 mm under the hot spot, and patches of bare laminate inside the ring of header pins and along the top edge read as copper. Board 4's top is not read at all: its gap lines are visible to the eye and the line filter finds only pieces of them. The hub board's top reads more laminate as copper beside its chip than before (copper share 0.31 to 0.41). The Driver and CM5 runs were not re-measured with these changes.
The visible layers come from the photos, and how they are scored
layers trace writes photo-layers.json; build draws from it when it exists. check fidelity plots the board through KiCad's service, draws its gerbers and drill file at 40 px/mm (gerber.rs) and compares them with what each photo shows.
Registration first. Fitted corners leave a warp. On the Driver board, the top and bottom frames disagreed by up to 0.58 mm (0.204 mm RMS over 40 shared holes). A quadratic map through the holes takes it to 0.020 mm, 0.026 mm with each hole left out of the fit, so scale now writes board/bottom.jpg that way. Everything drawn from the bottom photo, including B.Cu, moves with it.
What a pixel shows (photomat.rs). Silk is told from specular copper edges by colour, not brightness: on the Driver bottom, trace edges glint as bright as silk and as wide, but read B - R about 1 where silk reads 6 to 30. Lateral colour puts a red fringe on one side of thin lines, so the test takes the median over the bright core of each connected blob, not per pixel. Where the mask around is bright, glints turn bluer, so the bar rises with it. Parts are found by not being green, and large bodies fill to their hull because dark green plastic reads as mask. A hole must show on both faces, or have a part or joint opposite.
Drawing. Silk goes on as filled gr_poly, holes bridged in by keyhole, with library silk and reference labels moved off. A pad the photo shows becomes a custom pad of that outline, keeping its number and net; where a body hides part of it, that part stays as the library drew it. Through pads take the measured hole. Holes inside a pad become more pads of the same number. Holes and metal no footprint explains go on one net-less footprint, PHOTO1. The outline is four fitted edges, each from whichever face shows it better.
Scoring. Only area the photo can show counts: silk where the photo shows mask, silk or metal; openings and metal off part bodies; copper only under bare mask, 6 px clear of anything else (fidelity::BESIDE_PX). Excluded pixels are in neither the numerator nor the denominator, and their area is reported per layer. Three samples: tile set A of a 5 mm checkerboard sets thresholds, tile set B is held out, and the second photo of each face on tile set B is registered separately.
The class map also drives the tracer, so a high IoU on the primary photo is partly circular. The second photo tests noise and registration, but it has the same camera position and light. edgeAgainstLuminance walks from each drawn edge to the steepest luminance edge, with no threshold, and is the check on bias. Every layer is also scored moved by 0.25 mm, and check fidelity fails if that does not lower the IoU.
The copper scorer, and what it got wrong
fidelity::copper_read is a plain threshold on the green level against an upper envelope of the light. It knows nothing of gap lines, which is the point of it. Until November it was also the largest error in the copper figures. It took its envelope and its Otsu split from every mask pixel, and the pixels beside silk blocks and pads carry glare: in the MOTOR_TEMP box on the Driver top the envelope read 107 where the copper read 64, so whole pours scored as laminate. It now uses only pixels 6 px clear of anything else. A lowest-of-four directional closing over 0.6 mm fills dome shadows, which are short, and keeps gaps, which are long.
Repairing the scorer, with the board unchanged, moved F.Cu from 0.586 to about 0.83 and B.Cu from 0.754 to about 0.84. That rise says nothing about the board. Any before and after has to be scored with one scorer.
build also used to merge any routes/L<n>/routed.json it found, routed on older copper or not, which drew tracks across the new copper and displaced what was read. Routes now carry copperSha and merge only onto the copper they were routed on. check fidelity scores a board with fresh routes without them, unless --pcb names a board. It belongs after connect attribute, because cuts in the fills depend on nets.
The two photos of a face
The two photos of each face are 5 seconds apart. They were expected to separate glints from copper, because a glint should move and copper should not. They do not. The difference of the registered shots is noise plus a little parallax on tall parts, with no moving glints on the mask. Glare is fixed in both shots and has to be handled as light (see below).
What differs between them is the grain. The high-pass correlation between shots over flat mask is 0.08 on the top and 0.06 on the bottom at pixel level, 0.30 and 0.17 after a 2 px blur. So grain is sensor noise rather than texture on the board, and the two shots are two independent readings of it. trace uses that in three ways, all through Params::second:
- The mean of the shots is what the levels and materials are read from.
- The direction to smooth along is chosen on one shot and the mean along it taken from the other, both ways round. A smoother that picks the direction of least variance on the same pixels it averages draws worm-shaped false structure in flat areas, because it follows whatever the grain happens to do. Cross-fitting removes that without any threshold.
- A gap line's mean response must reach the lower ridge threshold's 0.7 in each shot separately.
A board traced this way uses the second photo, so check fidelity's second-photo figure is no longer independent of the drawing. TRACE_ONE_SHOT=1 traces from the primary alone, and scoring that board on the second photo is the fair test of the method.
Reading copper through solder mask
Copper under solder mask reads lighter than bare laminate, but only against the same part of the photo: on the Driver bottom photo, copper's green falls from about 118 at the top to 60 at the bottom, and the upper left is bluish and pale. trace.rs reads each pixel in five steps.
Lighting. two_levels estimates a copper level and a laminate level in log green for every pixel, coarse to fine (windows of radius 360, 180 and 90 px). In a window, a two-means split gives both levels if the split is real. If it is only grain, the window is one class: it takes the nearer level and keeps the separation from the coarser scale, so the levels follow the light across a wide uniform area. A plain ratio against a wide average, the first method, reads a wide area of laminate as copper and fragments a dark one. The fit leaves out anything within 8 px of a wide bright thing, for the same reason as the scorer: with silk blocks in the window, the pour in the Driver bottom's PROG box sat on the mid-point throughout and read as a blotch of copper and laminate.
Material. Pale pixels, relative to the copper level, are silk, tin, domes and glints. They are transparent: each takes the class of its nearest visible pixels. Saturation alone cannot say silk, which is why the upper-left rails were lost before: the copper there is low-saturation too. A thin pale line is the lit edge of copper. Oblique light gives a raised edge a thin white glint on one side and a dark shadow on the other, so a glint beside copper is copper, and reading it as silk is what used to erase signal bundles. Holes are opaque, and so are part bodies on the top face. Exposed gold counts as copper on the bottom.
Class. Each pixel is averaged along the direction it varies least in (16 directions, 17 pixels), when that line stands out from its surroundings, and over a small disc otherwise. Grain cancels without blurring across an edge or a gap. The result compared to the mid-point of the two levels is the pixel's class. Laminate specks with no gap line through them are grain; small dark blobs beside something pale are a dome's shadow or a letter's ink.
What is not a gap. Two dark things on the photo are not gaps. A dome's shadow is a blob within 0.5 mm of the dome (Params::domes), wholly inside that reach and enclosing nothing, so it stays copper whatever the line filter made of its edge. A line that never leaves a dome's neighbourhood goes too. And under one oblique light (Params::light, from the domes, so only where they agree on one) a raised edge facing the light glints, and the camera's sharpening digs a band 2 to 3 px wide right behind the glint, on the copper. Across a Driver bottom track: glint 5 px, band 2 px with a floor near 40, flat 3 px at the pour's level, then the real gap, 4 px with a floor near 15. Read as a gap, that band cut every track in two along its length. It counts as copper only if brightness returns to the copper level with no glint in between, so a narrow track with a real gap and the next track's glint stays apart.
Barrier. Laminate plus gap lines from an oriented dark-line filter: a Gaussian along the line (sigma 9 px), the second derivative of a Gaussian across it (sigma 2.6 px), 16 angles, on brightness clipped near the two levels so a glint cannot outweigh an edge. Its thresholds are fractions of the photo's 99th percentile response. Loose ends of gap lines are walked on until they meet another barrier. A line is drawn as wide as its dip at half depth around its centre line, not as wide as the filter's response, which took 1 to 1.5 px off the copper on each side.
Islands. What is left between barriers is one island. Then split_necks cuts thin tentacles off wide copper, each its own conductor, and cuts two wide parts joined only through a pinch. join_fragments joins a fragment to a neighbour only where nothing along their boundary shows a gap, and never joins two substantial pieces. A short between nets is worse than a missing track, so both steps lean towards splitting.
Tried first, and abandoned:
- Long straight gap lines with splitting at necks. It found pours only roughly, and no tracks at all.
- Merging over-segmented regions by boundary strength. One leak in the gap network merges pours, and noise makes false cells.
- Classifying material by hue. Grain makes dark mask pixels read any hue.
- A polynomial lighting field with a local mean correction. Where a dim area fell entirely below the polynomial's mid-point, its copper level never moved, and the whole area read as laminate.
Check any change by eye against evidence/copper-islands-*.jpg.
From islands to a board
Every island read ends up in detect/copper-island-accounting.json: kept as an item, merged into one, or not copper with a reason. Nothing is filtered for disagreeing with a rule: what the photo shows is drawn, and disagreements are reported (trackPadConflicts in detect/copper-measured.json).
On a part's top. Print on a chip, the step of a metal lid and light on a package read as small islands, and no copper can be seen there. An island is on a part's top when three things hold. At least 90 percent of it lies inside the body outline of one part placed on that face: the box of the footprint's fabrication-layer drawing at the part's place and rotation, drawn in by 0.25 mm, so terminals, leads and fillets at a body's edge stay out of it. The reader found copper over no more than a quarter of that outline: more means board shows there, through a connector or shield open in the middle, at a fiducial, or under an outline that is in the wrong place. And less than half of the island has the colour of the board's own copper, read off the face (the channel that stands furthest above grey in the median of everything read as copper is the mask's own, and a pixel has the board's colour when that channel stands a quarter as far above grey in it): that keeps copper beside a part when parts.json draws its outline too large. A two-pad part is left out. Such islands are taken out before pins and vias are joined, are drawn in the overlay as a gap is, and are listed in the accounting with the part (onPartBodies in its totals). connect measure takes the same islands out of its own trace. copper regions lists a region that is a part's whole top under onParts, by the outline as drawn and the same colour test. A board whose copper reads grey gives the colour test nothing, and nothing is taken off there.
A thin island with a line down it that explains its area is a track, one per chain of its skeleton. path_mm is the centre line, kept within a quarter of a pixel, widths_mm the width measured at each vertex, width_mm their median. Everything else is a pour, drawn by its pixels: rle_px holds runs of image pixels at 40 px/mm ([y, x0, x1, ...]), and rle the same on the 20 px/mm grid the router rasters onto. No morphology and no margins: the only additions are the exact area of a pad the island joins, which the photo cannot show under its part, and pinholes smaller than 40 pixels. polygon_mm is an outline within half a pixel.
KiCad needs each fill as a single polygon with its holes joined in by slits. kicad_out::keyhole does this left to right so slits never cross, and winds each hole against its outline. KiCad's triangulation reads a hole wound the same way as copper, and an even-odd check will not catch that. A pour read from the photo is written from rle_px, at image resolution, along the pixels' edges (crack_loops). Tracing through pixel centres left half a pixel off every outer edge.
Only another net's pad, via or routed track is cut out of a fill, and seen copper of another net is not, because the seen gap can be narrower than the design rule. A pad of another net keeps 0.15 mm, the width the photo cannot show beside metal. A via of another net keeps 0.05 mm: the photo shows no gap there, or the pour would have split, so the via's name is in doubt and the cut only stops the two shorting.
So that a refill keeps the shape, a read pour's zone carries the photographed shape in its outline: one (polygon) for the outline and one per hole, which is how KiCad reads a zone (the first polygon is the outline, each later one a hole). It also carries its own measured clearance to other nets, a 0.05 mm minimum thickness, and island removal off. The refill itself is untested: the shared service only plots and runs DRC on the stored fill, and neither refills. Setting ZONE_RULE_CLEARANCE writes the rule's clearance instead. On the Driver board that moves DRC's clearance count only from 506 to 510, which suggests the net class's 0.2 mm still applies and a refill would cut narrow gaps back.
check fidelity writes plotAgainstRead for each copper layer: KiCad's plot against the pours and tracks of copper.json, over the visible area and over the whole board. The whole-board figure also counts pads and vias, which the read layer does not draw.
What connects to what
islands.rs gives a first reading while tracing: a pad joins an island when the copper around it is that island and no closed gap stands between them. That reading is weak, and connect measure and connect attribute replace it; see below.
A via is only reported as inner when a gap closes all the way round it on every face, no copper leaves it, and each photo shows domes at all (domes::face_signature). On the Driver board the top fails that last test, so no via is inner, and the photos prove no minimum layer count. The gate is conservative: it tests for domes, not clearance rings, so it could hold back a true case on another board.
The earlier test called a via isolated when the islands round it were too small to draw, which says more about grain than about copper. The 3 vias it named are real vias, looked at on both faces. At (144.488, 132.263) a line on the bottom runs straight at the via, so it probably leaves on that face. The top there shows faint arcs, the one ring-like mark seen on that face. At (105.313, 157.638) and (105.338, 158.463) the top is flat mask, which fits a via joined to a top pour as well as an isolated one. A search for dark circles on the top at ring vias with a clear view found 7 of 93 above the 99th percentile of random spots. All 7 were letters printed on a chip or small parts when looked at.
Reading vias
domes.rs works from the shape of a tented via, because brightness alone also finds silkscreen, copper edges and pads. Each candidate is a local peak of a small blob response, or of what remains after 1 mm straight lines are explained. Everything measured on every candidate goes to detect/dome-candidates.json, so a rejection can be checked.
- Not a line. Opening along 1 mm lines in the four directions boards are drawn in removes tracks, copper edges, silkscreen lines and wide patches. What stays is short.
- The right size. The half-height blob stays within a 0.7 mm window and is under 0.62 mm long. Below 0.16 mm it must also have a clear shadow, because the smallest domes are as small as grain. Long thin straight dashes are out: a highlight on a dome bends towards the light.
- Not on silkscreen. Few pale pixels in the ring round it.
- A dome on a copper edge joins the edge's own glint, so it has no blob of its own. It is kept when its body stands proud of the line, wider than a rim, with a shadow on the far side from the light.
- A via with a track leaving it also has a joined highlight. It is kept on the strength of the dark circle round its pad.
- One light. The light's direction is measured from the bumps themselves. If a face's accepted bumps do not agree on it, that face is flat and none of them count. The Driver bottom agrees at 0.59, the top at 0.13. None of the 112 top candidates labelled was a dome: they were part ends, pads, silkscreen and edges.
Position and size then come from what each via shows. A dark circle 0.3 to 0.5 mm in radius gives both (ring), and the highlights inside it merge into that one via. A long thin arc lies on the near side of a via too wide to light in full (arc), so its offset and width are the median of the rings on that face. A round blob is the dome itself (blob), at its half-height centre, moved 0.03 mm away from the light. A dome on an edge takes its peak (edge).
Via pads. A dome's width is the dimple over the hole and a ring's is the clearance round the pad, so neither is the pad. A via whose surroundings are laminate shows the pad itself: domes::size_vias takes the median brightness at each radius round the via, so a track leaving covers only a few sectors, and the half height of the fall is the pad's edge. These read as one size whatever the via was read as, and every via takes their median, to the nearest 0.05 mm because the photo cannot tell 0.48 from 0.50. The readings are in detect/via-sizes.json. This rests on the vias that show a fall, about a tenth of them: vias in pours are assumed to be the same. The drill cannot be seen, so drill_mm stays the rule's, cut down to leave a 0.1 mm ring.
Vias as holes, spot by spot (viasig.rs). Domes need light from one side. Under even light what is left of a via is its hole: a small round dark centre where the mask sinks in or the drill is open, with a pale or light pad round it where the via stands in laminate and nothing round it where it stands in a pour. On a third board (a USB hub, board 10 of the test set) the dome reader and the pit reader both refused whole faces and kept no via at all, because grain passed the pit test by the thousand and the faces were 0.3 mm off each other, so nothing lined up within 0.1 mm. viasig decides each spot on its own evidence instead:
- Every dark centre is measured (
viasig::measure): how far the centre stands below its rim as a share of the rim (share), the core at half depth (size, long over short axis, whether it closes), what surrounds it by colour against the mask colour of that photo (Tone: the hue most coloured pixels share, so any mask colour works; pixels are mask, pale, black or another colour), whether mask follows 0.4 to 0.5 mm out, and the ring round it (a pad's edge or a clearance circle, with the share of the circle that shows it). All of it goes todetect/via-spots.jsonwith the verdict and its reason, kept or dropped. - The faces are laid on each other through the spots themselves (
viasig::lay). The deepest round spots of each face vote for a shift of the bottom within 1 mm; a shift counts when it gathers four times the votes a mirrored bottom gathers. Pairs then fit a quadratic map once 40 hold it. Board 10: 62 votes against 7, 129 pairs, the bottom 0.30 mm off. Thescalestep uses the same pairs to draw the bottom again (viasig::relay), which leaves the faces 0.02 mm apart at the vias; before, only 13 of 127 top vias had their bottom partner within 0.1 mm and 77 within the 0.3 mm thatislandspairs at. - The depth a via has is read from the pair of photos, not set. Depth share is cut into twenty steps. A step belongs to the via band when its spots pair with the other face at five times the rate chance gives (the density of round spots on the other face times the pairing disc) and so many pairs are under 1 percent likely by chance. The band is the run of such steps with the most pairs: 0.60 and up on board 10, 0.25 to 0.65 and 0.20 to 0.60 on the CM5, whose pits are shallower and have a few deeper non-vias above them. Within the band, a spot may stand on one face alone only at steps where four in five of the spots the other face can see are paired.
- Kept: a spot paired with a spot on the other face, both in their bands; a spot deep enough to stand alone whose place on the other face is covered (a part, print, a pad) or shows a faint spot; and where the other face shows bare mask and no hole, only a spot with a ring of its own. A faint spot is kept when a spot that stands alone lies on the other face at the same place.
- Dropped, each with its reason: a core that runs on (a gap, a part's edge), not round, a speck, too wide for a via's hole, dark all round (inside a slot or a part), metal or print all round with no mask further out (the pad of a part's pin, hardware, a label), in print with no ring, no ring among parts, too shallow, and a bare other face that shows nothing.
- Which reader counts where: a face with one light keeps its domes as before, and holes are added where no dome stands. A flat face takes its vias from the holes. Where the faces cannot be laid on each other through their spots (the Driver: its top shows no vias), nothing is kept this way and the pit reader's whole-face test stands as before.
Measured on board 10 by eye (8 tiles of 6 mm drawn at random from the right half, both faces, 45 vias labelled from clean crops before the reader's answer was seen; rules were developed on the left half): 42 of 44 kept are labelled vias and 42 of 45 labelled vias are kept. One of the two others is a via half under a connector that the labels missed, the other sat inside a slot, which led to the dark-all-round rule; with that and two later changes (each depth step judged on its own, the stand-alone depths step by step) the same tiles give 41 of 42 and 41 of 45, which is no longer a held-out figure. The misses: a via whose hole the mask has closed (a ring with no dark centre), one at a chip's corner whose core merges with the shadow, and its other end under print. On the CM5 audit sample in the run copy, 39 of 43 kept vias the auditor confirmed are still kept, all 14 places they called wrong (8 false vias and 6 planted) are dropped, and the kept list falls from 1155 to 946. The Driver list is unchanged, 1398. Of the 42 vias only the new reader keeps on the CM5, a look at the first version's 54 found about a third to be shadows and gaps among parts: the stand-alone rule without a ring is the weak one on a crowded board.
Not read: vias tented flat that show only a ring or a small glint in the middle. The red and the blue board of the test set (7 and 8) show theirs this way, and the reader finds no shift between their faces and keeps nothing. Tried on whole faces (2026-11-10): small bright specks on plain mask, 3,381 and 1,140 on the faces of board 7 and 4,230 and 5,363 on board 8, do not line up between the faces beyond chance at any shift (50 pairs at the best shift against 42 at the 99th percentile of the others; 221 against 196), because dust, print edges and grain outnumber the glints. By eye, of 30 such candidates per board, 3 on board 7 and 2 on board 8 are vias, all on the bottom faces where a via in a track has a clearance ring round it; 7 and 4 cannot be told. No tented via is kept on either board. Thresholds say where they came from at their definitions in viasig.rs.
Thresholds come from a labelled tuning sample and from overlays of the Driver photos, and say so where they are defined. Vias keep the distance the photo shows: a rule's hole spacing removed real vias in the dense fields. A pad blocks a via only on the face the via shows on, because the fields under U1, U2 and the MOSFETs run under top pads, and the old filter deleted nearly all of U1's and U2's. An open hole has no solder fillet, so it gets a smaller margin than a soldered pin.
Attributing every piece of copper
attrib.rs and conduct.rs give every pour, track and via a net and say how they know. They never move copper: only net, fieldTags, netEvidence and the nets themselves change. connect measure reads the photos once (about 3 minutes). connect attribute [--vias <file>] reads what that wrote and takes seconds, so a better via list drops in without a new trace. The file is {"vias": [...]} or a bare array, each entry with pos_mm and optional id, seen and valid.
Connectivity comes first and names second. The order is stitch, pads, vias, then names, and a name never joins anything. detect/connect-graph.json holds each stitch, contact, link and group with its evidence.
A gap map of its own (conduct::read_face). The reader leans towards splitting, so its boundaries cannot say what is a real gap. A gap here is one of two things. A dark line that runs straight for at least 0.6 mm: dome shadows on the Driver bottom are at most 0.5 mm long, so length separates them where darkness does not. Or a wide area whose colour is laminate: green minus red, against the local copper level, under 0.7. Laminate reaches 0.9 of copper's brightness on the bottom photo, so brightness alone reads it as copper, and that leak joined the rails in the first version.
Stitching. Two pieces become one conductor where a corridor free of gaps runs between them, at least 0.25 mm wide for two pours and 0.15 mm where a track is involved. A piece must look like copper in brightness and colour to be stitched at all, and no stitch is read within 0.2 mm of a pad, because a pad's solder and glare run across real gaps. Across silk, the two sides must face each other for at least 1 mm with no gap line beside the silk. Each stitch records what lay on the meeting: glint, dome shadow, silk or a cut by the reader. The geometry stays as photographed: stitching only makes several drawn items share a net.
Glare at fine pitch. The glare along a QFN's edge reads as a thin strip of copper past every pin, which tied whole rows of U1 and U2 together. Narrow copper within 0.25 mm of a fine pin is left out of the pieces. Several fine pins of one part still landing on one conductor means the photo did not separate them, so those contacts stay unsettled.
Pad edges. A flood from the pad's own metal says what can be reached, per sector, at each corridor width. A sector counts as closed if a gap crosses it or a lit copper edge does: oblique light catches the raised edge through the mask as a thin bright line, and on crops those lines trace the clearance round isolated pads. A pad joins a pour when at least three sectors in a row are open and at most half are closed. A plated pin on the bottom keeps its own test, a valley and a bright arc in the median brightness against distance, and a hole full of solder hides it. The outline read is the library's at the place the photo shows the metal, since a traced pad's box on a part turned 45 degrees covers its neighbours.
Following a track. A track reaches a pad or via when its drawn centre line ends there: within 0.2 mm of a pad's outline, 0.12 mm at fine pitch, coming from outside it, and at fine pitch arriving along the pad's long side, not past it. No pin name is used. A pad with no such track and no pour stays unsettled, or hidden when too little of its edge can be seen. Under U1 and U2 most of it cannot.
Vias. On each face a via takes what surrounds it, read from its pad outward. A dark gap round most of it means it joins only a track that ends on it, and a dome's highlight does not count as a closed ring, as it does at a pad. Near a boundary of two conductors the one reachable without crossing a gap wins, and if both are, the via joins neither and is reported in viasOnBoundary. A pad takes the conductor of the vias through it only from at least two, clearly: one via under a part's body may only pass beneath it, which is how an inductor pin first landed on GND.
The ladder, strongest first: copper (touches a pad of that net with no gap, through stitches at most), via (through two vias between the same conductors, or a via with a track ending on it on both faces), inferred (one via track to track, or an unsettled pad), group (a neutral UNK_G name, joined to other unnamed copper or by one via to a pour, which proves nothing), none (UNK_ISO). conflict is off the scale: two nets meet and nothing settles it, and no name is forced. Names spread over the links with the most vias first, and two named conductors are never merged. Their meeting is reported as an apparent short in net-evidence.json. The score weights each rung by strength.
The copper wins. A pin whose only solid contact is copper named on the copper or via rung moves to that net, and pinsMoved lists it. A pin the map had but the copper does not support is in unsupported, with inferredPins keeping the original claim so repeating connect attribute gives the same answer. Pins with no name in the map that share copper get a net of their own, CU_<pin>. Metal no footprint explains (PHOTO1) can carry copper but belongs to no net. nets.json keeps what the copper does not contradict, so most of it is still inferred: an INFERRED tag on a net means not every pin of it is seen joined.
A resistor's or capacitor's end votes little, because nothing on the photo says which end is pin 1. A two-pin part with both ends on one conductor is a sliver or leak, so those contacts are dropped. swapSuspects lists two-pin parts whose copper disagrees with their pins.
Order of steps. connect attribute writes nets into copper.json. check copper and check fidelity read shapes, so they now record attrib::geometry_hash, which leaves out the fields attribution writes. Attribution no longer makes them stale, and a changed shape still does. check mimic and the snapshot do depend on nets, so they stay stale until rebuilt. The sequence therefore runs attribution in the copper step, before anything is built on the copper. When it has to run again later, because pin names were added to nets.json, auto goes back by itself (build, check mimic, the snapshot, then on), and next names each of those commands in turn (auto.rs, plan).
Nets a document names. A net the AI supplied with tag DATASHEET and a source keeps both while the copper does not contradict it: not every pin being seen joined is no reason to call a document an inference. The copper contradicts a net when one of its pins sits on copper named for another net, or when the copper moved a pin the map had off it. Then the net becomes INFERRED, with a test, and contradiction in nets.json holds the finding: the counts, the pins moved and what the net was stated as (statedTag, which attribution keeps so a later run can give the tag back when the contradiction is gone). connect attribute lists the contradicted nets. A net tagged INFERRED always names a test, so check nets cannot fail for the want of one. With no nets.json at all, connect attribute starts one empty and names every net from copper.
Checking. tools/connect_sample.py draws pad-to-pad connections with a seed from the graph's hash, stratified by size of conductor and graded solid or unsettled, and renders a card per connection with no net names on it. The two ends of a resistor offered as one connection are controls.
Hidden connections are left unrouted
The default flow no longer routes what nobody can see. Routing took most of a run (35 of 50 minutes on the CM5), added hundreds of DRC errors, and drew one way to make the connections, not what is inside the board; KiCad's count of open connections barely moved with it (499 before, about 440 after, on the CM5). So the copy stops at what the photos show.
unrouted.rs lists what is left. A net's pins fall into pieces: each group of pins that seen copper joins (copperGroups, written by connect attribute) and each pin no copper is seen to reach, alone. A net of g pieces has g - 1 hidden connections, each from its largest piece to one of the others. Each carries the tag of what says its ends belong together: DATASHEET when the net cites a document, INFERRED otherwise, MEASURED once a bench probe found it joined. build writes them to unrouted.json and leaves each as a ratsnest line in the KiCad copy. The KiCad project gets one net class per kind of evidence (SEEN, HIDDEN_DATASHEET, HIDDEN_INFERRED, BENCH_MEASURED), each with its own colour and Default's sizes, so a ratsnest line shows in KiCad what it rests on and no rule changes. KiCad 10's DRC gave the same result with and without the classes on the CM5 copy, within its own run to run variation (3413 to 3416 violations, 499 unconnected each time).
KiCad's own ratsnest count is larger than the list: it counts every piece of copper of a net that the drawn shapes do not join, with or without a pin. On the CM5 copy all 499 are pieces of GND, which meet on inner planes no photo shows; unrouted.json holds the 57 between groups of pins.
The layer count is stated by layers state (layerstate.rs), not found by routing. A count comes from outside evidence only: a document (--source, tag DATASHEET) or a measurement on the board itself (--evidence <file> --kind edge-micrograph|cross-section|xray|ct, tag MEASURED, the file held by hash). Otherwise the count is UNKNOWN with test T-layers. Beside it the file states the minimum the photos give:
- vias ringed on both faces with no copper leaving on either (
kind: inner): at least 4,MEASURED; - at least 10 vias that show on one face while the other face is in view there and shows no via candidate of any kind within 0.3 mm, on a board where at least 10 other vias show on both faces: more than 2 layers,
INFERRED. It is not proof: a via plugged or tented flat on one face reads the same. The three numbers are policy, not fitted to a board; - copper read on both faces: 2,
SEEN.
A stated count below a proven minimum is refused. check layers recomputes the photos' evidence and fails if the file's differs, so the minimum cannot be typed. connect route still works when asked and records its stackups in layers.json; a count stated from outside evidence stays as stated, and no step requires routing.
A net's confidence is its weakest link
Attribution grades pieces of copper. A person asks about nets: is this net proven? netconf.rs answers from the same files (detect/connect-graph.json, detect/attribution.json, nets.json, copper.json), reads no photo and measures nothing new. nets draw writes the answer to net-confidence.json and draws it (netdraw.rs).
The share of a net's copper that was seen is the wrong measure. A ground net can have 95 percent of its copper on the copper rung and still have pins that nothing seen joins to it. So the level is defined on the connection graph, for each net:
- Vertices. The net's pins, and the conductors that carry its name. A conductor is copper already stitched into one piece, so it is one vertex.
- Links, strongest first.
copper: a pad joined to a conductor of the net with no gap (a solid contact; among these a bolted lug counts above a pour, a pour above a track end, a track end above vias through the pad).via: two conductors of the net joined through verified vias (a link of tier 2).inferred, in this order: one via track to track (tier 3); a pad whose edge does not settle what it touches (a weak contact); one unconfirmed via onto a pour together with the pin map (tier 4, which proves nothing by itself); the pin map alone. - The path set. Links are added strongest first, and a link is kept only if it joins two things not yet joined, until every pin is in one piece. That is a spanning tree that prefers the strongest links, so between any two pins it holds the path whose weakest link is as strong as it can be. Whatever the seen links leave apart, the pin map joins: every piece but the largest hangs on one pin-map link. Links to copper with no pin beyond it are dropped, because they join no pins.
- The level is the rung of the weakest link in the path set. That link is reported with its place (the pad, or the via), and with how many links like it the set holds.
So a pin with no solid contact to the net's copper is a member by inference only, and one such pin makes the net inferred. copper means every pin sits on one conductor. via means every pin is seen joined and at least one join is a verified via. The levels agree with attribution's own tag: a net of two or more pins is copper or via exactly when nets.json tags it MEASURED (21 of 192 on the CM5 run, none of 77 on the Driver run, checked on both).
A net of one pin has nothing to join. It is graded by how that pin meets its copper and marked singlePin, and the counts keep these apart: on the CM5 run 31 of the 52 copper nets are single connector pins with their track.
Places where a net appears to meet another (shorts in net-evidence.json) are not links between its own pins. They are listed per net as disputes, drawn as a cross, and do not lower the level. The copper in dispute carries no name and is drawn as conflict.
Drawing. A piece of copper is drawn at the weaker of its own rung and its net's level, so copper named by inference stays inferred on a net whose pins are all seen. Copper with no net is drawn by its class: group (UNK_G), none (UNK_ISO, stubs and vias that join nothing), conflict. Each level has a hue from the Okabe-Ito set and a pattern (solid, level bands, diagonal stripes, grid, dots, dark stripes), so the levels can be told apart without colour; tracks are too thin for a pattern, so the inferred, group and conflict ones are dashed. The pattern period is ten pixels at 1080p, coarse enough to survive video compression. attribution.jpg shows each piece by its own rung, for comparison. A per-net picture colours the net's copper by its own rung, marks pins seen joined (filled) and pins by inference (hollow), and rings the weakest link.
Sections (netsections.rs). Ground and supply rails are told by name, a rail from a signal about a rail by its control words (VBUS_EN is not a rail). Signals group by a shared stem: the first part of the name, with its index kept when three or more nets share it (HDMI0, HDMI1), or a stem whose members differ by one letter of a known set (GHA, GHB, GHC). A net whose name places it nowhere goes to the block in blocks.json that holds most of its pins.
Datasheet pins: a second witness for nets
The copper says what is joined. A chip's datasheet says what each pin is. Until now the AI typed nets from datasheets by hand and nothing held the two against each other. dspins.rs gives the datasheet a file of its own and lets the program use it.
The file. For each identified chip the AI fetches the public datasheet during the run and writes reverse-flow/datasheets/<ref>.pins.json. It cites the document by name, revision and the page of the pin table, and lists every pad of the footprint, the exposed pad too:
| Field | Meaning |
|---|---|
part |
the reference in parts.json (refs: more references, for identical parts) |
mpn |
the orderable part the marking and package settle |
document |
name, revision and page (of the pin table) are required; number, table, url, file when there are any |
pins[].pad |
the pad number on the footprint |
pins[].name |
the pin name as the datasheet prints it |
pins[].type |
power_in, power_out, ground, input, output, bidirectional, open_drain, passive or no_connect |
pins[].net |
optional: the board net the datasheet's own application circuit names for this pin, when the default name would mislead |
pins[].expects |
optional: what the typical application shows at the pin, as a list of {"kind", "pin", "note"} |
The kinds of expects: capacitor_to_ground, capacitor_to_pin (a bootstrap or flying capacitor; pin names the other pin of the same part), tied_to_pin, tied_to_ground, resistor_to_pin (a shunt, a divider), resistor_to_ground, pull_up (a resistor to a supply the datasheet does not name), crystal, inductor, not_connected, and other with a note for what none of these says. "orTied": true on a capacitor_to_pin or resistor_to_pin says the datasheet also allows the two pins tied together (a shunt input tied to its return when the amplifier is unused).
A worked example, the gate driver U2 of the Driver run. Its marking reads DRV8323S, and the 6 x 6 mm 40-pin package with an exposed pad is the RTA package. The pin table is Table 6-3 of TI's SLVSDJ3 revision D (March 2022), pages 8 to 10, column DRV8323S. A few of its 41 entries:
{"part": "U2", "mpn": "DRV8323S in the RTA package (DRV8323SRTAR or DRV8323SRTAT, same top marking)",
"document": {"name": "TI DRV832x 6 to 60-V Three-Phase Smart Gate Driver", "number": "SLVSDJ3", "revision": "D, March 2022", "page": 8,
"table": "Table 6-3, Pin Functions, 40-Pin DRV8323 Devices, column DRV8323S (pages 8 to 10)", "file": "datasheets/ti-drv8323-slvsdj3.pdf"},
"pins": [
{"pad": "1", "name": "CPL", "type": "passive", "expects": [{"kind": "capacitor_to_pin", "pin": "CPH", "note": "47 nF, VM-rated, between CPH and CPL"}]},
{"pad": "3", "name": "VCP", "type": "power_out", "expects": [{"kind": "capacitor_to_pin", "pin": "VM", "note": "1 uF, 25 V, between VCP and VM"}]},
{"pad": "4", "name": "VM", "type": "power_in", "expects": [{"kind": "capacitor_to_pin", "pin": "PGND", "note": "0.1 uF and at least 10 uF local"}, {"kind": "tied_to_pin", "pin": "VDRAIN"}]},
{"pad": "6", "name": "GHA", "type": "output"},
{"pad": "9", "name": "SPA", "type": "input", "expects": [{"kind": "resistor_to_pin", "pin": "SNA", "note": "current shunt"}]},
{"pad": "25", "name": "nFAULT", "type": "open_drain", "expects": [{"kind": "pull_up"}]},
{"pad": "32", "name": "AGND", "type": "ground", "expects": [{"kind": "tied_to_pin", "pin": "PGND"}]},
{"pad": "33", "name": "DVDD", "type": "power_out", "expects": [{"kind": "capacitor_to_pin", "pin": "AGND", "note": "1 uF, 6.3 V"}]},
{"pad": "41", "name": "PAD", "type": "ground", "expects": [{"kind": "tied_to_ground", "note": "thermal pad, must be connected to ground"}]}
]}
No guessing. A chip counts as identified when parts.json tags its value (or part number) SEEN, MEASURED or DATASHEET: its marking was read. A pins file for any other part is refused, so a chip nobody could read gets no datasheet witness. When there is no public datasheet, or the marking fits several parts or packages, the answer is {"part": "U5", "available": false, "reason": "..."} in the same file, and the flow goes on without a witness for that chip. A file that cites no document, uses a type outside the list, names a pad the footprint lacks or leaves a footprint pad out is refused with the reason. The last two usually mean the footprint is not the package the datasheet draws, which is worth knowing.
The stop. auto stops once with AUTO NEEDS AI: datasheet pins, in the parts step after the footprints are snapped and before check parts, naming each identified IC or transistor of three pads or more that has neither answer. A chip whose footprint is a bare body outline with no pads (a package nobody could match, as on the CM5 run) is not asked for: there is nothing to name. A run that passed the parts step before this existed is asked in the connect step, before the schematic is drawn. datasheet status prints the same list at any time.
Into the schematic. datasheet apply (run by auto) writes each sheet into its part in parts.json: pins, pad to {"name", "type"} with the type as KiCad's (ground becomes power_in, open_drain open_collector, no_connect passive), and pinsSource, the citation. The schematic generator already draws a part with pins as a box grouped by function with its real pin names. Pins typed by hand, with no pinsSource, are replaced only when a sheet exists for the part; pins a sheet gave are taken back when the sheet is withdrawn.
Onto the nets the copper proves. Attribution names every conductor that no net of the pin map reached. Before, such a conductor took CU_<first pin> when two or more pins sat on it, and stayed unnamed with one. Now a conductor that a datasheet-named pin sits on takes that pin's net name, with one pin on it or many: net when the sheet states one, GND for a ground pin, otherwise <ref>_<pin name> (U2_GHA), so two chips never share a name unless a sheet says so. With several named pins on one conductor the name comes from ground first, then power_out, power_in, output, open_drain, bidirectional, input, passive. A no_connect pin names nothing. After attribution, dspins::stamp marks such a net: nameSource and source cite the document, nameTag is DATASHEET, and the net's tag stays MEASURED when every pin is seen joined in one piece and is DATASHEET otherwise. Nothing is joined by this: the pins on the net are the ones the copper puts there (copperEvidence, copperGroups), and two conductors that share the name GND are still two pieces with a hidden connection between them. Every net, named by a sheet or by somebody, also lists the datasheet pins on it in datasheetPins, in nets.json and in net-evidence.json. A net somebody stated keeps its name and tag. auto runs attribution again whenever the names the sheets give change.
The witness: connect witness
witness.rs holds every pin of every chip that has a pins file against the copper graph and records agree, disagree or not visible. It runs at the end of every connect attribute and by itself as connect witness. It changes neither copper nor nets.
What the copper shows. A pin sits on a conductor when its pad has a solid contact with it (attribution's tier 1). Two conductors are one when verified vias join them (a link of tier 2). An unsettled pad edge, one unconfirmed via and the pin map are not seen, so they can neither agree nor disagree. What says a conductor is ground or a supply is always another pin on it, never the pin under test: a ground or supply pin of any pins file, or a pin with no datasheet type that somebody put on a net with a ground or supply name.
The checks, for a pin with a solid contact:
| What the datasheet says | Agree | Disagree |
|---|---|---|
type ground |
another ground pin on the same conductor | a supply pin or an output on it |
type power_in, power_out |
a capacitor from the conductor to ground copper | a ground pin on it; for power_out, another driving pin on it |
type output, open_drain |
the copper reaches other pins and no ground, supply or second output | ground, a supply (for output) or another output on it |
type no_connect, not_connected |
nothing else on the conductor | any other pin on it |
| pads of one name on one part (VDD, VSS) | on one conductor | |
capacitor_to_ground, resistor_to_ground |
such a part from the conductor to ground copper | |
capacitor_to_pin, resistor_to_pin |
such a part from the conductor to the other pin's conductor (any ground copper when that pin is a ground pin) | both pins on one conductor, unless orTied |
tied_to_pin, tied_to_ground |
on one conductor | tied_to_ground on supply copper |
pull_up |
a resistor to copper a supply pin sits on | the only resistor there goes to ground copper |
crystal, inductor |
such a part on the conductor |
An input, bidirectional or passive pin with no expects has nothing copper can show, and is not visible. A pin's verdict is disagree if any check disagrees, agree if one agrees, else not visible.
Absence is not disagreement. An inner layer may hold the capacitor's other end or the tie the datasheet asks for, so a part or a join that is not seen is not visible. disagree is kept for what the copper positively shows wrong.
The net list as a second pass. The same checks run on nets.json alone: a pin "sits" on its net, a net is one conductor, and a net's name stands for its pins (unless a datasheet pin gave the net that name, which would let a pin prove itself). Here the list is a whole claim, so two pins the datasheet ties that the list keeps on two nets do disagree. This verdict is recorded apart as netList (agree, disagree, not stated). It is circular wherever somebody typed the nets from the same datasheet, so it never counts as the copper's agreement. A third pass runs on the list as it was stated before the copper moved pins (inferredPins): where an expectation held as stated and no longer holds because the copper moved a pin of the part involved, that is a netList disagreement naming the move. Either the copper there is misread, or the part somebody took for the datasheet's capacitor is not it.
Findings. Each disagreement is a finding in detect/witness.json with a picture under evidence/witness/ and a test. The picture has two panels: the board photo close up at the pin (red), and the whole face with the pin, every pin it disagrees with (yellow) and the vias that join them (cyan). The finding says how the copper joins the pin to what it disagrees with: the same copper, or which verified vias. evidence/witness/findings.md lists them with every pin's verdict and reason. The test (T-witness-<pin>) is named on the net in nets.json, and tests generate writes it with the pins to probe; it goes when the finding clears. A finding never rewrites copper or nets.
Unresolved copper. When one piece of copper touches three or more pins of a chip without settling any, and the datasheet says pins among them cannot share a net (ground and a supply, a supply and an output, two outputs), the witness says so with a picture (unresolved-<ref>.jpg). That is the reader's limit at fine-pitch pads, not a claim about the board, and it is why those pins are not visible.
Per net. Each net's row in net-evidence.json gains datasheet: witness (agree, disagree or none), the counts, netList, the datasheet pins on it and its findings; nets.json gains datasheetWitness. nets draw copies the witness into net-confidence.json and into the Datasheet column of nets/index.md. It stands beside a net's level and never changes it.
What it found. Driver run (the board this was built on), U2 DRV8323S, 41 pins: 4 pins have a solid contact, 0 agree, 1 disagrees, 40 are not visible; 1 of 77 nets has a witness, 35 carry a datasheet pin. The disagreement: the exposed pad's copper (the bottom pour, reached through a via in the pad) is joined through 17 verified vias to a top pour holding C19.1 and D4.1, which the net list calls VIN and VIN_LOGIC, and the same bottom pour holds Q5.5, called VIN, through a via in that pad. The photo does not settle whether the vias, the bottom pour or the typed names are wrong; the test does. Against the net list: 21 agree, 2 disagree, 18 not stated; the two are CPL and CPH, whose flying capacitor the net list took to be C37 until the copper moved C37.1 onto that same pour, and the picture shows two capacitors beside those pins that are likelier candidates. Board 10 pilot run (not used while building this), U1 USB2532, 37 pins: no pin has a solid contact, all 37 are not visible, and the witness names why: one piece of copper touches 34 of the pins. So the witness is only as good as the copper at the chip's pads, and at 0.5 mm pitch that copper is mostly unresolved today.
Nets from the parts first: connect pinout
The owner's order (2026-11-11): when identifying nets, identify the part, then its orientation, then its pinout. Until then the nets started from copper alone, and a pin got a net only when the copper at its pad was resolved, which at fine pitch it mostly is not. pinnets.rs builds the nets from what the parts are, before attribution, and attribution then holds them against the copper like any stated net.
The chain, per part, in detect/pinout.json:
| Link | Values |
|---|---|
| identity | marking read; package only |
| orientation | pin 1 mark seen; polarity mark seen; not needed (two pads and no polarity, one pad, or a USB Type-C receptacle, whose ground, VBUS and data read the same turned half round); not determinable; not settled |
| pinout | the datasheet pin table (datasheets/<ref>.pins.json); the standard pinout of a connector whose type is seen (USB Type-C, by the footprint's pad names, as KiCad's Connector:USB_C_Receptacle_USB2.0_16P names them); names typed into the part's pins (silkscreen beside a header's pads); package family only (a three-lead transistor, a polarised two-pad part), which gives pin names and no nets; none |
A pinout on a chip turned the wrong way is worse than none. A chip with a pin table whose pin1.tag is not SEEN, MEASURED or DATASHEET gives no nets, and auto stops once with AUTO NEEDS AI: orientation of chips with a pinout, naming each. The answer is the mark that was seen, or {"mark": "<what was looked for>", "tag": "UNKNOWN"}: not determinable, which is an answer and still gives no nets. check pinout fails on the same list.
The rules. Every pin with a known function gets a net, and its record in the net's pinout.pins names the rule, the source and the tag:
| Rule | What it does | Tag of the pin |
|---|---|---|
ground |
every ground pin of every pinout joins GND | DATASHEET from a pin table or a connector standard, INFERRED from a typed name |
pin function |
the pin is on a net named for it, <ref>_<pin name>, with the pads of the same name on the same part (the four VBUS pads of a receptacle, both D+ pads). A supply name typed beside a header pad is the rail's own name, shared by every pad that carries it |
DATASHEET; INFERRED for typed names and for CC and SBU on a receptacle whose orientation is not settled |
datasheet tie |
tied_to_pin and tied_to_ground: the pin joins the other pin's net |
DATASHEET |
claimed part |
a pin with capacitor_to_ground, capacitor_to_pin, resistor_to_ground or resistor_to_pin takes the nearest free capacitor or resistor within 3.5 mm of its pad, near pad on the pin's net, far pad on ground or the other pin's net. A pin with crystal takes the nearest crystal within 8 mm: terminals to the crystal pins by nearness, the case pads of a four-pad crystal to ground, and each terminal's nearest free capacitor as its load. Nearest claims are served first, a part serves one expectation |
INFERRED |
| no connect | a no_connect pin goes to nets.json noConnect |
Rails of different parts are not merged by rule: U1_VDD33 and J3_VBUS stay apart until copper, or a name both carry, joins them. The two reaches are policy, not fitted to a board.
Merged on copper. Once attribution has read the copper, auto builds the pinout nets once more: nets with pins on one conductor (solid contacts, conductors joined by verified vias) become one net, under the name that says most (GND, then a rail name typed from silkscreen, then a supply, then the net of a driving pin). The merge is recorded in detect/pinout.json mergedOnCopper with the pins that show it. Three merges are refused and left for attribution to raise as a short, in mergesRefused: ground with a supply or an output; a supply with an output, or two outputs; and two differently named pins of one part, unless one is a datasheet input strapped to its own chip's ground or supply. Neighbouring pads read as joined are far more common than a real tie.
A net is DATASHEET when every pin on it is there because a document states it, and INFERRED, with a test, as soon as one pin is there by place or by a typed name. Nothing built here is SEEN or MEASURED. Pins somebody stated a net for by hand are left to them. The nets carry fromPinout, and pinoutKey in nets.json says what they were built from; auto builds them again when the pin tables, the orientations or the places of the parts change.
Copper confirms or contradicts. Attribution runs next, unchanged: a pinout net every pin of which is seen joined in one piece becomes MEASURED; pins the copper joins to it are added; a pin on copper named for another net, or a pin the copper moves, makes the net INFERRED with contradiction and a test. Where fine-pitch pads sit on one unresolved piece of copper, the pads stay unsettled, the pinout decides which pin is which net, and the witness lists those pins per net as copperCouldNotSeparate.
Consistent with the schematic. Two things the first runs on other boards showed. A pin the datasheet leaves unconnected that the copper joins to a net is on that net in nets.json, so after attribution its noConnect entry (one the pinout wrote) is removed and the net names it in unconnectedPinsOnCopper; before, the schematic drew a no-connect flag on a pin nets.json had on a net, and check schematic failed. And several pads of one name that drive (two VCAP pins of one regulator) are one output inside the chip: datasheet apply gives the first the driving pin type and the rest passive, as KiCad's own symbols do, so the ERC does not call the chip's own tie a conflict.
Wherever the run stands. auto builds the pinout nets not only on the way to attribution: a run at the review, the tests or the report whose pin tables, orientations or copper are newer than its pinout nets, or that never had any, gets connect pinout first, and the flow then goes back by itself. The orientation stop names each chip, its package and place, what mark to look for on that kind of package, and the crops that show it; a pin1 that records a mark with no tag is asked for its tag.
Shown apart. net-confidence.json gives each net knownFrom (pinout only, pinout, confirmed by copper, stated, copper); nets/index.md has a Known from column; the report tables gain "Nets by how they are known"; the schematic draws what is not seen in copper dashed, as before; bench plan puts the probes of INFERRED pinout nets first.
Board 10 (a USB hub; copy of the finished run): 10 nets and 22 pins on a net before; 55 nets and 119 pins after connect pinout and attribution. Of the 50 pinout nets, 31 are DATASHEET and 19 INFERRED; of their 102 pins, 68 are there by a document and 34 by inference (11 parts claimed by place, the CC and SBU pads). Copper added one pin to GND and one to U1_VDD33. One disagreement: the copper joins C9.2, claimed as the VDDCR12 end of that pin's capacitor, to the exposed ground pad, so either the claim took the capacitor the wrong way round or the copper there is misread; the merge is refused and listed. The 13 pin headers have no pinout: their silkscreen names were not typed into pins in that run.
Board 4 (five MOSFET channels, an LM2596S, a processor module, a USB-C port; copy of a run stopped at check mimic, so only connect pinout and connect attribute were run): 4 nets and 53 pins before, all copper with no names; 24 nets and 87 pins after, 7 DATASHEET and 17 INFERRED (the MOSFET pins are typed from a sibling part's sheet, so INFERRED). The copper joined three source nets and the regulator's ground pin into GND (55 pins, 46 of them seen joined in one piece), and two merges were refused and raised as shorts: Q503's gate with its source pads, and the receptacle's CC2 with its ground pad.
The router
connect route is optional (see above). route.rs works on a 0.05 mm grid per copper layer. Every piece of copper stamps a keep-out for the centre line of the narrowest track: the copper's own shape, plus the clearance, plus half a track. A cell holds a count, a sum and a sum of squares of the net ids stamped on it. All three agree only when every stamp is the same net, and subtracting a stamp restores the cell exactly. A wider track or a via tests a ring around the cell. This is exact, which is why the router's own copper produces almost no DRC errors.
Only what cannot move blocks: pads, holes, vias seen on the board and the board edge. Seen copper of another net costs extra to cross, six times its length if its net is unknown and twelve if known. Treating pours as walls left 235 of 245 connections open, because the copy tiles nearly all of both outer faces. A real via passes through a pour and the pour clears around it, and the fill here is computed after routing, so it clears too. The length that still crosses seen copper is reported, because it measures how far each stackup departs from the photo.
A connection that finds no path is searched again with routed copper passable at a cost. The connections on that path are ripped up, along with whatever landed on them, and queued again; the congested tiles get dearer. When passes stop improving, that stackup is the limit and the caller jumps two layers.
Inner layers are planes or signal layers. A plane net needs only a via per pin. On a stackup of n layers, the first plane net sits next to the top, the second next to the bottom, and everything between is routable. So 4 layers has no inner signal layer, and 6 has two.
How confidence is computed
The review used to stop when a pass changed nothing, with levels (High, Medium, Low) typed into work/confidence.json. Vias were rated Medium while up to a third of them were false, so a self-assigned level has been proven wrong. Now confidence.rs computes a number from 0 to 100 per category from the run's files, every time it is asked. Nothing reads a score back: review.json and confidence-scores.json only record what was computed, and check review recomputes and compares.
A category's score is the lowest of its ceilings, rounded down to 0.1. Rounding down means 89.96 prints as 89.9. confidence prints every ceiling and its inputs, and the report carries the same text.
The audit ceiling is the only one that needs independent evidence:
- No complete audit: 40 (
NO_AUDIT_CAP), and the limit says "no independent evidence". This number is policy, not a measurement: a claim nobody checked sits below a coin toss. For layers the cap is 30. - A complete audit: for each stratum,
100 x L x u, and the lowest stratum sets the ceiling. Lis the Wilson 95 percent lower bound on the share correct, withz = 1.96 x sqrt((N-n)/(N-1)),p = k/n,L = (p + z^2/2n - z sqrt(p(1-p)/n + z^2/4n^2)) / (1 + z^2/n).nis verdicts on items unchanged since they were judged,khow many of those areok,Nthe stratum's current size.cannot_tellcounts as not ok. With no errors from a large population it takes 35 items to reach 90, so 5 of 5 from 1000 is worth 56. Auditing all of a small population givesz = 0andL = k/n: no sampling error is left, only the answers.uis the share of the stratum's current items unchanged since the draw. A new or changed item was never eligible for the sample, so the bound says nothing about it.
Measured ceilings come from the files and images. They can only lower a score: the program checking its own reading is not independent, and some rest on tags an AI typed, which is why none can lift a score past the audit.
| Category | What is audited (strata) | Measured ceilings |
|---|---|---|
| identity | each part's stated identity | parts with an identity tagged SEEN, MEASURED or DATASHEET / parts |
| values | each part's stated value | parts with a value so tagged / parts with a value |
| blocks | each part's membership of its block | parts in a block so tagged / parts |
| placement | each part's outlined pads against the photo | parts located by snap.method pads, body centre or hole centre / parts; layers against the photos |
| copper | random points of each face where the photo can show copper, away from pad boxes and from anything but plain mask (see below): copper where the copy has copper, bare where it has none |
drawn mm2 / mm2 read as islands, lowest face; layers against the photos |
| vias | kept vias, and dropped candidates with no via within 0.3 mm |
100 - share of vias on pale pixels (more than a quarter of the square reaching 0.35 mm around the via with min(R,G,B) > 135, a rule from these photos and an upper bound, since a dome's highlight is pale too); layers against the photos |
| connectivity | for each pin with a net, the join of its pad to copper the copy drew; for each island, one join it is seen to make; pins and islands that show none counted as not correct (see below) |
named pour area / pour area; named track length / track length; pins agreeing with their copper; connections KiCad finds closed; connections joined by seen copper, or the outside-evidence bound if higher |
| layers | the layer count and each plane, outside evidence only | below 30 without it: min(30, closed / connections at the proposed stackup) |
Copper samples points rather than kept items, the claim says what the photo shows at the point, and vias sample what was dropped as well as what was kept, because a sample of kept items cannot notice what is missing. Connections joined by seen copper are 1 - connections routed / (pins - nets): what the router had to add is by definition not what the photos showed.
Layers against the photos. The owner's goal is that what the KiCad layers draw agrees with the photos wherever a photo shows that layer. fidelity.json, written by a separate program, limits copper, vias and placement. It is read by what its keys say and unknown fields are ignored: a figure (score, agreement, fidelity, match, value, percent, pct, overall) under a key named for the category counts, copper also takes any *.Cu key and placement parts or footprints; failing those, a top-level figure. The lowest figure found is the ceiling, on a 0 to 100 scale, or a fraction if every figure found is at most 1. With no file, no figure for the category, or a file older than what it scored (by the hashes in its inputs, files or hashes, otherwise by file time), the input is reported missing and the category is capped at 40.
Overall is the lowest category. An average lets 100 on placement hide 20 on connectivity, and a product falls with the number of categories instead of with what is wrong. A copy is as trustworthy as its weakest part, and the lowest category names what to fix next. It is now reported as two figures; see the next section.
Two scores
One overall figure was always 0: connectivity, layers and the values of unmarked parts cannot be earned from photos, so every board ended STALLED at 0 and the number showed no progress. Nothing about the formulas was wrong, so none of them changed. What changed is the grouping (twoscores.rs). Every ceiling is one of two kinds, and each kind has its own overall figure:
- The photo score is the lowest ceiling photos can earn. The review loop's target (90) applies to it.
- The bench score is the lowest ceiling that needs the real board or an outside document. It is printed beside the photo score with what would raise it, and the loop never waits for it.
A category's score is still the lowest of all its ceilings, so the lower of the two scores is exactly the lowest category, which is what the single overall figure was. A test holds this on every state of the fixture (regrouped).
| Category | Photo ceilings | Bench ceilings |
|---|---|---|
| identity | audit; identities tagged as seen | none |
| values | audit of marked parts; marked parts with a value tagged as seen | audit of unmarked parts; unmarked parts with a value tagged as seen |
| blocks | audit; parts in a block seen on the board | none |
| placement | audit; parts located by a measurement; layers against the photos | none |
| copper | audit; copper read that reached the copy; layers against the photos | none |
| vias | audit; vias not on pale pixels; layers against the photos | none |
| connectivity | audit of the joins a crop shows (islands with their share that show a join, pins as claimed); pins that agree with their copper | pins shown joined, by a crop or a bench probe; connections the photos show or outside evidence proves; copper with a net, by area and by length; connections KiCad finds closed (only on a routed copy) |
| layers | none | all of them |
A ceiling not listed as bench is a photo ceiling, so one added later counts towards the target until someone decides otherwise.
How the two mixed categories were taken apart.
Values. A photo shows a marking and nothing else of a value. The population has two strata, marked (the part has a marking that was read) and unmarked, and the old ceiling "values tagged as seen" is computed per stratum. The audit samples and bounds each stratum on its own, as it does for copper and vias. The marked stratum is the photo figure and the unmarked one the bench figure. Neither is easier than the pooled figure was: the pooled share lies between the two.
Connectivity. The audit's pin figure was 100 x L x u x (pins with a join / pins with a net). The first three factors say whether the joins put forward are right, which a crop can show. The last says how many pins show no join at all: connector pins named by a pinout, pads under their part, pads joined through a via. Those need the board. So the photo ceiling is the lower of the island figure, unchanged with its own share, and 100 x L x u for pins. The bench ceiling is the old pin figure in full, plus the pins a recorded bench probe found joined: 100 x (L x u x pins with a join + pins probed) / pins with a net. With no probe recorded the lower of the two is the old figure. With no finished audit the two caps are min(40, 100 x islands with a join / islands) and min(40, 100 x pins with a join / pins with a net) + 100 x pins probed / pins with a net, whose lower value is the old cap. An island stays a photo matter because it is copper the photo shows: one that joins nothing was misread or its via was missed.
Copper with a net is a bench ceiling because a name reaches a piece of copper only through a pin it is seen joined to. Copper that reaches no pin on the outer faces, a track between two vias for instance, gets its net from a probe or an X-ray. "Connections KiCad finds closed" judges a routed copy and exists only when connect route was run.
A run that never routed. "Connections the photos show" is 1 - hidden / (pins - nets), with the hidden connections counted from nets.json (see "Hidden connections are left unrouted"), plus those a recorded bench probe found joined. On a routed copy the router's own count stands in for hidden, capped at pins - nets: the router also counts copper islands that reach no pin, and uncapped that printed a negative number of seen connections. For layers, an UNKNOWN count scores 0, because nothing is stated to be confident in, and a count stated from outside evidence sits under the cap of 30 until an independent verdict with that evidence attached raises it, as before.
What the loop does with them. TARGET MET, STALLED and the rise of 0.1 are all read on photo figures. A pass recorded before the two scores holds no photo figure, so the first pass after it counts as a rise and the next one can stall. A category waits for an audit only when an audit is what holds its photo figure, and a category with nothing to audit (a board with no vias) waits for none. confidence, review pass, check review and report print both scores; report.md carries the line with both, and may not claim a confidence above the higher of them.
What the photo score still cannot reach. It is the lowest of seven categories, and each has a ceiling an honest run can leave low. Blocks reach the target only when silkscreen or a marked part with its datasheet backs each block, since a function is otherwise an inference. Identity needs every part read from its crop, and the default is UNKNOWN. The copper and connectivity audits count cannot_tell against the score, and under parts, silk and glare a crop often cannot tell. Vias and placement are capped at 40 when fidelity.json has no figure for them. So a careful run on good photos can raise the photo score, and a thin one leaves it at 0, as the CM5 run does (blocks 0.0, identity 4.6): the number now moves with the work, but it does not say the copy is right inside the board. That is the bench score.
The bench passback
bench plan and bench ingest (bench.rs, docs/bench.md) are optional and come after the run is complete. The plan orders one continuity probe per hidden connection by how many inferred or unknown items a joined answer settles: the connection, every pin of the far piece, and the net itself when it is that net's last hidden connection. Each probe names two pads, the easiest to touch of each piece (a plated hole or test point before a connector pin before a small pad; never a pad under its own part), and has a picture: for each pad a close view with a ring on it and the whole face with a cross, the bottom face turned back to how the person holding the board sees it. After the probes come the unmarked parts whose value is not known, the layer count, and the other measurements tests.json calls for.
bench ingest --file <results.json> records each answer in bench/measured.json with who measured, when, with what, and the hash of the file it came in, which is kept in bench/ingested/. A probe found joined makes its connection MEASURED; a net whose every hidden connection is joined is tagged MEASURED in nets.json. A measured value is written to the part with fieldTags.value: MEASURED and where it came from. A layer count with its method becomes the layer statement. Nothing else is touched, and the scores move because confidence computes them again: the two pads of a joined probe count as pins shown joined, the connection counts as proven, the part counts as a value tagged as seen. An open answer is recorded and flagged, and upgrades nothing. An answer for a probe that is not in the plan, or whose connection is no longer in nets.json as planned, is refused with the reason. connect attribute writes net tags again from the copper, so it stamps the recorded answers back afterwards. Ingest changes artifacts, so the checks that read them go stale; auto runs them again.
auto and next
auto runs every program-only step in order, each as the flow's own command, and ends with one of three lines: AUTO NEEDS AI: <input> (exit 2) with hints that name the file or command wanted, AUTO COMPLETE (exit 0), or AUTO FAILED (exit 1). next prints the same next action without running it. Both come from one function (auto::plan) that reads the files as they stand. The AI's inputs it stops for are: the scale (scale --body or scale --holes), parts.json after parts init, blocks.json, whatever a failed check lists, the independent audit verdicts a review pass waits for (each sheet, sample and the exact command), and report.md with its line. Pin names in nets.json are welcome at any time and never waited for. layers trace and check fidelity feed the review and are not gates: if they fail the flow goes on and the review counts the missing figure against the score. A command that passed and is still next ends the run with AUTO FAILED instead of looping. tests generate drops a generated test whose items are all gone, so check tests cannot fail on a test nobody wrote.
The run video
video build (run by auto once every step is done) makes a video of about two minutes from the run folder. It is not a step of the flow file and gates nothing: when it fails, auto records the reason, ends with AUTO COMPLETE: every step is done; the video was NOT built: <reason>, and does not try again until the report passes on changed files.
- Who computes what. The program writes
reverse-flow/video/work/facts.json: both scores as the files stand with the ceiling that limits each category, the review's verdict, each step's state, the count of hidden connections, every part's outline from the footprint it was built from, and whetherfidelity.jsonstill matches the copper and parts it scored. The tool (tools/video/, Python) reads everything else straight from the run's files. Each figure shown or spoken goes through one function that records its source;video.jsonlists them per scene. - The scenes are fixed (
scenes.py,SEQUENCE): title, photos, outline and scale, parts, blocks, copper and vias, the KiCad copy, nets, schematic, scores, tests, end card. Each scene checks its own inputs and is left out with a stated reason when one is missing. A scene that raises an error is left out the same way; the build goes on. - Pictures. The camera moves over the run's own pictures: the photos,
board/top.jpgandbottom.jpg,evidence/outline-*.jpg,evidence/fidelity-F_Cu.jpg,nets/confidence-<face>.jpg(cut to its board panel by the layoutnets drawuses, and checked against the picture's size),nets/net/<name>.jpg,schematic/*.png. Boxes on parts, block outlines, copper, dimension lines and the test list are drawn at build time fromparts.json,blocks.json,copper.json,scale.jsonandtests.json. KiCad's picture of the copy iskicad-cli pcb renderof the board file, cut to the board and laid over the photo by its outline; when the two outlines differ in proportion by more than 2 percent (a part overhangs the edge) it is not used. With nonets/folder the copper is coloured bynetEvidence.rungfromcopper.json. - Time. Each narration line is one take of the voice, so a caption starts where its line starts. Lines marked as required are always spoken; the others carry an importance and are added, most important first, while the total stays under 125 seconds. If the total is under 118 seconds the scenes are held longer in proportion to their weight, none more than doubled. A video outside 110 to 130 seconds is still written and
video.jsonsays why. - Frames are a pure function of time, made by two processes side by side, each feeding its own ffmpeg (H.264, rate capped at 3.5 Mbit/s); the two parts are joined without encoding again and the voice is added as AAC.
The audit
audit draw lists the category's items, each with a hash of the fields its claim rests on, so a new field added by another program does not make an audit stale but a moved via does. The seed is sha256(category, population hash, draw number): nobody chooses it, and the same files draw the same sample. Each stratum gets a random order from the seed, and a sample is a prefix of that order, so a larger sample extends the old one and keeps its verdicts. The default size can reach the target with one wrong answer.
audit verdict records ok, wrong or cannot_tell per item, with who and when, in an append-only verdicts.jsonl. It refuses a verdict on the wrong sample, on an item not in the sample, on an item changed since the draw, and from a thread that the ledger shows produced the run. One answer per item, replaced only by one with a new evidence file.
Read back, nothing in the audit is trusted. The seed and every sampled item must be what the population gives, or the status is tampered and the audit counts for nothing. A sample counts only when complete, so bad answers cannot be skipped. A current sample cannot be redrawn, so a bad one cannot be thrown away; only a changed population or a voided sample allows a new draw. About a tenth of each sample (at least 3 items) is planted false claims: an empty spot called a via, a real copper point stated the other way round, a part with another's identity, outlines shifted by 2 mm and 1.5 mm, a join whose other end is drawn where there is none. ok on one voids the sample.
What this does not stop. Anyone who can edit files can work out the controls from the seed, or write verdicts in another thread's name. The checks catch a careless auditor and a casual edit, not a determined forger. An outside evidence file is held to its hash, but the program cannot judge what the file shows.
The copper audit
The first copper audit scored 9.4: 31 ok, 6 wrong, 79 cannot_tell of 116 crops. Its auditor could judge dotted pours, pads and dark grey bands and nothing else, so the score measured what a raw crop can show, not whether the copper was right. Four things changed. None of them makes a wrong item pass, and cannot_tell still counts against the score.
The claim. It says what the photo shows at the ring's centre: copper under the mask, or bare board with no copper under the mask. The auditor has the photo in front of them, not the copy.
The panels. Each crop has four, from copper_crop, whose only inputs are the photos and the position, so it cannot carry the copy's answer and it treats a planted claim as it treats a real one. Top left is the raw photo, 6 mm across. Top right is the same window stretched: the mean of the face's two photos, because their grain is independent noise (see "The two photos of a face"), blurred lightly, and each colour scaled between its 3rd and 97th percentile over plain mask in the 12 mm round the point. Scaling each colour on its own is what does the work: copper turns green, and laminate turns purple or dark blue, on both faces, with no threshold chosen for the point. Using plain mask alone stops silk, metal and parts setting the range, and a minimum span of 8 stops flat mask showing nothing but grain. Bottom left is 18 mm across, to follow a gap line to the ring. Bottom right is 3 mm across, for a centre near an edge. The tracer's lighting model is not used, because the panels must not lean on the method they check. The contact sheets show the top right panel.
What is left out. A point goes before the draw only when a part's pad box covers it, as before, or when the pixel or one within fidelity::BESIDE_PX is not plain mask, the rule check fidelity scores copper with. The class map comes from the photos each time and is never read from a file, the copy's copper is not an input, and a test holds that emptying the copy leaves the set unchanged. Dark mask, gap lines, track edges and bundles are all plain mask, so they stay. audits/copper/exclusions.json gives the count by material, by face and by the copy's answer, and how the rule changes the share of points in the regions read worst: per face, the quarter of 5 mm tiles where the copy disagrees most with fidelity::copper_read. confidence and the sheet state the figures. On the Driver board, the rule takes 1211 of 6694 points (18.1%: silk 778, exposed metal 227, part body 145, hole 61), 18.2% of copper and 17.8% of bare. Pad boxes take 2200 of 8894 before it. The worst tiles hold 19.0% of the points before the rule and 17.7% after. The tilt is mostly the top, 16.0% to 13.1%, where the worst tiles are crowded by parts and silk. The bottom goes from 20.5% to 19.6%.
The controls. A control used to be a point on the 0.05 mm grid deep inside a large pour, always called bare, so its coordinates, its id and its picture all gave it away, and an auditor who answered ok to every copper claim was never caught. A control is now a real point of the population, at the position the population gives it, stated the other way round. It has the id, position and record a real item would have, and only its answer is false. Controls come from the far half of each stratum's order, so extending a sample does not reach them.
A planted claim must be false beyond doubt, or an honest auditor voids the sample. The first rule asked only that the copy and fidelity::copper_read agree at nine points out to 0.3 mm. On the CM5 that planted two samples' worth of doubtful controls, and both samples were voided by auditors who had looked properly. Six of the 22 lay on a part's body, because the class map was built as if only the top carried parts (it now measures that with photomat::carries_parts, as check fidelity does). One lay in a letter of silk. One lay in a teal triangle inside a lighter stitched pour, which both readers call copper because they share a threshold, and which a person calls bare because the pour beside it is lighter. Two readers agreeing is not the same as the photo being plain.
Planting::doubt now gives the first reason a point cannot carry a planted claim:
- within 1.0 mm of the board edge, 0.8 mm of a part's pad box, or 0.9 mm of a via or a via candidate on either face;
- out to 0.6 mm round the point (
CONTROL_REACH_MM; the ring is 0.4 mm), any pixel that is not plain maskBESIDE_PXclear of silk, metal, holes and bodies, or where the copy or the threshold scorer reads the other surface; - fewer than 5 percent of either surface, by the scorer, in the plain mask of the 12 mm the panel is stretched over, so there is nothing to compare the point with;
- the green round the point not clear of the middle of the stretched panel: its 5th percentile under 0.7 of the way from the panel's dark level to its light level for copper, or its 95th over 0.3 for bare board. These are the levels the auditor's top right panel is drawn with;
- in any of red, green and blue where the two surfaces' medians in that window lie 8 levels apart, the median round the point less than 0.6 of the way from the other surface's median to its own. Round a point in the teal triangle the median is 123 in green, which is the copper median of its window, and 53 in red, which is the bare median (copper 75): green says copper and red says bare. Plain pours sit at their copper median in both.
None of the 22 controls planted on the CM5 passes: 6 for a part, 6 for a via, 3 for the edge, 3 for silk or a body, 2 for the copy's own edge, 1 for the scorer's, and the one in the teal triangle that voided the second sample for its level in the panel. Of 755 points looked at there, 20 pass, every one of them copper stated as bare: no bare board on that board is 1.2 mm wide and clear of everything else. Measuring which faces carry parts also takes 464 points on part bodies out of the population there (1975 to 1511), which the photo could never show copper at. exclusions.json gives the count by what the claim states and by reason refused, and audit draw says when only one way can be planted, because an auditor who calls every claim of the other kind ok is then not caught. A draw that cannot plant the number a sample needs refuses, says how many points passed and what refused the rest, and leaves the current sample as it was.
audit preview [--n 24] --file <folder> writes crops and a separate key.json for checking legibility, from points clear of the sample and the controls, outside the audit. On a blind tuning sample of 24 points judged by the method's author, raw panel first, then all four, 9 could be decided from the raw panel and 19 from all four, and 18 of those 19 agreed with the copy. Only an independent auditor's figure counts.
The connectivity audit
An island's claim used to be its net: "outlined copper isl-bottom-292 is on net GND". No crop shows a net. On the CM5, 60 of 62 island claims came back cannot_tell, one ok and one wrong, so the island figure measured what a crop can show and nothing about the copper.
The claim is now one join the flow holds for that island, worded as what the crop shows: the copper outlined in pink reaches the pad boxed in blue with no break; it is one piece with the via in the blue ring; it is one piece with the copper outlined in blue, with no gap at the blue cross. The joins are read from what connect attribute wrote, and only where they are near enough for one crop to show both ends (JOIN_REACH_MM, 0.3 mm from the island's own drawn copper):
- a pad whose contact attribution kept as solid on this island's node, on this face, drawn in the outline and at the place the photo shows its metal (
detect/attrib/measure.json,core); - a valid via attached to this island's conductor on this face (
detect/attribution.json,vias); - copper stitched to one of the island's pieces, at the place the stitch was read (
detect/connect-graph.json,stitches), and other copper drawn from the same piece.
An island usually has several. The one claimed is the one nearest a name, in the order of attribution's own ladder: a pad before a via before other copper. Among several of that kind a hash of the island's id decides, so nobody picks. The item's hash covers the island's shape and net and the other end's name and shape, so a changed join makes the verdict stale. The net is no longer in the words. No claim of this audit states a net: a photo shows joins, and a net's name rests on whatever its source cites.
The crop is centred on the join. Its first row shows the island as far as 12 mm allows, raw and marked, and its second row is 3 mm across, or wide enough to hold the pad or the via's ring. A track is outlined beside its edges, not drawn down its middle, so the mark does not cover the copper.
Islands with no join. An island that touches no pad, no valid via and no other copper within reach has no evidence a crop can show. It is not put forward as a claim, least of all a claim that it is on a named net, and it counts as not correct: the island stratum's figure is 100 x L x u x (islands with a join / islands). On the CM5 that is 1864 of 2335, so 0.798. If no island has a join, or the files connect attribute writes are missing or were measured on other copper (copperSha), the factor is 0.
The island controls. A join stated of another island's marks would prove nothing, so an island's false claim keeps its outline and words and has its other end drawn where there is none: a pad's box on plain mask clear of every part, a via's ring on plain mask with no via or candidate within 1.5 mm, a second copper outline wholly on a part's body. A face with no parts cannot carry the third kind.
Pins. A pin's claim was its net too: "pin J2.86 is on net GND". The first independent audit of the join claims (CM5 run 5) settled 56 of 61 island claims from the crops and returned cannot_tell on all 49 pin claims, because a boxed pad shows no net. A pin's claim is now the same kind of join, read from the pad's side: "pad boxed in pink (pin C112.2): is joined to the copper outlined in blue (isl-top-21) with no break between them". The evidence is the pad joins above: a contact attribution kept as solid (connect-graph.json, contacts, tier 1, on the pad's own face), to a drawn pour or track of that node within JOIN_REACH_MM of the pad's outline. The box is the pad where the copy read its metal, the crop is centred on it, and it has the close row. Copper read from the photo comes before a stub the copy drew to reach the pad; among several a hash of the pin decides. The net is not in the words and is in the item's hash, so a pin moved to another net is another claim.
Pins with no join. A pin with a net and no such join has nothing a photo can show: a connector pin named by a datasheet's pinout, a pad whose ring did not settle it, a pad joined only through a via. It is not put forward, and it counts as not correct: the pin figure is 100 x L x u x (pins with a join / pins with a net). The status line gives the count, and beside it how many of those unproven pins are on a net that cites a source outside the photos (a source, or a nameSource that is a citation and not none, an inference or one of attribution's own class words other than datasheet). Those are named, and named by something a photo cannot confirm, so they stay unproven here: only outside evidence on the board itself raises them. The outside-evidence bound on pins is scaled by the same share, since evidence on the pins judged says nothing of the rest.
No cap above what an audit could reach. With no finished audit a category sits at 40. For connectivity that cap is now min(40, 100 x the lower of the two shares), so a copy in which nothing shows a join does not sit at 40 for want of an audit that could only give 0.
The pin controls. A pin's net swapped for another pin's was the old control. A join stated in another claim's words proves nothing, so a pin's false claim keeps its words and its copper and has its own pad's box drawn on plain mask clear of every part, where no pad is. Its crop is centred on that empty box, as a real pin's is on its pad. A draw plants a false pin join as often as a false island join, however many more islands there are.
Pad boxes. The box is the library pad's size at the place the copy read the pad's metal. Where a box spans two parts or sits on silk, the part is wrong, not the crop: on the CM5 run 3, C98 is an 0805 laid across the gap between two smaller capacitors, one of which is also in the copy as C66, and C43 is a silk colon read as an 0201. Those belong to placement and identity.
The loop
review pass re-measures, draws any audit that is missing, scores, records, and answers:
- TARGET MET: every category at or above the target (default 90,
--target; every line names any other value). - CONTINUE: do what it names, then pass again.
- STALLED: a full pass raised no category by at least 0.1 above its best so far. It names each category below the target, what limits it, the one thing most likely to raise it, and the outside evidence photos cannot replace.
- STOPPED: the pass limit (default 20,
--max-passes).
A pass is full when no category below the target is held by an audit that is missing, unfinished, void, stale or too small to reach the target. So repeating review pass without doing the audits never gives a stall, only CONTINUE, and at the limit a STOPPED that check review refuses. check review recomputes the scores and the verdict, and fails unless the target is met or the stall is real. report refuses a report.md without the computed confidence line, or with a confidence percentage above the real number.
The snapshot step records copper.json and parts.json by hash, and goes stale when either changes, which holds the later steps back in steps, next and the report. Run again, it keeps the old copy as <name>.old<n>.
The schematic
The first schematic was one box per part on an A0 sheet, a label on every pin, no wires, and a no-connect flag on every pin with no net. Nobody could read it, and the flags were false: a no-connect says a pin is unused, when the truth was that its net was not known.
sch.rs now draws from the same four files as the board (parts.json, blocks.json, nets.json, net-evidence.json), so the two cannot disagree. How it lays a block out:
- Anchors and satellites. A part with three or more pins, or a connector, is an anchor. A two-pin part with a standard shape, or a one-pin part, is a satellite. Each satellite is given to one anchor pin on a net it shares: a signal net before a rail, an IC before a connector, then the nearest pad on the board. So a decoupling capacitor lands on the supply pin it sits beside, which is the owner's rule.
- Strips. Each anchor pin starts a strip straight out from the pin. Satellites to a rail hang from it (down to ground, up to a supply), one satellite in series sits in line and the strip goes on along its far net, so a resistor, an LED and the rail read as one run. A net with pins elsewhere ends in a label at the end of its wire with the text running back along it; a net that leaves the sheet gets a hierarchical label and a pin on the root sheet.
- Pitch. A box opens its rows until every strip has room. A library symbol keeps its own pitch, so a strip with parts on it starts beyond the labels of the pins around it; when that would take it too far the pin keeps a label and its parts are drawn beside the symbol from the same label. Supply pins on the top edge of a library symbol share one rail wire above it with their capacitors in a row.
- What is left. Satellites no anchor took are drawn as runs from a label or a rail symbol, and single parts with nothing known in rows, each end showing its rail or the
?marker. - Sheets. Groups are packed onto the smallest paper that takes the block (A4, then A3, then more A3 sheets).
Evidence comes from the copper attribution: a wire is solid when net-evidence.json says the pad it leads to joins that net's copper, dashed otherwise. A ? marker is a small symbol whose reference starts with #: KiCad leaves it out of the netlist and the BOM, and the pin it sits on is not reported as open.
Three things check it. At every build the generator works out the connections of the sheets it drew from their geometry and compares them with nets.json. check schematic runs KiCad's ERC and compares the netlist KiCad itself exports with nets.json pin by pin. And the sheet rules in schcheck.rs are written apart from the generator, from the owner's design process (labels on wires, titled blocks, no decoupling-only block, text sizes, nothing over a pin or a body).
The schematic has its own place in the sequence next and auto follow: in the connect step, after the nets are attributed and checked and the layer statement is checked, auto draws the sheets whenever parts.json, blocks.json, nets.json or net-evidence.json are newer than them, then runs check schematic. The check records those files and every sheet by hash, so a later edit makes it stale and the flow comes back to it before the report. A failed check stops auto with the list of what is wrong. report then lists the sheets in the tables and writes one PDF and a picture of each sheet under reverse-flow/schematic/, so the report and the video show them without KiCad.
Two KiCad behaviours cost time and are worth knowing. A project file with no schematic.drawing section makes every wire and pin a hairline, so rules.rs writes one and the wires carry their own width. And the ERC calls a PWR_FLAG wired only to a power symbol an open pin when both are power symbols, so the flag here is an ordinary symbol with a power output pin.
Why not AI Flow's router
The first version of this flow called adom-aiflow route. It routes signals only on the outer two layers, so more layers could not close anything. It also does not treat seen pours as occupied: on the Driver board, 495 of 505 new DRC errors at 4 layers involved a zone, with vias placed straight through phase pours. The timeline page is still AI Flow's.
Photo intake: sorting an unlabelled set and stitching each face
init --photos takes every photo of a board with no labels. stitch (run by prep) turns them into one image per face. The work is in tools/stitch.py (Python with OpenCV, shipped inside the binary and run by it; src/intake.rs records the call in the ledger and hands the mosaics on as the run's photos). Its thresholds were set on the eleven test boards of 2026-11-10 and say so where they are defined.
Which photos belong together. Every photo is matched against every other with SIFT features at three sizes (long edge 1600, 800 and 400 px), so that a close-up shrunk to 400 meets a phone overview at 1600. A similarity is fitted by RANSAC. Four kinds of false fit turned up on the test photos, and each has its own guard:
- The table under two photos matches itself when the camera did not move. A fit that leaves the frame where it was is set aside and the rest fitted again.
- A fit with a scale outside 0.1 to 10 is not a pair of photos (the false ones were 0.05 to 0.07).
- Rows of holes and repeated parts look the same from both faces, so a tile can match a tile of the other face with 20 to 30 features. Two overviews that do not match each other are two faces, and no pair may join their groups. Two overviews count as matching only when they match each other about as well as tiles match them: on board 6, where terminal blocks hang over the edges and show from both faces, the two overviews matched with 42 features and each matched its own tiles with 100 to 470.
- A pair with few features counts only when a strong pair through one of its photos has the same scale and the same turn.
Photos that match show one face; within a face, a photo more than 1.5 times coarser than the finest is an overview.
Tiles the matching loses. A bare bottom face, or a board with the same circuit six times over, defeats the ratio test: every feature has twins. A tile that matched its overview weakly or not at all is placed by a vote instead. The pixel size and turn are known from the face's other tiles, so only the shift is open, and every feature votes for it with its six nearest look-alikes. The true place collects the twins that fit plus everything that exists once (a corner, a hole, a label), so it is the highest peak; it is taken when it leads the next best place by a quarter. On board 5 this moved a tile that RANSAC had put one period of the port circuit away.
Which face is the top. The face with more of its mask-coloured area under part bodies, measured on the finished mosaics. A connector hanging over the edge is seen from both faces and does not count. stitch.json gives both figures and says when they are close; then a person or the AI looks, and stitch --top <photo> names it. Of the eight test boards with two full faces the rule was clear on five, close and right on two (boards 6 and 7), and close and wrong on one (board 2, whose bottom carries many small parts).
When the photos do not say which face. Two groups of photos that share nothing are taken as the two faces, and stitch.json says that this is an assumption. With three or more such groups a group may be another part of the same face: then only the largest is built, as the top, and the rest are listed as unplaced.
Registration. Tiles are registered to each other, not to the overview. For each pair of tiles the overview (or their own coarse match) says where they overlap; features are then matched at full resolution inside that area only, with each feature's candidates limited to within about 60 px of the predicted place. When a pair's own match and the overview disagree by more than 150 px the overview is followed: that is a repeated circuit matched one period off. Features are taken on mask-coloured surface first (the colour is measured in each photo: the peak of the hue histogram among saturated pixels near the centre of the frame), because the top of a part stands above the board and moves with the camera.
A pair of tiles is linked only when ten or more of its features agree within 2 px, and five times more than chance would line up inside the search radius. With hundreds of candidates a loose fit always finds a dozen: on board 5's bottom "24 of 1239 agree" held 6 at 2 px and implied a scale of 1.09.
One similarity per tile is then solved for all tiles at once by least squares, with matches more than 2.5 px from the joint solution dropped. Added to that:
- A camera correction shared by the tiles of a face: a radial term and a tilt, found by pattern search on the capped mean residual, kept only when it gains 5 percent. On board 4 the tilt took the tiles' fitted scales from 0.986 to 1.000 down to 0.997 to 1.001 and the median feature residual from 3.6 to 2.2 px. The radial term alone did nothing there. The camera was not looking exactly straight down, and what looked like tiles of different sizes was that.
- The overview as a weak prior: each tile's place according to the overview enters with a tenth of the weight of a feature. It decides only what the features leave open.
- The overview as a referee: a tile that fewer than 40 tight features put more than 80 px from where the overview has it loses its links.
- Tiles with no link are placed where the overview has them, as rigid copies at scale 1, and the overview's homography is fitted again with them in, six times over. That is what brings out the overview's perspective when only one tile carried the first fit: on board 8's bottom, where no pair of tiles could be linked, the first fit gave the other tiles scales of 1.04 and the repeated fit 1.000.
After that the placement is solved once more with cells of surface from the overlaps added. The first pass also takes cells with a weaker peak and a larger shift when the cells of a pair agree with each other on it, which pulls in a tile the overview placed to within 10 or 20 px. A pass is kept only if the cells then agree better.
The overlap figure, and two traps. How far the tiles disagree is measured by phase correlation of 96 px cells where both tiles show board surface. Three things matter. The cells are blurred by 1.5 px for the measurement: unblurred, pixel noise decides the peak and the same cells read 2 to 6 px instead of 0.7. Only cells with a clear peak count (response 0.3 or more), since below that the shift found grows as the response falls, which is the measurement failing. And a cell must have detail in two directions: a cell of parallel tracks can slide along them freely, and on board 3's top such cells read a median of 4.9 px on joins that are clean by eye.
The first trap: OpenCV 5.0.0's phaseCorrelate writes its window into the arrays it is given. With overlapping cells cut from one array, later cells are read from data already multiplied by the same window in both images, and they agree near zero shift whatever the tiles do. Every cell is passed as a copy, and the cells do not overlap. The second: the placement is fitted in these same overlaps, so every other cell is held out of the fit and stitch.json gives the held-out figures separately. On a made-up board whose tiles are exact similarities (the test fixture) the figure reads 0.08 px.
The mosaic. The frame is the reference tile's own pixels, so nothing is scaled. Each tile is resampled once, through one map that combines the camera correction and its similarity (Lanczos). Nothing is sharpened. One source is chosen for every place: the start is the tile whose centre is nearest, and a graph cut (at 1/8 size, on colour difference) then moves each join to where the two tiles look the same, which is board surface, so joins run round parts rather than through them. The join is feathered over about 12 px.
Gain. Each tile gets a smooth gain per channel toward the overview's tone, measured on board surface over about 3 mm; without an overview, one gain per tile so that neighbours agree. That is not enough at the joins: a tile's own light (the ring light's glare, the falloff to its edge) changes faster than a smooth gain follows, and what is left is a step in tone along the join that reads like the edge of a pour. So at each join both tiles are also brought to their common tone on board surface (their ratio smoothed over 32 px, fading out about 200 px from the join). The tiles are registered to a fraction of a pixel, so that ratio carries no detail from one tile into the other.
What is not covered. Where no tile shows the board, the overview fills in, at its own much lower resolution and with one gain for all channels; stitch.json lists those areas as low resolution. The overview is only used inside the board outline, which is cut out of the overview by GrabCut seeded with the surface the tiles show. Its table is left out because a phone's table can have nearly the mask's hue, and prep would take it for board. Where nothing covers the canvas it stays black and is listed as not seen. The overview is mapped by one homography; where it fills in, a join between tile and overview can step by the "overview fit" figure in stitch.json (8 to 30 px on the test boards).
Thin sets. A face with one photo is that photo, copied byte for byte, with a note of every side on which the board runs out of the frame. Photos that match nothing are listed under unplaced and left out. Nothing is drawn that no photo shows.
Boards that are not green. The stitcher measures the mask colour per photo and works on red and blue boards. prep looks for a green board; when it finds none it takes the board box that stitch.json records for the mosaic and says so in prep.json (outlineFrom). What prep and the later steps read inside that outline still assumes a green mask.
# How the measurements work
For anyone changing the copper tracing or the router. Each section gives what was tried, what happened and why the code is the way it is.
## A second board: what was tuned on one
Tried on the Raspberry Pi Compute Module 5 from public photos, which differ from the Driver photos in every way that matters: mask luma 130 against 44 and 64, flat studio light against a falloff of half across the board, no rows of holes, parts on both faces, and 24 px/mm against 46.6. This is what broke and what replaced it. Nothing here moves the Driver figures (`status.md`), because each new reading sits behind something the photo itself shows.
**The photos.** Two sets, not one board. `top-everypin` prints "2024 R4" and `top-cnx` "2024 R5". The bottoms differ too: "FCC ID: 2ABCB-CM5" and "Proant AB" against "2ABCB-RPICM5" and "Abracon", and date codes 2324 and 4024. Copper lines up between the sets on 98 percent of plain 2.5 mm tiles (median correlation 0.64 and 0.58) after a shift of 0.15 to 0.25 mm, and the tiles that do not are parallax on tall connectors in the angled shots, so what the photos show of the outer copper is the same design. They were still kept apart. The run uses everypin: its edges are 1.2 photo pixels wide where CNX's are 3.1, so its thin tracks stand 30 to 49 grey levels off the laminate against 6 to 11. CNX has more pixels and less resolution. Each pair is one layout: through features correlate at 0.228 mirrored the right way against 0.037 the wrong way, and the faces lie on each other at 0.021 mm RMS. Whether each pair is one physical unit, photos alone cannot say.
**The mask is measured.** `prep::MaskTone` reads the peak of the hue histogram, and the value, luma and G - R of the pixels around it, from each picture. The mask's brightest value is 1.1 times its 98th percentile, never under the 0.62 where mint plastic begins, so a dark mask reads exactly as before. `photomat::classify` multiplies thresholds that depend on how bright the mask photographs by `exposure()` (luma over 64) and `chroma()` (G - R over 45), and leaves those on black, white and gold alone, because those colours do not depend on the board. Both factors are 1 within a quarter of the Driver's values. On a bright mask the print's core does not lean blue as it does on the Driver's, and its own median becomes the bar.
**Which faces have parts** is measured too, by the share of the face that is moulding or ceramic bodies (`photomat::carries_parts`): 14 percent on the Driver top, 0.16 on its bottom, 3.7 on the CM5 bottom. The line is 1 percent.
**Framing through four holes.** `scale --holes 48x33 --board 55x40 --source "..."`. Four points give scale and perspective together, so each photo gets its own homography and the corners it implies replace the fitted ones in `prep.json`. Fitted corners were 0.14 to 0.27 mm off. Each hole is read from its gold ring's outer edge with a trimmed circle fit, because an angled view moves the opening's centre (0.11 to 0.12 mm here, 0.34 mm on CNX), and silk that runs into a ring makes the plain metal mask lose the hole, so `prep` also keeps a gold-only list. Px/mm differed by axis by 1.2 percent on everypin, which a single scale would have left as a warp. A symmetric hole pattern cannot say how the board was turned over, so the flip comes from what shows through both faces (`board::flip_by_detail`), with `--flip` for when that is not decisive. Where tiles correlate between faces, a quadratic through 21 of them registers the bottom to the top.
**Flat light.** Local levels exist to follow the light. Where the light does not move they follow the copper, and a wide field of laminate with thin tracks through it splits into its own two classes and reads as copper. `trace::light_spread` measures how far the copper level moves over 8 mm tiles: 0.32 and 0.56 on the Driver, 0.18 and 0.11 on everypin, with the line at 0.2. Below it the levels are one plane through the plain mask, started from the two modes of the green histogram (178 and 200, 181 and 204), with the long dark tail of shadows left out. Fitted as before, that tail took the laminate level to 142 and left nothing reading as laminate. The dim-patch rule in `settle` is off below the line too, since there is no poor light to excuse a dim patch.
**Over-sharpening.** The photo digs a dark band round anything bright, 25 percent deep and 5 frame pixels wide on the CM5. It read as gap, and it set the scale of every line threshold, which depend on the 99th percentile of the response, so real gaps fell under them. `trace::sharpening_halo` measures it from the median green against distance from hidden pixels, and treats it as unseen. It counts only if it dips at least 10 percent and recovers within 8 px. The Driver top dips too, but over 13 px, which is a cast shadow, so it is left alone.
**Metal parts.** On a face with parts, pale neutral pixels were transparent as silk. A lid and a can are also pale and neutral, so copper ran straight through them and joined the whole face into one island. Pale pixels are now print only below 1.2 mm of solid width.
**Vias under frontal light.** Neither face has a dome highlight: the shading agreement is 0.11 and 0.21, so `domes` rightly says flat. A via still shows, as a dark pit about 0.18 mm wide with a light ring from sharpening. `domes::read_pits` measures every dark spot and keeps round ones in mask, of the face's own median size. On their own those are not enough: on the Driver top the same test passes 684 specks at 0.11 mm. Real through vias line up with the other face and grain does not, so a face's pits count only if at least a fifth have something on the other face within 0.1 mm and that is five times what the same count finds with the other face mirrored (`domes::pits_confirmed`). The Driver top finds 3 of 684, under its chance level of 9. The CM5 top finds 242 of 358 against 14. Pit width is the hole's dimple, not the pad. `size_vias` tried to read pads from the fall of brightness round each via, and its 0.32 to 1.17 mm spread is not one pad size, so it now refuses a spread above double and sizes stay as read.
**The scorer.** `copper_read` failed in the same two ways. Otsu split inside the dark tail, and the envelope sat on the laminate wherever a window was mostly laminate, so everything scored as copper. When a bright photo's plain mask has two modes, the split is half way between them against the face's own level. That threshold is shared with the reader, so this copper score is less independent than the Driver's: it knows nothing of gap lines, and it does respond to a 0.25 mm shift (0.72 to 0.62, 0.76 to 0.58), but it cannot show a wrong level. `via_report` looks for small dark spots on a bright mask, where a bright-blob test finds nothing.
**Smaller things a second board needed.** `build` writes bottom-side parts, flipped as KiCad flips them. `copper trace` starts from the outline `scale` measured when nobody sketched a `copper.json`. `check <step> --skip-earlier "<why>"` runs one check without the part-naming steps before it, logs that, and does not pass the step. A corner radius under 8 px no longer hides the whole face.
**Still from one board.** `rounded_board` assumes a 2.6 mm corner. `PIT_*` bars are from everypin tile set A. Exposure factors are a straight line through one bright board. `parts` package sizes assume bodies read as moulding or tan, and bleached ceramics on a bright mask do not; closing flecked metal on a bright mask also turns large print into candidates.
## Finding and sizing parts on a photo the reader has not seen
The chip passive reader carried the CM5 photos' colour numbers, and found 15 of the Driver's 90 chip parts and 7 of 40 on the left half of board 10. Three things changed (2026-11-10).
**Each photo's own levels.** `tone.rs` finds the board surface as the photo's dominant hue (within 12 OpenCV hue units, above half of its own 10th-percentile saturation, no brighter than 1.1 times its own 98th-percentile value) and the bright level as the median of the brighter group of neutral pixels off the board. `passives::calibrate` then reads tan bodies strictly (clear ends on both sides, a size close to a package) at six saturation floors with a wide hue window. Each distinct body that passes gives its own hue, saturation and value and those of the solder beyond its ends. With five or more, the photo is read at levels set from them: the hue window is theirs with 6 units to spare, the saturation floor lies 0.85 of the way from the ends' median to the bodies' lower quartile, a body is at least 0.77 of the median body's value, an end 0.6 of the median end's, and a dark body is whatever is darker than a tan one at the bodies' own hue. With fewer, the CM5's numbers are used, scaled by the photo's bright level, and the candidates say so (`levels`: `scaled`, a fifth less sure). A second reading at the scaled numbers runs behind the first and only adds where the first found nothing. The factors were chosen on the CM5, the Driver and the left half of board 10; nothing else was tried on the right half before it was scored.
What this does not reach: a resistor whose body is neutral dark with pale ends (the Driver's, board 10's and board 4's). Its body has no hue to calibrate on, a sharp photo shows it speckled, and its white substrate shows along all four sides, so the end test finds ends across it as well as along it. Five other readers were tried and dropped the same evening: a threshold sweep (found more, invented more than it found on the CM5), a fit of each package's outline to bright-dark-bright (drowned in 0201 matches), islands of not-board split by Otsu's threshold on whiteness (the solder fringe joins the body), islands with a whiteness profile (sizes one class large), and a run-length measure through a given centre (71 percent right on board 10, 33 percent on the CM5).
**Every candidate is rated.** `candconf.rs` gives each candidate a `confidence` by rule: what kind of body it is, whether it fits a package, how large it is, and whether it sits on a plated hole's pad, at the board edge or inside a body five times its size. The rules were laid out on the left half of board 10 and are not probabilities. `parts init` takes those rated 0.5 or more.
**A chip's size follows its body.** `parts snap` used to move a chip part to the next size down as soon as the smaller pads fitted the photo's metal as well, and a smaller pair of pads fits inside any larger patch of solder. The working image is always 40 px/mm, so the sharper photo did not reach the fit as pixels; it reached it as wider, cleaner solder. Now the part's own body, read on the photo by the same reader, settles the size, provided the pads of that size fit no worse by 0.08; where no body reads, pads alone change a size only on a fit 0.15 better, either way. Each chip part's `sizeCheck` says what was read.
## A third board: levels from edges, print, and any mask colour
Tried on a small USB hub board (two whole-board close-ups, 76.8 px/mm, green mask, parts on the top only) and on close-up tiles of a red and a blue board. The baseline read the hub's top as one island holding 192 of 228 pins joined on that face, and `check copper` passed. The reader was developed on the hub's top; its bottom and the other boards were looked at afterwards.
**Why one island.** The gaps were never the problem. The top is mostly laminate with thin tracks, under a light that casts shadows beside every part. The two-class fit split shadow from laminate, so the laminate took the copper level (118 where the tracks read 180) and every track, pad and pour joined through it.
**Levels checked against step edges (`edgelevels.rs`).** A copper edge is a sharp step whatever share of the face each class has. Every sharp edge clear of parts and print is measured: the brightness 2 to 7 px out on each side, upper quartile on the bright side and lower on the dark. Edges must be sharp (half the step within 4 px of the crest, which drops the penumbra of cast shadows) and run on for 0.5 mm (which drops specks). The step that occurs most often is copper over laminate, and edges within 35 percent of it are kept. `agreement` counts how many of them have both sides in one class under the fitted levels: 0.77 in the copper class on the hub top and 0.58 on the CM5 top, against a larger share of 0.19 and 0.29 on the Driver top and bottom and 0.20 on the CM5 bottom. Over half, and the levels are replaced by the edges' own two sides, averaged over the finest of 12, 6 and 3 mm windows that holds 60 samples. Under half, nothing changes, so the Driver and the CM5 bottom keep their levels. The CM5 top's are replaced: it goes from 102 kept islands to 299, tracks beside the processor that were one pour come apart, and its copper score moves from 0.702 to 0.670 (precision 0.79 to 0.92, recall 0.85 to 0.72) on a scorer that shares its threshold with the old levels. Both figures are written per face to `detect/copper-island-accounting.json` (`levelsAgainstEdges`, `levelsFromEdges`, `copperShare`).
**Print (`trace.rs`, `printgaps.rs`).** Two errors. A printed line's blurred rim is brighter than laminate and not pale, so it read as copper and every line of print on laminate became a track. What `photomat` calls silk is now never read, with a 2 px rim, and never counts as the lit edge of copper. And a gap that runs under print closed there, because the pixels under the print are nearer the copper on each side: `gaps_under` walks each gap mouth straight on under the print, and where the walk comes out on laminate within 2 mm the walked pixels are gap. A gap that bends under print, or runs under print wider than 2 mm, is still lost.
**Which faces carry parts.** `photomat::carries_parts` counts dark and tan blobs. Brass standoffs, their shadows and a strip of table passed it on the hub's bare bottom (2.4 percent against the 1 percent line), so the frame of its printed table read as solder. `face_carries_parts` asks `parts.json` once the run has placed parts and the photo only before that.
**Mask colour (`maskcolour.rs`).** The peak of the hue histogram of coloured pixels is the mask's hue. In the green bracket (65 to 200 degrees) everything is as it was, to the letter. Otherwise copper is read in the brightest channel whose median over the mask is at most 160: the mask's own channel sits where the camera's tone curve is flat (blue board: step 0.06 to 0.11 in blue against 0.24 to 0.44 in green; red board: red clipped over 16 to 18 percent of the mask). "Red over green", which meant gold, tan or a part, becomes a warm hue at least 20 degrees off the mask's. `photomat`'s G - R plane is rebuilt as 100 times saturation at the mask's hue, -60 for a warm colour off it and 0 otherwise, which puts mask, gold and neutral where the Driver photos had them. This was looked at on four red and four blue tiles and is not scored: no red or blue board has been run through the flow.
**Whole faces of the red and the blue board (`maskread.rs`, 2026-11-10).** Boards 7 (red) and 8 (blue) were taken from their photos through `prep`, `scale` by rulers and `copper trace`, with no parts placed. Three things changed, none of which touches a green mask:
- *The channel is measured.* The rule above put the red board on green or blue, whose median over the mask is 30 to 50 of 255: colour noise there is as large as the step from laminate to copper. For each channel the reader now takes the structure the plain mask shows (5th to 95th percentile of log brightness between 2 and 12 px of smoothing) over that channel's grain, and moves off the rule's channel only when another scores 1.5 times better. Board 7 reads in red (1.73 and 1.45 against 0.95 and 0.58 for the rule's channel); board 8 stays in green. Island fragments before joining fell from 17,384 to 4,580 on the red top and from 13,260 to 2,654 on its bottom.
- *A colour that cannot be the mask's is a part.* A black chip under a blue-white light reads dark teal, saturation 0.5, and passed the test for a dark body ("value under 0.30 and saturation under 0.42"), so it was traced as copper. On a mask that is not green, a coloured pixel 60 degrees or more from the mask's hue is not board.
- *Levels far wider than the edges step.* A face that is nearly all pour gives the two-class fit almost no laminate. When the fitted levels lie more than 1.6 times as far apart as the face's own copper edges step, they are taken from the edges (`edgelevels`), on masks that are not green only.
Judged by eye on places chosen before the reader's answer was looked at (15 random places on plain mask per face, and 10 on thin dark lines per face; the red top was the development face): the red bottom agrees at 12 of 12 random places (8 of 12 before) and 7 of 9 dark-line places (6 of 9 before); the blue bottom at 11 of 13 and 9 of 9, the blue top at 8 of 13 and 1 of 4, both unchanged. Places where the eye could not tell are left out of each count. The sample is small and nearly all copper, because both boards are flooded with pour.
Still wrong: on the red top, the fine tracks under the brightest light are not separated (the red channel sits on the camera's shoulder there and shows a clearance as a step of 0.06 in log brightness); the blue top breaks its pour into laminate patches at 5 of 13 places, under glare and a speckled mask; where the mosaic was filled from the overview nothing can be read. `scale` decides which way the board was turned over from through features when no hole agrees (`flipdecide.rs`); on these two boards the features do not correlate either, and `--flip` has to be given.
**`check copper` (`copperchecks.rs`)** now fails when one island holds over two thirds of a face's joined pins (hub baseline 0.84; largest honest share so far 0.51, the Driver bottom's ground pour), when one island joins over two thirds of the pins of a part with 16 or more, when over 95 percent of the visible face reads as copper, or when the levels miss the photo's step edges after replacement. An island with a third of the pins on three or more named nets gets a hint.
**Still wrong on the hub board.** Its bottom right, laminate with a few thin tracks, still reads as one ragged pour: the levels there pass the edge test over the face as a whole and are wrong locally. Rings of header holes come out as several small islands each. Print under a part's shadow is not found as print. On the red tile grain and scratches fragment the pours.
## Pads join their copper, and a track goes on in line
After the levels were fixed the hub board's tracks came apart, and so did every pad from its track: 63 of 258 top pads joined anything. The reader sees mask-covered copper. A pad is exposed metal under a part's end, with a fillet, a glint and often the part's shadow, none of which is read, so the copper stopped 4 to 11 px short of each pad and a join depended on a ring test round the pad.
**The sample.** 40 top pads of the hub board, the pad nearest each cell of an 8 by 5 grid, shown on photo crops only (`scratch/b10/sample/`), and for each the neighbouring pad, via or track its copper visibly reaches: 31 joins on 28 pads, the other 12 showing nothing that could be followed. Even-numbered pads were used to develop, odd ones to report. A join counts when `copper trace` puts the pad's pin on the island found within 0.2 mm of the target.
**`padjoin.rs`.** Each pad is a seed: the footprint's pad as placed, or a blob of exposed metal on a face with no parts placed yet. A seed grows through whatever the photo cannot show and stops at seen laminate or a gap line, 0.3 mm at most (`REACH_PX`), each pixel going to the nearest seed. The islands it meets are the pad's copper: they become one island with the pad's own area and a 3 px path. Shadow does not stop the growth: a pixel 0.3 of the separation below the laminate level is in shadow, not laminate. Two rules came from the first try, which shorted 32 two-pad parts and four pins of the hub chip through strips of copper that pass both pads: when two pads of one part reach the same island, the nearer keeps it if the other is 0.15 mm further off, and otherwise neither does, except two pins of a chip that both lie on it. `islands::pad_island` asks first which island covers the pad.
**`trackbridge.rs`.** Two track pieces are joined when one lies straight ahead of the other's end within 0.5 mm (`MAX_BRIDGE_PX`, shorter than the smallest two-pad part, so a bridge cannot span a part), has the same width, both run straight for 0.75 mm, both stand 0.4 of the level separation above both flanks, no gap line lies between, and the break does not read as laminate for 0.1 mm. Without the flank and length tests 21 of 24 bridges drawn at random joined hole rings, print rims and streaks. With them the hub board's top gets 2 bridges: its tracks were broken at pads, not along their length, so this step does little there.
No chain of pads may put the two ends of a two-pad part on one island either: each island carries the pads joined to it, and a join that would bring a pad and its mate together is refused.
**Figures.** Hub board top, joins reproduced: 6 of 17 before and 13 of 17 after on the development half, 3 of 14 before and 13 of 14 after on the reporting half. Top pads joined to anything 63 to 248 of 258, largest island's share of them 0.08 to 0.03, two-pad parts with both ends on one island 0. Driver and CM5 copper scores moved by under 0.01.
**A held-out board, and what it showed.** Board 04 (a stitched face at about 40 px/mm, green, no parts placed, so the seeds were blobs of metal) was looked at only after the above. Its pours are separated by gaps half a millimetre wide that the reader finds in most places and loses in some, so one island already held 30 percent of the crop before this round and holds 34 after, with 17 of 40 sampled pads on it. The 8 joins labelled there score 2 before and 4 after, and neither figure means anything, because both go through that island. `check copper` cannot see it either: with no parts placed there are no pins to count. Forcing the levels from the edges on that face (its fitted levels put 0.24 of its copper edges wholly in copper, under the one half that triggers) brings the island to 16 percent, and doing the same to the Driver bottom costs it 0.10 of its copper score and 227 vias, so the trigger stays where it is. Whole stitched faces of the red and the blue board do not read at all: the red board's chip reads as copper, and 42 percent of the blue face reads as pale.
**Not done.** Pours in the shadow of tall parts are still lost: both levels fall together there and the copper reads as laminate. Only pads reach across shadow. Seeds from metal blobs, used when no parts are placed, have no part to check a join against.
## Stitched faces: grain, levels place by place, and a face that is not read
Three stitched faces failed on 2026-11-10 after whole-board photos read: test board 1's bottom (one island with 117 of 148 joined pins), board 4's top (one "GND" holding both pads of two diodes) and board 1's top. What was found, face by face, and what changed (`locallevels.rs`, `faceread.rs`, 2026-11-11).
**Board 1 bottom: the edge test was blind.** The left half is laminate with thin tracks, the right half pours. The fit put the copper level on the laminate over the left half, as on the hub board. The edge test should have caught it and did not: the dim, soft ends of the tiles carry sensor grain whose crests join into one network, the run test keeps them, and they outnumber the copper edges, so the commonest step was 0.06 where copper over laminate steps 0.15 to 0.35. Now the commonest step is also taken among edges that go on in line (the crest is found again 6 px along, both ways, facing the same way), and when the two differ by more than one bin the straight edges are the face's edges (`without_grain`). A face where the two agree keeps every edge as before. Board 1's bottom then reads modal step 0.163 with 77 percent of its edges wholly in copper, and its levels are replaced. Board 4's bottom moved from 0.087 to 0.163 the same way.
**Each place against the edges near it (`correct`).** A face can pass as a whole and be wrong over part of itself. The shares of `edgelevels::agreement` are also counted in a 6 mm window round every place, and where a majority of the edges near a place lie wholly in one class the levels there come from the edges, blended over half a window. On the faces run so far this fired only over a fraction of a percent of the hub board's bottom.
**Under a moving light, by the side of the nearest edge (`levels_by_side`).** With the levels from window means of the edges, the bare laminate under the light's hot spot on board 1's bottom, 5 mm from any edge, read brighter than the copper level and joined three pours. Every place lies on the bright or the dark side of its nearest step edge, so its own brightness, moved half the edges' step towards the middle, is a sample of the mid level where it is; those samples are averaged over 1.5 mm. One mid level, not a level per side: with a level per side a false edge on bare laminate gave its few "bright side" places a copper level of their own and split the grain in two (first try, median separation 0.08). Edges within 0.5 mm of holes, parts and metal are left out (shadow rims), and so are places that lie along an edge's line past its end. Used only where the light moves (`FLAT_LIGHT`) and where at least 0.75 of the edges straddle their own window means (`SIDES_NEED`): on board 4's top, where 0.56 do, it turned 4 of 13 labelled places of pour into laminate.
**The scale of the line thresholds** is the 99th percentile of the response on plain board, 0.4 mm clear of parts and print, not of the whole face: fabric tape on board 1's bottom put it at 14.0 where the board reads 4.1.
**A face that cannot be read (`faceread.rs`).** Before anything is written, a face is taken out when the trace itself shows the failure: one island holds over two thirds of its joined pins, over 95 percent of it reads as copper, or ten or more and over half of its two-pad parts have both pads on one island. The copper is measured again without that face, `detect/copper-island-accounting.json` carries `unread` with the reason, `copper trace` and `check copper` say so, and the check passes: the pins on that face join nothing, which is a missing net and not a wrong one. For one build a fourth reason stood beside these: levels taken from the face's edges that under two thirds of those edges straddle. It came from board 4's top (0.56) and emptied two faces that read: board 9's bottom (0.59, islands right at 24 of 25 labelled places) and board 5's top (0.66). An edge figure cannot tell a face that reads from one that does not, so it is now a `warning` on the face in the accounting and a hint, and the reading is kept.
**Most pins of a large part on one island** (over two thirds of a part with 16 or more, the line `check copper` fails a run on) join nothing either, and are listed as `partPinsRefused`; the rest of the face stands.
**Two pads of one two-pad part on one island** join nothing (`pairs_on_one_island`), and are listed as `padPairsRefused`. The hub board's top had 18 such parts.
**Drills.** `layers trace` moved a through pad's anchor, and so its drill, to a point inside the pad's photographed outline whenever no photographed hole was matched. The anchor now stays at the hole, or at the library's place when no hole was found, and the outline is taken only if it holds that place.
**Measured** on by-eye labels made by a reader that saw photo crops only: 30 random places on plain mask and 24 random pairs per face, with "cannot tell" left out, so the counts are small. Places right, before and after: board 1 bottom 12 of 17 and 15 of 17 (bare board 3 of 8 and 8 of 8); board 1 top 17 of 21 and 20 of 21; board 4 bottom 12 of 14 and 13 of 14, pairs 10 of 13 and 12 of 13; board 4 top 15 of 18 before, not read after. Largest island's share of joined pins on board 1's bottom: 0.79 to 0.10.
**Held out** (looked at only after the above was fixed). Board 2's top, where the unchanged reader put 286 of 558 joined pins on one island (58 of the 80 pins of the processor) and 40 two-pad parts on one island: the grain test moved its modal step from 0.11 to 0.24, its levels were corrected place by place, and the largest island holds 46 of 526 pins (0.09), with the tracks beside the processor read one by one; the processor's top row of pins is still one island with the pour above it. No labels were made for board 2. Board 9's top: 11 of 20 labelled places right before and after, so nothing gained there. Board 9's bottom: see the misfire above.
**Still wrong.** Board 1's bottom: two of its three large pours are one island, because the lane between them is lost for 2 mm under the hot spot, and patches of bare laminate inside the ring of header pins and along the top edge read as copper. Board 4's top is not read at all: its gap lines are visible to the eye and the line filter finds only pieces of them. The hub board's top reads more laminate as copper beside its chip than before (copper share 0.31 to 0.41). The Driver and CM5 runs were not re-measured with these changes.
## The visible layers come from the photos, and how they are scored
`layers trace` writes `photo-layers.json`; `build` draws from it when it exists. `check fidelity` plots the board through KiCad's service, draws its gerbers and drill file at 40 px/mm (`gerber.rs`) and compares them with what each photo shows.
**Registration first.** Fitted corners leave a warp. On the Driver board, the top and bottom frames disagreed by up to 0.58 mm (0.204 mm RMS over 40 shared holes). A quadratic map through the holes takes it to 0.020 mm, 0.026 mm with each hole left out of the fit, so `scale` now writes `board/bottom.jpg` that way. Everything drawn from the bottom photo, including B.Cu, moves with it.
**What a pixel shows** (`photomat.rs`). Silk is told from specular copper edges by colour, not brightness: on the Driver bottom, trace edges glint as bright as silk and as wide, but read B - R about 1 where silk reads 6 to 30. Lateral colour puts a red fringe on one side of thin lines, so the test takes the median over the bright core of each connected blob, not per pixel. Where the mask around is bright, glints turn bluer, so the bar rises with it. Parts are found by not being green, and large bodies fill to their hull because dark green plastic reads as mask. A hole must show on both faces, or have a part or joint opposite.
**Drawing.** Silk goes on as filled `gr_poly`, holes bridged in by `keyhole`, with library silk and reference labels moved off. A pad the photo shows becomes a custom pad of that outline, keeping its number and net; where a body hides part of it, that part stays as the library drew it. Through pads take the measured hole. Holes inside a pad become more pads of the same number. Holes and metal no footprint explains go on one net-less footprint, `PHOTO1`. The outline is four fitted edges, each from whichever face shows it better.
**Scoring.** Only area the photo can show counts: silk where the photo shows mask, silk or metal; openings and metal off part bodies; copper only under bare mask, 6 px clear of anything else (`fidelity::BESIDE_PX`). Excluded pixels are in neither the numerator nor the denominator, and their area is reported per layer. Three samples: tile set A of a 5 mm checkerboard sets thresholds, tile set B is held out, and the second photo of each face on tile set B is registered separately.
The class map also drives the tracer, so a high IoU on the primary photo is partly circular. The second photo tests noise and registration, but it has the same camera position and light. `edgeAgainstLuminance` walks from each drawn edge to the steepest luminance edge, with no threshold, and is the check on bias. Every layer is also scored moved by 0.25 mm, and `check fidelity` fails if that does not lower the IoU.
## The copper scorer, and what it got wrong
`fidelity::copper_read` is a plain threshold on the green level against an upper envelope of the light. It knows nothing of gap lines, which is the point of it. Until November it was also the largest error in the copper figures. It took its envelope and its Otsu split from every mask pixel, and the pixels beside silk blocks and pads carry glare: in the MOTOR_TEMP box on the Driver top the envelope read 107 where the copper read 64, so whole pours scored as laminate. It now uses only pixels 6 px clear of anything else. A lowest-of-four directional closing over 0.6 mm fills dome shadows, which are short, and keeps gaps, which are long.
Repairing the scorer, with the board unchanged, moved F.Cu from 0.586 to about 0.83 and B.Cu from 0.754 to about 0.84. That rise says nothing about the board. Any before and after has to be scored with one scorer.
`build` also used to merge any `routes/L<n>/routed.json` it found, routed on older copper or not, which drew tracks across the new copper and displaced what was read. Routes now carry `copperSha` and merge only onto the copper they were routed on. `check fidelity` scores a board with fresh routes without them, unless `--pcb` names a board. It belongs after `connect attribute`, because cuts in the fills depend on nets.
## The two photos of a face
The two photos of each face are 5 seconds apart. They were expected to separate glints from copper, because a glint should move and copper should not. **They do not.** The difference of the registered shots is noise plus a little parallax on tall parts, with no moving glints on the mask. Glare is fixed in both shots and has to be handled as light (see below).
What differs between them is the grain. The high-pass correlation between shots over flat mask is 0.08 on the top and 0.06 on the bottom at pixel level, 0.30 and 0.17 after a 2 px blur. So grain is sensor noise rather than texture on the board, and the two shots are two independent readings of it. `trace` uses that in three ways, all through `Params::second`:
- The mean of the shots is what the levels and materials are read from.
- The direction to smooth along is chosen on one shot and the mean along it taken from the other, both ways round. A smoother that picks the direction of least variance on the same pixels it averages draws worm-shaped false structure in flat areas, because it follows whatever the grain happens to do. Cross-fitting removes that without any threshold.
- A gap line's mean response must reach the lower ridge threshold's 0.7 in each shot separately.
A board traced this way uses the second photo, so `check fidelity`'s second-photo figure is no longer independent of the drawing. `TRACE_ONE_SHOT=1` traces from the primary alone, and scoring that board on the second photo is the fair test of the method.
## Reading copper through solder mask
Copper under solder mask reads lighter than bare laminate, but only against the same part of the photo: on the Driver bottom photo, copper's green falls from about 118 at the top to 60 at the bottom, and the upper left is bluish and pale. `trace.rs` reads each pixel in five steps.
**Lighting.** `two_levels` estimates a copper level and a laminate level in log green for every pixel, coarse to fine (windows of radius 360, 180 and 90 px). In a window, a two-means split gives both levels if the split is real. If it is only grain, the window is one class: it takes the nearer level and keeps the separation from the coarser scale, so the levels follow the light across a wide uniform area. A plain ratio against a wide average, the first method, reads a wide area of laminate as copper and fragments a dark one. The fit leaves out anything within 8 px of a wide bright thing, for the same reason as the scorer: with silk blocks in the window, the pour in the Driver bottom's PROG box sat on the mid-point throughout and read as a blotch of copper and laminate.
**Material.** Pale pixels, relative to the copper level, are silk, tin, domes and glints. They are transparent: each takes the class of its nearest visible pixels. Saturation alone cannot say silk, which is why the upper-left rails were lost before: the copper there is low-saturation too. A thin pale line is the lit edge of copper. Oblique light gives a raised edge a thin white glint on one side and a dark shadow on the other, so a glint beside copper is copper, and reading it as silk is what used to erase signal bundles. Holes are opaque, and so are part bodies on the top face. Exposed gold counts as copper on the bottom.
**Class.** Each pixel is averaged along the direction it varies least in (16 directions, 17 pixels), when that line stands out from its surroundings, and over a small disc otherwise. Grain cancels without blurring across an edge or a gap. The result compared to the mid-point of the two levels is the pixel's class. Laminate specks with no gap line through them are grain; small dark blobs beside something pale are a dome's shadow or a letter's ink.
**What is not a gap.** Two dark things on the photo are not gaps. A dome's shadow is a blob within 0.5 mm of the dome (`Params::domes`), wholly inside that reach and enclosing nothing, so it stays copper whatever the line filter made of its edge. A line that never leaves a dome's neighbourhood goes too. And under one oblique light (`Params::light`, from the domes, so only where they agree on one) a raised edge facing the light glints, and the camera's sharpening digs a band 2 to 3 px wide right behind the glint, on the copper. Across a Driver bottom track: glint 5 px, band 2 px with a floor near 40, flat 3 px at the pour's level, then the real gap, 4 px with a floor near 15. Read as a gap, that band cut every track in two along its length. It counts as copper only if brightness returns to the copper level with no glint in between, so a narrow track with a real gap and the next track's glint stays apart.
**Barrier.** Laminate plus gap lines from an oriented dark-line filter: a Gaussian along the line (sigma 9 px), the second derivative of a Gaussian across it (sigma 2.6 px), 16 angles, on brightness clipped near the two levels so a glint cannot outweigh an edge. Its thresholds are fractions of the photo's 99th percentile response. Loose ends of gap lines are walked on until they meet another barrier. A line is drawn as wide as its dip at half depth around its centre line, not as wide as the filter's response, which took 1 to 1.5 px off the copper on each side.
**Islands.** What is left between barriers is one island. Then `split_necks` cuts thin tentacles off wide copper, each its own conductor, and cuts two wide parts joined only through a pinch. `join_fragments` joins a fragment to a neighbour only where nothing along their boundary shows a gap, and never joins two substantial pieces. A short between nets is worse than a missing track, so both steps lean towards splitting.
Tried first, and abandoned:
- **Long straight gap lines with splitting at necks.** It found pours only roughly, and no tracks at all.
- **Merging over-segmented regions by boundary strength.** One leak in the gap network merges pours, and noise makes false cells.
- **Classifying material by hue.** Grain makes dark mask pixels read any hue.
- **A polynomial lighting field with a local mean correction.** Where a dim area fell entirely below the polynomial's mid-point, its copper level never moved, and the whole area read as laminate.
Check any change by eye against `evidence/copper-islands-*.jpg`.
## From islands to a board
Every island read ends up in `detect/copper-island-accounting.json`: kept as an item, merged into one, or not copper with a reason. Nothing is filtered for disagreeing with a rule: what the photo shows is drawn, and disagreements are reported (`trackPadConflicts` in `detect/copper-measured.json`).
**On a part's top.** Print on a chip, the step of a metal lid and light on a package read as small islands, and no copper can be seen there. An island is on a part's top when three things hold. At least 90 percent of it lies inside the body outline of one part placed on that face: the box of the footprint's fabrication-layer drawing at the part's place and rotation, drawn in by 0.25 mm, so terminals, leads and fillets at a body's edge stay out of it. The reader found copper over no more than a quarter of that outline: more means board shows there, through a connector or shield open in the middle, at a fiducial, or under an outline that is in the wrong place. And less than half of the island has the colour of the board's own copper, read off the face (the channel that stands furthest above grey in the median of everything read as copper is the mask's own, and a pixel has the board's colour when that channel stands a quarter as far above grey in it): that keeps copper beside a part when parts.json draws its outline too large. A two-pad part is left out. Such islands are taken out before pins and vias are joined, are drawn in the overlay as a gap is, and are listed in the accounting with the part (`onPartBodies` in its totals). `connect measure` takes the same islands out of its own trace. `copper regions` lists a region that is a part's whole top under `onParts`, by the outline as drawn and the same colour test. A board whose copper reads grey gives the colour test nothing, and nothing is taken off there.
A thin island with a line down it that explains its area is a track, one per chain of its skeleton. `path_mm` is the centre line, kept within a quarter of a pixel, `widths_mm` the width measured at each vertex, `width_mm` their median. Everything else is a pour, drawn by its pixels: `rle_px` holds runs of image pixels at 40 px/mm (`[y, x0, x1, ...]`), and `rle` the same on the 20 px/mm grid the router rasters onto. No morphology and no margins: the only additions are the exact area of a pad the island joins, which the photo cannot show under its part, and pinholes smaller than 40 pixels. `polygon_mm` is an outline within half a pixel.
KiCad needs each fill as a single polygon with its holes joined in by slits. `kicad_out::keyhole` does this left to right so slits never cross, and winds each hole against its outline. KiCad's triangulation reads a hole wound the same way as copper, and an even-odd check will not catch that. A pour read from the photo is written from `rle_px`, at image resolution, along the pixels' edges (`crack_loops`). Tracing through pixel centres left half a pixel off every outer edge.
Only another net's pad, via or routed track is cut out of a fill, and seen copper of another net is not, because the seen gap can be narrower than the design rule. A pad of another net keeps 0.15 mm, the width the photo cannot show beside metal. A via of another net keeps 0.05 mm: the photo shows no gap there, or the pour would have split, so the via's name is in doubt and the cut only stops the two shorting.
So that a refill keeps the shape, a read pour's zone carries the photographed shape in its outline: one `(polygon)` for the outline and one per hole, which is how KiCad reads a zone (the first polygon is the outline, each later one a hole). It also carries its own measured clearance to other nets, a 0.05 mm minimum thickness, and island removal off. **The refill itself is untested**: the shared service only plots and runs DRC on the stored fill, and neither refills. Setting `ZONE_RULE_CLEARANCE` writes the rule's clearance instead. On the Driver board that moves DRC's clearance count only from 506 to 510, which suggests the net class's 0.2 mm still applies and a refill would cut narrow gaps back.
`check fidelity` writes `plotAgainstRead` for each copper layer: KiCad's plot against the pours and tracks of copper.json, over the visible area and over the whole board. The whole-board figure also counts pads and vias, which the read layer does not draw.
## What connects to what
`islands.rs` gives a first reading while tracing: a pad joins an island when the copper around it is that island and no closed gap stands between them. That reading is weak, and `connect measure` and `connect attribute` replace it; see below.
A via is only reported as `inner` when a gap closes all the way round it on every face, no copper leaves it, and each photo shows domes at all (`domes::face_signature`). On the Driver board the top fails that last test, so no via is `inner`, and the photos prove no minimum layer count. The gate is conservative: it tests for domes, not clearance rings, so it could hold back a true case on another board.
The earlier test called a via isolated when the islands round it were too small to draw, which says more about grain than about copper. The 3 vias it named are real vias, looked at on both faces. At (144.488, 132.263) a line on the bottom runs straight at the via, so it probably leaves on that face. The top there shows faint arcs, the one ring-like mark seen on that face. At (105.313, 157.638) and (105.338, 158.463) the top is flat mask, which fits a via joined to a top pour as well as an isolated one. A search for dark circles on the top at ring vias with a clear view found 7 of 93 above the 99th percentile of random spots. All 7 were letters printed on a chip or small parts when looked at.
## Reading vias
`domes.rs` works from the shape of a tented via, because brightness alone also finds silkscreen, copper edges and pads. Each candidate is a local peak of a small blob response, or of what remains after 1 mm straight lines are explained. Everything measured on every candidate goes to `detect/dome-candidates.json`, so a rejection can be checked.
- **Not a line.** Opening along 1 mm lines in the four directions boards are drawn in removes tracks, copper edges, silkscreen lines and wide patches. What stays is short.
- **The right size.** The half-height blob stays within a 0.7 mm window and is under 0.62 mm long. Below 0.16 mm it must also have a clear shadow, because the smallest domes are as small as grain. Long thin straight dashes are out: a highlight on a dome bends towards the light.
- **Not on silkscreen.** Few pale pixels in the ring round it.
- **A dome on a copper edge** joins the edge's own glint, so it has no blob of its own. It is kept when its body stands proud of the line, wider than a rim, with a shadow on the far side from the light.
- **A via with a track leaving it** also has a joined highlight. It is kept on the strength of the dark circle round its pad.
- **One light.** The light's direction is measured from the bumps themselves. If a face's accepted bumps do not agree on it, that face is flat and none of them count. The Driver bottom agrees at 0.59, the top at 0.13. None of the 112 top candidates labelled was a dome: they were part ends, pads, silkscreen and edges.
Position and size then come from what each via shows. A dark circle 0.3 to 0.5 mm in radius gives both (`ring`), and the highlights inside it merge into that one via. A long thin arc lies on the near side of a via too wide to light in full (`arc`), so its offset and width are the median of the rings on that face. A round blob is the dome itself (`blob`), at its half-height centre, moved 0.03 mm away from the light. A dome on an edge takes its peak (`edge`).
**Via pads.** A dome's width is the dimple over the hole and a ring's is the clearance round the pad, so neither is the pad. A via whose surroundings are laminate shows the pad itself: `domes::size_vias` takes the median brightness at each radius round the via, so a track leaving covers only a few sectors, and the half height of the fall is the pad's edge. These read as one size whatever the via was read as, and every via takes their median, to the nearest 0.05 mm because the photo cannot tell 0.48 from 0.50. The readings are in `detect/via-sizes.json`. This rests on the vias that show a fall, about a tenth of them: vias in pours are assumed to be the same. The drill cannot be seen, so `drill_mm` stays the rule's, cut down to leave a 0.1 mm ring.
**Vias as holes, spot by spot (`viasig.rs`).** Domes need light from one side. Under even light what is left of a via is its hole: a small round dark centre where the mask sinks in or the drill is open, with a pale or light pad round it where the via stands in laminate and nothing round it where it stands in a pour. On a third board (a USB hub, board 10 of the test set) the dome reader and the pit reader both refused whole faces and kept no via at all, because grain passed the pit test by the thousand and the faces were 0.3 mm off each other, so nothing lined up within 0.1 mm. `viasig` decides each spot on its own evidence instead:
- **Every dark centre is measured** (`viasig::measure`): how far the centre stands below its rim as a share of the rim (`share`), the core at half depth (size, long over short axis, whether it closes), what surrounds it by colour against the mask colour of that photo (`Tone`: the hue most coloured pixels share, so any mask colour works; pixels are mask, pale, black or another colour), whether mask follows 0.4 to 0.5 mm out, and the ring round it (a pad's edge or a clearance circle, with the share of the circle that shows it). All of it goes to `detect/via-spots.json` with the verdict and its reason, kept or dropped.
- **The faces are laid on each other through the spots themselves** (`viasig::lay`). The deepest round spots of each face vote for a shift of the bottom within 1 mm; a shift counts when it gathers four times the votes a mirrored bottom gathers. Pairs then fit a quadratic map once 40 hold it. Board 10: 62 votes against 7, 129 pairs, the bottom 0.30 mm off. The `scale` step uses the same pairs to draw the bottom again (`viasig::relay`), which leaves the faces 0.02 mm apart at the vias; before, only 13 of 127 top vias had their bottom partner within 0.1 mm and 77 within the 0.3 mm that `islands` pairs at.
- **The depth a via has is read from the pair of photos**, not set. Depth share is cut into twenty steps. A step belongs to the via band when its spots pair with the other face at five times the rate chance gives (the density of round spots on the other face times the pairing disc) and so many pairs are under 1 percent likely by chance. The band is the run of such steps with the most pairs: 0.60 and up on board 10, 0.25 to 0.65 and 0.20 to 0.60 on the CM5, whose pits are shallower and have a few deeper non-vias above them. Within the band, a spot may stand on one face alone only at steps where four in five of the spots the other face can see are paired.
- **Kept:** a spot paired with a spot on the other face, both in their bands; a spot deep enough to stand alone whose place on the other face is covered (a part, print, a pad) or shows a faint spot; and where the other face shows bare mask and no hole, only a spot with a ring of its own. A faint spot is kept when a spot that stands alone lies on the other face at the same place.
- **Dropped, each with its reason:** a core that runs on (a gap, a part's edge), not round, a speck, too wide for a via's hole, dark all round (inside a slot or a part), metal or print all round with no mask further out (the pad of a part's pin, hardware, a label), in print with no ring, no ring among parts, too shallow, and a bare other face that shows nothing.
- **Which reader counts where:** a face with one light keeps its domes as before, and holes are added where no dome stands. A flat face takes its vias from the holes. Where the faces cannot be laid on each other through their spots (the Driver: its top shows no vias), nothing is kept this way and the pit reader's whole-face test stands as before.
Measured on board 10 by eye (8 tiles of 6 mm drawn at random from the right half, both faces, 45 vias labelled from clean crops before the reader's answer was seen; rules were developed on the left half): 42 of 44 kept are labelled vias and 42 of 45 labelled vias are kept. One of the two others is a via half under a connector that the labels missed, the other sat inside a slot, which led to the dark-all-round rule; with that and two later changes (each depth step judged on its own, the stand-alone depths step by step) the same tiles give 41 of 42 and 41 of 45, which is no longer a held-out figure. The misses: a via whose hole the mask has closed (a ring with no dark centre), one at a chip's corner whose core merges with the shadow, and its other end under print. On the CM5 audit sample in the run copy, 39 of 43 kept vias the auditor confirmed are still kept, all 14 places they called wrong (8 false vias and 6 planted) are dropped, and the kept list falls from 1155 to 946. The Driver list is unchanged, 1398. Of the 42 vias only the new reader keeps on the CM5, a look at the first version's 54 found about a third to be shadows and gaps among parts: the stand-alone rule without a ring is the weak one on a crowded board.
Not read: vias tented flat that show only a ring or a small glint in the middle. The red and the blue board of the test set (7 and 8) show theirs this way, and the reader finds no shift between their faces and keeps nothing. Tried on whole faces (2026-11-10): small bright specks on plain mask, 3,381 and 1,140 on the faces of board 7 and 4,230 and 5,363 on board 8, do not line up between the faces beyond chance at any shift (50 pairs at the best shift against 42 at the 99th percentile of the others; 221 against 196), because dust, print edges and grain outnumber the glints. By eye, of 30 such candidates per board, 3 on board 7 and 2 on board 8 are vias, all on the bottom faces where a via in a track has a clearance ring round it; 7 and 4 cannot be told. No tented via is kept on either board. Thresholds say where they came from at their definitions in `viasig.rs`.
Thresholds come from a labelled tuning sample and from overlays of the Driver photos, and say so where they are defined. Vias keep the distance the photo shows: a rule's hole spacing removed real vias in the dense fields. A pad blocks a via only on the face the via shows on, because the fields under U1, U2 and the MOSFETs run under top pads, and the old filter deleted nearly all of U1's and U2's. An open hole has no solder fillet, so it gets a smaller margin than a soldered pin.
## Attributing every piece of copper
`attrib.rs` and `conduct.rs` give every pour, track and via a net and say how they know. They never move copper: only `net`, `fieldTags`, `netEvidence` and the nets themselves change. `connect measure` reads the photos once (about 3 minutes). `connect attribute [--vias <file>]` reads what that wrote and takes seconds, so a better via list drops in without a new trace. The file is `{"vias": [...]}` or a bare array, each entry with `pos_mm` and optional `id`, `seen` and `valid`.
Connectivity comes first and names second. The order is stitch, pads, vias, then names, and a name never joins anything. `detect/connect-graph.json` holds each stitch, contact, link and group with its evidence.
**A gap map of its own** (`conduct::read_face`). The reader leans towards splitting, so its boundaries cannot say what is a real gap. A gap here is one of two things. A dark line that runs straight for at least 0.6 mm: dome shadows on the Driver bottom are at most 0.5 mm long, so length separates them where darkness does not. Or a wide area whose colour is laminate: green minus red, against the local copper level, under 0.7. Laminate reaches 0.9 of copper's brightness on the bottom photo, so brightness alone reads it as copper, and that leak joined the rails in the first version.
**Stitching.** Two pieces become one conductor where a corridor free of gaps runs between them, at least 0.25 mm wide for two pours and 0.15 mm where a track is involved. A piece must look like copper in brightness and colour to be stitched at all, and no stitch is read within 0.2 mm of a pad, because a pad's solder and glare run across real gaps. Across silk, the two sides must face each other for at least 1 mm with no gap line beside the silk. Each stitch records what lay on the meeting: glint, dome shadow, silk or a cut by the reader. The geometry stays as photographed: stitching only makes several drawn items share a net.
**Glare at fine pitch.** The glare along a QFN's edge reads as a thin strip of copper past every pin, which tied whole rows of U1 and U2 together. Narrow copper within 0.25 mm of a fine pin is left out of the pieces. Several fine pins of one part still landing on one conductor means the photo did not separate them, so those contacts stay unsettled.
**Pad edges.** A flood from the pad's own metal says what can be reached, per sector, at each corridor width. A sector counts as closed if a gap crosses it or a lit copper edge does: oblique light catches the raised edge through the mask as a thin bright line, and on crops those lines trace the clearance round isolated pads. A pad joins a pour when at least three sectors in a row are open and at most half are closed. A plated pin on the bottom keeps its own test, a valley and a bright arc in the median brightness against distance, and a hole full of solder hides it. The outline read is the library's at the place the photo shows the metal, since a traced pad's box on a part turned 45 degrees covers its neighbours.
**Following a track.** A track reaches a pad or via when its drawn centre line ends there: within 0.2 mm of a pad's outline, 0.12 mm at fine pitch, coming from outside it, and at fine pitch arriving along the pad's long side, not past it. No pin name is used. A pad with no such track and no pour stays unsettled, or `hidden` when too little of its edge can be seen. Under U1 and U2 most of it cannot.
**Vias.** On each face a via takes what surrounds it, read from its pad outward. A dark gap round most of it means it joins only a track that ends on it, and a dome's highlight does not count as a closed ring, as it does at a pad. Near a boundary of two conductors the one reachable without crossing a gap wins, and if both are, the via joins neither and is reported in `viasOnBoundary`. A pad takes the conductor of the vias through it only from at least two, clearly: one via under a part's body may only pass beneath it, which is how an inductor pin first landed on GND.
**The ladder**, strongest first: `copper` (touches a pad of that net with no gap, through stitches at most), `via` (through two vias between the same conductors, or a via with a track ending on it on both faces), `inferred` (one via track to track, or an unsettled pad), `group` (a neutral `UNK_G` name, joined to other unnamed copper or by one via to a pour, which proves nothing), `none` (`UNK_ISO`). `conflict` is off the scale: two nets meet and nothing settles it, and no name is forced. Names spread over the links with the most vias first, and two named conductors are never merged. Their meeting is reported as an apparent short in `net-evidence.json`. The score weights each rung by strength.
**The copper wins.** A pin whose only solid contact is copper named on the `copper` or `via` rung moves to that net, and `pinsMoved` lists it. A pin the map had but the copper does not support is in `unsupported`, with `inferredPins` keeping the original claim so repeating `connect attribute` gives the same answer. Pins with no name in the map that share copper get a net of their own, `CU_<pin>`. Metal no footprint explains (`PHOTO1`) can carry copper but belongs to no net. `nets.json` keeps what the copper does not contradict, so most of it is still inferred: an `INFERRED` tag on a net means not every pin of it is seen joined.
A resistor's or capacitor's end votes little, because nothing on the photo says which end is pin 1. A two-pin part with both ends on one conductor is a sliver or leak, so those contacts are dropped. `swapSuspects` lists two-pin parts whose copper disagrees with their pins.
**Order of steps.** `connect attribute` writes nets into copper.json. `check copper` and `check fidelity` read shapes, so they now record `attrib::geometry_hash`, which leaves out the fields attribution writes. Attribution no longer makes them stale, and a changed shape still does. `check mimic` and the snapshot do depend on nets, so they stay stale until rebuilt. The sequence therefore runs attribution in the copper step, before anything is built on the copper. When it has to run again later, because pin names were added to nets.json, `auto` goes back by itself (`build`, `check mimic`, the snapshot, then on), and `next` names each of those commands in turn (`auto.rs`, `plan`).
**Nets a document names.** A net the AI supplied with tag `DATASHEET` and a `source` keeps both while the copper does not contradict it: not every pin being seen joined is no reason to call a document an inference. The copper contradicts a net when one of its pins sits on copper named for another net, or when the copper moved a pin the map had off it. Then the net becomes `INFERRED`, with a test, and `contradiction` in nets.json holds the finding: the counts, the pins moved and what the net was stated as (`statedTag`, which attribution keeps so a later run can give the tag back when the contradiction is gone). `connect attribute` lists the contradicted nets. A net tagged `INFERRED` always names a test, so `check nets` cannot fail for the want of one. With no nets.json at all, `connect attribute` starts one empty and names every net from copper.
**Checking.** `tools/connect_sample.py` draws pad-to-pad connections with a seed from the graph's hash, stratified by size of conductor and graded `solid` or `unsettled`, and renders a card per connection with no net names on it. The two ends of a resistor offered as one connection are controls.
## Hidden connections are left unrouted
The default flow no longer routes what nobody can see. Routing took most of a run (35 of 50 minutes on the CM5), added hundreds of DRC errors, and drew one way to make the connections, not what is inside the board; KiCad's count of open connections barely moved with it (499 before, about 440 after, on the CM5). So the copy stops at what the photos show.
`unrouted.rs` lists what is left. A net's pins fall into pieces: each group of pins that seen copper joins (`copperGroups`, written by `connect attribute`) and each pin no copper is seen to reach, alone. A net of g pieces has g - 1 hidden connections, each from its largest piece to one of the others. Each carries the tag of what says its ends belong together: `DATASHEET` when the net cites a document, `INFERRED` otherwise, `MEASURED` once a bench probe found it joined. `build` writes them to `unrouted.json` and leaves each as a ratsnest line in the KiCad copy. The KiCad project gets one net class per kind of evidence (`SEEN`, `HIDDEN_DATASHEET`, `HIDDEN_INFERRED`, `BENCH_MEASURED`), each with its own colour and Default's sizes, so a ratsnest line shows in KiCad what it rests on and no rule changes. KiCad 10's DRC gave the same result with and without the classes on the CM5 copy, within its own run to run variation (3413 to 3416 violations, 499 unconnected each time).
KiCad's own ratsnest count is larger than the list: it counts every piece of copper of a net that the drawn shapes do not join, with or without a pin. On the CM5 copy all 499 are pieces of GND, which meet on inner planes no photo shows; `unrouted.json` holds the 57 between groups of pins.
**The layer count** is stated by `layers state` (`layerstate.rs`), not found by routing. A count comes from outside evidence only: a document (`--source`, tag `DATASHEET`) or a measurement on the board itself (`--evidence <file> --kind edge-micrograph|cross-section|xray|ct`, tag `MEASURED`, the file held by hash). Otherwise the count is `UNKNOWN` with test T-layers. Beside it the file states the minimum the photos give:
- vias ringed on both faces with no copper leaving on either (`kind: inner`): at least 4, `MEASURED`;
- at least 10 vias that show on one face while the other face is in view there and shows no via candidate of any kind within 0.3 mm, on a board where at least 10 other vias show on both faces: more than 2 layers, `INFERRED`. It is not proof: a via plugged or tented flat on one face reads the same. The three numbers are policy, not fitted to a board;
- copper read on both faces: 2, `SEEN`.
A stated count below a proven minimum is refused. `check layers` recomputes the photos' evidence and fails if the file's differs, so the minimum cannot be typed. `connect route` still works when asked and records its stackups in layers.json; a count stated from outside evidence stays as stated, and no step requires routing.
## A net's confidence is its weakest link
Attribution grades pieces of copper. A person asks about nets: is this net proven? `netconf.rs` answers from the same files (`detect/connect-graph.json`, `detect/attribution.json`, `nets.json`, `copper.json`), reads no photo and measures nothing new. `nets draw` writes the answer to `net-confidence.json` and draws it (`netdraw.rs`).
The share of a net's copper that was seen is the wrong measure. A ground net can have 95 percent of its copper on the `copper` rung and still have pins that nothing seen joins to it. So the level is defined on the connection graph, for each net:
- **Vertices.** The net's pins, and the conductors that carry its name. A conductor is copper already stitched into one piece, so it is one vertex.
- **Links**, strongest first. `copper`: a pad joined to a conductor of the net with no gap (a solid contact; among these a bolted lug counts above a pour, a pour above a track end, a track end above vias through the pad). `via`: two conductors of the net joined through verified vias (a link of tier 2). `inferred`, in this order: one via track to track (tier 3); a pad whose edge does not settle what it touches (a weak contact); one unconfirmed via onto a pour together with the pin map (tier 4, which proves nothing by itself); the pin map alone.
- **The path set.** Links are added strongest first, and a link is kept only if it joins two things not yet joined, until every pin is in one piece. That is a spanning tree that prefers the strongest links, so between any two pins it holds the path whose weakest link is as strong as it can be. Whatever the seen links leave apart, the pin map joins: every piece but the largest hangs on one pin-map link. Links to copper with no pin beyond it are dropped, because they join no pins.
- **The level** is the rung of the weakest link in the path set. That link is reported with its place (the pad, or the via), and with how many links like it the set holds.
So a pin with no solid contact to the net's copper is a member by inference only, and one such pin makes the net `inferred`. `copper` means every pin sits on one conductor. `via` means every pin is seen joined and at least one join is a verified via. The levels agree with attribution's own tag: a net of two or more pins is `copper` or `via` exactly when nets.json tags it `MEASURED` (21 of 192 on the CM5 run, none of 77 on the Driver run, checked on both).
A net of one pin has nothing to join. It is graded by how that pin meets its copper and marked `singlePin`, and the counts keep these apart: on the CM5 run 31 of the 52 `copper` nets are single connector pins with their track.
Places where a net appears to meet another (`shorts` in net-evidence.json) are not links between its own pins. They are listed per net as `disputes`, drawn as a cross, and do not lower the level. The copper in dispute carries no name and is drawn as `conflict`.
**Drawing.** A piece of copper is drawn at the weaker of its own rung and its net's level, so copper named by inference stays `inferred` on a net whose pins are all seen. Copper with no net is drawn by its class: `group` (`UNK_G`), `none` (`UNK_ISO`, stubs and vias that join nothing), `conflict`. Each level has a hue from the Okabe-Ito set and a pattern (solid, level bands, diagonal stripes, grid, dots, dark stripes), so the levels can be told apart without colour; tracks are too thin for a pattern, so the inferred, group and conflict ones are dashed. The pattern period is ten pixels at 1080p, coarse enough to survive video compression. `attribution.jpg` shows each piece by its own rung, for comparison. A per-net picture colours the net's copper by its own rung, marks pins seen joined (filled) and pins by inference (hollow), and rings the weakest link.
**Sections** (`netsections.rs`). Ground and supply rails are told by name, a rail from a signal about a rail by its control words (`VBUS_EN` is not a rail). Signals group by a shared stem: the first part of the name, with its index kept when three or more nets share it (`HDMI0`, `HDMI1`), or a stem whose members differ by one letter of a known set (`GHA`, `GHB`, `GHC`). A net whose name places it nowhere goes to the block in blocks.json that holds most of its pins.
## Datasheet pins: a second witness for nets
The copper says what is joined. A chip's datasheet says what each pin is. Until now the AI typed nets from datasheets by hand and nothing held the two against each other. `dspins.rs` gives the datasheet a file of its own and lets the program use it.
**The file.** For each identified chip the AI fetches the public datasheet during the run and writes `reverse-flow/datasheets/<ref>.pins.json`. It cites the document by name, revision and the page of the pin table, and lists every pad of the footprint, the exposed pad too:
| Field | Meaning |
|---|---|
| `part` | the reference in parts.json (`refs`: more references, for identical parts) |
| `mpn` | the orderable part the marking and package settle |
| `document` | `name`, `revision` and `page` (of the pin table) are required; `number`, `table`, `url`, `file` when there are any |
| `pins[].pad` | the pad number on the footprint |
| `pins[].name` | the pin name as the datasheet prints it |
| `pins[].type` | `power_in`, `power_out`, `ground`, `input`, `output`, `bidirectional`, `open_drain`, `passive` or `no_connect` |
| `pins[].net` | optional: the board net the datasheet's own application circuit names for this pin, when the default name would mislead |
| `pins[].expects` | optional: what the typical application shows at the pin, as a list of `{"kind", "pin", "note"}` |
The kinds of `expects`: `capacitor_to_ground`, `capacitor_to_pin` (a bootstrap or flying capacitor; `pin` names the other pin of the same part), `tied_to_pin`, `tied_to_ground`, `resistor_to_pin` (a shunt, a divider), `resistor_to_ground`, `pull_up` (a resistor to a supply the datasheet does not name), `crystal`, `inductor`, `not_connected`, and `other` with a `note` for what none of these says. `"orTied": true` on a `capacitor_to_pin` or `resistor_to_pin` says the datasheet also allows the two pins tied together (a shunt input tied to its return when the amplifier is unused).
A worked example, the gate driver U2 of the Driver run. Its marking reads DRV8323S, and the 6 x 6 mm 40-pin package with an exposed pad is the RTA package. The pin table is Table 6-3 of TI's SLVSDJ3 revision D (March 2022), pages 8 to 10, column DRV8323S. A few of its 41 entries:
```json
{"part": "U2", "mpn": "DRV8323S in the RTA package (DRV8323SRTAR or DRV8323SRTAT, same top marking)",
"document": {"name": "TI DRV832x 6 to 60-V Three-Phase Smart Gate Driver", "number": "SLVSDJ3", "revision": "D, March 2022", "page": 8,
"table": "Table 6-3, Pin Functions, 40-Pin DRV8323 Devices, column DRV8323S (pages 8 to 10)", "file": "datasheets/ti-drv8323-slvsdj3.pdf"},
"pins": [
{"pad": "1", "name": "CPL", "type": "passive", "expects": [{"kind": "capacitor_to_pin", "pin": "CPH", "note": "47 nF, VM-rated, between CPH and CPL"}]},
{"pad": "3", "name": "VCP", "type": "power_out", "expects": [{"kind": "capacitor_to_pin", "pin": "VM", "note": "1 uF, 25 V, between VCP and VM"}]},
{"pad": "4", "name": "VM", "type": "power_in", "expects": [{"kind": "capacitor_to_pin", "pin": "PGND", "note": "0.1 uF and at least 10 uF local"}, {"kind": "tied_to_pin", "pin": "VDRAIN"}]},
{"pad": "6", "name": "GHA", "type": "output"},
{"pad": "9", "name": "SPA", "type": "input", "expects": [{"kind": "resistor_to_pin", "pin": "SNA", "note": "current shunt"}]},
{"pad": "25", "name": "nFAULT", "type": "open_drain", "expects": [{"kind": "pull_up"}]},
{"pad": "32", "name": "AGND", "type": "ground", "expects": [{"kind": "tied_to_pin", "pin": "PGND"}]},
{"pad": "33", "name": "DVDD", "type": "power_out", "expects": [{"kind": "capacitor_to_pin", "pin": "AGND", "note": "1 uF, 6.3 V"}]},
{"pad": "41", "name": "PAD", "type": "ground", "expects": [{"kind": "tied_to_ground", "note": "thermal pad, must be connected to ground"}]}
]}
```
**No guessing.** A chip counts as identified when parts.json tags its value (or part number) `SEEN`, `MEASURED` or `DATASHEET`: its marking was read. A pins file for any other part is refused, so a chip nobody could read gets no datasheet witness. When there is no public datasheet, or the marking fits several parts or packages, the answer is `{"part": "U5", "available": false, "reason": "..."}` in the same file, and the flow goes on without a witness for that chip. A file that cites no document, uses a type outside the list, names a pad the footprint lacks or leaves a footprint pad out is refused with the reason. The last two usually mean the footprint is not the package the datasheet draws, which is worth knowing.
**The stop.** `auto` stops once with `AUTO NEEDS AI: datasheet pins`, in the parts step after the footprints are snapped and before `check parts`, naming each identified IC or transistor of three pads or more that has neither answer. A chip whose footprint is a bare body outline with no pads (a package nobody could match, as on the CM5 run) is not asked for: there is nothing to name. A run that passed the parts step before this existed is asked in the connect step, before the schematic is drawn. `datasheet status` prints the same list at any time.
**Into the schematic.** `datasheet apply` (run by `auto`) writes each sheet into its part in parts.json: `pins`, pad to `{"name", "type"}` with the type as KiCad's (`ground` becomes `power_in`, `open_drain` `open_collector`, `no_connect` `passive`), and `pinsSource`, the citation. The schematic generator already draws a part with `pins` as a box grouped by function with its real pin names. Pins typed by hand, with no `pinsSource`, are replaced only when a sheet exists for the part; pins a sheet gave are taken back when the sheet is withdrawn.
**Onto the nets the copper proves.** Attribution names every conductor that no net of the pin map reached. Before, such a conductor took `CU_<first pin>` when two or more pins sat on it, and stayed unnamed with one. Now a conductor that a datasheet-named pin sits on takes that pin's net name, with one pin on it or many: `net` when the sheet states one, `GND` for a `ground` pin, otherwise `<ref>_<pin name>` (`U2_GHA`), so two chips never share a name unless a sheet says so. With several named pins on one conductor the name comes from ground first, then `power_out`, `power_in`, `output`, `open_drain`, `bidirectional`, `input`, `passive`. A `no_connect` pin names nothing. After attribution, `dspins::stamp` marks such a net: `nameSource` and `source` cite the document, `nameTag` is `DATASHEET`, and the net's `tag` stays `MEASURED` when every pin is seen joined in one piece and is `DATASHEET` otherwise. Nothing is joined by this: the pins on the net are the ones the copper puts there (`copperEvidence`, `copperGroups`), and two conductors that share the name `GND` are still two pieces with a hidden connection between them. Every net, named by a sheet or by somebody, also lists the datasheet pins on it in `datasheetPins`, in nets.json and in net-evidence.json. A net somebody stated keeps its name and tag. `auto` runs attribution again whenever the names the sheets give change.
### The witness: `connect witness`
`witness.rs` holds every pin of every chip that has a pins file against the copper graph and records `agree`, `disagree` or `not visible`. It runs at the end of every `connect attribute` and by itself as `connect witness`. It changes neither copper nor nets.
**What the copper shows.** A pin sits on a conductor when its pad has a solid contact with it (attribution's tier 1). Two conductors are one when verified vias join them (a link of tier 2). An unsettled pad edge, one unconfirmed via and the pin map are not seen, so they can neither agree nor disagree. What says a conductor is ground or a supply is always another pin on it, never the pin under test: a `ground` or supply pin of any pins file, or a pin with no datasheet type that somebody put on a net with a ground or supply name.
**The checks**, for a pin with a solid contact:
| What the datasheet says | Agree | Disagree |
|---|---|---|
| type `ground` | another ground pin on the same conductor | a supply pin or an output on it |
| type `power_in`, `power_out` | a capacitor from the conductor to ground copper | a ground pin on it; for `power_out`, another driving pin on it |
| type `output`, `open_drain` | the copper reaches other pins and no ground, supply or second output | ground, a supply (for `output`) or another output on it |
| type `no_connect`, `not_connected` | nothing else on the conductor | any other pin on it |
| pads of one name on one part (VDD, VSS) | on one conductor | |
| `capacitor_to_ground`, `resistor_to_ground` | such a part from the conductor to ground copper | |
| `capacitor_to_pin`, `resistor_to_pin` | such a part from the conductor to the other pin's conductor (any ground copper when that pin is a ground pin) | both pins on one conductor, unless `orTied` |
| `tied_to_pin`, `tied_to_ground` | on one conductor | `tied_to_ground` on supply copper |
| `pull_up` | a resistor to copper a supply pin sits on | the only resistor there goes to ground copper |
| `crystal`, `inductor` | such a part on the conductor | |
An `input`, `bidirectional` or `passive` pin with no `expects` has nothing copper can show, and is `not visible`. A pin's verdict is `disagree` if any check disagrees, `agree` if one agrees, else `not visible`.
**Absence is not disagreement.** An inner layer may hold the capacitor's other end or the tie the datasheet asks for, so a part or a join that is not seen is `not visible`. `disagree` is kept for what the copper positively shows wrong.
**The net list as a second pass.** The same checks run on nets.json alone: a pin "sits" on its net, a net is one conductor, and a net's name stands for its pins (unless a datasheet pin gave the net that name, which would let a pin prove itself). Here the list is a whole claim, so two pins the datasheet ties that the list keeps on two nets do disagree. This verdict is recorded apart as `netList` (`agree`, `disagree`, `not stated`). It is circular wherever somebody typed the nets from the same datasheet, so it never counts as the copper's agreement. A third pass runs on the list as it was stated before the copper moved pins (`inferredPins`): where an expectation held as stated and no longer holds because the copper moved a pin of the part involved, that is a `netList` disagreement naming the move. Either the copper there is misread, or the part somebody took for the datasheet's capacitor is not it.
**Findings.** Each disagreement is a finding in `detect/witness.json` with a picture under `evidence/witness/` and a test. The picture has two panels: the board photo close up at the pin (red), and the whole face with the pin, every pin it disagrees with (yellow) and the vias that join them (cyan). The finding says how the copper joins the pin to what it disagrees with: the same copper, or which verified vias. `evidence/witness/findings.md` lists them with every pin's verdict and reason. The test (`T-witness-<pin>`) is named on the net in nets.json, and `tests generate` writes it with the pins to probe; it goes when the finding clears. A finding never rewrites copper or nets.
**Unresolved copper.** When one piece of copper touches three or more pins of a chip without settling any, and the datasheet says pins among them cannot share a net (ground and a supply, a supply and an output, two outputs), the witness says so with a picture (`unresolved-<ref>.jpg`). That is the reader's limit at fine-pitch pads, not a claim about the board, and it is why those pins are `not visible`.
**Per net.** Each net's row in net-evidence.json gains `datasheet`: `witness` (`agree`, `disagree` or `none`), the counts, `netList`, the datasheet pins on it and its findings; nets.json gains `datasheetWitness`. `nets draw` copies the witness into net-confidence.json and into the Datasheet column of nets/index.md. It stands beside a net's level and never changes it.
**What it found.** Driver run (the board this was built on), U2 DRV8323S, 41 pins: 4 pins have a solid contact, 0 agree, 1 disagrees, 40 are not visible; 1 of 77 nets has a witness, 35 carry a datasheet pin. The disagreement: the exposed pad's copper (the bottom pour, reached through a via in the pad) is joined through 17 verified vias to a top pour holding C19.1 and D4.1, which the net list calls VIN and VIN_LOGIC, and the same bottom pour holds Q5.5, called VIN, through a via in that pad. The photo does not settle whether the vias, the bottom pour or the typed names are wrong; the test does. Against the net list: 21 agree, 2 disagree, 18 not stated; the two are CPL and CPH, whose flying capacitor the net list took to be C37 until the copper moved C37.1 onto that same pour, and the picture shows two capacitors beside those pins that are likelier candidates. Board 10 pilot run (not used while building this), U1 USB2532, 37 pins: no pin has a solid contact, all 37 are not visible, and the witness names why: one piece of copper touches 34 of the pins. So the witness is only as good as the copper at the chip's pads, and at 0.5 mm pitch that copper is mostly unresolved today.
### Nets from the parts first: `connect pinout`
The owner's order (2026-11-11): when identifying nets, identify the part, then its orientation, then its pinout. Until then the nets started from copper alone, and a pin got a net only when the copper at its pad was resolved, which at fine pitch it mostly is not. `pinnets.rs` builds the nets from what the parts are, before attribution, and attribution then holds them against the copper like any stated net.
**The chain**, per part, in `detect/pinout.json`:
| Link | Values |
|---|---|
| identity | marking read; package only |
| orientation | pin 1 mark seen; polarity mark seen; not needed (two pads and no polarity, one pad, or a USB Type-C receptacle, whose ground, VBUS and data read the same turned half round); not determinable; not settled |
| pinout | the datasheet pin table (`datasheets/<ref>.pins.json`); the standard pinout of a connector whose type is seen (USB Type-C, by the footprint's pad names, as KiCad's `Connector:USB_C_Receptacle_USB2.0_16P` names them); names typed into the part's `pins` (silkscreen beside a header's pads); package family only (a three-lead transistor, a polarised two-pad part), which gives pin names and no nets; none |
A pinout on a chip turned the wrong way is worse than none. A chip with a pin table whose `pin1.tag` is not SEEN, MEASURED or DATASHEET gives no nets, and `auto` stops once with `AUTO NEEDS AI: orientation of chips with a pinout`, naming each. The answer is the mark that was seen, or `{"mark": "<what was looked for>", "tag": "UNKNOWN"}`: not determinable, which is an answer and still gives no nets. `check pinout` fails on the same list.
**The rules.** Every pin with a known function gets a net, and its record in the net's `pinout.pins` names the rule, the source and the tag:
| Rule | What it does | Tag of the pin |
|---|---|---|
| `ground` | every ground pin of every pinout joins GND | DATASHEET from a pin table or a connector standard, INFERRED from a typed name |
| `pin function` | the pin is on a net named for it, `<ref>_<pin name>`, with the pads of the same name on the same part (the four VBUS pads of a receptacle, both D+ pads). A supply name typed beside a header pad is the rail's own name, shared by every pad that carries it | DATASHEET; INFERRED for typed names and for CC and SBU on a receptacle whose orientation is not settled |
| `datasheet tie` | `tied_to_pin` and `tied_to_ground`: the pin joins the other pin's net | DATASHEET |
| `claimed part` | a pin with `capacitor_to_ground`, `capacitor_to_pin`, `resistor_to_ground` or `resistor_to_pin` takes the nearest free capacitor or resistor within 3.5 mm of its pad, near pad on the pin's net, far pad on ground or the other pin's net. A pin with `crystal` takes the nearest crystal within 8 mm: terminals to the crystal pins by nearness, the case pads of a four-pad crystal to ground, and each terminal's nearest free capacitor as its load. Nearest claims are served first, a part serves one expectation | INFERRED |
| no connect | a `no_connect` pin goes to nets.json `noConnect` | |
Rails of different parts are not merged by rule: `U1_VDD33` and `J3_VBUS` stay apart until copper, or a name both carry, joins them. The two reaches are policy, not fitted to a board.
**Merged on copper.** Once attribution has read the copper, `auto` builds the pinout nets once more: nets with pins on one conductor (solid contacts, conductors joined by verified vias) become one net, under the name that says most (GND, then a rail name typed from silkscreen, then a supply, then the net of a driving pin). The merge is recorded in `detect/pinout.json` `mergedOnCopper` with the pins that show it. Three merges are refused and left for attribution to raise as a short, in `mergesRefused`: ground with a supply or an output; a supply with an output, or two outputs; and two differently named pins of one part, unless one is a datasheet input strapped to its own chip's ground or supply. Neighbouring pads read as joined are far more common than a real tie.
A net is DATASHEET when every pin on it is there because a document states it, and INFERRED, with a test, as soon as one pin is there by place or by a typed name. Nothing built here is SEEN or MEASURED. Pins somebody stated a net for by hand are left to them. The nets carry `fromPinout`, and `pinoutKey` in nets.json says what they were built from; `auto` builds them again when the pin tables, the orientations or the places of the parts change.
**Copper confirms or contradicts.** Attribution runs next, unchanged: a pinout net every pin of which is seen joined in one piece becomes MEASURED; pins the copper joins to it are added; a pin on copper named for another net, or a pin the copper moves, makes the net INFERRED with `contradiction` and a test. Where fine-pitch pads sit on one unresolved piece of copper, the pads stay unsettled, the pinout decides which pin is which net, and the witness lists those pins per net as `copperCouldNotSeparate`.
**Consistent with the schematic.** Two things the first runs on other boards showed. A pin the datasheet leaves unconnected that the copper joins to a net is on that net in nets.json, so after attribution its `noConnect` entry (one the pinout wrote) is removed and the net names it in `unconnectedPinsOnCopper`; before, the schematic drew a no-connect flag on a pin nets.json had on a net, and `check schematic` failed. And several pads of one name that drive (two VCAP pins of one regulator) are one output inside the chip: `datasheet apply` gives the first the driving pin type and the rest `passive`, as KiCad's own symbols do, so the ERC does not call the chip's own tie a conflict.
**Wherever the run stands.** `auto` builds the pinout nets not only on the way to attribution: a run at the review, the tests or the report whose pin tables, orientations or copper are newer than its pinout nets, or that never had any, gets `connect pinout` first, and the flow then goes back by itself. The orientation stop names each chip, its package and place, what mark to look for on that kind of package, and the crops that show it; a `pin1` that records a mark with no tag is asked for its tag.
**Shown apart.** net-confidence.json gives each net `knownFrom` (`pinout only`, `pinout, confirmed by copper`, `stated`, `copper`); nets/index.md has a Known from column; the report tables gain "Nets by how they are known"; the schematic draws what is not seen in copper dashed, as before; `bench plan` puts the probes of INFERRED pinout nets first.
**Board 10** (a USB hub; copy of the finished run): 10 nets and 22 pins on a net before; 55 nets and 119 pins after `connect pinout` and attribution. Of the 50 pinout nets, 31 are DATASHEET and 19 INFERRED; of their 102 pins, 68 are there by a document and 34 by inference (11 parts claimed by place, the CC and SBU pads). Copper added one pin to GND and one to U1_VDD33. One disagreement: the copper joins C9.2, claimed as the VDDCR12 end of that pin's capacitor, to the exposed ground pad, so either the claim took the capacitor the wrong way round or the copper there is misread; the merge is refused and listed. The 13 pin headers have no pinout: their silkscreen names were not typed into `pins` in that run.
**Board 4** (five MOSFET channels, an LM2596S, a processor module, a USB-C port; copy of a run stopped at `check mimic`, so only `connect pinout` and `connect attribute` were run): 4 nets and 53 pins before, all copper with no names; 24 nets and 87 pins after, 7 DATASHEET and 17 INFERRED (the MOSFET pins are typed from a sibling part's sheet, so INFERRED). The copper joined three source nets and the regulator's ground pin into GND (55 pins, 46 of them seen joined in one piece), and two merges were refused and raised as shorts: Q503's gate with its source pads, and the receptacle's CC2 with its ground pad.
## The router
`connect route` is optional (see above). `route.rs` works on a 0.05 mm grid per copper layer. Every piece of copper stamps a keep-out for the centre line of the narrowest track: the copper's own shape, plus the clearance, plus half a track. A cell holds a count, a sum and a sum of squares of the net ids stamped on it. All three agree only when every stamp is the same net, and subtracting a stamp restores the cell exactly. A wider track or a via tests a ring around the cell. This is exact, which is why the router's own copper produces almost no DRC errors.
Only what cannot move blocks: pads, holes, vias seen on the board and the board edge. Seen copper of another net costs extra to cross, six times its length if its net is unknown and twelve if known. Treating pours as walls left 235 of 245 connections open, because the copy tiles nearly all of both outer faces. A real via passes through a pour and the pour clears around it, and the fill here is computed after routing, so it clears too. The length that still crosses seen copper is reported, because it measures how far each stackup departs from the photo.
A connection that finds no path is searched again with routed copper passable at a cost. The connections on that path are ripped up, along with whatever landed on them, and queued again; the congested tiles get dearer. When passes stop improving, that stackup is the limit and the caller jumps two layers.
Inner layers are planes or signal layers. A plane net needs only a via per pin. On a stackup of n layers, the first plane net sits next to the top, the second next to the bottom, and everything between is routable. So 4 layers has no inner signal layer, and 6 has two.
## How confidence is computed
The review used to stop when a pass changed nothing, with levels (High, Medium, Low) typed into `work/confidence.json`. Vias were rated Medium while up to a third of them were false, so a self-assigned level has been proven wrong. Now `confidence.rs` computes a number from 0 to 100 per category from the run's files, every time it is asked. Nothing reads a score back: `review.json` and `confidence-scores.json` only record what was computed, and `check review` recomputes and compares.
**A category's score is the lowest of its ceilings, rounded down to 0.1.** Rounding down means 89.96 prints as 89.9. `confidence` prints every ceiling and its inputs, and the report carries the same text.
**The audit ceiling** is the only one that needs independent evidence:
- No complete audit: 40 (`NO_AUDIT_CAP`), and the limit says "no independent evidence". This number is policy, not a measurement: a claim nobody checked sits below a coin toss. For layers the cap is 30.
- A complete audit: for each stratum, `100 x L x u`, and the lowest stratum sets the ceiling.
- `L` is the Wilson 95 percent lower bound on the share correct, with `z = 1.96 x sqrt((N-n)/(N-1))`, `p = k/n`, `L = (p + z^2/2n - z sqrt(p(1-p)/n + z^2/4n^2)) / (1 + z^2/n)`. `n` is verdicts on items unchanged since they were judged, `k` how many of those are `ok`, `N` the stratum's current size. `cannot_tell` counts as not ok. With no errors from a large population it takes 35 items to reach 90, so 5 of 5 from 1000 is worth 56. Auditing all of a small population gives `z = 0` and `L = k/n`: no sampling error is left, only the answers.
- `u` is the share of the stratum's current items unchanged since the draw. A new or changed item was never eligible for the sample, so the bound says nothing about it.
**Measured ceilings** come from the files and images. They can only lower a score: the program checking its own reading is not independent, and some rest on tags an AI typed, which is why none can lift a score past the audit.
| Category | What is audited (strata) | Measured ceilings |
|---|---|---|
| identity | each part's stated identity | parts with an identity tagged SEEN, MEASURED or DATASHEET / parts |
| values | each part's stated value | parts with a value so tagged / parts with a value |
| blocks | each part's membership of its block | parts in a block so tagged / parts |
| placement | each part's outlined pads against the photo | parts located by `snap.method` pads, body centre or hole centre / parts; layers against the photos |
| copper | random points of each face where the photo can show copper, away from pad boxes and from anything but plain mask (see below): `copper` where the copy has copper, `bare` where it has none | drawn mm2 / mm2 read as islands, lowest face; layers against the photos |
| vias | `kept` vias, and `dropped` candidates with no via within 0.3 mm | 100 - share of vias on pale pixels (more than a quarter of the square reaching 0.35 mm around the via with min(R,G,B) > 135, a rule from these photos and an upper bound, since a dome's highlight is pale too); layers against the photos |
| connectivity | for each `pin` with a net, the join of its pad to copper the copy drew; for each `island`, one join it is seen to make; pins and islands that show none counted as not correct (see below) | named pour area / pour area; named track length / track length; pins agreeing with their copper; connections KiCad finds closed; connections joined by seen copper, or the outside-evidence bound if higher |
| layers | the layer count and each plane, outside evidence only | below 30 without it: `min(30, closed / connections at the proposed stackup)` |
Copper samples points rather than kept items, the claim says what the photo shows at the point, and vias sample what was dropped as well as what was kept, because a sample of kept items cannot notice what is missing. Connections joined by seen copper are `1 - connections routed / (pins - nets)`: what the router had to add is by definition not what the photos showed.
**Layers against the photos.** The owner's goal is that what the KiCad layers draw agrees with the photos wherever a photo shows that layer. `fidelity.json`, written by a separate program, limits copper, vias and placement. It is read by what its keys say and unknown fields are ignored: a figure (`score`, `agreement`, `fidelity`, `match`, `value`, `percent`, `pct`, `overall`) under a key named for the category counts, copper also takes any `*.Cu` key and placement `parts` or `footprints`; failing those, a top-level figure. The lowest figure found is the ceiling, on a 0 to 100 scale, or a fraction if every figure found is at most 1. With no file, no figure for the category, or a file older than what it scored (by the hashes in its `inputs`, `files` or `hashes`, otherwise by file time), the input is reported missing and the category is capped at 40.
**Overall is the lowest category.** An average lets 100 on placement hide 20 on connectivity, and a product falls with the number of categories instead of with what is wrong. A copy is as trustworthy as its weakest part, and the lowest category names what to fix next. It is now reported as two figures; see the next section.
### Two scores
One overall figure was always 0: connectivity, layers and the values of unmarked parts cannot be earned from photos, so every board ended STALLED at 0 and the number showed no progress. Nothing about the formulas was wrong, so none of them changed. What changed is the grouping (`twoscores.rs`). Every ceiling is one of two kinds, and each kind has its own overall figure:
- The **photo score** is the lowest ceiling photos can earn. The review loop's target (90) applies to it.
- The **bench score** is the lowest ceiling that needs the real board or an outside document. It is printed beside the photo score with what would raise it, and the loop never waits for it.
A category's score is still the lowest of all its ceilings, so the lower of the two scores is exactly the lowest category, which is what the single overall figure was. A test holds this on every state of the fixture (`regrouped`).
| Category | Photo ceilings | Bench ceilings |
|---|---|---|
| identity | audit; identities tagged as seen | none |
| values | audit of marked parts; marked parts with a value tagged as seen | audit of unmarked parts; unmarked parts with a value tagged as seen |
| blocks | audit; parts in a block seen on the board | none |
| placement | audit; parts located by a measurement; layers against the photos | none |
| copper | audit; copper read that reached the copy; layers against the photos | none |
| vias | audit; vias not on pale pixels; layers against the photos | none |
| connectivity | audit of the joins a crop shows (islands with their share that show a join, pins as claimed); pins that agree with their copper | pins shown joined, by a crop or a bench probe; connections the photos show or outside evidence proves; copper with a net, by area and by length; connections KiCad finds closed (only on a routed copy) |
| layers | none | all of them |
A ceiling not listed as bench is a photo ceiling, so one added later counts towards the target until someone decides otherwise.
**How the two mixed categories were taken apart.**
*Values.* A photo shows a marking and nothing else of a value. The population has two strata, `marked` (the part has a marking that was read) and `unmarked`, and the old ceiling "values tagged as seen" is computed per stratum. The audit samples and bounds each stratum on its own, as it does for copper and vias. The marked stratum is the photo figure and the unmarked one the bench figure. Neither is easier than the pooled figure was: the pooled share lies between the two.
*Connectivity.* The audit's pin figure was `100 x L x u x (pins with a join / pins with a net)`. The first three factors say whether the joins put forward are right, which a crop can show. The last says how many pins show no join at all: connector pins named by a pinout, pads under their part, pads joined through a via. Those need the board. So the photo ceiling is the lower of the island figure, unchanged with its own share, and `100 x L x u` for pins. The bench ceiling is the old pin figure in full, plus the pins a recorded bench probe found joined: `100 x (L x u x pins with a join + pins probed) / pins with a net`. With no probe recorded the lower of the two is the old figure. With no finished audit the two caps are `min(40, 100 x islands with a join / islands)` and `min(40, 100 x pins with a join / pins with a net) + 100 x pins probed / pins with a net`, whose lower value is the old cap. An island stays a photo matter because it is copper the photo shows: one that joins nothing was misread or its via was missed.
Copper with a net is a bench ceiling because a name reaches a piece of copper only through a pin it is seen joined to. Copper that reaches no pin on the outer faces, a track between two vias for instance, gets its net from a probe or an X-ray. "Connections KiCad finds closed" judges a routed copy and exists only when `connect route` was run.
**A run that never routed.** "Connections the photos show" is `1 - hidden / (pins - nets)`, with the hidden connections counted from nets.json (see "Hidden connections are left unrouted"), plus those a recorded bench probe found joined. On a routed copy the router's own count stands in for `hidden`, capped at `pins - nets`: the router also counts copper islands that reach no pin, and uncapped that printed a negative number of seen connections. For layers, an `UNKNOWN` count scores 0, because nothing is stated to be confident in, and a count stated from outside evidence sits under the cap of 30 until an independent verdict with that evidence attached raises it, as before.
**What the loop does with them.** TARGET MET, STALLED and the rise of 0.1 are all read on photo figures. A pass recorded before the two scores holds no photo figure, so the first pass after it counts as a rise and the next one can stall. A category waits for an audit only when an audit is what holds its photo figure, and a category with nothing to audit (a board with no vias) waits for none. `confidence`, `review pass`, `check review` and `report` print both scores; report.md carries the line with both, and may not claim a confidence above the higher of them.
**What the photo score still cannot reach.** It is the lowest of seven categories, and each has a ceiling an honest run can leave low. Blocks reach the target only when silkscreen or a marked part with its datasheet backs each block, since a function is otherwise an inference. Identity needs every part read from its crop, and the default is `UNKNOWN`. The copper and connectivity audits count `cannot_tell` against the score, and under parts, silk and glare a crop often cannot tell. Vias and placement are capped at 40 when `fidelity.json` has no figure for them. So a careful run on good photos can raise the photo score, and a thin one leaves it at 0, as the CM5 run does (blocks 0.0, identity 4.6): the number now moves with the work, but it does not say the copy is right inside the board. That is the bench score.
### The bench passback
`bench plan` and `bench ingest` (`bench.rs`, docs/bench.md) are optional and come after the run is complete. The plan orders one continuity probe per hidden connection by how many inferred or unknown items a `joined` answer settles: the connection, every pin of the far piece, and the net itself when it is that net's last hidden connection. Each probe names two pads, the easiest to touch of each piece (a plated hole or test point before a connector pin before a small pad; never a pad under its own part), and has a picture: for each pad a close view with a ring on it and the whole face with a cross, the bottom face turned back to how the person holding the board sees it. After the probes come the unmarked parts whose value is not known, the layer count, and the other measurements tests.json calls for.
`bench ingest --file <results.json>` records each answer in `bench/measured.json` with who measured, when, with what, and the hash of the file it came in, which is kept in `bench/ingested/`. A probe found `joined` makes its connection `MEASURED`; a net whose every hidden connection is joined is tagged `MEASURED` in nets.json. A measured value is written to the part with `fieldTags.value: MEASURED` and where it came from. A layer count with its method becomes the layer statement. Nothing else is touched, and the scores move because `confidence` computes them again: the two pads of a joined probe count as pins shown joined, the connection counts as proven, the part counts as a value tagged as seen. An `open` answer is recorded and flagged, and upgrades nothing. An answer for a probe that is not in the plan, or whose connection is no longer in nets.json as planned, is refused with the reason. `connect attribute` writes net tags again from the copper, so it stamps the recorded answers back afterwards. Ingest changes artifacts, so the checks that read them go stale; `auto` runs them again.
### `auto` and `next`
`auto` runs every program-only step in order, each as the flow's own command, and ends with one of three lines: `AUTO NEEDS AI: <input>` (exit 2) with hints that name the file or command wanted, `AUTO COMPLETE` (exit 0), or `AUTO FAILED` (exit 1). `next` prints the same next action without running it. Both come from one function (`auto::plan`) that reads the files as they stand. The AI's inputs it stops for are: the scale (`scale --body` or `scale --holes`), parts.json after `parts init`, blocks.json, whatever a failed check lists, the independent audit verdicts a review pass waits for (each sheet, sample and the exact command), and report.md with its line. Pin names in nets.json are welcome at any time and never waited for. `layers trace` and `check fidelity` feed the review and are not gates: if they fail the flow goes on and the review counts the missing figure against the score. A command that passed and is still next ends the run with AUTO FAILED instead of looping. `tests generate` drops a generated test whose items are all gone, so `check tests` cannot fail on a test nobody wrote.
### The run video
`video build` (run by `auto` once every step is done) makes a video of about two minutes from the run folder. It is not a step of the flow file and gates nothing: when it fails, `auto` records the reason, ends with `AUTO COMPLETE: every step is done; the video was NOT built: <reason>`, and does not try again until the report passes on changed files.
- **Who computes what.** The program writes `reverse-flow/video/work/facts.json`: both scores as the files stand with the ceiling that limits each category, the review's verdict, each step's state, the count of hidden connections, every part's outline from the footprint it was built from, and whether `fidelity.json` still matches the copper and parts it scored. The tool (`tools/video/`, Python) reads everything else straight from the run's files. Each figure shown or spoken goes through one function that records its source; `video.json` lists them per scene.
- **The scenes** are fixed (`scenes.py`, `SEQUENCE`): title, photos, outline and scale, parts, blocks, copper and vias, the KiCad copy, nets, schematic, scores, tests, end card. Each scene checks its own inputs and is left out with a stated reason when one is missing. A scene that raises an error is left out the same way; the build goes on.
- **Pictures.** The camera moves over the run's own pictures: the photos, `board/top.jpg` and `bottom.jpg`, `evidence/outline-*.jpg`, `evidence/fidelity-F_Cu.jpg`, `nets/confidence-<face>.jpg` (cut to its board panel by the layout `nets draw` uses, and checked against the picture's size), `nets/net/<name>.jpg`, `schematic/*.png`. Boxes on parts, block outlines, copper, dimension lines and the test list are drawn at build time from `parts.json`, `blocks.json`, `copper.json`, `scale.json` and `tests.json`. KiCad's picture of the copy is `kicad-cli pcb render` of the board file, cut to the board and laid over the photo by its outline; when the two outlines differ in proportion by more than 2 percent (a part overhangs the edge) it is not used. With no `nets/` folder the copper is coloured by `netEvidence.rung` from `copper.json`.
- **Time.** Each narration line is one take of the voice, so a caption starts where its line starts. Lines marked as required are always spoken; the others carry an importance and are added, most important first, while the total stays under 125 seconds. If the total is under 118 seconds the scenes are held longer in proportion to their weight, none more than doubled. A video outside 110 to 130 seconds is still written and `video.json` says why.
- **Frames** are a pure function of time, made by two processes side by side, each feeding its own ffmpeg (H.264, rate capped at 3.5 Mbit/s); the two parts are joined without encoding again and the voice is added as AAC.
### The audit
`audit draw` lists the category's items, each with a hash of the fields its claim rests on, so a new field added by another program does not make an audit stale but a moved via does. The seed is `sha256(category, population hash, draw number)`: nobody chooses it, and the same files draw the same sample. Each stratum gets a random order from the seed, and a sample is a prefix of that order, so a larger sample extends the old one and keeps its verdicts. The default size can reach the target with one wrong answer.
`audit verdict` records `ok`, `wrong` or `cannot_tell` per item, with who and when, in an append-only `verdicts.jsonl`. It refuses a verdict on the wrong sample, on an item not in the sample, on an item changed since the draw, and from a thread that the ledger shows produced the run. One answer per item, replaced only by one with a new evidence file.
Read back, nothing in the audit is trusted. The seed and every sampled item must be what the population gives, or the status is `tampered` and the audit counts for nothing. A sample counts only when complete, so bad answers cannot be skipped. A current sample cannot be redrawn, so a bad one cannot be thrown away; only a changed population or a voided sample allows a new draw. About a tenth of each sample (at least 3 items) is planted false claims: an empty spot called a via, a real copper point stated the other way round, a part with another's identity, outlines shifted by 2 mm and 1.5 mm, a join whose other end is drawn where there is none. `ok` on one voids the sample.
**What this does not stop.** Anyone who can edit files can work out the controls from the seed, or write verdicts in another thread's name. The checks catch a careless auditor and a casual edit, not a determined forger. An outside evidence file is held to its hash, but the program cannot judge what the file shows.
### The copper audit
The first copper audit scored 9.4: 31 ok, 6 wrong, 79 cannot_tell of 116 crops. Its auditor could judge dotted pours, pads and dark grey bands and nothing else, so the score measured what a raw crop can show, not whether the copper was right. Four things changed. None of them makes a wrong item pass, and `cannot_tell` still counts against the score.
**The claim.** It says what the photo shows at the ring's centre: copper under the mask, or bare board with no copper under the mask. The auditor has the photo in front of them, not the copy.
**The panels.** Each crop has four, from `copper_crop`, whose only inputs are the photos and the position, so it cannot carry the copy's answer and it treats a planted claim as it treats a real one. Top left is the raw photo, 6 mm across. Top right is the same window stretched: the mean of the face's two photos, because their grain is independent noise (see "The two photos of a face"), blurred lightly, and each colour scaled between its 3rd and 97th percentile over plain mask in the 12 mm round the point. Scaling each colour on its own is what does the work: copper turns green, and laminate turns purple or dark blue, on both faces, with no threshold chosen for the point. Using plain mask alone stops silk, metal and parts setting the range, and a minimum span of 8 stops flat mask showing nothing but grain. Bottom left is 18 mm across, to follow a gap line to the ring. Bottom right is 3 mm across, for a centre near an edge. The tracer's lighting model is not used, because the panels must not lean on the method they check. The contact sheets show the top right panel.
**What is left out.** A point goes before the draw only when a part's pad box covers it, as before, or when the pixel or one within `fidelity::BESIDE_PX` is not plain mask, the rule `check fidelity` scores copper with. The class map comes from the photos each time and is never read from a file, the copy's copper is not an input, and a test holds that emptying the copy leaves the set unchanged. Dark mask, gap lines, track edges and bundles are all plain mask, so they stay. `audits/copper/exclusions.json` gives the count by material, by face and by the copy's answer, and how the rule changes the share of points in the regions read worst: per face, the quarter of 5 mm tiles where the copy disagrees most with `fidelity::copper_read`. `confidence` and the sheet state the figures. On the Driver board, the rule takes 1211 of 6694 points (18.1%: silk 778, exposed metal 227, part body 145, hole 61), 18.2% of `copper` and 17.8% of `bare`. Pad boxes take 2200 of 8894 before it. The worst tiles hold 19.0% of the points before the rule and 17.7% after. The tilt is mostly the top, 16.0% to 13.1%, where the worst tiles are crowded by parts and silk. The bottom goes from 20.5% to 19.6%.
**The controls.** A control used to be a point on the 0.05 mm grid deep inside a large pour, always called bare, so its coordinates, its id and its picture all gave it away, and an auditor who answered ok to every copper claim was never caught. A control is now a real point of the population, at the position the population gives it, stated the other way round. It has the id, position and record a real item would have, and only its answer is false. Controls come from the far half of each stratum's order, so extending a sample does not reach them.
A planted claim must be false beyond doubt, or an honest auditor voids the sample. The first rule asked only that the copy and `fidelity::copper_read` agree at nine points out to 0.3 mm. On the CM5 that planted two samples' worth of doubtful controls, and both samples were voided by auditors who had looked properly. Six of the 22 lay on a part's body, because the class map was built as if only the top carried parts (it now measures that with `photomat::carries_parts`, as `check fidelity` does). One lay in a letter of silk. One lay in a teal triangle inside a lighter stitched pour, which both readers call copper because they share a threshold, and which a person calls bare because the pour beside it is lighter. Two readers agreeing is not the same as the photo being plain.
`Planting::doubt` now gives the first reason a point cannot carry a planted claim:
- within 1.0 mm of the board edge, 0.8 mm of a part's pad box, or 0.9 mm of a via or a via candidate on either face;
- out to 0.6 mm round the point (`CONTROL_REACH_MM`; the ring is 0.4 mm), any pixel that is not plain mask `BESIDE_PX` clear of silk, metal, holes and bodies, or where the copy or the threshold scorer reads the other surface;
- fewer than 5 percent of either surface, by the scorer, in the plain mask of the 12 mm the panel is stretched over, so there is nothing to compare the point with;
- the green round the point not clear of the middle of the stretched panel: its 5th percentile under 0.7 of the way from the panel's dark level to its light level for copper, or its 95th over 0.3 for bare board. These are the levels the auditor's top right panel is drawn with;
- in any of red, green and blue where the two surfaces' medians in that window lie 8 levels apart, the median round the point less than 0.6 of the way from the other surface's median to its own. Round a point in the teal triangle the median is 123 in green, which is the copper median of its window, and 53 in red, which is the bare median (copper 75): green says copper and red says bare. Plain pours sit at their copper median in both.
None of the 22 controls planted on the CM5 passes: 6 for a part, 6 for a via, 3 for the edge, 3 for silk or a body, 2 for the copy's own edge, 1 for the scorer's, and the one in the teal triangle that voided the second sample for its level in the panel. Of 755 points looked at there, 20 pass, every one of them copper stated as bare: no bare board on that board is 1.2 mm wide and clear of everything else. Measuring which faces carry parts also takes 464 points on part bodies out of the population there (1975 to 1511), which the photo could never show copper at. `exclusions.json` gives the count by what the claim states and by reason refused, and `audit draw` says when only one way can be planted, because an auditor who calls every claim of the other kind ok is then not caught. A draw that cannot plant the number a sample needs refuses, says how many points passed and what refused the rest, and leaves the current sample as it was.
`audit preview [--n 24] --file <folder>` writes crops and a separate `key.json` for checking legibility, from points clear of the sample and the controls, outside the audit. On a blind tuning sample of 24 points judged by the method's author, raw panel first, then all four, 9 could be decided from the raw panel and 19 from all four, and 18 of those 19 agreed with the copy. Only an independent auditor's figure counts.
### The connectivity audit
An island's claim used to be its net: "outlined copper isl-bottom-292 is on net GND". No crop shows a net. On the CM5, 60 of 62 island claims came back `cannot_tell`, one `ok` and one `wrong`, so the island figure measured what a crop can show and nothing about the copper.
**The claim** is now one join the flow holds for that island, worded as what the crop shows: the copper outlined in pink reaches the pad boxed in blue with no break; it is one piece with the via in the blue ring; it is one piece with the copper outlined in blue, with no gap at the blue cross. The joins are read from what `connect attribute` wrote, and only where they are near enough for one crop to show both ends (`JOIN_REACH_MM`, 0.3 mm from the island's own drawn copper):
- a pad whose contact attribution kept as solid on this island's node, on this face, drawn in the outline and at the place the photo shows its metal (`detect/attrib/measure.json`, `core`);
- a valid via attached to this island's conductor on this face (`detect/attribution.json`, `vias`);
- copper stitched to one of the island's pieces, at the place the stitch was read (`detect/connect-graph.json`, `stitches`), and other copper drawn from the same piece.
An island usually has several. The one claimed is the one nearest a name, in the order of attribution's own ladder: a pad before a via before other copper. Among several of that kind a hash of the island's id decides, so nobody picks. The item's hash covers the island's shape and net and the other end's name and shape, so a changed join makes the verdict stale. The net is no longer in the words. No claim of this audit states a net: a photo shows joins, and a net's name rests on whatever its `source` cites.
**The crop** is centred on the join. Its first row shows the island as far as 12 mm allows, raw and marked, and its second row is 3 mm across, or wide enough to hold the pad or the via's ring. A track is outlined beside its edges, not drawn down its middle, so the mark does not cover the copper.
**Islands with no join.** An island that touches no pad, no valid via and no other copper within reach has no evidence a crop can show. It is not put forward as a claim, least of all a claim that it is on a named net, and it counts as not correct: the island stratum's figure is `100 x L x u x (islands with a join / islands)`. On the CM5 that is 1864 of 2335, so 0.798. If no island has a join, or the files `connect attribute` writes are missing or were measured on other copper (`copperSha`), the factor is 0.
**The island controls.** A join stated of another island's marks would prove nothing, so an island's false claim keeps its outline and words and has its other end drawn where there is none: a pad's box on plain mask clear of every part, a via's ring on plain mask with no via or candidate within 1.5 mm, a second copper outline wholly on a part's body. A face with no parts cannot carry the third kind.
**Pins.** A pin's claim was its net too: "pin J2.86 is on net GND". The first independent audit of the join claims (CM5 run 5) settled 56 of 61 island claims from the crops and returned `cannot_tell` on all 49 pin claims, because a boxed pad shows no net. A pin's claim is now the same kind of join, read from the pad's side: "pad boxed in pink (pin C112.2): is joined to the copper outlined in blue (isl-top-21) with no break between them". The evidence is the pad joins above: a contact attribution kept as solid (`connect-graph.json`, `contacts`, tier 1, on the pad's own face), to a drawn pour or track of that node within `JOIN_REACH_MM` of the pad's outline. The box is the pad where the copy read its metal, the crop is centred on it, and it has the close row. Copper read from the photo comes before a stub the copy drew to reach the pad; among several a hash of the pin decides. The net is not in the words and is in the item's hash, so a pin moved to another net is another claim.
**Pins with no join.** A pin with a net and no such join has nothing a photo can show: a connector pin named by a datasheet's pinout, a pad whose ring did not settle it, a pad joined only through a via. It is not put forward, and it counts as not correct: the pin figure is `100 x L x u x (pins with a join / pins with a net)`. The status line gives the count, and beside it how many of those unproven pins are on a net that cites a source outside the photos (a `source`, or a `nameSource` that is a citation and not `none`, an inference or one of attribution's own class words other than `datasheet`). Those are named, and named by something a photo cannot confirm, so they stay unproven here: only outside evidence on the board itself raises them. The outside-evidence bound on pins is scaled by the same share, since evidence on the pins judged says nothing of the rest.
**No cap above what an audit could reach.** With no finished audit a category sits at 40. For connectivity that cap is now `min(40, 100 x the lower of the two shares)`, so a copy in which nothing shows a join does not sit at 40 for want of an audit that could only give 0.
**The pin controls.** A pin's net swapped for another pin's was the old control. A join stated in another claim's words proves nothing, so a pin's false claim keeps its words and its copper and has its own pad's box drawn on plain mask clear of every part, where no pad is. Its crop is centred on that empty box, as a real pin's is on its pad. A draw plants a false pin join as often as a false island join, however many more islands there are.
**Pad boxes.** The box is the library pad's size at the place the copy read the pad's metal. Where a box spans two parts or sits on silk, the part is wrong, not the crop: on the CM5 run 3, C98 is an 0805 laid across the gap between two smaller capacitors, one of which is also in the copy as C66, and C43 is a silk colon read as an 0201. Those belong to placement and identity.
### The loop
`review pass` re-measures, draws any audit that is missing, scores, records, and answers:
- **TARGET MET:** every category at or above the target (default 90, `--target`; every line names any other value).
- **CONTINUE:** do what it names, then pass again.
- **STALLED:** a full pass raised no category by at least 0.1 above its best so far. It names each category below the target, what limits it, the one thing most likely to raise it, and the outside evidence photos cannot replace.
- **STOPPED:** the pass limit (default 20, `--max-passes`).
A pass is full when no category below the target is held by an audit that is missing, unfinished, void, stale or too small to reach the target. So repeating `review pass` without doing the audits never gives a stall, only CONTINUE, and at the limit a STOPPED that `check review` refuses. `check review` recomputes the scores and the verdict, and fails unless the target is met or the stall is real. `report` refuses a report.md without the computed confidence line, or with a confidence percentage above the real number.
The snapshot step records `copper.json` and `parts.json` by hash, and goes stale when either changes, which holds the later steps back in `steps`, `next` and the report. Run again, it keeps the old copy as `<name>.old<n>`.
## The schematic
The first schematic was one box per part on an A0 sheet, a label on every pin, no wires, and a no-connect flag on every pin with no net. Nobody could read it, and the flags were false: a no-connect says a pin is unused, when the truth was that its net was not known.
`sch.rs` now draws from the same four files as the board (`parts.json`, `blocks.json`, `nets.json`, `net-evidence.json`), so the two cannot disagree. How it lays a block out:
- **Anchors and satellites.** A part with three or more pins, or a connector, is an anchor. A two-pin part with a standard shape, or a one-pin part, is a satellite. Each satellite is given to one anchor pin on a net it shares: a signal net before a rail, an IC before a connector, then the nearest pad on the board. So a decoupling capacitor lands on the supply pin it sits beside, which is the owner's rule.
- **Strips.** Each anchor pin starts a strip straight out from the pin. Satellites to a rail hang from it (down to ground, up to a supply), one satellite in series sits in line and the strip goes on along its far net, so a resistor, an LED and the rail read as one run. A net with pins elsewhere ends in a label at the end of its wire with the text running back along it; a net that leaves the sheet gets a hierarchical label and a pin on the root sheet.
- **Pitch.** A box opens its rows until every strip has room. A library symbol keeps its own pitch, so a strip with parts on it starts beyond the labels of the pins around it; when that would take it too far the pin keeps a label and its parts are drawn beside the symbol from the same label. Supply pins on the top edge of a library symbol share one rail wire above it with their capacitors in a row.
- **What is left.** Satellites no anchor took are drawn as runs from a label or a rail symbol, and single parts with nothing known in rows, each end showing its rail or the `?` marker.
- **Sheets.** Groups are packed onto the smallest paper that takes the block (A4, then A3, then more A3 sheets).
Evidence comes from the copper attribution: a wire is solid when `net-evidence.json` says the pad it leads to joins that net's copper, dashed otherwise. A `?` marker is a small symbol whose reference starts with `#`: KiCad leaves it out of the netlist and the BOM, and the pin it sits on is not reported as open.
Three things check it. At every build the generator works out the connections of the sheets it drew from their geometry and compares them with `nets.json`. `check schematic` runs KiCad's ERC and compares the netlist KiCad itself exports with `nets.json` pin by pin. And the sheet rules in `schcheck.rs` are written apart from the generator, from the owner's design process (labels on wires, titled blocks, no decoupling-only block, text sizes, nothing over a pin or a body).
The schematic has its own place in the sequence `next` and `auto` follow: in the connect step, after the nets are attributed and checked and the layer statement is checked, `auto` draws the sheets whenever parts.json, blocks.json, nets.json or net-evidence.json are newer than them, then runs `check schematic`. The check records those files and every sheet by hash, so a later edit makes it stale and the flow comes back to it before the report. A failed check stops `auto` with the list of what is wrong. `report` then lists the sheets in the tables and writes one PDF and a picture of each sheet under `reverse-flow/schematic/`, so the report and the video show them without KiCad.
Two KiCad behaviours cost time and are worth knowing. A project file with no `schematic.drawing` section makes every wire and pin a hairline, so `rules.rs` writes one and the wires carry their own width. And the ERC calls a PWR_FLAG wired only to a power symbol an open pin when both are power symbols, so the flag here is an ordinary symbol with a power output pin.
## Why not AI Flow's router
The first version of this flow called `adom-aiflow route`. It routes signals only on the outer two layers, so more layers could not close anything. It also does not treat seen pours as occupied: on the Driver board, 495 of 505 new DRC errors at 4 layers involved a zone, with vias placed straight through phase pours. The timeline page is still AI Flow's.
## Photo intake: sorting an unlabelled set and stitching each face
`init --photos` takes every photo of a board with no labels. `stitch` (run by `prep`) turns them into one image per face. The work is in `tools/stitch.py` (Python with OpenCV, shipped inside the binary and run by it; `src/intake.rs` records the call in the ledger and hands the mosaics on as the run's photos). Its thresholds were set on the eleven test boards of 2026-11-10 and say so where they are defined.
**Which photos belong together.** Every photo is matched against every other with SIFT features at three sizes (long edge 1600, 800 and 400 px), so that a close-up shrunk to 400 meets a phone overview at 1600. A similarity is fitted by RANSAC. Four kinds of false fit turned up on the test photos, and each has its own guard:
- The table under two photos matches itself when the camera did not move. A fit that leaves the frame where it was is set aside and the rest fitted again.
- A fit with a scale outside 0.1 to 10 is not a pair of photos (the false ones were 0.05 to 0.07).
- Rows of holes and repeated parts look the same from both faces, so a tile can match a tile of the other face with 20 to 30 features. Two overviews that do not match each other are two faces, and no pair may join their groups. Two overviews count as matching only when they match each other about as well as tiles match them: on board 6, where terminal blocks hang over the edges and show from both faces, the two overviews matched with 42 features and each matched its own tiles with 100 to 470.
- A pair with few features counts only when a strong pair through one of its photos has the same scale and the same turn.
Photos that match show one face; within a face, a photo more than 1.5 times coarser than the finest is an overview.
**Tiles the matching loses.** A bare bottom face, or a board with the same circuit six times over, defeats the ratio test: every feature has twins. A tile that matched its overview weakly or not at all is placed by a vote instead. The pixel size and turn are known from the face's other tiles, so only the shift is open, and every feature votes for it with its six nearest look-alikes. The true place collects the twins that fit plus everything that exists once (a corner, a hole, a label), so it is the highest peak; it is taken when it leads the next best place by a quarter. On board 5 this moved a tile that RANSAC had put one period of the port circuit away.
**Which face is the top.** The face with more of its mask-coloured area under part bodies, measured on the finished mosaics. A connector hanging over the edge is seen from both faces and does not count. `stitch.json` gives both figures and says when they are close; then a person or the AI looks, and `stitch --top <photo>` names it. Of the eight test boards with two full faces the rule was clear on five, close and right on two (boards 6 and 7), and close and wrong on one (board 2, whose bottom carries many small parts).
**When the photos do not say which face.** Two groups of photos that share nothing are taken as the two faces, and `stitch.json` says that this is an assumption. With three or more such groups a group may be another part of the same face: then only the largest is built, as the top, and the rest are listed as unplaced.
**Registration.** Tiles are registered to each other, not to the overview. For each pair of tiles the overview (or their own coarse match) says where they overlap; features are then matched at full resolution inside that area only, with each feature's candidates limited to within about 60 px of the predicted place. When a pair's own match and the overview disagree by more than 150 px the overview is followed: that is a repeated circuit matched one period off. Features are taken on mask-coloured surface first (the colour is measured in each photo: the peak of the hue histogram among saturated pixels near the centre of the frame), because the top of a part stands above the board and moves with the camera.
A pair of tiles is linked only when ten or more of its features agree within 2 px, and five times more than chance would line up inside the search radius. With hundreds of candidates a loose fit always finds a dozen: on board 5's bottom "24 of 1239 agree" held 6 at 2 px and implied a scale of 1.09.
One similarity per tile is then solved for all tiles at once by least squares, with matches more than 2.5 px from the joint solution dropped. Added to that:
- *A camera correction shared by the tiles of a face*: a radial term and a tilt, found by pattern search on the capped mean residual, kept only when it gains 5 percent. On board 4 the tilt took the tiles' fitted scales from 0.986 to 1.000 down to 0.997 to 1.001 and the median feature residual from 3.6 to 2.2 px. The radial term alone did nothing there. The camera was not looking exactly straight down, and what looked like tiles of different sizes was that.
- *The overview as a weak prior*: each tile's place according to the overview enters with a tenth of the weight of a feature. It decides only what the features leave open.
- *The overview as a referee*: a tile that fewer than 40 tight features put more than 80 px from where the overview has it loses its links.
- *Tiles with no link* are placed where the overview has them, as rigid copies at scale 1, and the overview's homography is fitted again with them in, six times over. That is what brings out the overview's perspective when only one tile carried the first fit: on board 8's bottom, where no pair of tiles could be linked, the first fit gave the other tiles scales of 1.04 and the repeated fit 1.000.
After that the placement is solved once more with cells of surface from the overlaps added. The first pass also takes cells with a weaker peak and a larger shift when the cells of a pair agree with each other on it, which pulls in a tile the overview placed to within 10 or 20 px. A pass is kept only if the cells then agree better.
**The overlap figure, and two traps.** How far the tiles disagree is measured by phase correlation of 96 px cells where both tiles show board surface. Three things matter. The cells are blurred by 1.5 px for the measurement: unblurred, pixel noise decides the peak and the same cells read 2 to 6 px instead of 0.7. Only cells with a clear peak count (response 0.3 or more), since below that the shift found grows as the response falls, which is the measurement failing. And a cell must have detail in two directions: a cell of parallel tracks can slide along them freely, and on board 3's top such cells read a median of 4.9 px on joins that are clean by eye.
The first trap: OpenCV 5.0.0's `phaseCorrelate` writes its window into the arrays it is given. With overlapping cells cut from one array, later cells are read from data already multiplied by the same window in both images, and they agree near zero shift whatever the tiles do. Every cell is passed as a copy, and the cells do not overlap. The second: the placement is fitted in these same overlaps, so every other cell is held out of the fit and `stitch.json` gives the held-out figures separately. On a made-up board whose tiles are exact similarities (the test fixture) the figure reads 0.08 px.
**The mosaic.** The frame is the reference tile's own pixels, so nothing is scaled. Each tile is resampled once, through one map that combines the camera correction and its similarity (Lanczos). Nothing is sharpened. One source is chosen for every place: the start is the tile whose centre is nearest, and a graph cut (at 1/8 size, on colour difference) then moves each join to where the two tiles look the same, which is board surface, so joins run round parts rather than through them. The join is feathered over about 12 px.
**Gain.** Each tile gets a smooth gain per channel toward the overview's tone, measured on board surface over about 3 mm; without an overview, one gain per tile so that neighbours agree. That is not enough at the joins: a tile's own light (the ring light's glare, the falloff to its edge) changes faster than a smooth gain follows, and what is left is a step in tone along the join that reads like the edge of a pour. So at each join both tiles are also brought to their common tone on board surface (their ratio smoothed over 32 px, fading out about 200 px from the join). The tiles are registered to a fraction of a pixel, so that ratio carries no detail from one tile into the other.
**What is not covered.** Where no tile shows the board, the overview fills in, at its own much lower resolution and with one gain for all channels; `stitch.json` lists those areas as low resolution. The overview is only used inside the board outline, which is cut out of the overview by GrabCut seeded with the surface the tiles show. Its table is left out because a phone's table can have nearly the mask's hue, and `prep` would take it for board. Where nothing covers the canvas it stays black and is listed as not seen. The overview is mapped by one homography; where it fills in, a join between tile and overview can step by the "overview fit" figure in `stitch.json` (8 to 30 px on the test boards).
**Thin sets.** A face with one photo is that photo, copied byte for byte, with a note of every side on which the board runs out of the frame. Photos that match nothing are listed under `unplaced` and left out. Nothing is drawn that no photo shows.
**Boards that are not green.** The stitcher measures the mask colour per photo and works on red and blue boards. `prep` looks for a green board; when it finds none it takes the board box that `stitch.json` records for the mosaic and says so in `prep.json` (`outlineFrom`). What `prep` and the later steps read inside that outline still assumes a green mask.