main
Caleb Publish 0.5.0 1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
//! `connect witness`: the datasheet as a second witness for the nets. For every pin of every chip that has a pins file
//! (`dspins`), what the datasheet says of the pin is held against what the copper graph shows at it: agree, disagree or not
//! visible. Nothing here changes copper or nets. A disagreement is a finding with a crop and a test, and each net's record in
//! net-evidence.json and nets.json gains the witness (`agree`, `disagree` or `none`).
//!
//! What "the copper shows" means here: a pin sits on a conductor when its pad has a solid contact with it (attribution's tier 1),
//! and two conductors are one when verified vias join them (a link of tier 2). Everything else (an unsettled pad edge, one
//! unconfirmed via, the pin map) is not seen, so it can neither agree nor disagree. What says a conductor is ground or a supply
//! is always another pin on it, never the pin under test: a ground pin of any datasheet, or a pin somebody put on a net with a
//! ground or supply name.
//!
//! Absence proves little on a photo: an inner layer may hold the capacitor's other end or the tie the datasheet asks for. So a
//! missing part or join is `not visible`, and `disagree` is kept for what the copper positively shows wrong: a ground pin on
//! supply copper, a supply or an output on ground copper, two outputs on one conductor, the two ends of a capacitor the
//! datasheet puts between two pins on one conductor, a pull-up that goes to ground, a no-connect pin with copper to other pins.
//!
//! The same checks run a second time on the net list alone (`World::of_list`: a pin "sits" on its net in nets.json, and a net
//! is one conductor). That says whether the nets as stated, seen or inferred, follow the datasheet. Here the list is a whole
//! claim, so two pins the datasheet ties that the list keeps on two nets do disagree. This verdict (`netList`) is kept apart
//! from the copper's: it is circular wherever somebody typed the nets from the same datasheet, and it is where a typed net
//! that does not follow the datasheet shows up.
use crate::dspins::{self, Pin, Sheet};
use crate::{items, sch};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

pub const FILE: &str = "detect/witness.json";
pub const CROPS: &str = "evidence/witness";

const AGREE: &str = "agree";
const DISAGREE: &str = "disagree";
const UNSEEN: &str = "not visible";

/// The copper graph as the witness reads it.
#[derive(Default)]
pub struct World {
    /// pin -> the conductor (after verified vias) its pad sits on solidly
    comp: BTreeMap<String, usize>,
    /// pins whose pad edge does not settle what it touches
    weak: BTreeSet<String>,
    on: BTreeMap<usize, BTreeSet<String>>,
    /// pin -> the net somebody stated for it in the pin map
    stated: BTreeMap<String, String>,
    kind: BTreeMap<String, sch::Kind>,
    /// pin -> (datasheet type, name)
    ds: BTreeMap<String, (String, String)>,
    /// pin -> the node its pad sits on, and the verified-via links between nodes with their via ids: for saying how two pins are joined
    node: BTreeMap<String, usize>,
    adj: BTreeMap<usize, Vec<(usize, Vec<String>)>>,
    /// this world is the net list, not the copper
    list: bool,
    /// pin -> the node an unsettled pad edge touches
    weak_node: BTreeMap<String, usize>,
}

fn root(p: &mut [usize], x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let t = p[y]; p[y] = r; y = t; } r }
fn part_of(pin: &str) -> &str { pin.rsplit_once('.').map(|(r, _)| r).unwrap_or(pin) }
fn list(v: &[String]) -> String { if v.len() > 6 { format!("{} and {} more", v[..6].join(", "), v.len() - 6) } else { v.join(", ") } }

impl World {
    /// `contacts`: (pin, node, solid, the net the pin map states). `links`: pairs of nodes joined by verified vias, with the via ids.
    pub fn new(contacts: &[(String, usize, bool, Option<String>)], links: &[(usize, usize, Vec<String>)], kinds: &[(String, sch::Kind)], sheets: &[&Sheet]) -> World {
        let n = contacts.iter().map(|c| c.1).chain(links.iter().flat_map(|l| [l.0, l.1])).max().map(|m| m + 1).unwrap_or(0);
        let mut uf: Vec<usize> = (0..n).collect();
        for (a, b, _) in links { let (x, y) = (root(&mut uf, *a), root(&mut uf, *b)); if x != y { uf[x] = y; } }
        // a pad that sits solidly on two conductors joins them
        let mut first: BTreeMap<&str, usize> = BTreeMap::new();
        for (pin, node, solid, _) in contacts { if !*solid || pin.starts_with("PHOTO1.") { continue; } match first.get(pin.as_str()) { Some(&o) => { let (x, y) = (root(&mut uf, o), root(&mut uf, *node)); if x != y { uf[x] = y; } } None => { first.insert(pin, *node); } } }
        let mut w = World::default();
        for (pin, node, solid, stated) in contacts {
            if pin.starts_with("PHOTO1.") { continue; }
            if let Some(s) = stated { w.stated.insert(pin.clone(), s.clone()); }
            if *solid { let c = root(&mut uf, *node); w.comp.insert(pin.clone(), c); w.on.entry(c).or_default().insert(pin.clone()); w.node.entry(pin.clone()).or_insert(*node); } else { w.weak.insert(pin.clone()); w.weak_node.entry(pin.clone()).or_insert(*node); }
        }
        for (a, b, ids) in links { w.adj.entry(*a).or_default().push((*b, ids.clone())); w.adj.entry(*b).or_default().push((*a, ids.clone())); }
        w.kind = kinds.iter().cloned().collect();
        for s in sheets { for p in &s.pins { w.ds.insert(format!("{}.{}", s.part, p.pad), (p.ty.clone(), p.name.clone())); } }
        w
    }

    /// The net list as a world: every pin of a net sits on that net, and what says a net is ground or a supply is its name
    /// (for pins no datasheet types) or the datasheet pins on it.
    pub fn of_list(nets: &Value, kinds: &[(String, sch::Kind)], sheets: &[&Sheet]) -> World {
        let mut contacts = vec![];
        for (k, n) in nets["nets"].as_array().into_iter().flatten().enumerate() { 
            // a name a datasheet pin gave the net says nothing more than that pin does: it is no second voice
            let name = n["name"].as_str().filter(|_| n["nameTag"] != json!("DATASHEET")).map(str::to_string);
            for p in n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) { contacts.push((p.to_string(), k, true, name.clone())); }
        }
        let mut w = World::new(&contacts, &[], kinds, sheets);
        w.list = true; w
    }
    /// How the copper joins two pins: the vias of each hop from one pin's copper to the other's. Empty when they sit on the same copper.
    pub fn hops(&self, a: &str, b: &str) -> Vec<Vec<String>> {
        let (Some(&from), Some(&to)) = (self.node.get(a), self.node.get(b)) else { return vec![] };
        let mut prev: BTreeMap<usize, (usize, Vec<String>)> = BTreeMap::new();
        let mut queue = std::collections::VecDeque::from([from]);
        while let Some(k) = queue.pop_front() {
            if k == to { break; }
            for (m, ids) in self.adj.get(&k).into_iter().flatten() { if *m != from && !prev.contains_key(m) { prev.insert(*m, (k, ids.clone())); queue.push_back(*m); } }
        }
        let (mut out, mut k) = (vec![], to);
        while let Some((p, ids)) = prev.get(&k) { out.push(ids.clone()); k = *p; }
        out.reverse(); out
    }
    fn joined(&self, pin: &str, other: &str) -> String {
        if self.list { return format!("{other} and {pin} are on one net in nets.json ({})", self.stated.get(pin).cloned().unwrap_or_default()); }
        let h = self.hops(pin, other);
        if h.is_empty() { return format!("{other} sits on the same copper as {pin}"); }
        format!("{other} is joined to {pin} through {}", h.iter().map(|ids| format!("{} verified via(s) ({}{})", ids.len(), ids.iter().take(4).cloned().collect::<Vec<_>>().join(", "), if ids.len() > 4 { ", ..." } else { "" })).collect::<Vec<_>>().join(", then "))
    }
    fn others(&self, c: usize, except: &str) -> Vec<String> { self.on.get(&c).into_iter().flatten().filter(|q| q.as_str() != except).cloned().collect() }
    fn ty(&self, pin: &str) -> &str { self.ds.get(pin).map(|d| d.0.as_str()).unwrap_or("") }
    fn tell(&self, pin: &str) -> String { match self.ds.get(pin) { Some((t, n)) => format!("{pin} ({n}, {})", t.replace('_', " ")), None => match self.stated.get(pin) { Some(n) => format!("{pin} (net {n})"), None => pin.to_string() } } }
    /// Pins on this conductor, other than `except`, that say it is ground: a datasheet ground pin, or a pin stated on a ground net.
    fn ground(&self, c: usize, except: &str) -> Vec<String> {
        self.others(c, except).into_iter().filter(|q| self.ty(q) == "ground" || (self.ty(q).is_empty() && self.stated.get(q).is_some_and(|n| sch::rail_of(n) == sch::Rail::Ground))).collect()
    }
    /// Pins that say it is a supply: a datasheet supply pin, or a pin with no datasheet type stated on a net with a supply name.
    fn supply(&self, c: usize, except: &str) -> Vec<String> {
        self.others(c, except).into_iter().filter(|q| matches!(self.ty(q), "power_in" | "power_out") || (self.ty(q).is_empty() && self.stated.get(q).is_some_and(|n| sch::rail_of(n) == sch::Rail::Supply))).collect()
    }
    /// Datasheet pins that drive: outputs and supply outputs.
    fn drivers(&self, c: usize, except: &str) -> Vec<String> { self.others(c, except).into_iter().filter(|q| matches!(self.ty(q), "output" | "power_out")).collect() }
    /// Two-pad parts of these kinds with a pad on this conductor: the part, and the conductor its other pad sits on when that is seen.
    fn across(&self, c: usize, kinds: &[sch::Kind]) -> Vec<(String, Option<usize>)> {
        let mut out = vec![];
        for q in self.on.get(&c).into_iter().flatten() {
            let (r, pad) = (part_of(q), q.rsplit_once('.').map(|x| x.1).unwrap_or(""));
            if !self.kind.get(r).is_some_and(|k| kinds.contains(k)) || !matches!(pad, "1" | "2") { continue; }
            let other = format!("{r}.{}", if pad == "1" { "2" } else { "1" });
            if out.iter().any(|(p, _): &(String, Option<usize>)| p == r) { continue; }
            out.push((r.to_string(), self.comp.get(&other).copied()));
        }
        out
    }
}

/// What the copper shows joined, with no names in it: the pins on each conductor (solid contacts, conductors joined by verified
/// vias being one), for every conductor that holds two pins or more. Empty before attribution has run.
pub fn conductors(flow: &Path) -> Vec<Vec<String>> {
    let Ok(graph) = items::read_json(&flow.join("detect/connect-graph.json")) else { return vec![] };
    let contacts: Vec<(String, usize, bool, Option<String>)> = graph["contacts"].as_array().into_iter().flatten().map(|c| (c["pin"].as_str().unwrap_or("").to_string(), c["node"].as_u64().unwrap_or(0) as usize, c["tier"].as_u64() == Some(1), None)).collect();
    let links: Vec<(usize, usize, Vec<String>)> = graph["links"].as_array().into_iter().flatten().filter(|l| l["tier"].as_u64().unwrap_or(9) <= 2).map(|l| (l["pieces"][0].as_u64().unwrap_or(0) as usize, l["pieces"][1].as_u64().unwrap_or(0) as usize, vec![])).collect();
    World::new(&contacts, &links, &[], &[]).on.into_values().filter(|v| v.len() >= 2).map(|v| v.into_iter().collect()).collect()
}

/// Copper the reader could not resolve at a chip: one piece that touches several of its pins without settling any, among them
/// pins the datasheet says cannot share a net. Returns (the pins on it, why they cannot be one net) for each such piece.
pub fn unresolved(w: &World, s: &Sheet) -> Vec<(Vec<String>, String)> {
    let mut on: BTreeMap<usize, Vec<&Pin>> = BTreeMap::new();
    for p in &s.pins { let pin = format!("{}.{}", s.part, p.pad); if !w.comp.contains_key(&pin) { if let Some(n) = w.weak_node.get(&pin) { on.entry(*n).or_default().push(p); } } }
    let mut out = vec![];
    for (_, pins) in on {
        let names = |tys: &[&str]| -> Vec<String> { pins.iter().filter(|p| tys.contains(&p.ty.as_str())).map(|p| p.name.clone()).collect::<BTreeSet<_>>().into_iter().collect() };
        let (g, sup, o) = (names(&["ground"]), names(&["power_in", "power_out"]), names(&["output"]));
        let why = if !g.is_empty() && !sup.is_empty() { format!("ground ({}) and supply ({})", list(&g), list(&sup)) } else if !sup.is_empty() && !o.is_empty() { format!("supply ({}) and output ({})", list(&sup), list(&o)) } else if !g.is_empty() && !o.is_empty() { format!("ground ({}) and output ({})", list(&g), list(&o)) } else if o.len() >= 2 { format!("{} outputs ({})", o.len(), list(&o)) } else if sup.len() >= 2 { format!("{} different supplies ({})", sup.len(), list(&sup)) } else { continue };
        if pins.len() < 3 { continue; }
        out.push((pins.iter().map(|p| format!("{}.{}", s.part, p.pad)).collect(), why));
    }
    out
}

#[derive(Clone, Debug)]
pub struct Check { pub what: String, pub result: &'static str, pub why: String, pub with: Vec<String> }

fn chk(what: &str, result: &'static str, why: String, with: Vec<String>) -> Check { Check { what: what.to_string(), result, why, with } }

/// What the pin's electrical type says of the copper it sits on.
fn by_type(w: &World, pin: &str, p: &Pin, c: usize) -> Option<Check> {
    let (g, s, d, all) = (w.ground(c, pin), w.supply(c, pin), w.drivers(c, pin), w.others(c, pin));
    let tells = |v: &[String]| list(&v.iter().map(|q| w.tell(q)).collect::<Vec<_>>());
    Some(match p.ty.as_str() {
        "ground" => {
            let bad: Vec<String> = s.iter().chain(d.iter()).cloned().collect::<BTreeSet<_>>().into_iter().collect();
            if !bad.is_empty() { chk("type", DISAGREE, format!("a ground pin on one piece of copper with {}{}", tells(&bad), if g.is_empty() { String::new() } else { format!(", and with ground pins too ({}): this copper joins ground to a supply or an output", list(&g)) }), bad) }
            else if !g.is_empty() { chk("type", AGREE, format!("on the ground conductor: one piece of copper with {}", tells(&g)), g) }
            else { chk("type", UNSEEN, "the copper at this ground pin reaches no other pin that says it is ground".into(), vec![]) }
        }
        "power_in" | "power_out" => {
            if !g.is_empty() { return Some(chk("type", DISAGREE, format!("a supply pin on ground copper: one piece with {}", tells(&g)), g)); }
            if p.ty == "power_out" && !d.is_empty() { return Some(chk("type", DISAGREE, format!("a supply output on one piece of copper with another driving pin: {}", tells(&d)), d)); }
            // a capacitor from this copper to ground copper: the decoupling every supply pin's datasheet asks for
            let caps: Vec<String> = w.across(c, &[sch::Kind::C]).into_iter().filter(|(_, o)| o.is_some_and(|o| o != c && !w.ground(o, "").is_empty())).map(|(r, _)| r).collect();
            if !caps.is_empty() { chk("type", AGREE, format!("a capacitor from this supply pin's copper to ground copper: {}", list(&caps)), caps) }
            else { chk("type", UNSEEN, "no capacitor is seen from this supply pin's copper to ground copper".into(), vec![]) }
        }
        "output" | "open_drain" => {
            let other_out: Vec<String> = d.iter().filter(|q| !(p.ty == "open_drain" && w.ty(q) == "open_drain")).cloned().collect();
            if !g.is_empty() { chk("type", DISAGREE, format!("an output on ground copper: one piece with {}", tells(&g)), g) }
            else if !s.is_empty() && p.ty == "output" { chk("type", DISAGREE, format!("an output on supply copper: one piece with {}", tells(&s)), s) }
            else if !other_out.is_empty() { chk("type", DISAGREE, format!("two driving pins on one piece of copper: with {}", tells(&other_out)), other_out) }
            else if !all.is_empty() { chk("type", AGREE, format!("the copper leaves this output for {} and meets no ground, supply or second output", list(&all)), vec![]) }
            else { chk("type", UNSEEN, "the copper at this output reaches no other pin".into(), vec![]) }
        }
        "no_connect" => if all.is_empty() { chk("type", AGREE, "nothing else sits on the copper at this unused pin".into(), vec![]) } else { chk("type", DISAGREE, format!("a pin the datasheet leaves unconnected is on one piece of copper with {}", list(&all)), all) },
        // an input may be strapped to ground or a supply, a passive or bidirectional pin may go anywhere: the type alone says nothing to check
        _ => return None,
    })
}

/// One `expects` entry held against the copper.
fn by_expect(w: &World, s: &Sheet, pin: &str, c: usize, x: &Value) -> Check {
    let kind = x["kind"].as_str().unwrap_or("other");
    let what = format!("expects {kind}{}", x["pin"].as_str().map(|p| format!(" {p}")).unwrap_or_default());
    // the other pin of a *_to_pin expectation: the conductors its pads sit on, as far as seen
    let other_pads: Vec<String> = x["pin"].as_str().map(|p| s.pads_named(p).into_iter().map(|pad| format!("{}.{pad}", s.part)).collect()).unwrap_or_default();
    let other_comps: BTreeSet<usize> = other_pads.iter().filter_map(|q| w.comp.get(q).copied()).collect();
    let other_ground = other_pads.iter().any(|q| w.ty(q) == "ground");
    let is_ground = |o: usize| !w.ground(o, "").is_empty();
    let two = |kinds: &[sch::Kind]| w.across(c, kinds);
    let name = |k: &[sch::Kind]| if k.contains(&sch::Kind::C) { "capacitor" } else if k.contains(&sch::Kind::R) { "resistor" } else { "part" };
    // a two-pad part from this copper to copper that passes `to`
    let bridge = |kinds: &[sch::Kind], to: &dyn Fn(usize) -> bool, to_what: &str| -> Check {
        let parts = two(kinds);
        let hit: Vec<String> = parts.iter().filter(|(_, o)| o.is_some_and(|o| o != c && to(o))).map(|(r, _)| r.clone()).collect();
        if !hit.is_empty() { return chk(&what, AGREE, format!("{} from this pin's copper to {to_what}", list(&hit)), hit); }
        if parts.is_empty() { return chk(&what, UNSEEN, format!("no {} is seen on this pin's copper", name(kinds)), vec![]); }
        let blind: Vec<String> = parts.iter().filter(|(_, o)| o.is_none()).map(|(r, _)| r.clone()).collect();
        chk(&what, UNSEEN, if blind.is_empty() { format!("the {}s on this pin's copper ({}) go elsewhere as far as the copper shows", name(kinds), list(&parts.iter().map(|p| p.0.clone()).collect::<Vec<_>>())) } else { format!("the other end of {} is not seen joined to anything", list(&blind)) }, vec![])
    };
    match kind {
        "capacitor_to_ground" => bridge(&[sch::Kind::C], &is_ground, "ground copper"),
        "resistor_to_ground" => bridge(&[sch::Kind::R], &is_ground, "ground copper"),
        "capacitor_to_pin" | "resistor_to_pin" => {
            let kinds = if kind == "capacitor_to_pin" { [sch::Kind::C] } else { [sch::Kind::R] };
            if other_comps.contains(&c) {
                if x["orTied"] == json!(true) { return chk(&what, AGREE, format!("tied to {} on one piece of copper, which the datasheet allows", list(&other_pads)), other_pads); }
                return chk(&what, DISAGREE, format!("this pin and {} are one piece of copper, so the {} the datasheet puts between them would be shorted", list(&other_pads), name(&kinds)), other_pads);
            }
            // a ground pin as the other end: any ground copper will do, since the grounds are tied
            let to = |o: usize| other_comps.contains(&o) || (other_ground && is_ground(o));
            bridge(&kinds, &to, &format!("the copper of {}", x["pin"].as_str().unwrap_or("")))
        }
        "tied_to_pin" => {
            if other_comps.contains(&c) { chk(&what, AGREE, format!("one piece of copper with {}", list(&other_pads)), other_pads) }
            else if other_comps.is_empty() { chk(&what, UNSEEN, format!("the copper at {} is not seen", x["pin"].as_str().unwrap_or("")), vec![]) }
            // the net list is a whole claim: two nets are two nets
            else if w.list { chk(&what, DISAGREE, format!("the datasheet ties this pin to {}, and nets.json has them on different nets ({})", list(&other_pads), list(&other_pads.iter().filter_map(|q| w.stated.get(q).cloned()).collect::<Vec<_>>())), other_pads) }
            else { chk(&what, UNSEEN, format!("not joined to {} in what the photos show: an inner layer or a hidden track may join them", list(&other_pads)), vec![]) }
        }
        "tied_to_ground" => {
            let (g, sup) = (w.ground(c, pin), w.supply(c, pin));
            if !g.is_empty() { chk(&what, AGREE, format!("one piece of copper with {}", list(&g)), g) } else if !sup.is_empty() { chk(&what, DISAGREE, format!("on supply copper with {}", list(&sup)), sup) } else { chk(&what, UNSEEN, "the copper at this pin reaches no pin that says it is ground".into(), vec![]) }
        }
        "pull_up" => {
            let parts = two(&[sch::Kind::R]);
            let up: Vec<String> = parts.iter().filter(|(_, o)| o.is_some_and(|o| o != c && !is_ground(o) && !w.supply(o, "").is_empty())).map(|(r, _)| r.clone()).collect();
            let down: Vec<String> = parts.iter().filter(|(_, o)| o.is_some_and(|o| o != c && is_ground(o))).map(|(r, _)| r.clone()).collect();
            if !up.is_empty() { chk(&what, AGREE, format!("{} from this pin's copper to supply copper", list(&up)), up) }
            else if !down.is_empty() && down.len() == parts.len() { chk(&what, DISAGREE, format!("the only resistor on this pin's copper ({}) goes to ground copper: pulled down, not up", list(&down)), down) }
            else { chk(&what, UNSEEN, if parts.is_empty() { "no resistor is seen on this pin's copper".to_string() } else { format!("{} is on this pin's copper, but nothing says its other end is a supply", list(&parts.iter().map(|p| p.0.clone()).collect::<Vec<_>>())) }, vec![]) }
        }
        "crystal" | "inductor" => {
            let k = if kind == "crystal" { sch::Kind::Xtal } else { sch::Kind::L };
            let hit: Vec<String> = w.others(c, pin).into_iter().filter(|q| w.kind.get(part_of(q)) == Some(&k)).map(|q| part_of(&q).to_string()).collect::<BTreeSet<_>>().into_iter().collect();
            if hit.is_empty() { chk(&what, UNSEEN, format!("no {kind} is seen on this pin's copper"), vec![]) } else { chk(&what, AGREE, format!("{} on this pin's copper", list(&hit)), hit) }
        }
        "not_connected" => { let all = w.others(c, pin); if all.is_empty() { chk(&what, AGREE, "nothing else sits on this pin's copper".into(), vec![]) } else { chk(&what, DISAGREE, format!("one piece of copper with {}", list(&all)), all) } }
        _ => chk(&what, UNSEEN, format!("not checked by the program: {}", x["note"].as_str().unwrap_or("no note")), vec![]),
    }
}

/// Every check of one pin, and the pin's verdict: disagree if any check disagrees, agree if one agrees, else not visible.
pub fn judge(w: &World, s: &Sheet, p: &Pin) -> (&'static str, &'static str, Vec<Check>) {
    let pin = format!("{}.{}", s.part, p.pad);
    let Some(&c) = w.comp.get(&pin) else {
        let weak = w.weak.contains(&pin);
        if w.list { return (UNSEEN, "none", vec![chk("net", UNSEEN, "this pin is on no net in nets.json".into(), vec![])]); }
        return (UNSEEN, if weak { "unsettled" } else { "none" }, vec![chk("copper", UNSEEN, if weak { "the pad's edge does not settle which copper it touches".into() } else { "no copper is seen joined to this pad".into() }, vec![])]);
    };
    let mut checks: Vec<Check> = by_type(w, &pin, p, c).into_iter().collect();
    // pads of one name on one part (VDD, VSS) are tied inside or outside the chip: seen on one conductor, that agrees
    if matches!(p.ty.as_str(), "power_in" | "power_out" | "ground") {
        let twins: Vec<String> = s.pins.iter().filter(|q| q.pad != p.pad && q.name == p.name).map(|q| format!("{}.{}", s.part, q.pad)).filter(|q| w.comp.get(q) == Some(&c)).collect();
        if !twins.is_empty() { checks.push(chk("same name", AGREE, format!("one piece of copper with the other {} pad(s): {}", p.name, list(&twins)), twins)); }
    }
    for x in &p.expects { checks.push(by_expect(w, s, &pin, c, x)); }
    if checks.is_empty() { checks.push(chk("type", UNSEEN, format!("the datasheet states nothing of this {} pin that copper can show", p.ty.replace('_', " ")), vec![])); }
    // a disagreement says how the copper joins the pin to the first pin it disagrees with, so a person knows where to look
    for k in checks.iter_mut().filter(|k| k.result == DISAGREE) { if let Some(o) = k.with.first() { k.why += &format!(". {}", w.joined(&pin, o)); } }
    // on the net list the same sentences speak of nets, not of copper
    if w.list { for k in checks.iter_mut() { k.why = k.why.replace("one piece of copper", "one net").replace("one piece with", "one net with").replace("is seen", "is listed").replace("as far as the copper shows", "as nets.json has it").replace("the copper", "the net").replace("copper", "net"); } }
    let verdict = if checks.iter().any(|k| k.result == DISAGREE) { DISAGREE } else if checks.iter().any(|k| k.result == AGREE) { AGREE } else { UNSEEN };
    (verdict, "solid", checks)
}

fn test_for(id: &str, settles: &str, pin: &str, pname: &str, why: &str, copper: bool) -> Value {
    if !copper {
        return json!({"id": id, "settles": [settles], "generated": true,
            "method": format!("Datasheet against the net list at {pin} ({pname}): {why}. With the board unpowered, measure continuity from {pin} to the pins and parts the datasheet puts there, and across each part the finding names"),
            "equipment": "DMM in continuity mode, fine probes",
            "expected": "what the datasheet's application shows at this pin",
            "ifNot": "if the board follows the datasheet, correct the pins of this net in nets.json (or the copper that moved one) and run `connect attribute` again; if it does not, the part is not the one in parts.json or the board departs from the typical application: say so in the net's `why`"});
    }
    json!({"id": id, "settles": [settles], "generated": true,
        "method": format!("Datasheet against copper at {pin} ({pname}): {why}. With the board unpowered, measure continuity from {pin} to each pin named in the finding, and look at the copper there under magnification"),
        "equipment": "DMM in continuity mode, fine probes, loupe or microscope",
        "expected": "what the datasheet says: no continuity between this pin and the pins the finding names (the copper was misread), unless the board really ties them",
        "ifNot": "if they are joined on the board, the part's identity, package or pin 1 is wrong in parts.json: check the marking and the orientation mark; if they are not, correct the copper (an island that bridges two pads) and run `connect attribute` again"})
}

/// Read the run, judge every pin, write the record. With `crops`, a crop per finding is drawn from the board photos (the photo
/// is drawn on, never measured). Returns the summary.
pub fn run(flow: &Path, crops: bool) -> Result<Value, String> {
    let st = dspins::status(flow);
    let sheets: Vec<&Sheet> = st.sheets.values().collect();
    let graph = items::read_json(&flow.join("detect/connect-graph.json")).map_err(|e| format!("{e}: run `connect attribute` first"))?;
    let attr = items::read_json(&flow.join("detect/attribution.json")).unwrap_or(Value::Null);
    let parts = items::read_json(&flow.join("parts.json"))?;
    let mut nets = items::read_json(&flow.join("nets.json"))?;
    let contacts: Vec<(String, usize, bool, Option<String>)> = graph["contacts"].as_array().into_iter().flatten().map(|c| (c["pin"].as_str().unwrap_or("").to_string(), c["node"].as_u64().unwrap_or(0) as usize, c["tier"].as_u64() == Some(1), c["inferred"].as_str().map(str::to_string))).collect();
    let links: Vec<(usize, usize, Vec<String>)> = graph["links"].as_array().into_iter().flatten().filter(|l| l["tier"].as_u64().unwrap_or(9) <= 2).map(|l| (l["pieces"][0].as_u64().unwrap_or(0) as usize, l["pieces"][1].as_u64().unwrap_or(0) as usize, l["ids"].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect())).collect();
    let kinds: Vec<(String, sch::Kind)> = parts["parts"].as_array().into_iter().flatten().map(|p| { let r = p["ref"].as_str().unwrap_or("").to_string(); let k = sch::kind_of(&r, p["footprint"].as_str().unwrap_or("")); (r, k) }).collect();
    let w = World::new(&contacts, &links, &kinds, &sheets);
    let side_of: BTreeMap<String, String> = parts["parts"].as_array().into_iter().flatten().map(|p| (p["ref"].as_str().unwrap_or("").to_string(), p["side"].as_str().unwrap_or("top").to_string())).collect();
    let mut at: BTreeMap<String, (f64, f64)> = BTreeMap::new();
    for p in attr["padStates"].as_array().into_iter().flatten() { if let (Some(pin), Some(x), Some(y)) = (p["pin"].as_str(), p["x"].as_f64(), p["y"].as_f64()) { at.entry(pin.to_string()).or_insert((x, y)); } }
    let net_of: BTreeMap<String, String> = nets["nets"].as_array().into_iter().flatten().flat_map(|n| { let name = n["name"].as_str().unwrap_or("").to_string(); n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).map(move |p| (p, name.clone())).collect::<Vec<_>>() }).collect();

    let lw = World::of_list(&nets, &kinds, &sheets);
    // the net list as it was stated, before the copper moved pins: where a datasheet expectation held as stated and no longer
    // holds, the copper and the stated net cannot both be right at that part, and the datasheet says which pin to look beside
    let stated_nets = json!({"nets": nets["nets"].as_array().into_iter().flatten().map(|n| json!({"name": n["name"], "nameTag": n["nameTag"], "pins": if n["inferredPins"].is_array() { n["inferredPins"].clone() } else if n["fromCopper"] == json!(true) { json!([]) } else { n["pins"].clone() }})).collect::<Vec<_>>()});
    let sw = World::of_list(&stated_nets, &kinds, &sheets);
    let moved: Vec<(String, String, String)> = graph["pinsMoved"].as_array().into_iter().flatten().filter_map(|m| Some((m["pin"].as_str()?.to_string(), m["inferred"].as_str()?.to_string(), m["copper"].as_str()?.to_string()))).collect();
    let (mut chips, mut findings, mut tests) = (vec![], vec![], vec![]);
    let mut per_net: BTreeMap<String, Vec<Value>> = BTreeMap::new();
    let zero = || -> BTreeMap<&'static str, usize> { [(AGREE, 0), (DISAGREE, 0), (UNSEEN, 0)].into_iter().collect() };
    let (mut tally, mut ltally) = (zero(), zero());
    let said = |v: &'static str| if v == UNSEEN { "not stated" } else { v };
    let rows_of = |checks: &[Check]| checks.iter().map(|k| json!({"check": k.what, "result": k.result, "why": k.why, "with": k.with})).collect::<Vec<_>>();
    for s in &sheets {
        let mut rows = vec![];
        let (mut t, mut lt) = (zero(), zero());
        for p in &s.pins {
            let pin = format!("{}.{}", s.part, p.pad);
            let (verdict, copper, checks) = judge(&w, s, p);
            let (mut lv, _, mut lchecks) = judge(&lw, s, p);
            let (_, _, schecks) = judge(&sw, s, p);
            for k in schecks.iter().filter(|k| k.result == AGREE) {
                if lchecks.iter().any(|l| l.what == k.what && l.result == AGREE) { continue; }
                let hit: Vec<&(String, String, String)> = moved.iter().filter(|m| m.0 == pin || k.with.iter().any(|x| *x == m.0 || x.as_str() == part_of(&m.0))).collect();
                if hit.is_empty() { continue; }
                let now = lchecks.iter().find(|l| l.what == k.what).map(|l| l.why.clone()).unwrap_or_default();
                lchecks.push(chk("moved by the copper", DISAGREE, format!("{}: as the net list was stated this held ({}); the copper moved {} and it no longer does ({now}). Either the copper there is misread, or the part is not the one the datasheet means and that one sits elsewhere, usually beside the pin: look at both places", k.what, k.why, hit.iter().map(|m| format!("{} from {} to {}", m.0, m.1, m.2)).collect::<Vec<_>>().join(", ")), hit.iter().map(|m| m.0.clone()).collect()));
                lv = DISAGREE;
            }
            *t.get_mut(verdict).unwrap() += 1; *tally.get_mut(verdict).unwrap() += 1;
            *lt.get_mut(lv).unwrap() += 1; *ltally.get_mut(lv).unwrap() += 1;
            let net = net_of.get(&pin).cloned();
            let place = at.get(&pin).map(|a| json!([(a.0 * 100.0).round() / 100.0, (a.1 * 100.0).round() / 100.0]));
            if let Some(n) = &net { per_net.entry(n.clone()).or_default().push(json!({"pin": pin, "name": p.name, "type": p.ty, "witness": verdict, "netList": said(lv)})); }
            // one finding per pin: what the copper shows wrong, else what the net list states against the datasheet
            let (which, src) = if verdict == DISAGREE { ("copper", &checks) } else if lv == DISAGREE { ("net list", &lchecks) } else { ("", &checks) };
            if !which.is_empty() {
                let bad: Vec<&Check> = src.iter().filter(|k| k.result == DISAGREE).collect();
                let why = bad.iter().map(|k| k.why.clone()).collect::<Vec<_>>().join("; ");
                let with: Vec<String> = bad.iter().flat_map(|k| k.with.iter().cloned()).collect::<BTreeSet<_>>().into_iter().collect();
                let vias: Vec<String> = if which == "copper" { with.first().map(|o| w.hops(&pin, o).into_iter().flatten().collect()).unwrap_or_default() } else { vec![] };
                let stem = format!("{}{}", if which == "copper" { "" } else { "list-" }, pin.replace('.', "_"));
                let (id, tid) = (format!("W-{stem}"), format!("T-witness-{stem}"));
                let lead = if which == "copper" { "" } else { "as nets.json has it, " };
                findings.push(json!({"id": id, "kind": which, "pin": pin, "name": p.name, "type": p.ty, "net": net, "finding": format!("{pin} ({}, {}): {lead}{why}", p.name, p.ty.replace('_', " ")), "with": with, "vias": vias, "source": s.cite, "atMm": place, "side": side_of.get(&s.part), "test": tid, "crop": format!("{CROPS}/{stem}.jpg")}));
                tests.push(test_for(&tid, &net.as_ref().map(|n| format!("net:{n}")).unwrap_or_else(|| s.part.clone()), &pin, &p.name, &why, which == "copper"));
            }
            rows.push(json!({"pin": pin, "name": p.name, "type": p.ty, "copper": copper, "net": net, "witness": verdict, "atMm": place, "checks": rows_of(&checks), "netList": {"witness": said(lv), "checks": rows_of(&lchecks)}}));
        }
        // one piece of copper over many of the chip's pins that cannot be one net: the reader did not resolve it, and says so
        let mut blind = vec![];
        for (k, (pins, why)) in unresolved(&w, s).into_iter().enumerate() {
            let stem = format!("unresolved-{}{}", s.part, if k == 0 { String::new() } else { format!("-{}", k + 1) });
            let text = format!("{}: the copper read at this chip is one piece that touches {} of its pins ({}) without settling any, among them {}: the datasheet says these are not one net, so the copper between these fine-pitch pads is not resolved, and no pin on it can be checked until it is", s.part, pins.len(), list(&pins), why);
            blind.push(json!({"pins": pins, "why": why, "finding": text}));
            findings.push(json!({"id": format!("W-{stem}"), "kind": "unresolved copper", "pin": pins[0], "name": "", "net": Value::Null, "finding": text, "with": pins[1..], "vias": [], "source": s.cite, "atMm": at.get(&pins[0]).map(|a| json!([a.0, a.1])), "side": side_of.get(&s.part), "test": Value::Null, "crop": format!("{CROPS}/{stem}.jpg")}));
        }
        chips.push(json!({"part": s.part, "unresolved": blind, "source": s.cite, "file": s.file, "agree": t[AGREE], "disagree": t[DISAGREE], "notVisible": t[UNSEEN], "netList": {"agree": lt[AGREE], "disagree": lt[DISAGREE], "notStated": lt[UNSEEN]}, "pins": rows}));
    }
    // pins on copper the reader left as one piece over several pins of a chip: the pinout says which pin is which net there
    let blurred: BTreeSet<String> = sheets.iter().flat_map(|s| unresolved(&w, s).into_iter().flat_map(|(pins, _)| pins)).collect();
    // per net: the witness, in nets.json and net-evidence.json
    let mut ev = items::read_json(&flow.join("net-evidence.json")).unwrap_or(Value::Null);
    let (mut with_witness, mut n_agree, mut n_disagree, mut total, mut carrying, mut l_agree, mut l_disagree) = (0, 0, 0, 0, 0, 0, 0);
    for n in nets["nets"].as_array_mut().into_iter().flatten() {
        total += 1;
        let name = n["name"].as_str().unwrap_or("").to_string();
        let pins = per_net.get(&name).cloned().unwrap_or_default();
        let count = |k: &str, v: &str| pins.iter().filter(|p| p[k] == json!(v)).count();
        let fs: Vec<&Value> = findings.iter().filter(|f| f["net"].as_str() == Some(name.as_str())).collect();
        let verdict = if count("witness", DISAGREE) > 0 { DISAGREE } else if count("witness", AGREE) > 0 { AGREE } else { "none" };
        let lverdict = if count("netList", DISAGREE) > 0 { DISAGREE } else if count("netList", AGREE) > 0 { AGREE } else { "none" };
        if !pins.is_empty() { carrying += 1; }
        let apart: Vec<&Value> = pins.iter().map(|p| &p["pin"]).filter(|p| p.as_str().is_some_and(|q| blurred.contains(q))).collect();
        if verdict != "none" { with_witness += 1; } if verdict == AGREE { n_agree += 1; } if verdict == DISAGREE { n_disagree += 1; }
        if lverdict == AGREE { l_agree += 1; } if lverdict == DISAGREE { l_disagree += 1; }
        let rec = if pins.is_empty() { json!({"witness": "none", "netList": "none", "why": "no pin of an identified chip with a datasheet is on this net"}) } else {
            json!({"witness": verdict, "agree": count("witness", AGREE), "disagree": count("witness", DISAGREE), "notVisible": count("witness", UNSEEN), "netList": lverdict, "copperCouldNotSeparate": apart, "pins": pins,
                "findings": fs.iter().map(|f| json!({"id": f["id"], "kind": f["kind"], "finding": f["finding"], "crop": f["crop"], "test": f["test"]})).collect::<Vec<_>>()})
        };
        // the tests a disagreement raises are named on the net, so `tests generate` and `check tests` carry them; none is left behind when it clears
        let mut ts: Vec<Value> = n["tests"].as_array().cloned().unwrap_or_default();
        ts.retain(|t| !t.as_str().unwrap_or("").starts_with("T-witness-"));
        for f in fs.iter().filter(|f| f["test"].is_string()) { ts.push(f["test"].clone()); }
        if let Some(o) = n.as_object_mut() {
            if ts.is_empty() { o.remove("tests"); } else { o.insert("tests".into(), json!(ts)); }
            if pins.is_empty() { o.remove("datasheetWitness"); } else { o.insert("datasheetWitness".into(), json!({"witness": verdict, "agree": count("witness", AGREE), "disagree": count("witness", DISAGREE), "notVisible": count("witness", UNSEEN), "netList": lverdict, "copperCouldNotSeparate": apart, "findings": fs.iter().map(|f| f["finding"].clone()).collect::<Vec<_>>()})); }
        }
        if let Some(row) = ev["nets"].as_array_mut().into_iter().flatten().find(|r| r["net"].as_str() == Some(name.as_str())) { row["datasheet"] = rec; }
    }
    let summary = json!({"chips": sheets.len(), "pins": tally.values().sum::<usize>(), "agree": tally[AGREE], "disagree": tally[DISAGREE], "notVisible": tally[UNSEEN], "findings": findings.iter().filter(|f| f["kind"] != json!("unresolved copper")).count(), "unresolved": findings.iter().filter(|f| f["kind"] == json!("unresolved copper")).count(),
        "nets": total, "netsCarryingDatasheetPins": carrying, "netsWithWitness": with_witness, "netsAgree": n_agree, "netsDisagree": n_disagree, "netsNone": total - with_witness,
        "netList": {"agree": ltally[AGREE], "disagree": ltally[DISAGREE], "notStated": ltally[UNSEEN], "netsAgree": l_agree, "netsDisagree": l_disagree},
        "noWitness": st.declined.iter().map(|(r, why)| json!({"part": r, "reason": why})).collect::<Vec<_>>()});
    let wr = |p: &Path, v: &Value| { if let Some(d) = p.parent() { std::fs::create_dir_all(d).ok(); } std::fs::write(p, serde_json::to_string_pretty(v).unwrap_or_default() + "\n").map_err(|e| format!("{}: {e}", p.display())) };
    if ev.is_object() { ev["datasheetWitness"] = summary.clone(); wr(&flow.join("net-evidence.json"), &ev)?; }
    wr(&flow.join("nets.json"), &nets)?;
    if crops { draw_crops(flow, &findings, &at, &side_of)?; }
    wr(&flow.join(FILE), &json!({"summary": summary, "chips": chips, "findings": findings, "tests": tests,
        "how": "a pin sits on a conductor when its pad has a solid contact; conductors joined by verified vias are one; what says a conductor is ground or a supply is another pin on it. Absence is `not visible`, never `disagree`"}))?;
    std::fs::write(flow.join(CROPS).join("findings.md"), sheet_md(&summary, &chips, &findings)).ok();
    Ok(summary)
}

/// The findings as a page a person reads, each with its crop.
fn sheet_md(summary: &Value, chips: &[Value], findings: &[Value]) -> String {
    let mut md = format!("# Datasheet witness\n\n{} pin(s) of {} chip(s) held against the copper: {} agree, {} disagree, {} not visible. {} of {} nets have a datasheet witness ({} agree, {} disagree).\n\nThe same pins against the net list alone (nets.json as it stands, seen or inferred): {} agree, {} disagree, {} not stated.\n\n", summary["pins"], summary["chips"], summary["agree"], summary["disagree"], summary["notVisible"], summary["netsWithWitness"], summary["nets"], summary["netsAgree"], summary["netsDisagree"], summary["netList"]["agree"], summary["netList"]["disagree"], summary["netList"]["notStated"]);
    md += "A disagreement changes neither copper nor nets. Either the copy is wrong there (an island that bridges two pads, a part turned or misidentified) or the expectation is: look at the crop, then run the test.\n\n";
    for f in findings { md += &format!("## {}\n\n{}\n\n- kind: {} against the datasheet ({})\n- test: {}\n\n![{}]({})\n\n", f["id"].as_str().unwrap_or(""), f["finding"].as_str().unwrap_or(""), f["kind"].as_str().unwrap_or(""), f["source"].as_str().unwrap_or(""), f["test"].as_str().unwrap_or("none: this is the reader's limit, not a claim about the board"), f["id"].as_str().unwrap_or(""), f["crop"].as_str().unwrap_or("").rsplit('/').next().unwrap_or("")); }
    for c in chips {
        md += &format!("## {}: every pin\n\nSource: {}\n\n| Pin | Name | Type | Net | Copper | Witness | Why | Net list | Why |\n|---|---|---|---|---|---|---|---|---|\n", c["part"].as_str().unwrap_or(""), c["source"].as_str().unwrap_or(""));
        for p in c["pins"].as_array().into_iter().flatten() {
            let why = |c: &Value| c.as_array().into_iter().flatten().map(|k| format!("{}: {}", k["result"].as_str().unwrap_or(""), k["why"].as_str().unwrap_or(""))).collect::<Vec<_>>().join("; ").replace('|', "/");
            md += &format!("| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", p["pin"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["net"].as_str().unwrap_or(""), p["copper"].as_str().unwrap_or(""), p["witness"].as_str().unwrap_or(""), why(&p["checks"]), p["netList"]["witness"].as_str().unwrap_or(""), why(&p["netList"]["checks"]));
        }
        md += "\n";
    }
    md
}

/// A picture for each finding, two panels: the board photo close up at the pin (ringed in red, with the pins it disagrees with
/// in yellow when they are near), and the whole face with the pin, every pin it disagrees with and the vias that join them
/// (cyan), so a person sees where to look. The photos are drawn on, never measured.
fn draw_crops(flow: &Path, findings: &[Value], at: &BTreeMap<String, (f64, f64)>, side_of: &BTreeMap<String, String>) -> Result<(), String> {
    use crate::{board, draw, netpaint};
    const S: u32 = 900;
    let dir = flow.join(CROPS);
    // crops of findings that cleared are not left to be read as current
    if let Ok(rd) = std::fs::read_dir(&dir) { for e in rd.flatten() { if e.path().extension().is_some_and(|x| x == "jpg") { std::fs::remove_file(e.path()).ok(); } } }
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    if findings.is_empty() { return Ok(()); }
    let via_at: BTreeMap<String, (f64, f64)> = items::read_json(&flow.join("copper.json")).ok().map(|c| c["vias"].as_array().into_iter().flatten().filter_map(|v| Some((v["id"].as_str()?.to_string(), (v["pos_mm"][0].as_f64()?, v["pos_mm"][1].as_f64()?)))).collect()).unwrap_or_default();
    let mut photos: BTreeMap<String, image::RgbImage> = BTreeMap::new();
    for f in findings {
        let pin = f["pin"].as_str().unwrap_or("");
        let Some(&c) = at.get(pin) else { continue };
        let side = side_of.get(part_of(pin)).cloned().unwrap_or("top".into());
        if !photos.contains_key(&side) { if let Ok(i) = image::open(flow.join(format!("board/{side}.jpg"))) { photos.insert(side.clone(), i.to_rgb8()); } }
        let Some(img) = photos.get(&side) else { continue };
        let with: Vec<(String, (f64, f64))> = f["with"].as_array().into_iter().flatten().filter_map(|q| q.as_str()).filter_map(|q| at.get(q).map(|a| (q.to_string(), *a))).collect();
        let vias: Vec<(f64, f64)> = f["vias"].as_array().into_iter().flatten().filter_map(|q| q.as_str()).filter_map(|q| via_at.get(q).copied()).collect();
        // close up: the pin and the pins it disagrees with that lie within 11 mm, in a window of 8 mm or more
        let near: Vec<&(String, (f64, f64))> = with.iter().filter(|(_, a)| (a.0 - c.0).abs() < 11.0 && (a.1 - c.1).abs() < 11.0).collect();
        let (mut x0, mut y0, mut x1, mut y1) = (c.0, c.1, c.0, c.1);
        for (_, a) in &near { x0 = x0.min(a.0); y0 = y0.min(a.1); x1 = x1.max(a.0); y1 = y1.max(a.1); }
        let half = (((x1 - x0).max(y1 - y0)) / 2.0 + 2.5).max(4.0);
        let (u0, v0) = board::to_px((x0 + x1) / 2.0 - half, (y0 + y1) / 2.0 - half);
        let (u0, v0) = (u0.max(0.0) as u32, v0.max(0.0) as u32);
        let size = ((half * 2.0 * board::RES) as u32).min(img.width().saturating_sub(u0)).min(img.height().saturating_sub(v0));
        if size < 40 { continue; }
        let k = S as f64 / size as f64;
        let mut close = image::imageops::resize(&image::imageops::crop_imm(img, u0, v0, size, size).to_image(), S, S, image::imageops::FilterType::CatmullRom);
        let to = |a: (f64, f64)| { let (u, v) = board::to_px(a.0, a.1); ((u - u0 as f64) * k, (v - v0 as f64) * k) };
        let r = 0.45 * board::RES * k;
        let few = near.len() <= 8;
        for (q, a) in &near { let p = to(*a); draw::circle(&mut close, p, if few { r } else { r * 0.6 }, [250, 220, 40], 3); if few { netpaint::text_halo(&mut close, p.0 + r + 4.0, p.1 - 10.0, 20.0, q, [250, 220, 40], true); } }
        for a in &vias { let p = to(*a); if p.0 > 0.0 && p.1 > 0.0 && p.0 < S as f64 && p.1 < S as f64 { draw::circle(&mut close, p, 0.3 * board::RES * k, [60, 230, 250], 2); } }
        let p = to(c);
        draw::circle(&mut close, p, r, [255, 60, 50], 4);
        netpaint::text_halo(&mut close, p.0 + r + 4.0, p.1 + 8.0, 22.0, &format!("{pin} {}", f["name"].as_str().unwrap_or("")), [255, 90, 80], true);
        // the whole face, fitted
        let ko = S as f64 / img.width().max(img.height()) as f64;
        let (ow, oh) = ((img.width() as f64 * ko) as u32, (img.height() as f64 * ko) as u32);
        let small = image::imageops::resize(img, ow.max(1), oh.max(1), image::imageops::FilterType::Triangle);
        let mut out = image::RgbImage::from_pixel(2 * S + 12, S, image::Rgb(netpaint::PAPER));
        image::imageops::replace(&mut out, &close, 0, 0);
        let (ox, oy) = ((S + 12 + (S - ow) / 2) as f64, ((S - oh) / 2) as f64);
        image::imageops::replace(&mut out, &small, ox as i64, oy as i64);
        let tov = |a: (f64, f64)| { let (u, v) = board::to_px(a.0, a.1); (ox + u * ko, oy + v * ko) };
        for a in &vias { draw::circle(&mut out, tov(*a), 3.0, [60, 230, 250], 2); }
        for (q, a) in &with { let p = tov(*a); if few { draw::circle(&mut out, p, 9.0, [250, 220, 40], 3); netpaint::text_halo(&mut out, p.0 + 12.0, p.1 - 9.0, 17.0, q, [250, 220, 40], true); } else { draw::circle(&mut out, p, 3.0, [250, 220, 40], 2); } }
        let p = tov(c);
        draw::circle(&mut out, p, 11.0, [255, 60, 50], 4);
        netpaint::text_halo(&mut out, p.0 + 14.0, p.1 + 4.0, 19.0, pin, [255, 90, 80], true);
        netpaint::text_halo(&mut out, (S + 22) as f64, 8.0, 17.0, &format!("{side} face: red the pin, yellow what it disagrees with, cyan the vias that join them"), netpaint::WHITE, false);
        // the finding in words along the bottom, in lines that fit
        let width = (2 * S) as f64 - 10.0;
        let (mut lines, mut cur) = (vec![], String::new());
        for wd in f["finding"].as_str().unwrap_or("").split(' ') { if !cur.is_empty() && netpaint::text_width(19.0, &format!("{cur} {wd}"), false) > width { lines.push(std::mem::take(&mut cur)); } if !cur.is_empty() { cur.push(' '); } cur += wd; }
        if !cur.is_empty() { lines.push(cur); }
        let lines: Vec<String> = lines.into_iter().take(5).collect();
        let band = 24 * lines.len() as u32 + 12;
        for y in S - band..S { for x in 0..out.width() { let px = out.get_pixel_mut(x, y); for ch in 0..3 { px[ch] = (px[ch] as f32 * 0.22) as u8; } } }
        for (i, l) in lines.iter().enumerate() { netpaint::text(&mut out, 10.0, (S - band + 8 + 24 * i as u32) as f64, 19.0, l, netpaint::WHITE, false); }
        let path = flow.join(f["crop"].as_str().unwrap_or("x.jpg"));
        out.save(&path).map_err(|e| format!("{}: {e}", path.display()))?;
    }
    Ok(())
}

/// The tests the findings raise, as rows for tests.json. `tests generate` puts them in before its own.
pub fn tests(flow: &Path) -> Vec<Value> { items::read_json(&flow.join(FILE)).ok().and_then(|d| d["tests"].as_array().cloned()).unwrap_or_default() }

/// What `connect witness` and `connect attribute` print.
pub fn one_line(s: &Value) -> Vec<String> {
    if s["chips"].as_u64().unwrap_or(0) == 0 { return vec!["datasheet witness: no identified chip has a pins file, so no net has a datasheet witness (`datasheet status` says what waits)".into()]; }
    let mut h = vec![format!("datasheet witness: {} pin(s) of {} chip(s) against the copper: {} agree, {} disagree, {} not visible; {} of {} nets have a witness ({} agree, {} disagree)", s["pins"], s["chips"], s["agree"], s["disagree"], s["notVisible"], s["netsWithWitness"], s["nets"], s["netsAgree"], s["netsDisagree"])];
    h.push(format!("the same pins against the net list alone (nets.json as stated, seen or inferred): {} agree, {} disagree, {} not stated", s["netList"]["agree"], s["netList"]["disagree"], s["netList"]["notStated"]));
    if s["unresolved"].as_u64().unwrap_or(0) > 0 { h.push(format!("{} piece(s) of copper touch several pins of a chip that its datasheet says cannot be one net: the copper there is not resolved, which is why those pins are not visible (pictures in reverse-flow/{CROPS}/, named unresolved-<ref>.jpg)", s["unresolved"])); }
    if s["findings"].as_u64().unwrap_or(0) > 0 { h.push(format!("{} finding(s), each with a crop and a test: reverse-flow/{CROPS}/findings.md. A finding changes neither copper nor nets: look at the crop, then fix the copy or the expectation", s["findings"])); }
    h.push(format!("per pin: reverse-flow/{FILE}; per net: net-evidence.json `datasheet` and nets.json `datasheetWitness`"));
    h
}

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

    fn sheet() -> Sheet {
        let v = json!({"part": "U1", "document": {"name": "XY1234 buck", "revision": "C", "page": 4}, "pins": [
            {"pad": "1", "name": "VIN", "type": "power_in", "expects": [{"kind": "capacitor_to_ground"}]},
            {"pad": "2", "name": "GND", "type": "ground"},
            {"pad": "3", "name": "SW", "type": "output", "expects": [{"kind": "inductor"}, {"kind": "capacitor_to_pin", "pin": "BST"}]},
            {"pad": "4", "name": "BST", "type": "passive", "expects": [{"kind": "capacitor_to_pin", "pin": "SW"}]},
            {"pad": "5", "name": "PG", "type": "open_drain", "expects": [{"kind": "pull_up"}]},
            {"pad": "6", "name": "EN", "type": "input"},
            {"pad": "7", "name": "NC", "type": "no_connect"},
            {"pad": "8", "name": "GND", "type": "ground", "expects": [{"kind": "tied_to_pin", "pin": "2"}]}]});
        dspins::parse(&v, "U1", "datasheets/U1.pins.json", None).unwrap()
    }
    fn kinds() -> Vec<(String, sch::Kind)> { [("U1", sch::Kind::Ic), ("C1", sch::Kind::C), ("C2", sch::Kind::C), ("L1", sch::Kind::L), ("R1", sch::Kind::R), ("J1", sch::Kind::Conn)].iter().map(|(r, k)| (r.to_string(), *k)).collect() }
    fn c(pin: &str, node: usize) -> (String, usize, bool, Option<String>) { (pin.to_string(), node, true, None) }
    fn stated(pin: &str, node: usize, net: &str) -> (String, usize, bool, Option<String>) { (pin.to_string(), node, true, Some(net.to_string())) }
    fn verdict(w: &World, s: &Sheet, pad: &str) -> (&'static str, String) { let (v, _, ch) = judge(w, s, s.pin(pad).unwrap()); (v, ch.iter().map(|k| format!("{}={}: {}", k.what, k.result, k.why)).collect::<Vec<_>>().join(" | ")) }

    #[test]
    fn a_board_wired_as_the_datasheet_draws_it_agrees_pin_by_pin() {
        let s = sheet();
        // node 0 input rail, 1 ground top, 9 ground bottom (joined to 1 by verified vias), 2 switch node, 3 boot, 4 power good, 5 logic rail
        let contacts = vec![c("U1.1", 0), c("C1.1", 0), c("C1.2", 1), c("U1.2", 1), c("U1.8", 9), stated("J1.2", 9, "GND"), c("U1.3", 2), c("L1.1", 2), c("C2.1", 2), c("C2.2", 3), c("U1.4", 3),
            c("U1.5", 4), c("R1.1", 4), c("R1.2", 5), stated("J1.1", 5, "+3V3"), ("U1.6".to_string(), 6, false, None), c("U1.7", 7)];
        let w = World::new(&contacts, &[(1, 9, vec!["v7".to_string(), "v8".to_string()])], &kinds(), &[&s]);
        assert_eq!(w.hops("U1.2", "U1.8"), vec![vec!["v7".to_string(), "v8".to_string()]]);
        assert!(w.hops("U1.2", "C1.2").is_empty());
        assert_eq!(verdict(&w, &s, "1").0, AGREE, "{}", verdict(&w, &s, "1").1);
        assert!(verdict(&w, &s, "1").1.contains("C1"));
        // both ground pads on the ground conductor, through the vias; the tie the datasheet asks for is seen
        assert_eq!(verdict(&w, &s, "2").0, AGREE); assert_eq!(verdict(&w, &s, "8").0, AGREE);
        assert!(verdict(&w, &s, "8").1.contains("expects tied_to_pin 2=agree"));
        // the switch node: inductor and boot capacitor found, no ground, supply or second output on it
        let sw = verdict(&w, &s, "3");
        assert_eq!(sw.0, AGREE); assert!(sw.1.contains("expects inductor=agree") && sw.1.contains("expects capacitor_to_pin BST=agree"));
        assert_eq!(verdict(&w, &s, "4").0, AGREE);
        assert_eq!(verdict(&w, &s, "5").0, AGREE, "{}", verdict(&w, &s, "5").1);
        // a pad whose edge settles nothing, and an unused pin alone on its copper
        let en = judge(&w, &s, s.pin("6").unwrap());
        assert_eq!((en.0, en.1), (UNSEEN, "unsettled"));
        assert_eq!(verdict(&w, &s, "7").0, AGREE);
    }

    #[test]
    fn what_the_copper_shows_wrong_disagrees_and_what_it_does_not_show_is_not_visible() {
        let s = sheet();
        // the supply pin on the ground conductor; the output and boot pins on one conductor; power good pulled to ground; the unused pin wired
        let contacts = vec![c("U1.1", 1), c("U1.2", 1), stated("J1.2", 1, "GND"), c("U1.3", 2), c("U1.4", 2), c("C2.1", 2), c("C2.2", 2),
            c("U1.5", 4), c("R1.1", 4), c("R1.2", 1), c("U1.7", 7), c("J1.3", 7), c("U1.8", 8)];
        let w = World::new(&contacts, &[], &kinds(), &[&s]);
        let vin = verdict(&w, &s, "1");
        assert_eq!(vin.0, DISAGREE); assert!(vin.1.contains("a supply pin on ground copper") && vin.1.contains("sits on the same copper as U1.1"), "{}", vin.1);
        // the ground pin itself reads as a ground pin beside a supply pin: the copper joins ground to a supply
        assert_eq!(verdict(&w, &s, "2").0, DISAGREE);
        let sw = verdict(&w, &s, "3");
        assert_eq!(sw.0, DISAGREE); assert!(sw.1.contains("would be shorted"), "{}", sw.1);
        let pg = verdict(&w, &s, "5");
        assert_eq!(pg.0, DISAGREE); assert!(pg.1.contains("pulled down, not up"), "{}", pg.1);
        assert_eq!(verdict(&w, &s, "7").0, DISAGREE);
        // a ground pad alone on its copper, not seen tied to the other: nothing is claimed either way
        let g = verdict(&w, &s, "8");
        assert_eq!(g.0, UNSEEN); assert!(g.1.contains("an inner layer or a hidden track may join them"), "{}", g.1);
        // one piece of copper the reader left unsettled over a supply, an output and two more pins: named as unresolved, not as a tie
        let blur: Vec<(String, usize, bool, Option<String>)> = ["U1.1", "U1.3", "U1.4", "U1.6"].iter().map(|p| (p.to_string(), 5, false, None)).collect();
        let wb = World::new(&blur, &[], &kinds(), &[&s]);
        let u = unresolved(&wb, &s);
        assert_eq!(u.len(), 1); assert_eq!(u[0].0.len(), 4); assert!(u[0].1.contains("supply (VIN) and output (SW)"), "{}", u[0].1);
        assert!(unresolved(&w, &s).is_empty());
        // a pin with no contact at all, and an input whose type says nothing copper can show
        assert_eq!(judge(&w, &s, s.pin("6").unwrap()).1, "none");
        let w2 = World::new(&[c("U1.6", 3), c("J1.4", 3)], &[], &kinds(), &[&s]);
        assert_eq!(verdict(&w2, &s, "6").0, UNSEEN);
    }

    #[test]
    fn the_pin_under_test_never_proves_its_own_copper_and_the_net_list_is_judged_as_a_whole_claim() {
        let s = sheet();
        // a lone ground pin on a big pour with a capacitor: nothing but the pin itself says ground, so not visible
        let w = World::new(&[c("U1.2", 1), c("C1.2", 1)], &[], &kinds(), &[&s]);
        assert_eq!(verdict(&w, &s, "2").0, UNSEEN);
        // the same copper with a pin somebody put on GND: now it agrees
        let w = World::new(&[c("U1.2", 1), stated("C1.2", 1, "GND")], &[], &kinds(), &[&s]);
        assert_eq!(verdict(&w, &s, "2").0, AGREE);
        // the net list as a world: the supply pin and the ground pin typed onto one net disagree with no copper at all,
        // and two pins the datasheet ties that the list keeps apart disagree too, which the copper alone would leave open
        let nets = json!({"nets": [{"name": "GND", "pins": ["U1.1", "U1.2", "C1.2"]}, {"name": "AGND", "pins": ["U1.8"]}, {"name": "SW", "pins": ["U1.3", "L1.1", "C2.1"]}, {"name": "BST", "pins": ["U1.4", "C2.2"]}]});
        let lw = World::of_list(&nets, &kinds(), &[&s]);
        let vin = verdict(&lw, &s, "1");
        assert_eq!(vin.0, DISAGREE); assert!(vin.1.contains("a supply pin on ground net") && vin.1.contains("are on one net in nets.json (GND)"), "{}", vin.1);
        let g8 = verdict(&lw, &s, "8");
        assert_eq!(g8.0, DISAGREE); assert!(g8.1.contains("nets.json has them on different nets (GND)"), "{}", g8.1);
        assert_eq!(verdict(&lw, &s, "3").0, AGREE); assert_eq!(verdict(&lw, &s, "4").0, AGREE);
        // a pin on no net is not stated
        assert_eq!(verdict(&lw, &s, "5"), (UNSEEN, "net=not visible: this pin is on no net in nets.json".to_string()));
    }
    #[test]
    fn a_run_records_the_witness_per_pin_and_per_net_raises_a_test_and_changes_no_pin() {
        let d = std::env::temp_dir().join(format!("witness-test-run-{}", std::process::id())).join("reverse-flow");
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(d.join("datasheets")).unwrap(); std::fs::create_dir_all(d.join("detect")).unwrap();
        let put = |f: &str, v: Value| std::fs::write(d.join(f), v.to_string()).unwrap();
        put("parts.json", json!({"parts": [{"ref": "U1", "value": "XY1234", "marking": "XY1234", "fieldTags": {"value": "SEEN"}, "side": "top"}, {"ref": "C1"}, {"ref": "C2"}, {"ref": "L1"}, {"ref": "R1"}, {"ref": "J1"}]}));
        put("datasheets/U1.pins.json", json!({"part": "U1", "document": {"name": "XY1234 buck", "revision": "C", "page": 4}, "pins": [
            {"pad": "1", "name": "VIN", "type": "power_in", "expects": [{"kind": "capacitor_to_ground"}]}, {"pad": "2", "name": "GND", "type": "ground"},
            {"pad": "3", "name": "SW", "type": "output", "expects": [{"kind": "inductor"}]}, {"pad": "4", "name": "EN", "type": "input"}]}));
        // VIN with its capacitor to ground copper; the ground pin on that ground copper; the switch node read as joined to ground through two vias
        let solid = |pin: &str, node: usize, net: Option<&str>| json!({"pin": pin, "node": node, "tier": 1, "kind": "pour", "inferred": net, "side": "top"});
        put("detect/connect-graph.json", json!({"contacts": [solid("U1.1", 0, None), solid("C1.1", 0, None), solid("C1.2", 1, Some("GND")), solid("U1.2", 1, None), solid("J1.2", 1, Some("GND")), solid("U1.3", 2, None), solid("L1.1", 2, None), {"pin": "U1.4", "node": 3, "tier": 3, "kind": "track-end", "side": "top"}],
            "links": [{"pieces": [2, 1], "tier": 2, "ids": ["v5", "v6"], "vias": 2}, {"pieces": [0, 2], "tier": 4, "ids": ["v9"], "vias": 1}], "pinsMoved": []}));
        put("detect/attribution.json", json!({"padStates": [{"pin": "U1.3", "side": "top", "x": 110.0, "y": 110.0}]}));
        let nets = json!({"nets": [{"name": "U1_VIN", "fromCopper": true, "nameTag": "DATASHEET", "tag": "MEASURED", "pins": ["U1.1", "C1.1"]}, {"name": "GND", "tag": "INFERRED", "pins": ["U1.2", "C1.2", "J1.2"], "tests": ["T-net-GND"]}, {"name": "U1_SW", "fromCopper": true, "nameTag": "DATASHEET", "tag": "MEASURED", "pins": ["U1.3", "L1.1"]}, {"name": "CU_R1_1", "fromCopper": true, "tag": "MEASURED", "pins": ["R1.1", "J1.4"]}]});
        put("nets.json", nets.clone());
        put("net-evidence.json", json!({"nets": [{"net": "U1_VIN"}, {"net": "GND"}, {"net": "U1_SW"}, {"net": "CU_R1_1"}]}));
        let s = run(&d, false).unwrap();
        assert_eq!((s["pins"].as_u64(), s["agree"].as_u64(), s["disagree"].as_u64(), s["notVisible"].as_u64()), (Some(4), Some(1), Some(2), Some(1)), "{s}");
        assert_eq!((s["nets"].as_u64(), s["netsWithWitness"].as_u64(), s["netsAgree"].as_u64(), s["netsDisagree"].as_u64()), (Some(4), Some(3), Some(1), Some(2)));
        // the unconfirmed via (tier 4) joined nothing: VIN is not on the switch node
        let w = items::read_json(&d.join(FILE)).unwrap();
        // one short, two pins that say so: the ground pin finds an output on its copper, the output finds ground on its own
        assert_eq!(w["findings"].as_array().unwrap().len(), 2);
        assert_eq!(w["findings"][0]["id"], "W-U1_2");
        let f = &w["findings"][1];
        assert_eq!(f["id"], "W-U1_3"); assert_eq!(f["kind"], "copper"); assert_eq!(f["vias"], json!(["v5", "v6"]));
        assert!(f["finding"].as_str().unwrap().contains("an output on ground copper") && f["finding"].as_str().unwrap().contains("through 2 verified via(s) (v5, v6)"), "{f}");
        assert_eq!(w["chips"][0]["pins"][3]["copper"], "unsettled");
        // per net, in both files; the test is named on the net and written out for `tests generate`; no pin moved
        let n = items::read_json(&d.join("nets.json")).unwrap();
        assert_eq!(n["nets"][2]["datasheetWitness"]["witness"], "disagree"); assert_eq!(n["nets"][2]["tests"], json!(["T-witness-U1_3"]));
        assert_eq!(n["nets"][1]["datasheetWitness"]["witness"], "disagree"); assert_eq!(n["nets"][1]["tests"], json!(["T-net-GND", "T-witness-U1_2"]));
        assert!(n["nets"][3]["datasheetWitness"].is_null());
        for k in 0..4 { assert_eq!(n["nets"][k]["pins"], nets["nets"][k]["pins"]); assert_eq!(n["nets"][k]["tag"], nets["nets"][k]["tag"]); }
        let e = items::read_json(&d.join("net-evidence.json")).unwrap();
        assert_eq!(e["nets"][0]["datasheet"]["witness"], "agree"); assert_eq!(e["nets"][3]["datasheet"]["witness"], "none"); assert_eq!(e["datasheetWitness"]["disagree"], 2);
        let t = tests(&d);
        assert_eq!(t.len(), 2); assert_eq!(t[1]["id"], "T-witness-U1_3"); assert_eq!(t[1]["settles"], json!(["net:U1_SW"]));
        // a second run writes the same files: `auto` sees nothing changed
        let before = std::fs::read_to_string(d.join("nets.json")).unwrap();
        run(&d, false).unwrap();
        assert_eq!(std::fs::read_to_string(d.join("nets.json")).unwrap(), before);
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }
}