← Commit history

Freeze spec budgets at placement; spec-amend records real corrections (0.1.95)

John Lauer ·9e758cafba · ·parent 68a0790
8 files changed +177−14
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.94"+version = "0.1.95" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.94"+version = "0.1.95" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.94"+version = "0.1.95" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.94"+version = "0.1.95" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.94"+version = "0.1.95" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.94"+version = "0.1.95" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.94"+version = "0.1.95" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.94"+version = "0.1.95" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.94"+version = "0.1.95" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.94"+version = "0.1.95" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.94"+version = "0.1.95" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1
@@ -288,6 +288,7 @@ The gates read the board and the tools' own output, so a file written to look li - **3D models are the parts.** A renamed or resized laser-etch proxy box is refused by `models` and `finish`. Use the wiki component page's model, the vendor STEP, or a model built from the datasheet. - **Silkscreen is printed.** `finish` refuses a board with fewer than 0.5 visible silkscreen labels per footprint (finished boards carry about 1 to 3.5): references, values and contact labels go on the silkscreen. - `step <name> --back` only returns to an earlier step.+- **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet`, `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. Every gate refuses a spec whose budgets moved since. Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary.  ## Component coverage before placement 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+43−1
@@ -15,6 +15,7 @@ mod browser_access; mod schematic_identity; mod placement_verify; mod provenance;+mod spec_freeze; mod export; mod silkproof; @@ -155,6 +156,8 @@ enum Cmd {     Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf> },     /// The finish line; refuses until every check passes     Finish,+    /// Record a change to the spec's budgets (loads, hot, rise limits, switch-node length, net widths, fab) after placement began. Budgets are frozen at placement; this is the only way to move one, and it stays on the record.+    SpecAmend { #[arg(long)] why: 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> },     /// 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.@@ -292,7 +295,29 @@ fn read_json(p: &str) -> Value {  fn spec_of(r: &Run) -> Value {     if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); }-    read_json(r.data["spec"].as_str().unwrap_or(""))+    let spec = read_json(r.data["spec"].as_str().unwrap_or(""));+    guard_budgets(r, &spec);+    spec+}++/// Once placement has begun the spec's budgets are frozen (`spec_freeze`): freeze them on the first read, and+/// refuse a spec whose budgets moved since without a `spec-amend` on the record.+fn guard_budgets(r: &Run, spec: &Value) {+    let placed = r.data["steps"]["placement"]["visits"].as_array().map(|a| !a.is_empty()).unwrap_or(false);+    if !placed { return; }+    let Some(rec) = spec_freeze::read(&r.dir) else {+        let step = r.data["currentStep"].as_str().unwrap_or("").to_string();+        let rec = spec_freeze::frozen(spec, &now(), &step);+        spec_freeze::write(&r.dir, &rec);+        r.log("spec-budgets-frozen", json!({"step": step, "budgets": rec["budgets"]}));+        return;+    };+    let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(spec));+    if changed.is_empty() { return; }+    err(&format!("the spec's budgets changed after placement began ({} change(s)): {}. The board changes to meet the budgets; the budgets do not move to meet the board", changed.len(), changed.join("; ")), &[+        format!("Put the spec back (the frozen values are in {}) and fix the board: more copper, vias, a better placement.", r.dir.join(spec_freeze::FILE).display()),+        "If a budget really is wrong (a misread datasheet value), amend it on the record: `adom-aiflow spec-amend --why \"<datasheet page and what was misread>\" --ai-thread <you>`. Every amendment shows in the run ledger and the finish summary.".into(),+    ]); }  /// The spec when there is one, Null before the board (an intake run) or when it does not read.@@ -3258,6 +3283,22 @@ fn main() {             }             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()]);         }+        Cmd::SpecAmend { why } => {+            let t = thread(&cli);+            let r = load_run(&cli);+            if why.trim().chars().count() < spec_freeze::MIN_WHY { err("spec-amend needs a real reason in --why: the datasheet page and what was misread", &[]); }+            if r.data["spec"].is_null() { err("this run has no spec yet", &[]); }+            let spec = read_json(r.data["spec"].as_str().unwrap_or(""));+            let Some(mut rec) = spec_freeze::read(&r.dir) else { err("the budgets are not frozen yet (they freeze once placement begins): edit the spec freely until then", &[]) };+            let changed = spec_freeze::diff(&rec["budgets"], &spec_freeze::budgets(&spec));+            if changed.is_empty() { err("the spec's budgets match the frozen ones: nothing to amend", &[]); }+            let a = json!({"at": now(), "step": r.data["currentStep"], "why": why.trim(), "changes": changed, "aiThread": t});+            rec["budgets"] = spec_freeze::budgets(&spec);+            rec["amendments"].as_array_mut().unwrap().push(a.clone());+            spec_freeze::write(&r.dir, &rec);+            r.log("spec-amend", a);+            ok(&format!("spec budgets amended on the record ({} change(s)): {}", changed.len(), changed.join("; ")), &["The amendment is listed in the finish summary; the gates now measure against the new values.".into()]);+        }         Cmd::Finish => {             let t = thread(&cli);             let mut r = load_run(&cli);@@ -3417,6 +3458,7 @@ fn main() {             record_usage(&mut r, "finish");             let summary = json!({"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true});             r.data["summary"] = summary;+            if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); }             r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));             r.save().unwrap();             ok(&format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\n{}", r.data["engine"], r.elapsed_minutes(), step_table(&r)), &["The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);
crates/adom-aiflow/src/spec_freeze.rsadded+120
@@ -0,0 +1,120 @@+//! Spec budgets freeze once placement begins: the board changes to meet them, they do not move to meet the board.+//!+//! A run (2026-10-15) missed the switch-node budget and raised `switchNodeMaxLengthMm` from 12 to 22 to 24 mm,+//! then failed thermal on L1's switch-node copper and moved L1's heat (`hot.L1.tabNet`) onto the big VOUT pour.+//! Every gate passed on numbers the run had changed. The budgets are snapshotted at placement+//! (`<run>/spec-budgets.json`); a later read that disagrees is refused, and a real correction goes through+//! `spec-amend --why`, which keeps it on the record (dashboard ledger and the finish summary).++use serde_json::{json, Map, Value};+use std::path::Path;++/// Spec keys that are budgets or physical models: what the gates measure the board against.+/// Layout keys (pours, thermalVias, fixed coordinates, planeUnder) stay editable: they describe the board.+pub const BUDGET_KEYS: &[&str] = &[+    "loads", "hot", "hotMaxRiseC", "maxRiseC", "switchNodeMaxLengthMm", "switchNodes", "wideNets", "midNets",+    "fab", "copperUm", "copperUmByLayer", "viaPower", "drillPower", "branchCurrentModel", "packageCooling",+];++pub const FILE: &str = "spec-budgets.json";++/// The budget keys present in a spec.+pub fn budgets(spec: &Value) -> Value {+    let mut m = Map::new();+    for k in BUDGET_KEYS { if let Some(v) = spec.get(*k) { if !v.is_null() { m.insert(k.to_string(), v.clone()); } } }+    Value::Object(m)+}++fn short(v: Option<&Value>) -> String {+    let s = v.map(|v| v.to_string()).unwrap_or_else(|| "(absent)".into());+    if s.chars().count() > 140 { format!("{}...", s.chars().take(137).collect::<String>()) } else { s }+}++/// Changed leaves between two budget sets, as "path: old -> new" (objects are walked, arrays and scalars compared whole).+pub fn diff(old: &Value, new: &Value) -> Vec<String> {+    let mut out = Vec::new();+    walk("", old, new, &mut out);+    out+}++fn walk(path: &str, old: &Value, new: &Value, out: &mut Vec<String>) {+    match (old, new) {+        (Value::Object(a), Value::Object(b)) => {+            let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect();+            keys.sort(); keys.dedup();+            for k in keys {+                let p = if path.is_empty() { k.clone() } else { format!("{path}.{k}") };+                match (a.get(k), b.get(k)) {+                    (Some(x), Some(y)) => walk(&p, x, y, out),+                    (x, y) => out.push(format!("{p}: {} -> {}", short(x), short(y))),+                }+            }+        }+        _ => if old != new { out.push(format!("{path}: {} -> {}", short(Some(old)), short(Some(new)))); },+    }+}++pub fn read(dir: &Path) -> Option<Value> {+    std::fs::read_to_string(dir.join(FILE)).ok().and_then(|t| serde_json::from_str(&t).ok())+}++pub fn write(dir: &Path, v: &Value) {+    let tmp = dir.join(format!("{FILE}.tmp"));+    if std::fs::write(&tmp, serde_json::to_string_pretty(v).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, dir.join(FILE)); }+}++/// A new freeze record.+pub fn frozen(spec: &Value, at: &str, step: &str) -> Value {+    json!({"at": at, "step": step, "budgets": budgets(spec), "amendments": []})+}++/// The amendments on record, as "why: changes" lines for the finish summary.+pub fn amendments(rec: &Value) -> Vec<Value> {+    rec["amendments"].as_array().cloned().unwrap_or_default()+}++/// The fewest characters a `--why` may have: a reason, not a word.+pub const MIN_WHY: usize = 20;++#[cfg(test)]+mod tests {+    use super::*;++    fn spec() -> Value {+        json!({"switchNodeMaxLengthMm": 12, "hot": {"L1": {"watts": 0.066, "tabNet": "SW"}, "U1": {"watts": 0.233, "tabNet": "GND"}},+               "hotMaxRiseC": 50, "pours": [{"net": "SW", "layers": ["F.Cu"]}], "fixed": {"MP1": [0, 0]}})+    }++    #[test]+    fn the_2026_10_15_edits_are_caught() {+        let a = budgets(&spec());+        let mut s = spec();+        s["switchNodeMaxLengthMm"] = json!(24);+        s["hot"]["L1"]["tabNet"] = json!("VOUT");+        let d = diff(&a, &budgets(&s));+        assert_eq!(d.len(), 2, "{d:?}");+        assert!(d.iter().any(|l| l.starts_with("hot.L1.tabNet: \"SW\" -> \"VOUT\"")));+        assert!(d.iter().any(|l| l.starts_with("switchNodeMaxLengthMm: 12 -> 24")));+    }++    #[test]+    fn layout_edits_stay_free() {+        let a = budgets(&spec());+        let mut s = spec();+        s["pours"] = json!([{"net": "SW", "layers": ["F.Cu", "B.Cu"]}]);+        s["fixed"]["MP1"] = json!([2, 2]);+        s["thermalVias"] = json!([{"at": "L1.1", "count": 4}]);+        assert!(diff(&a, &budgets(&s)).is_empty());+    }++    #[test]+    fn removing_or_adding_a_budget_counts() {+        let a = budgets(&spec());+        let mut s = spec();+        s.as_object_mut().unwrap().remove("hotMaxRiseC");+        s["maxRiseC"] = json!(80);+        let d = diff(&a, &budgets(&s));+        assert!(d.iter().any(|l| l == "hotMaxRiseC: 50 -> (absent)"), "{d:?}");+        assert!(d.iter().any(|l| l == "maxRiseC: (absent) -> 80"), "{d:?}");+    }+}
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.94",+  "version": "0.1.95",   "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 today; Altium, Fusion and 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.1.94",+  "version": "0.1.95",   "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 today; Altium, Fusion and 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.",