main
Caleb Publish 0.5.0 1984b40
//! The generated half of the report: evidence counts, the open items and the tests that settle them,
//! the layer proposal, the review passes and the check states. The AI writes report.md around these;
//! the numbers come from the artifacts, never from memory.
use crate::confidence;
use crate::flow::{self, Flow};
use crate::items::{self, TAGS};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
use std::path::Path;

pub fn stats(flow_dir: &Path) -> (String, Value) {
    let all = items::all(flow_dir);
    let mut by: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
    for it in &all {
        let a = it.artifact.trim_end_matches(".json");
        let label = if a == it.kind { a.to_string() } else { format!("{a}: {}", it.kind) };
        *by.entry(label).or_default().entry(it.tag.clone()).or_default() += 1; }
    let mut t = format!("| Items | Total | {} | Known % |\n|---|---|{}---|\n", TAGS.join(" | "), "---|".repeat(TAGS.len()));
    let mut v = serde_json::Map::new();
    for (k, m) in &by {
        let total: usize = m.values().sum();
        let known: usize = ["SEEN", "MEASURED", "DATASHEET"].iter().map(|x| m.get(*x).copied().unwrap_or(0)).sum();
        let pct = if total > 0 { 100.0 * known as f64 / total as f64 } else { 0.0 };
        t += &format!("| {k} | {total} | {} | {pct:.0} |\n", TAGS.iter().map(|x| m.get(*x).copied().unwrap_or(0).to_string()).collect::<Vec<_>>().join(" | "));
        v.insert(k.clone(), json!({"total": total, "tags": m, "knownPercent": (pct * 10.0).round() / 10.0}));
    }
    (t, Value::Object(v))
}

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

/// Computed now, not copied from a pass: the report cannot show a number the files do not give.
fn confidence_section(flow_dir: &Path) -> String {
    let (s, verdict) = current(flow_dir);
    let mut md = format!("\n## Confidence\n\n**{}**\n\n{}", confidence::overall_line(&s, &verdict), confidence::table(&s));
    md += "\nEvery figure is computed from the run's files (docs/method.md, \"Two scores\"). Each ceiling is marked with the score it belongs to. The inputs:\n\n```\n";
    md += &confidence::inputs_text(&s);
    md += "```\n";
    if !s.met() { md += "\n**Photo figures below the target:**\n\n"; for l in confidence::stall_lines(&s) { md += &format!("- {l}\n"); } }
    let bench = confidence::bench_lines(&s);
    if !bench.is_empty() { md += "\n**Bench figures, and what would raise each** (`bench plan` writes the instructions for a person with the real board):\n\n"; for l in bench { md += &format!("- {l}\n"); } }
    if let Ok(r) = items::read_json(&flow_dir.join("review.json")) {
        let passes = r["passes"].as_array().cloned().unwrap_or_default();
        md += "\n### Review loop\n\n| Pass | Verdict | Photo score | Bench score | Note |\n|---|---|---|---|---|\n";
        for p in &passes {
            // a pass recorded before the two scores has one figure, the lowest category
            let fig = |k: &str| p["scores"][k]["score"].as_f64().map(|x| format!("{x:.1}")).unwrap_or_else(|| format!("not split (lowest category {})", p["scores"]["overall"]));
            if p["scores"].is_object() { md += &format!("| {} | {} | {} | {} | {} |\n", p["n"], s_(p, "verdict"), fig("photo"), fig("bench"), s_(p, "note")); }
            else { md += &format!("| {} | typed levels, not counted | | | {} |\n", p["n"], s_(p, "note")); }
        }
    }
    md
}

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

/// The scores now, and what the review's history makes of them.
pub fn current(flow_dir: &Path) -> (confidence::Scores, String) {
    let r = items::read_json(&flow_dir.join("review.json")).unwrap_or(json!({"passes": []}));
    let passes = r["passes"].as_array().cloned().unwrap_or_default();
    let last = passes.iter().rposition(|p| p["scores"].is_object());
    let st = items::read_json(&flow_dir.join("state.json")).unwrap_or(json!({}));
    let target = last.and_then(|i| passes[i]["target"].as_f64()).unwrap_or_else(|| confidence::target_of(&st));
    let s = confidence::score_all(flow_dir, target);
    let verdict = match last { Some(i) => confidence::verdict_for(&passes[..i], &s, passes[i]["maxPasses"].as_u64().unwrap_or(confidence::DEFAULT_MAX_PASSES as u64) as usize).to_string(), None => "NO REVIEW PASS".to_string() };
    (s, verdict)
}

/// A starting report.md: what the program can say by itself, with the computed line, for the AI to write around. `report --draft`
/// writes it only when there is no report.md.
pub fn draft(flow_dir: &Path, line: &str) -> String {
    let name = flow_dir.parent().and_then(|p| p.file_name()).map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
    let count = |f: &str, k: &str| items::read_json(&flow_dir.join(f)).ok().and_then(|d| d[k].as_array().map(|a| a.len())).unwrap_or(0);
    let mut md = format!("# {name}: reverse engineered from photos\n\nThis is the program's draft. It states what was measured and computed; what the board is and what each block does are still to be written by whoever ran the flow.\n\n**{line}**\n\n");
    md += &format!("The copy holds {} parts in {} blocks, {} pours, {} tracks, {} vias and {} nets. Connections no photo shows are left unrouted and listed with their evidence; nothing inside the board was drawn.\n\n", count("parts.json", "parts"), count("blocks.json", "blocks"), count("copper.json", "zones"), count("copper.json", "traces"), count("copper.json", "vias"), count("nets.json", "nets"));
    for f in crate::notread::faces(flow_dir) { md += &format!("{} What the check found: {}\n\n", crate::notread::required(&f), f.messages.join(" ")); }
    if let Some(size) = crate::window::size_sentence(flow_dir) { md += &format!("{size}\n\n"); }
    md += "The generated tables are in report-tables.md: evidence per item type, what is not seen and the test that settles each item, the layer statement, the hidden connections, both scores with every input, and the state of every step. `bench plan` writes the optional instructions for a person with the real board.\n";
    md
}

pub fn tables(flow_dir: &Path, fl: &Flow) -> String {
    let mut md = String::from("# Generated tables\n\nWritten by `adom-aiflow-reverse report` from the artifacts. Do not edit by hand: change the artifacts and run it again.\n\n");
    // first, what the reader must know before any figure: copper that was not read, and a board the photos cut off
    md += &crate::notread::report_section(flow_dir);
    if let Some(size) = crate::window::size_sentence(flow_dir) { md += &format!("## Size\n\n{size}\n\n"); }
    md += "## Evidence per item type\n\n";
    md += &stats(flow_dir).0;

    let tests = items::read_json(&flow_dir.join("tests.json")).unwrap_or(json!({}));
    let mut by_item: HashMap<String, Vec<String>> = HashMap::new();
    let tlist: Vec<Value> = tests["tests"].as_array().cloned().unwrap_or_default();
    for t in &tlist {
        for x in t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {
            by_item.entry(x.split('.').next().unwrap_or(x).to_string()).or_default().push(s(t, "id").to_string());
        }
    }
    md += "\n## What is not seen, and the test that settles it\n\n| Item | Where | Not known | Tests |\n|---|---|---|---|\n";
    for it in items::all(flow_dir).iter().filter(|i| i.open()) {
        let mut ts = by_item.get(&it.id).cloned().unwrap_or_default();
        ts.extend(it.tests.iter().filter(|t| tlist.iter().any(|x| x["id"].as_str() == Some(t.as_str()))).cloned());
        ts.sort(); ts.dedup();
        md += &format!("| {} | {} | {} | {} |\n", it.id, it.artifact, it.open_what().join(", "), if ts.is_empty() { "**none yet**".into() } else { ts.join(", ") });
    }
    if !tlist.is_empty() {
        md += "\n## Tests\n\n| Test | Settles | Method | Equipment | Expected | If not |\n|---|---|---|---|---|---|\n";
        for t in &tlist {
            let st: Vec<&str> = t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
            md += &format!("| {} | {} | {} | {} | {} | {} |\n", s(t, "id"), st.join(", "), s(t, "method"), s(t, "equipment"), s(t, "expected"), s(t, "ifNot"));
        }
    }

    if let Ok(l) = items::read_json(&flow_dir.join("layers.json")) {
        let head = match l["proposed"].as_u64() { Some(n) => format!("**{}: {n} layers** ({})", if l["method"] == json!("stated") { "Stated" } else { "Proposed" }, s(&l, "tag")), None => "**Layer count: UNKNOWN**".to_string() };
        md += &format!("\n## Layer count\n\n{head}. {}\n", s(&l, "why"));
        if l["minimum"].is_object() { md += &format!("\nFrom the photos alone: at least {} ({}). {}\n", l["minimum"]["layers"], s(&l["minimum"], "tag"), s(&l["minimum"], "why")); }
        // never routed: the hidden connections are listed, not drawn
        if l["candidates"].as_array().map(|a| a.is_empty()).unwrap_or(true) {
            let nets: Vec<Value> = items::read_json(&flow_dir.join("nets.json")).ok().and_then(|n| n["nets"].as_array().cloned()).unwrap_or_default();
            let hidden = crate::unrouted::connections(&nets);
            let joined: std::collections::HashSet<String> = crate::unrouted::measured_now(flow_dir, &nets).into_iter().map(|h| h.id).collect();
            let mut by: BTreeMap<&str, usize> = BTreeMap::new();
            for h in &hidden { *by.entry(if joined.contains(&h.id) { "MEASURED" } else { h.tag.as_str() }).or_default() += 1; }
            md += &format!("\n## Hidden connections\n\nNot routed: `connect route` is optional, and its routes are one way to make the connections, not what is inside the board. {} connection(s) no photo shows stay as ratsnest lines in the KiCad copy, each in a net class named for its evidence, and are listed in reverse-flow/unrouted.json.\n\n| Evidence | Connections |\n|---|---|\n", hidden.len());
            for (k, n) in by { md += &format!("| {k} | {n} |\n"); }
        }
        if l["candidates"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { md += "\n| Layers | Open after routing | New DRC errors | Missing per KiCad | Vias | Track mm | Crossing seen copper, mm | Run |\n|---|---|---|---|---|---|---|---|\n"; }
        for c in l["candidates"].as_array().into_iter().flatten() {
            md += &format!("| {} | {} | {} | {} | {} | {} | {} | {} |\n", c["layers"], c["open"], c.get("drcErrors").cloned().unwrap_or(json!("")), c.get("kicadOpen").cloned().unwrap_or(json!("")), c.get("vias").cloned().unwrap_or(json!("")), c.get("trackMm").cloned().unwrap_or(json!("")), c.get("seenCrossingMm").cloned().unwrap_or(json!("")), s(c, "run"));
        }
        let planes = l["planes"].as_array().cloned().unwrap_or_default();
        if !planes.is_empty() {
            md += "\n| Inner layer | Net | Tag | Why |\n|---|---|---|---|\n";
            for p in planes { md += &format!("| {} | {} | {} | {} |\n", s(&p, "layer"), s(&p, "net"), s(&p, "tag"), s(&p, "why")); }
        }
    }

    md += &confidence_section(flow_dir);

    md += "\n## Steps\n\n| Step | State |\n|---|---|\n";
    for st in &fl.steps {
        let done = st.done.iter().all(|x| flow::exists(flow_dir, x));
        let stale: Vec<String> = st.done.iter().flat_map(|x| flow::stale(flow_dir, x)).collect();
        md += &format!("| {} | {} |\n", st.name, if done { "done".into() } else if !stale.is_empty() { format!("stale ({})", stale.join(", ")) } else { "not done".into() });
    }
    md
}