main
Caleb Publish 0.5.0 1984b40
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930
//! The review's confidence: a number from 0 to 100 per category, computed from the run's files every
//! time it is asked for and never read back from anywhere. docs/method.md states every formula.
//!
//! A category's score is the lowest of its ceilings. Measurements can only lower a score, because the
//! program checking its own reading is not independent evidence. The ceiling that can rise above the
//! cap is the audit: a random sample the program draws with a seed it derives itself, judged item by
//! item, reduced to a Wilson 95 percent lower bound. Nothing typed sets a score: a verdict is one
//! observation in a sample, and the bound decides what the sample as a whole is worth.
use crate::kicad_out::{self, Grid, Pad};
use crate::photomat::{self, Face};
use crate::raster::{self, Mask, Plane};
use crate::{board, draw, fidelity, items, ledger, twoscores, unrouted};
use image::RgbImage;
use serde_json::{json, Value};
use std::cell::OnceCell;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};

pub const CATEGORIES: [&str; 8] = ["identity", "values", "blocks", "placement", "copper", "vias", "connectivity", "layers"];
/// The owner's number. A run may set another with `review pass --target`, and every line then says so.
pub const DEFAULT_TARGET: f64 = 90.0;
/// Policy, not a measurement: with no independent check a category sits below a coin toss.
pub const NO_AUDIT_CAP: f64 = 40.0;
/// Policy: photos never show an inner layer, so a layer proposal from photos alone stays below this.
pub const PHOTO_ONLY_CAP: f64 = 30.0;
/// Two-sided 95 percent normal quantile.
const Z95: f64 = 1.96;
/// A pass counts as raising a score only by at least this much, so a loop cannot creep on forever.
const MIN_RAISE: f64 = 0.1;
pub const DEFAULT_MAX_PASSES: usize = 20;
/// A pale patch, from these Driver photos: more than a quarter of the square reaching 0.35 mm around a via
/// with min(R,G,B) above 135 suggests silkscreen. A dome's own highlight is pale too, so a tighter patch flags
/// nearly every via, and this count is an upper bound on false vias, which only an audit turns into a figure.
const PALE_LEVEL: u8 = 135;
const PALE_SHARE: f64 = 0.25;
const PALE_RADIUS_MM: f64 = 0.35;
const CROP_PX: u32 = 480;
const SHEET_CELL: u32 = 256;
const SHEET_COLS: u32 = 6;
const SHEET_ROWS: u32 = 4;
/// The copper claims, worded as what the photo shows: an auditor has the photo in front of them, not the copy.
const COPPER_CLAIM: &str = "the photo shows copper under the mask at the ring's centre";
const BARE_CLAIM: &str = "the photo shows bare board, no copper under the mask, at the ring's centre";
/// The three stretched panels of a copper crop: the window around the point, the wider view that shows the neighbouring gap
/// lines, and a close view for a point near an edge. Choices made on a tuning sample of the Driver photos, kept out of the audit.
const COPPER_WINDOW_MM: f64 = 6.0;
const COPPER_CONTEXT_MM: f64 = 18.0;
const COPPER_ZOOM_MM: f64 = 3.0;
/// The window the stretch's levels come from, wide enough to hold both copper and bare board in most places.
const STRETCH_REF_MM: f64 = 12.0;
/// A planted copper claim needs a point whose true answer is plain to a person, not only to the two readers. This far round the point
/// the copy, the threshold scorer and the plain mask all say the same; the crop's ring is 0.4 mm.
const CONTROL_REACH_MM: f64 = 0.6;
/// It keeps this far from the board edge, from a part's pad box, and from a via or via candidate seen on either face.
const CONTROL_EDGE_MM: f64 = 1.0;
const CONTROL_PART_MM: f64 = 0.8;
const CONTROL_VIA_MM: f64 = 0.9;
/// In the stretched panel the auditor judges from, where 0 is the 3rd and 1 the 97th percentile of green over plain mask in the window, the
/// green all round the point sits above the first (copper) or below the second (bare board): clear of the middle, where a person would hesitate.
const CONTROL_LIGHT: f32 = 0.7;
const CONTROL_DARK: f32 = 0.3;
/// Both surfaces must be in that window, each at least this share of its plain mask by the scorer, or there is nothing to compare the point with.
const CONTROL_BOTH: f64 = 0.05;
/// And the point has the colour of the surface it is, not only its green: in each of red, green and blue where the two surfaces' medians in the
/// window lie at least `CONTROL_TELLS` levels apart, the median round the point lies this far of the way from the other surface's median to its own.
/// A patch as green as copper and as little red as bare board is one a person calls either way.
const CONTROL_SIDE: f32 = 0.6;
const CONTROL_TELLS: f32 = 8.0;
/// A join counts for an island only when it lies this near the island's own drawn copper: its edge to the pad's outline, to the via's rim,
/// to the place two pieces meet, or to the other copper. A pad at the far end of a pour is nothing a crop of this island can show.
const JOIN_REACH_MM: f64 = 0.3;
/// The second row of a join crop, centred on the join, and how far from the join a crop's first row still tries to show the island.
const JOIN_ZOOM_MM: f64 = 3.0;
const JOIN_SHOW_MM: f64 = 5.0;
/// A track's outline and a via's ring are drawn this far outside the copper, so the mark runs beside it and not over it.
const MARK_CLEAR_MM: f64 = 0.08;
const OTHER_END: [u8; 3] = [0, 220, 255];
/// Tiles for comparing what is left out with where the copy and the scorer disagree most. 5 mm is the fidelity checkerboard's tile.
const WORST_TILE_PX: usize = 200;

// ---- hashing, seeds and the bound ----

pub fn sha256_hex(data: &[u8]) -> String {
    const K: [u32; 64] = [
        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
        0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
        0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
        0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
    ];
    let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
    let mut msg = data.to_vec();
    msg.push(0x80);
    while msg.len() % 64 != 56 { msg.push(0); }
    msg.extend_from_slice(&((data.len() as u64) * 8).to_be_bytes());
    for block in msg.chunks(64) {
        let mut w = [0u32; 64];
        for i in 0..16 { w[i] = u32::from_be_bytes([block[i * 4], block[i * 4 + 1], block[i * 4 + 2], block[i * 4 + 3]]); }
        for i in 16..64 {
            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
            w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
        }
        let mut v = h;
        for i in 0..64 {
            let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25);
            let ch = (v[4] & v[5]) ^ (!v[4] & v[6]);
            let t1 = v[7].wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
            let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22);
            let maj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]);
            let t2 = s0.wrapping_add(maj);
            v = [t1.wrapping_add(t2), v[0], v[1], v[2], v[3].wrapping_add(t1), v[4], v[5], v[6]];
        }
        for i in 0..8 { h[i] = h[i].wrapping_add(v[i]); }
    }
    h.iter().map(|x| format!("{x:08x}")).collect()
}

fn h(parts: &[&str]) -> String { sha256_hex(parts.join("\u{1f}").as_bytes())[..16].to_string() }

struct Rng(u64);
impl Rng {
    fn next(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15);
        let mut z = self.0;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
        z ^ (z >> 31)
    }
    fn below(&mut self, n: usize) -> usize { (self.next() % n.max(1) as u64) as usize }
    fn unit(&mut self) -> f64 { (self.next() >> 11) as f64 / (1u64 << 53) as f64 }
}

fn seed_u64(hex: &str, salt: &str) -> u64 { u64::from_str_radix(&h(&[hex, salt]), 16).unwrap_or(1) }

/// The sampling order of one stratum: the sorted ids in the seed's random order. A larger sample takes a
/// longer prefix of the same order, so extending a sample cannot drop the items already drawn.
fn order(ids: &[String], seed: &str, stratum: &str) -> Vec<String> {
    let mut v = ids.to_vec();
    v.sort();
    let mut r = Rng(seed_u64(seed, stratum));
    for i in (1..v.len()).rev() { v.swap(i, r.below(i + 1)); }
    v
}

/// Wilson 95 percent lower bound on the share correct, with the finite-population correction: auditing
/// the whole of a small population leaves no sampling error, while 5 of 5 from 1000 is worth 56.
pub fn wilson_lower(k: usize, n: usize, population: usize) -> f64 {
    if n == 0 { return 0.0; }
    let (nf, p) = (n as f64, k as f64 / n as f64);
    let fpc = if population > 1 && population >= n { ((population - n) as f64 / (population - 1) as f64).sqrt() } else if population == 1 { 0.0 } else { 1.0 };
    let z = Z95 * fpc;
    let z2 = z * z;
    let centre = p + z2 / (2.0 * nf);
    let spread = z * (p * (1.0 - p) / nf + z2 / (4.0 * nf * nf)).sqrt();
    ((centre - spread) / (1.0 + z2 / nf)).clamp(0.0, 1.0)
}

/// The smallest sample that can reach `target` with `misses` wrong answers.
pub fn required_n(target: f64, misses: usize, population: usize) -> usize {
    (1..=population).find(|&n| n > misses && wilson_lower(n - misses, n, population) * 100.0 >= target).unwrap_or(population)
}

fn controls_for(real: usize) -> usize { if real < 5 { 0 } else { (real / 10 + usize::from(real % 10 > 0)).max(3) } }

/// Rounded down, so a score can never round up to the target.
fn floor1(x: f64) -> f64 { (x.max(0.0) * 10.0 + 1e-9).floor() / 10.0 }

// ---- the run's files ----

struct Ctx { flow: PathBuf, parts: Vec<Value>, blocks: Vec<Value>, copper: Value, nets: Vec<Value>, layers: Value, domes: Vec<Value>, measured: Value, pads: Vec<Pad>, faces: OnceCell<Option<(Face, Face)>>, census: OnceCell<CopperCensus>, reads: OnceCell<HashMap<&'static str, (Mask, Mask)>>, views: OnceCell<HashMap<&'static str, CopperView>>, joins: OnceCell<Vec<(Cu, Vec<Join>)>> }

fn read(flow: &Path, f: &str) -> Value { items::read_json(&flow.join(f)).unwrap_or(json!({})) }
fn arr(v: &Value) -> Vec<Value> { v.as_array().cloned().unwrap_or_default() }
fn st<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }
fn xy(v: &Value) -> Option<(f64, f64)> { Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)) }
fn known(tag: &str) -> bool { matches!(tag, "SEEN" | "MEASURED" | "DATASHEET") }

impl Ctx {
    fn load(flow: &Path) -> Ctx {
        let parts_doc = read(flow, "parts.json");
        let parts: Vec<Value> = arr(&parts_doc["parts"]).into_iter().filter(|p| p["onBoard"] != json!(false)).collect();
        Ctx {
            flow: flow.to_path_buf(), pads: kicad_out::pads_of(&kicad_out::cache_dir(flow), &parts), parts,
            blocks: arr(&read(flow, "blocks.json")["blocks"]), copper: read(flow, "copper.json"), nets: arr(&read(flow, "nets.json")["nets"]),
            layers: read(flow, "layers.json"), domes: arr(&read(flow, "detect/domes.json")["domes"]), measured: read(flow, "detect/copper-measured.json"),
            faces: OnceCell::new(), census: OnceCell::new(), reads: OnceCell::new(), views: OnceCell::new(), joins: OnceCell::new(),
        }
    }
    /// What each pixel of the two faces shows, read from the photos alone by the reader `check fidelity` uses, and as it uses it: whether a
    /// face carries parts is measured, not taken from its name. Computed here every time and never read from a file, so what is left out
    /// of the copper audit cannot be edited by hand.
    fn faces(&self) -> Option<&(Face, Face)> {
        self.faces.get_or_init(|| {
            let (t, b) = (image::open(self.flow.join(board_file("top"))).ok()?.to_rgb8(), image::open(self.flow.join(board_file("bottom"))).ok()?.to_rgb8());
            let (mut top, mut bot) = (photomat::classify(&t, photomat::carries_parts(&t)), photomat::classify(&b, photomat::carries_parts(&b)));
            photomat::link_holes(&mut top, &mut bot);
            Some((top, bot))
        }).as_ref()
    }
    fn face(&self, name: &str) -> Option<&Face> { self.faces().map(|f| if name == "top" { &f.0 } else { &f.1 }) }
    fn census(&self) -> &CopperCensus { self.census.get_or_init(|| copper_census(self)) }
    /// Per face, the copy's copper on the board grid and the threshold scorer's read at 40 px/mm. Slow, so only a draw asks.
    fn reads(&self) -> &HashMap<&'static str, (Mask, Mask)> {
        self.reads.get_or_init(|| {
            let mut m = HashMap::new();
            let Some((g, _)) = copper_grid(self) else { return m };
            for (face, layer) in [("top", "F.Cu"), ("bottom", "B.Cu")] {
                let (Some(f), Ok(img)) = (self.face(face), image::open(self.flow.join(board_file(face)))) else { continue };
                m.insert(face, (covered(self, &g, layer), fidelity::copper_read(&img.to_rgb8(), f)));
            }
            m
        })
    }
    /// Each face as the copper panels show it. Slow, so only a draw or a preview asks.
    fn views(&self) -> &HashMap<&'static str, CopperView> {
        self.views.get_or_init(|| ["top", "bottom"].into_iter().filter_map(|face| Some((face, copper_view(&self.flow, face, self.face(face)?)?))).collect())
    }
    /// Every drawn island with the joins the flow holds for it.
    fn joins(&self) -> &Vec<(Cu, Vec<Join>)> { self.joins.get_or_init(|| island_joins(self)) }
    /// How many islands have a join a crop can show, and how many islands there are.
    fn island_proof(&self) -> (usize, usize) { (self.joins().iter().filter(|j| !j.1.is_empty()).count(), self.joins().len()) }
    /// How many pins with a net have a join a crop can show, how many pins have a net, and how many of the rest are on a net that cites a source
    /// outside the photos: named, and not confirmed by anything a photo shows.
    fn pin_proof(&self) -> (usize, usize, usize) {
        let (joined, pins) = (pin_joins(self), net_pins(self));
        let with = pins.iter().filter(|p| joined.contains_key(p.0)).count();
        (with, pins.len(), pins.iter().filter(|p| !joined.contains_key(p.0) && cites_outside(p.1)).count())
    }
    fn part_pos(&self, p: &Value) -> (f64, f64) { xy(&p["pos_mm"]).unwrap_or((0.0, 0.0)) }
    fn part_pads(&self, r: &str) -> Vec<&Pad> { self.pads.iter().filter(|x| x.part == r).collect() }
    /// The box a part's pads cover, or a small square at its position when its footprint is not cached.
    fn part_box(&self, p: &Value) -> (f64, f64, f64, f64) {
        let (x, y) = self.part_pos(p);
        self.part_pads(st(p, "ref")).iter().fold((x - 0.5, y - 0.5, x + 0.5, y + 0.5), |b, d| {
            let r = d.w.max(d.h) / 2.0;
            (b.0.min(d.x - r), b.1.min(d.y - r), b.2.max(d.x + r), b.3.max(d.y + r))
        })
    }
    fn proposed(&self) -> Option<Value> {
        let n = self.layers["proposed"].as_u64()?;
        arr(&self.layers["candidates"]).into_iter().find(|c| c["layers"].as_u64() == Some(n))
    }
    fn pins(&self) -> usize { self.nets.iter().map(|n| n["pins"].as_array().map(|a| a.len()).unwrap_or(0)).sum() }
}

fn net_unnamed(v: &Value) -> bool {
    let n = st(v, "net");
    n.is_empty() || n.starts_with("UNK") || v["fieldTags"]["net"] == json!("UNKNOWN")
}
fn via_face(v: &Value) -> &'static str { if st(v, "seen").starts_with("top") { "top" } else { "bottom" } }
fn layer_face(l: &str) -> Option<&'static str> { match l { "F.Cu" => Some("top"), "B.Cu" => Some("bottom"), _ => None } }
fn board_file(face: &str) -> &'static str { if face == "top" { "board/top.jpg" } else { "board/bottom.jpg" } }
fn path_len(path: &[(f64, f64)]) -> f64 { path.windows(2).map(|w| ((w[0].0 - w[1].0).powi(2) + (w[0].1 - w[1].1).powi(2)).sqrt()).sum() }
fn points(v: &Value) -> Vec<(f64, f64)> { arr(v).iter().filter_map(xy).collect() }

// ---- what can be audited, per category ----

/// One claim that can be checked. `class` says when two claims really differ, for planted controls. `shapes` are drawn in pink on the
/// marked panel and `other`, the other end of a claimed join, in blue.
#[derive(Clone)]
struct PItem { id: String, stratum: String, class: String, subject: String, body: String, hash: String, face: String, at: (f64, f64), window: f64, ring: f64, shapes: Vec<(Vec<(f64, f64)>, bool)>, other: Vec<(Vec<(f64, f64)>, bool)> }

impl PItem { fn claim(&self) -> String { format!("{}: {}", self.subject, self.body) } }

fn rect_of(p: &Pad) -> Vec<(f64, f64)> {
    let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());
    [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().map(|(a, b)| { let (lx, ly) = (a * p.w / 2.0, b * p.h / 2.0); (p.x + lx * c + ly * s, p.y - lx * s + ly * c) }).collect()
}

fn part_item(c: &Ctx, p: &Value, stratum: &str, body: String, hash: String) -> PItem {
    let r = st(p, "ref");
    let b = c.part_box(p);
    PItem {
        id: r.to_string(), stratum: stratum.to_string(), class: format!("{}|{}", r.trim_end_matches(|x: char| x.is_ascii_digit()), st(p, "package")),
        subject: r.to_string(), body, hash, face: if st(p, "side") == "bottom" { "bottom".into() } else { "top".into() },
        at: ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0), window: ((b.2 - b.0).max(b.3 - b.1) + 3.0).clamp(4.0, 16.0), ring: ((b.2 - b.0).max(b.3 - b.1) / 2.0 + 0.4).min(7.5), shapes: vec![], other: vec![],
    }
}

/// The copper the copy claims on one face, on the board's 20 px/mm grid.
fn copper_grid(c: &Ctx) -> Option<(Grid, Mask)> {
    let outline = points(&c.copper["outline"]["points_mm"]);
    if outline.len() < 3 { return None; }
    let (x0, y0) = outline.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1)));
    let (x1, y1) = outline.iter().fold((f64::MIN, f64::MIN), |a, p| (a.0.max(p.0), a.1.max(p.1)));
    let g = Grid { x0, y0, res: 20.0, w: ((x1 - x0) * 20.0).ceil() as usize + 1, h: ((y1 - y0) * 20.0).ceil() as usize + 1 };
    let inside = kicad_out::board_inside(&g, &outline, c.copper["outline"]["corner_mm"].as_f64().unwrap_or(0.0)).erode(10.0);
    Some((g, inside))
}

fn covered(c: &Ctx, g: &Grid, layer: &str) -> Mask {
    let mut m = g.empty();
    for z in arr(&c.copper["zones"]).iter().filter(|z| st(z, "layer") == layer) {
        let one = kicad_out::zone_mask(g, z, &points(&z["polygon_mm"]));
        for (a, b) in m.d.iter_mut().zip(one.d.iter()) { *a |= *b; }
    }
    for t in arr(&c.copper["traces"]).iter().filter(|t| st(t, "layer") == layer) {
        let p = points(&t["path_mm"]);
        for w in p.windows(2) { g.seg(&mut m, w[0], w[1], t["width_mm"].as_f64().unwrap_or(0.2)); }
    }
    for v in arr(&c.copper["vias"]) { if let Some((x, y)) = xy(&v["pos_mm"]) { g.disk(&mut m, x, y, v["diameter_mm"].as_f64().unwrap_or(0.6) / 2.0); } }
    m
}

// ---- joins: what an island is seen to touch ----

/// One drawn piece of copper: a pour by its outline, or a track by its centre line and half its width.
struct Cu { key: String, id: String, layer: String, face: &'static str, net: String, pts: Vec<(f64, f64)>, closed: bool, half: f64, geo: String, bbox: (f64, f64, f64, f64) }

impl Cu {
    /// Clear distance from a point to this copper, 0 inside it.
    fn dist(&self, p: (f64, f64)) -> f64 {
        let b = self.bbox;
        let far = (b.0 - p.0).max(p.0 - b.2).max(b.1 - p.1).max(p.1 - b.3) - self.half;
        if far > 2.0 { return far; }
        let n = self.pts.len();
        if self.closed {
            if crate::connect::inside(&self.pts, p) { return 0.0; }
            (0..n).map(|i| seg_dist(p, self.pts[i], self.pts[(i + 1) % n])).fold(f64::MAX, f64::min)
        } else {
            (self.pts.windows(2).map(|w| seg_dist(p, w[0], w[1])).fold(f64::MAX, f64::min) - self.half).max(0.0)
        }
    }
    /// The gap between two pieces of copper and where they come nearest, tried from the vertices of each.
    fn gap(&self, o: &Cu) -> (f64, (f64, f64)) {
        let (a, b) = (self.bbox, o.bbox);
        let apart = (a.0 - b.2).max(b.0 - a.2).max(a.1 - b.3).max(b.1 - a.3) - self.half - o.half;
        let mut best = (apart.max(0.0), self.pts[0]);
        if apart > 2.0 { return best; }
        best.0 = f64::MAX;
        for p in &self.pts { let d = (o.dist(*p) - self.half).max(0.0); if d < best.0 { best = (d, *p); } }
        for p in &o.pts { let d = (self.dist(*p) - o.half).max(0.0); if d < best.0 { best = (d, *p); } }
        best
    }
    /// What a crop draws round it: the pour's own outline, or the track's centre line swollen to just past its edges.
    fn outline(&self) -> Vec<(f64, f64)> { if self.closed { self.pts.clone() } else { swell(&self.pts, self.half + MARK_CLEAR_MM) } }
}

/// A closed outline round a centre line, `half` to each side and past each end.
fn swell(path: &[(f64, f64)], half: f64) -> Vec<(f64, f64)> {
    let n = path.len();
    if n < 2 { return path.to_vec(); }
    let dir = |i: usize| { let (a, b) = (path[i.saturating_sub(1)], path[(i + 1).min(n - 1)]); let l = crate::geom::dist(a, b).max(1e-9); ((b.0 - a.0) / l, (b.1 - a.1) / l) };
    let side = |i: usize, s: f64| {
        let d = dir(i);
        let e = if i == 0 { -half } else if i == n - 1 { half } else { 0.0 };
        (path[i].0 + d.0 * e - d.1 * half * s, path[i].1 + d.1 * e + d.0 * half * s)
    };
    (0..n).map(|i| side(i, 1.0)).chain((0..n).rev().map(|i| side(i, -1.0))).collect()
}

fn circle_mm(at: (f64, f64), r: f64) -> Vec<(f64, f64)> { (0..24).map(|k| { let a = k as f64 * std::f64::consts::TAU / 24.0; (at.0 + r * a.cos(), at.1 + r * a.sin()) }).collect() }

/// One join the flow holds for an island: a pad, a via or other copper, where they meet, and the marks that show the other end.
#[derive(Clone)]
struct Join { kind: &'static str, name: String, at: (f64, f64), other: Vec<(Vec<(f64, f64)>, bool)>, geo: String }

/// The gap between a piece of copper and a pad's outline: 0 when either reaches into the other.
fn pad_gap(cu: &Cu, pad: &Pad) -> f64 {
    if cu.pts.iter().any(|p| crate::conduct::pad_inside(pad, p.0, p.1, 0.0)) { return 0.0; }
    let r = rect_of(pad);
    let mut best = cu.dist((pad.x, pad.y));
    for i in 0..4 {
        let (a, b) = (r[i], r[(i + 1) % 4]);
        let n = (crate::geom::dist(a, b) / 0.05).ceil().max(1.0) as usize;
        for k in 0..n { let t = k as f64 / n as f64; best = best.min(cu.dist((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t))); }
    }
    best
}

fn copper_join(o: &Cu, at: (f64, f64)) -> Join {
    // the other copper's outline, and a cross with an open middle where the two meet
    let mut other = vec![(o.outline(), true)];
    for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { other.push((vec![(at.0 + dx * 0.12, at.1 + dy * 0.12), (at.0 + dx * 0.3, at.1 + dy * 0.3)], false)); }
    Join { kind: "copper", name: o.id.clone(), at, other, geo: format!("{} {:.3} {:.3}", o.geo, at.0, at.1) }
}

/// Every drawn island with the joins `connect attribute` holds for it, read from its files (detect/attribution.json, detect/connect-graph.json,
/// detect/attrib/measure.json): a pad whose solid contact is this island's copper, a valid via sitting in its conductor, copper stitched to it, and
/// copper drawn from the same piece. A join counts only within `JOIN_REACH_MM` of the island's own drawn copper, so that one crop shows both ends.
/// Sorted, one per other end. Evidence measured on other copper than copper.json now holds counts for nothing.
fn island_joins(c: &Ctx) -> Vec<(Cu, Vec<Join>)> {
    let mut cus: Vec<Cu> = vec![];
    for (key, pre) in [("zones", "zone"), ("traces", "trace")] { for z in arr(&c.copper[key]) {
        let Some(face) = layer_face(st(&z, "layer")) else { continue };
        let closed = key == "zones";
        let geo = if closed { &z["polygon_mm"] } else { &z["path_mm"] };
        let pts = points(geo);
        if pts.len() < 2 { continue; }
        let bbox = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));
        cus.push(Cu { key: format!("{pre}:{}", st(&z, "id")), id: st(&z, "id").into(), layer: st(&z, "layer").into(), face, net: st(&z, "net").into(), half: if closed { 0.0 } else { z["width_mm"].as_f64().unwrap_or(0.2) / 2.0 }, closed, geo: geo.to_string(), pts, bbox });
    } }
    let (att, graph, ms) = (read(&c.flow, "detect/attribution.json"), read(&c.flow, "detect/connect-graph.json"), read(&c.flow, "detect/attrib/measure.json"));
    let stale = ms["copperSha"].as_str().is_some_and(|s| s != crate::attrib::zones_key(&c.copper));
    let index: HashMap<&str, usize> = cus.iter().enumerate().map(|(i, k)| (k.id.as_str(), i)).collect();
    let n = |v: &Value| v.as_u64().map(|x| x as usize);
    // pieces of copper as the photo was read: which conductor and node each is in, and which drawn items lie on it
    let (mut own, mut piece): (HashMap<usize, Vec<usize>>, HashMap<usize, (Option<usize>, Option<usize>, Vec<usize>)>) = (HashMap::new(), HashMap::new());
    for (k, p) in arr(&att["pieces"]).iter().enumerate() {
        let k = n(&p["piece"]).unwrap_or(k);
        let on: Vec<usize> = arr(&p["items"]).iter().filter_map(|x| x.as_str().and_then(|s| index.get(s).copied())).collect();
        for i in &on { own.entry(*i).or_default().push(k); }
        piece.insert(k, (n(&p["conductor"]).or(n(&graph["conductorOfPiece"][k])), n(&p["node"]).or(n(&graph["nodeOfPiece"][k])), on));
    }
    // pads: the contacts attribution kept as solid, each at the place and in the outline the photo shows the pad's metal
    let mut shape: HashMap<String, Vec<Pad>> = HashMap::new();
    for p in arr(&ms["pads"]) {
        let f = |k: &str| p["core"][k].as_f64().unwrap_or(0.0);
        shape.entry(format!("{}.{}", st(&p, "part"), st(&p, "num"))).or_default().push(Pad { part: st(&p, "part").into(), num: st(&p, "num").into(), x: f("x"), y: f("y"), w: f("w"), h: f("h"), rot: f("rot"), round: false, drill: 0.0, layers: vec![], net: String::new() });
    }
    let seen_at: HashMap<(String, String), (f64, f64)> = arr(&att["padStates"]).iter().filter_map(|s| Some(((st(s, "pin").to_string(), st(s, "side").to_string()), (s["x"].as_f64()?, s["y"].as_f64()?)))).collect();
    let mut pads: HashMap<(String, usize), Vec<(String, Pad)>> = HashMap::new();
    for k in arr(&graph["contacts"]).iter().filter(|k| k["tier"].as_u64() == Some(1) && st(k, "kind") != "via-in-pad") {
        let (pin, side) = (st(k, "pin").to_string(), st(k, "side").to_string());
        let (Some(node), Some(at)) = (n(&k["node"]), seen_at.get(&(pin.clone(), side.clone()))) else { continue };
        let Some(p) = shape.get(&pin).and_then(|v| v.iter().min_by(|a, b| ((a.x - at.0).abs() + (a.y - at.1).abs()).partial_cmp(&((b.x - at.0).abs() + (b.y - at.1).abs())).unwrap_or(std::cmp::Ordering::Equal))) else { continue };
        pads.entry((side, node)).or_default().push((pin, p.clone()));
    }
    let place: HashMap<String, ((f64, f64), f64)> = arr(&c.copper["vias"]).iter().filter_map(|v| Some((st(v, "id").to_string(), (xy(&v["pos_mm"])?, v["diameter_mm"].as_f64().unwrap_or(0.6))))).collect();
    let mut vias: HashMap<(&str, usize), Vec<(&String, (f64, f64), f64)>> = HashMap::new();
    for (id, v) in att["vias"].as_object().into_iter().flatten() {
        let (true, Some((at, d))) = (v["valid"] == json!(true), place.get(id)) else { continue };
        for (side, key) in [("top", "topConductor"), ("bottom", "bottomConductor")] { if let Some(cd) = n(&v[key]) { vias.entry((side, cd)).or_default().push((id, *at, *d)); } }
    }
    let mut stitch: HashMap<usize, Vec<(usize, (f64, f64))>> = HashMap::new();
    for s in arr(&graph["stitches"]) {
        let (Some(a), Some(b), Some(at)) = (n(&s["pieces"][0]), n(&s["pieces"][1]), xy(&s["at"])) else { continue };
        stitch.entry(a).or_default().push((b, at));
        stitch.entry(b).or_default().push((a, at));
    }
    let mut found: Vec<Vec<Join>> = vec![];
    for (i, cu) in cus.iter().enumerate() {
        let mut joins: Vec<(f64, Join)> = vec![];
        let on: Vec<usize> = if stale { vec![] } else { own.get(&i).cloned().unwrap_or_default() };
        let conductors: std::collections::BTreeSet<usize> = on.iter().filter_map(|k| piece.get(k).and_then(|p| p.0)).collect();
        let nodes: std::collections::BTreeSet<usize> = on.iter().filter_map(|k| piece.get(k).and_then(|p| p.1)).collect();
        for node in &nodes { for (pin, pad) in pads.get(&(cu.face.to_string(), *node)).into_iter().flatten() {
            let gap = pad_gap(cu, pad);
            if gap > JOIN_REACH_MM { continue; }
            let boxed = Pad { w: pad.w + 2.0 * MARK_CLEAR_MM, h: pad.h + 2.0 * MARK_CLEAR_MM, ..pad.clone() };
            joins.push((gap, Join { kind: "pad", name: pin.clone(), at: (pad.x, pad.y), other: vec![(rect_of(&boxed), true)], geo: format!("{:.3} {:.3} {:.3} {:.3} {:.1}", pad.x, pad.y, pad.w, pad.h, pad.rot) }));
        } }
        for cd in &conductors { for (id, at, d) in vias.get(&(cu.face, *cd)).into_iter().flatten() {
            let gap = (cu.dist(*at) - d / 2.0).max(0.0);
            if gap > JOIN_REACH_MM { continue; }
            joins.push((gap, Join { kind: "via", name: (*id).clone(), at: *at, other: vec![(circle_mm(*at, d / 2.0 + 2.0 * MARK_CLEAR_MM), true)], geo: format!("{:.3} {:.3}", at.0, at.1) }));
        } }
        for k in &on {
            // a stitch joins two pieces: the other end is whichever drawn item of the other piece lies nearest the place they meet
            for (o, at) in stitch.get(k).into_iter().flatten() {
                let Some((d, j)) = piece.get(o).into_iter().flat_map(|p| p.2.iter()).filter(|j| **j != i && cus[**j].face == cu.face).map(|j| (cus[*j].dist(*at), *j)).min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)) else { continue };
                let gap = d.max(cu.dist(*at));
                if gap <= JOIN_REACH_MM { joins.push((gap, copper_join(&cus[j], *at))); }
            }
            for j in piece.get(k).into_iter().flat_map(|p| p.2.iter()).filter(|j| **j != i && cus[**j].face == cu.face) {
                let (gap, at) = cu.gap(&cus[*j]);
                if gap <= JOIN_REACH_MM { joins.push((gap, copper_join(&cus[*j], at))); }
            }
        }
        joins.sort_by(|a, b| (a.1.kind, &a.1.name).cmp(&(b.1.kind, &b.1.name)).then(a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)));
        joins.dedup_by(|a, b| a.1.kind == b.1.kind && a.1.name == b.1.name);
        found.push(joins.into_iter().map(|j| j.1).collect());
    }
    cus.into_iter().zip(found).collect()
}

/// The join an island's claim states: the one nearest a name, as attribution's own ladder ranks them, so a pad before a via before other copper.
/// Among several of that kind, one by a hash of the island's id: nobody picks.
fn claimed<'a>(cu: &Cu, joins: &'a [Join]) -> Option<&'a Join> {
    let kind = ["pad", "via", "copper"].into_iter().find(|k| joins.iter().any(|j| j.kind == *k))?;
    let of: Vec<&Join> = joins.iter().filter(|j| j.kind == kind).collect();
    Some(of[(seed_u64(&cu.key, "join") % of.len() as u64) as usize])
}

/// An island's claim: one join, worded as what the crop shows, with both ends marked. The crop is centred on the join.
fn join_item(cu: &Cu, j: &Join) -> PItem {
    let body = match j.kind {
        "pad" => format!("reaches the pad boxed in blue (pin {}) with no break between them", j.name),
        "via" => "is one piece with the via in the blue ring: no gap between the via and this copper".to_string(),
        _ => format!("is one piece with the copper outlined in blue ({}): no gap between them at the blue cross", j.name),
    };
    let far = |pts: &[(f64, f64)]| pts.iter().map(|p| (p.0 - j.at.0).abs().max((p.1 - j.at.1).abs())).fold(0.0, f64::max);
    let reach = j.other.iter().map(|s| far(&s.0)).fold(far(&cu.pts), f64::max).min(JOIN_SHOW_MM);
    PItem {
        id: cu.key.clone(), stratum: "island".into(), class: j.kind.into(), subject: format!("copper outlined in pink ({})", cu.id), body,
        hash: h(&[&cu.layer, &cu.net, &cu.geo, j.kind, &j.name, &j.geo]), face: cu.face.into(), at: j.at, window: (2.0 * reach + 2.0).clamp(6.0, 12.0), ring: 0.0,
        shapes: vec![(cu.outline(), true)], other: j.other.clone(),
    }
}

/// Every pin of every net whose part is on the board, once, with its net.
fn net_pins(c: &Ctx) -> Vec<(&str, &Value)> {
    let mut seen = HashSet::new();
    c.nets.iter().flat_map(|n| n["pins"].as_array().into_iter().flatten().filter_map(move |x| x.as_str().map(|pin| (pin, n))))
        .filter(|(pin, _)| pin.rsplit_once('.').is_some_and(|(r, _)| c.parts.iter().any(|p| st(p, "ref") == r)) && seen.insert(*pin)).collect()
}

/// The joins of pads to drawn copper, by pin: the pad joins `island_joins` found, read from the pad's side.
fn pin_joins(c: &Ctx) -> HashMap<&str, Vec<(&Cu, &Join)>> {
    let mut m: HashMap<&str, Vec<(&Cu, &Join)>> = HashMap::new();
    for (cu, joins) in c.joins() { for j in joins.iter().filter(|j| j.kind == "pad") { m.entry(j.name.as_str()).or_default().push((cu, j)); } }
    m
}

/// The copper a pin's claim names. Copper read from the photo comes before a stub the copy drew to reach the pad; among several, one by a hash of the pin: nobody picks.
fn pin_claimed<'a>(pin: &str, joins: &[(&'a Cu, &'a Join)]) -> Option<(&'a Cu, &'a Join)> {
    let read: Vec<&(&Cu, &Join)> = joins.iter().filter(|j| !j.0.id.contains("stub")).collect();
    let of = if read.is_empty() { joins.iter().collect() } else { read };
    of.get((seed_u64(pin, "join") % of.len().max(1) as u64) as usize).map(|j| **j)
}

/// A pin's claim: its pad, boxed where the photo shows its metal, is joined to one piece of copper the copy drew. The crop is centred on the pad.
fn pin_item(pin: &str, net: &str, cu: &Cu, j: &Join) -> PItem {
    let far = |pts: &[(f64, f64)]| pts.iter().map(|p| (p.0 - j.at.0).abs().max((p.1 - j.at.1).abs())).fold(0.0, f64::max);
    PItem {
        id: format!("pin:{pin}"), stratum: "pin".into(), class: "pin".into(), subject: format!("pad boxed in pink (pin {pin})"),
        body: format!("is joined to the copper outlined in blue ({}) with no break between them", cu.id),
        hash: h(&[pin, net, &j.geo, &cu.key, &cu.layer, &cu.geo]), face: cu.face.into(), at: j.at, window: (2.0 * far(&cu.pts).min(JOIN_SHOW_MM) + 2.0).clamp(6.0, 12.0), ring: 0.0,
        shapes: j.other.iter().take(1).cloned().collect(), other: vec![(cu.outline(), true)],
    }
}

/// Whether a net states a source outside the photos for what it is: a `source` or `nameSource` citation (a datasheet's pinout, say), as opposed to
/// nothing, the program's own measuring, or a reading of the photos.
fn cites_outside(n: &Value) -> bool {
    let stated = |k: &str| { let t = st(n, k).trim().to_lowercase(); !(t.is_empty() || t.starts_with("none") || t.starts_with("infer") || matches!(t.as_str(), "measured" | "router" | "copper" | "silkscreen" | "circuit")) };
    stated("source") || stated("nameSource")
}

/// A join claim made false beyond doubt: the claim keeps its words, and one end is drawn where there is none. For an island, the other end moves: a
/// pad's box onto plain mask clear of every part, where no pad can be; a via's ring onto plain mask with no via or via candidate within 1.5 mm; a
/// second copper outline wholly onto a part's body, where the photo shows no copper at all. For a pin, its own pad's box moves onto plain mask
/// clear of every part. Nobody looking can call any of these ok.
fn false_join(c: &Ctx, base: &PItem, r: &mut Rng) -> Option<PItem> {
    let pin = base.stratum == "pin";
    let f = c.face(&base.face)?;
    let (g, inside) = copper_grid(c)?;
    let near: Vec<(f64, f64)> = arr(&c.copper["vias"]).iter().chain(c.domes.iter()).filter_map(|v| xy(&v["pos_mm"])).collect();
    let boxes: Vec<(f64, f64, f64, f64)> = c.parts.iter().filter(|p| (st(p, "side") == "bottom") == (base.face == "bottom")).map(|p| c.part_box(p)).collect();
    // anywhere the whole mark still shows in the crop
    let room = base.window / 2.0 - 0.3;
    for _ in 0..400 {
        let (dx, dy) = ((r.unit() * 2.0 - 1.0) * room, (r.unit() * 2.0 - 1.0) * room);
        let moved: Vec<(Vec<(f64, f64)>, bool)> = if pin { &base.shapes } else { &base.other }.iter().map(|(p, k)| (p.iter().map(|q| (q.0 + dx, q.1 + dy)).collect(), *k)).collect();
        let mark = &moved.first()?.0;
        let b = mark.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));
        if b.0 < base.at.0 - room || b.2 > base.at.0 + room || b.1 < base.at.1 - room || b.3 > base.at.1 + room { continue; }
        let (u, v) = ((((b.0 + b.2) / 2.0 - g.x0) * g.res) as i64, (((b.1 + b.3) / 2.0 - g.y0) * g.res) as i64);
        if u < 0 || v < 0 || u as usize >= g.w || v as usize >= g.h || !inside.d[v as usize * g.w + u as usize] { continue; }
        let good = if base.class == "copper" {
            mark.iter().all(|q| photo_index(f, *q).map(|i| f.cls[i] == photomat::OBJECT).unwrap_or(false))
        } else {
            let ((x0, y0), (x1, y1)) = (board::to_px(b.0, b.1), board::to_px(b.2, b.3));
            let plain = x0 >= 0.0 && y0 >= 0.0 && (x1 as usize) < f.w && (y1 as usize) < f.h && (y0 as usize..=y1 as usize).all(|y| (x0 as usize..=x1 as usize).all(|x| f.cls[y * f.w + x] == photomat::MASK));
            plain && !boxes.iter().any(|p| b.2 >= p.0 - 0.5 && b.0 <= p.2 + 0.5 && b.3 >= p.1 - 0.5 && b.1 <= p.3 + 0.5)
                && !(base.class == "via" && near.iter().any(|n| (n.0 - (b.0 + b.2) / 2.0).powi(2) + (n.1 - (b.1 + b.3) / 2.0).powi(2) < 1.5 * 1.5))
        };
        // a pin's crop is centred on its box, false or real, so the empty box sits where a real pad would
        if good { let mut it = base.clone(); if pin { it.shapes = moved; it.at = ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0); } else { it.other = moved; } return Some(it); }
    }
    None
}

const PAD_BOX: &str = "part's pad box";

/// A point of the board the copper audit leaves out, and what the photo shows there instead.
#[derive(Clone)]
struct LeftOut { face: String, at: (f64, f64), copy_has_copper: bool, why: &'static str }

/// Every candidate point of the copper audit: those a photo can settle, and those it cannot, with the reason.
#[derive(Default)]
struct CopperCensus { points: Vec<PItem>, left_out: Vec<LeftOut> }

impl CopperCensus {
    fn counts(&self) -> BTreeMap<&'static str, usize> {
        let mut m = BTreeMap::new();
        for l in &self.left_out { *m.entry(l.why).or_insert(0) += 1; }
        m
    }
    /// One line: the share left out and on which materials.
    fn line(&self) -> String {
        let (n, all) = (self.left_out.len(), self.left_out.len() + self.points.len());
        let by: Vec<String> = self.counts().iter().map(|(k, v)| format!("{k} {v}")).collect();
        format!("{n} of {all} points ({:.1}%) left out because the photo cannot show copper there: {}", pct(n as f64, all as f64), by.join(", "))
    }
}

/// The pixel at a point, when it lies on the photo.
fn photo_index(f: &Face, at: (f64, f64)) -> Option<usize> {
    let (x, y) = board::to_px(at.0, at.1);
    if x < 0.0 || y < 0.0 || x as usize >= f.w || y as usize >= f.h { return None; }
    Some(y as usize * f.w + x as usize)
}

/// What stops the photo showing the surface under the mask at this pixel, by the rule `check fidelity` scores copper with: the pixel,
/// or one within `fidelity::BESIDE_PX`, is not plain mask. The nearest such pixel names the material. The copy is not an input.
fn photo_blocked(f: &Face, at: (f64, f64)) -> Option<&'static str> {
    let Some(i) = photo_index(f, at) else { return Some("off the photo") };
    let (cx, cy) = ((i % f.w) as i64, (i / f.w) as i64);
    let r = fidelity::BESIDE_PX as i64;
    let mut best: Option<(i64, u8)> = None;
    for dy in -r..=r { for dx in -r..=r {
        let d = dx * dx + dy * dy;
        if d > r * r { continue; }
        let (x, y) = (cx + dx, cy + dy);
        if x < 0 || y < 0 || x >= f.w as i64 || y >= f.h as i64 { continue; }
        let k = f.cls[y as usize * f.w + x as usize];
        if k != photomat::MASK && best.map(|b| d < b.0).unwrap_or(true) { best = Some((d, k)); }
    } }
    best.map(|(_, k)| match k { photomat::SILK => "silk", photomat::GOLD | photomat::SILVER => "exposed metal", photomat::HOLE => "hole", _ => "part body" })
}

fn copper_body(on: bool) -> &'static str { if on { COPPER_CLAIM } else { BARE_CLAIM } }

fn copper_item(id: String, face: &str, at: (f64, f64), on: bool) -> PItem {
    let body = copper_body(on);
    PItem { id, stratum: if on { "copper".into() } else { "bare".into() }, class: body.into(), subject: format!("{face} point"), body: body.into(), hash: h(&[body]), face: face.into(), at, window: COPPER_WINDOW_MM, ring: 0.4, shapes: vec![], other: vec![] }
}

/// Random points of the board, on each face, away from the board edge and from what a part hides. The
/// answer to "what does the copy say here" catches copper that is missing as well as copper that is false,
/// which a sample of the kept items never can. A point goes only by what the photo shows there, the same rule for both
/// answers and with the copy's copper no input to it, so a region the flow reads badly stays in: dark mask, gap lines and track edges are all plain mask.
fn copper_census(c: &Ctx) -> CopperCensus {
    let Some((g, inside)) = copper_grid(c) else { return CopperCensus::default() };
    let mut out = CopperCensus::default();
    for (face, layer) in [("top", "F.Cu"), ("bottom", "B.Cu")] {
        let m = covered(c, &g, layer);
        let hide: Vec<(f64, f64, f64, f64)> = c.parts.iter().filter(|p| st(p, "side") == face).map(|p| { let b = c.part_box(p); (b.0 - 0.3, b.1 - 0.3, b.2 + 0.3, b.3 + 0.3) }).collect();
        let (cols, rows) = ((g.w as f64 / g.res) as usize, (g.h as f64 / g.res) as usize);
        let photo = c.face(face);
        for j in 0..rows { for i in 0..cols {
            let id = format!("pt:{face}:{i}:{j}");
            let mut r = Rng(seed_u64(&id, "jitter"));
            let (x, y) = (g.x0 + i as f64 + r.unit(), g.y0 + j as f64 + r.unit());
            let (u, v) = (((x - g.x0) * g.res) as usize, ((y - g.y0) * g.res) as usize);
            if u >= g.w || v >= g.h || !inside.d[v * g.w + u] { continue; }
            let on = m.d[v * g.w + u];
            let why = if hide.iter().any(|b| x >= b.0 && x <= b.2 && y >= b.1 && y <= b.3) { Some(PAD_BOX) } else { photo.and_then(|f| photo_blocked(f, (x, y))) };
            match why {
                Some(why) => out.left_out.push(LeftOut { face: face.into(), at: (x, y), copy_has_copper: on, why }),
                None => out.points.push(copper_item(id, face, (x, y), on)),
            }
        } }
    }
    out
}

fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
    let l = dx * dx + dy * dy;
    let t = if l > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l).clamp(0.0, 1.0) } else { 0.0 };
    ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()
}

/// What the planted-claim rule reads on one face: the copy's copper, the threshold scorer's, the face as the panels show it, and the parts' pad boxes.
struct PlantFace<'a> { copy: &'a Mask, scorer: &'a Mask, view: &'a CopperView, boxes: Vec<(f64, f64, f64, f64)> }

/// Where a copper claim may be planted. A control is a real point stated the other way round, so its true answer has to be certain, and certain to a
/// person looking at the crop: two readers that share a threshold can agree and both be wrong, as on a dark patch inside a lighter pour.
struct Planting<'a> { g: Grid, outline: Vec<(f64, f64)>, dots: Vec<(f64, f64)>, faces: HashMap<&'static str, PlantFace<'a>> }

impl<'a> Planting<'a> {
    fn new(c: &'a Ctx) -> Option<Planting<'a>> {
        let (g, _) = copper_grid(c)?;
        let dots = arr(&c.copper["vias"]).iter().chain(c.domes.iter()).filter_map(|v| xy(&v["pos_mm"])).collect();
        let mut faces = HashMap::new();
        for face in ["top", "bottom"] {
            let (Some((copy, scorer)), Some(view)) = (c.reads().get(face), c.views().get(face)) else { continue };
            let boxes = c.parts.iter().filter(|p| (st(p, "side") == "bottom") == (face == "bottom")).map(|p| c.part_box(p)).collect();
            faces.insert(face, PlantFace { copy, scorer, view, boxes });
        }
        Some(Planting { g, outline: points(&c.copper["outline"]["points_mm"]), dots, faces })
    }

    /// Why this point cannot carry a planted claim, or None where `on` (copper, or bare board) is plain to see: away from the board edge, parts and vias;
    /// out to `CONTROL_REACH_MM` nothing but plain mask, with the copy and the threshold scorer both reading `on` at every pixel; both surfaces in the
    /// panel's window to compare with; and the green all round the point clear of the middle of the stretched panel's range, on the side `on` says.
    fn doubt(&self, face: &str, at: (f64, f64), on: bool) -> Option<&'static str> {
        let Some(f) = self.faces.get(face) else { return Some("no reading of this face") };
        let n = self.outline.len();
        if (0..n).any(|i| seg_dist(at, self.outline[i], self.outline[(i + 1) % n]) < CONTROL_EDGE_MM) { return Some("near the board edge"); }
        if f.boxes.iter().any(|b| at.0 >= b.0 - CONTROL_PART_MM && at.0 <= b.2 + CONTROL_PART_MM && at.1 >= b.1 - CONTROL_PART_MM && at.1 <= b.3 + CONTROL_PART_MM) { return Some("near a part"); }
        if self.dots.iter().any(|d| (d.0 - at.0).powi(2) + (d.1 - at.1).powi(2) < CONTROL_VIA_MM * CONTROL_VIA_MM) { return Some("near a via or a via candidate"); }
        let (g, v) = (&self.g, f.view);
        let (w, h) = (v.plain.w as i64, v.plain.h as i64);
        let (cx, cy) = board::to_px(at.0, at.1);
        let r = (CONTROL_REACH_MM * board::RES).ceil() as i64;
        let mut round: Vec<usize> = vec![];
        for dy in -r..=r { for dx in -r..=r {
            if dx * dx + dy * dy > r * r { continue; }
            let (x, y) = (cx as i64 + dx, cy as i64 + dy);
            if x < 0 || y < 0 || x >= w || y >= h { return Some("off the photo"); }
            let i = (y * w + x) as usize;
            if !v.plain.d[i] { return Some("silk, metal, a hole or a part body nearby"); }
            let (mx, my) = board::to_mm(x as f64 + 0.5, y as f64 + 0.5);
            let (a, b) = (((mx - g.x0) * g.res) as i64, ((my - g.y0) * g.res) as i64);
            if (a >= 0 && b >= 0 && (a as usize) < g.w && (b as usize) < g.h && f.copy.d[b as usize * g.w + a as usize]) != on { return Some("the copy has the other surface nearby"); }
            if f.scorer.d.get(i).copied().unwrap_or(false) != on { return Some("the threshold scorer reads the other surface nearby"); }
            round.push(i);
        } }
        let px = window_px(v, at, STRETCH_REF_MM);
        let lit = |i: &usize| f.scorer.d.get(*i).copied().unwrap_or(false);
        let n_lit = px.iter().filter(|i| lit(i)).count();
        if (n_lit.min(px.len() - n_lit) as f64) < CONTROL_BOTH * px.len() as f64 { return Some("only one surface in view to compare with"); }
        let at_q = |mut vals: Vec<f32>, q: usize| -> f32 { let k = (vals.len() - 1) * q / 100; *vals.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)).1 };
        let (lo, hi) = level(v, &px, 1);
        let shown = |q: usize| (at_q(round.iter().map(|i| v.rgb[1].d[*i]).collect(), q) - lo) / (hi - lo);
        if if on { shown(5) < CONTROL_LIGHT } else { shown(95) > CONTROL_DARK } { return Some("not clearly the lighter or the darker surface in the stretched panel"); }
        for k in 0..3 {
            // every third pixel of the window is plenty for a median
            let (copper, bare) = (at_q(px.iter().step_by(3).filter(|i| lit(i)).map(|i| v.rgb[k].d[*i]).collect(), 50), at_q(px.iter().step_by(3).filter(|i| !lit(i)).map(|i| v.rgb[k].d[*i]).collect(), 50));
            if (copper - bare).abs() < CONTROL_TELLS { continue; }
            let side = (at_q(round.iter().map(|i| v.rgb[k].d[*i]).collect(), 50) - bare) / (copper - bare);
            if if on { side < CONTROL_SIDE } else { side > 1.0 - CONTROL_SIDE } { return Some("not the colour of the surface it is, in red, green and blue alike"); }
        }
        None
    }

    fn rule() -> String {
        format!("a planted claim goes only where its true answer is plain to a person: {CONTROL_EDGE_MM} mm from the board edge, {CONTROL_PART_MM} mm from a part's pad box, {CONTROL_VIA_MM} mm from a via or via candidate; \
out to {CONTROL_REACH_MM} mm round the point only plain mask, with the copy and the threshold scorer agreeing at every pixel; each surface at least {:.0}% of the plain mask in the {STRETCH_REF_MM} mm the panel is stretched over; \
the stretched green round the point above {CONTROL_LIGHT} for copper or below {CONTROL_DARK} for bare board; and in each colour that tells the two surfaces apart by {CONTROL_TELLS} levels, the point at least {CONTROL_SIDE} of the way to its own surface's median", CONTROL_BOTH * 100.0)
    }
}

/// The planted claims a draw may use, and how the rest of the points looked at were refused.
#[derive(Default)]
struct Planted { pool: Vec<PItem>, looked: usize, eligible: usize, stated: BTreeMap<String, usize>, refused: BTreeMap<&'static str, usize> }

impl Planted {
    fn refusals(&self) -> String { self.refused.iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(", ") }
}

/// Planted copper claims: real points of the population, at the very position the population gives them, each stated the other way
/// round. So a control is drawn as real items are, on both faces and in both directions, and only its answer is false. It is planted only where
/// `Planting::doubt` finds nothing: a control whose own truth is doubtful would void an honest auditor's sample. They come from the far end
/// of each stratum's order, so extending a sample does not reach them. `used` are the ids already on the sheet.
fn copper_control_pool(c: &Ctx, pop: &[PItem], seed: &str, used: &HashSet<String>) -> Planted {
    let mut out = Planted::default();
    for s in ["bare", "copper"] { out.stated.insert(s.into(), 0); }
    let Some(rule) = Planting::new(c) else { return out };
    let by = strata(pop);
    let mut pick = |s: &str| -> Vec<PItem> {
        let Some(list) = by.get(s) else { return vec![] };
        let ids: Vec<String> = list.iter().map(|p| p.id.clone()).collect();
        let ord = order(&ids, seed, s);
        let at: HashMap<&str, &&PItem> = list.iter().map(|p| (p.id.as_str(), p)).collect();
        let mut keep = vec![];
        for p in ord.iter().rev().take(ord.len() / 2).filter_map(|id| at.get(id.as_str())) {
            out.looked += 1;
            match rule.doubt(&p.face, p.at, p.stratum == "copper") {
                Some(why) => *out.refused.entry(why).or_insert(0) += 1,
                None => { out.eligible += 1; *out.stated.entry(if p.stratum == "copper" { "bare" } else { "copper" }.to_string()).or_insert(0) += 1; if !used.contains(&p.id) { keep.push(copper_item(p.id.clone(), &p.face, p.at, p.stratum != "copper")); } }
            }
        }
        keep
    };
    let (mut wrong_bare, mut wrong_copper) = (pick("copper").into_iter(), pick("bare").into_iter());
    loop {
        let (a, b) = (wrong_bare.next(), wrong_copper.next());
        if a.is_none() && b.is_none() { break; }
        out.pool.extend(a); out.pool.extend(b);
    }
    out
}

/// Whether what is left out hides the regions the flow reads worst. Those are, per face, the quarter of 5 mm tiles where the copy disagrees most
/// with the threshold scorer over plain mask. Pad boxes were out of the population before this rule, so the comparison is of the rule on what the pad boxes leave:
/// what share of the points lay in those tiles before it, and what share after. Written beside the sample, and stated on the sheet.
fn exclusion_report(c: &Ctx, planted: &Planted) -> Value {
    let census = c.census();
    let photo_rule = |l: &&LeftOut| l.why != PAD_BOX;
    let mut faces = serde_json::Map::new();
    let tile_of = |at: (f64, f64)| -> (usize, usize) { let (x, y) = board::to_px(at.0, at.1); (x.max(0.0) as usize / WORST_TILE_PX, y.max(0.0) as usize / WORST_TILE_PX) };
    let (mut before_all, mut after_all, mut in_before_all, mut in_after_all) = (0usize, 0usize, 0usize, 0usize);
    let grid = copper_grid(c);
    for face in ["top", "bottom"] {
        let (n, k) = (census.points.iter().filter(|p| p.face == face).count(), census.left_out.iter().filter(|l| l.face == face).count());
        let mut by = BTreeMap::new();
        for l in census.left_out.iter().filter(|l| l.face == face) { *by.entry(l.why).or_insert(0usize) += 1; }
        let mut d = json!({"candidates": n + k, "leftOut": k, "share": pct(k as f64, (n + k) as f64), "byMaterial": by});
        if let (Some((g, _)), Some(f), Some((copy, scorer))) = (&grid, c.face(face), c.reads().get(face)) {
            let plain = Mask { w: f.w, h: f.h, d: f.cls.iter().map(|k| *k == photomat::MASK).collect() }.erode(fidelity::BESIDE_PX);
            let (cols, rows) = (f.w / WORST_TILE_PX + 1, f.h / WORST_TILE_PX + 1);
            let (mut bad, mut all) = (vec![0usize; cols * rows], vec![0usize; cols * rows]);
            for y in 0..f.h { for x in 0..f.w {
                let i = y * f.w + x;
                if !plain.d[i] { continue; }
                let (mx, my) = board::to_mm(x as f64 + 0.5, y as f64 + 0.5);
                let (u, v) = (((mx - g.x0) * g.res) as i64, ((my - g.y0) * g.res) as i64);
                if u < 0 || v < 0 || u as usize >= g.w || v as usize >= g.h { continue; }
                let t = (y / WORST_TILE_PX) * cols + x / WORST_TILE_PX;
                all[t] += 1;
                if copy.d[v as usize * g.w + u as usize] != scorer.d[i] { bad[t] += 1; }
            } }
            // a tile needs a tenth of its area in plain mask to have a figure at all
            let mut tiles: Vec<(usize, usize, f64)> = (0..rows).flat_map(|r| (0..cols).map(move |k| (k, r))).filter(|(k, r)| all[r * cols + k] * 10 >= WORST_TILE_PX * WORST_TILE_PX).map(|(k, r)| (k, r, bad[r * cols + k] as f64 / all[r * cols + k] as f64)).collect();
            tiles.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
            let worst: HashSet<(usize, usize)> = tiles.iter().take(tiles.len() / 4).map(|t| (t.0, t.1)).collect();
            let kept: Vec<(f64, f64)> = census.points.iter().filter(|p| p.face == face).map(|p| p.at).collect();
            let gone: Vec<(f64, f64)> = census.left_out.iter().filter(|l| l.face == face).filter(photo_rule).map(|l| l.at).collect();
            let (ka, ga) = (kept.iter().filter(|a| worst.contains(&tile_of(**a))).count(), gone.iter().filter(|a| worst.contains(&tile_of(**a))).count());
            d["worstReadTiles"] = json!({"tiles": worst.len(), "shareOfPointsBeforeTheRule": pct((ka + ga) as f64, (kept.len() + gone.len()) as f64), "shareOfPointsAfterTheRule": pct(ka as f64, kept.len() as f64), "ruleLeavesOutThere": pct(ga as f64, (ka + ga) as f64), "ruleLeavesOutElsewhere": pct((gone.len() - ga) as f64, (kept.len() + gone.len() - ka - ga) as f64)});
            before_all += kept.len() + gone.len(); after_all += kept.len(); in_before_all += ka + ga; in_after_all += ka;
        }
        faces.insert(face.into(), d);
    }
    let ans = |on: bool| { let (n, k) = (census.points.iter().filter(|p| (p.stratum == "copper") == on).count(), census.left_out.iter().filter(photo_rule).filter(|l| l.copy_has_copper == on).count()); json!({"pointsBeforeTheRule": n + k, "ruleLeavesOut": k, "share": pct(k as f64, (n + k) as f64)}) };
    let photo_out = census.left_out.iter().filter(photo_rule).count();
    let all = census.points.len() + census.left_out.len();
    json!({
        "rule": "a point goes when a part's pad box covers it, as before, or by what the photo shows: not plain mask within fidelity::BESIDE_PX, the rule `check fidelity` scores copper with. The copy's copper is no input.",
        "candidates": all, "leftOut": census.left_out.len(), "share": pct(census.left_out.len() as f64, all as f64),
        "photoRule": {"pointsBeforeTheRule": census.points.len() + photo_out, "leftOut": photo_out, "share": pct(photo_out as f64, (census.points.len() + photo_out) as f64)},
        "byMaterial": census.counts(), "byFace": faces, "byCopysAnswer": {"copper": ans(true), "bare": ans(false)},
        "worstReadTiles": {"meaning": "per face, the quarter of 5 mm tiles where the copy disagrees most with the threshold scorer over plain mask. If these hold a much smaller share of the points after the photo rule than before, the rule hides what the flow reads worst", "shareOfPointsBeforeTheRule": pct(in_before_all as f64, before_all as f64), "shareOfPointsAfterTheRule": pct(in_after_all as f64, after_all as f64)},
        "plantedClaims": {"pointsEligible": planted.eligible, "ofPointsLookedAt": planted.looked, "eligibleByWhatTheClaimStates": planted.stated, "refused": planted.refused, "rule": Planting::rule(), "meaning": "real points in the far half of each stratum's order whose true answer is plain to a person: only these can be stated falsely with confidence. `refused` counts the rest by the first test each failed"},
    })
}

fn population(c: &Ctx, cat: &str) -> Vec<PItem> {
    let mut out = vec![];
    match cat {
        "identity" => for p in &c.parts {
            let body = format!("{}; package {}; marking {}; value {}", st(p, "function"), st(p, "package"), if st(p, "marking").is_empty() { "none read" } else { st(p, "marking") }, if st(p, "value").is_empty() { "not stated" } else { st(p, "value") });
            out.push(part_item(c, p, "all", body, h(&[st(p, "ref"), st(p, "side"), st(p, "package"), st(p, "footprint"), st(p, "function"), st(p, "marking"), st(p, "mpn")])));
        },
        "values" => for p in c.parts.iter().filter(|p| !p["fieldTags"]["value"].is_null() || !st(p, "value").is_empty()) {
            let body = format!("value is {} (marking {})", if st(p, "value").is_empty() { "not stated" } else { st(p, "value") }, if st(p, "marking").is_empty() { "none read" } else { st(p, "marking") });
            // a value a photo can show is one read from a marking; an unmarked part's value needs the bench (docs/method.md, "Two scores")
            let mut it = part_item(c, p, twoscores::value_stratum(p), body, h(&[st(p, "ref"), st(p, "value"), st(p, "marking")]));
            it.class = st(p, "value").to_string();
            out.push(it);
        },
        "blocks" => for b in &c.blocks { for r in arr(&b["parts"]).iter().filter_map(|x| x.as_str()) {
            let Some(p) = c.parts.iter().find(|p| st(p, "ref") == r) else { continue };
            let body = format!("belongs to block {} ({})", st(b, "name"), st(b, "function"));
            let mut it = part_item(c, p, "all", body, h(&[r, st(b, "id"), st(b, "name"), st(b, "function"), st(p, "function")]));
            it.subject = format!("{r} ({})", st(p, "function"));
            it.class = st(b, "id").to_string();
            out.push(it);
        } },
        "placement" => for p in &c.parts {
            let r = st(p, "ref");
            let mut it = part_item(c, p, "all", format!("the outlines sit on {r}'s real pads or body ({})", st(p, "footprint")), h(&[r, st(p, "side"), st(p, "footprint"), &p["pos_mm"].to_string(), &p["rot"].to_string()]));
            it.shapes = c.part_pads(r).iter().map(|d| (rect_of(d), true)).collect();
            if it.shapes.is_empty() { it.shapes.push((rect_of(&Pad { part: r.into(), num: String::new(), x: it.at.0, y: it.at.1, w: 1.0, h: 1.0, rot: 0.0, round: false, drill: 0.0, layers: vec![], net: String::new() }), true)); }
            out.push(it);
        },
        "copper" => out = c.census().points.clone(),
        "vias" => {
            let vias = arr(&c.copper["vias"]);
            let kept: Vec<(f64, f64)> = vias.iter().filter_map(|v| xy(&v["pos_mm"])).collect();
            for v in &vias {
                let Some(at) = xy(&v["pos_mm"]) else { continue };
                let body = "a real via at the ring's centre";
                out.push(PItem { id: format!("via:{}", st(v, "id")), stratum: "kept".into(), class: "via".into(), subject: format!("via {}", st(v, "id")), body: body.into(), hash: h(&[&v["pos_mm"].to_string()]), face: via_face(v).into(), at, window: 4.0, ring: 0.6, shapes: vec![], other: vec![] });
            }
            for d in &c.domes {
                let Some(at) = xy(&d["pos_mm"]) else { continue };
                if kept.iter().any(|k| (k.0 - at.0).abs() < 0.3 && (k.1 - at.1).abs() < 0.3) { continue; }
                let (face, body) = (if st(d, "side") == "top" { "top" } else { "bottom" }, "NOT a via: the program dropped this candidate");
                out.push(PItem { id: format!("dome:{face}:{}", d["pos_mm"]), stratum: "dropped".into(), class: "dropped".into(), subject: format!("{face} candidate"), body: body.into(), hash: h(&[body]), face: face.into(), at, window: 4.0, ring: 0.6, shapes: vec![], other: vec![] });
            }
        }
        "connectivity" => {
            // A pin's claim is the join of its pad to copper the copy drew, never its net: no crop shows a net. A pin with a net and no such join is put
            // forward as nothing: `pin_proof` counts it against the stratum.
            let joined = pin_joins(c);
            for (pin, n) in net_pins(c) { if let Some((cu, j)) = joined.get(pin).and_then(|l| pin_claimed(pin, l)) { out.push(pin_item(pin, st(n, "name"), cu, j)); } }
            // An island's claim is one join it is seen to make, never its net: no crop shows a net. An island with no join at all is put forward as
            // nothing: `island_proof` counts it against the stratum.
            for (cu, joins) in c.joins() { if let Some(j) = claimed(cu, joins) { out.push(join_item(cu, j)); } }
        }
        "layers" => {
            if let Some(n) = c.layers["proposed"].as_u64() { out.push(PItem { id: "layers".into(), stratum: "all".into(), class: "count".into(), subject: "layer count".into(), body: format!("the board has {n} copper layers"), hash: h(&[&n.to_string()]), face: String::new(), at: (0.0, 0.0), window: 0.0, ring: 0.0, shapes: vec![], other: vec![] }); }
            for p in arr(&c.layers["planes"]) { out.push(PItem { id: format!("plane:{}", st(&p, "layer")), stratum: "all".into(), class: "plane".into(), subject: format!("plane {}", st(&p, "layer")), body: format!("is a {} plane", st(&p, "net")), hash: h(&[st(&p, "layer"), st(&p, "net")]), face: String::new(), at: (0.0, 0.0), window: 0.0, ring: 0.0, shapes: vec![], other: vec![] }); }
        }
        _ => {}
    }
    // two records of one thing are one item: a sample must not count it twice
    let mut seen = HashSet::new();
    out.retain(|p| seen.insert((p.stratum.clone(), p.id.clone())));
    // a face whose copper failed `check copper` is counted as failed by its own ceiling (`notread`): no auditor is asked to judge it crop by crop
    if matches!(cat, "copper" | "connectivity") { let unread = crate::notread::faces(&c.flow); if !unread.is_empty() { out.retain(|p| !crate::notread::skips(&unread, &p.face)); } }
    out
}

fn population_hash(pop: &[PItem]) -> String {
    let mut lines: Vec<String> = pop.iter().map(|p| format!("{}|{}|{}", p.stratum, p.id, p.hash)).collect();
    lines.sort();
    sha256_hex(lines.join("\n").as_bytes())
}

fn strata(pop: &[PItem]) -> BTreeMap<String, Vec<&PItem>> {
    let mut m: BTreeMap<String, Vec<&PItem>> = BTreeMap::new();
    for p in pop { m.entry(p.stratum.clone()).or_default().push(p); }
    m
}

fn derive_seed(cat: &str, pop_hash: &str, draw: u64) -> String { h(&[cat, pop_hash, &draw.to_string()]) }

/// A claim known to be false: a careless auditor who says `ok` to it voids the sample. Controls borrow
/// a real item's id, so a sheet does not show them apart from the rest.
fn control(c: &Ctx, cat: &str, pop: &[PItem], r: &mut Rng) -> Option<PItem> {
    match cat {
        // planted by `copper_control_pool`
        "layers" | "copper" => None,
        "vias" => {
            let (g, inside) = copper_grid(c)?;
            let near: Vec<(f64, f64)> = arr(&c.copper["vias"]).iter().chain(c.domes.iter()).filter_map(|v| xy(&v["pos_mm"])).collect();
            let kept: Vec<&PItem> = pop.iter().filter(|p| p.stratum == "kept").collect();
            if kept.is_empty() { return None; }
            let base = kept[r.below(kept.len())];
            for _ in 0..2000 {
                let (x, y) = (g.x0 + r.unit() * g.w as f64 / g.res, g.y0 + r.unit() * g.h as f64 / g.res);
                let (u, v) = (((x - g.x0) * g.res) as usize, ((y - g.y0) * g.res) as usize);
                if u >= g.w || v >= g.h || !inside.d[v * g.w + u] { continue; }
                if near.iter().any(|n| (n.0 - x).powi(2) + (n.1 - y).powi(2) < 1.5 * 1.5) { continue; }
                let face = if r.below(2) == 0 { "top" } else { "bottom" };
                if c.parts.iter().any(|p| { if st(p, "side") != face { return false; } let b = c.part_box(p); x >= b.0 - 0.5 && x <= b.2 + 0.5 && y >= b.1 - 0.5 && y <= b.3 + 0.5 }) { continue; }
                let mut it = base.clone();
                it.face = face.into(); it.at = (x, y);
                return Some(it);
            }
            None
        }
        "connectivity" => {
            // A join stated in another claim's words proves nothing either way, so a false join has one end drawn where there is none. A pin's
            // as often as an island's, however many more islands there are: each stratum's claims are judged in their own right.
            for _ in 0..500 {
                let stratum = if r.below(2) == 0 { "pin" } else { "island" };
                let of: Vec<&PItem> = pop.iter().filter(|p| p.stratum == stratum).collect();
                if of.is_empty() { continue; }
                if let Some(k) = false_join(c, of[r.below(of.len())], r) { return Some(k); }
            }
            None
        }
        "placement" => {
            let base = &pop[r.below(pop.len())];
            let mut it = base.clone();
            for (pts, _) in it.shapes.iter_mut() { for p in pts.iter_mut() { p.0 += 2.0; p.1 += 1.5; } }
            Some(it)
        }
        _ => {
            for _ in 0..500 {
                let (a, b) = (&pop[r.below(pop.len())], &pop[r.below(pop.len())]);
                if a.stratum == b.stratum && a.class != b.class && a.body != b.body && !b.class.is_empty() {
                    let mut it = a.clone();
                    it.body = b.body.clone();
                    return Some(it);
                }
            }
            None
        }
    }
}

fn proof(seed: &str, a: &str, kind: &str) -> String { h(&[seed, a, kind]) }
fn is_control(seed: &str, it: &Value) -> bool { it["proof"].as_str() == Some(proof(seed, st(it, "a"), "control").as_str()) }

// ---- drawing a sample and writing its crops ----

fn audit_dir(flow: &Path, cat: &str) -> PathBuf { flow.join("audits").join(cat) }
pub fn check_category(cat: &str) -> Result<(), String> { if CATEGORIES.contains(&cat) { Ok(()) } else { Err(format!("unknown category {cat}: {}", CATEGORIES.join(", "))) } }

/// Draw the category's sample, or extend the current one along the same random order. `n` is per stratum;
/// the default can reach `target` with one wrong answer. A current sample cannot be thrown away for a new
/// one, because redrawing until a sample looks good would be the easiest way to cheat: only a changed
/// population or a voided sample allows a new draw, and the seed follows from those, not from anyone's choice.
/// The record that no sample can be drawn for a category as its items stand (`audits/<cat>/undrawable.json`), when there is one.
fn undrawable(flow: &Path, cat: &str, pop: &[PItem]) -> Option<String> {
    let d = items::read_json(&audit_dir(flow, cat).join("undrawable.json")).ok()?;
    (st(&d, "populationHash") == population_hash(pop) && st(&d, "photos") == photos_key(flow)).then(|| st(&d, "why").to_string())
}

/// The two board images a draw looks at, by hash: a record that no sample can be drawn holds only for the photos it was tried on.
fn photos_key(flow: &Path) -> String {
    let mut all = vec![];
    for face in ["top", "bottom"] { all.extend(std::fs::read(flow.join(board_file(face))).unwrap_or_default()); }
    sha256_hex(&all)
}

/// Draw a sample, or extend the current one. A draw that cannot be made at all on this board (too few places where a false claim would be
/// false beyond doubt) is recorded with the items it was tried on: the category then stays at its cap and the review loop stops waiting for
/// an audit nobody can give. The record lapses when the items change, and a draw that succeeds removes it.
pub fn draw_sample(flow: &Path, cat: &str, n: Option<usize>, who: &str, target: f64) -> Result<Value, String> {
    let r = draw_sample_inner(flow, cat, n, who, target);
    let mark = audit_dir(flow, cat).join("undrawable.json");
    match &r {
        Ok(_) => { let _ = std::fs::remove_file(&mark); }
        Err(e) if e.contains("planted claims") => {
            let pop = population(&Ctx::load(flow), cat);
            let _ = std::fs::create_dir_all(audit_dir(flow, cat));
            let _ = std::fs::write(&mark, json!({"populationHash": population_hash(&pop), "photos": photos_key(flow), "why": e, "at": ledger::now(), "note": "no sample can be drawn on these items: the category stays at its cap and the review does not wait for this audit"}).to_string() + "\n");
        }
        Err(_) => {}
    }
    r
}

fn draw_sample_inner(flow: &Path, cat: &str, n: Option<usize>, who: &str, target: f64) -> Result<Value, String> {
    check_category(cat)?;
    let c = Ctx::load(flow);
    let pop = population(&c, cat);
    if pop.is_empty() { return Err(format!("{cat}: nothing to audit yet")); }
    let dir = audit_dir(flow, cat);
    std::fs::create_dir_all(dir.join("crops")).map_err(|e| e.to_string())?;
    let pop_hash = population_hash(&pop);
    let by = strata(&pop);
    let old = items::read_json(&dir.join("sample.json")).ok();
    let mut items_out: Vec<Value> = vec![];
    let mut draw = 1u64;
    let mut extend = false;
    let mut archive = None;
    if let Some(o) = &old {
        let prev = o["draw"].as_u64().unwrap_or(1);
        let void = audit_state(&c, cat, &pop, target).status == "void";
        if st(o, "populationHash") == pop_hash && !void {
            extend = true;
            draw = prev;
            items_out = arr(&o["items"]);
        } else {
            archive = Some(prev);
            draw = prev + 1;
        }
    }
    let seed = derive_seed(cat, &pop_hash, draw);
    let mut counts = serde_json::Map::new();
    let mut new_real: Vec<&PItem> = vec![];
    for (s, list) in &by {
        let ids: Vec<String> = list.iter().map(|p| p.id.clone()).collect();
        let want = n.unwrap_or_else(|| required_n(target, 1, list.len())).min(list.len());
        let ord = order(&ids, &seed, s);
        let have: usize = if extend { old.as_ref().and_then(|o| o["counts"][s.as_str()].as_u64()).unwrap_or(0) as usize } else { 0 };
        let take = want.max(have);
        for id in &ord[have..take] { if let Some(p) = list.iter().find(|p| &p.id == id) { new_real.push(*p); } }
        counts.insert(s.clone(), json!(take));
    }
    if extend && new_real.is_empty() {
        return Err(format!("{cat}: sample {seed} is current and at least that large: finish it. A current sample cannot be redrawn; only a larger one extends it"));
    }
    let total_real: usize = counts.values().filter_map(|v| v.as_u64()).sum::<u64>() as usize;
    let have_ctl = items_out.iter().filter(|i| is_control(&seed, i)).count();
    let mut r = Rng(seed_u64(&seed, &format!("control{total_real}")));
    let mut batch: Vec<(PItem, bool)> = new_real.iter().map(|p| ((*p).clone(), false)).collect();
    let want_ctl = controls_for(total_real).saturating_sub(have_ctl);
    let mut planted = Planted::default();
    if cat == "copper" {
        // an id already on the sheet cannot come back under the other claim
        let used: HashSet<String> = items_out.iter().map(|i| st(i, "id").to_string()).chain(new_real.iter().map(|p| p.id.clone())).collect();
        planted = copper_control_pool(&c, &pop, &seed, &used);
        // too few places where a false claim is false beyond doubt: say so, and plant none that an honest auditor could call ok
        if planted.pool.len() < want_ctl {
            return Err(format!("copper: only {} of the {want_ctl} planted claims a sample of {total_real} needs can be placed where the photo is plain to a person ({} of {} points looked at pass; refused: {}). A sample with too few controls proves nothing and a doubtful control voids an honest audit, so none was drawn and nothing was changed",
                planted.pool.len(), planted.eligible, planted.looked, planted.refusals()));
        }
        batch.extend(planted.pool.iter().take(want_ctl).map(|k| (k.clone(), true)));
    } else {
        for _ in 0..want_ctl { if let Some(k) = control(&c, cat, &pop, &mut r) { batch.push((k, true)); } }
    }
    // the old sample is put away only now that the new one can be written
    if let Some(prev) = archive {
        for f in ["sample", "population"] { let _ = std::fs::rename(dir.join(format!("{f}.json")), dir.join(format!("{f}-{prev}.json"))); }
        // a smaller new sample must not leave the old sample's crops beside its own
        if let Ok(rd) = std::fs::read_dir(dir.join("crops")) { for e in rd.flatten() { let _ = std::fs::remove_file(e.path()); } }
    }
    for i in (1..batch.len()).rev() { batch.swap(i, r.below(i + 1)); }
    let mut next = items_out.len() + 1;
    for (p, ctl) in &batch {
        let a = format!("a{next:03}");
        let crop = if p.window > 0.0 { format!("audits/{cat}/crops/{a}.jpg") } else { String::new() };
        items_out.push(json!({"a": a, "id": p.id, "stratum": p.stratum, "hash": p.hash, "claim": p.claim(), "face": p.face, "at_mm": [p.at.0, p.at.1], "crop": crop, "proof": proof(&seed, &a, if *ctl { "control" } else { "real" })}));
        next += 1;
    }
    let pop_map: BTreeMap<&String, BTreeMap<&String, &String>> = by.iter().map(|(s, l)| (s, l.iter().map(|p| (&p.id, &p.hash)).collect())).collect();
    std::fs::write(dir.join("population.json"), serde_json::to_string(&pop_map).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;
    let sizes: BTreeMap<&String, usize> = by.iter().map(|(s, l)| (s, l.len())).collect();
    let doc = json!({"category": cat, "draw": draw, "seed": seed, "populationHash": pop_hash, "population": sizes, "counts": counts, "drawnBy": who, "drawnAt": ledger::now(), "items": items_out});
    std::fs::write(dir.join("sample.json"), serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;
    let new_a: Vec<(String, &PItem)> = { let mut k = next - batch.len(); batch.iter().map(|(p, _)| { let a = format!("a{k:03}"); k += 1; (a, p) }).collect() };
    if cat == "copper" {
        write_copper_crops(&c, &new_a);
        std::fs::write(dir.join("exclusions.json"), serde_json::to_string_pretty(&exclusion_report(&c, &planted)).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;
    } else {
        write_crops(flow, cat, &new_a);
    }
    write_sheets(flow, cat, &doc);
    Ok(doc)
}

fn sample_px(src: &RgbImage, x: f64, y: f64) -> [u8; 3] {
    if x < 0.0 || y < 0.0 || x >= (src.width() - 1) as f64 || y >= (src.height() - 1) as f64 { return [0, 0, 0]; }
    let (x0, y0, fx, fy) = (x as u32, y as u32, x.fract(), y.fract());
    let mut o = [0u8; 3];
    for k in 0..3 {
        let g = |a: u32, b: u32| src.get_pixel(a, b)[k] as f64;
        o[k] = (g(x0, y0) * (1.0 - fx) * (1.0 - fy) + g(x0 + 1, y0) * fx * (1.0 - fy) + g(x0, y0 + 1) * (1.0 - fx) * fy + g(x0 + 1, y0 + 1) * fx * fy) as u8;
    }
    o
}

fn panel(src: &RgbImage, p: &PItem, size: u32, marks: bool) -> RgbImage {
    let (cx, cy) = board::to_px(p.at.0, p.at.1);
    let per = p.window * board::RES / size as f64;
    let mut out = RgbImage::new(size, size);
    for v in 0..size { for u in 0..size { out.put_pixel(u, v, image::Rgb(sample_px(src, cx + (u as f64 - size as f64 / 2.0) * per, cy + (v as f64 - size as f64 / 2.0) * per))); } }
    if marks {
        let map = |q: (f64, f64)| { let (a, b) = board::to_px(q.0, q.1); (size as f64 / 2.0 + (a - cx) / per, size as f64 / 2.0 + (b - cy) / per) };
        if p.ring > 0.0 { mark_ring(&mut out, p.ring * board::RES / per); }
        for (pts, closed) in &p.shapes {
            let q: Vec<(f64, f64)> = pts.iter().map(|x| map(*x)).collect();
            if *closed { draw::poly(&mut out, &q, [255, 60, 200], 1); } else { for w in q.windows(2) { draw::line(&mut out, w[0], w[1], [255, 60, 200], 1); } }
        }
        for (pts, closed) in &p.other {
            let q: Vec<(f64, f64)> = pts.iter().map(|x| map(*x)).collect();
            if *closed { draw::poly(&mut out, &q, OTHER_END, 1); } else { for w in q.windows(2) { draw::line(&mut out, w[0], w[1], OTHER_END, 1); } }
        }
    }
    out
}

/// The yellow ring and its ticks at the middle of a panel. Nothing is drawn inside it.
fn mark_ring(out: &mut RgbImage, r: f64) {
    let mid = out.width() as f64 / 2.0;
    draw::circle(out, (mid, mid), r, [255, 230, 0], 1);
    for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { draw::line(out, (mid + dx * r, mid + dy * r), (mid + dx * (r + 14.0), mid + dy * (r + 14.0)), [255, 230, 0], 1); }
}

/// A face as the copper panels see it, and nothing else: the two photos of the face averaged and lightly blurred, because their grain is
/// independent sensor noise (see docs/method.md), and which pixels are plain mask. The copy is not an input, so no panel can carry its answer.
struct CopperView { raw: RgbImage, rgb: [Plane; 3], plain: Mask }

fn copper_view(flow: &Path, face: &str, f: &Face) -> Option<CopperView> {
    let raw = image::open(flow.join(board_file(face))).ok()?.to_rgb8();
    let second = image::open(flow.join(format!("board/{face}-second.png"))).ok().map(|i| i.to_rgb8()).filter(|i| i.dimensions() == raw.dimensions());
    let (w, h) = (raw.width() as usize, raw.height() as usize);
    let chan = |k: usize| -> Plane {
        let d = raw.pixels().enumerate().map(|(i, p)| match &second { Some(s) => (p[k] as f32 + s.get_pixel((i % w) as u32, (i / w) as u32)[k] as f32) / 2.0, None => p[k] as f32 }).collect();
        raster::blur(&Plane { w, h, d }, 1, 2)
    };
    let rgb = [chan(0), chan(1), chan(2)];
    let plain = Mask { w: f.w, h: f.h, d: f.cls.iter().map(|k| *k == photomat::MASK).collect() }.erode(fidelity::BESIDE_PX);
    Some(CopperView { raw, rgb, plain })
}

/// The plain mask pixels in the window round a point, or every pixel of the window when under 400 of it are plain.
fn window_px(v: &CopperView, at: (f64, f64), window: f64) -> Vec<usize> {
    let (cx, cy) = board::to_px(at.0, at.1);
    let half = window * board::RES / 2.0;
    let (w, h) = (v.plain.w as i64, v.plain.h as i64);
    let (x0, x1, y0, y1) = (((cx - half) as i64).clamp(0, w), ((cx + half) as i64).clamp(0, w), ((cy - half) as i64).clamp(0, h), ((cy + half) as i64).clamp(0, h));
    let pick = |only_plain: bool| -> Vec<usize> { let mut o = vec![]; for y in y0..y1 { for x in x0..x1 { let i = (y * w + x) as usize; if !only_plain || v.plain.d[i] { o.push(i); } } } o };
    let at_px = pick(true);
    if at_px.len() < 400 { pick(false) } else { at_px }
}

/// One channel's 3rd and 97th percentile over those pixels, with a span of at least 8 so flat mask stays flat instead of showing its grain.
fn level(v: &CopperView, px: &[usize], k: usize) -> (f32, f32) {
    let mut vals: Vec<f32> = px.iter().map(|i| v.rgb[k].d[*i]).collect();
    if vals.is_empty() { return (0.0, 255.0); }
    let mut at = |q: usize| { let n = (vals.len() - 1) * q / 100; *vals.select_nth_unstable_by(n, |a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)).1 };
    let (lo, hi) = (at(3), at(97));
    (lo, hi.max(lo + 8.0))
}

/// Each channel's levels over the plain mask in the window round a point, so silk, metal and parts cannot set the range. The same rule for every point.
fn stretch_levels(v: &CopperView, at: (f64, f64), window: f64) -> [(f32, f32); 3] {
    let px = window_px(v, at, window);
    [level(v, &px, 0), level(v, &px, 1), level(v, &px, 2)]
}

fn stretched_panel(v: &CopperView, at: (f64, f64), window: f64, levels: &[(f32, f32); 3], size: u32) -> RgbImage {
    let (cx, cy) = board::to_px(at.0, at.1);
    let per = window * board::RES / size as f64;
    let mut out = RgbImage::new(size, size);
    let (w, h) = (v.plain.w as f64, v.plain.h as f64);
    for b in 0..size { for a in 0..size {
        let (x, y) = (cx + (a as f64 - size as f64 / 2.0) * per, cy + (b as f64 - size as f64 / 2.0) * per);
        if x < 0.0 || y < 0.0 || x >= w - 1.0 || y >= h - 1.0 { continue; }
        let mut o = [0u8; 3];
        for k in 0..3 { let (lo, hi) = levels[k]; o[k] = (((v.rgb[k].sample(x as f32, y as f32) - lo) / (hi - lo)).clamp(0.0, 1.0) * 255.0) as u8; }
        out.put_pixel(a, b, image::Rgb(o));
    } }
    mark_ring(&mut out, 0.4 * board::RES / per);
    out
}

/// One copper crop, from the photos and the position only: a real item and a planted one get the same treatment because this
/// function cannot tell them apart. Top row, the raw photo and the stretched window; bottom row, the wider stretched view and the close one.
fn copper_crop(v: &CopperView, face: &str, at: (f64, f64)) -> RgbImage {
    let levels = stretch_levels(v, at, STRETCH_REF_MM);
    let wide = stretch_levels(v, at, COPPER_CONTEXT_MM);
    let it = copper_item(String::new(), face, at, true);
    let raw = panel(&v.raw, &it, CROP_PX, true);
    let mut all = RgbImage::new(CROP_PX * 2 + 8, CROP_PX * 2 + 8);
    image::imageops::replace(&mut all, &raw, 0, 0);
    image::imageops::replace(&mut all, &stretched_panel(v, at, COPPER_WINDOW_MM, &levels, CROP_PX), (CROP_PX + 8) as i64, 0);
    image::imageops::replace(&mut all, &stretched_panel(v, at, COPPER_CONTEXT_MM, &wide, CROP_PX), 0, (CROP_PX + 8) as i64);
    image::imageops::replace(&mut all, &stretched_panel(v, at, COPPER_ZOOM_MM, &levels, CROP_PX), (CROP_PX + 8) as i64, (CROP_PX + 8) as i64);
    all
}

fn save_jpeg(img: &RgbImage, path: &Path) -> Result<(), String> {
    let mut f = std::io::BufWriter::new(std::fs::File::create(path).map_err(|e| e.to_string())?);
    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut f, 90).encode_image(img).map_err(|e| e.to_string())
}

fn write_copper_crops(c: &Ctx, list: &[(String, &PItem)]) {
    let dir = audit_dir(&c.flow, "copper").join("crops");
    for (face, view) in c.views() {
        for (a, p) in list.iter().filter(|(_, p)| p.face == *face) { let _ = save_jpeg(&copper_crop(view, face, p.at), &dir.join(format!("{a}.jpg"))); }
    }
}

/// Crops for looking at legibility, in a folder of the caller's and outside the audit: points of the current population that are not in the
/// sample's reach and not in the planted claims' reach, with the claims kept apart in `key.json`. No verdict can be recorded on them.
pub fn copper_preview(flow: &Path, n: usize, out: &Path) -> Result<usize, String> {
    let (Ok(a), Ok(b)) = (std::fs::canonicalize(flow.join("audits")), std::fs::create_dir_all(out).and_then(|_| std::fs::canonicalize(out))) else { return Err("cannot prepare the folders".into()) };
    if b.starts_with(&a) { return Err(format!("{} is inside the audit: a preview stays out of it", out.display())); }
    let c = Ctx::load(flow);
    let pop = population(&c, "copper");
    // the seed the next draw will take, or the current sample's if it is of this population, so these points stay clear of it
    let pop_hash = population_hash(&pop);
    let old = items::read_json(&audit_dir(flow, "copper").join("sample.json")).ok();
    let draw = old.as_ref().map(|o| { let d = o["draw"].as_u64().unwrap_or(1); if st(o, "populationHash") == pop_hash { d } else { d + 1 } }).unwrap_or(1);
    let seed = derive_seed("copper", &pop_hash, draw);
    let (mut key, mut made) = (vec![], 0usize);
    let mut picks: Vec<PItem> = vec![];
    for (s, list) in &strata(&pop) {
        let ids: Vec<String> = list.iter().map(|p| p.id.clone()).collect();
        let ord = order(&ids, &seed, s);
        let from = ord.len() / 3;
        for id in ord.iter().skip(from).take(n.div_ceil(2)) { if let Some(p) = list.iter().find(|p| &p.id == id) { picks.push((*p).clone()); } }
    }
    let mut r = Rng(seed_u64(&seed, "preview"));
    for i in (1..picks.len()).rev() { picks.swap(i, r.below(i + 1)); }
    for face in ["top", "bottom"] {
        let Some(view) = c.views().get(face) else { continue };
        for (i, p) in picks.iter().enumerate().filter(|(_, p)| p.face == face) {
            save_jpeg(&copper_crop(view, face, p.at), &out.join(format!("t{:02}.jpg", i + 1)))?;
            key.push(json!({"t": format!("t{:02}", i + 1), "face": p.face, "at_mm": [p.at.0, p.at.1], "claim": p.claim(), "stratum": p.stratum}));
            made += 1;
        }
    }
    std::fs::write(out.join("key.json"), serde_json::to_string_pretty(&key).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;
    let planted = copper_control_pool(&c, &pop, &seed, &HashSet::new());
    std::fs::write(out.join("exclusions.json"), serde_json::to_string_pretty(&exclusion_report(&c, &planted)).map_err(|e| e.to_string())? + "\n").map_err(|e| e.to_string())?;
    Ok(made)
}

fn write_crops(flow: &Path, cat: &str, list: &[(String, &PItem)]) {
    let mut imgs: HashMap<&str, RgbImage> = HashMap::new();
    for face in ["top", "bottom"] { if let Ok(i) = image::open(flow.join(board_file(face))) { imgs.insert(face, i.to_rgb8()); } }
    let dir = audit_dir(flow, cat).join("crops");
    for (a, p) in list {
        let Some(src) = imgs.get(p.face.as_str()) else { continue };
        let (raw, marked) = (panel(src, p, CROP_PX, false), panel(src, p, CROP_PX, true));
        // a join gets a second row, close in on the place the two ends meet
        let join = !p.other.is_empty();
        let mut both = RgbImage::new(CROP_PX * 2 + 8, if join { CROP_PX * 2 + 8 } else { CROP_PX });
        image::imageops::replace(&mut both, &raw, 0, 0);
        image::imageops::replace(&mut both, &marked, (CROP_PX + 8) as i64, 0);
        if join {
            // close enough to see the join, and wide enough to hold the pad or the via ring it is made to
            let mark: &[(f64, f64)] = if p.stratum == "pin" { &p.shapes[0].0 } else if p.class != "copper" { &p.other[0].0 } else { &[] };
            let across = 2.0 * mark.iter().map(|q| (q.0 - p.at.0).abs().max((q.1 - p.at.1).abs())).fold(0.0, f64::max) + 1.0;
            let close = PItem { window: across.clamp(JOIN_ZOOM_MM, p.window.max(JOIN_ZOOM_MM)), ..(*p).clone() };
            image::imageops::replace(&mut both, &panel(src, &close, CROP_PX, false), 0, (CROP_PX + 8) as i64);
            image::imageops::replace(&mut both, &panel(src, &close, CROP_PX, true), (CROP_PX + 8) as i64, (CROP_PX + 8) as i64);
        }
        let _ = both.save(dir.join(format!("{a}.jpg")));
    }
}

/// The copper sheet's instructions. The same for every item: nothing here depends on an item's answer.
fn copper_sheet_text(ex: &Value) -> String {
    let share = |v: &Value| format!("{:.1}%", v.as_f64().unwrap_or(0.0));
    let by: Vec<String> = ex["byMaterial"].as_object().into_iter().flatten().map(|(k, v)| format!("{k} {v}")).collect();
    format!("Each claim says what the photo shows under the solder mask at the centre of the yellow ring. Every crop has four panels, made the same way for every item from the photos alone:\n\n\
- **Top left:** the raw photo, 6 mm across.\n\
- **Top right:** the same 6 mm, stretched: the two photos of the face averaged, lightly blurred, each colour scaled between its 3rd and 97th percentile over plain mask in the 12 mm round the point. The sheets show this panel only; judge from the crops.\n\
- **Bottom left:** 18 mm across, stretched the same way over that window, to follow gap lines and tracks from the surrounding copper to the ring.\n\
- **Bottom right:** 3 mm across, on the same scale as the top right, for a centre close to an edge.\n\n\
How to read them: under the mask, copper is lighter and greener than bare board beside it, so compare the centre with the two sides of the nearest gap line, not with a fixed colour. The light changes across the board, and a surface can read dark in a dim area. A dark line with a thin bright line beside it is a raised copper edge. A dome at the centre is a via, which is copper.\n\n\
Answer `ok` if the panels show what the claim says, `wrong` if they show the other, and `cannot_tell` if they do not settle it, for instance when the centre lies on an edge and could be either side. `cannot_tell` counts against the score: do not guess. Some claims are planted and false.\n\n\
Points the photo cannot show copper at were left out before this sample was drawn: {} of {} ({}), by material {}. The tiles where the copy and a plain threshold read of the photo disagree most held {} of the points before the photo's materials were taken out and {} after.",
        ex["leftOut"], ex["candidates"], share(&ex["share"]), by.join(", "), share(&ex["worstReadTiles"]["shareOfPointsBeforeTheRule"]), share(&ex["worstReadTiles"]["shareOfPointsAfterTheRule"]))
}

/// The connectivity sheet's instructions. A copper claim states a join the crop shows, never a net.
const CONNECTIVITY_SHEET: &str = "Judge each claim from its crop, left raw and right marked; the sheets show the marked side only.\n\n\
Every claim states one join, and no claim says which net anything is on: you are not asked. Each crop has a second row, closer in on the join: 3 mm across, or wide enough to hold the pad. Pink is what the claim is about and blue is the other end.\n\n\
- **Pin claims** (`pad boxed in pink (pin R1.2) ...`): the pink box is the pin's pad where the copy read its metal, and blue outlines one piece of copper the copy drew; it may run on past the crop.\n\
- **Copper claims** (`copper outlined in pink ...`): pink outlines the copper, as the copy drew it. Blue is a box round a pad, a ring round a via, or the outline of other copper with a cross where the two meet.\n\n\
Judge that join at that place and nothing else. `ok` if the photo shows copper running from the one mark to the other with no gap line between them. `wrong` if a gap separates them, or if what a mark names is not there: no pad in the box, no via in the ring, no copper under the outline.\n\n\
Answer `cannot_tell` if the crop does not settle it. `cannot_tell` counts against the score: do not guess. Some claims are planted and false.";

/// `sheet.md` (the claims, without saying which are controls) and contact sheets, 24 crops to a page.
fn write_sheets(flow: &Path, cat: &str, doc: &Value) {
    let dir = audit_dir(flow, cat);
    let list = arr(&doc["items"]);
    let how = if cat == "copper" { copper_sheet_text(&items::read_json(&dir.join("exclusions.json")).unwrap_or(json!({}))) } else if cat == "connectivity" { CONNECTIVITY_SHEET.to_string() } else { "Judge each claim from its crop, left raw and right marked; the sheets show the marked side only. Answer `ok` if the claim is right, `wrong` if it is not, `cannot_tell` if the crop does not settle it. `cannot_tell` counts against the score: do not guess. Some claims are planted and false.".to_string() };
    let mut md = format!("# Audit {cat}, sample {} (draw {})\n\n{how}\n\n| Item | Claim | Crop |\n|---|---|---|\n", st(doc, "seed"), doc["draw"]);
    for it in &list { md += &format!("| {} | {} | {} |\n", st(it, "a"), st(it, "claim"), st(it, "crop")); }
    let _ = std::fs::write(dir.join("sheet.md"), md);
    let cells: Vec<&Value> = list.iter().filter(|i| !st(i, "crop").is_empty()).collect();
    if let Ok(rd) = std::fs::read_dir(&dir) { for e in rd.flatten() { let n = e.file_name().to_string_lossy().to_string(); if n.starts_with("sheet-") && n.ends_with(".jpg") { let _ = std::fs::remove_file(e.path()); } } }
    for (k, page) in cells.chunks((SHEET_COLS * SHEET_ROWS) as usize).enumerate() {
        let mut sheet = RgbImage::new(SHEET_COLS * SHEET_CELL, SHEET_ROWS * SHEET_CELL);
        for (i, it) in page.iter().enumerate() {
            let Ok(full) = image::open(flow.join(st(it, "crop"))) else { continue };
            let full = full.to_rgb8();
            let marked = image::imageops::crop_imm(&full, CROP_PX + 8, 0, CROP_PX, CROP_PX).to_image();
            let mut cell = image::imageops::resize(&marked, SHEET_CELL - 2, SHEET_CELL - 2, image::imageops::FilterType::Triangle);
            let num: String = st(it, "a").trim_start_matches('a').to_string();
            for y in 0..22 { for x in 0..52 { cell.put_pixel(x, y, image::Rgb([0, 0, 0])); } }
            draw::label(&mut cell, (4.0, 3.0), &num, [255, 255, 255], 3);
            let (u, v) = ((i as u32 % SHEET_COLS) * SHEET_CELL, (i as u32 / SHEET_COLS) * SHEET_CELL);
            image::imageops::replace(&mut sheet, &cell, u as i64, v as i64);
        }
        let _ = sheet.save(dir.join(format!("sheet-{}.jpg", k + 1)));
    }
}

// ---- verdicts ----

/// Who wrote this run's items, from the ledger. An audit needs someone else.
fn producers(flow: &Path) -> HashSet<String> {
    const MAKES: [&str; 8] = ["init", "prep", "scale", "detect", "parts-", "blocks-", "copper-", "connect-"];
    std::fs::read_to_string(flow.join("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str::<Value>(l).ok())
        .filter(|e| { let ev = st(e, "ev"); MAKES.iter().any(|m| ev.starts_with(m)) })
        .filter_map(|e| e["aiThread"].as_str().map(str::to_string)).collect()
}

/// An outside record: not a photo the run already read, and stored by hash.
fn outside_file(flow: &Path, path: &str) -> Result<Value, String> {
    let p = items::resolve(flow, path).ok_or(format!("evidence file {path} is not there"))?;
    let full = std::fs::canonicalize(&p).map_err(|e| e.to_string())?;
    for d in ["photos", "board", "evidence", "audits", "detect"] {
        if let Ok(x) = std::fs::canonicalize(flow.join(d)) { if full.starts_with(x) { return Err(format!("{path} is this run's own {d}: outside evidence means an X-ray, an edge micrograph, a continuity log or a netlist")); } }
    }
    let b = std::fs::read(&full).map_err(|e| e.to_string())?;
    Ok(json!({"file": full.display().to_string(), "sha256": sha256_hex(&b)}))
}

fn verdicts_of(flow: &Path, cat: &str, draw: u64) -> HashMap<String, Value> {
    let mut m = HashMap::new();
    for l in std::fs::read_to_string(audit_dir(flow, cat).join("verdicts.jsonl")).unwrap_or_default().lines() {
        let Ok(v) = serde_json::from_str::<Value>(l) else { continue };
        if v["draw"].as_u64() == Some(draw) { m.insert(st(&v, "a").to_string(), v); }
    }
    m
}

/// One auditor's answer: the item on the sheet, ok, wrong or cannot_tell, and a note.
pub struct Answer { pub a: String, pub verdict: String, pub note: String }

/// Record verdicts on the current sample, each accepted or refused on its own. `sample` is the seed printed
/// on the sheet the auditor is looking at. The result lists the refusals.
pub fn record_verdicts(flow: &Path, cat: &str, sample: &str, answers: &[Answer], who: &str, evidence: Option<&str>, kind: Option<&str>) -> Result<Vec<String>, String> {
    check_category(cat)?;
    if who.trim().is_empty() { return Err("a verdict needs --who".into()); }
    if producers(flow).contains(who) { return Err(format!("{who} produced this run's files: an audit must come from another thread or a person")); }
    let dir = audit_dir(flow, cat);
    let doc = items::read_json(&dir.join("sample.json")).map_err(|_| format!("{cat}: no sample: `audit draw --category {cat}` first"))?;
    if st(&doc, "seed") != sample { return Err(format!("wrong sample: {sample} is not the current sample of {cat} ({})", st(&doc, "seed"))); }
    let list = arr(&doc["items"]);
    let c = Ctx::load(flow);
    let pop = population(&c, cat);
    let now: HashMap<(&str, &str), &str> = pop.iter().map(|p| ((p.stratum.as_str(), p.id.as_str()), p.hash.as_str())).collect();
    let ev = match evidence { Some(path) => Some(outside_file(flow, path)?), None => None };
    let given = verdicts_of(flow, cat, doc["draw"].as_u64().unwrap_or(0));
    // a sample counts only when complete: until every item has a verdict nothing is scored, and the auditor who gave a verdict may correct it
    let unscored = list.iter().any(|i| !given.contains_key(st(i, "a")));
    let mut done: HashSet<String> = given.keys().cloned().collect();
    let at = ledger::now();
    let (mut lines, mut refused) = (String::new(), vec![]);
    for ans in answers {
        let a = ans.a.as_str();
        let Some(it) = list.iter().find(|i| st(i, "a") == a) else { refused.push(format!("{a} is not in sample {sample}")); continue };
        if !matches!(ans.verdict.as_str(), "ok" | "wrong" | "cannot_tell") { refused.push(format!("{a}: verdict must be ok, wrong or cannot_tell, not {}", ans.verdict)); continue; }
        if !is_control(sample, it) && now.get(&(st(it, "stratum"), st(it, "id"))).copied() != it["hash"].as_str() { refused.push(format!("stale: {} changed since sample {sample} was drawn, so {a} cannot be judged: draw again", st(it, "id"))); continue; }
        if cat == "layers" && ans.verdict != "cannot_tell" && ev.is_none() { refused.push(format!("{a}: photos cannot settle {}: attach the X-ray, edge micrograph or cross-section with --evidence", st(it, "claim"))); continue; }
        let mut amends = Value::Null;
        if done.contains(a) && ev.is_none() {
            let old = given.get(a);
            let same_who = old.is_some_and(|o| st(o, "who") == who);
            if !same_who { refused.push(format!("{a} already has a verdict from {}: another auditor cannot overwrite it; only a verdict backed by a new --evidence file can replace it", old.map(|o| st(o, "who")).unwrap_or("someone else"))); continue; }
            if !unscored { refused.push(format!("{a} already has a verdict and the sample is complete, so it is scored: only a verdict backed by a new --evidence file can replace it now")); continue; }
            if ans.note.trim().is_empty() { refused.push(format!("{a}: to correct your own verdict, say why in the note")); continue; }
            // the first verdict stays in the file; this line replaces it and says what it replaced
            amends = old.map(|o| json!({"verdict": o["verdict"], "note": o["note"], "at": o["at"]})).unwrap_or(Value::Null);
        }
        lines += &format!("{}\n", json!({"draw": doc["draw"], "seed": sample, "a": a, "id": it["id"], "hash": it["hash"], "verdict": ans.verdict, "who": who, "at": at, "note": ans.note, "evidence": ev, "kind": kind, "amends": amends}));
        done.insert(a.to_string());
    }
    use std::io::Write;
    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("verdicts.jsonl")).map_err(|e| e.to_string())?;
    f.write_all(lines.as_bytes()).map_err(|e| e.to_string())?;
    Ok(refused)
}

/// One verdict, for callers that have just one: a refusal is an error.
pub fn record_verdict(flow: &Path, cat: &str, sample: &str, a: &str, verdict: &str, who: &str, note: &str, evidence: Option<&str>, kind: Option<&str>) -> Result<(), String> {
    let r = record_verdicts(flow, cat, sample, &[Answer { a: a.into(), verdict: verdict.into(), note: note.into() }], who, evidence, kind)?;
    match r.into_iter().next() { Some(e) => Err(e), None => Ok(()) }
}

// ---- reading an audit back, trusting nothing in it ----

#[derive(Default, Clone)]
pub struct AuditState { pub status: String, pub value: Option<f64>, pub detail: String, pub strata: Vec<Value>, pub held: bool, pub failed: Vec<String>, pub outside_pins: Option<f64>, pub seed: String, pub done: usize, pub total: usize }

fn audit_state(c: &Ctx, cat: &str, pop: &[PItem], target: f64) -> AuditState {
    let none = |status: &str, detail: String| AuditState { status: status.into(), detail, held: true, ..Default::default() };
    let dir = audit_dir(&c.flow, cat);
    let Ok(doc) = items::read_json(&dir.join("sample.json")) else { return none("none", "no sample drawn".into()) };
    let (seed, draw) = (st(&doc, "seed").to_string(), doc["draw"].as_u64().unwrap_or(0));
    // the sample must be the one the seed gives: hand-picked items do not count
    let Ok(pop_doc) = items::read_json(&dir.join("population.json")) else { return none("tampered", "population.json is missing".into()) };
    let drawn: Vec<(String, String, String)> = pop_doc.as_object().into_iter().flatten().flat_map(|(s, m)| m.as_object().into_iter().flatten().map(move |(id, hh)| (s.clone(), id.clone(), hh.as_str().unwrap_or("").to_string()))).collect();
    let mut lines: Vec<String> = drawn.iter().map(|(s, i, hh)| format!("{s}|{i}|{hh}")).collect();
    lines.sort();
    let drawn_hash = sha256_hex(lines.join("\n").as_bytes());
    if drawn_hash != st(&doc, "populationHash") { return none("tampered", "population.json does not match the sample's population hash".into()); }
    if derive_seed(cat, &drawn_hash, draw) != seed { return none("tampered", format!("seed {seed} is not the one this population and draw give")); }
    let list = arr(&doc["items"]);
    let reals: Vec<&Value> = list.iter().filter(|i| !is_control(&seed, i)).collect();
    if list.iter().filter(|i| is_control(&seed, i)).count() < controls_for(reals.len()).min(list.len() - reals.len()) { return none("tampered", "controls are missing".into()); }
    for (s, want) in doc["counts"].as_object().into_iter().flatten() {
        let ids: Vec<String> = drawn.iter().filter(|d| &d.0 == s).map(|d| d.1.clone()).collect();
        let mut expect: Vec<String> = order(&ids, &seed, s).into_iter().take(want.as_u64().unwrap_or(0) as usize).collect();
        let mut have: Vec<String> = reals.iter().filter(|i| st(i, "stratum") == s).map(|i| st(i, "id").to_string()).collect();
        expect.sort(); have.sort();
        if expect != have { return none("tampered", format!("the {s} items are not the ones seed {seed} draws")); }
    }
    let verdicts = verdicts_of(&c.flow, cat, draw);
    let who_made = producers(&c.flow);
    let usable = |v: &Value| -> bool {
        if who_made.contains(st(v, "who")) { return false; }
        match v["evidence"].is_object() {
            true => std::fs::read(st(&v["evidence"], "file")).map(|b| sha256_hex(&b) == st(&v["evidence"], "sha256")).unwrap_or(false),
            false => true,
        }
    };
    let (done, total) = (list.iter().filter(|i| verdicts.get(st(i, "a")).map(|v| usable(v)).unwrap_or(false)).count(), list.len());
    // a planted false claim called right voids the sample, whoever else answered
    let fooled: Vec<String> = list.iter().filter(|i| is_control(&seed, i)).filter_map(|i| verdicts.get(st(i, "a")).filter(|v| st(v, "verdict") == "ok").map(|v| format!("{} by {}", st(i, "a"), st(v, "who")))).collect();
    if !fooled.is_empty() { return AuditState { seed, done, total, ..none("void", format!("planted false claim called ok: {}; those verdicts are void, draw again with another auditor", fooled.join(", "))) }; }
    // A sample counts only when complete, with one exception: an extension that nobody has judged yet does not erase the
    // sample it extends. An extension runs along the same random order, so when the judged items of every stratum are exactly
    // the first ones of that order they are the smaller sample the same seed draws, and they are scored as that sample (with
    // its wider bound). The state stays "incomplete" and held, so the loop still asks for the rest. Without this a run that
    // stops with an extension open showed its audited categories at the unaudited cap (test board 11, 2026-11-11: connectivity
    // audited at 0.6 read 40).
    let mut partial = false;
    let reals: Vec<&Value> = if done < total {
        let has = |i: &&Value| verdicts.get(st(i, "a")).map(|v| usable(v)).unwrap_or(false);
        let mut prefix = reals.iter().any(|i| has(i));
        for (s, _) in doc["counts"].as_object().into_iter().flatten() {
            let ids: Vec<String> = drawn.iter().filter(|d| &d.0 == s).map(|d| d.1.clone()).collect();
            let got: std::collections::HashSet<&str> = reals.iter().filter(|i| st(i, "stratum") == s && has(i)).map(|i| st(i, "id")).collect();
            if got.is_empty() || !order(&ids, &seed, s).iter().take(got.len()).all(|id| got.contains(id.as_str())) { prefix = false; }
        }
        if !prefix { return AuditState { seed: seed.clone(), done, total, ..none("incomplete", format!("{done} of {total} verdicts in sample {seed}; a sample counts only when complete")) }; }
        partial = true;
        reals.into_iter().filter(|i| has(i)).collect()
    } else { reals };
    let now: HashMap<(&str, &str), &str> = pop.iter().map(|p| ((p.stratum.as_str(), p.id.as_str()), p.hash.as_str())).collect();
    let by = strata(pop);
    let mut out = AuditState { status: "scored".into(), seed: seed.clone(), done, total, ..Default::default() };
    let mut low = f64::MAX;
    let mut stale_any = 0usize;
    let mut pin_ok_outside = (0usize, 0usize, 0usize);
    // islands and pins with no join a crop can show were never put forward as claims: unproven, so they count as not correct
    let proof: BTreeMap<&str, (usize, usize)> = if cat == "connectivity" { let p = c.pin_proof(); [("island", c.island_proof()), ("pin", (p.0, p.1))].into() } else { BTreeMap::new() };
    for (s, cur) in &by {
        let drawn_hashes: HashMap<&str, &str> = drawn.iter().filter(|d| &d.0 == s).map(|d| (d.1.as_str(), d.2.as_str())).collect();
        let unchanged = cur.iter().filter(|p| drawn_hashes.get(p.id.as_str()) == Some(&p.hash.as_str())).count();
        let share = unchanged as f64 / cur.len().max(1) as f64;
        let (mut n, mut k, mut stale, mut outside) = (0usize, 0usize, 0usize, 0usize);
        for i in reals.iter().filter(|i| st(i, "stratum") == s) {
            let v = &verdicts[st(i, "a")];
            if now.get(&(s.as_str(), st(i, "id"))).copied() != v["hash"].as_str() { stale += 1; continue; }
            n += 1;
            let good = st(v, "verdict") == "ok" && (cat != "layers" || v["evidence"].is_object());
            if good { k += 1; if v["evidence"].is_object() { outside += 1; } } else { out.failed.push(format!("{} {} ({})", st(i, "a"), st(i, "id"), st(v, "verdict"))); }
        }
        if s == "pin" { pin_ok_outside = (outside, n, cur.len()); }
        let lb = wilson_lower(k, n, cur.len());
        let (with, all) = proof.get(s.as_str()).copied().unwrap_or((1, 1));
        let value = 100.0 * lb * share * with as f64 / all.max(1) as f64;
        let need = required_n(target, 0, cur.len());
        out.strata.push(json!({"stratum": s, "population": cur.len(), "audited": n, "ok": k, "staleVerdicts": stale, "unchangedShare": (share * 1000.0).round() / 1000.0, "wilson95Lower": (lb * 1000.0).round() / 1000.0, "value": floor1(value), "claimsValue": 100.0 * lb * share, "fullValue": value, "needForTarget": need}));
        if proof.contains_key(s.as_str()) { let row = out.strata.last_mut().unwrap(); row["unproven"] = json!(all - with); row[if s == "pin" { "pins" } else { "islands" }] = json!(all); }
        if n == 0 { stale_any += stale.max(1); low = low.min(0.0); continue; }
        if stale > 0 || share < 1.0 { stale_any += stale.max(1); }
        if n < need { out.held = true; }
        low = low.min(value);
    }
    if stale_any > 0 { out.held = true; }
    if out.strata.iter().all(|x| x["audited"].as_u64() == Some(0)) { return AuditState { seed, done, total, ..none("stale", format!("every verdict in sample {} is on an item that has changed", st(&doc, "seed"))) }; }
    if stale_any > 0 { out.status = "scored, partly stale".into(); }
    // and when not one island, or not one pin, has a join, there is no such stratum to hold the score down: say so, at nothing
    for (s, (_, all)) in proof.iter().filter(|p| p.1.0 == 0 && p.1.1 > 0) {
        out.strata.push(json!({"stratum": s, "population": 0, "audited": 0, "ok": 0, "staleVerdicts": 0, "unchangedShare": 1.0, "wilson95Lower": 0.0, "value": 0.0, "needForTarget": 0, "unproven": all, if *s == "pin" { "pins" } else { "islands" }: all}));
        low = 0.0;
    }
    // outside evidence on the pins judged says nothing of the pins that were never put forward
    let judged = proof.get("pin").map(|p| p.0 as f64 / p.1.max(1) as f64).unwrap_or(1.0);
    out.outside_pins = (pin_ok_outside.1 > 0).then(|| 100.0 * judged * wilson_lower(pin_ok_outside.0, pin_ok_outside.1, pin_ok_outside.2));
    out.value = Some(low);
    out.detail = out.strata.iter().map(|x| format!("{} {} of {} ok from {} (bound {:.1}%, unchanged {:.0}%)", x["stratum"].as_str().unwrap_or(""), x["ok"], x["audited"], x["population"], x["wilson95Lower"].as_f64().unwrap_or(0.0) * 100.0, x["unchangedShare"].as_f64().unwrap_or(0.0) * 100.0)).collect::<Vec<_>>().join("; ");
    if partial {
        out.status = "incomplete".into(); out.held = true;
        out.detail = format!("{done} of {total} verdicts in sample {seed}: the extension is not judged, so the score stands on the part that was: {}", out.detail);
    }
    out
}

// ---- the score ----

/// One limit on a category: a value, the inputs it came from, and what would raise it.
#[derive(Clone)]
pub struct Ceil { pub name: String, pub value: f64, pub inputs: String, pub raise: String }

#[derive(Clone)]
pub struct CatScore { pub category: String, pub score: f64, pub ceilings: Vec<Ceil>, pub audit: AuditState, pub limit: String, pub raise: String, pub outside: Option<String>, pub held: bool,
    /// The lowest of this category's ceilings that photos can earn, and of those that need the real board or outside documents (`twoscores`).
    pub photo: Option<twoscores::Part>, pub bench: Option<twoscores::Part> }

/// `photo` and `bench` are the two overall figures; `overall` is the lower of them, which is the lowest category, kept for reading.
pub struct Scores { pub target: f64, pub cats: Vec<CatScore>, pub overall: f64, pub photo: f64, pub bench: f64, pub full: bool }

impl Scores {
    /// The review loop's target applies to the photo score.
    pub fn met(&self) -> bool { self.photo >= self.target }
    /// The categories whose photo figure is under the target.
    pub fn below(&self) -> Vec<&CatScore> { self.cats.iter().filter(|c| c.photo.as_ref().is_some_and(|p| p.score < self.target)).collect() }
    pub fn json(&self) -> Value {
        let part = |p: &Option<twoscores::Part>| p.as_ref().map(|p| json!({"score": p.score, "limit": p.limit, "raise": p.raise}));
        let cats: serde_json::Map<String, Value> = self.cats.iter().map(|c| (c.category.clone(), json!({
            "score": c.score, "photo": part(&c.photo), "bench": part(&c.bench), "limit": c.limit, "raise": c.raise, "outside": c.outside, "heldByAudit": c.held,
            "audit": {"status": c.audit.status, "detail": c.audit.detail, "sample": c.audit.seed, "verdicts": c.audit.done, "items": c.audit.total, "strata": c.audit.strata, "failed": c.audit.failed},
            "ceilings": c.ceilings.iter().map(|x| json!({"name": x.name, "kind": twoscores::kind_of(&c.category, &x.name).word(), "value": floor1(x.value), "inputs": x.inputs})).collect::<Vec<_>>(),
        }))).collect();
        json!({"target": self.target, "photo": {"score": self.photo, "rule": twoscores::PHOTO_RULE, "targetApplies": true}, "bench": {"score": self.bench, "rule": twoscores::BENCH_RULE, "targetApplies": false},
            "overall": self.overall, "overallRule": "the lower of the photo and bench scores, which is the lowest category", "fullPass": self.full, "categories": cats})
    }
}

fn pct(a: f64, b: f64) -> f64 { if b > 0.0 { (100.0 * a / b).clamp(0.0, 100.0) } else { 0.0 } }

fn pale_vias(c: &Ctx) -> Option<(usize, usize, usize)> {
    let top = image::open(c.flow.join(board_file("top"))).ok()?.to_rgb8();
    let bottom = image::open(c.flow.join(board_file("bottom"))).ok()?.to_rgb8();
    let (r, mut pale, mut total, mut both) = ((PALE_RADIUS_MM * board::RES) as i64, 0, 0, 0);
    for v in arr(&c.copper["vias"]) {
        let Some(at) = xy(&v["pos_mm"]) else { continue };
        total += 1;
        if st(&v, "seen").contains("both") { both += 1; }
        let img = if via_face(&v) == "top" { &top } else { &bottom };
        let (cx, cy) = board::to_px(at.0, at.1);
        let (mut n, mut hit) = (0usize, 0usize);
        for dy in -r..=r { for dx in -r..=r {
            let (x, y) = (cx as i64 + dx, cy as i64 + dy);
            if x < 0 || y < 0 || x >= img.width() as i64 || y >= img.height() as i64 { continue; }
            n += 1;
            let p = img.get_pixel(x as u32, y as u32);
            if p[0].min(p[1]).min(p[2]) > PALE_LEVEL { hit += 1; }
        } }
        if n > 0 && hit as f64 / n as f64 > PALE_SHARE { pale += 1; }
    }
    Some((pale, total, both))
}

/// Leaves of a JSON document as (lowercase path, number).
fn leaves(v: &Value, path: &str, out: &mut Vec<(String, f64)>) {
    match v {
        Value::Number(n) => { if let Some(x) = n.as_f64() { out.push((path.to_lowercase(), x)); } }
        Value::Object(m) => for (k, x) in m { leaves(x, &format!("{path}/{k}"), out); },
        Value::Array(a) => for (i, x) in a.iter().enumerate() { leaves(x, &format!("{path}/{i}"), out); },
        _ => {}
    }
}

/// How the layers were scored 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` comes from another program,
/// so it is read by what its keys say and any new field is ignored. A figure named for the category counts:
/// copper also takes any `*.cu` layer, placement also takes `parts` or `footprints`; failing those, a top-level
/// `score`, `overall` or `fidelity`. The lowest figure found limits the category, on a 0 to 100 scale, or
/// read as a fraction if every figure found is at most 1. Missing, older than the files it scored, or silent
/// on the category: the input is missing and the category stays capped.
fn fidelity_ceiling(c: &Ctx, cat: &str) -> Ceil {
    let raise = "run the layer scoring so fidelity.json compares this category's layers with the photos".to_string();
    let capped = |why: String| Ceil { name: "layers against the photos".into(), value: NO_AUDIT_CAP, inputs: format!("input missing: {why}: capped at {NO_AUDIT_CAP}"), raise: raise.clone() };
    let path = c.flow.join("fidelity.json");
    let Ok(doc) = items::read_json(&path) else { return capped(if path.exists() { "fidelity.json is not valid JSON".into() } else { "fidelity.json is not there".to_string() }) };
    // stale against what it scored: its own hash record if it has one, otherwise the file times
    let mut record = false;
    for key in ["inputs", "files", "hashes"] { for (f, hh) in doc[key].as_object().into_iter().flatten() {
        let Some(want) = hh.as_str() else { continue };
        let Ok(b) = std::fs::read(c.flow.join(f)) else { continue };
        record = true;
        let same = if want.starts_with("geometry:") { crate::attrib::geometry_hash(&c.flow.join(f)) == want } else { sha256_hex(&b) == want };
        if !same { return capped(format!("fidelity.json predates the current {f}")); }
    } }
    if !record {
        let when = |f: &str| std::fs::metadata(c.flow.join(f)).and_then(|m| m.modified()).ok();
        for f in ["copper.json", "parts.json"] { if let (Some(a), Some(b)) = (when("fidelity.json"), when(f)) { if a < b { return capped(format!("fidelity.json is older than {f}")); } } }
    }
    let mut all = vec![];
    leaves(&doc, "", &mut all);
    const FIGURE: [&str; 8] = ["score", "agreement", "fidelity", "match", "value", "percent", "pct", "overall"];
    let names: &[&str] = match cat { "copper" => &["copper", ".cu"], "vias" => &["vias", "via"], _ => &["placement", "parts", "footprints"] };
    let figure = |p: &str| p.rsplit('/').next().map(|l| FIGURE.contains(&l)).unwrap_or(false);
    let named = |p: &str| p.split('/').any(|seg| names.iter().any(|n| if n.starts_with('.') { seg.ends_with(n) } else { seg == *n }));
    let mut found: Vec<(String, f64)> = all.iter().filter(|(p, _)| named(p) && (figure(p) || names.iter().any(|n| p.rsplit('/').next() == Some(*n)))).cloned().collect();
    if found.is_empty() { found = all.iter().filter(|(p, _)| p.matches('/').count() == 1 && figure(p)).cloned().collect(); }
    if found.is_empty() { return capped(format!("fidelity.json has no figure for {cat}")); }
    let scale = if found.iter().all(|(_, x)| *x <= 1.0) { 100.0 } else { 1.0 };
    let low = found.iter().fold(("", f64::MAX), |b, (p, x)| if *x < b.1 { (p.as_str(), *x) } else { b });
    Ceil { name: "layers against the photos".into(), value: (low.1 * scale).clamp(0.0, 100.0), inputs: format!("fidelity.json: lowest of {} figure(s) is {:.1} at {}", found.len(), low.1 * scale, low.0), raise: format!("make what the KiCad layers draw agree with the photos at {}", low.0) }
}

fn outside_for(cat: &str) -> Option<String> {
    match cat {
        "identity" => Some("a bill of materials, or markings read under a microscope for the unmarked parts, attached to the verdicts with --evidence".into()),
        "values" => Some("an LCR reading of each unmarked part off the board, or a bill of materials, attached to the verdicts with --evidence".into()),
        "vias" => Some("an X-ray or a backlit photo of the bare board shows through vias directly, attached with --evidence".into()),
        "copper" => Some("an X-ray, or a photo of the bare board with the mask stripped, where the photo is dark or a part hides the copper".into()),
        "connectivity" => Some("a continuity log per net on the real board, or a real netlist, attached to the pin verdicts with --evidence: photos cannot show what joins inside the board".into()),
        "layers" => Some("an edge micrograph, a cross-section or an X-ray that shows the copper layers, attached to the layer and plane verdicts with --evidence: photos never show an inner layer".into()),
        _ => None,
    }
}

fn measured_ceilings(c: &Ctx, cat: &str, audit: &AuditState) -> Vec<Ceil> {
    let n = c.parts.len() as f64;
    match cat {
        "identity" => {
            let k = c.parts.iter().filter(|p| known(p["fieldTags"]["identity"].as_str().unwrap_or(st(p, "tag")))).count();
            vec![Ceil { name: "identity tagged as seen".into(), value: pct(k as f64, n), inputs: format!("{k} of {n} parts have an identity tagged SEEN, MEASURED or DATASHEET"), raise: format!("read the {} parts whose identity is INFERRED or UNKNOWN from the crops or outside evidence", n as usize - k) }]
        }
        "values" => {
            let list: Vec<&Value> = c.parts.iter().filter(|p| !p["fieldTags"]["value"].is_null() || !st(p, "value").is_empty()).collect();
            // the same share as before, taken apart: a marked part's value can be read from a photo, an unmarked part's cannot
            let mut v = vec![];
            for (stratum, name) in [("marked", twoscores::VALUES_MARKED), ("unmarked", twoscores::VALUES_UNMARKED)] {
                let of: Vec<&&Value> = list.iter().filter(|p| twoscores::value_stratum(p) == stratum).collect();
                if of.is_empty() { continue; }
                let k = of.iter().filter(|p| known(p["fieldTags"]["value"].as_str().unwrap_or(st(p, "tag")))).count();
                let raise = if stratum == "marked" { format!("read the marking of the {} marked parts whose value is still INFERRED or UNKNOWN (`decode <code>`, or the maker's marking table)", of.len() - k) } else { format!("measure the {} unmarked parts on the bench (`bench plan` lists them), or attach a bill of materials: an unmarked part's value cannot be read from a photo", of.len() - k) };
                v.push(Ceil { name: name.into(), value: pct(k as f64, of.len() as f64), inputs: format!("{k} of {} {stratum} parts with a value have it tagged SEEN, MEASURED or DATASHEET", of.len()), raise });
            }
            if v.is_empty() { v.push(Ceil { name: twoscores::VALUES_MARKED.into(), value: 0.0, inputs: "0 of 0 parts carry a value".into(), raise: "state each part's value in parts.json with how it is known".into() }); }
            v
        }
        "blocks" => {
            let k: usize = c.blocks.iter().filter(|b| known(st(b, "tag"))).map(|b| b["parts"].as_array().map(|a| a.len()).unwrap_or(0)).sum();
            vec![Ceil { name: "parts in a block seen on the board".into(), value: pct(k as f64, n), inputs: format!("{k} of {n} parts are in a block tagged SEEN, MEASURED or DATASHEET"), raise: "settle the blocks tagged INFERRED from silkscreen and part identities".into() }]
        }
        "placement" => {
            let k = c.parts.iter().filter(|p| { let m = st(&p["snap"], "method"); m.contains("pads") || m.contains("body centre") || m.contains("hole centre") }).count();
            vec![Ceil { name: "parts located by a program measurement".into(), value: pct(k as f64, n), inputs: format!("{k} of {n} parts have snap.method by pads, body centre or hole centre"), raise: format!("make `parts snap` measure the {} parts placed by a reading instead", n as usize - k) }]
        }
        "copper" => {
            let mut low = 100.0f64;
            let mut text = vec![];
            for (face, layer) in [("top", "F.Cu"), ("bottom", "B.Cu")] {
                let read_mm2: f64 = arr(&read(&c.flow, &format!("detect/copper-islands-{face}.json"))["regions"]).iter().map(|r| r["areaMm2"].as_f64().unwrap_or(0.0)).sum();
                let zones: f64 = arr(&c.copper["zones"]).iter().filter(|z| st(z, "layer") == layer).map(|z| z["areaMm2"].as_f64().unwrap_or(0.0)).sum();
                let tracks: f64 = arr(&c.copper["traces"]).iter().filter(|z| st(z, "layer") == layer).map(|t| path_len(&points(&t["path_mm"])) * t["width_mm"].as_f64().unwrap_or(0.2)).sum();
                if read_mm2 <= 0.0 { low = low.min(NO_AUDIT_CAP); text.push(format!("{face}: no islands read")); continue; }
                low = low.min(pct(zones + tracks, read_mm2));
                text.push(format!("{face}: {:.0} mm2 drawn of {read_mm2:.0} mm2 read as islands", zones + tracks));
            }
            vec![Ceil { name: "copper read that reached the copy".into(), value: low, inputs: text.join("; "), raise: "draw the islands the tracer read and dropped: the darker face and the short fragments".into() }]
        }
        "vias" => match pale_vias(c) {
            Some((pale, total, both)) => vec![Ceil { name: "vias not on pale pixels".into(), value: pct((total - pale) as f64, total as f64), inputs: format!("{pale} of {total} kept vias sit on a patch more than a quarter pale (min(R,G,B) > {PALE_LEVEL} in the square reaching {PALE_RADIUS_MM} mm around it, a rule from these photos and an upper bound on false vias); {both} seen on both faces"), raise: format!("make the via filter reject the {pale} vias on silkscreen") }],
            None => vec![Ceil { name: "vias not on pale pixels".into(), value: NO_AUDIT_CAP, inputs: "board images missing: the pale test could not run".into(), raise: "run `scale` so the board images exist".into() }],
        },
        "connectivity" => {
            let (zones, traces) = (arr(&c.copper["zones"]), arr(&c.copper["traces"]));
            let area = |only_named: bool| -> f64 { zones.iter().filter(|z| !only_named || !net_unnamed(z)).map(|z| z["areaMm2"].as_f64().unwrap_or(0.0)).sum() };
            let len = |only_named: bool| -> f64 { traces.iter().filter(|t| !only_named || !net_unnamed(t)).map(|t| path_len(&points(&t["path_mm"]))).sum() };
            let rep = &c.measured["report"];
            let clashes = rep["pinClashes"].as_u64().or_else(|| c.measured["pinClashes"].as_array().map(|a| a.len() as u64)).unwrap_or(0) as f64;
            let joined = rep["pinsJoined"].as_u64().unwrap_or(0) as f64;
            let (pins, nets) = (c.pins() as f64, c.nets.len() as f64);
            let needed = (pins - nets).max(0.0);
            let cand = c.proposed();
            let routed = cand.as_ref().and_then(|k| k["connections"].as_f64());
            // never routed: the connections no photo shows are the ones nets.json leaves between its pieces (`unrouted`)
            // the router also counts copper islands that reach no pin, so its figure can pass what the pins need: no more than all of them can be unseen
            let over = routed.filter(|r| *r > needed).map(|r| format!(" (the router counted {r:.0}, more than the pins need: it also joins islands that reach no pin)")).unwrap_or_default();
            let unseen = routed.unwrap_or(unrouted::count(&c.nets) as f64).min(needed);
            let by_bench = (unrouted::measured_now(&c.flow, &c.nets).len() as f64).min(unseen);
            let seen = pct(needed - unseen + by_bench, needed);
            let kicad_open = cand.as_ref().and_then(|k| k["kicadOpen"].as_f64());
            let outside = audit.outside_pins.unwrap_or(0.0);
            let mut v = vec![
                Ceil { name: twoscores::NET_AREA.into(), value: pct(area(true), area(false)), inputs: format!("{:.0} of {:.0} mm2 of pour has a net", area(true), area(false)), raise: "attribute the pours of unknown net: copper that reaches no pin on the outer faces gets its net from a bench probe (`bench plan`)".into() },
                Ceil { name: twoscores::CONNECTIONS.into(), value: seen.max(outside), inputs: format!("{:.0} of {needed:.0} connections ({pins:.0} pins, {nets:.0} nets) are joined by seen copper; {unseen:.0} {}, not seen; {by_bench:.0} of those found joined by a recorded bench probe; outside-evidence bound {outside:.1}", needed - unseen, if routed.is_some() { format!("routed by the program{over}") } else { "left unrouted as ratsnest (unrouted.json)".to_string() }), raise: "only the real board settles a connection the photos do not show: `bench plan` writes the continuity probes, `bench ingest` records the answers".into() },
            ];
            if len(false) > 0.0 { v.push(Ceil { name: twoscores::NET_LENGTH.into(), value: pct(len(true), len(false)), inputs: format!("{:.0} of {:.0} mm of track has a net", len(true), len(false)), raise: "attribute the tracks of unknown net".into() }); }
            if joined > 0.0 { v.push(Ceil { name: "pins that agree with their copper".into(), value: pct(joined - clashes, joined), inputs: format!("{clashes:.0} of {joined:.0} joined pins disagree with the copper they touch; via net clashes: {}", rep["viaClashes"].as_u64().or_else(|| c.measured["viaClashes"].as_array().map(|a| a.len() as u64)).unwrap_or(0)), raise: "settle each pin clash: a wrong net or a misread island".into() }); }
            if let Some(o) = kicad_open { v.push(Ceil { name: twoscores::KICAD_CLOSED.into(), value: pct(needed - o, needed), inputs: format!("{o:.0} of {needed:.0} missing per KiCad at the proposed stackup"), raise: "close what the copy leaves open".into() }); }
            else if cand.as_ref().map(|k| k["drcError"].is_string()).unwrap_or(false) { v.push(Ceil { name: twoscores::KICAD_CLOSED.into(), value: 0.0, inputs: "KiCad's DRC did not run at the proposed stackup: unknown, which counts as none closed".into(), raise: "run `connect route` again when service-kicad answers".into() }); }
            v
        }
        "layers" => {
            let settled = audit.status.starts_with("scored");
            let mut v = vec![];
            if !settled {
                v.push(Ceil { name: "photos alone".into(), value: PHOTO_ONLY_CAP, inputs: format!("photos never show an inner layer: cap {PHOTO_ONLY_CAP}"), raise: "only outside evidence raises this: an edge micrograph, a cross-section or an X-ray".into() });
                let k = c.proposed();
                let (open, conns) = (k.as_ref().and_then(|x| x["open"].as_f64()), k.as_ref().and_then(|x| x["connections"].as_f64()));
                if let (Some(o), Some(t)) = (open, conns) { v.push(Ceil { name: "the proposed stackup closes".into(), value: if t > 0.0 { pct(t - o, t) } else { 100.0 }, inputs: format!("{o:.0} of {t:.0} connections stay open at {} layers", c.layers["proposed"]), raise: "a stackup that closes every connection".into() }); }
                // stated without routing (`layers state`): an unknown count has earned nothing; a count from outside evidence sits under the cap above until that evidence is judged
                else if c.layers["method"] == json!("stated") {
                    if c.layers["proposed"].as_u64().is_none() { v.push(Ceil { name: "a layer count is stated".into(), value: 0.0, inputs: format!("the layer count is UNKNOWN; from the photos: {}", st(&c.layers["minimum"], "why")), raise: "T-layers: count the copper layers on the board edge under a microscope or X-ray the board, then `layers state --count <n> --evidence <file> --kind edge-micrograph|cross-section|xray|ct`".into() }); }
                }
                else { v.push(Ceil { name: "the proposed stackup closes".into(), value: 0.0, inputs: "no layer statement and no routed candidate at the proposed count".into(), raise: "run `layers state` (or the optional `connect route`)".into() }); }
            }
            v
        }
        _ => vec![],
    }
}

fn score_one(c: &Ctx, cat: &str, target: f64) -> CatScore {
    let pop = population(c, cat);
    let mut audit = audit_state(c, cat, &pop, target);
    if cat == "copper" { audit.detail = format!("{}; {}", audit.detail, c.census().line()); }
    // a sample that cannot be drawn on this board is not an audit to wait for: the cap stands, and the reason is said
    let no_draw = if audit.value.is_none() { undrawable(&c.flow, cat, &pop) } else { None };
    if let Some(why) = &no_draw { audit.detail = format!("{}; no sample can be drawn on these items: {why}", audit.detail); }
    // what no photo audit can reach: said in every state of the audit, and no cap may sit above it
    let mut reach = 1.0f64;
    if cat == "connectivity" {
        let ((with, all), (pw, pa, cited)) = (c.island_proof(), c.pin_proof());
        if with < all { audit.detail = format!("{}; {} of {all} islands show no join to a pad, a via or other copper, so they are unproven and count as not correct (the island figure is multiplied by {:.3})", audit.detail, all - with, with as f64 / all as f64); }
        if pw < pa { audit.detail = format!("{}; {} of {pa} pins with a net show no join of their pad to copper the copy drew, so they are unproven and count as not correct (the pin figure is multiplied by {:.3}); {cited} of those {} are on a net that cites a source outside the photos, which no photo confirms", audit.detail, pa - pw, pw as f64 / pa as f64, pa - pw); }
        for (w, a) in [(with, all), (pw, pa)] { if a > 0 { reach = reach.min(w as f64 / a as f64); } }
    }
    let cap = (if cat == "layers" { PHOTO_ONLY_CAP } else { NO_AUDIT_CAP }).min(100.0 * reach);
    let audit_raise = match audit.status.as_str() {
        _ if no_draw.is_some() => "no sample can be drawn on this board as it stands, so no audit can raise this: it stays at the cap. Sharper or more evenly lit photos, or a change to the copper, allow a new draw".to_string(),
        "none" if cat == "layers" => "attach outside evidence with `audit verdict --evidence`".to_string(),
        "none" => format!("run the audit: `audit draw --category {cat}`, then an independent verdict on every crop"),
        "incomplete" => format!("finish sample {}: {} of {} verdicts; audits/{cat}/sheet.md", audit.seed, audit.done, audit.total),
        "void" => format!("draw again with another auditor: `audit draw --category {cat}`"),
        "tampered" => "the sample was changed by hand: delete nothing, draw again after restoring it".to_string(),
        "stale" => format!("the items changed: `audit draw --category {cat}` for a new sample"),
        _ if audit.held => format!("extend or redraw the sample: `audit draw --category {cat} --n <larger>`; each stratum needs enough items that even all ok can reach the target"),
        _ if audit.failed.is_empty() => "a larger sample: no audited item failed".to_string(),
        _ => format!("fix the method behind the failed items ({}), then redraw", audit.failed.iter().take(6).cloned().collect::<Vec<_>>().join(", ")),
    };
    let value = audit.value.unwrap_or(cap);
    let audit_inputs = match &audit.value {
        Some(_) => format!("{}: {}", audit.status, audit.detail),
        None => format!("no independent evidence ({}: {}): capped at {}", audit.status, audit.detail, floor1(cap)),
    };
    let row = |s: &str| audit.strata.iter().find(|r| st(r, "stratum") == s);
    let mut ceilings = match cat {
        // one ceiling per stratum: marked parts are judged from their crops, unmarked parts only with outside evidence
        "values" => {
            let mut v = vec![];
            for (stratum, name) in [("marked", twoscores::AUDIT_MARKED), ("unmarked", twoscores::AUDIT_UNMARKED)] {
                if !pop.iter().any(|p| p.stratum == stratum) { continue; }
                let (val, inputs) = match (&audit.value, row(stratum)) {
                    (Some(_), Some(r)) => (r["fullValue"].as_f64().unwrap_or(0.0), format!("{}: {stratum} {} of {} ok from {} (bound {:.1}%, unchanged {:.0}%)", audit.status, r["ok"], r["audited"], r["population"], r["wilson95Lower"].as_f64().unwrap_or(0.0) * 100.0, r["unchangedShare"].as_f64().unwrap_or(0.0) * 100.0)),
                    (Some(_), None) => (0.0, format!("{}: no {stratum} part was in the sample", audit.status)),
                    (None, _) => (cap, audit_inputs.clone()),
                };
                v.push(Ceil { name: name.into(), value: val, inputs, raise: if stratum == "unmarked" && audit.value.is_some() { "an unmarked part's value is judged only with outside evidence: an LCR reading or a bill of materials, attached to the verdict with --evidence, or measured on the bench (`bench plan`)".into() } else { audit_raise.clone() } });
            }
            if v.is_empty() { v.push(Ceil { name: twoscores::AUDIT.into(), value, inputs: audit_inputs, raise: audit_raise }); }
            v
        }
        // the joins a crop can show are judged as claims; the pins that show no join are a matter for the bench. The lower of the two is the figure this was before.
        "connectivity" => {
            let ((iw, ia), (pw, pa, _)) = (c.island_proof(), c.pin_proof());
            let share = |w: usize, a: usize| if a > 0 { w as f64 / a as f64 } else { 1.0 };
            let seen_joined: HashSet<&str> = pin_joins(c).into_keys().collect();
            let counted: HashSet<&str> = net_pins(c).into_iter().map(|p| p.0).collect();
            let probed: HashSet<String> = unrouted::probed_pins(&c.flow, &c.nets).into_iter().filter(|p| counted.contains(p.as_str()) && !seen_joined.contains(p.as_str())).collect();
            let by_bench = if pa > 0 { 100.0 * probed.len() as f64 / pa as f64 } else { 0.0 };
            let (photo, bench) = match audit.value {
                Some(_) => {
                    let island = if ia > 0 { row("island").and_then(|r| r["fullValue"].as_f64()).unwrap_or(0.0) } else { 100.0 };
                    let claims = row("pin").and_then(|r| r["claimsValue"].as_f64()).unwrap_or(0.0);
                    (if pa > 0 { island.min(claims) } else { island }, if pa > 0 { claims * share(pw, pa) + by_bench } else { 100.0 })
                }
                None => (NO_AUDIT_CAP.min(100.0 * share(iw, ia)).min(if pa > 0 && pw == 0 { 0.0 } else { 100.0 }), NO_AUDIT_CAP.min(100.0 * share(pw, pa)) + by_bench),
            };
            vec![Ceil { name: twoscores::AUDIT_JOINS.into(), value: photo, inputs: audit_inputs, raise: audit_raise },
                 Ceil { name: twoscores::PINS_PROVEN.into(), value: bench.min(100.0), inputs: format!("{pw} of {pa} pins with a net show a join a crop can judge{}; {} more were found joined by a recorded bench probe; the other {} are unproven and count as not correct", if audit.value.is_some() { format!(" (those claims: {:.1})", row("pin").and_then(|r| r["claimsValue"].as_f64()).unwrap_or(0.0)) } else { format!(" (not audited: capped at {NO_AUDIT_CAP})") }, probed.len(), pa.saturating_sub(pw + probed.len())), raise: "probe the pins no photo shows joined: `bench plan` writes a continuity probe for each, `bench ingest` records the answers".into() }]
        }
        _ => vec![Ceil { name: twoscores::AUDIT.into(), value, inputs: audit_inputs, raise: audit_raise }],
    };
    ceilings.extend(measured_ceilings(c, cat, &audit));
    // an ERC error the copper as read causes counts against connectivity
    if cat == "connectivity" { if let Some((value, inputs)) = crate::ercfind::ceiling(&c.flow, &c.nets) { ceilings.push(Ceil { name: crate::ercfind::CEILING.into(), value, inputs, raise: "each finding has a continuity test (`tests generate`, `bench plan`); where the board shows no join, the copper reader joined two conductors and only a fix to it, or a better photo, clears the finding".into() }); } }
    // a face not read counts as failed in both scores that rest on copper
    if matches!(cat, "copper" | "connectivity") { if let Some((value, inputs)) = crate::notread::ceiling(&c.flow) { ceilings.push(Ceil { name: crate::notread::CEILING.into(), value, inputs, raise: crate::notread::RAISE.into() }); } }
    if matches!(cat, "copper" | "vias" | "placement") { ceilings.push(fidelity_ceiling(c, cat)); }
    let bind = ceilings.iter().enumerate().fold(0, |b, (i, x)| if x.value < ceilings[b].value { i } else { b });
    let score = floor1(ceilings[bind].value);
    let (photo, bench) = (twoscores::part(cat, &ceilings, twoscores::Kind::Photo), twoscores::part(cat, &ceilings, twoscores::Kind::Bench));
    // the loop waits for an audit only where the photo figure is the one an audit holds down
    // and a category with nothing to audit (a board with no vias) has no audit to wait for: it would hold the loop for ever
    let held = audit.held && !pop.is_empty() && no_draw.is_none() && photo.as_ref().is_some_and(|p| p.score < target && p.by_audit);
    CatScore { category: cat.into(), score, limit: format!("{}: {}", ceilings[bind].name, ceilings[bind].inputs), raise: ceilings[bind].raise.clone(), ceilings, audit, outside: outside_for(cat), held, photo, bench }
}

/// Every score, computed now from the run's files. This is the only place a score comes from.
pub fn score_all(flow: &Path, target: f64) -> Scores {
    let c = Ctx::load(flow);
    let cats: Vec<CatScore> = CATEGORIES.iter().map(|k| score_one(&c, k, target)).collect();
    let overall = cats.iter().map(|x| x.score).fold(100.0, f64::min);
    let full = !cats.iter().any(|x| x.held);
    let (photo, bench) = (twoscores::total(&cats, twoscores::Kind::Photo), twoscores::total(&cats, twoscores::Kind::Bench));
    Scores { target, cats, overall, photo, bench, full }
}

pub fn target_of(state: &Value) -> f64 { state["confidenceTarget"].as_f64().unwrap_or(DEFAULT_TARGET) }

/// Files a review depends on beyond the artifacts: editing one undoes the review.
pub fn audit_files(flow: &Path) -> Vec<String> {
    let mut v = vec![];
    for k in CATEGORIES { for f in ["sample.json", "population.json", "verdicts.jsonl", "undrawable.json"] {
        let rel = format!("audits/{k}/{f}");
        if flow.join(&rel).is_file() { v.push(rel); }
    } }
    if flow.join("fidelity.json").is_file() { v.push("fidelity.json".into()); }
    v
}

// ---- the loop ----

/// What a pass concludes. The history is read only to see whether anything rose: a hand-raised old score
/// can only make a later pass look like no rise, never make a category meet the target.
pub fn verdict_for(prev: &[Value], s: &Scores, max_passes: usize) -> &'static str {
    if s.met() { return "TARGET MET"; }
    let measured: Vec<&Value> = prev.iter().filter(|p| p["scores"].is_object()).collect();
    let at_limit = measured.len() + 1 >= max_passes;
    if !s.full { return if at_limit { "STOPPED" } else { "CONTINUE" }; }
    if measured.is_empty() { return "CONTINUE"; }
    // the loop works on the photo score: a pass recorded before the two scores has no photo figure, so the first pass after it counts as a rise
    let best = |c: &str| measured.iter().filter_map(|p| p["scores"]["categories"][c]["photo"]["score"].as_f64()).fold(f64::MIN, f64::max);
    let raised = s.cats.iter().any(|c| c.photo.as_ref().is_some_and(|p| p.score >= best(&c.category) + MIN_RAISE));
    if !raised { return "STALLED"; }
    if at_limit { "STOPPED" } else { "CONTINUE" }
}

/// The line report.md must carry word for word.
pub fn overall_line(s: &Scores, verdict: &str) -> String { twoscores::line(s, verdict) }

pub fn table(s: &Scores) -> String { twoscores::tables(s) }

pub fn inputs_text(s: &Scores) -> String {
    let mut t = String::new();
    for c in &s.cats {
        t += &format!("{}: photo {}, bench {}\n", c.category, twoscores::show(&c.photo), twoscores::show(&c.bench));
        for x in &c.ceilings { t += &format!("  {:>5.1}  [{}] {}: {}\n", floor1(x.value), twoscores::kind_of(&c.category, &x.name).word(), x.name, x.inputs); }
        if let Some(o) = &c.outside { if c.score < s.target { t += &format!("         outside evidence: {o}\n"); } }
    }
    t
}

/// The categories under the target, each with what limits it and the one thing most likely to raise it.
pub fn stall_lines(s: &Scores) -> Vec<String> {
    s.below().iter().filter_map(|c| c.photo.as_ref().map(|p| format!("{} photo figure {:.1} of {}: limited by {}. Most likely to raise it: {}.", c.category, p.score, s.target, p.limit, p.raise))).collect()
}

/// The bench score beside it: each category's bench figure under the target, with what would raise it. Nothing here holds the loop.
pub fn bench_lines(s: &Scores) -> Vec<String> {
    s.cats.iter().filter_map(|c| c.bench.as_ref().filter(|b| b.score < s.target).map(|b| {
        let out = c.outside.as_ref().map(|o| format!(" Outside evidence that would raise it: {o}.")).unwrap_or_default();
        format!("{} bench figure {:.1}: limited by {}. What would raise it: {}.{out}", c.category, b.score, b.limit, b.raise)
    })).collect()
}

/// Whether a stated verdict stands, from the files as they are now. Nothing recorded is believed.
pub fn gate(flow: &Path, review: &Value, hashes_now: &BTreeMap<String, String>) -> Result<(Scores, String), Vec<String>> {
    let passes = arr(&review["passes"]);
    let Some(idx) = passes.iter().rposition(|p| p["scores"].is_object()) else {
        return Err(vec!["no measured review pass: levels typed into confidence.json no longer count. Run `review pass`".into()]);
    };
    let last = &passes[idx];
    let target = last["target"].as_f64().unwrap_or(DEFAULT_TARGET);
    let s = score_all(flow, target);
    let mut errs = vec![];
    for c in &s.cats {
        let said = last["scores"]["categories"][c.category.as_str()]["score"].as_f64();
        if said.map(|x| (x - c.score).abs() > 0.05).unwrap_or(true) {
            errs.push(format!("review.json says {} is {}, the files give {:.1}: scores are computed, never recorded by hand. Run `review pass`", c.category, said.map(|x| x.to_string()).unwrap_or("missing".into()), c.score));
        }
    }
    let then: BTreeMap<String, String> = serde_json::from_value(last["hashes"].clone()).unwrap_or_default();
    let changed: Vec<&String> = hashes_now.keys().filter(|k| then.get(*k) != hashes_now.get(*k)).collect();
    if !changed.is_empty() { errs.push(format!("changed since the last pass: {}; run `review pass`", changed.iter().map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }
    if !errs.is_empty() { return Err(errs); }
    let max = last["maxPasses"].as_u64().unwrap_or(DEFAULT_MAX_PASSES as u64) as usize;
    let verdict = verdict_for(&passes[..idx], &s, max);
    if st(last, "verdict") != verdict { errs.push(format!("review.json says {}, the files give {verdict}. Run `review pass`", st(last, "verdict"))); }
    match verdict {
        "TARGET MET" => {}
        "STALLED" => {}
        "STOPPED" if s.full => {}
        // a declared stop: the pass that stopped carries its reason in words (`review pass --max-passes <n> --note "<why>"`),
        // so a run whose audits cannot be finished (a sample voided again and again, no auditor left) still ends, and says so
        "STOPPED" if !st(last, "note").trim().is_empty() => {}
        _ => {
            errs.push(format!("{} (photo score {:.1}, target {}): keep looping, `review pass` says what to do next", if verdict == "STOPPED" { "the pass limit was reached with audits unfinished".to_string() } else { "not finished".to_string() }, s.photo, s.target));
            errs.extend(stall_lines(&s));
        }
    }
    if !errs.is_empty() { return Err(errs); }
    Ok((s, verdict.to_string()))
}

/// A line of report.md that claims more confidence than the run earned.
pub fn false_claim(md: &str, s: &Scores) -> Option<String> {
    if s.photo >= s.target && s.bench >= s.target { return None; }
    for line in md.lines() {
        let low = line.to_lowercase();
        if !low.contains("confiden") || low.contains("target") || low.contains("goal") { continue; }
        let b = low.as_bytes();
        let mut i = 0;
        while i < b.len() {
            if b[i].is_ascii_digit() {
                let j = (i..b.len()).find(|&k| !(b[k].is_ascii_digit() || b[k] == b'.')).unwrap_or(b.len());
                let rest = low[j..].trim_start();
                if let Ok(v) = low[i..j].parse::<f64>() { if v > s.photo.max(s.bench) + 0.05 && v <= 100.0 && (rest.starts_with('%') || rest.starts_with("percent")) { return Some(line.to_string()); } }
                i = j;
            } else { i += 1; }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dir(name: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("confidence-test-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(d.join("board")).unwrap();
        d
    }

    fn write(d: &Path, f: &str, v: &Value) {
        if let Some(p) = d.join(f).parent() { std::fs::create_dir_all(p).unwrap(); }
        std::fs::write(d.join(f), v.to_string()).unwrap();
    }

    /// A small board on which every category can honestly reach the target.
    fn fixture(name: &str) -> PathBuf {
        let d = dir(name);
        let grey = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));
        grey.save(d.join("board/top.jpg")).unwrap();
        grey.save(d.join("board/bottom.jpg")).unwrap();
        let parts: Vec<Value> = (0..200).map(|i| json!({"ref": format!("{}{i}", if i % 2 == 0 { "R" } else { "C" }), "tag": "MEASURED", "side": "top", "package": if i % 2 == 0 { "R0603" } else { "C0805" }, "function": "x", "value": format!("{}k", i % 7), "marking": "1", "pos_mm": [100.0 + (i % 20) as f64 * 1.5, 100.0 + (i / 20) as f64 * 1.5], "rot": 0, "snap": {"method": "pads"}, "fieldTags": {"identity": "SEEN", "value": "SEEN"}})).collect();
        let refs: Vec<String> = parts.iter().map(|p| st(p, "ref").to_string()).collect();
        write(&d, "parts.json", &json!({"parts": parts}));
        write(&d, "blocks.json", &json!({"blocks": [{"id": "a", "name": "A", "function": "does a", "tag": "SEEN", "parts": refs[..100]}, {"id": "b", "name": "B", "function": "does b", "tag": "SEEN", "parts": refs[100..]}]}));
        let vias: Vec<Value> = (0..300).map(|i| json!({"id": format!("v{i}"), "pos_mm": [120.0 + (i % 15) as f64, 120.0 + (i / 15) as f64], "kind": "seen", "seen": "bottom", "tag": "MEASURED"})).collect();
        write(&d, "copper.json", &json!({"outline": {"points_mm": [[100, 100], [140, 100], [140, 140], [100, 140]]},
            "zones": [{"id": "z1", "layer": "F.Cu", "net": "GND", "areaMm2": 400.0, "polygon_mm": [[100, 120], [140, 120], [140, 140], [100, 140]]}, {"id": "z2", "layer": "B.Cu", "net": "GND", "areaMm2": 400.0, "polygon_mm": [[100, 100], [140, 100], [140, 120], [100, 120]]}],
            "traces": [], "vias": vias}));
        write(&d, "detect/copper-islands-top.json", &json!({"regions": [{"areaMm2": 400.0}]}));
        write(&d, "detect/copper-islands-bottom.json", &json!({"regions": [{"areaMm2": 400.0}]}));
        let domes: Vec<Value> = (0..200).map(|i| json!({"pos_mm": [101.0 + (i % 18) as f64, 131.0 + (i % 9) as f64], "side": "bottom"})).collect();
        write(&d, "detect/domes.json", &json!({"domes": domes}));
        let nets = vec![json!({"name": "A", "tag": "SEEN", "pins": refs[..100].iter().map(|r| format!("{r}.1")).collect::<Vec<_>>()}), json!({"name": "B", "tag": "SEEN", "pins": refs[100..].iter().map(|r| format!("{r}.1")).collect::<Vec<_>>()})];
        write(&d, "nets.json", &json!({"nets": nets}));
        write(&d, "detect/copper-measured.json", &json!({"report": {"pinsJoined": 200, "pinClashes": 0}}));
        // what `connect attribute` would hold: each pour is one piece, with a via sitting in it
        write(&d, "detect/attribution.json", &json!({"pieces": [{"piece": 0, "side": "top", "conductor": 0, "node": 0, "items": ["z1"]}, {"piece": 1, "side": "bottom", "conductor": 1, "node": 1, "items": ["z2"]}],
            "vias": {"v20": {"valid": true, "topConductor": 0, "bottomConductor": null}, "v5": {"valid": true, "topConductor": 0, "bottomConductor": 1}},
            "padStates": refs.iter().enumerate().map(|(i, r)| json!({"pin": format!("{r}.1"), "side": "top", "x": 101.0 + (i % 20) as f64 * 0.9, "y": 121.0 + (i / 20) as f64 * 0.9})).collect::<Vec<_>>()}));
        // and each pin's pad is read on the top pour, with nothing between them
        write(&d, "detect/connect-graph.json", &json!({"contacts": refs.iter().map(|r| json!({"pin": format!("{r}.1"), "node": 0, "tier": 1, "kind": "pour", "side": "top"})).collect::<Vec<_>>()}));
        write(&d, "detect/attrib/measure.json", &json!({"pads": refs.iter().enumerate().map(|(i, r)| json!({"part": r, "num": "1", "core": {"x": 101.0 + (i % 20) as f64 * 0.9, "y": 121.0 + (i / 20) as f64 * 0.9, "w": 0.6, "h": 0.6, "rot": 0.0}})).collect::<Vec<_>>()}));
        write(&d, "layers.json", &json!({"proposed": 2, "candidates": [{"layers": 2, "open": 0, "connections": 0, "kicadOpen": 0}], "planes": []}));
        write(&d, "fidelity.json", &json!({"categories": {"copper": {"score": 97.0}, "vias": {"score": 97.0}, "placement": {"score": 97.0}}}));
        d
    }

    /// An honest auditor: every real claim right, every planted claim wrong.
    fn audit_honestly(d: &Path, cat: &str, n: Option<usize>) {
        let doc = draw_sample(d, cat, n, "tester", DEFAULT_TARGET).unwrap();
        answer(d, cat, &doc, |_, _| "ok");
    }

    fn answer(d: &Path, cat: &str, doc: &Value, f: impl Fn(&Value, bool) -> &'static str) {
        let seed = st(doc, "seed").to_string();
        let done = verdicts_of(d, cat, doc["draw"].as_u64().unwrap());
        let list: Vec<Answer> = arr(&doc["items"]).iter().filter(|it| !done.contains_key(st(it, "a"))).map(|it| {
            let ctl = is_control(&seed, it);
            Answer { a: st(it, "a").into(), verdict: if ctl { "wrong" } else { f(it, ctl) }.into(), note: String::new() }
        }).collect();
        assert!(record_verdicts(d, cat, &seed, &list, "auditor", None, None).unwrap().is_empty());
    }

    fn score(d: &Path, cat: &str) -> f64 { score_all(d, DEFAULT_TARGET).cats.iter().find(|c| c.category == cat).unwrap().score }

    #[test]
    fn sha256_matches_the_standard() {
        assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
        assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
    }

    #[test]
    fn wilson_bound_needs_a_real_sample() {
        // no sample of fewer than 35 can claim 90 from a large population, even with no errors
        assert!(wilson_lower(34, 34, 1_000_000) < 0.90);
        assert!(wilson_lower(35, 35, 1_000_000) >= 0.90);
        assert!(wilson_lower(5, 5, 1000) < 0.60);
        assert_eq!(wilson_lower(0, 0, 100), 0.0);
        // the whole of a small population audited leaves no sampling error, only the answers
        assert_eq!(wilson_lower(11, 11, 11), 1.0);
        assert!((wilson_lower(10, 11, 11) - 10.0 / 11.0).abs() < 1e-9);
        assert!(required_n(90.0, 0, 1_000_000) == 35);
        assert!(required_n(90.0, 1, 1_000_000) > 35);
    }

    #[test]
    fn with_no_audit_every_category_is_capped_low() {
        let d = fixture("noaudit");
        let s = score_all(&d, DEFAULT_TARGET);
        for c in &s.cats { assert!(c.score <= NO_AUDIT_CAP, "{} {}", c.category, c.score); assert!(c.limit.contains("no independent evidence"), "{}", c.limit); }
        assert!(!s.met());
        assert!(!s.full);
        assert_eq!(s.overall, PHOTO_ONLY_CAP);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn an_honest_full_audit_reaches_the_target_and_only_then() {
        let d = fixture("honest");
        two_level_photos(&d);
        for k in CATEGORIES { if k != "layers" { audit_honestly(&d, k, None); } }
        // layers need outside evidence: no verdict without a file, and none from the run's own photos
        let doc = draw_sample(&d, "layers", None, "tester", DEFAULT_TARGET).unwrap();
        let seed = st(&doc, "seed").to_string();
        assert!(record_verdict(&d, "layers", &seed, "a001", "ok", "auditor", "", None, None).unwrap_err().contains("photos cannot settle"));
        assert!(record_verdict(&d, "layers", &seed, "a001", "ok", "auditor", "", Some("board/top.jpg"), Some("xray")).unwrap_err().contains("outside evidence"));
        let s = score_all(&d, DEFAULT_TARGET);
        assert!(s.cats.iter().find(|c| c.category == "layers").unwrap().score <= PHOTO_ONLY_CAP);
        // the photo audits are done, so the photo score is there; the bench score waits for the outside evidence
        assert!(s.met() && s.photo >= 90.0, "{}", table(&s));
        assert!(s.bench <= PHOTO_ONLY_CAP && s.overall <= PHOTO_ONLY_CAP, "{}", table(&s));
        std::fs::write(d.join("xray.txt"), "edge view").unwrap();
        record_verdict(&d, "layers", &seed, "a001", "ok", "auditor", "", Some("xray.txt"), Some("xray")).unwrap();
        let s = score_all(&d, DEFAULT_TARGET);
        assert!(s.met(), "{}", table(&s));
        assert!(s.photo >= 90.0 && s.bench >= 90.0 && s.overall >= 90.0, "{}", table(&s));
        // the evidence file is held to its hash: the bench score falls back, and the photo score never rested on it
        std::fs::write(d.join("xray.txt"), "something else").unwrap();
        let s = score_all(&d, DEFAULT_TARGET);
        assert!(s.bench <= PHOTO_ONLY_CAP && s.photo >= 90.0, "{}", table(&s));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_hand_edited_score_is_refused() {
        let d = fixture("handedit");
        let (s0, hashes) = (score_all(&d, DEFAULT_TARGET), BTreeMap::<String, String>::new());
        // a pass recorded honestly, then edited to claim the target on every category
        let mut scores = s0.json();
        for k in CATEGORIES { scores["categories"][k]["score"] = json!(95.0); }
        scores["overall"] = json!(95.0);
        let review = json!({"passes": [{"n": 1, "target": 90.0, "verdict": "TARGET MET", "scores": scores, "hashes": hashes}]});
        let e = gate(&d, &review, &BTreeMap::new()).err().expect("the gate must refuse");
        assert!(e.iter().any(|m| m.contains("says identity is 95")), "{e:?}");
        // the same when the numbers are left alone and only the verdict is changed
        let review = json!({"passes": [{"n": 1, "target": 90.0, "verdict": "TARGET MET", "scores": s0.json(), "hashes": {}}]});
        assert!(gate(&d, &review, &BTreeMap::new()).is_err());
        // an old file with typed levels counts for nothing, nor does a scores file on disk
        let levels = json!({"passes": [{"n": 1, "changes": 0, "confidence": {"vias": {"level": "High", "why": "x"}}}]});
        assert!(gate(&d, &levels, &BTreeMap::new()).err().unwrap()[0].contains("no measured review pass"));
        write(&d, "confidence-scores.json", &json!({"overall": 100, "categories": {"vias": {"score": 100}}}));
        assert_eq!(score(&d, "vias"), NO_AUDIT_CAP);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_stale_audit_is_refused_and_does_not_reach_the_target() {
        let d = fixture("stale");
        audit_honestly(&d, "vias", None);
        let before = score(&d, "vias");
        assert!(before >= 90.0, "{before}");
        // change every third via after the audit
        let mut c = read(&d, "copper.json");
        for (i, v) in c["vias"].as_array_mut().unwrap().iter_mut().enumerate() { if i % 3 == 0 { v["pos_mm"][0] = json!(v["pos_mm"][0].as_f64().unwrap() + 0.2); } }
        write(&d, "copper.json", &c);
        // the layers scored again, so only the stale audit holds it
        write(&d, "fidelity.json", &json!({"categories": {"vias": {"score": 97.0}}}));
        let after = score_all(&d, DEFAULT_TARGET);
        let vias = after.cats.iter().find(|c| c.category == "vias").unwrap();
        assert!(vias.score < 90.0, "{}", vias.score);
        assert!(vias.audit.status.contains("stale"), "{}", vias.audit.status);
        assert!(vias.held, "a stale audit holds the pass open");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_verdict_on_a_stale_item_or_the_wrong_sample_is_refused() {
        let d = fixture("refuse");
        let doc = draw_sample(&d, "vias", Some(40), "tester", DEFAULT_TARGET).unwrap();
        let seed = st(&doc, "seed").to_string();
        assert!(record_verdict(&d, "vias", "deadbeef", "a001", "ok", "auditor", "", None, None).unwrap_err().contains("wrong sample"));
        assert!(record_verdict(&d, "vias", &seed, "a999", "ok", "auditor", "", None, None).unwrap_err().contains("not in sample"));
        assert!(record_verdict(&d, "vias", &seed, "a001", "maybe", "auditor", "", None, None).is_err());
        let real = arr(&doc["items"]).into_iter().find(|i| !is_control(&seed, i) && st(i, "stratum") == "kept").unwrap();
        let id = st(&real, "id").trim_start_matches("via:").to_string();
        let mut c = read(&d, "copper.json");
        for v in c["vias"].as_array_mut().unwrap() { if st(v, "id") == id { v["pos_mm"][1] = json!(v["pos_mm"][1].as_f64().unwrap() + 0.3); } }
        write(&d, "copper.json", &c);
        assert!(record_verdict(&d, "vias", &seed, st(&real, "a"), "ok", "auditor", "", None, None).unwrap_err().contains("stale"));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_too_small_sample_does_not_reach_the_target() {
        let d = fixture("small");
        audit_honestly(&d, "vias", Some(10));
        let s = score_all(&d, DEFAULT_TARGET);
        let vias = s.cats.iter().find(|c| c.category == "vias").unwrap();
        assert!(vias.score < 90.0, "{}", vias.score);
        assert!(vias.held, "a sample too small to reach the target is not a full pass");
        assert!(!s.full);
        // extending along the same order keeps the verdicts already given and can then reach it
        let doc = draw_sample(&d, "vias", Some(60), "tester", DEFAULT_TARGET).unwrap();
        answer(&d, "vias", &doc, |_, _| "ok");
        assert!(score(&d, "vias") >= 90.0);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_sample_cannot_be_shopped_for_or_hand_picked() {
        let d = fixture("shop");
        let doc = draw_sample(&d, "vias", Some(40), "tester", DEFAULT_TARGET).unwrap();
        // no redraw of a current sample, however bad its results
        assert!(draw_sample(&d, "vias", Some(40), "tester", DEFAULT_TARGET).unwrap_err().contains("cannot be redrawn"));
        // the seed is the population's: the same files draw the same sample
        let d2 = fixture("shop2");
        assert_eq!(st(&doc, "seed"), st(&draw_sample(&d2, "vias", Some(40), "tester", DEFAULT_TARGET).unwrap(), "seed"));
        // swapping an item for an easier one breaks the sample
        let mut hand = read(&d, "audits/vias/sample.json");
        let list = hand["items"].as_array_mut().unwrap();
        let seed = doc["seed"].as_str().unwrap().to_string();
        let i = list.iter().position(|x| !is_control(&seed, x) && st(x, "stratum") == "kept").unwrap();
        list[i]["id"] = json!("via:v299x");
        write(&d, "audits/vias/sample.json", &hand);
        let s = score_all(&d, DEFAULT_TARGET);
        let vias = s.cats.iter().find(|c| c.category == "vias").unwrap();
        assert_eq!(vias.audit.status, "tampered");
        assert!(vias.score <= NO_AUDIT_CAP);
        std::fs::remove_dir_all(&d).ok();
        std::fs::remove_dir_all(&d2).ok();
    }

    #[test]
    fn a_rubber_stamp_voids_the_sample_and_incomplete_counts_for_nothing() {
        let d = fixture("stamp");
        let doc = draw_sample(&d, "vias", None, "tester", DEFAULT_TARGET).unwrap();
        let seed = st(&doc, "seed").to_string();
        let list = arr(&doc["items"]);
        assert!(list.iter().any(|i| is_control(&seed, i)), "the sample has planted false claims");
        // and they do not all borrow one name, which would give them away on the sheet
        let names: HashSet<&str> = list.iter().filter(|i| is_control(&seed, i)).map(|i| st(i, "claim")).collect();
        assert!(names.len() > 1, "{names:?}");
        // the good answers alone, with the rest skipped, are worth nothing
        let ans = |it: &Value, v: &str| Answer { a: st(it, "a").into(), verdict: v.into(), note: String::new() };
        let good: Vec<Answer> = list.iter().take(list.len() / 2).map(|it| ans(it, if is_control(&seed, it) { "wrong" } else { "ok" })).collect();
        assert!(record_verdicts(&d, "vias", &seed, &good, "auditor", None, None).unwrap().is_empty());
        assert_eq!(score_all(&d, DEFAULT_TARGET).cats.iter().find(|c| c.category == "vias").unwrap().audit.status, "incomplete");
        // while the sample is not complete, so not scored, the same auditor may correct a verdict, with a note; the change is recorded
        let first = st(&list[0], "a").to_string();
        let was = if is_control(&seed, &list[0]) { "wrong" } else { "ok" };
        assert!(record_verdict(&d, "vias", &seed, &first, "cannot_tell", "auditor", "", None, None).unwrap_err().contains("say why in the note"));
        assert!(record_verdict(&d, "vias", &seed, &first, "cannot_tell", "someone-else", "I disagree", None, None).unwrap_err().contains("another auditor cannot overwrite"));
        record_verdict(&d, "vias", &seed, &first, "cannot_tell", "auditor", "looked at the wrong crop the first time", None, None).unwrap();
        let v = verdicts_of(&d, "vias", doc["draw"].as_u64().unwrap_or(0));
        assert_eq!(v[&first]["verdict"], "cannot_tell"); assert_eq!(v[&first]["amends"]["verdict"], was);
        record_verdict(&d, "vias", &seed, &first, was, "auditor", "and back: the first reading was right", None, None).unwrap();
        let stamp: Vec<Answer> = list.iter().skip(list.len() / 2).map(|it| ans(it, "ok")).collect();
        assert!(record_verdicts(&d, "vias", &seed, &stamp, "auditor", None, None).unwrap().is_empty());
        let s = score_all(&d, DEFAULT_TARGET);
        let vias = s.cats.iter().find(|c| c.category == "vias").unwrap();
        assert_eq!(vias.audit.status, "void", "{}", vias.audit.detail);
        assert!(vias.score <= NO_AUDIT_CAP);
        // an answer already given cannot be taken back without new evidence
        assert!(record_verdict(&d, "vias", &seed, st(&list[0], "a"), "wrong", "auditor", "", None, None).unwrap_err().contains("already has a verdict"));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn the_maker_cannot_audit_its_own_work() {
        let d = fixture("maker");
        std::fs::write(d.join("run.jsonl"), "{\"aiThread\":\"maker\",\"ev\":\"copper-trace\",\"data\":{}}\n").unwrap();
        let doc = draw_sample(&d, "vias", Some(40), "tester", DEFAULT_TARGET).unwrap();
        assert!(record_verdict(&d, "vias", st(&doc, "seed"), "a001", "ok", "maker", "", None, None).unwrap_err().contains("produced this run"));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn failures_in_either_stratum_hold_the_score_down() {
        let d = fixture("fails");
        let doc = draw_sample(&d, "vias", None, "tester", DEFAULT_TARGET).unwrap();
        // every kept via right, but a third of the dropped candidates were real
        let seed = st(&doc, "seed").to_string();
        let all: Vec<Answer> = arr(&doc["items"]).iter().enumerate().map(|(i, it)| {
            let v = if is_control(&seed, it) || (st(it, "stratum") == "dropped" && i % 3 == 0) { "wrong" } else { "ok" };
            Answer { a: st(it, "a").into(), verdict: v.into(), note: String::new() }
        }).collect();
        assert!(record_verdicts(&d, "vias", &seed, &all, "auditor", None, None).unwrap().is_empty());
        let s = score_all(&d, DEFAULT_TARGET);
        let vias = s.cats.iter().find(|c| c.category == "vias").unwrap();
        assert!(vias.score < 80.0, "{}", vias.score);
        assert!(!vias.held, "enough was audited: the failures are what hold it");
        assert!(vias.raise.contains("fix the method"), "{}", vias.raise);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn overall_is_the_lowest_and_a_loop_stalls_when_nothing_rises() {
        let d = fixture("stall");
        two_level_photos(&d);
        for k in ["identity", "values", "blocks", "placement", "copper", "vias", "connectivity"] { audit_honestly(&d, k, None); }
        let s = score_all(&d, DEFAULT_TARGET);
        assert!(s.cats.iter().filter(|c| c.category != "layers").all(|c| c.score >= 90.0), "{}", table(&s));
        // the layer count needs outside evidence: it holds the bench score down and no longer the loop
        assert_eq!(s.bench, PHOTO_ONLY_CAP);
        assert_eq!(s.overall, PHOTO_ONLY_CAP, "the lower of the two scores is the lowest category");
        assert!(s.photo >= 90.0 && s.met(), "{}", table(&s));
        assert_eq!(verdict_for(&[], &s, 20), "TARGET MET");
        assert!(stall_lines(&s).is_empty());
        let bench = bench_lines(&s);
        assert_eq!(bench.len(), 1);
        assert!(bench[0].starts_with("layers") && bench[0].contains("What would raise it") && bench[0].contains("X-ray"), "{bench:?}");
        // a photo figure under the target that no audit holds: the loop goes on once, then stalls when nothing rises
        write(&d, "fidelity.json", &json!({"categories": {"copper": {"score": 97.0}, "vias": {"score": 97.0}, "placement": {"score": 50.0}}}));
        let s = score_all(&d, DEFAULT_TARGET);
        assert_eq!(s.photo, 50.0, "one weak photo figure sets the photo score");
        assert!(s.full, "nothing below the target is held by an audit, so more audits cannot help");
        assert_eq!(verdict_for(&[], &s, 20), "CONTINUE");
        let p1 = json!({"scores": s.json()});
        assert_eq!(verdict_for(&[p1.clone()], &s, 20), "STALLED");
        // a pass recorded before the two scores holds no photo figure: the first pass after it counts as a rise, the next one stalls
        let mut old = s.json(); for c in old["categories"].as_object_mut().unwrap().values_mut() { c.as_object_mut().unwrap().remove("photo"); }
        assert_eq!(verdict_for(&[json!({"scores": old.clone()})], &s, 20), "CONTINUE");
        assert_eq!(verdict_for(&[json!({"scores": old}), p1.clone()], &s, 20), "STALLED");
        let lines = stall_lines(&s);
        assert_eq!(lines.len(), 1);
        assert!(lines[0].starts_with("placement photo figure 50.0") && lines[0].contains("Most likely to raise it"), "{lines:?}");
        let line = overall_line(&s, "STALLED");
        assert!(line.contains("photo score 50.0 of 100, target 90: STALLED") && line.contains("bench score 30.0 of 100"), "{line}");
        // the pass limit stops a loop that keeps creeping
        let mut lower = s.json();
        lower["categories"]["placement"]["photo"]["score"] = json!(10.0);
        assert_eq!(verdict_for(&vec![json!({"scores": lower}); 19], &s, 20), "STOPPED");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_pass_is_not_full_while_an_audit_is_what_holds_a_category() {
        let d = fixture("notfull");
        let s = score_all(&d, DEFAULT_TARGET);
        assert!(!s.full);
        let p = json!({"scores": s.json()});
        // nothing has risen, but the audits were never done: no stall can be declared
        assert_eq!(verdict_for(&[p], &s, 20), "CONTINUE");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn fidelity_limits_the_layers_scores_and_its_absence_caps_them() {
        let d = fixture("fidelity");
        two_level_photos(&d);
        for k in ["copper", "vias", "placement"] { audit_honestly(&d, k, None); }
        for k in ["copper", "vias", "placement"] { assert!(score(&d, k) >= 90.0, "{k}"); }
        // a low figure limits, whatever the audit says
        write(&d, "fidelity.json", &json!({"categories": {"copper": {"score": 61.5}, "vias": {"score": 97}, "placement": {"score": 97}}}));
        assert_eq!(score(&d, "copper"), 61.5);
        assert!(score(&d, "vias") >= 90.0);
        // a figure per layer, a fraction, and a new field nobody here knows
        write(&d, "fidelity.json", &json!({"layers": {"F.Cu": {"score": 0.97, "attribution": [1, 2]}, "B.Cu": {"score": 0.72}}, "vias": {"score": 0.95}, "placement": {"agreement": 0.5}}));
        assert_eq!(score(&d, "copper"), 72.0);
        assert_eq!(score(&d, "placement"), 50.0);
        // a file that says nothing for a category leaves that category capped
        write(&d, "fidelity.json", &json!({"vias": {"score": 95}}));
        assert_eq!(score(&d, "copper"), NO_AUDIT_CAP);
        // gone: the input is missing and the score says so
        std::fs::remove_file(d.join("fidelity.json")).unwrap();
        let s = score_all(&d, DEFAULT_TARGET);
        let cu = s.cats.iter().find(|c| c.category == "copper").unwrap();
        assert_eq!(cu.score, NO_AUDIT_CAP);
        assert!(cu.limit.contains("input missing") && cu.limit.contains("fidelity.json"), "{}", cu.limit);
        assert!(!cu.held, "the audit is done: running it again would not help");
        // older than what it scored: the same as missing, by hash where it records one
        write(&d, "fidelity.json", &json!({"inputs": {"copper.json": "0000"}, "categories": {"copper": {"score": 99}}}));
        assert_eq!(score(&d, "copper"), NO_AUDIT_CAP);
        std::fs::remove_dir_all(&d).ok();
    }

    /// The two faces of the fixture lit as the copy says: copper lighter than bare board, top lower half and bottom upper half.
    fn two_level_photos(d: &Path) {
        let (cu, bare) = (image::Rgb([30, 95, 45]), image::Rgb([18, 45, 28]));
        RgbImage::from_fn(1600, 1600, |_, y| if y >= 800 { cu } else { bare }).save(d.join("board/top.jpg")).unwrap();
        RgbImage::from_fn(1600, 1600, |_, y| if y < 800 { cu } else { bare }).save(d.join("board/bottom.jpg")).unwrap();
    }

    #[test]
    fn points_the_photo_cannot_show_are_left_out_by_material_and_reported() {
        let d = fixture("materials");
        write(&d, "parts.json", &json!({"parts": []}));
        let c = Ctx::load(&d);
        let before = c.census().points.len();
        assert!(c.census().left_out.is_empty(), "plain mask everywhere: {}", c.census().line());
        // a block of silk and a square of exposed gold on the bottom: everything else is plain mask
        let mut img = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));
        for y in 300..500 { for x in 300..700 { img.put_pixel(x, y, image::Rgb([210, 215, 235])); } }
        for y in 900..1100 { for x in 900..1100 { img.put_pixel(x, y, image::Rgb([210, 170, 60])); } }
        img.save(d.join("board/bottom.jpg")).unwrap();
        let c = Ctx::load(&d);
        let n = c.census();
        assert!(n.left_out.iter().all(|l| l.face == "bottom"));
        let by = n.counts();
        assert!(by["silk"] >= 40, "{}", n.line());
        assert!(by["exposed metal"] >= 15, "{}", n.line());
        assert_eq!(n.points.len() + n.left_out.len(), before, "a point left out stays counted");
        for p in &n.points { if p.face == "bottom" { let (x, y) = board::to_px(p.at.0, p.at.1); assert!(!(x > 310.0 && x < 690.0 && y > 310.0 && y < 490.0), "{:?} lies on the silk", p.at); } }
        assert!(n.line().contains("left out") && n.line().contains("silk"));
        let s = score_all(&d, DEFAULT_TARGET);
        assert!(s.cats.iter().find(|x| x.category == "copper").unwrap().audit.detail.contains("left out"), "the score says what was left out");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn what_is_left_out_does_not_depend_on_the_copys_copper() {
        let d = fixture("nodepend");
        let mut img = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));
        for y in 300..500 { for x in 300..700 { img.put_pixel(x, y, image::Rgb([210, 215, 235])); } }
        img.save(d.join("board/bottom.jpg")).unwrap();
        let ids = |d: &Path| { let c = Ctx::load(d); let mut l: Vec<String> = c.census().left_out.iter().map(|l| format!("{} {:?} {}", l.face, l.at, l.why)).collect(); l.sort(); l };
        let first = ids(&d);
        assert!(!first.is_empty());
        // the copy loses all its copper: answers change, what is left out does not
        let mut c = read(&d, "copper.json");
        c["zones"] = json!([]);
        c["vias"] = json!([]);
        write(&d, "copper.json", &c);
        assert_eq!(ids(&d), first);
        // and dark mask stays in: only what is not mask goes
        let dark = RgbImage::from_pixel(1600, 1600, image::Rgb([6, 22, 12]));
        dark.save(d.join("board/top.jpg")).unwrap();
        let c = Ctx::load(&d);
        assert!(c.census().points.iter().any(|p| p.face == "top"), "a dim face stays in the population");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn claims_say_what_the_photo_shows_and_the_sheet_explains_the_panels() {
        let d = fixture("wording");
        two_level_photos(&d);
        let doc = draw_sample(&d, "copper", Some(20), "tester", DEFAULT_TARGET).unwrap();
        for it in arr(&doc["items"]) { assert!(st(&it, "claim").contains("the photo shows"), "{}", st(&it, "claim")); assert!(!st(&it, "claim").contains("the copy")); }
        let md = std::fs::read_to_string(d.join("audits/copper/sheet.md")).unwrap();
        assert!(md.contains("Top right") && md.contains("Bottom left") && md.contains("cannot_tell") && md.contains("left out"));
        assert!(d.join("audits/copper/exclusions.json").is_file());
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn copper_crops_come_from_the_photos_and_the_position_alone() {
        let d = fixture("panels");
        two_level_photos(&d);
        let doc = draw_sample(&d, "copper", Some(20), "tester", DEFAULT_TARGET).unwrap();
        // the copy changes completely: a crop rebuilt from the photos and the position alone is the same file, real or planted
        let mut k = read(&d, "copper.json");
        k["zones"] = json!([]);
        write(&d, "copper.json", &k);
        let c = Ctx::load(&d);
        let (top, bottom) = (copper_view(&d, "top", c.face("top").unwrap()).unwrap(), copper_view(&d, "bottom", c.face("bottom").unwrap()).unwrap());
        for it in arr(&doc["items"]) {
            let at = xy(&it["at_mm"]).unwrap();
            let f = d.join("again.jpg");
            save_jpeg(&copper_crop(if st(&it, "face") == "top" { &top } else { &bottom }, st(&it, "face"), at), &f).unwrap();
            assert_eq!(std::fs::read(&f).unwrap(), std::fs::read(d.join(st(&it, "crop"))).unwrap(), "{}", st(&it, "a"));
        }
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn stretch_levels_ignore_what_is_not_mask_and_keep_flat_mask_flat() {
        let d = fixture("levels");
        let mut img = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));
        for y in 700..760 { for x in 700..760 { img.put_pixel(x, y, image::Rgb([240, 240, 245])); } }
        img.save(d.join("board/top.jpg")).unwrap();
        let c = Ctx::load(&d);
        let v = copper_view(&d, "top", c.face("top").unwrap()).unwrap();
        let l = stretch_levels(&v, (118.0, 118.0), STRETCH_REF_MM);
        for k in 0..3 { assert!(l[k].1 - l[k].0 <= 14.0, "a bright block sets no level: {l:?}"); assert!(l[k].1 - l[k].0 >= 8.0, "{l:?}"); }
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn planted_copper_claims_are_real_points_stated_the_other_way_round() {
        let d = fixture("planted");
        two_level_photos(&d);
        write(&d, "parts.json", &json!({"parts": []}));
        let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();
        let seed = st(&doc, "seed").to_string();
        let c = Ctx::load(&d);
        let pop = population(&c, "copper");
        let list = arr(&doc["items"]);
        let ctl: Vec<&Value> = list.iter().filter(|i| is_control(&seed, i)).collect();
        assert!(ctl.len() >= 3);
        let real: HashSet<&str> = list.iter().filter(|i| !is_control(&seed, i)).map(|i| st(i, "id")).collect();
        for it in &ctl {
            let p = pop.iter().find(|p| p.id == st(it, "id")).expect("a control is a point of the population");
            assert_eq!(xy(&it["at_mm"]).unwrap(), p.at, "at the population's own position, so its coordinates show nothing");
            assert_eq!(st(it, "face"), p.face);
            assert_ne!(st(it, "stratum"), p.stratum, "its claim is the other answer");
            assert_eq!(st(it, "hash"), copper_item(String::new(), &p.face, p.at, st(it, "stratum") == "copper").hash, "its record reads as a real item of that claim");
            assert!(st(it, "claim").ends_with(if st(it, "stratum") == "copper" { COPPER_CLAIM } else { BARE_CLAIM }));
            assert!(!real.contains(st(it, "id")), "a point is on the sheet once");
        }
        let ways: HashSet<&str> = ctl.iter().map(|i| st(i, "stratum")).collect();
        assert_eq!(ways.len(), 2, "planted both ways, so answering one claim ok every time is caught: {ways:?}");
        let ids: HashSet<&str> = ctl.iter().map(|i| st(i, "id")).collect();
        assert_eq!(ids.len(), ctl.len());
        // calling one ok voids the sample; calling them wrong or cannot_tell does not
        let ans: Vec<Answer> = list.iter().map(|it| Answer { a: st(it, "a").into(), verdict: if is_control(&seed, it) { "cannot_tell" } else { "ok" }.into(), note: String::new() }).collect();
        assert!(record_verdicts(&d, "copper", &seed, &ans, "auditor", None, None).unwrap().is_empty());
        let cu = score_all(&d, DEFAULT_TARGET);
        let cu = cu.cats.iter().find(|x| x.category == "copper").unwrap();
        assert!(cu.audit.status.starts_with("scored"), "{}", cu.audit.status);
        std::fs::remove_dir_all(&d).ok();
        let d = fixture("planted2");
        two_level_photos(&d);
        let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();
        let seed = st(&doc, "seed").to_string();
        let stamp: Vec<Answer> = arr(&doc["items"]).iter().map(|it| Answer { a: st(it, "a").into(), verdict: "ok".into(), note: String::new() }).collect();
        assert!(record_verdicts(&d, "copper", &seed, &stamp, "auditor", None, None).unwrap().is_empty());
        assert_eq!(score_all(&d, DEFAULT_TARGET).cats.iter().find(|x| x.category == "copper").unwrap().audit.status, "void");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn cannot_tell_still_counts_against_the_copper_score() {
        let d = fixture("cannot");
        two_level_photos(&d);
        let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();
        let seed = st(&doc, "seed").to_string();
        let ans: Vec<Answer> = arr(&doc["items"]).iter().enumerate().map(|(i, it)| Answer { a: st(it, "a").into(), verdict: if is_control(&seed, it) { "wrong" } else if i % 2 == 0 { "cannot_tell" } else { "ok" }.into(), note: String::new() }).collect();
        assert!(record_verdicts(&d, "copper", &seed, &ans, "auditor", None, None).unwrap().is_empty());
        assert!(score(&d, "copper") < 70.0, "{}", score(&d, "copper"));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_preview_stays_out_of_the_audit() {
        let d = fixture("preview");
        two_level_photos(&d);
        let doc = draw_sample(&d, "copper", Some(20), "tester", DEFAULT_TARGET).unwrap();
        let before = std::fs::read(d.join("audits/copper/sample.json")).unwrap();
        assert!(copper_preview(&d, 6, &d.join("audits/copper/x")).unwrap_err().contains("inside the audit"));
        assert_eq!(copper_preview(&d, 6, &d.join("tune")).unwrap(), 6);
        assert_eq!(std::fs::read(d.join("audits/copper/sample.json")).unwrap(), before);
        assert!(!d.join("audits/copper/verdicts.jsonl").exists());
        let key = read(&d, "tune/key.json");
        let sampled: HashSet<String> = arr(&doc["items"]).iter().map(|i| format!("{}", i["at_mm"])).collect();
        for k in arr(&key) { assert!(!sampled.contains(&format!("{}", k["at_mm"])), "preview points are not in the sample"); }
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_planted_copper_claim_goes_only_where_the_photo_is_plain_to_a_person() {
        let d = fixture("plain");
        two_level_photos(&d);
        // on the bottom, inside the copper: a patch as green as copper and as little red as bare board, and beside it one half way between the two in green
        let mut img = image::open(d.join("board/bottom.jpg")).unwrap().to_rgb8();
        for y in 560..720 { for x in 320..480 { img.put_pixel(x, y, image::Rgb([18, 95, 45])); } }
        for y in 560..720 { for x in 1040..1200 { img.put_pixel(x, y, image::Rgb([24, 72, 37])); } }
        img.save(d.join("board/bottom.jpg")).unwrap();
        let c = Ctx::load(&d);
        let rule = Planting::new(&c).unwrap();
        // plain copper and plain bare board, each with the other in view, clear of everything
        assert_eq!(rule.doubt("top", (110.3, 123.3), true), None);
        assert_eq!(rule.doubt("top", (110.3, 116.7), false), None);
        assert_eq!(rule.doubt("bottom", (120.3, 116.7), true), None);
        // and every way a point can be less than plain
        assert_eq!(rule.doubt("top", (100.6, 123.3), true), Some("near the board edge"));
        assert_eq!(rule.doubt("top", (125.4, 123.5), true), Some("near a via or a via candidate"));
        assert_eq!(rule.doubt("top", (110.3, 114.6), false), Some("near a part"));
        assert_eq!(rule.doubt("top", (110.3, 120.3), true), Some("the copy has the other surface nearby"), "0.3 mm from the edge of the copper: inside the ring");
        assert_eq!(rule.doubt("bottom", (110.3, 105.3), true), Some("only one surface in view to compare with"));
        assert_eq!(rule.doubt("bottom", (110.0, 116.0), true), Some("not the colour of the surface it is, in red, green and blue alike"), "the copy and the scorer both read copper here, and a person would not be sure");
        assert!(rule.doubt("bottom", (128.0, 116.0), true).is_some(), "half way between the two surfaces is not plain");
        // silk beside the point
        let mut img = image::open(d.join("board/top.jpg")).unwrap().to_rgb8();
        for y in 900..960 { for x in 420..480 { img.put_pixel(x, y, image::Rgb([225, 228, 240])); } }
        img.save(d.join("board/top.jpg")).unwrap();
        let c = Ctx::load(&d);
        let rule = Planting::new(&c).unwrap();
        assert_eq!(rule.doubt("top", (110.3, 123.3), true), Some("silk, metal, a hole or a part body nearby"));
        // a draw plants only where the rule finds nothing, still both ways round, and as many as before
        let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();
        let seed = st(&doc, "seed").to_string();
        let list = arr(&doc["items"]);
        let ctl: Vec<&Value> = list.iter().filter(|i| is_control(&seed, i)).collect();
        assert_eq!(ctl.len(), controls_for(list.len() - ctl.len()));
        for it in &ctl { assert_eq!(rule.doubt(st(it, "face"), xy(&it["at_mm"]).unwrap(), st(it, "stratum") != "copper"), None, "{}", st(it, "a")); }
        assert_eq!(ctl.iter().map(|i| st(i, "stratum")).collect::<HashSet<_>>().len(), 2);
        let ex = read(&d, "audits/copper/exclusions.json");
        let planted = &ex["plantedClaims"];
        assert!(planted["pointsEligible"].as_u64().unwrap() >= ctl.len() as u64 && planted["refused"].as_object().unwrap().len() >= 3, "{planted}");
        assert_eq!(planted["eligibleByWhatTheClaimStates"]["bare"].as_u64().unwrap() + planted["eligibleByWhatTheClaimStates"]["copper"].as_u64().unwrap(), planted["pointsEligible"].as_u64().unwrap());
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_draw_that_cannot_plant_plain_controls_refuses_and_changes_nothing() {
        // one flat surface: nowhere is a false claim false beyond doubt
        let d = fixture("noplain");
        let e = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap_err();
        assert!(e.contains("planted claims") && e.contains("plain to a person") && e.contains("only one surface in view"), "{e}");
        assert!(!d.join("audits/copper/sample.json").exists());
        // with a sample in hand, a draw that cannot be made leaves it and its crops alone
        two_level_photos(&d);
        let doc = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();
        let before = std::fs::read(d.join("audits/copper/sample.json")).unwrap();
        let mut k = read(&d, "copper.json");
        k["zones"][0]["polygon_mm"] = json!([[100, 124], [140, 124], [140, 140], [100, 140]]);
        write(&d, "copper.json", &k);
        RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30])).save(d.join("board/top.jpg")).unwrap();
        RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30])).save(d.join("board/bottom.jpg")).unwrap();
        assert!(draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap_err().contains("nothing was changed"));
        assert_eq!(std::fs::read(d.join("audits/copper/sample.json")).unwrap(), before);
        assert!(!d.join("audits/copper/sample-1.json").exists());
        assert!(d.join(st(&arr(&doc["items"])[0], "crop")).is_file());
        std::fs::remove_dir_all(&d).ok();
    }

    /// A board of its own for the joins: a pour with a via in it and a sliver stitched to it, a track that ends on a pad, a pour whose pad is far
    /// from it, and a pour that touches nothing. A chip's body lies on the top photo.
    fn join_fixture(name: &str) -> PathBuf {
        let d = fixture(name);
        let sq = |x0: f64, y0: f64, x1: f64, y1: f64| json!([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);
        write(&d, "copper.json", &json!({"outline": {"points_mm": [[100, 100], [140, 100], [140, 140], [100, 140]]},
            "zones": [{"id": "zA", "layer": "F.Cu", "net": "GND", "areaMm2": 100.0, "polygon_mm": sq(105.0, 115.0, 115.0, 125.0)}, {"id": "zB", "layer": "F.Cu", "net": "GND", "areaMm2": 8.0, "polygon_mm": sq(115.1, 118.0, 117.0, 122.0)},
                {"id": "zC", "layer": "F.Cu", "net": "UNK_ISO1", "areaMm2": 16.0, "polygon_mm": sq(130.0, 130.0, 134.0, 134.0)}, {"id": "zD", "layer": "F.Cu", "net": "N7", "areaMm2": 16.0, "polygon_mm": sq(120.0, 126.0, 124.0, 130.0)}],
            "traces": [{"id": "tE", "layer": "F.Cu", "net": "N5", "width_mm": 0.2, "path_mm": [[105.0, 133.0], [112.0, 133.0]]}],
            "vias": [{"id": "v1", "pos_mm": [110.0, 120.0], "diameter_mm": 0.6, "seen": "top", "tag": "MEASURED"}]}));
        write(&d, "detect/domes.json", &json!({"domes": []}));
        write(&d, "detect/attribution.json", &json!({"pieces": [{"piece": 0, "side": "top", "conductor": 0, "node": 0, "items": ["zA"]}, {"piece": 1, "side": "top", "conductor": 0, "node": 0, "items": ["zB"]},
                {"piece": 2, "side": "top", "conductor": 1, "node": 1, "items": ["zC"]}, {"piece": 3, "side": "top", "conductor": 2, "node": 2, "items": ["zD"]}, {"piece": 4, "side": "top", "conductor": 3, "node": 3, "items": ["tE"]}],
            "vias": {"v1": {"valid": true, "topConductor": 0, "bottomConductor": null}},
            "padStates": [{"pin": "R8.1", "side": "top", "x": 112.4, "y": 133.0, "contacts": [{"conductor": 3, "tier": 1, "kind": "track-end"}]}, {"pin": "R10.1", "side": "top", "x": 135.0, "y": 128.0, "contacts": [{"conductor": 2, "tier": 1, "kind": "pour"}]}]}));
        // five pins have a net: one ends a track, one is read on a pour 11 mm from its pad, three touch nothing. One net cites a datasheet
        write(&d, "nets.json", &json!({"nets": [{"name": "A", "tag": "INFERRED", "pins": ["R8.1", "R10.1", "C1.1"], "source": "Module datasheet, Table 4", "nameSource": "Module datasheet, Table 4"}, {"name": "N001", "tag": "INFERRED", "pins": ["R12.1", "C3.1"], "nameSource": "none: unnamed"}]}));
        write(&d, "detect/connect-graph.json", &json!({"contacts": [{"pin": "R8.1", "node": 3, "tier": 1, "kind": "track-end", "side": "top"}, {"pin": "R10.1", "node": 2, "tier": 1, "kind": "pour", "side": "top"}],
            "stitches": [{"pieces": [0, 1], "side": "top", "at": [115.05, 120.0], "kind": "no gap in the photo"}], "nodeOfPiece": [0, 0, 1, 2, 3], "conductorOfPiece": [0, 0, 1, 2, 3]}));
        write(&d, "detect/attrib/measure.json", &json!({"pads": [{"part": "R8", "num": "1", "core": {"x": 112.4, "y": 133.0, "w": 0.6, "h": 0.8, "rot": 0.0}}, {"part": "R10", "num": "1", "core": {"x": 135.0, "y": 128.0, "w": 0.6, "h": 0.8, "rot": 0.0}}]}));
        let mut top = RgbImage::from_pixel(1600, 1600, image::Rgb([20, 60, 30]));
        for y in 560..1040 { for x in 704..1000 { top.put_pixel(x, y, image::Rgb([12, 12, 14])); } }
        top.save(d.join("board/top.jpg")).unwrap();
        d
    }

    #[test]
    fn an_island_claims_a_join_the_crop_shows_and_never_a_net() {
        let d = join_fixture("joins");
        let c = Ctx::load(&d);
        let pop = population(&c, "connectivity");
        let isl: HashMap<&str, &PItem> = pop.iter().filter(|p| p.stratum == "island").map(|p| (p.id.as_str(), p)).collect();
        // three islands have a join within reach; the pour that touches nothing and the pour whose pad is 11 mm off are put forward as nothing
        assert_eq!(isl.keys().copied().collect::<std::collections::BTreeSet<_>>(), ["trace:tE", "zone:zA", "zone:zB"].into_iter().collect());
        assert_eq!(c.island_proof(), (3, 5));
        for p in isl.values() {
            assert!(!p.claim().contains("net "), "{}", p.claim());
            assert!(p.claim().starts_with("copper outlined in pink"), "{}", p.claim());
            // both ends are in the crop, and the other end is at the island's own copper
            for (pts, _) in p.shapes.iter().take(1).chain(p.other.iter()) { assert!(pts.iter().any(|q| (q.0 - p.at.0).abs().max((q.1 - p.at.1).abs()) < p.window / 2.0), "{}", p.id); }
            assert!(!p.other.is_empty());
        }
        assert_eq!(isl["trace:tE"].body, "reaches the pad boxed in blue (pin R8.1) with no break between them");
        assert_eq!(isl["zone:zB"].body, "is one piece with the copper outlined in blue (zA): no gap between them at the blue cross");
        assert_eq!(isl["zone:zB"].at, (115.05, 120.0), "centred where the stitch was read");
        // the pour has two joins, a via and the sliver: the via is nearer a name, so the via is its claim
        assert_eq!(c.joins().iter().find(|j| j.0.id == "zA").unwrap().1.iter().map(|j| j.kind).collect::<Vec<_>>(), ["copper", "via"]);
        assert_eq!(isl["zone:zA"].body, "is one piece with the via in the blue ring: no gap between the via and this copper");
        assert_eq!(isl["zone:zA"].at, (110.0, 120.0));
        // a track is outlined beside its edges, not down its middle
        let line = &isl["trace:tE"].shapes[0].0;
        assert!(line.iter().all(|q| (q.1 - 133.0).abs() > 0.15) && line.iter().any(|q| q.0 < 104.9) && line.iter().any(|q| q.0 > 112.1), "{line:?}");
        // the sample: each claim has a crop with a close row
        let doc = draw_sample(&d, "connectivity", None, "tester", DEFAULT_TARGET).unwrap();
        let seed = st(&doc, "seed").to_string();
        let list = arr(&doc["items"]);
        let real = |id: &str| list.iter().find(|i| st(i, "id") == id && !is_control(&seed, i)).unwrap().clone();
        assert_eq!(image::open(d.join(st(&real("trace:tE"), "crop"))).unwrap().height(), CROP_PX * 2 + 8);
        let md = std::fs::read_to_string(d.join("audits/connectivity/sheet.md")).unwrap();
        assert!(md.contains("blue is the other end") && md.contains("cannot_tell") && !md.contains("is on net"), "{md}");
        // every real claim right: the two islands with nothing to show still hold the island figure at 3 of 5
        answer(&d, "connectivity", &doc, |_, _| "ok");
        let s = score_all(&d, DEFAULT_TARGET);
        let con = s.cats.iter().find(|x| x.category == "connectivity").unwrap();
        let row = con.audit.strata.iter().find(|x| x["stratum"] == json!("island")).unwrap();
        assert_eq!((row["ok"].as_u64(), row["audited"].as_u64(), row["unproven"].as_u64(), row["islands"].as_u64()), (Some(3), Some(3), Some(2), Some(5)));
        assert_eq!(row["value"].as_f64(), Some(60.0));
        assert!(con.score <= 60.0 && con.audit.detail.contains("2 of 5 islands show no join"), "{} {}", con.score, con.audit.detail);
        // the pin on that track is judged from the same join, read from the pad's side: one of five pins, so a fifth
        let pin = real("pin:R8.1");
        assert_eq!(st(&pin, "claim"), "pad boxed in pink (pin R8.1): is joined to the copper outlined in blue (tE) with no break between them");
        assert_eq!(image::open(d.join(st(&pin, "crop"))).unwrap().height(), CROP_PX * 2 + 8);
        let row = con.audit.strata.iter().find(|x| x["stratum"] == json!("pin")).unwrap();
        assert_eq!((row["ok"].as_u64(), row["audited"].as_u64(), row["unproven"].as_u64(), row["pins"].as_u64(), row["value"].as_f64()), (Some(1), Some(1), Some(4), Some(5), Some(20.0)));
        assert_eq!(con.score, 20.0);
        assert!(con.audit.detail.contains("4 of 5 pins with a net show no join of their pad to copper the copy drew") && con.audit.detail.contains("2 of those 4 are on a net that cites a source outside the photos"), "{}", con.audit.detail);
        // the pad the track ends on is read somewhere else: that claim is another claim now, and its verdict is stale
        let mut ms = read(&d, "detect/attrib/measure.json");
        ms["pads"][0]["core"]["y"] = json!(133.1);
        write(&d, "detect/attrib/measure.json", &ms);
        assert!(record_verdict(&d, "connectivity", &seed, st(&real("trace:tE"), "a"), "ok", "auditor", "", None, None).unwrap_err().contains("stale"));
        assert!(record_verdict(&d, "connectivity", &seed, st(&pin, "a"), "ok", "auditor", "", None, None).unwrap_err().contains("stale"));
        assert!(score_all(&d, DEFAULT_TARGET).cats.iter().find(|x| x.category == "connectivity").unwrap().audit.status.contains("stale"));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_pin_claims_the_join_of_its_pad_to_drawn_copper_and_never_a_net() {
        let d = join_fixture("pins");
        let c = Ctx::load(&d);
        let pop = population(&c, "connectivity");
        let pins: Vec<&PItem> = pop.iter().filter(|p| p.stratum == "pin").collect();
        // five pins have a net and one has a join a crop can show: the one whose pad ends the track. The pin read on a pour 11 mm away, like the pour, is put forward as nothing
        assert_eq!(pins.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), ["pin:R8.1"]);
        assert_eq!(c.pin_proof(), (1, 5, 2), "four unproven, two of them on the net that cites a datasheet");
        let p = pins[0];
        assert_eq!(p.claim(), "pad boxed in pink (pin R8.1): is joined to the copper outlined in blue (tE) with no break between them");
        assert!(!pop.iter().any(|p| p.claim().contains("is on net")));
        // the crop is centred on the pad; pink is the pad's box where its metal was read, blue the track outlined beside its edges
        assert_eq!(p.at, (112.4, 133.0));
        let boxed = |pts: &[(f64, f64)]| pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));
        let (pink, blue) = (boxed(&p.shapes[0].0), boxed(&p.other[0].0));
        assert!((pink.0 - 112.02).abs() < 0.01 && (pink.2 - 112.78).abs() < 0.01 && (pink.1 - 132.52).abs() < 0.01 && (pink.3 - 133.48).abs() < 0.01, "{pink:?}");
        assert!(blue.0 < 104.9 && blue.2 > 112.1 && (blue.1 - 132.82).abs() < 0.01 && (blue.3 - 133.18).abs() < 0.01, "{blue:?}");
        assert!(p.window >= 6.0 && blue.2 - p.at.0 < p.window / 2.0);
        // its record holds the net, so a pin moved to another net is another claim, though the words do not name it
        let mut nets = read(&d, "nets.json");
        nets["nets"][0]["name"] = json!("A2");
        write(&d, "nets.json", &nets);
        let again = population(&Ctx::load(&d), "connectivity");
        let q = again.iter().find(|x| x.id == "pin:R8.1").unwrap();
        assert_eq!(q.claim(), p.claim());
        assert_ne!(q.hash, p.hash);
        // what counts as citing a source outside the photos
        for (n, cites) in [(json!({"source": "Module datasheet, Table 4"}), true), (json!({"nameSource": "datasheet"}), true), (json!({"nameSource": "none: unnamed"}), false), (json!({"nameSource": "inference from the photos"}), false),
            (json!({"source": "measured"}), false), (json!({"nameSource": "circuit"}), false), (json!({}), false)] { assert_eq!(cites_outside(&n), cites, "{n}"); }
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn what_shows_no_join_counts_as_not_correct_in_every_state_of_the_audit() {
        let d = fixture("nojoin");
        audit_honestly(&d, "connectivity", None);
        assert!(score(&d, "connectivity") >= 90.0);
        let con = |d: &Path| score_all(d, DEFAULT_TARGET).cats.into_iter().find(|x| x.category == "connectivity").unwrap();
        // no pin's pad is read on copper any more: the islands still have their vias, and every island claim is right, but the pins are unproven
        let graph = read(&d, "detect/connect-graph.json");
        write(&d, "detect/connect-graph.json", &json!({}));
        std::fs::remove_dir_all(d.join("audits/connectivity")).unwrap();
        let c = Ctx::load(&d);
        assert_eq!((c.island_proof(), c.pin_proof()), ((2, 2), (0, 200, 0)));
        assert!(population(&c, "connectivity").iter().all(|p| p.stratum == "island"), "no pin is put forward as a claim");
        audit_honestly(&d, "connectivity", None);
        let k = con(&d);
        assert_eq!(k.score, 0.0, "{}", k.audit.detail);
        assert!(k.audit.status.starts_with("scored") && k.audit.strata.iter().any(|x| x["stratum"] == json!("pin") && x["unproven"] == json!(200) && x["value"] == json!(0.0)), "{:?}", k.audit.strata);
        assert!(k.audit.detail.contains("200 of 200 pins with a net show no join"), "{}", k.audit.detail);
        // the joins were measured on other copper than the copy now holds: they are evidence of nothing, for islands and pins alike
        write(&d, "detect/connect-graph.json", &graph);
        write(&d, "detect/attrib/measure.json", &json!({"copperSha": "0000", "pads": []}));
        let c = Ctx::load(&d);
        assert_eq!((c.island_proof(), c.pin_proof()), ((0, 2), (0, 200, 0)));
        assert!(population(&c, "connectivity").is_empty(), "nothing is put forward as a claim");
        let k = con(&d);
        assert_eq!(k.score, 0.0, "{}", k.audit.detail);
        assert!(k.audit.detail.contains("2 of 2 islands show no join") && k.audit.detail.contains("unproven"), "{}", k.audit.detail);
        // and with no audit at all the cap does not sit above what an audit could reach
        std::fs::remove_dir_all(d.join("audits/connectivity")).unwrap();
        std::fs::remove_file(d.join("detect/attribution.json")).unwrap();
        let k = con(&d);
        assert_eq!((k.audit.status.as_str(), k.score), ("none", 0.0));
        assert!(draw_sample(&d, "connectivity", None, "tester", DEFAULT_TARGET).unwrap_err().contains("nothing to audit"));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_planted_join_has_its_other_end_drawn_where_there_is_none() {
        let d = join_fixture("falsejoin");
        let c = Ctx::load(&d);
        let pop = population(&c, "connectivity");
        let f = c.face("top").unwrap();
        let cls = |q: (f64, f64)| photo_index(f, q).map(|i| f.cls[i]);
        let boxed = |pts: &[(f64, f64)]| pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));
        let item = |id: &str, kind: &str| { let j = c.joins().iter().find(|j| j.0.id == id).unwrap(); join_item(&j.0, j.1.iter().find(|x| x.kind == kind).unwrap()) };
        let pin = pop.iter().find(|p| p.id == "pin:R8.1").unwrap().clone();
        for (base, seeds) in [(item("tE", "pad"), 1..9u64), (item("zA", "via"), 1..9), (item("zA", "copper"), 1..9), (pin, 1..9)] { for k in seeds {
            let it = false_join(&c, &base, &mut Rng(k)).unwrap_or_else(|| panic!("no false {} join", base.class));
            // the claim, its words and its record are the real item's: only one end has moved, the other end for an island and the pad's own box for a pin
            assert_eq!((&it.id, &it.hash, it.claim()), (&base.id, &base.hash, base.claim()));
            let (moved, was, kept, same) = if base.stratum == "pin" { (&it.shapes[0].0, &base.shapes[0].0, &it.other, &base.other) } else { (&it.other[0].0, &base.other[0].0, &it.shapes, &base.shapes) };
            assert_ne!(moved, was);
            assert_eq!(kept, same);
            let b = boxed(moved);
            assert!((b.0 - it.at.0).abs().max((b.2 - it.at.0).abs()).max((b.1 - it.at.1).abs()).max((b.3 - it.at.1).abs()) < it.window / 2.0, "the false end is in the crop");
            // an island's crop stays on its join; a pin's is centred on its box, so the empty box sits where a real pad would
            if base.stratum == "pin" { assert!((it.at.0 - (b.0 + b.2) / 2.0).abs() < 1e-9 && (it.at.1 - (b.1 + b.3) / 2.0).abs() < 1e-9); } else { assert_eq!(it.at, base.at); }
            match base.class.as_str() {
                // the second copper lies wholly on the chip's body
                "copper" => assert!(moved.iter().all(|q| cls(*q) == Some(photomat::OBJECT)), "{moved:?}"),
                kind => {
                    // plain mask under all of it: no pad's metal, and for a via no via within 1.5 mm
                    for q in [(b.0, b.1), (b.2, b.1), (b.2, b.3), (b.0, b.3), ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0)] { assert_eq!(cls(q), Some(photomat::MASK)); }
                    if kind == "via" { assert!(((b.0 + b.2) / 2.0 - 110.0).hypot((b.1 + b.3) / 2.0 - 120.0) >= 1.5); }
                    for p in &c.parts { let pb = c.part_box(p); assert!(b.2 < pb.0 - 0.5 || b.0 > pb.2 + 0.5 || b.3 < pb.1 - 0.5 || b.1 > pb.3 + 0.5, "clear of {}", st(p, "ref")); }
                }
            }
        } }
        // what a draw plants, for an island or a pin, is such a claim, never another claim's words
        let mut r = Rng(7);
        let mut seen = HashSet::new();
        for _ in 0..60 {
            let Some(k) = control(&c, "connectivity", &pop, &mut r) else { continue };
            let real = pop.iter().find(|p| p.id == k.id && p.stratum == k.stratum).unwrap();
            assert_eq!(k.body, real.body);
            if k.stratum == "island" { assert_ne!(k.other[0].0, real.other[0].0); } else { assert_ne!(k.shapes[0].0, real.shapes[0].0); }
            seen.insert(k.stratum.clone());
        }
        assert_eq!(seen.len(), 2, "{seen:?}");
        std::fs::remove_dir_all(&d).ok();
    }

    /// The lower of the two scores is the lowest category, and each category is the lower of its two figures: the regrouping let nothing rise.
    fn regrouped(s: &Scores) {
        for c in &s.cats {
            let parts: Vec<f64> = [&c.photo, &c.bench].iter().filter_map(|p| p.as_ref().map(|p| p.score)).collect();
            assert!(!parts.is_empty(), "{} has no figure", c.category);
            assert_eq!(parts.iter().cloned().fold(100.0, f64::min), c.score, "{}: {}", c.category, inputs_text(s));
            assert_eq!(c.ceilings.iter().map(|x| floor1(x.value)).fold(100.0, f64::min), c.score, "{}", c.category);
        }
        assert_eq!(s.photo.min(s.bench), s.overall, "{}", table(s));
        assert_eq!(s.cats.iter().map(|c| c.score).fold(100.0, f64::min), s.overall);
    }

    #[test]
    fn the_two_scores_regroup_the_ceilings_and_the_lower_one_is_the_old_overall() {
        let d = fixture("regroup");
        two_level_photos(&d);
        let s = score_all(&d, DEFAULT_TARGET);
        regrouped(&s);
        // layers has no photo figure, the photo categories no bench figure, and the two mixed categories have both
        let of = |s: &Scores, c: &str| { let x = s.cats.iter().find(|x| x.category == c).unwrap(); (x.photo.clone().map(|p| p.score), x.bench.clone().map(|p| p.score)) };
        assert_eq!(of(&s, "layers").0, None);
        for c in ["identity", "blocks", "placement", "copper", "vias"] { assert_eq!(of(&s, c).1, None, "{c}"); assert!(of(&s, c).0.is_some(), "{c}"); }
        assert!(of(&s, "connectivity").0.is_some() && of(&s, "connectivity").1.is_some());
        for k in CATEGORIES { if k != "layers" { audit_honestly(&d, k, None); } }
        let s = score_all(&d, DEFAULT_TARGET);
        regrouped(&s);
        // every ceiling in the JSON says which score it belongs to
        let j = s.json();
        assert!(j["categories"]["layers"]["ceilings"].as_array().unwrap().iter().all(|c| c["kind"] == "bench"));
        assert!(j["categories"]["copper"]["ceilings"].as_array().unwrap().iter().all(|c| c["kind"] == "photo"));
        assert_eq!(j["photo"]["score"], json!(s.photo));
        assert_eq!(j["bench"]["score"], json!(s.bench));
        assert_eq!(j["bench"]["targetApplies"], json!(false));
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn unmarked_values_count_against_the_bench_score_and_never_against_the_photo_score() {
        let d = fixture("unmarked");
        let before = score_all(&d, DEFAULT_TARGET);
        let photo_before = before.cats.iter().find(|c| c.category == "values").unwrap().photo.clone().unwrap().score;
        // fifty unmarked capacitors whose value nobody could read
        let mut doc = read(&d, "parts.json");
        let more: Vec<Value> = (0..50).map(|i| json!({"ref": format!("CX{i}"), "tag": "MEASURED", "side": "top", "package": "C0402", "function": "decoupling", "value": "100nF", "marking": "", "pos_mm": [101.0 + i as f64 * 0.7, 135.0], "rot": 0, "snap": {"method": "pads"}, "fieldTags": {"identity": "SEEN", "value": "INFERRED"}, "tests": ["T-CX"]})).collect();
        doc["parts"].as_array_mut().unwrap().extend(more);
        write(&d, "parts.json", &doc);
        let s = score_all(&d, DEFAULT_TARGET);
        regrouped(&s);
        let v = s.cats.iter().find(|c| c.category == "values").unwrap();
        assert_eq!(v.photo.as_ref().unwrap().score, photo_before, "the marked parts' figure does not move");
        let b = v.bench.as_ref().unwrap();
        assert_eq!(b.score, 0.0, "{}", inputs_text(&s));
        assert!(b.limit.contains("0 of 50 unmarked parts") && b.raise.contains("bench plan"), "{} / {}", b.limit, b.raise);
        // a bench reading recorded on ten of them moves the figure by computation
        let mut doc = read(&d, "parts.json");
        for p in doc["parts"].as_array_mut().unwrap().iter_mut().filter(|p| st(p, "ref").starts_with("CX")).take(10) { p["fieldTags"]["value"] = json!("MEASURED"); }
        write(&d, "parts.json", &doc);
        let s = score_all(&d, DEFAULT_TARGET);
        assert_eq!(s.cats.iter().find(|c| c.category == "values").unwrap().bench.as_ref().unwrap().score, 20.0);
        // and an audit puts the two kinds in their own strata, so a marked part's verdict says nothing for an unmarked one
        let drawn = draw_sample(&d, "values", None, "tester", DEFAULT_TARGET).unwrap();
        assert!(drawn["counts"]["marked"].as_u64().unwrap() > 0 && drawn["counts"]["unmarked"].as_u64().unwrap() > 0, "{}", drawn["counts"]);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_run_that_never_routed_is_scored_for_what_it_states_and_a_bench_probe_moves_the_bench_score() {
        let d = fixture("unrouted");
        // two nets of 100 pins each: seen copper joins 60 pins of A into one group and 100 of B into another
        let refs: Vec<String> = arr(&read(&d, "parts.json")["parts"]).iter().map(|p| st(p, "ref").to_string()).collect();
        let pins = |r: &[String]| r.iter().map(|x| format!("{x}.1")).collect::<Vec<_>>();
        write(&d, "nets.json", &json!({"nets": [
            {"name": "A", "tag": "INFERRED", "tests": ["T-net-A"], "pins": pins(&refs[..100]), "copperGroups": [pins(&refs[..60])]},
            {"name": "B", "tag": "MEASURED", "pins": pins(&refs[100..]), "copperGroups": [pins(&refs[100..])]}]}));
        // no layer statement and no route: nothing earned, and the way out is named
        write(&d, "layers.json", &json!({}));
        let s = score_all(&d, DEFAULT_TARGET);
        let layers = s.cats.iter().find(|c| c.category == "layers").unwrap();
        assert_eq!(layers.score, 0.0);
        assert!(layers.raise.contains("layers state"), "{}", layers.raise);
        // stated UNKNOWN: still nothing, honestly, with the test that settles it
        std::fs::create_dir_all(d.join("bench")).unwrap();
        crate::layerstate::state(&d, &crate::layerstate::Ask { count: None, source: None, evidence: None, kind: None }).unwrap();
        let s = score_all(&d, DEFAULT_TARGET);
        regrouped(&s);
        let layers = s.cats.iter().find(|c| c.category == "layers").unwrap();
        assert_eq!(layers.score, 0.0);
        assert!(layers.limit.contains("UNKNOWN") && layers.raise.contains("T-layers"), "{} / {}", layers.limit, layers.raise);
        // a count from a document sits under the photo-only cap until that evidence is judged
        crate::layerstate::state(&d, &crate::layerstate::Ask { count: Some(4), source: Some("maker's page"), evidence: None, kind: None }).unwrap();
        assert_eq!(score(&d, "layers"), PHOTO_ONLY_CAP);
        // connectivity: 198 connections needed, 40 of them no photo shows; no ceiling asks for a routed copy
        let s = score_all(&d, DEFAULT_TARGET);
        regrouped(&s);
        let conn = s.cats.iter().find(|c| c.category == "connectivity").unwrap();
        assert!(!conn.ceilings.iter().any(|x| x.name == twoscores::KICAD_CLOSED));
        let seen = conn.ceilings.iter().find(|x| x.name == twoscores::CONNECTIONS).unwrap();
        assert_eq!(floor1(seen.value), floor1(100.0 * 158.0 / 198.0), "{}", seen.inputs);
        assert!(seen.inputs.contains("40 left unrouted as ratsnest"), "{}", seen.inputs);
        let pins_before = conn.ceilings.iter().find(|x| x.name == twoscores::PINS_PROVEN).unwrap().value;
        // ten recorded bench probes, each found joined
        let hidden = unrouted::connections(&arr(&read(&d, "nets.json")["nets"]));
        assert_eq!(hidden.len(), 40);
        let rec: serde_json::Map<String, Value> = hidden.iter().take(10).map(|h| (h.id.clone(), json!({"result": "joined", "a": h.from[0], "b": h.to[0], "to": h.to}))).collect();
        write(&d, crate::bench::MEASURED, &json!({"connections": rec}));
        let s = score_all(&d, DEFAULT_TARGET);
        regrouped(&s);
        let conn = s.cats.iter().find(|c| c.category == "connectivity").unwrap();
        assert_eq!(floor1(conn.ceilings.iter().find(|x| x.name == twoscores::CONNECTIONS).unwrap().value), floor1(100.0 * 168.0 / 198.0));
        assert!(conn.ceilings.iter().find(|x| x.name == twoscores::PINS_PROVEN).unwrap().value >= pins_before);
        // a record for a connection that is no longer in nets.json counts for nothing
        let mut nets = read(&d, "nets.json"); nets["nets"][0]["copperGroups"] = json!([pins(&refs[..100])]);
        write(&d, "nets.json", &nets);
        let s = score_all(&d, DEFAULT_TARGET);
        assert_eq!(floor1(s.cats.iter().find(|c| c.category == "connectivity").unwrap().ceilings.iter().find(|x| x.name == twoscores::CONNECTIONS).unwrap().value), 100.0);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_router_that_counted_more_than_the_pins_need_gives_no_negative_count() {
        let d = fixture("negative");
        // 198 connections needed by the pins; the router reports 245, because it also joined islands that reach no pin
        write(&d, "layers.json", &json!({"proposed": 2, "candidates": [{"layers": 2, "open": 0, "connections": 245, "kicadOpen": 0}], "planes": []}));
        let s = score_all(&d, DEFAULT_TARGET);
        let seen = s.cats.iter().find(|c| c.category == "connectivity").unwrap().ceilings.iter().find(|x| x.name == twoscores::CONNECTIONS).unwrap().clone();
        assert_eq!(seen.value, 0.0);
        assert!(seen.inputs.starts_with("0 of 198 connections") && !seen.inputs.contains(" -") && seen.inputs.contains("the router counted 245"), "{}", seen.inputs);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_sample_that_cannot_be_drawn_stops_holding_the_loop_and_earns_nothing() {
        let d = fixture("undrawable");
        // flat photos: no point is plain enough to carry a planted copper claim, so the copper draw refuses
        let e = draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap_err();
        assert!(e.contains("planted claims"), "{e}");
        let s = score_all(&d, DEFAULT_TARGET);
        let cu = s.cats.iter().find(|c| c.category == "copper").unwrap();
        assert!(!cu.held, "nobody can give this audit, so the loop does not wait for it");
        assert_eq!(cu.score, NO_AUDIT_CAP, "and it earns nothing: the cap stands");
        assert!(cu.limit.contains("no sample can be drawn") && cu.raise.contains("no audit can raise this"), "{} / {}", cu.limit, cu.raise);
        assert!(audit_files(&d).contains(&"audits/copper/undrawable.json".to_string()));
        // the items change (photos on which a claim can be planted): the record lapses and the audit is waited for again
        two_level_photos(&d);
        let s = score_all(&d, DEFAULT_TARGET);
        assert!(s.cats.iter().find(|c| c.category == "copper").unwrap().held);
        draw_sample(&d, "copper", None, "tester", DEFAULT_TARGET).unwrap();
        assert!(!d.join("audits/copper/undrawable.json").exists());
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_category_with_nothing_to_audit_does_not_hold_the_loop() {
        let d = fixture("novias");
        let mut c = read(&d, "copper.json"); c["vias"] = json!([]);
        write(&d, "copper.json", &c);
        write(&d, "detect/domes.json", &json!({"domes": []}));
        let s = score_all(&d, DEFAULT_TARGET);
        let vias = s.cats.iter().find(|c| c.category == "vias").unwrap();
        assert!(!vias.held, "a board with no vias has no via audit to wait for");
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn a_report_cannot_claim_more_than_was_earned() {
        let d = fixture("claim");
        let s = score_all(&d, DEFAULT_TARGET);
        assert!(false_claim("We are 95% confident in the copper.", &s).is_some());
        assert!(false_claim("Confidence: 90 percent overall.", &s).is_some());
        assert!(false_claim("The target is 90% confidence.", &s).is_none());
        assert!(false_claim("Confidence is 30% overall.", &s).is_none());
        std::fs::remove_dir_all(&d).ok();
    }
}