app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Publish 0.2.31
16 files changed
+470−35
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+12−3bin/adom-aiflowcrates/adom-aiflow/src/assembly_contacts.rs+138crates/adom-aiflow/src/main.rs+33−6crates/adom-aiflow/src/mechanical.rs+7−2crates/adom-aiflow/src/placement_verify.rs+12−4crates/adom-aiflow/src/thermalvias.rs+6−1crates/aiflow-analyze/src/lead_cooling.rs+135crates/aiflow-analyze/src/lib.rs+44−3docs/approved-courtyard-contacts.md+25docs/lead-cooled-parts.md+22package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+23−3Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.30"+version = "0.2.31" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.30"+version = "0.2.31" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.30"+version = "0.2.31" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.30"+version = "0.2.31" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.30"+version = "0.2.31" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.30"+version = "0.2.31" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.30"+version = "0.2.31" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.30"+version = "0.2.31" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.30"+version = "0.2.31" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.30"+version = "0.2.31" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.30"+version = "0.2.31" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+12−3@@ -6,7 +6,9 @@ description: >- # adom-aiflow: the loop, measured -This tool is the place-route-pour-analyze skill made executable: the step order is a file, the gates refuse, the hints arrive as the answer to the command you just ran, and the ledger records what you did. Follow the commands and the skill follows itself.+This tool is the place-route-pour-analyze skill made executable: the step order is a file, the checks report what is wrong, the hints arrive as the answer to the command you just ran, and the ledger records what you did. Follow the commands and the skill follows itself.++**AI Flow is a helper, not a wall.** It gives you ready-made tools (the router, pours, DRC and analysis gates, linters) so you don't write them from scratch, hints for the next step, and the Adom habits: public wiki pages for new parts that every Adom user then shares, and a project page with the 3D, schematic and board viewers and the datasheets. You are still the engineer. When a check refuses a board that is right, because AI Flow has not seen a design like it, don't stop and don't fake a pass: improve AI Flow (see "Contribute to AI Flow" at the end) and finish the board with your build. Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kicad` for the offline DRC gate, and the KiCad Bridge on a desktop for the live stages). `adom-aiflow --version`. @@ -344,7 +346,7 @@ The gates read the board and the tools' own output, so a file written to look li - `step <name> --back` only returns to an earlier step. - **Silkscreen audit geometry is the board's (0.1.97).** `silkscreen-audit --record` and `finish` check the supplied obstacle boxes against the saved board: every pad of every footprint must fall inside one of that footprint's obstacle boxes (ids `U1`, `U1:body`, `U1:pad:1`), and every via inside a via/hole box as large as the via. Measure the boxes from the board; placeholder or point-sized boxes are refused. - **Thermal counts the board's vias (0.1.96).** `analyze thermal` counts the vias on each tab net in the saved board as well as the routing plan, so thermal vias landed after routing count once the board is saved.-- **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet`, `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. Every gate refuses a spec whose budgets moved since. Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary.+- **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet` (or `cooling: "leads"` for a part with no exposed tab, judged by the Fields solve: docs/lead-cooled-parts.md), `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. Every gate refuses a spec whose budgets moved since. Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary. ## Spec intent checked by the binary (0.2.3) @@ -474,8 +476,15 @@ Thermal-via candidates and existing-via counts must be within 3 mm of the specif ## Contribute to AI Flow +Every Adom user's AI is welcome to improve AI Flow. When a check is wrong for this board, a helper is missing, or a hint misleads you, fix it in the source and send the change back. That also gets this board built: your build runs it, which tests the change on a real design.++1. `adom-wiki repo log adom/adom-aiflow --limit 1` (note the head hash), then `adom-wiki repo clone adom/adom-aiflow --dir aiflow-src`.+2. Make the smallest change that handles the case: teach the check about the design, rather than switching it off. Add a test beside the code.+3. `cargo build --release`, `cargo test --release`, then run this board with `aiflow-src/target/release/adom-aiflow`.+4. Send it back: `adom-wiki pr create adom/adom-aiflow --title "..." --files <each changed file> --body-file <pr.md>`. In pr.md, give the base hash, what blocked the board, what you changed and how you tested it. If the head moved, re-clone and re-apply before opening it. Or, if you can't make the change, `adom-wiki issue create adom/adom-aiflow` with the board's evidence.+ When the user asks “Help me contribute an improvement to adom-aiflow through a pull request”,-read `dev-skills/review-safe-contributions/SKILL.md` from the public source repository.+also read `dev-skills/review-safe-contributions/SKILL.md` from the public source repository. Discuss the specific improvement, clone the current immutable source, implement a focused change, run the relevant checks and review the diff before opening the requested PR. Source and pull requests: https://wiki.adom.inc/adom/adom-aiflow/prs.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/assembly_contacts.rsadded+138@@ -0,0 +1,138 @@+//! Explicit owner-reviewed assembly contact. Never an electrical DRC exception.+//! Original overlaps remain in reports; declarations bind complete footprint bytes.+use aiflow_board::Board;+use serde_json::{json, Value};+use sha2::{Digest, Sha256};+use std::collections::BTreeSet;++pub fn fingerprint(b: &Board, reference: &str) -> Option<String> {+ let fp=b.root.find_all("footprint").find(|fp| aiflow_board::reference_of(fp).as_deref()==Some(reference))?;+ Some(format!("{:x}", Sha256::digest(b.text.get(fp.start..fp.end)?.as_bytes())))+}+fn nonempty(v: &Value) -> bool { v.as_str().is_some_and(|s| !s.trim().is_empty()) }+pub fn covers(rows: &[Value], a: &str, b: &str) -> bool {+ rows.iter().any(|r| (r["a"]==a && r["b"]==b)||(r["a"]==b && r["b"]==a))+}+pub fn review(b: &Board, spec: &Value, mechanical: &crate::mechanical::Report) -> Result<Vec<Value>,String> {+ let decl=&spec["placement"]["approvedCourtyardContacts"];+ if decl.is_null() { return Ok(Vec::new()); }+ let rows=decl.as_array().ok_or("placement.approvedCourtyardContacts must be an array")?;+ let mut result=Vec::new(); let mut seen=BTreeSet::new();+ for row in rows {+ let a=row["a"].as_str().filter(|s|!s.is_empty()).ok_or("contact needs reference a")?;+ let c=row["b"].as_str().filter(|s|!s.is_empty()&&*s!=a).ok_or("contact needs distinct reference b")?;+ let mut pair=[a,c];pair.sort();+ if !seen.insert(pair) { return Err(format!("duplicate courtyard contact {a}/{c}")); }+ for key in ["by","date","source","reason"] {+ if !nonempty(&row["approval"][key]) { return Err(format!("{a}/{c}: explicit approval.{key} is required")); }+ }+ if !nonempty(&row["liveRevision"]) || !nonempty(&row["remoteBoard"]) { return Err(format!("{a}/{c}: bind the reviewed liveRevision and remoteBoard")); }+ let side=row["side"].as_str().filter(|s|matches!(*s,"F.Cu"|"B.Cu")).ok_or("contact side must be F.Cu or B.Cu")?;+ let max_depth=row["maxOverlapMm"].as_f64().filter(|x|x.is_finite()&&*x>0.0).ok_or("contact needs a positive finite maxOverlapMm")?;+ let max_area=row["maxAreaMm2"].as_f64().filter(|x|x.is_finite()&&*x>0.0).ok_or("contact needs a positive finite maxAreaMm2")?;+ let mut ids=Vec::new();+ for r in [a,c] {+ let f=b.footprints.get(r).ok_or_else(||format!("unknown contact reference {r}"))?;+ let id=fingerprint(b,r).ok_or_else(||format!("missing footprint bytes for {r}"))?;+ if row["fingerprints"][r]!=id { return Err(format!("{r}: contact fingerprint is absent or stale (library, pose, pads, body, courtyard or model changed)")); }+ if row["footprints"][r]!=f.lib || f.layer!=side { return Err(format!("{r}: contact library/side differs")); }+ let fp=b.root.find_all("footprint").find(|fp|aiflow_board::reference_of(fp).as_deref()==Some(r)).unwrap();+ let attr_has = |key: &str| fp.find("attr").is_some_and(|a| a.items.iter().any(|i| matches!(i, aiflow_board::sx::Item::Atom {text,..} if text==key)));+ if attr_has("exclude_from_bom") && attr_has("exclude_from_pos_files") { return Err(format!("{r}: assembly contacts require a real checked part body, not excluded bare artwork")); }+ let uuid=fp.find("uuid").and_then(|u|u.value()).filter(|s|!s.is_empty()).ok_or_else(||format!("{r}: a native footprint UUID is required"))?;+ ids.push(uuid.to_string());+ let body=&spec["bodies"][r];+ if !nonempty(&body["source"]) || ["lengthMm","widthMm"].iter().any(|k|body[*k].as_f64().is_none_or(|x|!x.is_finite()||x<=0.0)) || body["tolMm"].as_f64().is_none_or(|x|!x.is_finite()||x<0.0) {+ return Err(format!("{r}: contact needs sourced body dimensions and an explicit tolerance"));+ }+ if crate::mechanical::fab_extent(fp).is_none() || mechanical.no_body.iter().any(|n|n==r) { return Err(format!("{r}: contact requires a drawn, checked body")); }+ if mechanical.body_overlaps.iter().any(|o|o.a==r||o.b==r) { return Err(format!("{r}: physical body overlap is never excused")); }+ }+ if !mechanical.body_size_errors.is_empty() { return Err("body dimension errors remain; contact declarations cannot excuse them".into()); }+ let hit=mechanical.courtyard_overlaps.iter().find(|o|((o.a==a&&o.b==c)||(o.a==c&&o.b==a))&&o.front==(side=="F.Cu")).ok_or_else(||format!("{a}/{c}: declaration has no matching courtyard overlap"))?;+ if hit.depth>max_depth+1e-6 || hit.area>max_area+1e-6 { return Err(format!("{a}/{c}: courtyard overlap exceeds its reviewed bound")); }+ let mut out=row.clone();out["uuidPair"]=json!(ids);out["measuredOverlapMm"]=json!(hit.depth);out["measuredAreaMm2"]=json!(hit.area);out["scope"]=json!("courtyard only; native body, copper, hole, edge and all other pair checks retained");result.push(out);+ }+ Ok(result)+}++/// Accept only a complete native report whose *every* error is one of these+/// exact UUID pairs. No count-only waiver, substring reference matching or rule suppression.+pub fn native_contacts_only(rows: &[Value], drc: &Value, spec: &Value, revision: &str, remote: &str) -> bool {+ if rows.is_empty() || drc["violationReportMayBeTruncated"]!=false { return false; }+ let Some(violations)=drc["placementViolations"].as_array() else{return false};+ if violations.is_empty() || violations.len()!=rows.len() || drc["placementViolationCount"].as_u64()!=Some(violations.len() as u64) || drc["errors"].as_u64()!=Some(violations.len() as u64) || drc["placementErrors"].as_u64()!=Some(violations.len() as u64) || drc["placementClean"]!=false {return false;}+ let mut seen=BTreeSet::new();+ for v in violations {+ if v["type"]!="courtyards_overlap" || v["severity"]!="error" {return false;}+ let Some(items)=v["items"].as_array().filter(|a|a.len()==2) else{return false};+ let Some(mut uuids)=items.iter().map(|i|i["uuid"].as_str()).collect::<Option<Vec<_>>>() else{return false};uuids.sort();+ if uuids[0]==uuids[1] || !seen.insert(uuids.clone()) {return false;}+ let matched=rows.iter().any(|row| {+ let Some(mut expected)=row["uuidPair"].as_array().and_then(|a|a.iter().map(Value::as_str).collect::<Option<Vec<_>>>()) else{return false};expected.sort();+ expected==uuids && row["liveRevision"]==revision && row["remoteBoard"]==remote+ });+ if !matched {return false;}+ }+ // The live declaration itself remains in the input-bound spec; no hidden state.+ spec["placement"]["approvedCourtyardContacts"].as_array().is_some_and(|a|a.len()==rows.len())+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::mechanical::{self, tests::{board,part}};+ fn fixture() -> (Board,Value) {+ let p=|r:&str,x:f64| part(r,x,80.0,5.0,2.0,Some(0.35),"").replacen("(layer \"F.Cu\")",&format!("(uuid \"id-{r}\") (layer \"F.Cu\")"),1);+ let b=board(&[p("J1",100.0),p("J2",105.0)]);+ let s=json!({"bodies":{"J1":{"lengthMm":5,"widthMm":2,"tolMm":0.01,"source":"manufacturer drawing"},"J2":{"lengthMm":5,"widthMm":2,"tolMm":0.01,"source":"manufacturer drawing"}},"placement":{"approvedCourtyardContacts":[{"a":"J1","b":"J2","side":"F.Cu","maxOverlapMm":0.701,"maxAreaMm2":1.9,"fingerprints":{"J1":fingerprint(&b,"J1").unwrap(),"J2":fingerprint(&b,"J2").unwrap()},"footprints":{"J1":"T:Cap","J2":"T:Cap"},"liveRevision":"rev","remoteBoard":"board","approval":{"by":"owner","date":"2026-11-01","source":"recorded decision","reason":"intentional contact"}}]}});+ (b,s)+ }+ fn checked(b:&Board,s:&Value)->mechanical::Report {mechanical::check_board(b,&b.path,std::path::Path::new("."),s)}+ #[test]+ fn bounded_contact_preserves_original_findings_and_body_checks() {+ let (b,s)=fixture();let r=checked(&b,&s);+ assert!(r.courtyards_ok(),"{:?}",r.errors());assert!(r.bodies_ok());assert_eq!(r.courtyard_overlaps.len(),1);assert_eq!(r.approved_contacts.len(),1);+ let no=checked(&b,&json!({}));assert!(!no.courtyards_ok());assert_eq!(no.courtyard_overlaps.len(),1);+ }+ #[test]+ fn approval_bounds_fingerprint_library_source_side_and_duplicate_are_required() {+ let (b,s)=fixture();+ for (path,value) in [("/placement/approvedCourtyardContacts/0/approval/by",json!("")),("/placement/approvedCourtyardContacts/0/fingerprints/J1",json!("wrong")),("/placement/approvedCourtyardContacts/0/footprints/J1",json!("wrong")),("/placement/approvedCourtyardContacts/0/maxOverlapMm",json!(0.1)),("/placement/approvedCourtyardContacts/0/maxAreaMm2",json!(0.1)),("/placement/approvedCourtyardContacts/0/side",json!("B.Cu")),("/bodies/J1/source",json!("")),("/bodies/J1/lengthMm",json!(4.0))] {+ let mut bad=s.clone();*bad.pointer_mut(path).unwrap()=value;assert!(!checked(&b,&bad).courtyards_ok(),"{path}");+ }+ let mut bad=s.clone();let row=bad["placement"]["approvedCourtyardContacts"][0].clone();bad["placement"]["approvedCourtyardContacts"].as_array_mut().unwrap().push(row);assert!(!checked(&b,&bad).courtyards_ok());+ }+ #[test]+ fn body_intersection_cannot_be_declared_away_even_with_fresh_fingerprints() {+ let (b,mut s)=fixture();let b=Board::from_text("body-hit",b.text.replace("(at 105 80)","(at 104.9 80)")).unwrap();+ s["placement"]["approvedCourtyardContacts"][0]["fingerprints"]["J2"]=json!(fingerprint(&b,"J2").unwrap());+ s["placement"]["approvedCourtyardContacts"][0]["maxOverlapMm"]=json!(5);s["placement"]["approvedCourtyardContacts"][0]["maxAreaMm2"]=json!(50);+ let r=checked(&b,&s);assert!(!r.bodies_ok());assert!(!r.courtyards_ok());assert!(r.approved_contacts.is_empty());+ }+ #[test]+ fn excluded_bare_artwork_cannot_claim_a_checked_assembly_body() {+ let (b,mut s)=fixture();let b=Board::from_text("bare",b.text.replace("(attr smd )","(attr smd exclude_from_bom exclude_from_pos_files)")).unwrap();+ for r in ["J1","J2"] { s["placement"]["approvedCourtyardContacts"][0]["fingerprints"][r]=json!(fingerprint(&b,r).unwrap()); }+ assert!(!checked(&b,&s).courtyards_ok());+ }+ fn native() -> Value {json!({"placementClean":false,"placementViolationCount":1,"placementErrors":1,"errors":1,"violationReportMayBeTruncated":false,"placementViolations":[{"type":"courtyards_overlap","severity":"error","items":[{"uuid":"id-J1"},{"uuid":"id-J2"}]}]})}+ #[test]+ fn native_requires_complete_exact_uuid_errors_and_unchanged_live_identity() {+ let (b,s)=fixture();let r=checked(&b,&s);let d=native();+ assert!(native_contacts_only(&r.approved_contacts,&d,&s,"rev","board"));+ assert!(!native_contacts_only(&r.approved_contacts,&d,&s,"changed","board"));+ assert!(!native_contacts_only(&r.approved_contacts,&d,&s,"rev","other"));+ for (path,value) in [("/errors",json!(2)),("/placementViolationCount",json!(0)),("/violationReportMayBeTruncated",json!(true)),("/placementViolations/0/type",json!("clearance")),("/placementViolations/0/items/1/uuid",json!("id-J3")),("/placementViolations/0/severity",json!("warning"))] {+ let mut bad=d.clone();*bad.pointer_mut(path).unwrap()=value;assert!(!native_contacts_only(&r.approved_contacts,&bad,&s,"rev","board"),"{path}");+ }+ }+ #[test]+ fn placement_accepts_only_the_reviewed_contacts_with_all_other_guards() {+ let (b,spec)=fixture();let state=json!({"success":true,"source":"live-editor","revision":"rev","boardPath":"board","footprintCount":2,"footprints":[{"ref":"J1","x":100,"y":80,"rotation":0,"side":"F.Cu","footprint":"T:Cap","insideOutline":true},{"ref":"J2","x":105,"y":80,"rotation":0,"side":"F.Cu","footprint":"T:Cap","insideOutline":true}]});+ let mut val=json!({"success":true,"source":"live-editor","revision":"rev","currentRevision":"rev","stale":false,"placed":false,"outsideOutline":[],"parked":[],"courtyardOverlaps":[{"a":"J1","b":"J2","side":"F.Cu"}],"withoutCourtyard":[],"drc":native()});val["drc"]["available"]=json!(true);val["drc"]["source"]=json!("live-editor-snapshot");+ let call=|v:&Value,s:&Value|crate::placement_verify::check_with_spec(&b,&state,v,&state,"board",s);+ assert_eq!(call(&val,&spec).unwrap().approved_contacts.len(),1);assert!(call(&val,&json!({})).is_err());+ val["drc"]["errors"]=json!(2);assert!(call(&val,&spec).is_err());+ }+}
crates/adom-aiflow/src/main.rs+33−6@@ -18,6 +18,7 @@ mod browser_access; mod schematic_identity; mod remote_binding; mod placement_verify;+mod assembly_contacts; mod provenance; mod spec_freeze; mod spec_nets;@@ -371,6 +372,9 @@ fn ok(msg: &str, hints: &[String]) { /// section), set once in main and printed under every ERROR of the command. static INPUT_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new(); +/// Every refusal says how to change AI Flow itself when the check, not the board, is what is wrong.+const CONTRIBUTE: &str = "If this check is wrong for your board (AI Flow has not seen a design like it), improve AI Flow rather than stopping or working around it: `adom-wiki repo clone adom/adom-aiflow`, fix it, `cargo build --release`, finish this board with your build (that tests the change), then send it back with `adom-wiki pr create adom/adom-aiflow --title \"...\" --files <changed files> --body-file <why, base hash, how you tested>`. Every Adom user gets the fix.";+ fn err(msg: &str, hints: &[String]) -> ! { close_turn(false, msg.lines().next().unwrap_or("")); println!("ERROR: {msg}");@@ -380,6 +384,8 @@ fn err(msg: &str, hints: &[String]) -> ! { } } if let Some(h) = INPUT_HINT.get() { println!("Hint: {h}"); }+ // a usage slip (no thread name, a bad flag) is not a check that may be wrong about the board+ if !(hints.iter().any(|h| h.starts_with("Re-run with --ai-thread")) || msg.starts_with("usage")) { println!("Hint: {CONTRIBUTE}"); } std::process::exit(2) } @@ -3548,11 +3554,11 @@ fn main() { let checked=placement_verify::check_with_spec(&b,&before,&validation,&after,&rb,&spec_of(&r)); let stable=placement_verify::digest(Path::new(&board_path)).ok().as_deref()==Some(board_hash.as_str()) && placement_verify::digest(Path::new(&spec_path)).ok().as_deref()==Some(spec_hash.as_str()); let error=if !stable { Some("saved board or spec changed during placement verification".to_string()) } else { checked.as_ref().err().cloned() };- let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()).map(|v|v.to_json());+ let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()||!v.approved_contacts.is_empty()).map(|v|v.to_json()); r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.as_ref().map(|v|v.accepted).unwrap_or(0)}else{0},"excused":excused,"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error})); r.save().unwrap_or_else(|e|err(&e,&[])); if let Some(e)=error { err(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); }- let excused_line=excused.as_ref().map(|e|format!("; excused for spec.fixedRefs: {} bridge box overlap(s), {} padless footprint(s) without a courtyard (KiCad's exact placement DRC clean)",e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()))).unwrap_or_default();+ let excused_line=excused.as_ref().map(|e|format!("; reviewed assembly contacts: {}; fixedRefs box exceptions: {}; padless courtyard exceptions: {} (original native findings retained)",e["approvedCourtyardContacts"].as_array().map_or(0,|a|a.len()),e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()))).unwrap_or_default(); ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved{excused_line}",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]); } "pack" => {@@ -3885,7 +3891,7 @@ fn main() { let silk_n = s["silkAdvisories"].as_u64().unwrap_or(0) as usize; if silk_n > 0 { eprintln!("Warning: {}", silk_policy::advisory_line(silk_n, &s["silkAdvisoryList"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect::<Vec<_>>())); } if !stairs.is_empty() { eprintln!("Warning: {} staircase run(s) left in the copper (tiny alternating 0/90 degree steps; redraw as one 45 degree segment):\n {}", stairs.len(), stairs.iter().take(8).cloned().collect::<Vec<_>>().join("\n ")); }- let checked = match &mech { Some(m) => format!("; courtyards present and clear on {} footprint(s), {} part bodies clear", m.with_courtyard, m.bodies_checked), None => "; courtyards and bodies not checked (Fusion board)".into() };+ let checked = match &mech { Some(m) => format!("; courtyards checked on {} footprint(s), {} explicitly reviewed contact(s), {} part bodies clear", m.with_courtyard, m.approved_contacts.len(), m.bodies_checked), None => "; courtyards and bodies not checked (Fusion board)".into() }; let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false); ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}{checked}; copper {}\nThis is the routing gate (DRC, connectivity, courtyards, bodies), not a fabrication check: pours in the saved board, Gerbers and human review come later.", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]); }@@ -4200,7 +4206,7 @@ fn main() { } else { Vec::new() }; let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e }); // issue #67: the same-input solve with a declared board-sink block stands in for the estimate- if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }+ if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); sink_notes.extend(aiflow_analyze::lead_cooling::accept_solve(&mut res, &f)); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); } // issue #107: the passed solve's rise at each net's narrowest neck judges the whole-net width screen if what == "current" && s.is_ok() { aiflow_analyze::solved_ampacity::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); } s@@ -4417,7 +4423,27 @@ fn main() { } } let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);- if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {+ // Owner-reviewed contacts are revalidated, by exact identity, at finish.+ // Never turn their number into a generic inherited-error allowance.+ let mut contacts_at_finish = Value::Null;+ let contact_declared = spec_f["placement"]["approvedCourtyardContacts"].as_array().is_some_and(|a| !a.is_empty());+ if contact_declared && !pattern {+ let rb = r.data["remoteBoard"].as_str().unwrap_or("");+ let before = br.call("kicad_placement_state", &json!({"filePath": rb, "detail": true}));+ let validation = br.call("kicad_placement_validate", &json!({"filePath": rb}));+ let after = br.call("kicad_placement_state", &json!({"filePath": rb, "detail": true}));+ let checked = placement_verify::check_with_spec(&board_of(&r), &before, &validation, &after, rb, &spec_f);+ write_json(&dir.join("finish-assembly-contacts.json"), &json!({"before":before,"validation":validation,"after":after}));+ match checked {+ Ok(v) if !v.approved_contacts.is_empty()+ && validation["drc"]["unconnected"].as_u64() == Some(0)+ && validation["drc"]["unconnectedReportMayBeTruncated"] == false+ && live["errors"] == validation["drc"]["errors"] => contacts_at_finish = v.to_json(),+ Ok(_) => missing.push("finish: native contact/connectivity reports do not reconcile".into()),+ Err(e) => missing.push(format!("finish: reviewed assembly contacts: {e}")),+ }+ }+ if live["unconnected"].as_u64() != Some(0) || (live["errors"].as_u64().unwrap_or(u64::MAX) > inherited && contacts_at_finish.is_null()) { missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"])); } // KiCad's full DRC on the live board, warnings included: the manufacturing findings, and the silkscreen@@ -4431,7 +4457,8 @@ fn main() { // the live result is the run's DRC evidence (issue #86): the pattern lane's DRC and connectivity // items read it, bound to the board it ran on; recorded even when finish refuses let silk_blocking = silk_policy::blocking(&spec_f, &r.current_board());- let rec = verified::live_drc_record_with(&live, &drc, &target, r.data["remoteBoard"].as_str().unwrap_or(""), &r.current_board(), inherited, silk_blocking);+ let mut rec = verified::live_drc_record_with(&live, &drc, &target, r.data["remoteBoard"].as_str().unwrap_or(""), &r.current_board(), inherited, silk_blocking);+ rec["reviewedAssemblyContacts"] = contacts_at_finish; // raw counts and clean=false stay honest if pattern && rec["clean"] != true { let f = &rec["fullDrc"]; if f["ran"] != true { missing.push(format!("native DRC: the live full DRC returned no result ({}); finish again with the board open on {target}", f["error"].as_str().unwrap_or("no reply"))); }
crates/adom-aiflow/src/mechanical.rs+7−2@@ -54,6 +54,8 @@ pub struct Report { pub missing_courtyards: Vec<String>, pub exempt: Vec<(String, String)>, pub courtyard_overlaps: Vec<Overlap>,+ pub approved_contacts: Vec<Value>,+ pub contact_errors: Vec<String>, pub body_overlaps: Vec<Overlap>, pub bodies_checked: usize, pub no_body: Vec<String>,@@ -66,7 +68,7 @@ pub struct Report { } impl Report {- pub fn courtyards_ok(&self) -> bool { self.missing_courtyards.is_empty() && self.courtyard_overlaps.is_empty() }+ pub fn courtyards_ok(&self) -> bool { self.missing_courtyards.is_empty() && self.contact_errors.is_empty() && self.courtyard_overlaps.iter().all(|o| crate::assembly_contacts::covers(&self.approved_contacts, &o.a, &o.b)) } pub fn bodies_ok(&self) -> bool { self.body_overlaps.is_empty() && self.body_size_errors.is_empty() } /// Every blocking finding, one line each.@@ -75,7 +77,8 @@ impl Report { if !self.missing_courtyards.is_empty() { out.push(format!("{} footprint(s) without a courtyard (F.CrtYd/B.CrtYd): {}", self.missing_courtyards.len(), short(&self.missing_courtyards))); }- out.extend(self.courtyard_overlaps.iter().map(|o| o.line("courtyards")));+ out.extend(self.contact_errors.iter().cloned());+ out.extend(self.courtyard_overlaps.iter().filter(|o| !crate::assembly_contacts::covers(&self.approved_contacts, &o.a, &o.b)).map(|o| o.line("courtyards"))); out.extend(self.body_overlaps.iter().map(|o| o.line("bodies"))); out.extend(self.body_size_errors.iter().cloned()); out@@ -95,6 +98,7 @@ impl Report { "footprints": self.footprints, "withCourtyard": self.with_courtyard, "missingCourtyards": self.missing_courtyards, "exempt": self.exempt.iter().map(|(r, w)| json!({"ref": r, "why": w})).collect::<Vec<_>>(),+ "approvedCourtyardContacts": self.approved_contacts, "contactDeclarationErrors": self.contact_errors, "courtyardOverlaps": ov(&self.courtyard_overlaps), "bodyOverlaps": ov(&self.body_overlaps), "bodiesChecked": self.bodies_checked, "noBody": self.no_body, "bodySizeErrors": self.body_size_errors, "bodySizeNotVerified": self.body_size_unverified,@@ -373,6 +377,7 @@ pub fn check_board(b: &Board, board_path: &str, project_dir: &Path, spec: &Value let (errors, unverified) = declared_bodies(b, spec); rep.body_size_errors = errors; rep.body_size_unverified = unverified;+ match crate::assembly_contacts::review(b, spec, &rep) { Ok(v) => rep.approved_contacts = v, Err(e) => rep.contact_errors.push(e) } if !rep.body_size_unverified.is_empty() { rep.notes.push(format!("body size not checked against a datasheet (no spec.bodies entry): {}", short(&rep.body_size_unverified))); } rep }
crates/adom-aiflow/src/placement_verify.rs+12−4@@ -26,12 +26,13 @@ pub struct Verified { pub excused_overlaps: Vec<Value>, /// spec.fixedRefs footprints with no pads and no courtyard (the gate's own exemption). pub excused_without_courtyard: Vec<String>,+ pub approved_contacts: Vec<Value>, } impl Verified { pub fn to_json(&self) -> Value {- serde_json::json!({"courtyardOverlaps": self.excused_overlaps, "withoutCourtyard": self.excused_without_courtyard,- "rule": "only between spec.fixedRefs footprints, with KiCad's placement DRC clean and aiflow's exact courtyard check clear; a fixedRefs footprint may lack a courtyard only when it has no pads"})+ serde_json::json!({"approvedCourtyardContacts": self.approved_contacts, "courtyardOverlaps": self.excused_overlaps, "withoutCourtyard": self.excused_without_courtyard,+ "rule": "fixedRefs box exceptions require clean native/exact courtyards; explicit assembly contacts require bound owner approval, checked bodies and exact native UUID pairs; padless fixedRefs may lack a courtyard"}) } } @@ -117,11 +118,17 @@ pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after: if p["insideOutline"]!=true { edge_connector_why(board,p,spec).map_err(|e|format!("{reference}: outline mismatch: {e}"))?; } } }+ let mech = crate::mechanical::check_board(board, &board.path, Path::new(&board.path).parent().unwrap_or(Path::new(".")), spec);+ if !mech.contact_errors.is_empty() { return Err(mech.contact_errors.join("; ")); }+ let contacts = mech.approved_contacts.clone();+ if contacts.iter().any(|c| c["liveRevision"]!=revision || c["remoteBoard"]!=remote) { return Err("reviewed courtyard contact live identity changed".into()); } let drc=&validation["drc"]; if validation["success"]!=true || validation["source"]!="live-editor" || validation["stale"]!=false || validation["revision"]!=revision || validation["currentRevision"]!=revision { return Err("native placement validation failed or changed revision".into()); }- if drc["available"]!=true || drc["source"]!="live-editor-snapshot" || drc["placementClean"]!=true || drc["placementViolationCount"].as_u64()!=Some(0) || drc["errors"].as_u64()!=Some(0) || drc["violationReportMayBeTruncated"]!=false {+ let native_clear = drc["placementClean"]==true && drc["placementViolationCount"].as_u64()==Some(0) && drc["errors"].as_u64()==Some(0);+ let reviewed_contacts_only = crate::assembly_contacts::native_contacts_only(&contacts, drc, spec, revision, remote);+ if drc["available"]!=true || drc["source"]!="live-editor-snapshot" || (!native_clear && !reviewed_contacts_only) || drc["violationReportMayBeTruncated"]!=false { return Err("complete native placement DRC is required".into()); } let outside=validation["outsideOutline"].as_array().ok_or("missing native outside-outline report")?;@@ -147,10 +154,11 @@ pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after: let fixed:BTreeSet<&str>=spec["fixedRefs"].as_array().map(|a|a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); let exact=if overlaps.is_empty() { Vec::new() } else { crate::mechanical::check(board,"",&|_|None).courtyard_overlaps }; let exact_hit=|a:&str,b:&str| exact.iter().any(|o|(o.a==a&&o.b==b)||(o.a==b&&o.b==a));- let mut verified=Verified{accepted:board.footprints.len(),..Default::default()};+ let mut verified=Verified{accepted:board.footprints.len(),approved_contacts:contacts.clone(),..Default::default()}; let mut open=Vec::new(); for o in overlaps { let (a,b)=(o["a"].as_str().unwrap_or(""),o["b"].as_str().unwrap_or(""));+ if crate::assembly_contacts::covers(&contacts,a,b) { continue; } if fixed.contains(a) && fixed.contains(b) && !exact_hit(a,b) { verified.excused_overlaps.push(o.clone()); } else { open.push(o); } } if !open.is_empty() {
crates/adom-aiflow/src/thermalvias.rs+6−1@@ -91,6 +91,11 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a let hot = spec["hot"].as_object().cloned().unwrap_or_default(); for (reference, h) in hot { let watts = h["watts"].as_f64().unwrap_or(0.0);+ // issue #116: a lead-cooled part has no tab to put heatsink vias beside+ if aiflow_analyze::lead_cooling::declared(&reference, &h).unwrap_or(false) {+ parts.push(Part { reference: reference.clone(), net: aiflow_analyze::lead_cooling::LEADS.into(), target: 0, existing: 0, planned: vec![], note: Some(format!("{reference} is lead-cooled (no exposed tab): no heatsink vias; the Fields solve judges it")) });+ continue;+ } let net = h["tabNet"].as_str().unwrap_or("GND").to_string(); let tgt = target(watts, h["maxRiseC"].as_f64().unwrap_or(max_rise)); let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();@@ -258,7 +263,7 @@ fn short_with(spec: &Value, b: &Board, vias: &[(String, f64, f64, f64)], live_pa let pours = spec["pours"].as_array().cloned().unwrap_or_default(); let mut out = Vec::new(); for (reference, h) in spec["hot"].as_object().cloned().unwrap_or_default() {- if acked.contains(&reference) { continue; }+ if acked.contains(&reference) || aiflow_analyze::lead_cooling::declared(&reference, &h).unwrap_or(false) { continue; } let net = h["tabNet"].as_str().unwrap_or("GND"); let tgt = target(h["watts"].as_f64().unwrap_or(0.0), h["maxRiseC"].as_f64().unwrap_or(max_rise)); let pads: Vec<(f64, f64)> = live_pads(&reference, net).unwrap_or_else(|| b.pads.iter().filter(|p| p.reference == reference && p.net == net).map(|p| (p.x, p.y)).collect());
crates/aiflow-analyze/src/lead_cooling.rsadded+135@@ -0,0 +1,135 @@+//! Issue #116: a hot part with no exposed tab, cooled through its leads (LEDs, inductors, small+//! leaded ICs). `spec.hot.<REF>: {"watts": W, "cooling": "leads"}` with no tabNet. adom-fields+//! models such a part with its watts through all of its pads by area and reports it as tabNet+//! "leads". There is no tab pour to estimate, so the part passes only on that same-input Fields+//! solve, never on the copper estimate, and it gets no heatsink-via target.+use aiflow_board::Board;+use serde_json::{json, Value};++/// The tabNet adom-fields reports for a part it modelled through its leads.+pub const LEADS: &str = "leads";++/// Whether `spec.hot.<reference>` declares lead cooling; Err for a contradictory declaration.+pub fn declared(reference: &str, h: &Value) -> Result<bool, String> {+ match &h["cooling"] {+ Value::Null => Ok(false),+ Value::String(s) if s == LEADS => {+ if !(h["tabNet"].is_null() || h["tabNet"] == "") {+ return Err(format!("{reference}: cooling \"leads\" is for a part with no exposed tab, but it also names tabNet {}: keep the tabNet if the part has an exposed pad, else remove it", h["tabNet"]));+ }+ if !h["tabPad"].is_null() { return Err(format!("{reference}: cooling \"leads\" has no tab, so tabPad does not apply")); }+ if !h["tjMaxC"].is_null() {+ let theta = h["thetaJcKPerW"].as_f64().filter(|v| v.is_finite() && *v > 0.0);+ let source = h["thetaSource"].as_str().filter(|s| !s.trim().is_empty());+ if theta.is_none() || source.is_none() || !h["tjMaxC"].as_f64().is_some_and(|v| v.is_finite()) {+ return Err(format!("{reference}: a junction limit (tjMaxC) on a lead-cooled part needs thetaJcKPerW, the datasheet's junction-to-board figure for this package, and thetaSource naming where it came from"));+ }+ }+ Ok(true)+ }+ other => Err(format!("{reference}: cooling must be \"leads\" (a part with no exposed tab), not {other}")),+ }+}++/// The copper-estimate row for a declared lead-cooled part: pending until the Fields solve judges it.+pub fn preliminary(reference: &str, h: &Value) -> Value {+ let watts = h["watts"].as_f64().unwrap_or(0.0);+ let mut r = json!({"ref": reference, "watts": watts, "cooling": LEADS, "tabNet": LEADS});+ if watts <= 0.0 {+ r["status"] = json!("pass");+ r["how"] = json!(format!("lead-cooled, {watts} W declared: nothing to dissipate"));+ } else {+ r["status"] = json!("pending-fields");+ r["hint"] = json!(format!("{reference} is lead-cooled ({watts} W through its leads, no exposed tab): no tab pour applies, so it passes only on the same-input Fields solve (adom-fields analyze on this saved board and spec)"));+ }+ r+}++/// Refuse lead cooling for a part that has an exposed pad: its heat goes into that pad's net.+/// Two-terminal parts are their leads; with three or more pads, one pad at least three times the+/// median of the others is a tab.+pub fn check_footprint(b: &Board, reference: &str) -> Result<(), String> {+ let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference).collect();+ if pads.is_empty() { return Err(format!("{reference} (cooling \"leads\") has no pads on the board")); }+ if pads.len() < 3 { return Ok(()); }+ let area = |p: &aiflow_board::Pad| p.size.0 * p.size.1;+ let big = pads.iter().copied().max_by(|a, z| area(a).total_cmp(&area(z))).unwrap();+ let mut rest: Vec<f64> = pads.iter().filter(|p| !std::ptr::eq(**p, big)).map(|p| area(p)).collect();+ rest.sort_by(f64::total_cmp);+ let median = rest[rest.len() / 2];+ if area(big) >= 3.0 * median {+ return Err(format!("{reference} has an exposed pad ({} on {}, {:.2} mm2 against {:.2} mm2 for its leads): declare tabNet {} instead of cooling \"leads\"", big.name, if big.net.is_empty() { "no net" } else { big.net.as_str() }, area(big), median, if big.net.is_empty() { "<its net>" } else { big.net.as_str() }));+ }+ Ok(())+}++/// After the same-input field screen passed: each pending lead-cooled row takes its solved rise.+pub fn accept_solve(results: &mut [Value], fields: &Value) -> Vec<String> {+ let mut notes = Vec::new();+ for r in results.iter_mut().filter(|r| r["cooling"] == LEADS && r["status"] == "pending-fields") {+ let reference = r["ref"].as_str().unwrap_or("").to_string();+ let row = fields["chips"].as_array().into_iter().flatten().find(|c| c["reference"] == reference.as_str() && c["tabNet"] == LEADS);+ match row {+ Some(c) => {+ r["status"] = json!("pass");+ r["judgedBy"] = json!("fields-solve");+ r["riseC"] = c["riseC"].clone();+ r["riseMovingC"] = c["riseMovingC"].clone();+ r["how"] = json!(format!("lead-cooled: the same-input Fields solve puts it at +{} C still air, +{} C moving air, within its budget", c["riseC"], c["riseMovingC"]));+ r.as_object_mut().unwrap().remove("hint");+ notes.push(format!("{reference}: lead-cooled, judged by the Fields solve"));+ }+ None => {+ r["status"] = json!("fail");+ r["hint"] = json!(format!("{reference}: the Fields report has no lead-cooled row for it; solve again on this saved board and spec"));+ }+ }+ }+ notes+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn declaration_is_explicit_and_consistent() {+ assert_eq!(declared("D1", &json!({"watts": 0.06})), Ok(false));+ assert_eq!(declared("D1", &json!({"watts": 0.06, "cooling": "leads"})), Ok(true));+ assert!(declared("D1", &json!({"watts": 0.06, "cooling": "leads", "tabNet": "GND"})).is_err());+ assert!(declared("D1", &json!({"watts": 0.06, "cooling": "leads", "tabPad": "3"})).is_err());+ assert!(declared("D1", &json!({"watts": 0.06, "cooling": "tab"})).is_err());+ assert!(declared("U1", &json!({"watts": 0.3, "cooling": "leads", "tjMaxC": 125})).is_err());+ assert!(declared("U1", &json!({"watts": 0.3, "cooling": "leads", "tjMaxC": 125, "thetaJcKPerW": 60})).is_err());+ assert_eq!(declared("U1", &json!({"watts": 0.3, "cooling": "leads", "tjMaxC": 125, "thetaJcKPerW": 60, "thetaSource": "datasheet p.4 psiJB"})), Ok(true));+ }++ #[test]+ fn pending_until_the_solve_then_judged_by_it() {+ let mut rows = vec![preliminary("D10", &json!({"watts": 0.07, "cooling": "leads"}))];+ assert_eq!(rows[0]["status"], "pending-fields");+ assert_eq!(preliminary("D11", &json!({"watts": 0, "cooling": "leads"}))["status"], "pass");+ let wrong = json!({"chips": [{"reference": "D10", "tabNet": "GND", "riseC": 3.0, "riseMovingC": 2.0}]});+ let mut failed = rows.clone();+ accept_solve(&mut failed, &wrong);+ assert_eq!(failed[0]["status"], "fail");+ let fields = json!({"chips": [{"reference": "D10", "tabNet": "leads", "riseC": 3.1, "riseMovingC": 2.2}]});+ assert_eq!(accept_solve(&mut rows, &fields).len(), 1);+ assert_eq!(rows[0]["status"], "pass");+ assert_eq!(rows[0]["judgedBy"], "fields-solve");+ }++ fn board(pads: &str) -> Board {+ Board::from_text("t", format!("(kicad_pcb (version 20241229) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal) (25 \"Edge.Cuts\" user)) (net 0 \"\") (net 1 \"GND\") (net 2 \"A\") (gr_rect (start 0 0) (end 20 20) (layer \"Edge.Cuts\")) (footprint \"T:P\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"U1\") {pads}))")).unwrap()+ }++ #[test]+ fn an_exposed_pad_refuses_lead_cooling() {+ let leads = "(pad \"1\" smd rect (at -1 0) (size 0.6 1.0) (layers \"F.Cu\") (net 2 \"A\")) (pad \"2\" smd rect (at 0 0) (size 0.6 1.0) (layers \"F.Cu\") (net 1 \"GND\")) (pad \"3\" smd rect (at 1 0) (size 0.6 1.0) (layers \"F.Cu\") (net 2 \"A\"))";+ assert!(check_footprint(&board(leads), "U1").is_ok());+ let tab = format!("{leads} (pad \"4\" smd rect (at 0 3) (size 3.2 2.0) (layers \"F.Cu\") (net 1 \"GND\"))");+ let e = check_footprint(&board(&tab), "U1").unwrap_err();+ assert!(e.contains("exposed pad") && e.contains("GND"), "{e}");+ assert!(check_footprint(&board(leads), "U9").is_err());+ }+}
crates/aiflow-analyze/src/lib.rs+44−3@@ -8,6 +8,7 @@ pub mod board_sink; pub mod branches; pub mod current_acks; pub mod fields_report;+pub mod lead_cooling; pub mod package_cooling; pub mod pour_cover; pub mod solved_ampacity;@@ -187,6 +188,9 @@ pub fn analyze_thermal_on_board(plan: &Value, spec: &Value, zone_state: Option<& let mut on_board: BTreeMap<String, usize> = BTreeMap::new(); for v in board.vias() { *on_board.entry(v.net).or_default() += 1; } let (_, mut results) = thermal_with_vias(plan, spec, zone_state, Some(&on_board));+ for r in results.iter_mut().filter(|r| r["cooling"] == lead_cooling::LEADS) {+ if let Err(e) = lead_cooling::check_footprint(board, r["ref"].as_str().unwrap_or("")) { r["status"] = json!("fail"); r["hint"] = json!(e); }+ } // issue #67: a declared board-face sink, for the parts it names only if let Err(e) = board_sink::apply(&mut results, spec, board, zone_state) { results.push(json!({"ref": "boardSink", "status": "fail", "hint": e})); } (results.iter().all(|r| r["status"] == "pass"), results)@@ -201,6 +205,13 @@ fn thermal_with_vias(plan: &Value, spec: &Value, zone_state: Option<&Value>, boa let mut results = Vec::new(); let mut ok = true; for (reference, h) in spec.get("hot").and_then(|l| l.as_object()).cloned().unwrap_or_default() {+ // issue #116: a part with no exposed tab is judged by the Fields solve, not by a tab pour+ match lead_cooling::declared(&reference, &h) {+ Err(e) => { ok = false; results.push(json!({"ref": reference, "status": "fail", "hint": e})); continue; }+ Ok(true) if package_cooling::definition(spec, &reference).is_some() => { ok = false; results.push(json!({"ref": reference, "status": "fail", "hint": format!("{reference}: declared both cooling \"leads\" and packageCooling; a part with an external sink is not lead-cooled, keep one")})); continue; }+ Ok(true) => { let r = lead_cooling::preliminary(&reference, &h); ok &= r["status"] == "pass"; results.push(r); continue; }+ Ok(false) => {}+ } if let Some(package) = package_cooling::definition(spec, &reference) { let result = package_cooling::preliminary(package, &h, spec); ok &= result["status"] == "pass";@@ -223,7 +234,7 @@ fn thermal_with_vias(plan: &Value, spec: &Value, zone_state: Option<&Value>, boa let mut r = json!({"ref": reference, "watts": watts, "tabNet": net, "pourLayers": layers, "pourFilledMm2": area, "viasOnNet": vias, "thetaEstimateCPerW": (theta * 10.0).round() / 10.0, "limitCPerW": theta_limit, "riseEstimateC": (rise * 10.0).round() / 10.0, "riseLimitC": rise_limit}); if zs.is_empty() { r["status"] = json!("fail");- r["hint"] = json!(format!("{reference} dissipates {watts:.1} W into {net} but {net} has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab"));+ r["hint"] = json!(if net.is_empty() { format!("{reference} dissipates {watts:.2} W but names no tabNet: give the net of its exposed tab, or, for a part with no tab (an LED, an inductor, a small leaded IC), declare \"cooling\": \"leads\" so the Fields solve judges it through its leads") } else { format!("{reference} dissipates {watts:.1} W into {net} but {net} has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab") }); ok = false; } else if theta > limit { r["status"] = json!("fail");@@ -337,8 +348,12 @@ pub fn field_screen_with_acks(fields: &Value, spec: &Value, board_bytes: &[u8], let row = entries.iter().find(|r|r[key].as_str()==Some(name.as_str())).ok_or_else(||format!("Fields did not solve {group}.{name}; resolve its missing terminals or copper and rerun analyze"))?; if kind == "thermal" { let watts = value["watts"].as_f64().unwrap();- if !row["watts"].as_f64().is_some_and(|v| v.is_finite() && (v-watts).abs() <= 1e-8)- || row["tabNet"] != value["tabNet"] {+ let leads = lead_cooling::declared(name, value)?;+ let tab_ok = if leads { row["tabNet"] == lead_cooling::LEADS } else { row["tabNet"] == value["tabNet"] };+ if !row["watts"].as_f64().is_some_and(|v| v.is_finite() && (v-watts).abs() <= 1e-8) || !tab_ok {+ if !leads && row["tabNet"] == lead_cooling::LEADS && value["tabNet"].as_str().unwrap_or("").is_empty() {+ return Err(format!("Fields modelled {name} through its leads (no tabNet): if it has no exposed tab, declare spec.hot.{name}.cooling \"leads\"; if it has one, give its tabNet"));+ } return Err(format!("Fields {name} thermal source differs from its declared watts or tab net")); } let rise_limit = value.get("maxRiseC").or_else(|| spec.get("hotMaxRiseC"));@@ -358,6 +373,12 @@ pub fn field_screen_with_acks(fields: &Value, spec: &Value, board_bytes: &[u8], if rise + 0.05 > limit { return Err(format!("Fields {name} {key} plus 0.05 C reporting allowance exceeds its {limit} C rise budget")); }+ // a claimed junction limit on a lead-cooled part: ambient, the solved board rise and the declared package rise+ if leads { if let Some(tj_max) = value["tjMaxC"].as_f64() {+ let ambient = spec["ambientC"].as_f64().unwrap_or(25.0);+ let tj = ambient + rise + 0.05 + value["thetaJcKPerW"].as_f64().unwrap_or(f64::INFINITY) * watts;+ if tj > tj_max { return Err(format!("Fields {name} junction about {tj:.1} C from {key} ({ambient} C ambient + rise + {} K/W x {watts} W, {}) exceeds its tjMaxC {tj_max}", value["thetaJcKPerW"], value["thetaSource"].as_str().unwrap_or(""))); }+ } } } } if kind == "current" {@@ -482,6 +503,26 @@ mod field_screen_tests { f } #[test]+ fn lead_cooled_sources_need_their_declaration_and_keep_every_limit() {+ // issue #116: Fields reports a part with no tabNet as tabNet "leads"+ let mut f=thermal_report();f["chips"]=json!([{"reference":"D10","watts":0.07,"tabNet":"leads","riseC":4.0,"riseMovingC":3.0}]);+ let undeclared=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07}}});+ assert!(field_screen(&f,&undeclared,b"board",b"spec","thermal").unwrap_err().contains("cooling \"leads\""));+ let spec=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07,"cooling":"leads"}}});+ field_screen(&f,&spec,b"board",b"spec","thermal").unwrap();+ let mut hot=f.clone();hot["chips"][0]["riseC"]=json!(25.0);+ assert!(field_screen(&hot,&spec,b"board",b"spec","thermal").unwrap_err().contains("rise budget"));+ let mut watts=f.clone();watts["chips"][0]["watts"]=json!(0.05);+ assert!(field_screen(&watts,&spec,b"board",b"spec","thermal").is_err());+ let mut tab=f.clone();tab["chips"][0]["tabNet"]=json!("GND");+ assert!(field_screen(&tab,&spec,b"board",b"spec","thermal").is_err());+ // a claimed junction limit: 25 + 4.05 + 300 K/W x 0.07 W = 50.05 C+ let mut tj=spec.clone();tj["hot"]["D10"]["tjMaxC"]=json!(50);tj["hot"]["D10"]["thetaJcKPerW"]=json!(300);tj["hot"]["D10"]["thetaSource"]=json!("datasheet");+ assert!(field_screen(&f,&tj,b"board",b"spec","thermal").unwrap_err().contains("junction"));+ tj["hot"]["D10"]["tjMaxC"]=json!(85);+ field_screen(&f,&tj,b"board",b"spec","thermal").unwrap();+ }+ #[test] fn individual_thermal_limit_and_rounding_allowance_are_enforced() { let spec=json!({"hotMaxRiseC":35,"hot":{"Q1":{"watts":5,"tabNet":"VCC","maxRiseC":15}}}); field_screen(&thermal_report(),&spec,b"board",b"spec","thermal").unwrap();
docs/approved-courtyard-contacts.mdadded+25@@ -0,0 +1,25 @@+# Explicitly approved courtyard contact++Some connector families are intentionally placed with their nominal bodies touching. Their assembly courtyards then overlap. `placement.approvedCourtyardContacts` represents that exact reviewed intent without redrawing the courtyards, moving the connectors or changing native DRC rules. The original findings remain in the mechanical report, alongside `approvedCourtyardContacts` and any declaration errors.++Each declaration needs two distinct references, their side, exact footprint library names, SHA-256 fingerprints of the complete footprint S-expressions (opening through closing parenthesis), positive maximum overlap depth and area, and approval `by`, `date`, `source` and `reason`. The source is the actual owner decision; the software cannot establish whether a claimed human approval is truthful. The caller must verify it. It also binds the reviewed `liveRevision` and exact `remoteBoard` path for native placement validation.++```json+{"placement":{"approvedCourtyardContacts":[{+ "a":"J1","b":"J2","side":"F.Cu",+ "maxOverlapMm":0.70,"maxAreaMm2":1.89,+ "footprints":{"J1":"Project:Header2","J2":"Project:Header2"},+ "fingerprints":{"J1":"<sha256 of complete footprint bytes>","J2":"<sha256 of complete footprint bytes>"},+ "liveRevision":"<verified native revision>","remoteBoard":"<exact native path>",+ "approval":{"by":"<owner>","date":"<decision date>","source":"<decision record>","reason":"<intentional assembly contact>"}+}]},"bodies":{+ "J1":{"lengthMm":5,"widthMm":2,"tolMm":0.01,"source":"<dimension source>"},+ "J2":{"lengthMm":5,"widthMm":2,"tolMm":0.01,"source":"<dimension source>"}+}}+```++The numbers above are a synthetic example, not dimensions for any real connector. Body dimensions and explicit tolerances need their real source. Every contact needs a drawn body that passes the dimension check and has no physical-body intersection with any part. Missing bodies, changed footprint bytes, different library/side, duplicate declarations, stale live identity, absent overlap or an exceeded overlap bound fail closed. Changing a model binding or even footprint text expires the byte fingerprint; review again rather than automatically regenerating approvals.++`place verify` accepts native courtyard findings only when the complete error report contains exactly the approved footprint UUID pairs. Extra electrical errors, unknown pairs, changed error types, truncated reports, counts that do not reconcile or a stale revision still refuse. The offline routing gate uses the same mechanical review. Finish rereads native placement before accepting the exact contacts, requires zero open connections and reconciled native counts, and records the reviewed contacts separately from raw DRC counts. It does not increase the generic inherited-error allowance; `clean` stays false when native findings remain. All other gates are unchanged. An approval does not clear body, copper, drill, edge, missing-courtyard, switching, thermal or connectivity failures. This is not a global ignore flag and does not prove tolerance-qualified assembly fit.++This change is a source proposal for issue #115. Installed runtime replacement and production acceptance remain separate from regression testing.
docs/lead-cooled-parts.mdadded+22@@ -0,0 +1,22 @@+# Lead-cooled hot parts (issue #116)++Most parts that warm up have no exposed tab: LEDs, inductors, small leaded ICs. Their heat leaves through their leads into the copper they are soldered to. Declare such a part in `spec.hot` with `"cooling": "leads"` and no `tabNet`:++```json+"hot": {+ "D10": {"watts": 0.07, "cooling": "leads"},+ "L1": {"watts": 0.12, "cooling": "leads", "maxRiseC": 20},+ "U3": {"watts": 0.30, "cooling": "leads", "tjMaxC": 125, "thetaJcKPerW": 60, "thetaSource": "datasheet p.6, psiJB"}+}+```++- **What Fields does:** adom-fields already models a part with no tabNet this way. Its watts go into all of its pads by pad area, and it reports the part as tabNet `leads`.+- **What AI Flow does:**+ - There is no tab pour to estimate, so `analyze thermal` marks the part pending, then passes it on the same-input Fields solve.+ - The part's rise budget (`maxRiseC`, else `hotMaxRiseC`) and its declared watts are checked exactly as for a tab-cooled part.+ - It gets no heatsink-via target.+- **Junction limit:** a `tjMaxC` on a lead-cooled part needs `thetaJcKPerW` (the datasheet's junction-to-board figure for the package) and `thetaSource`. The screen then checks ambient (`ambientC`, default 25 C) plus the solved rise plus thetaJcKPerW times watts against it.+- **Refused:**+ - `cooling: "leads"` together with a `tabNet`, a `tabPad` or `packageCooling`;+ - a footprint with an exposed pad (with three or more pads, one at least three times the median of the others). Give that part its real `tabNet`.+- **Without the declaration:** a hot part with no `tabNet` still fails, and the message names both fixes: the tab net, or `cooling: "leads"`.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.30",+ "version": "0.2.31", "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.30",+ "version": "0.2.31", "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/adom-aiflow/SKILL.md+23−3@@ -6,7 +6,9 @@ description: >- # adom-aiflow: the loop, measured -This tool is the place-route-pour-analyze skill made executable: the step order is a file, the gates refuse, the hints arrive as the answer to the command you just ran, and the ledger records what you did. Follow the commands and the skill follows itself.+This tool is the place-route-pour-analyze skill made executable: the step order is a file, the checks report what is wrong, the hints arrive as the answer to the command you just ran, and the ledger records what you did. Follow the commands and the skill follows itself.++**AI Flow is a helper, not a wall.** It gives you ready-made tools (the router, pours, DRC and analysis gates, linters) so you don't write them from scratch, hints for the next step, and the Adom habits: public wiki pages for new parts that every Adom user then shares, and a project page with the 3D, schematic and board viewers and the datasheets. You are still the engineer. When a check refuses a board that is right, because AI Flow has not seen a design like it, don't stop and don't fake a pass: improve AI Flow (see "Contribute to AI Flow" at the end) and finish the board with your build. Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kicad` for the offline DRC gate, and the KiCad Bridge on a desktop for the live stages). `adom-aiflow --version`. @@ -165,7 +167,9 @@ the library, simulation, current, thermal, fields and export, Fusion for everyth `switchNodeMaxLengthMm` (12 mm default) and sends you back to placement: fix it there, not with a wider trace. 10. **Heatsink vias.** `thermal-vias plan` (a target per hot part from spec.hot watts and the rise- budget, at least 6) and `thermal-vias land`.+ budget, at least 6) and `thermal-vias land`. A hot part with no exposed tab (an LED, an+ inductor, a small leaded IC) takes `"cooling": "leads"` instead of a tabNet: no via target, and+ the Fields solve judges it (docs/lead-cooled-parts.md). 11. **Pours**, then **rounded corners**: `outline round` (1.0 mm default, spec outline.cornerRadius). 12. **Current, heat and fields, in Hydrogen.** `analyze current`, `analyze thermal`, adom-fields in the right pane with every part ghosted (`ui view --parts ghost --camera iso`, `tour`). A problem here is@@ -537,7 +541,7 @@ When multiple physical lands share one logical pin number, use the reviewed Fiel ## Verify existing placement without replaying moves -After adopting an already placed KiCad board, use `place verify` during the placement step. It compares every live reference, XY position, rotation, side and library binding with the saved board, brackets native placement validation with two matching live revisions, requires complete native placement DRC and no outside/missing-courtyard findings, and records board/spec hashes. The bridge judges courtyards as boxes, so nested generated copper (concentric rings) overlaps there: a box overlap is excused only when both references are in `spec.fixedRefs`, KiCad's placement DRC is clean and aiflow's exact courtyard check finds no overlap for the pair; a missing courtyard is excused only for a `fixedRefs` footprint with no pads (the gate's own exemption). Excused findings are recorded in the outcome (`excused`); every other pair or reference is named in the error. Failed or stale verification remains blocking. `finish` and `finish-guard` reject changed inputs. The outcome uses `place-land` with `mode: verified-existing` for sequence compatibility; it does not claim any footprint was moved or created. Other DRC warnings remain in the report; full DRC, netlist, current, thermal and physical qualification are separate requirements. Never send dummy moves just to advance a step.+After adopting an already placed KiCad board, use `place verify` during the placement step. It compares every live reference, XY position, rotation, side and library binding with the saved board, brackets native placement validation with two matching live revisions, requires complete native placement DRC and no outside/missing-courtyard findings, and records board/spec hashes. The bridge judges courtyards as boxes, so nested generated copper (concentric rings) overlaps there: a box overlap is excused only when both references are in `spec.fixedRefs`, KiCad's placement DRC is clean and aiflow's exact courtyard check finds no overlap for the pair; a missing courtyard is excused only for a `fixedRefs` footprint with no pads (the gate's own exemption). Parts that touch on purpose (a gang of connectors butted body to body, approved by the board's owner) take `placement.approvedCourtyardContacts`: the exact pair, side, footprint fingerprints, the reviewed overlap bound and the owner's approval. `place verify`, `gate` and `finish` then accept that courtyard overlap and nothing else; body intersections still refuse (docs/approved-courtyard-contacts.md). Excused findings are recorded in the outcome (`excused`); every other pair or reference is named in the error. Failed or stale verification remains blocking. `finish` and `finish-guard` reject changed inputs. The outcome uses `place-land` with `mode: verified-existing` for sequence compatibility; it does not claim any footprint was moved or created. Other DRC warnings remain in the report; full DRC, netlist, current, thermal and physical qualification are separate requirements. Never send dummy moves just to advance a step. ## One live dashboard per run @@ -650,3 +654,19 @@ Optional steps, required only when the spec (or requirements) declares them: `"m - `finish` refuses a declared review that is missing, failed, or stale (the review file, spec or GLB changed, or the board is not the reviewed one unless the copper carry records prove the copper is unchanged). - `adopt-board` and `place check` compare the named connectors and every NPTH or mounting hole between the old and new board. A change removes the review outcomes and the test plan outcome (`serviceInvalidated` in run.json); routing, gate, landings, measure, current and thermal are already voided by the copper change. Run `testplan check` and the reviews again. - The tools check declared completeness, dimensions and lengths. They are not swept-volume or collision solvers; a pass is not physical-fit evidence. Tool animations are optional. Not declared: nothing changes.++## Contribute to AI Flow++Every Adom user's AI is welcome to improve AI Flow. When a check is wrong for this board, a helper is missing, or a hint misleads you, fix it in the source and send the change back. That also gets this board built: your build runs it, which tests the change on a real design.++1. `adom-wiki repo log adom/adom-aiflow --limit 1` (note the head hash), then `adom-wiki repo clone adom/adom-aiflow --dir aiflow-src`.+2. Make the smallest change that handles the case: teach the check about the design, rather than switching it off. Add a test beside the code.+3. `cargo build --release`, `cargo test --release`, then run this board with `aiflow-src/target/release/adom-aiflow`.+4. Send it back: `adom-wiki pr create adom/adom-aiflow --title "..." --files <each changed file> --body-file <pr.md>`. In pr.md, give the base hash, what blocked the board, what you changed and how you tested it. If the head moved, re-clone and re-apply before opening it. Or, if you can't make the change, `adom-wiki issue create adom/adom-aiflow` with the board's evidence.++When the user asks “Help me contribute an improvement to adom-aiflow through a pull request”,+also read `dev-skills/review-safe-contributions/SKILL.md` from the public source repository.+Discuss the specific improvement, clone the current immutable source, implement a focused+change, run the relevant checks and review the diff before opening the requested PR.+Source and pull requests: https://wiki.adom.inc/adom/adom-aiflow/prs.+AI Flow is MIT licensed; keep notices and third-party licenses intact.