main
Caleb Publish 0.5.0 1984b40
//! The step checks. Each reads its artifact, applies the rules the Driver board taught us, and on a
//! pass writes `checks/<name>.ok` with the sha256 of every file it read. A check whose files changed
//! afterwards is stale, so `next` sends the AI back to it: editing parts.json after the review undoes
//! the review.
use crate::items::{self, Item};
use crate::{kicad_in as kicad, ledger, sha256};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;

pub struct Pass { pub summary: Value, pub hints: Vec<String>, pub files: Vec<String> }
pub type Outcome = Result<Pass, Vec<String>>;

/// Every artifact the review covers (and that a later edit would make stale).
pub const REVIEWED: [&str; 7] = ["prep.json", "scale.json", "parts.json", "blocks.json", "copper.json", "nets.json", "layers.json"];
pub const VIA_KINDS: [&str; 7] = ["stitch", "signal", "thermal", "inner", "unknown", "seen", "seen-net"];

fn doc(flow: &Path, name: &str) -> Result<Value, Vec<String>> {
    items::read_json(&flow.join(name)).map_err(|e| vec![format!("{e}: the step that writes it has not run yet; `next` names the command")])
}

fn s<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }

fn pos(v: &Value) -> Option<(f64, f64)> {
    let a = v["pos_mm"].as_array()?;
    Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}

fn tag_counts(items: &[Item]) -> Value {
    let mut m: BTreeMap<String, usize> = BTreeMap::new();
    for it in items { *m.entry(it.tag.clone()).or_default() += 1; }
    json!(m)
}

fn validate_all(flow: &Path, items: &[Item], errs: &mut Vec<String>) {
    let mut seen = HashSet::new();
    for it in items {
        items::validate(flow, it, errs);
        if !it.id.is_empty() && !seen.insert(it.id.clone()) { errs.push(format!("{}: listed twice in {}", it.id, it.artifact)); }
    }
}

/// A part that only fits one way needs its orientation mark checked before anything is routed: the
/// Driver's two QFNs were placed 180 degrees out (the router failed on every pin of one), and its
/// MOSFETs' high or low side was read from which way the tab faced. ICs, transistors, MOSFETs,
/// regulators, diodes and LEDs: `pin1` names the mark (pin-1 dot, notch, bevel, tab, cathode band).
pub fn needs_pin1(p: &Value) -> bool {
    let pk = s(p, "package").to_uppercase();
    let r = s(p, "ref").to_uppercase();
    const PKG: [&str; 22] = ["QFN", "QFP", "SOIC", "SOP", "MSOP", "BGA", "DFN", "SON", "LGA", "DIP", "PLCC", "SOT-23", "SOT23", "SOT-223", "SOT-89", "DPAK", "TO-2", "SMA", "SMB", "SMC", "SOD", "LED"];
    PKG.iter().any(|k| pk.contains(k)) || ["U", "IC", "Q", "D", "LED"].iter().any(|x| r.starts_with(x) && r[x.len()..].starts_with(|c: char| c.is_ascii_digit()))
}

/// Every registered photo measured, and the bottom-to-top mapping decided clearly.
pub fn prep(flow: &Path, state: &Value) -> Outcome {
    let d = doc(flow, "prep.json")?;
    let mut errs = vec![];
    let done: Vec<String> = d["photos"].as_array().into_iter().flatten().map(|p| s(p, "file").to_string()).collect();
    for p in state["photos"].as_array().into_iter().flatten() { if !done.iter().any(|f| f == s(p, "file")) { errs.push(format!("{} was registered but not measured: run `prep`", s(p, "file"))); } }
    let (m, o) = (d["flip"]["matches"].as_u64().unwrap_or(0), d["flip"]["other"].as_u64().unwrap_or(0));
    // a symmetric hole pattern agrees both ways: `scale --holes` settles the flip from what shows through, and says how in prep.json
    let settled = d["flip"]["how"].is_string();
    let symmetric = d["flip"].is_object() && m == o && d["photos"].as_array().into_iter().flatten().all(|p| p["goldHoles"].as_array().map(|a| a.len()).unwrap_or(0) >= 4);
    if d["flip"].is_object() && m < 3 * o.max(1) && !settled && !symmetric { errs.push(format!("the bottom-to-top mapping is not clear ({m} holes agree one way, {o} the other): check the photos show the whole board")); }
    // once the frame is made: the faces must be held together by holes or vias, not by one shared hole. A flip the person stated lets a weak one through, with a warning.
    let mut hints = vec![];
    if let Ok(sc) = items::read_json(&flow.join("scale.json")) { if let Some(why) = crate::facereg::unregistered(&sc) {
        if settled { hints.push(format!("WARNING: {why}. The flip was stated, so this passes; every via pair and every net across the faces rests on the bottom photo's outline.")); }
        else { errs.push(format!("{why}. Look at board/top.jpg against board/bottom.jpg; if the board has no through features, state the flip (`scale ... --flip x|y`) and say so in your report")); }
    } }
    if !errs.is_empty() { return Err(errs); }
    for p in d["photos"].as_array().into_iter().flatten() { let cut: Vec<&str> = p["notBoardEdge"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
        if !cut.is_empty() { hints.push(format!("WARNING: {} does not show the whole board: its {} side(s) are the photo's edge, not the board's edge", s(p, "file"), cut.join(" and "))); } }
    for p in d["photos"].as_array().into_iter().flatten() { hints.push(format!("{} ({}): sharpness {:.0}, {} holes, {} rows", s(p, "file"), s(p, "side"), p["sharpness"].as_f64().unwrap_or(0.0), p["holes"].as_array().map(|a| a.len()).unwrap_or(0), p["rows"].as_array().map(|a| a.len()).unwrap_or(0))); }
    // the mosaics the photos were measured on: stitched again (`stitch --top`), the measurements are of the old faces and this check is stale
    let mut files = vec!["prep.json".to_string()];
    if flow.join("stitch.json").is_file() { files.push("stitch.json".into()); }
    Ok(Pass { summary: json!({"photos": done.len(), "flip": d["flip"]}), hints, files })
}

pub fn parts(flow: &Path) -> Outcome {
    let d = doc(flow, "parts.json")?;
    let list = d["parts"].as_array().cloned().unwrap_or_default();
    if list.is_empty() { return Err(vec!["parts.json has no parts".into()]); }
    let its = items::items_of("parts.json", &d);
    let mut errs = vec![];
    validate_all(flow, &its, &mut errs);
    let mut placed: Vec<(String, String, f64, f64)> = vec![];
    for p in &list {
        let r = s(p, "ref");
        if r.is_empty() { continue; }
        if !matches!(s(p, "side"), "top" | "bottom") { errs.push(format!("{r}: side must be top or bottom")); }
        match pos(p) { Some((x, y)) => placed.push((r.into(), s(p, "side").into(), x, y)), None => errs.push(format!("{r}: pos_mm [x, y] missing (board frame, mm, y down, as KiCad)")) }
        if p["rot"].as_f64().is_none() { errs.push(format!("{r}: rot (degrees, KiCad convention) missing")); }
        if s(p, "package").is_empty() && s(p, "footprint").is_empty() { errs.push(format!("{r}: give the package or the footprint")); }
        if needs_pin1(p) {
            let pin = &p["pin1"];
            if s(pin, "mark").is_empty() { errs.push(format!("{r}: a part that fits only one way, without pin1.mark: name the mark you saw (pin-1 dot, notch, bevel, tab, cathode band, silk triangle) and where it sits, before routing")); }
            else if let Some(e) = pin["evidence"].as_str() { if items::resolve(flow, e).is_none() { errs.push(format!("{r}: pin1 evidence {e} is not there")); } }
            else if is_seen(s(pin, "tag")) { errs.push(format!("{r}: pin1 tagged {} without an evidence crop", s(pin, "tag"))); }
        }
    }
    for i in 0..placed.len() {
        for j in i + 1..placed.len() {
            let (a, b) = (&placed[i], &placed[j]);
            if a.1 == b.1 && ((a.2 - b.2).powi(2) + (a.3 - b.3).powi(2)).sqrt() < 0.5 {
                errs.push(format!("{} and {} sit within 0.5 mm of each other on the {}: one part counted twice?", a.0, b.0, a.1));
            }
        }
    }
    // every candidate the program measured is accounted for: used by a part, or dismissed with a reason
    if let Ok(det) = items::read_json(&flow.join("detect/parts.json")) {
        let used: std::collections::HashSet<String> = list.iter().filter_map(|p| p["candidate"].as_str().map(str::to_string)).collect();
        let dismissed: HashMap<String, String> = d["dismissed"].as_array().into_iter().flatten().map(|x| (s(x, "candidate").to_string(), s(x, "why").to_string())).collect();
        for (c, why) in &dismissed { if why.is_empty() { errs.push(format!("dismissed {c}: say why (silkscreen, logo, reflection, part of another body)")); } }
        let open_c: Vec<String> = det["candidates"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).filter(|c| !used.contains(*c) && !dismissed.contains_key(*c)).map(str::to_string).collect();
        if !open_c.is_empty() { errs.push(format!("{} measured candidate(s) neither used by a part nor dismissed: {}", open_c.len(), open_c.iter().take(30).cloned().collect::<Vec<_>>().join(", "))); }
    }
    if !errs.is_empty() { return Err(errs); }
    let open = its.iter().filter(|i| i.open()).count();
    let hints = vec![format!("{} parts, {open} with something inferred or unknown. Every one of those needs a test in step 7.", its.len())];
    Ok(Pass { summary: json!({"parts": its.len(), "tags": tag_counts(&its), "open": open}), hints, files: vec!["parts.json".into()] })
}

fn is_seen(t: &str) -> bool { matches!(t, "SEEN" | "MEASURED") }

pub fn blocks(flow: &Path) -> Outcome {
    let d = doc(flow, "blocks.json")?;
    let p = doc(flow, "parts.json")?;
    let refs: Vec<String> = p["parts"].as_array().into_iter().flatten().map(|x| s(x, "ref").to_string()).collect();
    let its = items::items_of("blocks.json", &d);
    let mut errs = vec![];
    if its.is_empty() { errs.push("blocks.json has no blocks".into()); }
    validate_all(flow, &its, &mut errs);
    let mut owner: HashMap<String, String> = HashMap::new();
    for b in d["blocks"].as_array().into_iter().flatten() {
        let id = s(b, "id");
        if s(b, "name").is_empty() || s(b, "function").is_empty() { errs.push(format!("block:{id}: give a name and a function (what it does)")); }
        for r in b["parts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {
            if !refs.iter().any(|x| x == r) { errs.push(format!("block:{id}: part {r} is not in parts.json")); }
            if let Some(o) = owner.insert(r.into(), id.into()) { errs.push(format!("{r} is in two blocks: {o} and {id}")); }
        }
    }
    let loose: Vec<&String> = refs.iter().filter(|r| !owner.contains_key(*r)).collect();
    if !loose.is_empty() { errs.push(format!("parts in no block (holes, logos and fiducials go in a `mechanical` block): {}", loose.iter().map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }
    if !errs.is_empty() { return Err(errs); }
    Ok(Pass { summary: json!({"blocks": its.len(), "tags": tag_counts(&its)}), hints: vec![], files: vec!["blocks.json".into(), "parts.json".into()] })
}

pub fn inner_vias(copper: &Value) -> usize {
    copper["vias"].as_array().into_iter().flatten().filter(|v| s(v, "kind") == "inner").count()
}

pub fn copper(flow: &Path) -> Outcome {
    let d = doc(flow, "copper.json")?;
    let its = items::items_of("copper.json", &d);
    let mut errs = vec![];
    validate_all(flow, &its, &mut errs);
    match d["outline"]["points_mm"].as_array() {
        Some(a) if a.len() >= 3 => {}
        _ => errs.push("outline.points_mm needs at least three [x, y] points (the measured board edge)".into()),
    }
    for (key, need) in [("zones", ["net", "layer"]), ("traces", ["net", "layer"])] {
        for v in d[key].as_array().into_iter().flatten() {
            for k in need { if s(v, k).is_empty() { errs.push(format!("{key} {}: `{k}` missing (UNKNOWN is a valid net name only with an UNKNOWN tag)", s(v, "id"))); } }
        }
    }
    for z in d["zones"].as_array().into_iter().flatten() {
        let id = s(z, "id");
        let pts = z["polygon_mm"].as_array().map(|a| a.len()).unwrap_or(0);
        if pts < 3 { errs.push(format!("zone {id}: no polygon_mm: sketch it (sketch_mm) and run `copper fit`")); continue; }
        // an outline traced from measured regions is measured by construction; a hand sketch must land on gaps
        if matches!(s(z, "tag"), "SEEN" | "MEASURED") && s(z, "from") != "regions" && s(z, "from") != "islands" {
            let share = z["fit"]["edgeShare"].as_f64().unwrap_or(0.0);
            if share < 0.6 { errs.push(format!("zone {id}: only {:.0}% of its outline sits on a measured gap line: re-sketch it closer to the photo, or tag it INFERRED with a test if its edges are hidden", share * 100.0)); }
        }
    }
    for v in d["vias"].as_array().into_iter().flatten() {
        if pos(v).is_none() { errs.push(format!("via {}: pos_mm missing", s(v, "id"))); }
        if !VIA_KINDS.contains(&s(v, "kind")) { errs.push(format!("via {}: kind must be one of {}", s(v, "id"), VIA_KINDS.join(", "))); }
    }
    // the copper as a whole: no island may have swallowed a face (`copperchecks`)
    // A face the reader got wrong as a whole is not something a runner can fix: it is recorded as NOT READ and the run goes on
    // with the failure carried (`notread`). Anything else here is an error to fix, as before.
    let (swallowed, look) = crate::copperchecks::of_run(flow);
    let (mut not_read, other) = crate::notread::split(swallowed);
    // a face the reader gave up on at trace time (`faceread`) is the same failure found earlier: one record, one set of words
    for f in crate::notread::declared(flow) { if !not_read.iter().any(|x| x.side == f.side) { not_read.push(f); } }
    let look: Vec<String> = look.into_iter().filter(|l| !l.contains(" face was NOT READ: ")).collect();
    errs.extend(other);
    if !errs.is_empty() { errs.extend(not_read.iter().flat_map(|f| f.messages.clone())); return Err(errs); }
    let n = inner_vias(&d);
    let mut hints: Vec<String> = not_read.iter().map(crate::notread::sentence).collect();
    if !not_read.is_empty() { hints.push("This is a fault of the copper reader, not of your inputs: nothing you may type changes it, and nobody edits copper by hand. `auto` goes on: attribution runs again without that face, and the review, tests and report carry the failure.".into()); }
    hints.extend(look);
    if n > 0 { hints.push(format!("{n} inner via(s): ringed on both outer faces with no trace on either, so they continue on an inner layer. The board has at least 4 layers.")); }
    else { hints.push("No inner vias recorded. If both faces are fully visible, 2 layers is possible; say so in step 5 only after looking under the large parts and pours.".into()); }
    let mut summary = json!({"zones": d["zones"].as_array().map(|a| a.len()).unwrap_or(0), "traces": d["traces"].as_array().map(|a| a.len()).unwrap_or(0), "vias": d["vias"].as_array().map(|a| a.len()).unwrap_or(0), "innerVias": n, "tags": tag_counts(&its)});
    if !not_read.is_empty() { summary["notRead"] = crate::notread::to_json(&not_read); }
    Ok(Pass { summary, hints, files: vec!["copper.json".into()] })
}

fn find_glob(flow: &Path, prefix: &str) -> bool {
    for base in [flow.to_path_buf(), flow.parent().map(Path::to_path_buf).unwrap_or_default()] {
        if let Ok(rd) = std::fs::read_dir(base.join("evidence")) {
            if rd.flatten().any(|e| e.file_name().to_string_lossy().starts_with(prefix)) { return true; }
        }
    }
    false
}

fn ang(a: f64, b: f64, m: f64) -> f64 { let d = (a - b).rem_euclid(m); d.min(m - d) }


/// The corner points of one zone's outline, from the text after its `(zone` up to its end.
fn zone_outline(z: &str) -> Vec<(f64, f64)> {
    let Some(start) = z.find("(polygon") else { return vec![] };
    let body = &z[start..];
    let body = &body[..body.find("\n\t)").unwrap_or(body.len())];
    body.split("(xy ").skip(1).filter_map(|t| { let mut w = t.split(|c: char| c == ' ' || c == ')'); Some((w.next()?.parse().ok()?, w.next()?.parse().ok()?)) }).collect()
}

fn outline_area(o: &[(f64, f64)]) -> f64 {
    (0..o.len()).map(|i| { let (a, b) = (o[i], o[(i + 1) % o.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>().abs() / 2.0
}

#[cfg(test)]
mod zone_outline_tests {
    use super::*;
    #[test]
    fn a_keep_out_rule_area_is_not_a_zone_with_no_fill_and_a_left_out_part_says_why() {
        let d = std::env::temp_dir().join(format!("mimic-keepout-{}", std::process::id())).join("reverse-flow");
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(d.join("evidence")).unwrap();
        std::fs::write(d.join("evidence/overlay-top.jpg"), "x").unwrap();
        std::fs::write(d.join("parts.json"), json!({"parts": []}).to_string()).unwrap();
        std::fs::write(d.join("copper.json"), json!({"zones": [{"id": "z1"}]}).to_string()).unwrap();
        let peg = "\n\t(zone\n\t\t(net 0)\n\t\t(layers \"F.Cu\")\n\t\t(name \"peg\")\n\t\t(keepout\n\t\t\t(tracks not_allowed)\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t)";
        let pour = "\n\t(zone\n\t\t(net 1)\n\t\t(name \"z1\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t\t(filled_polygon (pts (xy 0 0)))\n\t)";
        let pcb = d.join("b.kicad_pcb");
        std::fs::write(&pcb, format!("(kicad_pcb{peg}{peg}{pour}\n)")).unwrap();
        let r = mimic(&d, &pcb, &json!({"photos": []}));
        let pass = r.unwrap_or_else(|e| panic!("{e:?}"));
        assert_eq!(pass.summary["zones"], 1, "the two rule areas are not counted as copper zones");
        // the same areas with no keepout would be zones that lost their fill
        std::fs::write(&pcb, format!("(kicad_pcb{}{pour}\n)", peg.replace("(keepout", "(other"))).unwrap();
        assert!(mimic(&d, &pcb, &json!({"photos": []})).err().unwrap()[0].contains("zones with no fill: peg"));
        // a 6 mm2 pour with no fill is an error, unless the build recorded it with its area and what covers it: then it is a finding and the check goes on
        let bare = pour.replace("\n\t\t(filled_polygon (pts (xy 0 0)))", "").replace("(xy 4 0) (xy 4 3)", "(xy 4 0) (xy 4 1.5)");
        std::fs::write(&pcb, format!("(kicad_pcb{bare}\n)")).unwrap();
        assert!(mimic(&d, &pcb, &json!({"photos": []})).err().unwrap()[0].contains("zones with no fill: z1"));
        std::fs::write(d.join(crate::kicad_out::LEFT_OUT), json!({"leftOut": [], "noFill": [{"id": "z1", "areaMm2": 6.0, "why": "every part of it lies inside pads of another net: J1.1"}]}).to_string()).unwrap();
        let pass = mimic(&d, &pcb, &json!({"photos": []})).unwrap_or_else(|e| panic!("{e:?}"));
        assert_eq!(pass.summary["noFill"][0]["id"], "z1");
        assert!(pass.hints[0].starts_with("FINDING, not passed: pour z1 (6.0 mm2) is drawn with no fill") && pass.hints[0].contains("J1.1"), "{:?}", pass.hints);
        // a part the build left out is named with the footprint it wants
        std::fs::write(d.join("parts.json"), json!({"parts": [{"ref": "SW2", "side": "top", "footprint": "RE6:THT_3Pin"}]}).to_string()).unwrap();
        let rows = crate::kicad_out::left_out_rows(&[format!("{}SW2: unexpected token at 12", crate::kicad_out::LEFT_OUT_MARK), "zone z9: something else".into()], &[json!({"ref": "SW2", "footprint": "RE6:THT_3Pin"})]);
        assert_eq!(rows.len(), 1);
        std::fs::write(d.join(crate::kicad_out::LEFT_OUT), json!({"leftOut": rows}).to_string()).unwrap();
        let e = mimic(&d, &pcb, &json!({"photos": []})).err().unwrap();
        assert!(e.iter().any(|x| x.contains("SW2: in parts.json but not on the board: the build left it out") && x.contains("RE6:THT_3Pin") && x.contains("reverse-flow/lib/fp/RE6/THT_3Pin.kicad_mod")), "{e:?}");
        std::fs::remove_dir_all(d.parent().unwrap()).ok();
    }
    #[test]
    fn outline_stops_at_the_end_of_its_zone() {
        let z = "\n\t\t(name \"a#1\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t)\n\t(footprint (at 9 9))\n\t(xy 50 50)";
        let o = zone_outline(z);
        assert_eq!(o.len(), 4);
        assert!((outline_area(&o) - 12.0).abs() < 1e-9);
    }
}

pub fn mimic(flow: &Path, pcb: &Path, state: &Value) -> Outcome {
    let d = doc(flow, "parts.json")?;
    let text = std::fs::read_to_string(pcb).map_err(|e| vec![format!("{}: {e}", pcb.display())])?;
    let fps = kicad::footprints(&text);
    let mut errs = vec![];
    let on_board: HashMap<String, &kicad::Footprint> = fps.iter().map(|f| (f.reference.clone(), f)).collect();
    let mut wanted = HashSet::new();
    for p in d["parts"].as_array().into_iter().flatten() {
        let r = s(p, "ref");
        if p["onBoard"] == json!(false) { continue; }
        wanted.insert(r.to_string());
        let Some(f) = on_board.get(r) else {
            let why = items::read_json(&flow.join(crate::kicad_out::LEFT_OUT)).ok().and_then(|d| d["leftOut"].as_array().and_then(|a| a.iter().find(|x| s(x, "ref") == r).map(|x| s(x, "why").to_string())));
            errs.push(match why { Some(w) => format!("{r}: in parts.json but not on the board: the build left it out: {w}"), None => format!("{r}: in parts.json but not on the board") }); continue };
        let side_ok = (s(p, "side") == "top") == f.layer.starts_with("F.");
        if !side_ok { errs.push(format!("{r}: parts.json says {} but the footprint is on {}", s(p, "side"), f.layer)); }
        if let Some((x, y)) = pos(p) {
            let dd = ((f.x - x).powi(2) + (f.y - y).powi(2)).sqrt();
            if dd > 0.5 { errs.push(format!("{r}: on the board at ({:.2}, {:.2}), measured at ({x:.2}, {y:.2}): {dd:.2} mm apart (limit 0.5)", f.x, f.y)); }
        }
        if needs_pin1(p) && s(p, "side") == "top" {
            if let Some(rot) = p["rot"].as_f64() {
                if ang(f.rot, rot, 360.0) > 2.0 { errs.push(format!("{r}: rotated {} on the board, {rot} in parts.json: check pin 1 against the photo", f.rot)); }
            }
        }
    }
    for f in &fps {
        // PHOTO1 holds the holes and metal no footprint explains, drawn from the photo: it is no part
        if f.reference.is_empty() || f.reference.starts_with("REF**") || f.reference == "PHOTO1" { continue; }
        if !wanted.contains(&f.reference) { errs.push(format!("{}: on the board but not in parts.json (add it with its evidence, or remove it)", f.reference)); }
    }
    if !find_glob(flow, "overlay-top") { errs.push("evidence/overlay-top.* missing: overlay the rendered board on the top photo and look at every part".into()); }
    let has_bottom = state["photos"].as_array().into_iter().flatten().any(|p| s(p, "side") == "bottom");
    if has_bottom && !find_glob(flow, "overlay-bottom") { errs.push("evidence/overlay-bottom.* missing: overlay the bottom render on the bottom photo (mirror one of them)".into()); }
    if !errs.is_empty() { return Err(errs); }
    // a keep-out rule area (KiCad's own USB-C footprint carries two named `peg`) is not copper and never has a fill.
    // KiCad writes a zone's `keepout` before its outline, so the text up to the outline says which it is
    let rule_area = |z: &str| z[..z.find("(polygon").unwrap_or(z.len())].contains("(keepout");
    let zones = text.split("\n\t(zone").skip(1).filter(|z| !rule_area(z)).count();
    let unfilled: Vec<(String, Vec<(f64, f64)>)> = text.split("\n\t(zone").skip(1).filter(|z| !z.contains("filled_polygon") && !rule_area(z)).map(|z| (z.split("(name \"").nth(1).and_then(|x| x.split('"').next()).unwrap_or("?").to_string(), zone_outline(z))).collect();
    // a traced island under 5 mm2 can be cut away whole by routed copper and its clearance; a pour must never be.
    // Each zone is judged by its own drawn outline: a holed island is written as several zones (`id#k`) that copper.json lists as one.
    // A small zone centred on a part's origin is metal that part's own pad already draws, so nothing is lost.
    let lost: Vec<String> = unfilled.iter().filter(|(_, o)| {
        if outline_area(o) < 5.0 { return false; }
        let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));
        let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));
        let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
        let on_pad = (x1 - x0).max(y1 - y0) < 6.0 && fps.iter().any(|f| (f.x - cx).hypot(f.y - cy) < 0.3);
        !on_pad
    }).map(|(n, _)| n.clone()).collect();
    let unfilled: Vec<String> = unfilled.into_iter().map(|(n, _)| n).collect();
    // A pour the build itself found covered whole by other copper, and recorded with its area and what covers it (`build-left-out.json`
    // noFill), is a finding: no rebuild and no input changes it, the copper stays in copper.json, and the run goes on. One the build
    // did not record is still an error: that would be a fill lost without a reason.
    let recorded: HashMap<String, (String, String)> = items::read_json(&flow.join(crate::kicad_out::LEFT_OUT)).ok().and_then(|d| d["noFill"].as_array().cloned()).unwrap_or_default().iter().map(|z| (s(z, "id").to_string(), (z["areaMm2"].to_string(), s(z, "why").to_string()))).collect();
    let base = |n: &str| n.split('#').next().unwrap_or(n).to_string();
    let (known, lost): (Vec<String>, Vec<String>) = lost.into_iter().partition(|n| recorded.contains_key(&base(n)));
    if !lost.is_empty() { return Err(vec![format!("zones with no fill: {} (build fills every zone: rebuild, and check the zone is not cut away entirely by other-net copper)", lost.join(", "))]); }
    let no_fill: Vec<Value> = { let mut ids: Vec<String> = known.iter().map(|n| base(n)).collect(); ids.sort(); ids.dedup(); ids.iter().map(|id| json!({"id": id, "areaMm2": recorded[id].0, "why": recorded[id].1})).collect() };
    let cz = items::read_json(&flow.join("copper.json")).ok().and_then(|c| c["zones"].as_array().map(|a| a.len())).unwrap_or(0);
    let mut hints: Vec<String> = no_fill.iter().map(|z| format!("FINDING, not passed: pour {} ({} mm2) is drawn with no fill: {}. It stays in copper.json; the review lists it", s(z, "id"), s(z, "areaMm2"), s(z, "why"))).collect();
    if !unfilled.is_empty() { hints.push(format!("{} small island(s) under 5 mm2 cut away entirely by other copper: {}", unfilled.len(), unfilled.join(", "))); }
    if zones < cz { return Err(vec![format!("the board has {zones} zone(s), copper.json lists {cz}: every seen zone must be on the board")]); }
    let mut summary = json!({"pcb": pcb.display().to_string(), "pcbSha256": sha256(pcb), "footprints": fps.len(), "zones": zones});
    if !no_fill.is_empty() { summary["noFill"] = json!(no_fill); }
    Ok(Pass { summary, hints, files: vec!["parts.json".into(), "copper.json".into()] })
}

pub fn nets(flow: &Path) -> Outcome {
    let d = doc(flow, "nets.json")?;
    let p = doc(flow, "parts.json")?;
    let refs: HashSet<String> = p["parts"].as_array().into_iter().flatten().map(|x| s(x, "ref").to_string()).collect();
    let its = items::items_of("nets.json", &d);
    let mut errs = vec![];
    if its.is_empty() { errs.push("nets.json has no nets".into()); }
    validate_all(flow, &its, &mut errs);
    let mut pin_net: HashMap<String, String> = HashMap::new();
    for n in d["nets"].as_array().into_iter().flatten() {
        let name = s(n, "name");
        let pins: Vec<&str> = n["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
        if pins.is_empty() { errs.push(format!("net:{name}: no pins")); }
        for pin in pins {
            let Some((r, _)) = pin.rsplit_once('.') else { errs.push(format!("net:{name}: pin {pin} is not REF.PIN")); continue };
            // PHOTO1 holds the holes and metal no footprint explains, as in `check mimic`: it is no part
            if r != "PHOTO1" && !refs.contains(r) { errs.push(format!("net:{name}: {r} is not in parts.json")); }
            if let Some(o) = pin_net.insert(pin.into(), name.into()) { errs.push(format!("{pin} is on two nets: {o} and {name}")); }
        }
    }
    if !errs.is_empty() { return Err(errs); }
    Ok(Pass { summary: json!({"nets": its.len(), "pins": pin_net.len(), "tags": tag_counts(&its)}), hints: vec![], files: vec!["nets.json".into(), "parts.json".into()] })
}

pub fn layers(flow: &Path, pcb: Option<&Path>) -> Outcome {
    let d = doc(flow, "layers.json").map_err(|mut e| { e.push("`layers state` writes it without routing: from outside evidence when given, otherwise UNKNOWN with its test".into()); e })?;
    // stated without routing (`layers state`): the default. The rules below judge a count the optional router proposed
    if d["method"] == json!("stated") { return crate::layerstate::check(flow, &d, pcb); }
    let c = doc(flow, "copper.json")?;
    let its = items::items_of("layers.json", &d);
    let mut errs = vec![];
    validate_all(flow, &its, &mut errs);
    let cands: Vec<&Value> = d["candidates"].as_array().into_iter().flatten().collect();
    if cands.is_empty() { errs.push("no candidates: `connect route` tries each stackup and records the result".into()); }
    for k in &cands {
        if k["layers"].as_u64().is_none() || k["open"].as_u64().is_none() { errs.push(format!("candidate {k}: needs `layers` and `open` (connections left after routing)")); }
        if s(k, "run").is_empty() { errs.push(format!("candidate {}: `run` must name the route folder or report it came from", k["layers"])); }
    }
    let proposed = d["proposed"].as_u64().unwrap_or(0);
    // routing that could not tell the count says UNKNOWN with its test (`write_proposal`): that is an honest answer and passes
    let unknown = proposed == 0 && s(&d, "tag") == "UNKNOWN" && d["tests"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !cands.is_empty();
    if proposed == 0 && !unknown { errs.push("`proposed` (the layer count you propose) missing: `layers state` states it without routing".into()); }
    if s(&d, "why").is_empty() { errs.push("`why` missing: the evidence for the proposal (inner vias, routing results, planes)".into()); }
    let inner = inner_vias(&c);
    let floor = if inner > 0 { 4 } else { 2 };
    let tried = cands.iter().filter_map(|k| k["layers"].as_u64()).min().unwrap_or(0);
    if tried > floor { errs.push(format!("the fewest layers routed is {tried}, but the evidence allows {floor}: route at {floor} first, so the proposal is the smallest count that works")); }
    if inner > 0 && proposed > 0 && proposed < 4 { errs.push(format!("{inner} inner via(s) in copper.json prove at least 4 layers; {proposed} proposed")); }
    let chosen = cands.iter().find(|k| k["layers"].as_u64() == Some(proposed));
    if proposed > 0 && chosen.is_none() { errs.push(format!("{proposed} layers proposed but no candidate routed at {proposed}")); }
    if let Some(k) = chosen {
        if let Some(e) = k["drcError"].as_str() { errs.push(format!("KiCad's DRC did not run on the proposed {proposed}-layer route ({e}): its errors and missing connections are unknown. Run `connect route --layers {proposed}` again when the service answers")); }
        if k["open"].as_u64().unwrap_or(0) > 0 && s(&d, "openNote").is_empty() {
            errs.push(format!("the proposed {proposed}-layer route leaves {} connection(s) open: route more, propose more layers, or explain in `openNote` (and list them in tests)", k["open"]));
        }
    }
    let nets: HashSet<String> = items::read_json(&flow.join("nets.json")).ok().map(|n| n["nets"].as_array().into_iter().flatten().map(|x| s(x, "name").to_string()).collect()).unwrap_or_default();
    for pl in d["planes"].as_array().into_iter().flatten() {
        let l = s(pl, "layer");
        let idx = l.strip_prefix("In").and_then(|x| x.strip_suffix(".Cu")).and_then(|x| x.parse::<u64>().ok());
        match idx { Some(i) if proposed >= 2 && i <= proposed - 2 => {}, _ => errs.push(format!("plane {l}: not an inner layer of a {proposed}-layer board (In1.Cu ..)")) }
        if !nets.contains(s(pl, "net")) { errs.push(format!("plane {l}: net {} is not in nets.json", s(pl, "net"))); }
        if s(pl, "why").is_empty() { errs.push(format!("plane {l}: `why` missing (which vias and pins say this layer is {})", s(pl, "net"))); }
    }
    if let Some(p) = pcb.filter(|_| !unknown) {
        let t = std::fs::read_to_string(p).map_err(|e| vec![format!("{}: {e}", p.display())])?;
        let n = kicad::copper_layers(&t);
        if n as u64 != proposed { errs.push(format!("{} has {n} copper layers, {proposed} proposed: save the project with the proposed stackup", p.display())); }
    }
    if !errs.is_empty() { return Err(errs); }
    let min_closed = cands.iter().filter(|k| k["open"].as_u64() == Some(0)).filter_map(|k| k["layers"].as_u64()).min();
    let mut hints = vec![];
    if let Some(m) = min_closed { if proposed > m.max(if inner > 0 { 4 } else { 2 }) { hints.push(format!("{m} layers already closed every connection; {proposed} proposed: make sure `why` says what the extra layers are for")); } }
    Ok(Pass { summary: json!({"proposed": proposed, "innerVias": inner, "candidates": cands.len(), "planes": d["planes"].as_array().map(|a| a.len()).unwrap_or(0)}), hints, files: vec!["layers.json".into(), "nets.json".into(), "copper.json".into()] })
}

pub fn current_hashes(flow: &Path) -> BTreeMap<String, String> {
    REVIEWED.iter().map(|f| f.to_string()).filter(|f| flow.join(f).is_file()).chain(crate::confidence::audit_files(flow)).map(|f| { let h = sha256(&flow.join(&f)); (f, h) }).collect()
}

/// Passes only when every category met the target or the stall is stated honestly: scores and the verdict
/// are computed again from the files, and what review.json records is only compared with them.
pub fn review(flow: &Path) -> Outcome {
    let d = doc(flow, "review.json")?;
    let (s, verdict) = crate::confidence::gate(flow, &d, &current_hashes(flow))?;
    let mut files: Vec<String> = current_hashes(flow).into_keys().collect();
    files.push("review.json".into());
    let mut hints = vec![crate::confidence::overall_line(&s, &verdict)];
    if s.target != crate::confidence::DEFAULT_TARGET { hints.push(format!("the target here is {}, not the default {}", s.target, crate::confidence::DEFAULT_TARGET)); }
    if verdict != "TARGET MET" { hints.push(format!("{verdict}: the photo score did not meet the target. The report must say so")); hints.extend(crate::confidence::stall_lines(&s)); }
    hints.push(format!("bench score {:.1}, reported beside the photo score and outside the target. What would raise it:", s.bench));
    hints.extend(crate::confidence::bench_lines(&s));
    Ok(Pass { summary: json!({"verdict": verdict, "target": s.target, "photo": s.photo, "bench": s.bench, "overall": s.overall, "scores": s.cats.iter().map(|c| (c.category.clone(), json!(c.score))).collect::<serde_json::Map<_, _>>()}), hints, files })
}

pub fn tests(flow: &Path) -> Outcome {
    let d = doc(flow, "tests.json")?;
    let all = items::all(flow);
    let ids: HashSet<&str> = all.iter().map(|i| i.id.as_str()).collect();
    // a face whose copper was not read is an item too, with its own test (`notread`)
    let faces: HashSet<String> = crate::notread::faces(flow).iter().map(|f| crate::notread::item_id(&f.side)).collect();
    let known = |x: &str| ids.contains(x) || faces.contains(x) || x.rsplit_once('.').map(|(a, _)| ids.contains(a)).unwrap_or(false);
    let mut errs = vec![];
    let mut test_ids = HashSet::new();
    let mut settled: HashSet<String> = HashSet::new();
    for t in d["tests"].as_array().into_iter().flatten() {
        let id = s(t, "id");
        if id.is_empty() || !test_ids.insert(id.to_string()) { errs.push(format!("test {id:?}: empty or repeated id")); }
        for k in ["method", "equipment", "expected", "ifNot"] { if s(t, k).is_empty() { errs.push(format!("test {id}: `{k}` missing")); } }
        let st: Vec<&str> = t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
        if st.is_empty() { errs.push(format!("test {id}: settles nothing")); }
        for x in st {
            if !known(x) { errs.push(format!("test {id}: settles {x}, which is no item (part ref, block:, zone:, trace:, via:, net:, plane:, outline, layers)")); }
            settled.insert(x.split('.').next().unwrap_or(x).to_string());
            settled.insert(x.to_string());
        }
    }
    for it in &all {
        for t in &it.tests { if !test_ids.contains(t) { errs.push(format!("{}: names test {t}, which is not in tests.json", it.id)); } }
        let named = it.tests.iter().any(|t| test_ids.contains(t));
        if it.open() && !settled.contains(&it.id) && !named { errs.push(format!("{} ({}) is settled by no test in tests.json", it.id, it.open_what().join(", "))); }
    }
    for f in &faces { if !settled.contains(f) { errs.push(format!("{f}: its copper was not read and no test in tests.json settles it: `tests generate` writes one")); } }
    if !errs.is_empty() { return Err(errs); }
    let mut files: Vec<String> = items::ARTIFACTS.iter().filter(|f| flow.join(f).is_file()).map(|f| f.to_string()).collect();
    files.push("tests.json".into());
    Ok(Pass { summary: json!({"tests": test_ids.len(), "openItems": all.iter().filter(|i| i.open()).count()}), hints: vec![], files })
}

pub fn write_ok(flow: &Path, name: &str, pass: &Pass) -> std::io::Result<()> {
    std::fs::create_dir_all(flow.join("checks"))?;
    // the copper check reads shapes: attribution writes nets into the same file and must not make it stale
    let files: BTreeMap<String, String> = pass.files.iter().map(|f| (f.clone(), if name == "copper" && f == "copper.json" { crate::attrib::geometry_hash(&flow.join(f)) } else { sha256(&flow.join(f)) })).collect();
    let v = json!({"check": name, "at": ledger::now(), "files": files, "summary": pass.summary});
    std::fs::write(flow.join(format!("checks/{name}.ok")), serde_json::to_string_pretty(&v)? + "\n")
}