← Commit history

Merge PR #476: AI Flow 0.2.70: check each wiki part against its datasheet (aiflow-0.2.70 -> main) merge

Colby Knox ·591cf0613a · ·parents 257491d, d6d9d33

Showing changes introduced by the merge (against its first parent).

9 files changed +258−13
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.69"+version = "0.2.70" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.69"+version = "0.2.70" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.69"+version = "0.2.70" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.69"+version = "0.2.70" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.69"+version = "0.2.70" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.69"+version = "0.2.70" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.69"+version = "0.2.70" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.69"+version = "0.2.70" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.69"+version = "0.2.70" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.69"+version = "0.2.70" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.69"+version = "0.2.70" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+60
@@ -3,6 +3,7 @@ mod continuation; mod supply; mod priorities;+mod part_check; mod gerber_silk; mod copper_carry; mod artifact_store;@@ -310,6 +311,11 @@ enum Cmd {     #[arg(long)] about: Option<String> },     /// `priorities`: the board's two rankings (issues #129, #130), spec.signalPriority (signal classes, each with nets and why; routed in that order) and spec.placementPriorities (thermal, EMC, routability with rank and why; `place check` scores in that order). Shows what the spec has, drafts what it lacks from the spec and the net names (a start to reorder and explain, never the answer), and scores the current board's placement. `--write <file>` saves the drafts as JSON to edit into the spec.     Priorities { #[arg(long)] write: Option<String> },+    /// `part-check [--file design/part-checks.json]` (issue #157): compare what each part's datasheet says (the facts+    /// you read and wrote down, each with its source: maker, body height, the recommended land pad by pad) with the+    /// footprint and 3D model the board will use (its wiki page's, or the project library's), and the BOM's maker.+    /// Findings are suggestions: fix the page, or give the part from the project library with "prefer": true.+    PartCheck { #[arg(long, default_value = part_check::DEFAULT_FILE)] file: String },     /// The background media worker (issue #170): checks and cuts the clips queued in <run>/media/queue and writes     /// the results to <run>/media/done for the next command to fold in. Started by AI Flow itself, one at a time.     #[command(hide = true)]@@ -4344,6 +4350,8 @@ fn main() {                 findings(&format!("{} of {} parts have no Adom wiki component page{} yet; the decision each needs:\n{}\n{}{}", missing.len(), rows.len(), if plib.is_some() { " and no complete project-library entry" } else { "" }, lead.join("\n"), list.join("\n"), if gaps.is_empty() { String::new() } else { format!("\n{}", gaps.join("\n")) }),                     &["For each part with no page: `part-request <MPN>` asks the factory; a refusal gets one retry with a changed input; a part still without a page gets a supply assessment and up to three alternatives (`supply-risk`), and the human chooses (aiflow-sourcing skill, \"When a part cannot get a page\"). The project page and the KiCad library are built from these pages.".into(), "A part whose page cannot be public yet: `parts --project-lib <dir>` with its symbol, footprint, STEP model and a provenance.json entry (aiflow-sourcing skill).".into()]);             }+            // issue #157: a page matched by MPN is not yet a page checked against the datasheet+            note("a page matched by MPN is not proof its footprint and model match the datasheet: write what each datasheet says in design/part-checks.json and run `adom-aiflow part-check` (aiflow-sourcing, \"Check each part against its datasheet\")", &[]);             ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]);         }         Cmd::Priorities { write } => {@@ -4382,6 +4390,49 @@ fn main() {             hints.push("Rank by this board: the highest speed, the most sensitive and the highest power first (a USB pair before a 9600-baud UART; two hot chips with one slow link: thermal first). `place check` scores the placement in your order; `route` routes the classes in it; gate and finish report per class.".into());             ok(&out.join("\n"), &hints);         }+        Cmd::PartCheck { file } => {+            let mut r = load_run(&cli);+            let text = std::fs::read_to_string(file).unwrap_or_else(|e| err(&format!("{file}: {e}"), &["Write what each part's datasheet says there (aiflow-sourcing skill, \"Check each part against its datasheet\"), then run this again.".into()]));+            let doc: Value = serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("{file}: not JSON: {e}"), &[]));+            let facts: Vec<Value> = doc["parts"].as_array().or(doc.as_array()).cloned().unwrap_or_default();+            let rows: Vec<Value> = r.data["outcomes"]["parts"]["parts"].as_array().cloned().unwrap_or_default();+            let wiki_file = |page: &str, path: &str| -> Option<String> {+                let o = std::process::Command::new("adom-wiki").args(["repo", "show", page, path]).stdin(std::process::Stdio::null()).output().ok().filter(|o| o.status.success())?;+                Some(String::from_utf8_lossy(&o.stdout).to_string())+            };+            let (mut results, mut lines) = (Vec::new(), Vec::new());+            for f in &facts {+                let mpn = f["mpn"].as_str().unwrap_or("");+                let row = rows.iter().find(|x| x["mpn"].as_str().is_some_and(|m| m.eq_ignore_ascii_case(mpn))).cloned().unwrap_or(Value::Null);+                let bom_maker = row["manufacturer"].as_str().map(str::to_string);+                let (what, pads, height, maker) = if row["projectLib"].is_object() {+                    let pl = &row["projectLib"];+                    let pads = pl["footprintFile"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).map(|t| part_check::footprint_pads(&t)).unwrap_or_default();+                    let h = pl["model"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| part_check::step_height(&t));+                    ("the project library".to_string(), pads, h, pl["provenance"]["manufacturer"].as_str().map(str::to_string))+                } else if let Some(page) = row["wiki"].as_str().or(f["page"].as_str()) {+                    let pj = wiki_page_json(page).unwrap_or(Value::Null);+                    let parts = &pj["component"]["parts"];+                    let pads = parts["footprint"].as_str().and_then(|fp| wiki_file(page, fp)).map(|t| part_check::footprint_pads(&t)).unwrap_or_default();+                    let h = parts["step"].as_str().and_then(|st| wiki_file(page, st)).and_then(|t| part_check::step_height(&t));+                    (page.to_string(), pads, h, pj["component"]["manufacturer"].as_str().map(str::to_string))+                } else {+                    lines.push(format!("{mpn}: not in the parts record and no \"page\" given: nothing to compare (run `parts` first)"));+                    continue;+                };+                let (v, fl) = part_check::compare(f, &what, &pads, height, maker.as_deref(), bom_maker.as_deref());+                results.push(v);+                lines.extend(fl);+            }+            let checked: Vec<String> = facts.iter().filter_map(|f| f["mpn"].as_str().map(|m| m.to_ascii_uppercase())).collect();+            let unchecked: Vec<String> = rows.iter().filter(|x| x["wiki"].is_string() || x["projectLib"].is_object()).filter_map(|x| x["mpn"].as_str()).filter(|m| !checked.contains(&m.to_ascii_uppercase())).map(str::to_string).collect();+            r.outcome("part-check", json!({"file": std::fs::canonicalize(file).map(|p| p.display().to_string()).unwrap_or(file.clone()), "parts": results, "findings": lines, "unchecked": unchecked}));+            r.save().unwrap();+            let tail = if unchecked.is_empty() { String::new() } else { format!("\nnot checked yet ({}): {}", unchecked.len(), unchecked.iter().take(20).cloned().collect::<Vec<_>>().join(", ")) };+            let hints = vec!["A page that is wrong: fix it when it is yours, or open an issue on it with what the datasheet says (never edit someone else's page); meanwhile give the part from the project library with \"prefer\": true (aiflow-sourcing).".to_string()];+            if !lines.is_empty() { findings(&format!("{} part(s) checked against their datasheets; what disagrees:\n  {}{tail}", results.len(), lines.join("\n  ")), &hints); }+            ok(&format!("{} part(s) checked against their datasheets: footprints, models and makers agree{tail}", results.len()), &[]);+        }         Cmd::PartRequest { mpn, as_mpn, datasheet, manufacturer, refresh, later } => {             let t = thread(&cli);             let mut r = load_run(&cli);@@ -5921,6 +5972,13 @@ fn main() {             let (sig_ranked, place_ranked) = { let sp_ = spec_soft(&r); (!aiflow_router::signal_priority::from_spec(&sp_).is_empty(), !priorities::placement_from_spec(&sp_).is_empty()) };             if !sig_ranked { quality_lines.push("signal classes: not ranked (spec.signalPriority; `adom-aiflow priorities` drafts one): the router used its own order".into()); }             if !place_ranked { quality_lines.push("placement priorities: not ranked (spec.placementPriorities)".into()); }+            // issue #157: parts on wiki pages (or the project library) never compared with their datasheets+            {+                let rows = r.data["outcomes"]["parts"]["parts"].as_array().cloned().unwrap_or_default();+                let checked: Vec<String> = r.data["outcomes"]["part-check"]["parts"].as_array().into_iter().flatten().filter_map(|p| p["mpn"].as_str().map(|m| m.to_ascii_uppercase())).collect();+                let n = rows.iter().filter(|x| x["wiki"].is_string() || x["projectLib"].is_object()).filter(|x| x["mpn"].as_str().is_some_and(|m| !checked.contains(&m.to_ascii_uppercase()))).count();+                if n > 0 { quality_lines.push(format!("parts: {n} part(s) not checked against their datasheets (land, body height, maker): `adom-aiflow part-check` (aiflow-sourcing)")); }+            }             // every required step before the finish line, done in order and proven             let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();             // issue #60: declared mechanical and wiring reviews, bound to this board, spec and review file@@ -5936,6 +5994,8 @@ fn main() {             let parts_v = o.get("parts").map(|p| p["parts"].clone()).unwrap_or(Value::Null);             let lib_parts = project_lib::report(&parts_v);             for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); }+            // issue #157: a part whose footprint or model disagrees with its datasheet (the AI's own facts)+            for f in o.get("part-check").and_then(|p| p["findings"].as_array()).into_iter().flatten().filter_map(Value::as_str) { missing.push(format!("part-check: {f}")); }             // issue #199: a board page (or a molecule's) must show this board's design review, not an earlier one's             if let Some(item) = page_results::page_item(&r.data, &sha256_file(&r.current_board()), molecule_pins::wants_molecule(&spec_soft(&r), &requirements_of(&r))) { missing.push(item); }             // issue #193: a part with no page is reported with its requests, its assessment and the next command
crates/adom-aiflow/src/part_check.rsadded+163
@@ -0,0 +1,163 @@+//! Issue #157 (Josh): a part's wiki page (or project-library entry) can disagree with its manufacturer's+//! datasheet: a thermal pad's height and edge, a 0402 body drawn 0.35 mm tall against the datasheet's 0.5, a+//! shunt with generic IPC pads instead of the maker's recommended land, a TVS page naming another maker.+//! `parts` matches pages by MPN and cannot see that. Reading a datasheet is the AI's work: it writes what the+//! datasheet says in `design/part-checks.json`, each fact with its source, and `part-check` compares those facts+//! with the footprint and the 3D model the board will actually use:+//!+//! ```json+//! {"parts": [{"mpn": "CL05B104KB54PNC", "source": "datasheet rev 2.1, p. 4, fig. 3 (https://...)",+//!   "manufacturer": "Samsung Electro-Mechanics", "bodyHeightMm": {"nom": 0.5, "max": 0.55},+//!   "land": [{"pad": "1", "x": -0.48, "y": 0, "w": 0.56, "h": 0.62}, {"pad": "2", "x": 0.48, "y": 0, "w": 0.56, "h": 0.62}]}]}+//! ```+//!+//! `land` is the maker's recommended land in the footprint's own frame (mm, y down, pad 1 where the footprint+//! has it); give only the pads the datasheet dimensions. Findings are suggestions: fix the page (or open an issue+//! on it when it is not yours), or give the part from the project library with `"prefer": true`.+use aiflow_board::sx;+use serde_json::{json, Value};++pub const DEFAULT_FILE: &str = "design/part-checks.json";+/// A pad centre or size this far from the datasheet is reported (mm).+pub const POS_TOL: f64 = 0.05;+pub const SIZE_TOL: f64 = 0.05;+/// A model this far from the datasheet's body height is reported (mm).+pub const HEIGHT_TOL: f64 = 0.05;++#[derive(Debug, Clone, PartialEq)]+pub struct Pad { pub num: String, pub x: f64, pub y: f64, pub w: f64, pub h: f64 }++/// The pads of a footprint file (`.kicad_mod`), in its own frame. A pad turned 90 or 270 degrees has its size swapped.+pub fn footprint_pads(text: &str) -> Vec<Pad> {+    let Ok(root) = sx::parse(text) else { return Vec::new() };+    root.find_all("pad").filter_map(|p| {+        let at = p.find("at")?;+        let size = p.find("size")?;+        let (mut w, mut h) = (size.number(1)?, size.number(2).or(size.number(1))?);+        if at.number(3).is_some_and(|r| ((r.rem_euclid(180.0)) - 90.0).abs() < 1.0) { std::mem::swap(&mut w, &mut h); }+        Some(Pad { num: p.atom(1).unwrap_or("").to_string(), x: at.number(1)?, y: at.number(2)?, w, h })+    }).collect()+}++/// The height of a STEP model: the spread of its points' z (mm), when it has any.+pub fn step_height(text: &str) -> Option<f64> {+    let mut lo = f64::MAX;+    let mut hi = f64::MIN;+    for line in text.split("CARTESIAN_POINT").skip(1) {+        // CARTESIAN_POINT('name',(x,y,z)): the coordinates are the last parenthesised group before ';'+        let stmt = &line[..line.find(';').unwrap_or(line.len())];+        let Some(close) = stmt.rfind(')').and_then(|c| stmt[..c].rfind(')')) else { continue };+        let Some(open) = stmt[..close].rfind('(') else { continue };+        let nums: Vec<f64> = stmt[open + 1..close].split(',').filter_map(|v| v.trim().parse::<f64>().ok()).collect();+        if nums.len() == 3 { lo = lo.min(nums[2]); hi = hi.max(nums[2]); }+    }+    (hi >= lo).then_some(hi - lo)+}++fn norm(s: &str) -> String {+    s.to_ascii_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>()+        .replace("incorporated", "").replace("corporation", "").replace("inc", "").replace("corp", "").replace("ltd", "").replace("co", "")+}++/// Two maker names for the same company: equal once lower-cased and stripped of punctuation and suffixes, or one inside the other.+pub fn same_maker(a: &str, b: &str) -> bool {+    let (x, y) = (norm(a), norm(b));+    !x.is_empty() && !y.is_empty() && (x == y || x.contains(&y) || y.contains(&x))+}++/// Compare one part's datasheet facts with what the board uses. `what` names the source ("adom/x" or "project+/// library"), `page_maker` the maker the page or entry names, `bom_maker` the BOM's.+pub fn compare(facts: &Value, what: &str, pads: &[Pad], height: Option<f64>, page_maker: Option<&str>, bom_maker: Option<&str>) -> (Value, Vec<String>) {+    let mpn = facts["mpn"].as_str().unwrap_or("?");+    let mut f = Vec::new();+    // the maker: the datasheet's, the page's and the BOM's are one company+    if let Some(ds) = facts["manufacturer"].as_str() {+        if let Some(pm) = page_maker.filter(|pm| !same_maker(ds, pm)) { f.push(format!("{mpn}: {what} names the maker {pm}, the datasheet {ds}")); }+        if let Some(bm) = bom_maker.filter(|bm| !bm.trim().is_empty() && !same_maker(ds, bm)) { f.push(format!("{mpn}: the BOM names the maker {bm}, the datasheet {ds}: is this the same part?")); }+    }+    // the land, pad by pad+    let mut compared = 0;+    for l in facts["land"].as_array().into_iter().flatten() {+        let n = l["pad"].as_str().map(str::to_string).or_else(|| l["pad"].as_u64().map(|x| x.to_string())).unwrap_or_default();+        let mine: Vec<&Pad> = pads.iter().filter(|p| p.num == n).collect();+        if mine.is_empty() { f.push(format!("{mpn}: the datasheet's pad {n} is not in the footprint of {what}")); continue; }+        compared += 1;+        let (x, y, w, h) = (l["x"].as_f64(), l["y"].as_f64(), l["w"].as_f64(), l["h"].as_f64());+        // a repeated pad number (a split thermal pad) is compared with the nearest of its pads+        let p = mine.iter().min_by(|a, b| {+            let d = |q: &Pad| (x.unwrap_or(q.x) - q.x).powi(2) + (y.unwrap_or(q.y) - q.y).powi(2);+            d(a).total_cmp(&d(b))+        }).unwrap();+        let mut diffs = Vec::new();+        if let (Some(x), Some(y)) = (x, y) {+            let d = ((x - p.x).powi(2) + (y - p.y).powi(2)).sqrt();+            if d > POS_TOL { diffs.push(format!("centre ({:.3}, {:.3}) against the datasheet's ({x:.3}, {y:.3}), {d:.3} mm off", p.x, p.y)); }+        }+        if let Some(w) = w { if (w - p.w).abs() > SIZE_TOL { diffs.push(format!("width {:.3} against {w:.3}", p.w)); } }+        if let Some(h) = h { if (h - p.h).abs() > SIZE_TOL { diffs.push(format!("height {:.3} against {h:.3}", p.h)); } }+        if !diffs.is_empty() { f.push(format!("{mpn}: pad {n} of {what}: {}", diffs.join(", "))); }+    }+    // the body height+    let (nom, max) = (facts["bodyHeightMm"]["nom"].as_f64().or(facts["bodyHeightMm"].as_f64()), facts["bodyHeightMm"]["max"].as_f64());+    match (height, nom.or(max)) {+        (Some(hm), Some(want)) => {+            let low = nom.unwrap_or(want) - HEIGHT_TOL;+            let high = max.unwrap_or(want) + HEIGHT_TOL;+            if hm < low || hm > high { f.push(format!("{mpn}: the 3D model of {what} is {hm:.2} mm tall, the datasheet {} ", match (nom, max) { (Some(n), Some(m)) => format!("{n:.2} mm (max {m:.2})"), (Some(n), None) => format!("{n:.2} mm"), (None, Some(m)) => format!("at most {m:.2} mm"), _ => String::new() }).trim_end().to_string()); }+        }+        (None, Some(_)) => f.push(format!("{mpn}: no 3D model of {what} to measure against the datasheet's body height")),+        _ => {}+    }+    if facts["source"].as_str().is_none_or(|s| s.trim().is_empty()) { f.push(format!("{mpn}: say where each fact comes from (\"source\": the datasheet, its revision, page and figure)")); }+    (json!({"mpn": mpn, "checked": what, "padsCompared": compared, "modelHeightMm": height.map(|h| (h * 1000.0).round() / 1000.0), "findings": f, "pass": f.is_empty()}), f)+}++#[cfg(test)]+mod tests {+    use super::*;++    const FP: &str = r#"(footprint "R_2512" (layer "F.Cu")+      (pad "1" smd roundrect (at -2.9 0) (size 1.5 3.4) (layers "F.Cu"))+      (pad "2" smd roundrect (at 2.9 0 90) (size 3.4 1.5) (layers "F.Cu")))"#;++    #[test]+    fn reads_pads_and_a_step_height() {+        let p = footprint_pads(FP);+        assert_eq!(p.len(), 2);+        assert_eq!((p[1].w, p[1].h), (1.5, 3.4), "a pad turned 90 degrees has its size swapped");+        let step = "#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=CARTESIAN_POINT('',(1.0,0.5,0.35));\n#3=DIRECTION('',(0.,0.,1.));";+        assert!((step_height(step).unwrap() - 0.35).abs() < 1e-9);+    }++    /// The four disagreements of #157, each named; a part that matches says nothing.+    #[test]+    fn names_each_disagreement_and_passes_a_match() {+        let pads = footprint_pads(FP);+        // the shunt: generic IPC pads against the maker's land+        let shunt = json!({"mpn": "WSL2512", "source": "Vishay WSL datasheet p. 6", "manufacturer": "Vishay Dale",+            "land": [{"pad": "1", "x": -2.6, "y": 0, "w": 2.1, "h": 3.4}, {"pad": "2", "x": 2.6, "y": 0, "w": 2.1, "h": 3.4}]});+        let (_, f) = compare(&shunt, "adom/wsl2512", &pads, Some(0.65), Some("Vishay"), Some("Vishay Dale"));+        assert!(f.iter().any(|x| x.contains("pad 1 of adom/wsl2512") && x.contains("0.300 mm off") && x.contains("width 1.500 against 2.100")), "{f:?}");+        // the 0402 drawn 0.35 tall against 0.5+        let cap = json!({"mpn": "C0402", "source": "p. 2", "bodyHeightMm": {"nom": 0.5, "max": 0.55}});+        let (_, f) = compare(&cap, "adom/c0402", &[], Some(0.35), None, None);+        assert_eq!(f, vec!["C0402: the 3D model of adom/c0402 is 0.35 mm tall, the datasheet 0.50 mm (max 0.55)"]);+        // the TVS page naming another maker+        let tvs = json!({"mpn": "SMBJ5.0A", "source": "Littelfuse SMBJ p. 1", "manufacturer": "Littelfuse"});+        let (_, f) = compare(&tvs, "adom/smbj5-0a", &[], None, Some("Vishay General Semiconductor"), Some("Littelfuse Inc."));+        assert_eq!(f.len(), 1, "{f:?}");+        assert!(f[0].contains("names the maker Vishay General Semiconductor"));+        // a match+        let ok = json!({"mpn": "R", "source": "p. 1", "manufacturer": "Yageo", "bodyHeightMm": 0.65, "land": [{"pad": "1", "x": -2.9, "y": 0.02, "w": 1.5, "h": 3.4}]});+        let (v, f) = compare(&ok, "adom/r", &pads, Some(0.66), Some("YAGEO Corporation"), Some("Yageo"));+        assert!(f.is_empty() && v["pass"] == true, "{f:?}");+    }++    #[test]+    fn a_split_thermal_pad_is_compared_with_its_nearest_piece() {+        let fp = r#"(footprint "Q" (pad "9" smd rect (at -0.5 0) (size 0.9 1.8)) (pad "9" smd rect (at 0.5 0) (size 0.9 1.8)))"#;+        let facts = json!({"mpn": "Q", "source": "p. 9", "land": [{"pad": "9", "x": 0.5, "y": 0.1, "w": 0.9, "h": 1.6}]});+        let (_, f) = compare(&facts, "adom/q", &footprint_pads(fp), None, None, None);+        assert_eq!(f, vec!["Q: pad 9 of adom/q: centre (0.500, 0.000) against the datasheet's (0.500, 0.100), 0.100 mm off, height 1.800 against 1.600"]);+    }+}
docs/release-0.2.70.mdadded+10
@@ -0,0 +1,10 @@+# AI Flow 0.2.70++- **#157 (Josh), the part left: a wiki part checked against its datasheet.** `adom-aiflow part-check+  [--file design/part-checks.json]` compares what the AI read in each datasheet (maker, body height, the+  recommended land pad by pad, each with its source) with the footprint and STEP the board will use, the wiki+  page's or the project library's: a pad centre or size more than 0.05 mm off, a model more than 0.05 mm+  outside the body height, a page or BOM naming another maker. A repeated pad number (a split thermal pad) is+  compared with its nearest piece. `parts` says a matched page is not yet a checked one; `finish` lists+  part-check findings as open items and notes the parts never checked. The aiflow-sourcing skill has the+  section "Check each part against its datasheet".
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.69",+  "version": "0.2.70",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.69",+  "version": "0.2.70",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/aiflow-sourcing/SKILL.md+12
@@ -190,6 +190,18 @@ Verified example, 2026-10-08: Pup showed Mouser Access Denied for MF40200V1-1000    `risk`: high (one source, thin stock against the build and the product's life, NRND or last-time-buy, no land pattern published), medium, or low. Assess a project-library part the same way (a part with no public page is often the same part that is hard to buy). 3. **Propose up to three alternatives**: active, multi-sourced, in stock at the profile's preferred source, ideally already on the wiki or in Adom Basic Parts, each with what it changes on the board (footprint, values, firmware). Lead your report with the decision, as `parts` and `finish` do: "U7: refused twice (no land pattern); supply risk high (NRND, 1 distributor); alternatives ALT1, ALT2". **The human chooses; never substitute on your own.** Record their choice with `supply-risk --choose <MPN> --pick <alternative|keep> --why "<their words>"`; then put the alternative in the BOM (and `sourcing check`, `parts` again), or keep the part through the project library below. +## Check each part against its datasheet++A page `parts` matched by MPN is a page with the right name, not proof that its footprint and model match the part (issue #157: a thermal pad's height and edge off, a 0402 body drawn 0.35 mm tall against 0.5, a shunt with generic IPC pads instead of the maker's land, a TVS page naming another maker). For each part, read its datasheet and write what it says in `design/part-checks.json`, each part with its `source` (the datasheet, its revision, page and figure):++```json+{"parts": [{"mpn": "WSL2512R0100FEA", "source": "Vishay WSL datasheet rev 12, p. 6, recommended land",+  "manufacturer": "Vishay Dale", "bodyHeightMm": {"nom": 0.64, "max": 0.71},+  "land": [{"pad": "1", "x": -2.6, "y": 0, "w": 2.1, "h": 3.4}, {"pad": "2", "x": 2.6, "y": 0, "w": 2.1, "h": 3.4}]}]}+```++`land` is the maker's recommended land in the footprint's own frame (mm, y down, pad 1 where the footprint has it); give the pads the datasheet dimensions, including the thermal pad. Then `adom-aiflow part-check`: it reads the footprint and the STEP the board will use (the wiki page's, or the project library's) and reports each pad whose centre or size is more than 0.05 mm off, a model more than 0.05 mm outside the datasheet's body height, and a page or BOM naming another maker. A finding: fix the page when it is yours; otherwise open an issue on it with what the datasheet says, and give the part from the project library with `"prefer": true` meanwhile. `finish` lists the findings, and notes the parts never checked.+ ## Parts with no public component page yet  When a part's page must stay private, waits for an owner's sign-off, or the footprint is a reviewed project-only one, give it from the board's project library: `adom-aiflow parts --project-lib <dir>` (or `"projectLib": "<dir>"` in the spec or requirements). The folder holds the KiCad symbols (`*.kicad_sym`), footprints (`*.pretty/*.kicad_mod`), STEP models and a `provenance.json`: