← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.5"+version = "0.2.6" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.5"+version = "0.2.6" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.5"+version = "0.2.6" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.5"+version = "0.2.6" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.5"+version = "0.2.6" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.5"+version = "0.2.6" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.5"+version = "0.2.6" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.5"+version = "0.2.6" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.5"+version = "0.2.6" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.5"+version = "0.2.6" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.5"+version = "0.2.6" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+2
@@ -193,6 +193,8 @@ The clock starts at the prompt even when there is no board yet.  After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. When the Fields URL is a localhost proxy (an Adom Hydrogen desktop container's `VSCODE_PROXY_URI` is `http://{{port}}.localhost:<port>/`, reachable only on the machine hosting the container), the window opens on the user's primary machine (`adom-bridge targets` names it) instead of the test box, and the answer says so; `--on <box>` overrides. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`. +**A switching converter needs `voltages`, `sources` and `switching` in the spec (issue #75).** A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence (`evidence add --kind calcs`) carries a switching frequency (`fsw`, `fsw_kHz`, `switching_frequency_Hz`, ...) or a converter `topology`. Fields models the cycle, the hot loop, emissions and the PDN only with the rails, the part feeding each output and the switching node with its frequency: `"voltages": {"VIN": 12, "VOUT": 5}, "sources": {"VOUT": ["L1"], "SW": ["U1"]}, "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}}`. `start`, `evidence add --kind calcs` and `analyze` warn when the block is missing; `tour fields` (the step's proof) and `finish` refuse it, and also refuse a solve whose `fields.json` says it modelled no converter (`review.converter` null) or checked no rail's PDN (`review.pdn.rails` all skipped). The refusal prints the block filled from calcs.json (VIN, VOUT, fsw) and the board (switch, inductor, rails); `riseNs` 8 is adom-fields' default edge time, an estimate to replace from the regulator datasheet. Add the block, solve again, serve, `tour fields`.+ ## Time: sessions, not the wall clock  The AI's time on a run is the sum of its sessions: one per human prompt, from the prompt to the AI's done, with idle gaps (over 15 min, no command running) cut out. The first prompt is `start --prompt-time`; every follow-up prompt is `prompt --text "..."` (`--at <time>` when you start late); the answer's end is `done --message "..."` (`deliver` is the done of the first task). Say `done` the moment an answer is complete, so nothing idle is charged; work after a done with no `prompt` mark is listed as an unmarked follow-up, one row per idle gap. The delivery number on the page is the AI time from the first prompt to `deliver`; follow-ups have their own rows. `sessions` prints the table. The per-step table and the video's run clock ("AI TIME, THIS RUN") count active seconds only.
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/converter.rsadded+270
@@ -0,0 +1,270 @@+//! Switching converters and the Adom Fields spec (issue #75). Fields models a converter's switching+//! cycle, its hot loop and emissions (EMC) and its power delivery (PDN) only when the spec names the+//! rails (`voltages`), which part feeds each net (`sources`) and the switching node with its frequency+//! (`switching`). Without them the solve still passes for current and heat, its `review.converter` is+//! null, and the Cycle, EMC and Power views say "not modelled".+//!+//! A design is a converter here when (conservative: no guessing from part names or values):+//! - the spec declares `switchNodes` (what the switch-node checks read, switchnode.rs) or `switching`, or+//! - the run's calcs evidence (`evidence add --kind calcs`, design/calcs.json) carries a switching+//!   frequency (a key such as `fsw`, `fsw_kHz`, `f_sw`, `switching_frequency_Hz`, or `{"name": "fsw",+//!   "value": ..., "unit": "kHz"}`) or a converter `topology` (buck, boost, buck-boost, sepic, cuk,+//!   flyback, forward, inverting).+//!+//! Then the spec must carry non-empty `voltages`, `sources` and `switching`, every switching node with+//! `freqHz`; `start` and `evidence add --kind calcs` warn, `tour fields` and finish refuse. The Fields+//! report must also model it: `review.converter` (adom-fields emc.rs) is null when the solve found no+//! converter (no switching node with its inductor, switch, input and output rails and output load), and+//! `review.pdn.rails` lists the rails it checked (each with `skipped` when it could not).++use serde_json::{json, Value};++/// Numbers calcs.json gives for the block: input and output volts, switching frequency (Hz), topology.+#[derive(Debug, Default, Clone, PartialEq)]+pub struct Calcs { pub vin: Option<f64>, pub vout: Option<f64>, pub fsw_hz: Option<f64>, pub topology: Option<String> }++const TOPOLOGIES: &[&str] = &["buck-boost", "buck", "boost", "sepic", "cuk", "flyback", "forward", "inverting"];++fn norm_key(k: &str) -> String { k.to_ascii_lowercase().replace(['-', ' '], "_") }++/// A frequency key and its unit multiplier: fsw, fsw_hz, fsw_khz, f_sw_mhz, switching_frequency(_hz|_khz), freq_sw ...+fn freq_key(k: &str) -> Option<f64> {+    let k = norm_key(k);+    let (base, mul) = if let Some(b) = k.strip_suffix("_mhz") { (b.to_string(), Some(1e6)) } else if let Some(b) = k.strip_suffix("_khz") { (b.to_string(), Some(1e3)) } else if let Some(b) = k.strip_suffix("_hz") { (b.to_string(), Some(1.0)) } else { (k.clone(), None) };+    let is = matches!(base.as_str(), "fsw" | "f_sw" | "fs_w" | "freq_sw" | "sw_freq" | "switching_freq" | "switching_frequency" | "fswitch" | "f_switch" | "fsw_nom" | "fsw_typ");+    // no unit in the key: a converter switches at 10 kHz or more, so a small number is kHz+    if is { Some(mul.unwrap_or(0.0)) } else { None }+}++fn unit_mul(u: &str) -> Option<f64> {+    match u.trim().to_ascii_lowercase().as_str() { "hz" => Some(1.0), "khz" => Some(1e3), "mhz" => Some(1e6), _ => None }+}++fn volts_key(k: &str, which: &str) -> Option<usize> {+    let k = norm_key(k);+    let k = k.strip_suffix("_v").unwrap_or(&k);+    let order = ["", "_nom", "_nominal", "_typ", "_target", "_set"];+    order.iter().position(|s| k == format!("{which}{s}"))+}++fn walk(v: &Value, c: &mut Calcs, vin_rank: &mut usize, vout_rank: &mut usize) {+    match v {+        Value::Object(m) => {+            // {"name": "fsw", "value": 500, "unit": "kHz"}: the calcs evidence shape+            if let (Some(n), Some(x)) = (m.get("name").and_then(|n| n.as_str()), m.get("value").and_then(|x| x.as_f64())) {+                if let Some(mul) = freq_key(n) {+                    let mul = m.get("unit").and_then(|u| u.as_str()).and_then(unit_mul).unwrap_or(mul);+                    if c.fsw_hz.is_none() { c.fsw_hz = Some(scale_freq(x, mul)); }+                }+                for (which, rank, slot) in [("vin", &mut *vin_rank, &mut c.vin), ("vout", &mut *vout_rank, &mut c.vout)] {+                    if let Some(p) = volts_key(n, which) { if p < *rank { *rank = p; *slot = Some(x); } }+                }+            }+            for (k, x) in m {+                if norm_key(k) == "topology" {+                    if let Some(t) = x.as_str() { let t = t.to_ascii_lowercase(); if c.topology.is_none() { c.topology = TOPOLOGIES.iter().find(|n| t.contains(*n)).map(|n| n.to_string()); } }+                }+                if let Some(n) = x.as_f64() {+                    if let Some(mul) = freq_key(k) { if c.fsw_hz.is_none() { c.fsw_hz = Some(scale_freq(n, mul)); } }+                    if let Some(p) = volts_key(k, "vin") { if p < *vin_rank { *vin_rank = p; c.vin = Some(n); } }+                    if let Some(p) = volts_key(k, "vout") { if p < *vout_rank { *vout_rank = p; c.vout = Some(n); } }+                }+            }+            for x in m.values() { if x.is_object() || x.is_array() { walk(x, c, vin_rank, vout_rank); } }+        }+        Value::Array(a) => for x in a { walk(x, c, vin_rank, vout_rank) },+        _ => {}+    }+}++fn scale_freq(x: f64, mul: f64) -> f64 { if mul > 0.0 { x * mul } else if x < 1e4 { x * 1e3 } else { x } }++/// The converter numbers a calcs.json carries (any nesting; the first frequency found, the best-named volts).+pub fn calcs(v: &Value) -> Calcs {+    let mut c = Calcs::default();+    let (mut a, mut b) = (usize::MAX, usize::MAX);+    walk(v, &mut c, &mut a, &mut b);+    c+}++/// The run's latest calcs evidence as JSON (its copy in the run first, then the original file).+pub fn calcs_of_run(data: &Value) -> Option<Value> {+    let e = data["evidence"].as_array()?.iter().filter(|e| e["kind"] == "calcs").max_by_key(|e| e["at"].as_str().unwrap_or("").to_string())?;+    [&e["copy"], &e["file"]].iter().filter_map(|p| p.as_str()).find_map(|p| std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()))+}++/// Why this design is a switching converter, or None.+pub fn detect(spec: &Value, calcs_v: Option<&Value>) -> Option<String> {+    if let Some(a) = spec["switchNodes"].as_array().filter(|a| !a.is_empty()) {+        return Some(format!("the spec declares switchNodes {}", a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")));+    }+    if let Some(o) = spec["switching"].as_object().filter(|o| !o.is_empty()) {+        return Some(format!("the spec declares switching node(s) {}", o.keys().cloned().collect::<Vec<_>>().join(", ")));+    }+    let c = calcs(calcs_v?);+    if let Some(f) = c.fsw_hz { return Some(format!("calcs.json gives a switching frequency of {} kHz", trim(f / 1e3))); }+    c.topology.map(|t| format!("calcs.json gives the {t} topology"))+}++/// What the spec lacks for Fields to model the converter.+pub fn spec_gaps(spec: &Value) -> Vec<String> {+    let mut out: Vec<String> = ["voltages", "sources", "switching"].iter().filter(|k| spec[**k].as_object().map_or(true, |o| o.is_empty())).map(|k| k.to_string()).collect();+    if let Some(o) = spec["switching"].as_object() {+        for (n, s) in o { if s["freqHz"].as_f64().is_none() { out.push(format!("switching.{n}.freqHz")); } }+    }+    out+}++fn trim(x: f64) -> String { let s = format!("{:.3}", x); s.trim_end_matches('0').trim_end_matches('.').to_string() }++/// The spec block to add, filled from the spec, calcs.json and the board where they say; `<...>` where nothing does.+/// `refs` is (switch net, switch part, inductor, output net, input net) when the board tells them.+pub fn hint_block(spec: &Value, c: &Calcs, refs: Option<(String, Option<String>, Option<String>, Option<String>, Option<String>)>) -> String {+    let sw = refs.as_ref().map(|r| r.0.clone())+        .or_else(|| spec["switchNodes"].as_array().and_then(|a| a.first()).and_then(|x| x.as_str()).map(str::to_string))+        .or_else(|| spec["switching"].as_object().and_then(|o| o.keys().next().cloned()))+        .unwrap_or_else(|| "SW".into());+    let (uref, lref, out_net, in_net) = match &refs { Some((_, u, l, o, i)) => (u.clone(), l.clone(), o.clone(), i.clone()), None => (None, None, None, None) };+    let in_net = in_net.unwrap_or_else(|| "VIN".into());+    let out_net = out_net.unwrap_or_else(|| "VOUT".into());+    let num = |v: Option<f64>, what: &str| v.map(trim).unwrap_or_else(|| format!("\"<{what}>\""));+    let vin = spec["voltages"][&in_net].as_f64().or(c.vin);+    let vout = spec["voltages"][&out_net].as_f64().or(c.vout);+    // a buck's switch node swings between the input and ground; a boost's between the output and ground+    let high = if c.topology.as_deref() == Some("boost") { vout } else { vin };+    let fsw = spec["switching"][&sw]["freqHz"].as_f64().or(c.fsw_hz);+    let q = |s: &str| json!(s).to_string();+    format!("\"voltages\": {{{}: {}, {}: {}}},\n\"sources\": {{{}: [{}], {}: [{}]}},\n\"switching\": {{{}: {{\"highV\": {}, \"lowV\": 0, \"freqHz\": {}, \"riseNs\": 8}}}}",+        q(&in_net), num(vin, "VIN volts"), q(&out_net), num(vout, "VOUT volts"),+        q(&out_net), q(lref.as_deref().unwrap_or("<the inductor, e.g. L1>")), q(&sw), q(uref.as_deref().unwrap_or("<the switch or regulator, e.g. U1>")),+        q(&sw), num(high, "input volts"), fsw.map(|f| format!("{}", f.round() as i64)).unwrap_or_else(|| "\"<fsw in Hz>\"".into()))+}++pub const RISE_NOTE: &str = "riseNs 8 is adom-fields' default edge time, an estimate: replace it with the switch's rise time from the regulator's datasheet (it sets the EMC harmonics). Values in <...> were not in the spec or calcs.json: fill them from the design.";++/// The switch node and its parts on the board: (switch net, switch part U*, inductor L*, output net, input net).+pub fn board_refs(spec: &Value, b: &aiflow_board::Board) -> Option<(String, Option<String>, Option<String>, Option<String>, Option<String>)> {+    let sw = crate::switchnode::nets(spec, b).into_iter().next()?;+    let on = |r: &str| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference == r && !p.net.is_empty()).map(|p| p.net.clone()).collect(); v.sort(); v.dedup(); v };+    let mut refs: Vec<String> = b.pads.iter().filter(|p| p.net == sw).map(|p| p.reference.clone()).collect();+    refs.sort(); refs.dedup();+    let lref = refs.iter().find(|r| r.starts_with('L')).cloned();+    let uref = refs.iter().find(|r| r.starts_with('U')).cloned();+    let ground = |n: &str| { let u = n.to_ascii_uppercase(); u.contains("GND") || u == "VSS" };+    let out_net = lref.as_ref().and_then(|l| on(l).into_iter().find(|n| *n != sw && !ground(n)));+    let in_net = uref.as_ref().and_then(|u| on(u).into_iter().find(|n| { let x = n.to_ascii_uppercase(); x.trim_start_matches(['/', '+']).starts_with("VIN") || x.trim_start_matches(['/', '+']) == "IN" }));+    Some((sw, uref, lref, out_net, in_net))+}++/// What the Fields report says it did not model for a converter (empty when it modelled it).+pub fn report_gaps(fields: &Value) -> Vec<String> {+    let review = &fields["review"];+    if !review.is_object() { return vec!["the Fields report has no `review` block (no converter, EMC or PDN review): update adom-fields and solve again".into()]; }+    let mut out = Vec::new();+    if review["converter"].is_null() {+        out.push("the Fields report modelled no switching converter (`review.converter` is null: no switching node with its frequency, inductor, switch, input and output rails and output load), so its Cycle, EMC and Power views say not modelled and there are no hot-loop, emissions or ripple numbers".into());+    }+    if let Some(rails) = review["pdn"]["rails"].as_array() {+        if !rails.iter().any(|r| r["skipped"].is_null()) {+            let why: Vec<String> = rails.iter().map(|r| format!("{}: {}", r["net"].as_str().unwrap_or("?"), r["skipped"].as_str().unwrap_or("skipped"))).collect();+            out.push(format!("the Fields report checked no rail's PDN impedance (`review.pdn.rails`{})", if why.is_empty() { " is empty".to_string() } else { format!(": {}", why.join("; ")) }));+        }+    }+    out+}++/// The refusal (or warning) text for a converter whose spec or Fields report is short, else None.+pub fn check(spec: &Value, calcs_v: Option<&Value>, fields: Option<&Value>, board: Option<&aiflow_board::Board>) -> Option<(String, Vec<String>)> {+    let why = detect(spec, calcs_v)?;+    let gaps = spec_gaps(spec);+    let rep = fields.map(report_gaps).unwrap_or_default();+    if gaps.is_empty() && rep.is_empty() { return None; }+    let mut parts = Vec::new();+    if !gaps.is_empty() { parts.push(format!("the spec lacks {}", gaps.join(", "))); }+    parts.extend(rep);+    let c = calcs_v.map(calcs).unwrap_or_default();+    let block = hint_block(spec, &c, board.and_then(|b| board_refs(spec, b)));+    Some((format!("this is a switching converter ({why}) but {}", parts.join("; ")), vec![+        format!("Add to the spec (top level), then solve again (`adom-fields analyze --board <current board> --spec <spec> --out <run>/fields`):\n{block}"),+        RISE_NOTE.into(),+        "Fields needs the rails, the part that feeds each output and the switching node with its frequency to model the cycle, the hot loop, emissions and the PDN (aiflow-power-layout, the adom-fields skill).".into(),+    ]))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn calcs_fixture() -> Value {+        json!({"brief": {"VIN_NOM": 12.0, "VIN_MAX": 16.0, "VOUT": 5.0}, "regulator": {"topology": "Synchronous buck", "fsw_kHz": 500},+               "feedback": {"vout_nominal": 4.98}, "inductor": {"L_uH": 15}})+    }++    #[test]+    fn calcs_give_rails_frequency_and_topology() {+        let c = calcs(&calcs_fixture());+        assert_eq!(c, Calcs { vin: Some(12.0), vout: Some(5.0), fsw_hz: Some(500e3), topology: Some("buck".into()) });+        let c = calcs(&json!([{"name": "fsw", "value": 1.2, "unit": "MHz"}, {"name": "Vin", "value": 5}]));+        assert_eq!((c.fsw_hz, c.vin), (Some(1.2e6), Some(5.0)));+        assert_eq!(calcs(&json!({"fsw": 400})).fsw_hz, Some(400e3));+        assert_eq!(calcs(&json!({"switching_frequency_Hz": 2100000})).fsw_hz, Some(2.1e6));+        // a calcs.json with no frequency and no topology (an LDO, an op-amp stage) is not a converter+        assert_eq!(detect(&json!({}), Some(&json!({"feedback": {"vout_nominal": 3.3}, "ldo": {"dropout_V": 0.25}}))), None);+    }++    #[test]+    fn detection_is_from_the_spec_or_calcs_only() {+        assert!(detect(&json!({"switchNodes": ["SW"]}), None).unwrap().contains("switchNodes SW"));+        assert!(detect(&json!({"switching": {"LX": {"highV": 5}}}), None).is_some());+        assert!(detect(&json!({}), Some(&calcs_fixture())).unwrap().contains("500 kHz"));+        assert!(detect(&json!({}), Some(&json!({"topology": "boost"}))).unwrap().contains("boost"));+        assert_eq!(detect(&json!({"wideNets": ["VIN"]}), None), None);+        assert_eq!(detect(&json!({"switchNodes": []}), None), None);+    }++    #[test]+    fn a_converter_spec_without_the_block_is_refused_with_the_block_filled() {+        // the TPS54202 run of issue #75: wideNets, loads and hot parts, no voltages/sources/switching+        let spec = json!({"switchNodes": ["SW"], "wideNets": ["VIN", "VOUT", "SW", "GND"], "loads": {"VOUT": {"amps": 2}}});+        assert_eq!(spec_gaps(&spec), vec!["voltages", "sources", "switching"]);+        let (msg, hints) = check(&spec, Some(&calcs_fixture()), None, None).unwrap();+        assert!(msg.contains("switching converter (the spec declares switchNodes SW) but the spec lacks voltages, sources, switching"), "{msg}");+        assert!(hints[0].contains("\"voltages\": {\"VIN\": 12, \"VOUT\": 5}"), "{}", hints[0]);+        assert!(hints[0].contains("\"switching\": {\"SW\": {\"highV\": 12, \"lowV\": 0, \"freqHz\": 500000, \"riseNs\": 8}}"), "{}", hints[0]);+        assert!(hints[0].contains("\"sources\": {\"VOUT\": [\"<the inductor, e.g. L1>\"], \"SW\": [\"<the switch or regulator, e.g. U1>\"]}"), "{}", hints[0]);+        assert!(hints[1].contains("estimate"));+        // nothing in calcs: placeholders, never invented numbers+        let (_, hints) = check(&spec, None, None, None).unwrap();+        assert!(hints[0].contains("\"freqHz\": \"<fsw in Hz>\"") && hints[0].contains("\"VIN\": \"<VIN volts>\""), "{}", hints[0]);+        // a switching node without its frequency is a gap too (adom-fields would assume 500 kHz)+        assert_eq!(spec_gaps(&json!({"voltages": {"VIN": 12}, "sources": {"VOUT": ["L1"]}, "switching": {"SW": {"highV": 12}}})), vec!["switching.SW.freqHz"]);+    }++    #[test]+    fn a_complete_spec_and_a_modelled_solve_pass() {+        let spec = json!({"switchNodes": ["SW"], "voltages": {"VIN": 12, "VOUT": 5}, "sources": {"VOUT": ["L1"], "SW": ["U1"]}, "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}}});+        let solved = json!({"review": {"converter": {"hotLoop": {"cap": "C1", "mm2": 2.35}, "emissions": {"worstMarginDb": 56.1}}, "pdn": {"rails": [{"net": "VOUT", "worst": {"ratio": 0.4}}]}}});+        assert_eq!(check(&spec, None, Some(&solved), None), None);+        assert_eq!(check(&spec, None, None, None), None);+        // not a converter: nothing asked+        assert_eq!(check(&json!({}), None, Some(&json!({"review": {"converter": null}})), None), None);+    }++    #[test]+    fn a_solve_that_modelled_no_converter_is_refused() {+        let spec = json!({"switchNodes": ["SW"], "voltages": {"VIN": 12, "VOUT": 5}, "sources": {"VOUT": ["L1"]}, "switching": {"SW": {"highV": 12, "freqHz": 500000}}});+        let unmodelled = json!({"review": {"converter": null, "pdn": {"rails": [{"net": "VOUT", "skipped": "no load given"}]}}});+        let (msg, _) = check(&spec, None, Some(&unmodelled), None).unwrap();+        assert!(msg.contains("review.converter` is null"), "{msg}");+        assert!(msg.contains("checked no rail's PDN impedance (`review.pdn.rails`: VOUT: no load given)"), "{msg}");+        assert_eq!(report_gaps(&json!({"nets": []})).len(), 1);+    }++    #[test]+    fn the_board_names_the_parts_in_the_block() {+        let refs = Some(("SW".to_string(), Some("U1".to_string()), Some("L1".to_string()), Some("+5V".to_string()), Some("VIN".to_string())));+        let b = hint_block(&json!({}), &calcs(&calcs_fixture()), refs);+        assert!(b.contains("\"voltages\": {\"VIN\": 12, \"+5V\": 5}"), "{b}");+        assert!(b.contains("\"sources\": {\"+5V\": [\"L1\"], \"SW\": [\"U1\"]}"), "{b}");+    }+}
crates/adom-aiflow/src/export.rs+1−1
@@ -307,7 +307,7 @@ fn read_dir(dir: &Path, exts: &[&str]) -> Vec<(String, String)> { fn kicad(cli: &str, args: &[String]) -> Result<(), String> {     let o = std::process::Command::new(cli).args(args).output().map_err(|e| format!("{cli} is not runnable: {e}"))?;     if o.status.success() { return Ok(()); }-    let tail = |b: &[u8]| { let t = String::from_utf8_lossy(b).to_string(); t.chars().rev().take(400).collect::<String>().chars().rev().collect::<String>() };+    let tail = |b: &[u8]| aiflow_run::clip_text(&String::from_utf8_lossy(b), 400);     Err(format!("{cli} {} failed: {} {}", args[..3.min(args.len())].join(" "), tail(&o.stdout), tail(&o.stderr))) } 
crates/adom-aiflow/src/main.rs+60−11
@@ -10,6 +10,7 @@ mod silkscreen_sections; mod sequence; mod testplan; mod switchnode;+mod converter; mod thermalvias; mod outline; mod browser_access;@@ -391,6 +392,18 @@ fn spec_soft(r: &Run) -> Value {     spec_nets::align(spec, &r.current_board()) } +/// Issue #75: a switching converter whose spec lacks Fields' `voltages`/`sources`/`switching` (or whose+/// Fields report modelled no converter). Warns (stderr WARNING and a hint) where the spec is read early;+/// `tour fields` and finish refuse on the same check.+fn converter_warning(data: &Value, spec: &Value, fields: Option<&Value>, board: Option<&Board>) -> Vec<String> {+    let calcs = converter::calcs_of_run(data);+    let Some((msg, hints)) = converter::check(spec, calcs.as_ref(), fields, board) else { return Vec::new() };+    eprintln!("WARNING: {msg}. The fields step will refuse until the spec carries the block and the Fields solve models the converter.");+    let mut out = vec![format!("Converter spec for Adom Fields: {msg}; the fields step refuses until it is fixed.")];+    out.extend(hints);+    out+}+ /// requirements.json (aiflow-intake) when the run knows one, else Null. fn requirements_of(r: &Run) -> Value {     r.data["requirements"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)@@ -472,7 +485,7 @@ fn publish_clips(r: &Run, dir: &Path, page: &str, push: bool) -> Result<(String,         for f in batch { cmd.arg(&f.0); }         match cmd.output() {             Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }-            Ok(o) => failed.push(format!("{} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())),+            Ok(o) => failed.push(format!("{} file(s): {}", batch.len(), aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 160))),             Err(e) => failed.push(format!("{} file(s): {e}", batch.len())),         }     }@@ -1994,7 +2007,7 @@ fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, th             for f in batch { cmd.arg(&f.0); }             match cmd.output() {                 Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }-                Ok(o) => { failed += batch.len(); notes.push(format!("push failed for {} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())); }+                Ok(o) => { failed += batch.len(); notes.push(format!("push failed for {} file(s): {}", batch.len(), aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 160))); }                 Err(e) => { failed += batch.len(); notes.push(format!("push failed: {e}")); }             }         }@@ -2155,7 +2168,7 @@ fn export_sch_netlist(sch: &str, out: &Path) -> Result<String, String> {     let o = std::process::Command::new(&cli).args(["sch", "export", "netlist", "--format", "kicadsexpr", "--output"]).arg(out).arg(sch).output()         .map_err(|e| format!("{cli} is not runnable: {e}"))?;     if !o.status.success() || !out.is_file() {-        let tail = |b: &[u8]| { let t = String::from_utf8_lossy(b).to_string(); t.chars().rev().take(500).collect::<String>().chars().rev().collect::<String>() };+        let tail = |b: &[u8]| aiflow_run::clip_text(&String::from_utf8_lossy(b), 500);         return Err(format!("{cli} sch export netlist failed: {} {}", tail(&o.stdout), tail(&o.stderr)));     }     Ok(cli)@@ -2633,6 +2646,7 @@ fn main() {                 let rc_flags = rules_card::at_start(&dir, &read_json(spec), &copy, &r.data["fabRules"]);                 for f in &rc_flags { eprintln!("WARNING: {f}"); }                 attach_hints.push(rules_card_hint(&rc_flags));+                attach_hints.extend(converter_warning(&r.data, &read_json(spec), None, Board::load(&copy.display().to_string()).ok().as_ref()));                 ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &attach_hints);                 print_next(&cli);                 return;@@ -2654,6 +2668,7 @@ fn main() {             let rc_flags = rules_card::at_start(&dir, &spec_v, &copy, &r.data["fabRules"]);             for f in &rc_flags { eprintln!("WARNING: {f}"); }             start_hints.push(rules_card_hint(&rc_flags));+            start_hints.extend(converter_warning(&r.data, &spec_v, None, Board::load(&copy.display().to_string()).ok().as_ref()));             if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }             start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);@@ -2770,6 +2785,11 @@ fn main() {             };             let mut hints = vec![hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()];             hints.extend(review.iter().map(|w| format!("warning: {w}")));+            // issue #75: the calcs say what the converter is; a spec that exists already must carry Fields' block+            if kind == "calcs" {+                let spec = spec_soft(&r);+                if !spec.is_null() { hints.extend(converter_warning(&r.data, &spec, None, Board::load(&r.current_board()).ok().as_ref())); }+            }             ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &hints);         }         Cmd::Clips { page, push } => {@@ -2831,11 +2851,17 @@ fn main() {             let mut missing = Vec::new();             for row in rows.iter_mut() {                 let mpn = row["mpn"].as_str().unwrap_or("").to_string();-                let o = std::process::Command::new("adom-wiki").args(["search", &mpn, "--json"]).stdin(std::process::Stdio::null()).output().ok();-                let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);-                let hits = v["data"]["results"].as_array().or(v["data"].as_array()).or(v["results"].as_array()).cloned().unwrap_or_default();-                let page = hits.iter().find(|h| h["type"] == "hardware" && matches!(h["match"]["tier"].as_str(), Some("exact_slug") | Some("exact_title")))-                    .and_then(|h| h["ref"].as_str().map(str::to_string).or_else(|| Some(format!("{}/{}", h["owner"].as_str()?, h["slug"].as_str()?))));+                // the MPN, then the wiki's slug form of it (AP2112K-3.3TRG1 lives at ap2112k-3-3trg1, issue #76)+                let mut page = None;+                let mut queries = vec![mpn.clone()];+                if let Some(w) = preboard::part_slug_candidates(&mpn).into_iter().skip(1).next() { queries.push(w); }+                for q in &queries {+                    let o = std::process::Command::new("adom-wiki").args(["search", q, "--json"]).stdin(std::process::Stdio::null()).output().ok();+                    let v: Value = o.and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+                    let hits = v["data"]["results"].as_array().or(v["data"].as_array()).or(v["results"].as_array()).cloned().unwrap_or_default();+                    page = preboard::part_page(&hits, &mpn);+                    if page.is_some() { break; }+                }                 if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); }                 row["wiki"] = json!(page);             }@@ -2890,7 +2916,7 @@ fn main() {             let _ = std::fs::create_dir_all(cfgp.parent().unwrap());             std::fs::write(&cfgp, serde_json::to_string_pretty(&cfg).unwrap()).unwrap();             let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-page.py")).arg(&cfgp).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));-            let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| json!({"ok": false, "error": String::from_utf8_lossy(&o.stderr).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>()}));+            let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| json!({"ok": false, "error": aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 600)}));             if v["ok"] != true {                 if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); r.save().unwrap(); }                 err(&format!("project page: {}", v["error"].as_str().unwrap_or("failed")), &["Nothing was published. Rate-limited adds are retried with backoff; rerun `project-page` after the wait and it reuses the same Cloud Library (never a second one).".into()]);@@ -3718,6 +3744,8 @@ fn main() {                 s             } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };             let passed=heuristic_passed && screen.is_ok();+            // issue #75: the fields step refuses a converter Fields did not model; say so here already+            let converter_hints = converter_warning(&r.data, &spec, if fields_file.is_file() { std::fs::read(&fields_file).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) } else { None }.as_ref(), Some(&board_now));             let covered_acks = screen.as_ref().map(|c| c.clone()).unwrap_or_default();             let sink_assumptions = aiflow_analyze::board_sink::assumptions(&res);             let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into());@@ -3746,7 +3774,9 @@ fn main() {                 if !why.is_empty() { eprintln!("why:\n  {}", why.join("\n  ")); }                 err(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);             }-            ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &[draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()]);+            let mut hints = vec![draw, fields_hint, walk, "The heuristic and same-input Fields solve are engineering screens with documented assumptions, not manufacturing or thermal qualification.".into()];+            hints.extend(converter_hints);+            ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &hints);         }         Cmd::SpecAmend { why } => {             let t = thread(&cli);@@ -3819,6 +3849,14 @@ fn main() {                     } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }                 }             }+            // issue #75: a switching converter needs Fields' voltages/sources/switching and a solve that modelled it+            {+                let report = o.get("analyze-current").and_then(|a| a["fieldReport"].as_str()).map(PathBuf::from).filter(|p| p.is_file()).or_else(|| Some(dir.join("fields/fields.json")).filter(|p| p.is_file()))+                    .and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice::<Value>(&b).ok());+                if let Some((msg, hints)) = converter::check(&spec_of(&r), converter::calcs_of_run(&r.data).as_ref(), report.as_ref(), Some(&board_now())) {+                    missing.push(format!("fields: {msg}. {}", hints.join(" ")));+                }+            }             if let Err(m) = netlist_gate(&r) { missing.push(m); }             if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); }             let silk_warnings = silk_polarity::warnings(&board_now());@@ -4177,7 +4215,7 @@ fn main() {                 let mut cmd = std::process::Command::new("adom-wiki");                 cmd.current_dir(&stage).args(["repo", "push", &page, "-m", &format!("aiflow comparison {}: {} versus {}", rel, r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or("")), "--files"]);                 for f in &files { cmd.arg(f); }-                match cmd.output() { Ok(o) if o.status.success() => notes.push(format!("pushed {} files", files.len())), Ok(o) => err(&format!("push failed: {}", String::from_utf8_lossy(&o.stderr).chars().rev().take(200).collect::<String>().chars().rev().collect::<String>()), &["Run it again: the files are still staged.".into()]), Err(e) => err(&format!("push failed: {e}"), &[]) }+                match cmd.output() { Ok(o) if o.status.success() => notes.push(format!("pushed {} files", files.len())), Ok(o) => err(&format!("push failed: {}", aiflow_run::clip_text(&String::from_utf8_lossy(&o.stderr), 200)), &["Run it again: the files are still staged.".into()]), Err(e) => err(&format!("push failed: {e}"), &[]) }             }             if *refresh {                 if let Some(m) = r.data["userMachine"].as_str() {@@ -4833,6 +4871,17 @@ fn main() {                                 format!("Stop that server (`pgrep -af \"adom-fields serve\"` names it; kill its pid), then solve this board and serve it: `adom-fields analyze --board <this run's board> --spec <spec> --out {}/fields` and `adom-fields serve --fields {}/fields --port {port} &`.", run_dir.display(), run_dir.display())]);                         }                     }+                    // issue #75: a switching converter's walkthrough (the step's proof) needs Fields to have modelled+                    // the converter: the spec's voltages/sources/switching and a solve whose review.converter is set+                    {+                        let spec = spec_soft(&r);+                        let calcs = converter::calcs_of_run(&r.data);+                        if let Some((msg, hints)) = converter::check(&spec, calcs.as_ref(), Some(&served), Board::load(&r.current_board()).ok().as_ref()) {+                            r.log("fields-refused", json!({"why": msg}));+                            r.save().unwrap();+                            err(&format!("the fields step cannot pass: {msg}"), &hints);+                        }+                    }                     let proxy = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| err("no VSCODE_PROXY_URI in this container: the test box cannot reach the app", &["Run inside an Adom container (the proxy URL is how a browser outside reaches a port inside), or set VSCODE_PROXY_URI to https://<slug>.adom.cloud/proxy/{{port}}/.".into()]));                     r.stage_start("tour-fields", Some("binary"), None);                     // pup's browser on a cold box takes longer than one relay call: warm it, wait until it is ready
crates/adom-aiflow/src/preboard.rs+54
@@ -518,9 +518,63 @@ pub fn bom_parts(text: &str) -> Vec<Value> {     }).collect() } +/// The wiki's slug for a title or MPN: lowercase, every run of characters outside [a-z0-9] one "-",+/// no "-" at either end (adom/ap2112k-3-3trg1 for AP2112K-3.3TRG1, adom/ap2112k-1-2trg1 for+/// AP2112K-1.2TRG1). Some older pages keep a "." (adom/ap2112k-3.3), so the lowercase MPN stays a candidate.+pub fn wiki_slug(s: &str) -> String {+    let mut out = String::new();+    for c in s.to_lowercase().chars() {+        if c.is_ascii_lowercase() || c.is_ascii_digit() { out.push(c); } else if !out.is_empty() && !out.ends_with('-') { out.push('-'); }+    }+    out.trim_end_matches('-').to_string()+}++/// The slugs a part's component page may have: the lowercase MPN, then the wiki's slug form (issue #76).+pub fn part_slug_candidates(mpn: &str) -> Vec<String> {+    let mut v = vec![mpn.trim().to_lowercase()];+    let w = wiki_slug(mpn);+    if !w.is_empty() && !v.contains(&w) { v.push(w); }+    v+}++/// The component page for an MPN among `adom-wiki search` hits: a hardware page the search matched by+/// exact slug or exact title, else a hardware page whose slug is one of the MPN's slug candidates+/// (the search ranks such a page by its title or triggers, not as an exact slug). "owner/slug".+pub fn part_page(hits: &[Value], mpn: &str) -> Option<String> {+    let page_ref = |h: &Value| h["ref"].as_str().map(str::to_string).or_else(|| Some(format!("{}/{}", h["owner"].as_str()?, h["slug"].as_str()?)));+    let hw = || hits.iter().filter(|h| h["type"] == "hardware");+    if let Some(p) = hw().find(|h| matches!(h["match"]["tier"].as_str(), Some("exact_slug") | Some("exact_title"))).and_then(page_ref) { return Some(p); }+    let cands = part_slug_candidates(mpn);+    for c in &cands {+        if let Some(p) = hw().find(|h| h["slug"].as_str().map(|s| s.to_lowercase()) == Some(c.clone())).and_then(page_ref) { return Some(p); }+    }+    None+}+ #[cfg(test)] mod bom_parts_tests {     use super::*;++    #[test]+    fn a_dot_in_the_mpn_becomes_a_dash_in_the_slug() {+        assert_eq!(wiki_slug("AP2112K-3.3TRG1"), "ap2112k-3-3trg1");+        assert_eq!(wiki_slug(" BAT54S,215 "), "bat54s-215");+        assert_eq!(wiki_slug("LM317T/NOPB"), "lm317t-nopb");+        assert_eq!(part_slug_candidates("AP2112K-3.3TRG1"), vec!["ap2112k-3.3trg1".to_string(), "ap2112k-3-3trg1".to_string()]);+        assert_eq!(part_slug_candidates("TPS54202DDCR"), vec!["tps54202ddcr".to_string()]);+        // what `adom-wiki search AP2112K-3.3TRG1` returns: the page is ranked by its triggers, not as an exact slug+        let hits = vec![+            json!({"type": "hardware", "owner": "adom", "slug": "amfm-receiver-molecule", "match": {"tier": "phrase"}}),+            json!({"type": "hardware", "owner": "adom", "slug": "ap2112k-3.3", "match": {"tier": "phrase"}}),+            json!({"type": "hardware", "owner": "adom", "slug": "ap2112k-3-3trg1", "title": "AP2112K-3.3TRG1 - 3.3 V Low-Dropout Regulator", "match": {"tier": "declared_trigger"}}),+            json!({"type": "hardware", "owner": "adom", "slug": "tpap2112k-3-3trg1", "match": {"tier": "contains"}}),+        ];+        assert_eq!(part_page(&hits, "AP2112K-3.3TRG1").as_deref(), Some("adom/ap2112k-3-3trg1"));+        assert_eq!(part_page(&hits, "AP2112K-3.3").as_deref(), Some("adom/ap2112k-3.3"));+        assert_eq!(part_page(&hits, "AP2112K-1.2TRG1"), None);+        let exact = vec![json!({"type": "hardware", "ref": "adom/tps54202ddcr", "match": {"tier": "exact_slug"}})];+        assert_eq!(part_page(&exact, "TPS54202DDCR").as_deref(), Some("adom/tps54202ddcr"));+    }     #[test]     fn rows_with_an_mpn_become_parts_and_no_part_rows_are_left_out() {         let csv = "ref,value,mpn,manufacturer,source\n\"C1, C2\",22uF,CL21A226MPQNNNE,Samsung,Mouser\nTP1,,no part,,\nU1,TPS54202,TPS54202DDCR,TI,Mouser\n";
crates/adom-aiflow/src/schematic_check.rs+1−1
@@ -49,7 +49,7 @@ pub fn run_erc(cli: &str, sch: &str, report: &Path) -> Result<(Value, Vec<String         .stdin(std::process::Stdio::null()).output()         .map_err(|e| format!("{cli} is not runnable: {e}"))?;     erc_errors(report).map_err(|e| {-        let tail = |b: &[u8]| { let t = String::from_utf8_lossy(b).to_string(); t.chars().rev().take(400).collect::<String>().chars().rev().collect::<String>() };+        let tail = |b: &[u8]| aiflow_run::clip_text(&String::from_utf8_lossy(b), 400);         format!("{e}. {cli} sch erc said: {} {}", tail(&o.stdout), tail(&o.stderr)).trim().to_string()     }) }
crates/aiflow-copper/src/lib.rs+14−1
@@ -573,7 +573,7 @@ pub fn run_drc(path: &str) -> Result<Value, String> {         Command::new("service-kicad").args(["pcb", "drc", "--format", "json", "--out", report.to_str().unwrap(), path]).output()     }.map_err(|e|format!("DRC backend not runnable: {e}"))?;     if !out.status.success() || !report.is_file() {-        return Err(format!("KiCad DRC failed: {} {}", String::from_utf8_lossy(&out.stdout).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>(), String::from_utf8_lossy(&out.stderr).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>()));+        return Err(format!("KiCad DRC failed: {} {}", clip_text(&String::from_utf8_lossy(&out.stdout), 600), clip_text(&String::from_utf8_lossy(&out.stderr), 600)));     }     let data: Value = serde_json::from_str(&std::fs::read_to_string(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;     let _ = std::fs::remove_file(&report);@@ -644,6 +644,19 @@ pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value {     }) } +/// Error text within `n` characters that keeps its beginning (where the cause is) and its end:+/// about 70% from the front, " ... ", the rest from the back (issue #76). Never cuts the front off.+fn clip_text(s: &str, n: usize) -> String {+    let s = s.trim();+    let count = s.chars().count();+    if count <= n { return s.to_string(); }+    let head = n * 7 / 10;+    let tail = n.saturating_sub(head + 5);+    let front: String = s.chars().take(head).collect();+    let back: String = s.chars().skip(count - tail).collect();+    format!("{front} ... {back}")+}+ #[cfg(test)] mod gate_tests {     use super::*;
crates/aiflow-run/src/lib.rs+23
@@ -7,6 +7,19 @@ use serde_json::{json, Map, Value};  pub const STAGES: [&str; 9] = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"]; +/// Error text within `n` characters that keeps its beginning (where the cause is) and its end:+/// about 70% from the front, " ... ", the rest from the back (issue #76). Never cuts the front off.+pub fn clip_text(s: &str, n: usize) -> String {+    let s = s.trim();+    let count = s.chars().count();+    if count <= n { return s.to_string(); }+    let head = n * 7 / 10;+    let tail = n.saturating_sub(head + 5);+    let front: String = s.chars().take(head).collect();+    let back: String = s.chars().skip(count - tail).collect();+    format!("{front} ... {back}")+}+ pub fn now() -> String {     let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);     iso(t)@@ -641,6 +654,16 @@ impl Run {  #[cfg(test)] mod tests {+    #[test]+    fn clip_text_keeps_the_cause() {+        let msg = format!("CAUSE: the footprint is missing{}END", " x".repeat(400));+        let c = super::clip_text(&msg, 200);+        assert!(c.starts_with("CAUSE: the footprint is missing"), "{c}");+        assert!(c.ends_with("END") && c.contains(" ... "), "{c}");+        assert_eq!(c.chars().count(), 200);+        assert_eq!(super::clip_text("short", 200), "short");+    }+     use super::*;     #[test]     fn external_turn_covers_a_long_gap_without_inventing_idle_activity() {
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.5",+  "version": "0.2.6",   "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.5",+  "version": "0.2.6",   "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+2
@@ -230,6 +230,8 @@ A KiCad run that starts with no board (`intake` with no `start --board`) follows  After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. When the Fields URL is a localhost proxy (an Adom Hydrogen desktop container's `VSCODE_PROXY_URI` is `http://{{port}}.localhost:<port>/`, reachable only on the machine hosting the container), the window opens on the user's primary machine (`adom-bridge targets` names it) instead of the test box, and the answer says so; `--on <box>` overrides. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`. +**A switching converter needs `voltages`, `sources` and `switching` in the spec (issue #75).** A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence (`evidence add --kind calcs`) carries a switching frequency (`fsw`, `fsw_kHz`, `switching_frequency_Hz`, ...) or a converter `topology`. Fields models the cycle, the hot loop, emissions and the PDN only with the rails, the part feeding each output and the switching node with its frequency: `"voltages": {"VIN": 12, "VOUT": 5}, "sources": {"VOUT": ["L1"], "SW": ["U1"]}, "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}}`. `start`, `evidence add --kind calcs` and `analyze` warn when the block is missing; `tour fields` (the step's proof) and `finish` refuse it, and also refuse a solve whose `fields.json` says it modelled no converter (`review.converter` null) or checked no rail's PDN (`review.pdn.rails` all skipped). The refusal prints the block filled from calcs.json (VIN, VOUT, fsw) and the board (switch, inductor, rails); `riseNs` 8 is adom-fields' default edge time, an estimate to replace from the regulator datasheet. Add the block, solve again, serve, `tour fields`.+ ## Time: sessions, not the wall clock  The AI's time on a run is the sum of its sessions: one per human prompt, from the prompt to the AI's done, with idle gaps (over 15 min, no command running) cut out. The first prompt is `start --prompt-time`; every follow-up prompt is `prompt --text "..."` (`--at <time>` when you start late); the answer's end is `done --message "..."` (`deliver` is the done of the first task). Say `done` the moment an answer is complete, so nothing idle is charged; work after a done with no `prompt` mark is listed as an unmarked follow-up, one row per idle gap. The delivery number on the page is the AI time from the first prompt to `deliver`; follow-ups have their own rows. `sessions` prints the table. The per-step table and the video's run clock ("AI TIME, THIS RUN") count active seconds only.
skills/aiflow-circuit-design/SKILL.md+1
@@ -33,6 +33,7 @@ Rules: - Print results at both ends of each tolerance, not only nominal (`vout_range_vref_tol`). - When you choose a value different from the equation's result, keep both in the JSON (`calc_pF`, `chosen_pF`) and a `note` with the reason. - Mark a target you set yourself as self-set, separate from a brief requirement. A self-set budget missed by a little is a judgement; a requirement missed is a failure.+- For a switching converter, write the headline numbers under plain keys: `topology` ("buck", "boost", ...), `fsw_kHz` (or `fsw` in Hz), `VIN` and `VOUT` (or `vin_nom`, `vout_nom`). adom-aiflow reads them from the calcs evidence to know the design is a converter and to fill the spec's `voltages`/`sources`/`switching` block Adom Fields needs (aiflow-power-layout); a converter's fields step refuses a spec without that block.  ```bash cd design && python3 calcs.py     # prints the table, writes calcs.json
skills/aiflow-power-layout/SKILL.md+11
@@ -21,6 +21,15 @@ Write `aiflow/spec.json` from the schematic before `start`, with a note on every - `decoupling` for each local cap and the supply pin it serves (`{"cap": "C2", "pin": "U1.2", "returnPins": ["U1.1"], "maxLengthMm": 3}`): `route` lays that connection before the rail and `gate` reports its copper path against the budget (adom-aiflow SKILL, Spec intent checked by the binary). - `placement.proximity` / `placement.cluster` for the hot loop: the input cap within a few mm of VIN and GND, the hot-loop parts in one cluster. `place check` and `place verify` fail with the distance and pin named. - Read the `start` WARNING lines and the design-rule card on the dashboard before placement: a 0.025 that should be 0.25 shows there.+- `voltages`, `sources` and `switching` for Adom Fields: the rails, the part that feeds each output and the switching node with its frequency. Without them Fields cannot model the converter: its Cycle, EMC and Power (PDN) views say "not modelled" and the review has no hot-loop, emissions or ripple numbers. Fill them from calcs.json:++  ```json+  "voltages": {"VIN": 12, "VOUT": 5},+  "sources": {"VOUT": ["L1"], "SW": ["U1"]},+  "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}}+  ```++  `highV` is the input rail for a buck (the output for a boost), `freqHz` the switching frequency from calcs.json, `riseNs` the switch's edge time from the regulator datasheet (8 ns is adom-fields' default, an estimate to replace). A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence carries a switching frequency (`fsw`, `fsw_kHz`, ...) or a `topology` (buck, boost, ...). Then `start` and `evidence add --kind calcs` warn when the block is missing, and `tour fields` and `finish` refuse it, printing the block filled from calcs.json and the board.  ## 2. Placement: the hot loop first, chains in one rotation @@ -58,6 +67,8 @@ adom-fields analyze --board <current saved board> --spec aiflow/spec.json --out adom-aiflow --ai-thread "<t>" analyze current --fields <run>/fields/fields.json ``` +On a converter the solve must model it: `fields.json` `review.converter` carries the hot loop, the emissions margin and the ripple. When it is null (no switching node, frequency, inductor, rails or output load) or `review.pdn.rails` checked no rail, `tour fields` and `finish` refuse; fix the spec's `voltages`/`sources`/`switching` (and `loads`) and solve again.+ The first solve showed 215 A/mm2 at the SW pin and U1 at +51 C (Kelvin keepouts had fenced its GND pin off the pour, no thermal vias). Back to pours (`step pours --back --why "..."`): SW patch, solid GND and vias at U1.1, keepouts on the sense layer: 60 A/mm2 at the patch throat. Opened the throat: 50 A/mm2. Each pass: change the spec, `pour`, `land pours`, `measure`, solve, analyze. Draw the final map and register it (`artifact --kind analysis-image`).  ## 6. Thermal: know when the board is area-limited
skills/aiflow-project-page/SKILL.md+1−1
@@ -7,7 +7,7 @@ user-invocable: true  ## Revision 1: `project-page` -`adom-aiflow project-page --page <owner/slug> --create [--brief <text or file>]` makes one Cloud Library per board and keeps its `lib_` id in the run; a rerun reuses it and never makes a second library of the same name (when duplicates already exist it uses the fullest and says which to delete). Rate-limited adds (HTTP 429, `retry_after`) are retried with backoff (`ADOM_AIFLOW_WIKI_ATTEMPTS`, `ADOM_AIFLOW_WIKI_MAX_WAIT`). The step is an ERROR, and nothing is published, when the exported library is short of any BOM part (symbols, footprints and models counted against the distinct parts) or when the packaged symbol library does not load: the header is written as version 20241209 (KiCad 9 format, read by KiCad 10) and the file is parse-checked, with `kicad-cli sym upgrade` through `ADOM_AIFLOW_KICAD_CLI` when available. `--brief` takes text or a path to a file whose contents are used. Parts from the board's project library (`parts --project-lib`) are packaged beside the wiki parts.+`adom-aiflow project-page --page <owner/slug> --create [--brief <text or file>]` makes one Cloud Library per board and keeps its `lib_` id in the run; a rerun reuses it and never makes a second library of the same name (when duplicates already exist it uses the fullest and says which to delete). Rate-limited adds (HTTP 429, `retry_after`) are retried with backoff (`ADOM_AIFLOW_WIKI_ATTEMPTS`, `ADOM_AIFLOW_WIKI_MAX_WAIT`). The step is an ERROR, and nothing is published, when the exported library is short of any BOM part (per part: its symbol is in the library, the symbol's Footprint names a footprint file in it, and that footprint's model file is in it; parts may share a footprint or a model, and a part marked `noModelExpected` needs none; the error names each part by MPN and references) or when the packaged symbol library does not load: the header is written as version 20241209 (KiCad 9 format, read by KiCad 10) and the file is parse-checked, with `kicad-cli sym upgrade` through `ADOM_AIFLOW_KICAD_CLI` when available. `--brief` takes text or a path to a file whose contents are used. Parts from the board's project library (`parts --project-lib`) are packaged beside the wiki parts.  A board project must showcase its board assembly, not one of its component assets. Never rely on recursive repository asset discovery: embedded component GLBs, STEP files and PDFs can silently win. 
tests/project-page-library.py+36−5
@@ -1,6 +1,7 @@ """project-page's Cloud Library and PCM package against a fake adom-wiki (and a fake kicad-cli) on PATH: HTTP 429 retries, one library per board (recorded lib_ id, no duplicates), an incomplete export refused,-the symbol header normalised and parse-checked, project-library parts packaged (issues #64, #66).+the symbol header normalised and parse-checked, project-library parts packaged (issues #64, #66), and+every part resolving through shared footprints and models while a missing footprint or model is refused (#76).    python3 tests/project-page-library.py """@@ -47,10 +48,14 @@ if a[:2] == ["library", "export"]:     syms, files = [], {}     for i, p in enumerate(parts):         mpn = st["pages"][p]+        # parts may share one footprint ("fpOf") and one model ("modelOf"); "dropFootprints"/"dropModels" leave files out+        fp = st.get("fpOf", {}).get(p, mpn); model = st.get("modelOf", {}).get(p, fp)         # one KiCad 9 style symbol (hide yes, unquoted pin_numbers) under the export's old header-        syms.append('  (symbol "%s" (pin_numbers (hide yes)) (property "Reference" "U") (property "Footprint" "%s:%s") (property "Value" "%s" (effects (hide yes))))' % (mpn, name, mpn, mpn))-        files["%s/%s.pretty/%s.kicad_mod" % (name, name, mpn)] = '(footprint "%s" (layer "F.Cu") (model "3dmodels/%s.step"))\n' % (mpn, mpn)-        files["%s/3dmodels/%s.step" % (name, mpn)] = "ISO-10303-21;\nEND-ISO-10303-21;\n"+        syms.append('  (symbol "%s" (pin_numbers (hide yes)) (property "Reference" "U") (property "Footprint" "%s:%s") (property "Value" "%s" (effects (hide yes))))' % (mpn, name, fp, mpn))+        if fp not in st.get("dropFootprints", []):+            files["%s/%s.pretty/%s.kicad_mod" % (name, name, fp)] = '(footprint "%s" (layer "F.Cu") (model "3dmodels/%s.step"))\n' % (fp, model)+        if model not in st.get("dropModels", []):+            files["%s/3dmodels/%s.step" % (name, model)] = "ISO-10303-21;\nEND-ISO-10303-21;\n"     files["%s/%s.kicad_sym" % (name, name)] = "(kicad_symbol_lib (version 20211014) (generator adom_wiki_cloud_library)\n" + "\n".join(syms) + "\n)\n"     with zipfile.ZipFile(dest, "w") as z:         for k, v in files.items(): z.writestr(k, v)@@ -131,7 +136,33 @@ class ProjectPageLibrary(unittest.TestCase):         v = self.run_page()         self.assertFalse(v["ok"])         self.assertIn("AMPLH5030S-150MT", v["error"])-        self.assertIn("2 symbols for 3 distinct parts", v["error"])+        self.assertIn("1 of 3 parts are not in the exported library", v["error"])++    def test_shared_footprint_and_model_pass(self):+        # issue #76: two capacitors on one 0805 footprint, and the inductor's footprint using that same model+        s = self.read_state()+        s["fpOf"] = {"adom/c1-page": "C_0805", "adom/c2-page": "C_0805"}+        s["modelOf"] = {"adom/c1-page": "C_0805", "adom/c2-page": "C_0805", "adom/l1-page": "C_0805"}+        s["pages"] = dict(PAGES, **{"adom/c2-page": "GRM21BR61E106KA73L"}); self.write_state(s)+        v = self.run_page(parts=self.parts(s["pages"]))+        self.assertTrue(v["ok"], v)+        self.assertEqual(v["counts"], {"distinctParts": 4, "symbols": 4, "footprints": 3, "models": 2})++    def test_missing_footprint_names_the_part(self):+        s = self.read_state()+        s["fpOf"] = {"adom/c1-page": "C_0805"}; s["dropFootprints"] = ["C_0805"]; self.write_state(s)+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("CL21A226MPQNNNE (X2): symbol CL21A226MPQNNNE -> footprint C_0805 is not in the library", v["error"])++    def test_missing_model_names_the_part(self):+        s = self.read_state(); s["dropModels"] = ["TPS54202DDCR"]; self.write_state(s)+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("TPS54202DDCR (X0): footprint TPS54202DDCR -> model TPS54202DDCR.step is not in the library", v["error"])+        # a part that says it has no model (a test point, a fiducial) is not refused for it+        parts = self.parts(); parts[0]["noModelExpected"] = True+        self.assertTrue(self.run_page(parts=parts)["ok"])      def test_second_run_reuses_the_recorded_library(self):         self.assertTrue(self.run_page()["ok"])
tools/altium-project.py+3−3
@@ -82,7 +82,7 @@ def cmd_library(a):         step = None         if w.get('footprint'):             r = run(['altium-kicad2altium', w['footprint'], str(spec_fp), key, 'board-only pad'])-            if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (key, (r.stdout + r.stderr)[-300:]))+            if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (key, clip(r.stdout + r.stderr, 300)))             files.append(spec_fp)         else:             pdir = STATE / 'pages' / key@@ -93,12 +93,12 @@ def cmd_library(a):             if not fp: err('%s: the page has no .kicad_mod footprint' % w['mpn'])             desc = (page.get('brief') or comp.get('package') or '')[:120].replace('\n', ' ')             r = run(['altium-kicad2altium', str(fp), str(spec_fp), key, desc])-            if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (w['mpn'], (r.stdout + r.stderr)[-300:]))+            if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (w['mpn'], clip(r.stdout + r.stderr, 300)))             files.append(spec_fp)             use = outline if (outline and len(set(re.findall(r'\(pad "([^"]+)"', fp.read_text(errors='ignore')))) > 2) else sym             if use:                 r = run(['altium-symbol-modes', str(use), str(spec_sym), key, desc])-                if r.returncode != 0 or not spec_sym.is_file(): err('%s: symbol conversion failed: %s' % (w['mpn'], (r.stdout + r.stderr)[-300:]))+                if r.returncode != 0 or not spec_sym.is_file(): err('%s: symbol conversion failed: %s' % (w['mpn'], clip(r.stdout + r.stderr, 300)))                 files.append(spec_sym)             if step: files.append(step)             numbers, notes = lookup_numbers(w['mpn'], comp.get('distributor_links'), cache)
tools/fusion-project.py+13−4
@@ -93,6 +93,15 @@ def err(msg, hints=()):     sys.exit(1)  +def clip(text, n=600):+    """Error text within n characters, keeping its beginning (the cause) and its end (issue #76)."""+    text = text or ""+    if len(text) <= n:+        return text+    head = int(n * 0.7)+    return text[:head] + " ... " + text[len(text) - (n - head - 5):]++ def say(msg):     print(msg, flush=True) @@ -100,7 +109,7 @@ def say(msg): def run(cmd, timeout=600, check=False):     p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)     if check and p.returncode != 0:-        err('%s failed: %s' % (cmd[0], (p.stdout + p.stderr).strip()[-400:]))+        err('%s failed: %s' % (cmd[0], clip((p.stdout + p.stderr).strip(), 400)))     return p  @@ -110,7 +119,7 @@ def ab(verb, args, timeout=600):     a = dict(args); a.setdefault('reason', 'adom-aiflow fusion')     p = run(['adom-bridge', '--target', TARGET, '--ai-thread', THREAD, verb, json.dumps(a)], timeout=timeout)     try: d = json.loads(p.stdout)-    except Exception: return {'success': False, 'error': (p.stdout + p.stderr)[-400:]}+    except Exception: return {'success': False, 'error': clip(p.stdout + p.stderr, 400)}     o = d.get('output')     if isinstance(o, str) and o.startswith('{'):         try: d = json.loads(o)@@ -708,7 +717,7 @@ def cmd_library(a):         out_lbr = lib_dir / 'parts' / (mpn + '.lbr')         if w.get('footprint'):  # a board-only part (test pad) from a footprint file             r = run(['adom-lbr', 'generate', '--fp', w['footprint'], '--name', mpn.upper(), '--package-name', mpn.upper(), '-o', str(out_lbr)])-            if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, (r.stdout + r.stderr)[-300:]))+            if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, clip(r.stdout + r.stderr, 300)))             st['parts'][mpn] = {'lbr': str(out_lbr), 'step': None, 'boardOnly': True, 'numbers': {}, 'lbrVersion': lbr_ver}             added.append(mpn); say('part %s: board only (footprint %s)' % (mpn, Path(w['footprint']).name)); continue         slug = w.get('page') or mpn.lower()@@ -733,7 +742,7 @@ def cmd_library(a):         if use: args[2:2] = ['--sym', str(use)]         if choice in DEVICE_SYMBOLS: args += ['--symbol-layout', 'keep']         r = run(args)-        if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, (r.stdout + r.stderr)[-300:]))+        if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, clip(r.stdout + r.stderr, 300)))         missing = [k for k in ('mouser', 'digikey', 'lcsc') if not numbers.get(k)]         stock = (meta['component'].get('manufacturer') or '').strip().lower() == 'adom'         if missing and not stock: unverified[mpn] = missing + (['(' + '; '.join(sorted(set(notes))) + ')'] if notes else [])
tools/kicad-cli-remote+2−1
@@ -212,7 +212,8 @@ PY tail_of() { python3 -c 'import sys,json try: d=json.load(sys.stdin) except Exception: sys.exit(0)-print(d.get("stdout","")[-800:],d.get("stderr","")[-800:])' >&2 || true; }+c=lambda t,n=800:t if len(t)<=n else t[:560]+" ... "+t[-235:]+print(c(d.get("stdout","")),c(d.get("stderr","")))' >&2 || true; } new_job() { find_cli; find_tmp; JOB="$RTMP/aiflow-$1-$(date +%s)-$$"; } # Bring one report back into a local file: <remote file> <local file>. Returns 1 when it did not come back. fetch_one() {
tools/laser-etch/etch_part_occ.py+9−2
@@ -40,12 +40,19 @@ from OCP.BRepBndLib import BRepBndLib def ext(p):     r = STEPControl_Reader(); r.ReadFile(p); r.TransferRoots(); b = Bnd_Box(); BRepBndLib.Add_s(r.OneShape(), b)     mn, mx = b.CornerMin(), b.CornerMax(); return mx.X() - mn.X(), mx.Y() - mn.Y(), mx.Z() - mn.Z()+def clip(text, n=600):+    """Error text within n characters, keeping its beginning (the cause) and its end (issue #76)."""+    text = text or ""+    if len(text) <= n:+        return text+    head = int(n * 0.7)+    return text[:head] + " ... " + text[len(text) - (n - head - 5):] def run(*args):     r = subprocess.run([PY, *args], capture_output=True, text=True)-    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")+    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {clip(r.stderr, 600)}") def runj(*args):     r = subprocess.run([PY, *args], capture_output=True, text=True)-    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")+    if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {clip(r.stderr, 600)}")     return json.loads([l for l in r.stdout.splitlines() if l.startswith("{")][-1])   # OCCT writers print banners too RGB = {"light": "0.78,0.78,0.76", "dark": "0.012,0.012,0.012"} tmp = tempfile.mkdtemp(prefix="etch-"); cur = a.src; undo = []; info = {}
tools/project-common.py+11−2
@@ -44,6 +44,15 @@ def err(msg, hints=()):     sys.exit(1)  +def clip(text, n=600):+    """Error text within n characters, keeping its beginning (the cause) and its end (issue #76)."""+    text = text or ""+    if len(text) <= n:+        return text+    head = int(n * 0.7)+    return text[:head] + " ... " + text[len(text) - (n - head - 5):]++ def say(msg):     print(msg, flush=True) @@ -51,7 +60,7 @@ def say(msg): def run(cmd, timeout=600, check=False):     p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)     if check and p.returncode != 0:-        err('%s failed: %s' % (cmd[0], (p.stdout + p.stderr).strip()[-400:]))+        err('%s failed: %s' % (cmd[0], clip((p.stdout + p.stderr).strip(), 400)))     return p  @@ -61,7 +70,7 @@ def ab(verb, args, timeout=600):     a = dict(args); a.setdefault('reason', 'adom-aiflow %s' % TOOL)     p = run(['adom-bridge', '--target', TARGET, '--ai-thread', THREAD, verb, json.dumps(a)], timeout=timeout)     try: d = json.loads(p.stdout)-    except Exception: return {'success': False, 'error': (p.stdout + p.stderr)[-400:]}+    except Exception: return {'success': False, 'error': clip(p.stdout + p.stderr, 400)}     o = d.get('output')     if isinstance(o, str) and o.startswith('{'):         try: d = json.loads(o)
tools/project-page.py+90−29
@@ -17,7 +17,8 @@ where PCM puts the files (adom/kicad-pcm documents the layout). Prints one JSON The library is made once per board: its lib_ id is kept in the run (<run>/project-page/cloud-library.json and the config's libRef) and reused, so a second run never makes a second library of the same name. Adds that the wiki rate-limits (HTTP 429, retry_after) are retried with backoff; the export must then hold every part-(symbols, footprints and models counted against the BOM's distinct parts), or the step is an ERROR. Parts+(each distinct part's symbol, the footprint it names and that footprint's model resolve; parts may share a+footprint or a model), or the step is an ERROR. Parts from the board's own project library (row "projectLib", `parts --project-lib`) are packaged beside them. The symbol file gets one current header (SYM_VERSION) and is parse-checked (structurally, and with `kicad-cli sym upgrade` when ADOM_AIFLOW_KICAD_CLI or kicad-cli is there) before anything is published."""@@ -31,6 +32,15 @@ KICAD10_SYM_VERSION = 20251024 ADD_CHUNK = 10  +def clip(text, n=600):+    """Error text within n characters, keeping its beginning (the cause) and its end (issue #76)."""+    text = text or ""+    if len(text) <= n:+        return text+    head = int(n * 0.7)+    return text[:head] + " ... " + text[len(text) - (n - head - 5):]++ def fail(msg, **extra):     raise SystemExit(json.dumps(dict({"ok": False, "error": msg}, **extra))) @@ -38,7 +48,7 @@ def fail(msg, **extra): def sh(args, check=True):     p = subprocess.run(args, capture_output=True, text=True, stdin=subprocess.DEVNULL)     if check and p.returncode != 0:-        raise SystemExit(json.dumps({"ok": False, "error": f"{' '.join(args[:4])}: {(p.stderr or p.stdout).strip()[-600:]}"}))+        raise SystemExit(json.dumps({"ok": False, "error": f"{' '.join(args[:4])}: {clip((p.stderr or p.stdout).strip(), 600)}"}))     return p.stdout  @@ -171,7 +181,7 @@ def cloud_library(name, parts, work, recorded=None):                 ok1, out1, w1 = run_retrying(["adom-wiki", "library", "add", ref, pg, "--json"])                 waits += w1                 if not ok1:-                    failed.append({"page": pg, "error": out1.strip()[-300:]})+                    failed.append({"page": pg, "error": clip(out1.strip(), 300)})     if failed:         fail(f"{len(failed)} of {len(pages)} part pages could not be added to the Cloud Library {ref} " +              f"(after retries{' honouring retry_after' if waits else ''}): " + ", ".join(f["page"] for f in failed),@@ -186,7 +196,7 @@ def cloud_library(name, parts, work, recorded=None):     except Exception:         report = {}     if not ok or not os.path.isfile(bundle):-        fail("library export wrote no bundle: " + out.strip()[-400:], cloudLibrary=ref)+        fail("library export wrote no bundle: " + clip(out.strip(), 400), cloudLibrary=ref)     return ref, bundle, report, {"addedPages": len(pages), "rateLimitWaits": waits, "notes": notes}  @@ -312,10 +322,10 @@ def kicad_check(sym_path):         return "unavailable", f"{kc}: {e}"     text = ((p.stdout or "") + (p.stderr or "")).strip()     if p.returncode == 0 and os.path.isfile(out) and not re.search(r"unable to load|failed to load|error", text, re.I):-        return "pass", text[-200:]+        return "pass", clip(text, 200)     if re.search(r"load|pars|library|symbol", text, re.I):-        return "fail", text[-400:]-    return "unavailable", f"{kc} exit {p.returncode}: {text[-200:]}"+        return "fail", clip(text, 400)+    return "unavailable", f"{kc} exit {p.returncode}: {clip(text, 200)}"   def norm(s):@@ -366,44 +376,95 @@ def merge_project_parts(parts, sym_text, fp_dir, m_dir, lib, pkgid):     return sym_text[:i].rstrip() + "\n  " + "\n  ".join(blocks) + "\n)\n"  +def sym_property(sym_text, syms, name, prop, seen=()):+    """A symbol's property value, following (extends "parent") when the symbol itself has none."""+    a, b = syms[name]+    block = sym_text[a:b]+    m = re.search(r'\(property\s+"' + re.escape(prop) + r'"\s+"([^"]*)"', block)+    if m:+        return m.group(1)+    e = re.search(r'\(extends\s+"([^"]*)"', block)+    if e and e.group(1) in syms and e.group(1) not in seen:+        return sym_property(sym_text, syms, e.group(1), prop, seen + (name,))+    return ""++ def completeness(cfg_parts, report, sym_path, fp_dir, m_dir):-    """Every distinct BOM part in the packaged library: the export's symbol, footprint and model counts-    against the BOM's distinct parts, every symbol's footprint present, every part named. Returns-    (counts, problems)."""+    """Every distinct BOM part resolves in the packaged library (issue #76): its symbol is there, the+    symbol's Footprint property names a footprint file in the export, and that footprint's (model ...)+    names a model file in the export (unless the part says noModelExpected, or the footprint is+    board_only). Parts may share one footprint or one model: file counts are reported, never compared.+    Returns (counts, problems), the problems naming each part by MPN and references."""     wiki = list(dict.fromkeys(p["wiki"] for p in cfg_parts if p.get("wiki")))     proj = [p for p in cfg_parts if p.get("projectLib") and not p.get("wiki")]-    mpn_of = {}-    for p in cfg_parts:-        if p.get("wiki"):-            mpn_of.setdefault(p["wiki"], p["mpn"])+    first = {}+    for p in cfg_parts:  # one entry per distinct part: wiki page, else project-library MPN+        key = p.get("wiki") or (p["mpn"] if p.get("projectLib") else None)+        if not key:+            continue+        e = first.setdefault(key, dict(p, refs=[]))+        e["refs"] = e["refs"] + list(p.get("refs") or [])+        if p.get("noModelExpected"):+            e["noModelExpected"] = True     sym_text = open(sym_path).read() if os.path.isfile(sym_path) else ""     syms = top_symbols(sym_text) if sym_text else {}+    by_norm = {}+    for n in syms:+        by_norm.setdefault(norm(n), n)     fps = {os.path.splitext(f)[0] for f in os.listdir(fp_dir) if f.endswith(".kicad_mod")} if os.path.isdir(fp_dir) else set()     models = [f for f in os.listdir(m_dir) if f.lower().endswith((".step", ".stp", ".wrl"))] if os.path.isdir(m_dir) else []+    model_set = set(models)     distinct = len(wiki) + len({p["mpn"] for p in proj})     rep = payload(report, "report") or report or {}     rep_parts = payload(rep, "parts") or []     skipped = payload(rep, "skipped") or []-    exported = {norm(x.get("mpn")) for x in rep_parts if isinstance(x, dict)} | {norm(x.get("name")) for x in rep_parts if isinstance(x, dict)} | {norm(n) for n in syms}+    # the export report's own name for each MPN (the symbol may be named for the page, not the MPN)+    rep_names = {}+    for x in rep_parts:+        if isinstance(x, dict) and x.get("mpn") and x.get("name"):+            rep_names.setdefault(norm(x["mpn"]), []).append(x["name"])+    fp_models = {}++    def models_of(fp):+        if fp not in fp_models:+            t = open(os.path.join(fp_dir, fp + ".kicad_mod")).read()+            refs = [m.group(1) or m.group(2) for m in re.finditer(r'\(model\s+(?:"([^"]*)"|([^\s()]+))', t)]+            fp_models[fp] = ([os.path.basename(r.replace("\\", "/")) for r in refs], bool(re.search(r"\(attr\b[^)]*\bboard_only\b", t)))+        return fp_models[fp]++    missing, no_fp, no_model = [], [], []+    for key, p in first.items():+        label = p["mpn"] + (f" ({', '.join(p['refs'])})" if p.get("refs") else "")+        if p.get("projectLib") and not p.get("wiki"):+            name = p["projectLib"]["symbol"] if p["projectLib"]["symbol"] in syms else None+        else:+            name = next((by_norm[norm(c)] for c in [p["mpn"]] + rep_names.get(norm(p["mpn"]), []) if norm(c) in by_norm), None)+        if not name:+            missing.append(label)+            continue+        fp = sym_property(sym_text, syms, name, "Footprint").split(":")[-1]+        if not fp or fp not in fps:+            no_fp.append(f"{label}: symbol {name} -> " + (f"footprint {fp} is not in the library" if fp else "no Footprint property"))+            continue+        refs, board_only = models_of(fp)+        if p.get("noModelExpected") or board_only:+            continue+        if not refs:+            no_model.append(f"{label}: footprint {fp} names no 3D model")+        else:+            gone = [r for r in refs if r not in model_set]+            if gone:+                no_model.append(f"{label}: footprint {fp} -> model {', '.join(gone)} is not in the library")     problems = []-    missing = [mpn_of[w] for w in wiki if norm(mpn_of[w]) not in exported]-    missing += [p["mpn"] for p in proj if p["projectLib"]["symbol"] not in syms]     if missing:         problems.append(f"{len(missing)} of {distinct} parts are not in the exported library: " + ", ".join(sorted(set(missing))))     if skipped:         problems.append("the export skipped: " + "; ".join(f"{x.get('ref') or x.get('name') or x} ({x.get('reason', '')})" if isinstance(x, dict) else str(x) for x in skipped))-    counts = {"distinctParts": distinct, "symbols": len(syms), "footprints": len(fps), "models": len(models)}-    for k in ("symbols", "footprints", "models"):-        if counts[k] < distinct:-            problems.append(f"{counts[k]} {k} for {distinct} distinct parts")-    no_fp = []-    for name, (a, b) in syms.items():-        m = re.search(r'\(property\s+"Footprint"\s+"([^"]*)"', sym_text[a:b])-        fp = (m.group(1) if m else "").split(":")[-1]-        if fp and fp not in fps:-            no_fp.append(f"{name} -> {fp}")     if no_fp:-        problems.append("symbols whose footprint is not in the library: " + ", ".join(no_fp[:12]))+        problems.append("parts whose footprint does not resolve: " + "; ".join(no_fp[:12]) + (f" (and {len(no_fp) - 12} more)" if len(no_fp) > 12 else ""))+    if no_model:+        problems.append("parts whose 3D model does not resolve: " + "; ".join(no_model[:12]) + (f" (and {len(no_model) - 12} more)" if len(no_model) > 12 else ""))+    counts = {"distinctParts": distinct, "symbols": len(syms), "footprints": len(fps), "models": len(models)}     return counts, problems  @@ -565,7 +626,7 @@ def main():         m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)         pushed = m.group(1) if m else None         if p.returncode != 0 or not pushed:-            raise SystemExit(json.dumps({"ok": False, "error": "push failed: " + (p.stderr or p.stdout)[-600:]}))+            raise SystemExit(json.dumps({"ok": False, "error": "push failed: " + clip(p.stderr or p.stdout, 600)}))     print(json.dumps({"ok": True, "page": cfg["page"], "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,                       "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]), "counts": counts, "libraryCheck": check,                       "adds": adds, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],
tools/project-steps.py+10−1
@@ -13,6 +13,15 @@ import json, os, re, shutil, subprocess, sys BEGIN, END = "<!-- aiflow:steps -->", "<!-- /aiflow:steps -->"  +def clip(text, n=600):+    """Error text within n characters, keeping its beginning (the cause) and its end (issue #76)."""+    text = text or ""+    if len(text) <= n:+        return text+    head = int(n * 0.7)+    return text[:head] + " ... " + text[len(text) - (n - head - 5):]++ def load(run):     data = json.load(open(os.path.join(run, "run.json")))     events = []@@ -138,7 +147,7 @@ def main():         print(json.dumps({"ok": True, "dry": True, "stage": stage, "steps": want, "files": len(files)})); return     p = subprocess.run(["adom-wiki", "repo", "push", page, "--files", *files, "-m", f"adom-aiflow: step evidence ({', '.join(want)})"], capture_output=True, text=True, cwd=stage, stdin=subprocess.DEVNULL)     m = re.search(r'"commit":\s*"([0-9a-f]{7,})"', p.stdout)-    print(json.dumps({"ok": bool(m), "commit": m.group(1) if m else None, "steps": want, "files": len(files), "error": None if m else (p.stderr or p.stdout)[-400:]}))+    print(json.dumps({"ok": bool(m), "commit": m.group(1) if m else None, "steps": want, "files": len(files), "error": None if m else clip(p.stderr or p.stdout, 400)}))   if __name__ == "__main__":