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
Merge PR #472: AI Flow 0.2.67: rank the signals and the placement priorities, then place and route in that order (aiflow-0.2.67 -> main) merge
Showing changes introduced by the merge (against its first parent).
14 files changed
+593−20
Cargo.lock+10−10Cargo.toml+1−1bin/adom-aiflowcrates/adom-aiflow/src/main.rs+68−4crates/adom-aiflow/src/priorities.rs+357crates/adom-aiflow/src/route_quality.rs+4−1crates/aiflow-router/src/drive.rs+5−1crates/aiflow-router/src/lib.rs+1crates/aiflow-router/src/rules.rs+5−1crates/aiflow-router/src/signal_priority.rs+108docs/release-0.2.67.md+22package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+10Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.66"+version = "0.2.67" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.66"+version = "0.2.67" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.66"+version = "0.2.67" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.66"+version = "0.2.67" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.66"+version = "0.2.67" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.66"+version = "0.2.67" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.66"+version = "0.2.67" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.66"+version = "0.2.67" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.66"+version = "0.2.67" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.66"+version = "0.2.67" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.66"+version = "0.2.67" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+68−4@@ -2,6 +2,7 @@ //! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run. mod continuation; mod supply;+mod priorities; mod copper_carry; mod artifact_store; mod pourverify;@@ -301,7 +302,10 @@ enum Cmd { /// Hands-on AI Flow (docs/hands-on.md): the human chooses, step by step, whether the AI takes care of it (auto), does it and checks in (checkin), leaves it to them (human) or follows their instructions (custom), saved as flow profiles. `plan [--profile P]` shows the choice on the timeline before any design work; `set STEP=MODE ... [--note STEP=TEXT]`, `start [--save-as NAME] [--default]` confirm it from chat; `use <profile>` switches (full-ai is built in); `ask STEP --summary`, `approve|changes|done STEP --quote`, `wait` carry the human's part; `status`, `profiles [--profile P --default] [--delete P]`, `off`. Handson { #[arg(value_parser = ["plan", "set", "start", "use", "ask", "approve", "changes", "done", "wait", "status", "profiles", "off"])] action: String, args: Vec<String>, #[arg(long)] profile: Option<String>, #[arg(long)] save_as: Option<String>, #[arg(long)] default: bool, #[arg(long)] quote: Option<String>, #[arg(long)] summary: Option<String>, #[arg(long)] note: Vec<String>, #[arg(long)] notify: Option<String>, #[arg(long)] screen: Option<String>, #[arg(long, default_value_t = 540)] timeout: u64, #[arg(long)] delete: Option<String> }, /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.- Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },+ Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String>, /// with --back to placement: which ranking item the return is about (thermal, emc, routability, or a signal class), so the ledger shows whether the ranking or the placement was wrong (issue #130)+ #[arg(long)] about: Option<String> },+ /// `priorities`: the board's two rankings (issues #129, #130), spec.signalPriority (signal classes, each with nets and why; routed in that order) and spec.placementPriorities (thermal, EMC, routability with rank and why; `place check` scores in that order). Shows what the spec has, drafts what it lacks from the spec and the net names (a start to reorder and explain, never the answer), and scores the current board's placement. `--write <file>` saves the drafts as JSON to edit into the spec.+ Priorities { #[arg(long)] write: Option<String> }, /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file. Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message) #[arg(long)] accept_suspect: bool },@@ -485,6 +489,11 @@ fn board_step_hints(r: &Run, step: &str) -> Vec<String> { } if matches!(step, "schematic" | "silkscreen" | "finish") { out.extend(led_states::hint(&path, &b)); } if matches!(step, "schematic" | "placement") { out.extend(programming::hint(&b, spec["molecule"].is_object() || b.footprints.values().any(|f| checks::molecule_part(&f.lib).is_some()))); }+ // issues #129, #130: rank first, then place and route in that order+ if step == "placement" {+ let missing: Vec<&str> = [("signalPriority", "the signal classes"), ("placementPriorities", "thermal, EMC and routability")].into_iter().filter(|(k, _)| !spec[*k].is_array()).map(|(_, w)| w).collect();+ if !missing.is_empty() { out.push(format!("Before placing, rank {} for this board: `adom-aiflow priorities` drafts both from the spec and the net names; reorder them, say why, and put them in the spec (`signalPriority`, `placementPriorities`). `place check` then scores the placement in that order and `route` routes the classes in it. A suggestion: the flow goes on without it.", missing.join(" and "))); }+ } out } @@ -4008,6 +4017,42 @@ fn main() { } ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]); }+ Cmd::Priorities { write } => {+ let mut r = load_run(&cli);+ let spec = spec_soft(&r);+ let b = Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));+ let classes = aiflow_router::signal_priority::from_spec(&spec);+ let placed = priorities::placement_from_spec(&spec);+ let mut out = Vec::new();+ let mut drafts = json!({});+ if classes.is_empty() {+ let d = priorities::draft_signal(&spec, &b);+ out.push(format!("signal classes: not ranked. A draft from the spec and the net names (reorder, rename, add each class's why for this board, mark noisy and sensitive classes, add viaBudget or maxLengthMm where it matters):\n{}", serde_json::to_string_pretty(&d).unwrap()));+ drafts["signalPriority"] = d;+ } else {+ out.push(format!("signal classes (spec.signalPriority, routed in this order):\n{}", classes.iter().enumerate().map(|(i, c)| format!(" {}. {}: {} ({}){}", i + 1, c.class, c.nets.join(", "), if c.why.is_empty() { "no why given" } else { c.why.as_str() }, [c.noisy.then_some(" noisy"), c.sensitive.then_some(" sensitive")].into_iter().flatten().collect::<String>())).collect::<Vec<_>>().join("\n")));+ let unmatched: Vec<String> = b.nets.keys().filter(|n| !n.is_empty() && !n.to_ascii_lowercase().starts_with("unconnected-") && !route_quality::power_like(n) && aiflow_router::signal_priority::rank_of(&classes, n).is_none()).cloned().collect();+ if !unmatched.is_empty() { out.push(format!(" signal nets in no class (routed after the classes): {}{}", unmatched.iter().take(20).cloned().collect::<Vec<_>>().join(", "), if unmatched.len() > 20 { ", ..." } else { "" })); }+ }+ if placed.is_empty() {+ let d = priorities::draft_placement(&spec, &b);+ out.push(format!("placement priorities: not ranked. A draft from the spec's numbers (put them in the order this board needs and say why):\n{}", serde_json::to_string_pretty(&d).unwrap()));+ drafts["placementPriorities"] = d;+ }+ let (score, lines) = priorities::placement_score(&spec, &b);+ out.push(format!("the current placement, scored:\n{}", lines.join("\n")));+ r.outcome("priorities", json!({"signalClasses": classes.len(), "placementRanked": !placed.is_empty(), "score": score}));+ r.save().ok();+ let mut hints = Vec::new();+ if let Some(f) = write {+ if drafts.as_object().is_some_and(|o| !o.is_empty()) { write_json(Path::new(f), &drafts); hints.push(format!("Drafts written to {f}: edit them, put them in the spec, then `spec-amend --why \"ranked the signals and placement priorities\"`.")); }+ else { hints.push("Both rankings are already in the spec: nothing to draft.".into()); }+ } else if drafts.as_object().is_some_and(|o| !o.is_empty()) {+ hints.push("Edit the drafts into the spec (`signalPriority`, `placementPriorities`), or `priorities --write priorities-draft.json` to save them; `spec-amend --why` records the change.".into());+ }+ hints.push("Rank by this board: the highest speed, the most sensitive and the highest power first (a USB pair before a 9600-baud UART; two hot chips with one slow link: thermal first). `place check` scores the placement in your order; `route` routes the classes in it; gate and finish report per class.".into());+ ok(&out.join("\n"), &hints);+ } Cmd::PartRequest { mpn, as_mpn, datasheet, manufacturer, refresh, later } => { let t = thread(&cli); let mut r = load_run(&cli);@@ -4419,6 +4464,8 @@ fn main() { for l in &chain_lines { msg.push_str(&format!("\n {l}")); } for (name, _, v) in &verdicts { msg.push_str(&format!("\n chain {name}: {v}")); } }+ // issue #129: pack goes in wish order; say where the signal ranking would put a part earlier+ for n in priorities::wish_order_notes(&spec, &b, &wishes.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>()) { note(&n, &[]); } let chain_hint = if verdicts.iter().any(|v| !v.1) || chain_lines.iter().any(|l| l.contains("not applied")) { "A chain not honoured: give its first member a wish with room along the chain's direction (or set \"direction\" in spec.chains), put the chain early in the wishes, and pack again.".to_string() } else { String::new() }; ok(&msg, &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }, if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.to_string() }, chain_hint]); }@@ -4468,11 +4515,14 @@ fn main() { if !mech.errors().is_empty() { note(&format!("the moved board fails the courtyard and body checks:\n {}", mech.errors().join("\n ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); } if mech.errors().is_empty() { format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked) } else { format!("; {} courtyard/body finding(s) to look at (above)", mech.errors().len()) } } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() };+ // issue #130: the placement scored in the order of spec.placementPriorities (suggestions)+ let (rank_v, rank_lines) = priorities::placement_score(&spec_i, &nb);+ r.outcome("placement-ranking", json!({"board": outp.display().to_string(), "score": rank_v})); let old_board = r.current_board(); if let Some(n) = reviews::void_on_service_change(&mut r, Path::new(&old_board), &outp) { eprintln!("{n}"); } r.add_board(&outp.display().to_string()); r.save().unwrap();- let lines: Vec<String> = lines.into_iter().chain(overhang_lines).chain(outside.iter().map(|(_, w)| format!("FINDING: {w}"))).collect();+ let lines: Vec<String> = lines.into_iter().chain(overhang_lines).chain(outside.iter().map(|(_, w)| format!("FINDING: {w}"))).chain(rank_lines).collect(); let outside_hint = if outside.is_empty() { String::new() } else { aiflow_place::OUTSIDE_HINT.to_string() }; ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line}{} and is now the run's current board", lines.join("\n"), outp.display(), if outside.is_empty() { String::new() } else { format!("; {} part(s) outside the outline (FINDING lines above)", outside.len()) }), &[outside_hint, if overhang.is_empty() { "Land the same moves live with `place land --moves ...`.".into() } else { "Land the same moves live with `place land --moves ...` (the edge overhang parts are sent with allowOutside, the others are not).".into() }]); }@@ -5534,7 +5584,11 @@ fn main() { let (_, gaps, _) = testplan::check_board(&list, &board_now()); if !gaps.is_empty() { missing.push(format!("test plan: no probe landing on {} (`testplan check` says why)", gaps.join(", "))); } }- let (qv, quality_lines) = route_quality::of_board(&board_now(), &spec_soft(&r));+ let (qv, mut quality_lines) = route_quality::of_board(&board_now(), &spec_soft(&r));+ // issues #129, #130: the report card says when the board was not ranked (a suggestion, not an open item)+ let (sig_ranked, place_ranked) = { let sp_ = spec_soft(&r); (!aiflow_router::signal_priority::from_spec(&sp_).is_empty(), !priorities::placement_from_spec(&sp_).is_empty()) };+ if !sig_ranked { quality_lines.push("signal classes: not ranked (spec.signalPriority; `adom-aiflow priorities` drafts one): the router used its own order".into()); }+ if !place_ranked { quality_lines.push("placement priorities: not ranked (spec.placementPriorities)".into()); } // every required step before the finish line, done in order and proven let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap(); // issue #60: declared mechanical and wiring reviews, bound to this board, spec and review file@@ -5606,6 +5660,7 @@ fn main() { let qp = dir.join("finish-route-quality.json"); write_json(&qp, &qv); r.data["summary"]["routeQuality"] = route_quality::outcome(&qv, &qp.display().to_string());+ r.data["summary"]["priorities"] = json!({"signalRanked": sig_ranked, "placementRanked": place_ranked, "classes": qv["classes"]}); if nothing_to_source { r.data["summary"]["notApplicable"].as_array_mut().unwrap().push(json!("sourcing check: not applicable (no footprint in the BOM)")); } r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())})); r.save().unwrap();@@ -6999,7 +7054,7 @@ fn main() { for l in &lines { println!("{l}"); } refresh_dashboard(&dir); }- Cmd::Step { name, back, why } => {+ Cmd::Step { name, back, why, about } => { // never refused: an out-of-order step is noted and recorded (a return when it goes back) let mut back_eff = *back; thread(&cli);@@ -7114,6 +7169,15 @@ fn main() { let vh = json!({"board": vh_b, "spec": vh_s}); if let Some(v) = prev.as_deref().and_then(|p| r.data["steps"][p]["visits"].as_array_mut()).and_then(|a| a.last_mut()) { if v["end"].is_null() { v["endHashes"] = vh.clone(); } } r.step_set(name, back_eff, why.as_deref());+ // issue #130: a return names the ranking item it is about+ if let Some(a) = about.as_deref().filter(|_| back_eff) {+ let sp_ = spec_soft(&r);+ let known = priorities::ITEMS.contains(&a.to_ascii_lowercase().as_str()) || aiflow_router::signal_priority::from_spec(&sp_).iter().any(|c| c.class.eq_ignore_ascii_case(a));+ if !known { note(&format!("--about \"{a}\" is not thermal, emc, routability or a class in spec.signalPriority; recorded as written"), &[]); }+ let ranked_at = priorities::placement_from_spec(&sp_).iter().find(|p| p.0 == a.to_ascii_lowercase()).map(|p| p.1);+ if let Some(v) = r.data["steps"][name.as_str()]["visits"].as_array_mut().and_then(|a| a.last_mut()) { v["about"] = json!(a); v["aboutRank"] = json!(ranked_at); }+ r.log("return-about", json!({"step": name, "about": a, "rank": ranked_at, "why": why}));+ } if let Some(v) = r.data["steps"][name.as_str()]["visits"].as_array_mut().and_then(|a| a.last_mut()) { v["hashes"] = vh; } if !seq.is_empty() { sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE); } // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)
crates/adom-aiflow/src/priorities.rsadded+357@@ -0,0 +1,357 @@+//! Issues #129 and #130: rank first, then place and route in that order. Two rankings in the spec, both the+//! AI's judgement for this board, both suggestions (AI Flow never refuses):+//!+//! - `signalPriority` (#129): ordered signal classes, each with its nets and why (aiflow_router::signal_priority).+//! The router routes the classes in that order; the route-quality report (route, gate, finish) gives a line+//! per class, compared with the class's preferences (`viaBudget`, `maxLengthMm`).+//! - `placementPriorities` (#130): thermal, EMC and routability, each with a rank and why:+//! `[{"item": "thermal", "rank": 1, "why": "U1 dissipates 1.4 W in still air"}, ...]`. `place check`+//! scores the placement in that order and names the worst offender for each.+//!+//! `adom-aiflow priorities` drafts both from what the spec and the board already know (a start for the AI to+//! reorder and explain, never the answer), and scores the current placement.+use aiflow_board::Board;+use aiflow_router::signal_priority::{self as sp, Class};+use serde_json::{json, Value};+use std::collections::BTreeSet;++pub const ITEMS: [&str; 3] = ["thermal", "emc", "routability"];+type Pt = (f64, f64);++fn dist(a: Pt, b: Pt) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }+fn r1(x: f64) -> f64 { (x * 10.0).round() / 10.0 }+fn bare(net: &str) -> String { let t = net.trim_start_matches('/'); t.rsplit('/').next().unwrap_or(t).to_ascii_uppercase() }++/// The draft's buckets, in its suggested order: (class, why, noisy, sensitive, test on the bare upper-case name).+fn buckets() -> Vec<(&'static str, &'static str, bool, bool, fn(&str) -> bool)> {+ vec![+ ("high speed and impedance", "edges and impedance decide whether it works; differential pairs stay coupled", false, false,+ |n| n.contains("USB") || n.starts_with("D+") || n.starts_with("D-") || n == "DP" || n == "DM" || n == "DN" || n.contains("ETH") || n.contains("RGMII") || n.contains("LVDS") || n.contains("MIPI") || n.contains("CAN_H") || n.contains("CAN_L") || n.contains("CANH") || n.contains("CANL") || n.contains("HDMI") || n.contains("PCIE")),+ ("clocks", "fast periodic edges radiate and couple", true, false,+ |n| n.contains("XTAL") || n.contains("XIN") || n.contains("XOUT") || n.starts_with("OSC") || n.contains("MCLK") || n.contains("REFCLK") || (n.contains("CLK") && !n.contains("SWCLK") && !n.contains("SCK"))),+ ("gate drive and PWM", "high di/dt and dv/dt: short loops beside their driver", true, false,+ |n| n.starts_with("GH") || n.starts_with("GL") || n.contains("GATE") || n.ends_with("_HO") || n.ends_with("_LO") || n.contains("PWM") || n.starts_with("INH") || n.starts_with("INL") || n == "BST" || n.contains("BOOT")),+ ("analogue and sense", "small signals: away from the noisy classes, short, over a quiet return", false, true,+ |n| n.contains("SENSE") || n.contains("SNS") || n.contains("BEMF") || n.contains("ADC") || n.contains("AIN") || n.contains("VREF") || n.contains("REF") || n.starts_with("FB") || n.ends_with("_FB") || n.contains("NTC") || n.contains("TEMP") || n.contains("ISET") || n.contains("MIC") || n.contains("AUDIO")),+ ("digital buses", "kHz to tens of MHz: route after the classes above", false, false,+ |n| ["SDA", "SCL", "SCK", "MOSI", "MISO", "SDO", "SDI", "CS", "NSS", "TX", "RX", "SWD", "SWCLK", "SWDIO", "NRST", "RESET", "TMS", "TCK", "TDI", "TDO", "UART", "SPI", "I2C"].iter().any(|k| n.contains(k))),+ ]+}++/// A first draft of `signalPriority` from the spec (switch nodes, loads, impedance targets) and the net names.+pub fn draft_signal(spec: &Value, b: &Board) -> Value {+ let names: Vec<String> = b.nets.keys().filter(|n| !n.is_empty() && !n.to_ascii_lowercase().starts_with("unconnected-")).cloned().collect();+ let mut taken: BTreeSet<String> = BTreeSet::new();+ let mut out = Vec::new();+ // the power path: the switch nodes and the nets the spec says carry load current+ let mut power: Vec<String> = Vec::new();+ for n in spec["switchNodes"].as_array().into_iter().flatten().filter_map(Value::as_str) { power.push(n.to_string()); }+ for k in ["loads", "wideNets"] { for n in spec[k].as_object().into_iter().flat_map(|o| o.keys()) { power.push(n.clone()); } }+ power.retain(|p| names.iter().any(|n| sp::matches(p, n)));+ power.dedup();+ if !power.is_empty() {+ let noisy = spec["switchNodes"].as_array().is_some_and(|a| !a.is_empty());+ out.push(json!({"class": "power path", "nets": power, "why": "carries the load current and the switching loop: sets heat and EMI; keep the loop small and wide", "noisy": noisy}));+ for n in &names { if out[0]["nets"].as_array().unwrap().iter().any(|p| sp::matches(p.as_str().unwrap_or(""), n)) { taken.insert(n.clone()); } }+ }+ // impedance targets from the spec go to the high-speed class first+ let mut imp: Vec<String> = Vec::new();+ for (k, t) in spec["impedanceTargets"].as_object().into_iter().flatten() {+ let members: Vec<String> = t["pair"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_else(|| vec![k.clone()]);+ for m in members { if let Some(n) = names.iter().find(|n| sp::matches(&m, n)) { if taken.insert(n.clone()) { imp.push(n.clone()); } } }+ }+ for (class, why, noisy, sensitive, test) in buckets() {+ let mut nets: Vec<String> = if class.starts_with("high speed") { std::mem::take(&mut imp) } else { Vec::new() };+ for n in &names {+ if taken.contains(n) || crate::route_quality::power_like(n) { continue; }+ if test(&bare(n)) { taken.insert(n.clone()); nets.push(n.clone()); }+ }+ if nets.is_empty() { continue; }+ let mut c = json!({"class": class, "nets": nets, "why": why});+ if noisy { c["noisy"] = json!(true); }+ if sensitive { c["sensitive"] = json!(true); }+ out.push(c);+ }+ json!(out)+}++/// `placementPriorities` from the spec, in rank order: (item, rank, why).+pub fn placement_from_spec(spec: &Value) -> Vec<(String, u64, String)> {+ let mut v: Vec<(String, u64, String)> = spec["placementPriorities"].as_array().into_iter().flatten().enumerate().filter_map(|(i, p)| {+ let item = p["item"].as_str()?.to_ascii_lowercase();+ Some((item, p["rank"].as_u64().unwrap_or(i as u64 + 1), p["why"].as_str().unwrap_or("").to_string()))+ }).collect();+ v.sort_by_key(|x| x.1);+ v+}++/// The hot parts on this board: (reference, watts), largest first.+fn hot_parts(spec: &Value, b: &Board) -> Vec<(String, f64)> {+ let mut h: Vec<(String, f64)> = spec["hot"].as_object().into_iter().flatten()+ .filter(|(r, _)| b.footprints.contains_key(*r)).map(|(r, v)| (r.clone(), v["watts"].as_f64().unwrap_or(0.0))).collect();+ h.sort_by(|a, b| b.1.total_cmp(&a.1));+ h+}++/// A first draft of `placementPriorities` from the spec's numbers.+pub fn draft_placement(spec: &Value, b: &Board) -> Value {+ let hot = hot_parts(spec, b);+ let watts: f64 = hot.iter().map(|h| h.1).sum();+ let sw: Vec<&str> = spec["switchNodes"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();+ let top = sp::from_spec(spec).first().map(|c| c.class.clone());+ let mut items: Vec<(f64, &str, String)> = vec![+ (if watts >= 1.0 || hot.first().is_some_and(|h| h.1 >= 0.5) { 3.0 } else if watts > 0.0 { 1.0 } else { 0.0 }, "thermal",+ if hot.is_empty() { "no hot parts in spec.hot".into() } else { format!("{:.2} W in {} hot part(s), the largest {} at {:.2} W", watts, hot.len(), hot[0].0, hot[0].1) }),+ (if sw.is_empty() { 0.5 } else { 2.5 }, "emc",+ if sw.is_empty() { "no switch nodes in the spec: loop areas of the noisy classes, if any".into() } else { format!("switch node(s) {}: keep the hot loop small and away from the sensitive class", sw.join(", ")) }),+ (2.0, "routability", match top { Some(c) => format!("the top signal class ({c}) wants short, direct runs"), None => "short, direct runs for the top signal class (rank the signals first)".into() }),+ ];+ items.sort_by(|a, b| b.0.total_cmp(&a.0));+ json!(items.iter().enumerate().map(|(i, (_, item, why))| json!({"item": item, "rank": i + 1, "why": why})).collect::<Vec<_>>())+}++fn pads_of_net(b: &Board, net: &str) -> Vec<Pt> {+ b.nets.get(net).map(|ix| ix.iter().map(|&i| (b.pads[i].x, b.pads[i].y)).collect()).unwrap_or_default()+}++fn nets_of(classes: &[Class], i: usize, b: &Board) -> Vec<String> {+ b.nets.keys().filter(|n| sp::rank_of(classes, n) == Some(i)).cloned().collect()+}++/// The area of the convex hull of the points (mm2): the loop-area proxy of a class's pads.+pub fn hull_area(pts: &[Pt]) -> f64 {+ let mut p: Vec<Pt> = pts.to_vec();+ p.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));+ p.dedup_by(|a, b| dist(*a, *b) < 1e-6);+ if p.len() < 3 { return 0.0; }+ let cross = |o: Pt, a: Pt, b: Pt| (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0);+ let mut h: Vec<Pt> = Vec::new();+ for pass in 0..2 {+ let start = h.len();+ let it: Box<dyn Iterator<Item = &Pt>> = if pass == 0 { Box::new(p.iter()) } else { Box::new(p.iter().rev()) };+ for &q in it {+ while h.len() >= start + 2 && cross(h[h.len() - 2], h[h.len() - 1], q) <= 0.0 { h.pop(); }+ h.push(q);+ }+ h.pop();+ }+ (0..h.len()).map(|i| { let (a, b) = (h[i], h[(i + 1) % h.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>().abs() / 2.0+}++fn crosses(a: (Pt, Pt), b: (Pt, Pt)) -> bool {+ let o = |p: Pt, q: Pt, r: Pt| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+ let (d1, d2, d3, d4) = (o(b.0, b.1, a.0), o(b.0, b.1, a.1), o(a.0, a.1, b.0), o(a.0, a.1, b.1));+ d1 * d2 < -1e-9 && d3 * d4 < -1e-9+}++fn mst_edges(pts: &[Pt]) -> Vec<(Pt, Pt)> {+ crate::route_quality::mst(pts).1.into_iter().map(|(i, j, _)| (pts[i], pts[j])).collect()+}++/// `place check`'s score of a placement, in the order of `placementPriorities` (or thermal, EMC, routability+/// when the spec has none). Each item names its worst offender. Suggestions only.+pub fn placement_score(spec: &Value, b: &Board) -> (Value, Vec<String>) {+ let ranked = placement_from_spec(spec);+ let order: Vec<(String, Option<u64>, String)> = if ranked.is_empty() {+ ITEMS.iter().map(|i| (i.to_string(), None, String::new())).collect()+ } else {+ let mut o: Vec<(String, Option<u64>, String)> = ranked.iter().map(|(i, r, w)| (i.clone(), Some(*r), w.clone())).collect();+ for i in ITEMS { if !o.iter().any(|x| x.0 == i) { o.push((i.to_string(), None, String::new())); } }+ o+ };+ let classes = sp::from_spec(spec);+ let mut items = Vec::new();+ let mut lines = vec![if ranked.is_empty() {+ "placement ranking: not ranked (spec.placementPriorities; `adom-aiflow priorities` drafts one); scored in the default order".to_string()+ } else {+ format!("placement ranking (spec.placementPriorities): {}", ranked.iter().map(|(i, r, _)| format!("{r}. {i}")).collect::<Vec<_>>().join(", "))+ }];+ for (item, rank, why) in order {+ let label = format!("{item}{}", rank.map(|r| format!(" (rank {r})")).unwrap_or_default());+ let (v, line) = match item.as_str() {+ "thermal" => {+ let hot = hot_parts(spec, b);+ let at = |r: &str| b.footprints.get(r).map(|f| (f.x, f.y)).unwrap_or((0.0, 0.0));+ let mut pairs: Vec<(String, String, f64)> = Vec::new();+ for i in 0..hot.len() { for j in i + 1..hot.len() { pairs.push((hot[i].0.clone(), hot[j].0.clone(), dist(at(&hot[i].0), at(&hot[j].0)))); } }+ pairs.sort_by(|a, b| a.2.total_cmp(&b.2));+ let line = match (hot.len(), pairs.first()) {+ (0, _) => "no hot parts in spec.hot: nothing to spread".to_string(),+ (_, None) => format!("one hot part, {} at {:.2} W: give it copper and vias, keep other parts out of its heat path", hot[0].0, hot[0].1),+ (_, Some(p)) => format!("{} hot parts ({}); closest pair {} and {} {:.1} mm apart (worst): spread them so their heat does not add", hot.len(), hot.iter().map(|h| format!("{} {:.2} W", h.0, h.1)).collect::<Vec<_>>().join(", "), p.0, p.1, p.2),+ };+ (json!({"hot": hot.iter().map(|h| json!({"ref": h.0, "watts": h.1})).collect::<Vec<_>>(), "closest": pairs.first().map(|p| json!({"a": p.0, "b": p.1, "mm": r1(p.2)}))}), line)+ }+ "emc" => {+ let sw: Vec<String> = spec["switchNodes"].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect();+ let mut noisy: Vec<(String, Vec<Pt>)> = classes.iter().enumerate().filter(|(_, c)| c.noisy).map(|(i, c)| (c.class.clone(), nets_of(&classes, i, b).iter().flat_map(|n| pads_of_net(b, n)).collect())).collect();+ if !sw.is_empty() {+ let pts: Vec<Pt> = b.nets.keys().filter(|n| sw.iter().any(|s| sp::matches(s, n))).flat_map(|n| pads_of_net(b, n)).collect();+ if !pts.is_empty() { noisy.push(("switch nodes".into(), pts)); }+ }+ let sensitive: Vec<(String, Vec<Pt>)> = classes.iter().enumerate().filter(|(_, c)| c.sensitive).map(|(i, c)| (c.class.clone(), nets_of(&classes, i, b).iter().flat_map(|n| pads_of_net(b, n)).collect())).collect();+ let areas: Vec<(String, f64)> = noisy.iter().map(|(c, p)| (c.clone(), hull_area(p))).collect();+ let mut near: Option<(String, String, f64)> = None;+ for (sc, sp_) in &sensitive { for (nc, np) in &noisy { for a in sp_ { for q in np {+ let d = dist(*a, *q);+ if near.as_ref().map_or(true, |x| d < x.2) { near = Some((sc.clone(), nc.clone(), d)); }+ } } } }+ let line = if noisy.is_empty() && sensitive.is_empty() {+ "no class marked noisy or sensitive and no spec.switchNodes: nothing to measure (mark them in signalPriority)".to_string()+ } else {+ let worst = areas.iter().max_by(|a, b| a.1.total_cmp(&b.1));+ let mut s = areas.iter().map(|(c, a)| format!("{c} loop {a:.0} mm2")).collect::<Vec<_>>().join(", ");+ if let Some(w) = worst { s.push_str(&format!(" (largest: {})", w.0)); }+ if let Some(n) = &near { s.push_str(&format!("; {} is {:.1} mm from {} (closest)", n.0, n.2, n.1)); } else if !sensitive.is_empty() { s.push_str("; no noisy class to measure the sensitive one against"); }+ s+ };+ (json!({"loops": areas.iter().map(|(c, a)| json!({"class": c, "hullMm2": r1(*a)})).collect::<Vec<_>>(), "closestSensitiveToNoisy": near.map(|n| json!({"sensitive": n.0, "noisy": n.1, "mm": r1(n.2)}))}), line)+ }+ _ => {+ if classes.is_empty() {+ (json!({"classes": []}), "the signals are not ranked (spec.signalPriority), so there is no top class to measure".to_string())+ } else {+ let all_edges: Vec<(String, (Pt, Pt))> = b.nets.keys().filter(|n| !n.is_empty()).flat_map(|n| mst_edges(&pads_of_net(b, n)).into_iter().map(move |e| (n.clone(), e))).collect();+ let mut per = Vec::new();+ let mut parts = Vec::new();+ for (i, c) in classes.iter().enumerate().take(3) {+ let nets = nets_of(&classes, i, b);+ let lens: Vec<(String, f64)> = nets.iter().map(|n| (n.clone(), crate::route_quality::mst(&pads_of_net(b, n)).0)).collect();+ let total: f64 = lens.iter().map(|l| l.1).sum();+ let worst = lens.iter().max_by(|a, b| a.1.total_cmp(&b.1)).cloned();+ let mine: Vec<&(String, (Pt, Pt))> = all_edges.iter().filter(|e| nets.contains(&e.0)).collect();+ let crossings = mine.iter().map(|e| all_edges.iter().filter(|o| o.0 != e.0 && crosses(e.1, o.1)).count()).sum::<usize>();+ per.push(json!({"class": c.class, "rank": i + 1, "nets": nets.len(), "ratsnestMm": r1(total), "worst": worst.as_ref().map(|w| json!({"net": w.0, "mm": r1(w.1)})), "crossings": crossings}));+ parts.push(if nets.is_empty() { format!("{}: no nets on this board match", c.class) } else {+ format!("{}: {} net(s), ratsnest {:.0} mm, worst {} {:.1} mm, {crossings} crossing(s)", c.class, nets.len(), total, worst.as_ref().map(|w| w.0.as_str()).unwrap_or("-"), worst.as_ref().map(|w| w.1).unwrap_or(0.0))+ });+ }+ (json!({"classes": per}), parts.join("; "))+ }+ }+ };+ lines.push(format!(" {label}: {line}{}", if why.is_empty() { String::new() } else { format!(" [why first: {why}]") }));+ items.push(json!({"item": item, "rank": rank, "why": why, "score": v}));+ }+ (json!({"ranked": !ranked.is_empty(), "items": items, "blocking": false}), lines)+}++/// The route-quality report's lines per signal class (#129), with the class's preferences compared.+pub fn class_report(spec: &Value, quality: &Value) -> Option<(Value, Vec<String>)> {+ let classes = sp::from_spec(spec);+ if classes.is_empty() { return None; }+ let nets: Vec<&Value> = quality["nets"].as_array().map(|a| a.iter().collect()).unwrap_or_default();+ let mut out = Vec::new();+ let mut lines = vec!["route quality by signal class (spec.signalPriority, routed in this order; preferences, not gates):".to_string()];+ for (i, c) in classes.iter().enumerate() {+ let mine: Vec<&&Value> = nets.iter().filter(|n| n["net"].as_str().is_some_and(|x| sp::rank_of(&classes, x) == Some(i))).collect();+ let len = |n: &Value| n["lengthMm"].as_f64().unwrap_or(0.0);+ let vias = |n: &Value| n["vias"].as_u64().unwrap_or(0) as usize;+ let worst = mine.iter().max_by(|a, b| len(a).total_cmp(&len(b)));+ let total_vias: usize = mine.iter().map(|n| vias(n)).sum();+ let over_len: Vec<String> = c.max_length_mm.map(|m| mine.iter().filter(|n| len(n) > m).filter_map(|n| n["net"].as_str().map(str::to_string)).collect()).unwrap_or_default();+ let over_vias: Vec<String> = c.via_budget.map(|b| mine.iter().filter(|n| vias(n) > b).filter_map(|n| n["net"].as_str().map(str::to_string)).collect()).unwrap_or_default();+ let mut l = format!(" {}. {}: {} routed net(s)", i + 1, c.class, mine.len());+ if let Some(w) = worst { l.push_str(&format!(", worst {} {:.1} mm", w["net"].as_str().unwrap_or("?"), len(w))); }+ l.push_str(&format!(", {total_vias} via(s){}", c.via_budget.map(|b| format!(" (preference {b} per net)")).unwrap_or_default()));+ if !over_len.is_empty() { l.push_str(&format!("; over {} mm: {}", c.max_length_mm.unwrap_or(0.0), over_len.join(", "))); }+ if !over_vias.is_empty() { l.push_str(&format!("; over the via preference: {}", over_vias.join(", "))); }+ lines.push(l);+ out.push(json!({"class": c.class, "rank": i + 1, "nets": mine.len(), "worst": worst.map(|w| json!({"net": w["net"], "mm": w["lengthMm"]})), "vias": total_vias,+ "viaBudget": c.via_budget, "maxLengthMm": c.max_length_mm, "overLength": over_len, "overVias": over_vias}));+ }+ Some((json!(out), lines))+}++/// `place pack` packs in wish order: say where a part of a higher-ranked class comes after a lower one.+pub fn wish_order_notes(spec: &Value, b: &Board, refs: &[String]) -> Vec<String> {+ let classes = sp::from_spec(spec);+ if classes.is_empty() { return Vec::new(); }+ let rank = |r: &str| b.pads_of(r).iter().filter_map(|p| sp::rank_of(&classes, &p.net)).min();+ let ranked: Vec<(String, usize)> = refs.iter().filter_map(|r| rank(r).map(|k| (r.clone(), k))).collect();+ let mut notes = Vec::new();+ for (i, (r, k)) in ranked.iter().enumerate() {+ if let Some((w, wk)) = ranked[..i].iter().find(|(_, wk)| wk > k) {+ notes.push(format!("{r} ({}, rank {}) comes after {w} ({}, rank {})", classes[*k].class, k + 1, classes[*wk].class, wk + 1));+ }+ if notes.len() == 3 { break; }+ }+ if notes.is_empty() { return notes; }+ vec![format!("pack places parts in wish order, and the signal ranking puts some later than lower-ranked ones: {}. Put the higher-ranked parts first so they get their space (or keep the order on purpose).", notes.join("; "))]+}++#[cfg(test)]+mod tests {+ use super::*;++ const BOARD: &str = r#"(kicad_pcb (version 20240108) (generator "t")+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))+ (net 0 "") (net 1 "GND") (net 2 "SW") (net 3 "GHA") (net 4 "BEMF_A") (net 5 "SDA") (net 6 "USB_DP")+ (gr_rect (start 0 0) (end 40 30) (layer "Edge.Cuts") (stroke (width 0.1) (type default)))+ (footprint "R" (layer "F.Cu") (at 5 5) (property "Reference" "U1" (at 0 0 0) (layer "F.SilkS"))+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 2 "SW")) (pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net 3 "GHA")))+ (footprint "R" (layer "F.Cu") (at 8 5) (property "Reference" "U2" (at 0 0 0) (layer "F.SilkS"))+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 2 "SW")) (pad "2" smd rect (at 0 4) (size 1 1) (layers "F.Cu") (net 1 "GND")))+ (footprint "R" (layer "F.Cu") (at 30 20) (property "Reference" "U3" (at 0 0 0) (layer "F.SilkS"))+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 4 "BEMF_A")) (pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net 5 "SDA"))+ (pad "3" smd rect (at 4 0) (size 1 1) (layers "F.Cu") (net 6 "USB_DP")) (pad "4" smd rect (at 6 0) (size 1 1) (layers "F.Cu") (net 3 "GHA")))+ (footprint "R" (layer "F.Cu") (at 30 5) (property "Reference" "R9" (at 0 0 0) (layer "F.SilkS"))+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 5 "SDA")) (pad "2" smd rect (at 0 4) (size 1 1) (layers "F.Cu") (net 4 "BEMF_A")))+)"#;++ fn board() -> Board { Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap() }++ #[test]+ fn the_draft_starts_from_the_spec_and_the_net_names() {+ let spec = json!({"switchNodes": ["SW"], "hot": {"U1": {"watts": 1.2}, "U2": {"watts": 0.3}}, "impedanceTargets": {"USB": {"pair": ["USB_DP", "USB_DN"]}}});+ let d = draft_signal(&spec, &board());+ let classes: Vec<&str> = d.as_array().unwrap().iter().map(|c| c["class"].as_str().unwrap()).collect();+ assert_eq!(classes, vec!["power path", "high speed and impedance", "gate drive and PWM", "analogue and sense", "digital buses"]);+ assert_eq!(d[0]["noisy"], true);+ assert_eq!(d[1]["nets"], json!(["USB_DP"]));+ assert_eq!(d[3]["sensitive"], true);+ let p = draft_placement(&spec, &board());+ assert_eq!(p[0]["item"], "thermal", "1.5 W of hot parts comes first: {p}");+ assert_eq!(p[1]["item"], "emc");+ }++ #[test]+ fn the_score_follows_the_ranking_and_names_the_worst() {+ let spec = json!({"hot": {"U1": {"watts": 1.2}, "U2": {"watts": 0.8}}, "switchNodes": ["SW"],+ "placementPriorities": [{"item": "routability", "rank": 1, "why": "only slow signals"}, {"item": "thermal", "rank": 2}],+ "signalPriority": [{"class": "gate drive", "nets": ["GH*"], "noisy": true}, {"class": "sense", "nets": ["BEMF_*"], "sensitive": true}]});+ let (v, lines) = placement_score(&spec, &board());+ let order: Vec<&str> = v["items"].as_array().unwrap().iter().map(|i| i["item"].as_str().unwrap()).collect();+ assert_eq!(order, vec!["routability", "thermal", "emc"], "ranked first, the unranked after");+ let all = lines.join("\n");+ assert!(all.contains("closest pair U1 and U2 3.0 mm apart"), "{all}");+ assert!(all.contains("gate drive: 1 net(s), ratsnest 33 mm, worst GHA 32.6 mm") && all.contains("[why first: only slow signals]"), "{all}");+ assert!(all.contains("sense is"), "the sensitive class is measured against the noisy ones: {all}");+ let (_, unranked) = placement_score(&json!({}), &board());+ assert!(unranked[0].contains("not ranked"));+ }++ #[test]+ fn the_class_report_compares_with_the_preferences() {+ let spec = json!({"signalPriority": [{"class": "gate drive", "nets": ["GH*"], "viaBudget": 0, "maxLengthMm": 20}]});+ let q = json!({"nets": [{"net": "GHA", "lengthMm": 47.0, "vias": 2}, {"net": "GHB", "lengthMm": 12.0, "vias": 0}, {"net": "SDA", "lengthMm": 90.0, "vias": 4}]});+ let (v, lines) = class_report(&spec, &q).unwrap();+ assert_eq!(v[0]["nets"], 2);+ assert_eq!(lines[1], " 1. gate drive: 2 routed net(s), worst GHA 47.0 mm, 2 via(s) (preference 0 per net); over 20 mm: GHA; over the via preference: GHA");+ assert!(class_report(&json!({}), &q).is_none());+ }++ #[test]+ fn a_high_ranked_part_late_in_the_wishes_is_named() {+ let spec = json!({"signalPriority": [{"class": "gate drive", "nets": ["GH*"]}, {"class": "bus", "nets": ["SDA"]}]});+ let n = wish_order_notes(&spec, &board(), &["R9".into(), "U1".into()]);+ assert!(n[0].contains("U1 (gate drive, rank 1) comes after R9 (bus, rank 2)"), "{n:?}");+ assert!(wish_order_notes(&spec, &board(), &["U1".into(), "R9".into()]).is_empty());+ }++ #[test]+ fn hull_area_of_a_square() {+ assert!((hull_area(&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (1.0, 1.0)]) - 4.0).abs() < 1e-9);+ assert_eq!(hull_area(&[(0.0, 0.0), (1.0, 1.0)]), 0.0);+ }+}
crates/adom-aiflow/src/route_quality.rs+4−1@@ -247,7 +247,10 @@ pub fn of_board(b: &Board, spec: &Value) -> (Value, Vec<String>) { } let power = spec_power(spec); let signal = |n: &str| !n.is_empty() && !power.contains(n) && !power_like(n) && !n.to_ascii_lowercase().starts_with("unconnected-");- report_of(&analyze(&segs, &vias, &pads, &part_pads, &signal))+ let (mut v, mut lines) = report_of(&analyze(&segs, &vias, &pads, &part_pads, &signal));+ // issue #129: a line per signal class, when the spec ranks them+ if let Some((cv, cl)) = crate::priorities::class_report(spec, &v) { v["classes"] = cv; lines.extend(cl); }+ (v, lines) } /// The report for a plan applied to a board (what `route` planned), or None when it does not apply.
crates/aiflow-router/src/drive.rs+5−1@@ -944,12 +944,16 @@ impl Router { signal.sort_by(|a, b| self.nets[*a].mst_len.partial_cmp(&self.nets[*b].mst_len).unwrap()); // `route --variants`: another order of the same nets (the default keeps shortest first untouched) crate::variants::reorder(&mut signal, &self.nets, self.order);+ // issue #129: the AI's ranking goes first, class by class; within a class (and for the nets no class+ // names, which come last) the order above stands (the sort is stable)+ let ranked = !self.rules.signal_priority.is_empty();+ crate::signal_priority::sort_by_rank(&mut signal, &self.rules.signal_priority, |&n| self.nets[n].name.clone()); let mut first: Vec<usize> = signal.iter().copied().filter(|n| priority.contains(&self.nets[*n].name)).collect(); let mut rest: Vec<usize> = signal.iter().copied().filter(|n| !priority.contains(&self.nets[*n].name)).collect(); let mut queue: Vec<usize> = Vec::new(); queue.append(&mut first); queue.append(&mut rest);- self.log(format!("routing {} signal nets, {}{}", queue.len(), self.order.describe(), if priority.is_empty() { String::new() } else { format!(", {} nets that failed last pass go first: {:?}", priority.len(), priority) }));+ self.log(format!("routing {} signal nets, {}{}{}", queue.len(), if ranked { "by the spec's signalPriority classes, then " } else { "" }, self.order.describe(), if priority.is_empty() { String::new() } else { format!(", {} nets that failed last pass go first: {:?}", priority.len(), priority) })); let mut order_no = 0; let mut best_state: Option<(usize, Snapshot, Vec<usize>)> = None; while !queue.is_empty() {
crates/aiflow-router/src/lib.rs+1@@ -19,6 +19,7 @@ pub mod board_copper; pub mod copper_path; pub mod decoupling;+pub mod signal_priority; pub mod drive; pub mod impedance; pub mod plane_under;
crates/aiflow-router/src/rules.rs+5−1@@ -28,6 +28,9 @@ pub struct Rules { pub hole_to_hole: f64, pub via_search_mm: f64, pub kelvin: BTreeMap<String, String>,+ /// spec.signalPriority (issue #129): the AI's ranking of the signal classes, routed in this order+ #[serde(default)]+ pub signal_priority: Vec<crate::signal_priority::Class>, /// Layer policy (spec "planeUnder"): regions of a layer that belong to one net's plane; every /// other net treats them as a keepout on that layer (vias included, they pierce both layers). pub plane_under: Vec<PlaneUnder>,@@ -79,7 +82,7 @@ impl Default for Rules { Rules { track: 0.25, clearance: 0.2, net_clearance: Default::default(), via: 0.8, drill: 0.4, via_small: 0.6, drill_small: 0.3, edge: 0.5, stub: 0.25, narrow: 0.5, escape: 0.45, corridor: 0.5, ic_zone: 2.0, channel: 2.5,- planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), plane_under: Vec::new(), decoupling: Vec::new(), impedance: BTreeMap::new(),+ planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), signal_priority: Vec::new(), plane_under: Vec::new(), decoupling: Vec::new(), impedance: BTreeMap::new(), route_layers: Vec::new(), vias: true, } }@@ -104,6 +107,7 @@ impl Rules { r.wide = get_widths("wideNets"); r.mid = get_widths("midNets"); r.plane_under = PlaneUnder::from_spec(spec);+ r.signal_priority = crate::signal_priority::from_spec(spec); r.decoupling = crate::decoupling::Pair::from_spec(spec).unwrap_or_default(); let num = |k: &str| spec.get(k).and_then(|v| v.as_f64()); if let Some(v) = num("clearance") { r.clearance = v; }
crates/aiflow-router/src/signal_priority.rsadded+108@@ -0,0 +1,108 @@+//! Issue #129: the AI's ranking of this board's signals, `spec.signalPriority`, an ordered list of classes:+//!+//! ```json+//! [{"class": "power loop", "nets": ["VBAT", "PH*", "GND"], "why": "20 A, sets heat and EMI"},+//! {"class": "gate drive", "nets": ["GH*", "GL*"], "why": "highest di/dt", "viaBudget": 0, "maxLengthMm": 25},+//! {"class": "BEMF sense", "nets": ["BEMF_*"], "why": "analogue zero-crossing", "sensitive": true}]+//! ```+//!+//! First is most important. `nets` are names or patterns (`*` any run, `?` one character; case is ignored,+//! and a hierarchical `/GND` matches `GND`). The router routes the classes in this order; `place check` and+//! the route-quality report measure each class against its preferences. Preferences are compared, never+//! enforced: AI Flow suggests, it does not refuse.+use serde_json::Value;++#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]+#[serde(default)]+pub struct Class {+ pub class: String,+ pub nets: Vec<String>,+ pub why: String,+ /// vias the class should need at most per net (a preference the report compares against)+ pub via_budget: Option<usize>,+ /// a routed net of the class longer than this is reported+ pub max_length_mm: Option<f64>,+ /// a class that suffers from noise (analogue sense, a receiver input): `place check` measures its distance to noisy nets+ pub sensitive: bool,+ /// a class that makes noise (a switch node, gate drive): the other side of that distance+ pub noisy: bool,+}++/// The ranking in the spec, first is most important. A class without a name or nets is left out.+pub fn from_spec(spec: &Value) -> Vec<Class> {+ spec["signalPriority"].as_array().into_iter().flatten().filter_map(|c| {+ let nets: Vec<String> = match &c["nets"] {+ Value::Array(a) => a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect(),+ Value::String(s) => vec![s.clone()],+ _ => Vec::new(),+ };+ let class = c["class"].as_str().or(c["name"].as_str())?.to_string();+ (!nets.is_empty()).then(|| Class {+ class, nets, why: c["why"].as_str().unwrap_or("").to_string(),+ via_budget: c["viaBudget"].as_u64().map(|v| v as usize),+ max_length_mm: c["maxLengthMm"].as_f64(),+ sensitive: c["sensitive"].as_bool().unwrap_or(false),+ noisy: c["noisy"].as_bool().unwrap_or(false),+ })+ }).collect()+}++fn glob_ci(pat: &[char], s: &[char]) -> bool {+ match (pat.first(), s.first()) {+ (None, None) => true,+ (Some('*'), _) => glob_ci(&pat[1..], s) || (!s.is_empty() && glob_ci(pat, &s[1..])),+ (Some('?'), Some(_)) => glob_ci(&pat[1..], &s[1..]),+ (Some(p), Some(c)) if p.to_lowercase().eq(c.to_lowercase()) => glob_ci(&pat[1..], &s[1..]),+ _ => false,+ }+}++/// Does a net name match a pattern: the whole name, the name without a leading `/`, or its last path part.+pub fn matches(pattern: &str, net: &str) -> bool {+ let p: Vec<char> = pattern.trim().trim_start_matches('/').chars().collect();+ let trimmed = net.trim_start_matches('/');+ [trimmed, trimmed.rsplit('/').next().unwrap_or(trimmed)].iter().any(|n| glob_ci(&p, &n.chars().collect::<Vec<_>>()))+}++/// The rank (0 = most important) of the first class whose patterns match the net.+pub fn rank_of(classes: &[Class], net: &str) -> Option<usize> {+ classes.iter().position(|c| c.nets.iter().any(|p| matches(p, net)))+}++/// Put the items in class order, most important class first, the unranked last; a stable sort, so the+/// order within a class (the router's own, or a variant's) stands.+pub fn sort_by_rank<T>(items: &mut [T], classes: &[Class], name: impl Fn(&T) -> String) {+ if classes.is_empty() { return; }+ items.sort_by_key(|i| rank_of(classes, &name(i)).unwrap_or(usize::MAX));+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ #[test]+ fn classes_read_in_order_and_patterns_match_like_a_designer_writes_them() {+ let spec = json!({"signalPriority": [+ {"class": "gate drive", "nets": ["GH*", "GL?"], "why": "di/dt", "viaBudget": 0, "noisy": true},+ {"class": "sense", "nets": "BEMF_*", "sensitive": true},+ {"class": "empty", "nets": []},+ {"nets": ["X"]}]});+ let c = from_spec(&spec);+ assert_eq!(c.len(), 2);+ assert_eq!((c[0].via_budget, c[0].noisy, c[1].sensitive), (Some(0), true, true));+ assert_eq!(rank_of(&c, "/GHA"), Some(0));+ assert_eq!(rank_of(&c, "/drive/GLB"), Some(0), "the last part of a hierarchical name");+ assert_eq!(rank_of(&c, "gla"), Some(0), "case is ignored");+ assert_eq!(rank_of(&c, "GLAB"), None, "? is one character");+ assert_eq!(rank_of(&c, "BEMF_A"), Some(1));+ assert_eq!(rank_of(&c, "SWD"), None);+ // the router's order (shortest first, say) stands inside a class; the ranking decides between classes+ let mut nets = vec!["SWD", "BEMF_A", "GHB", "TLM", "GHA", "BEMF_B"];+ sort_by_rank(&mut nets, &c, |n| n.to_string());+ assert_eq!(nets, vec!["GHB", "GHA", "BEMF_A", "BEMF_B", "SWD", "TLM"]);+ let mut same = vec!["B", "A"];+ sort_by_rank(&mut same, &[], |n| n.to_string());+ assert_eq!(same, vec!["B", "A"], "no ranking: nothing moves");+ }+}
docs/release-0.2.67.mdadded+22@@ -0,0 +1,22 @@+# AI Flow 0.2.67++Rank first, then place and route in that order (issues #129 and #130, Noah's ESC runs). All of it is suggestion.++- **`spec.signalPriority` (#129):** ordered signal classes, each with nets (names or patterns), why, and+ optional `noisy`, `sensitive`, `viaBudget`, `maxLengthMm`. `route` routes the classes in this order (the+ router's own order inside a class, unranked nets last). `route`, `gate` and `finish` print a line per class:+ routed nets, the worst length, vias, and the nets over the class's preferences.+- **`spec.placementPriorities` (#130):** thermal, EMC and routability, each with a rank and why. `place check`+ scores the placement in that order and names the worst offender of each: the closest pair of hot parts; the+ pad-hull loop area of each noisy class and the switch nodes, and the closest a sensitive class comes to a+ noisy one; the ratsnest length, worst net and crossings of the top three signal classes.+- **`adom-aiflow priorities [--write <file>]`** shows both rankings, drafts what the spec lacks from the spec+ (switch nodes, loads, hot parts, impedance targets) and the net names, and scores the current placement.+ `step placement` suggests it when a ranking is missing.+- **`place pack`** notes when a higher-ranked part comes later in the wishes than a lower-ranked one (it packs+ in wish order; the order stays the AI's).+- **`step placement --back --why ... --about <thermal|emc|routability|class>`** records which ranking item a+ return is about.+- `finish` notes "not ranked" in its report (not an open item) and records `summary.priorities`.++Not in this release: rip-up that spares higher-ranked classes (the router still rips any blocker).
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.66",+ "version": "0.2.67", "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.66",+ "version": "0.2.67", "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+10@@ -473,6 +473,16 @@ Declare each decoupling cap with the supply pin it serves: `"decoupling": [{"cap `placement.proximity: [{"ref": "D2", "nearRef": "U4", "pin": "5", "maxMm": 5}]` (pad edge to pad edge), `placement.cluster: [{"name": "buck", "refs": ["U4", "C7", "L1"], "radiusMm": 6}]` or `"region": "<name in placement.regions>" | [[x, y], ...]`, and `placement.anchor: [{"ref": "U4", "cluster": "buck"}]`. These are intent for you, the placer: the binary verifies, it does not move parts. `place check` and `place verify` fail with the distance and the pin named (`proximity D2 near U4 pin 5: 8.00 mm pad edge to pad edge (D2.1 to U4.5), over maxMm 5`), write `placement-intent.json` and record the outcome `finish` reads. A cluster radius is measured between pad-box centres, from the anchor when there is one. `place pack` honours an anchor only in the simple case: a member with no wish of its own follows the anchor's wish at its present offset (turned with the anchor when the anchor's wish names one rotation); it prints the intent verdicts on the packed spots as advice. +### Rank first, then place and route (`spec.signalPriority`, `spec.placementPriorities`)++Before placing, decide what matters most on this board and write it down (issues #129, #130). The highest speed, the most sensitive and the highest power go first: a USB pair before a 9600-baud UART; on a board of two hot chips with one slow link, thermal comes before routability. `adom-aiflow priorities` drafts both rankings from the spec (switch nodes, loads, hot parts, impedance targets) and the net names, and scores the current placement; the draft is a start, never the answer, so reorder it and say why for this board (`--write <file>` saves it to edit into the spec, then `spec-amend --why`).++- `signalPriority`: `[{"class": "gate drive", "nets": ["GH*", "GL*"], "why": "highest di/dt", "noisy": true, "viaBudget": 0, "maxLengthMm": 25}, {"class": "BEMF sense", "nets": ["BEMF_*"], "why": "analogue zero-crossing", "sensitive": true}, ...]`, first is most important. `nets` are names or patterns (`*`, `?`, case ignored, `/GND` matches `GND`). `route` routes the classes in this order (the router's own order within a class, unranked nets last); `route`, `gate` and `finish` print a line per class (routed nets, worst length, vias) compared with `viaBudget` and `maxLengthMm`. `place pack` says when a higher-ranked part comes later in the wishes than a lower-ranked one (it packs in wish order).+- `placementPriorities`: `[{"item": "thermal", "rank": 1, "why": "U1 dissipates 1.4 W in still air"}, {"item": "emc", "rank": 2, "why": "..."}, {"item": "routability", "rank": 3, "why": "..."}]`. `place check` scores the placement in this order and names the worst offender of each: thermal (the closest pair of `spec.hot` parts), EMC (the pad-hull area of each noisy class and the switch nodes, and the closest a sensitive class comes to a noisy one), routability (ratsnest length, worst net and crossings of the top three signal classes).+- A return to placement from current, thermal or Fields says which item it is about: `step placement --back --why "..." --about thermal` (or `emc`, `routability`, a class name), so the ledger shows whether the ranking or the placement was wrong.++All of it is suggestion: nothing refuses without a ranking, and `finish` notes "not ranked" in its report.+ ### Design-rule card at `start` `start` reads the spec's track widths, clearances, via pads, drills, edge clearance, copper thickness and load currents, checks each against the fab lane's range (generic: trace 0.1 to 2 mm, drill 0.2 mm and up, copper 12 to 210 um; raised by the minimums in the `.kicad_dru` beside the board) and prints a `WARNING:` line for each value outside it. The same card, drawn to scale beside the board outline, appears on the run dashboard (`rulesCard` in `<run>/timeline/presentation.json`, also `<run>/rules-card.json`). It is read-only and changes no gate: fix a unit slip in the spec before placement.