Separate region drafts from measurements and reject stale source geometry

Automatic bands now produce a non-overwriting draft, not a measured spec. Bind successful region measurements to their source bytes so failed revisions cannot advance against old geometry. Preserve prior measurements and native fidelity checks.

AI-assisted implementation and validation by Codex under maintainer direction.
This commit is contained in:
Paul Bakaus
2026-09-18 09:43:24 -07:00
parent 37e1757a5d
commit af460886ee
7 changed files with 114 additions and 7 deletions
+3
View File
@@ -336,6 +336,8 @@ fn gate_spec(io: &Io, state: &Value) -> Gate {
"no spec at {SPEC_PATH}: run comp-spec.mjs --comp {comp} --grid, name the regions, then --regions regions.json"
)]);
};
if spec["draft"] == true { return Gate::fail(vec!["automatic region draft is not a measured element map; refine it with comp-spec --regions".into()]); }
if let Some(issue) = crate::comp_spec::region_source_issue(io,&spec) { return Gate::fail(vec![issue]); }
let regions = spec_regions(&spec);
if regions.is_empty() {
return Gate::fail(vec!["spec has no regions".into()]);
@@ -496,6 +498,7 @@ fn gate_plates(io: &Io) -> Gate {
fn gate_plates_for(io: &Io, spec: &Value, only_id: Option<&str>) -> Gate {
let s = self_cmd(io);
if let Some(issue) = crate::comp_spec::region_source_issue(io,spec) { return Gate::fail(vec![issue]); }
let regions = spec_regions(&spec);
let raster_regions: Vec<Value> = regions.iter().filter(|r| r.get("medium").and_then(Value::as_str) == Some("raster")
&& only_id.is_none_or(|id| r["id"] == id)).cloned().collect();
@@ -754,3 +754,51 @@ fn stripped_blurred_shifted_comp_pixels_cannot_pass_with_a_generation_prompt() {
let texture_gate = gate_plates(&ws.io());
assert!(!texture_gate.reasons.iter().any(|r|r.contains("is the comp crop")),"{:?}",texture_gate.reasons);
}
#[test]
fn rejected_region_revision_cannot_advance_using_an_older_spec() {
let ws = Workspace::new();
let image = r::create_image(64,64,[80,100,120,255]);
ws.write("comp.png", &png_io::encode_png(&image, &[]).unwrap());
let input = json!({"regions":[{"id":"photo","kind":"image","bleed":true,
"pixelBox":{"x":0,"y":0,"w":64,"h":64},"note":"Full frame photograph"}]});
ws.write("regions.json",input.to_string().as_bytes());
let mut io=ws.io();
let args=["--comp","comp.png","--regions","regions.json"].map(String::from);
assert_eq!(crate::comp_spec::run(&args,&mut io),0);
let state=json!({"comp":"comp.png"});
assert!(gate_spec(&io,&state).ok);
let measured=std::fs::read(ws.path.join(SPEC_PATH)).unwrap();
ws.write("regions.json",b"{bad json");
assert_eq!(crate::comp_spec::run(&args,&mut io),1);
assert_eq!(std::fs::read(ws.path.join(SPEC_PATH)).unwrap(),measured);
let gate=gate_spec(&io,&state);
assert!(!gate.ok,"a rejected edit must not silently reuse the last measured map");
assert!(gate.reasons.join(" ").contains("regions.json"));
assert!(gate_plates(&io).reasons.join(" ").contains("regions.json"));
ws.write("regions.json",input.to_string().as_bytes());
assert!(gate_spec(&io,&state).ok);
std::fs::remove_file(ws.path.join("regions.json")).unwrap();
assert!(!gate_spec(&io,&state).ok);
}
#[test]
fn automatic_bands_are_a_draft_not_a_build_spec() {
let ws=Workspace::new();
let mut image=r::create_image(128,128,[255,255,255,255]);
for y in (5..100).step_by(6) { r::fill_rect(&mut image,10.,y as f64,100.,3.,[0.,0.,0.,255.]); }
ws.write("comp.png",&png_io::encode_png(&image,&[]).unwrap());
let mut io=ws.io();
let args=["--comp","comp.png","--auto"].map(String::from);
assert_eq!(crate::comp_spec::run(&args,&mut io),0,"auto must produce a usable draft even on a busy comp");
let draft=ws.path.join(".impeccable/build/regions.draft.json");
assert!(draft.exists());
assert_eq!(crate::comp_spec::run(&["--comp","comp.png","--regions",".impeccable/build/regions.draft.json"].map(String::from),&mut io),1);
assert!(!ws.path.join(SPEC_PATH).exists());
assert!(!gate_spec(&io,&json!({"comp":"comp.png"})).ok);
ws.write(SPEC_PATH,&std::fs::read(&draft).unwrap());
assert!(!gate_spec(&io,&json!({"comp":"comp.png"})).ok,"copying a draft to the spec path cannot validate it");
ws.write(SPEC_PATH,b"previous accepted spec");
assert_eq!(crate::comp_spec::run(&args,&mut io),1,"do not overwrite an edited draft");
assert_eq!(std::fs::read(ws.path.join(SPEC_PATH)).unwrap(),b"previous accepted spec");
}
+49 -4
View File
@@ -893,11 +893,24 @@ fn resolve(io: &Io, p: &str) -> PathBuf {
// ---- CLI -------------------------------------------------------------------
/// `impeccable comp-spec ...`
/// A failed edit leaves the last valid measurements intact, but they cannot
/// authorize a build against different source geometry.
pub fn region_source_issue(io: &Io, spec: &Value) -> Option<String> {
let source = spec.get("regionsSource")?;
let Some(path) = source["path"].as_str() else {
return Some("spec has invalid region source evidence; re-run comp-spec --regions".into());
};
let current = std::fs::read(resolve(io,path)).ok()
.map(|bytes| format!("{:x}",Sha256::digest(&bytes)));
if current.as_deref().is_some_and(|hash| Some(hash) == source["sha256"].as_str()) { return None; }
Some(format!("region source {path} changed or is missing since measurement; fix it and re-run comp-spec --regions {path} before continuing"))
}
pub fn run(argv: &[String], io: &mut Io) -> i32 {
let spec_path = arg_or(argv, "spec", SPEC_PATH).to_string();
if flag(argv, "help") || argv.is_empty() {
io.out("REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\n");
io.out("usage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp <png> --auto band regions when you have no regions file\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n");
io.out("usage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp <png> --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n");
return 0;
}
if flag(argv, "print") {
@@ -1037,10 +1050,39 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
return 0;
}
if flag(argv,"auto") && arg(argv,"regions").is_none() {
if flag(argv,"spec") {
io.err("comp-spec: --auto writes a draft, not a measured spec; use --out <draft.json> instead of --spec\n");
return 1;
}
let draft_path = arg_or(argv,"out",".impeccable/build/regions.draft.json");
let dest = resolve(io,draft_path);
if dest == resolve(io,SPEC_PATH) {
io.err("comp-spec: an automatic draft cannot replace the measured spec\n");
return 1;
}
let mut draft = auto_regions(&comp);
draft["draft"] = json!(true);
draft["comp"] = json!(comp_path);
if let Some(parent) = dest.parent() { let _ = std::fs::create_dir_all(parent); }
use std::io::Write;
let written = std::fs::OpenOptions::new().write(true).create_new(true).open(&dest)
.and_then(|mut file| file.write_all(util::json_pretty(&draft).as_bytes()));
if let Err(error) = written {
io.err(&format!("comp-spec: cannot write draft {draft_path}: {error}; use a new --out path to preserve existing work\n"));
return 1;
}
io.out(&format!("DRAFT {draft_path}: {} approximate horizontal bands, not an element map.\nRefine the bands into the visible elements, remove the draft flag, then run comp-spec --comp {comp_path} --regions {draft_path}. The measured spec and build state are unchanged.\n",draft["regions"].as_array().map_or(0,Vec::len)));
return 0;
}
let regions_source;
let regions_input: Value = if let Some(rf) = arg(argv, "regions") {
match std::fs::read_to_string(resolve(io, rf)) {
Ok(raw) => match serde_json::from_str(&raw) {
Ok(v) => v,
Ok(v) => {
regions_source = json!({"path":rf,"sha256":format!("{:x}",Sha256::digest(raw.as_bytes()))});
v
},
Err(e) => {
io.err(&format!("comp-spec: cannot read regions {rf}: {e}\n"));
return 1;
@@ -1051,12 +1093,14 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
return 1;
}
}
} else if flag(argv, "auto") {
auto_regions(&comp)
} else {
io.err("comp-spec: pass --grid to get the coordinate grid, then --regions <json> (or --auto for band regions)\n");
return 1;
};
if regions_input["draft"] == true {
io.err("comp-spec: this is an automatic draft, not a measured element map; refine its bands into the visible elements before removing the draft flag\n");
return 1;
}
let mut spec = match measure_regions(&comp, &regions_input, comp_path) {
Ok(s) => s,
Err(e) => {
@@ -1072,6 +1116,7 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
hasher.update(comp.height.to_le_bytes());
hasher.update(&comp.data);
spec["compSha256"] = json!(format!("{:x}", hasher.finalize()));
spec["regionsSource"] = regions_source;
if let Some(previous) = std::fs::read(&spec_out).ok()
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok()) {
preserve_typography(&mut spec, &previous);
+2 -1
View File
@@ -449,7 +449,8 @@ retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNI
Ported from the former `skill/scripts/{comp-spec,comp-diff,font-match,build-phase}.mjs` (+ `lib/{png,raster,image-metrics,font-fingerprint,font-index,hero-checks}.mjs`) into the engine; invoked as `{{scripts_path}}/impeccable <verb>`. All four resolve paths against the process cwd. Printed commands spell the launcher via `IMPECCABLE_SELF` (default `impeccable`), so they name `{{scripts_path}}/impeccable <verb>`, never `node …mjs`. ISO `createdAt`/`startedAt` timestamps in stdout and written JSON are the only run-dependent output.
- **`comp-spec`** — turns an approved comp into a measured build spec. `--comp <png> --grid` writes `.impeccable/build/comp-grid.png` (10x10 labeled grid) and prints PALETTE/BANDS/NEXT; `--comp <png> --regions <json>` measures regions into `.impeccable/build/spec.json` (region box, sampled palette, medium, aspect, detail energy, plate path for raster kinds) and prints the spec; `--comp <png> --auto` derives band regions; `--print` prints the compact spec; `--crop <id> [--out f] [--scale n] [--raw]` writes a reference crop; `--plate-prompt <id>` prints the regeneration prompt. `--spec <path>` overrides the spec path (default `.impeccable/build/spec.json`). Validation refusals (stderr, exit 1) are the JS strings verbatim: a region with no id / duplicate id / no note, a code-kind region whose note names painted material, a code region over 25% of the comp, a grid span that is not `<colrow>:<colrow>`, uncovered ink cells without `allowUncovered`. spec.json is byte-identical to the JS output.
- **`comp-spec`** — turns an approved comp into a measured build spec. `--comp <png> --grid` writes `.impeccable/build/comp-grid.png` (10x10 labeled grid) and prints PALETTE/BANDS/NEXT; `--comp <png> --regions <json>` measures regions into `.impeccable/build/spec.json` (region box, sampled palette, medium, aspect, detail energy, plate path for raster kinds) and prints the spec; `--comp <png> --auto [--out <draft.json>]` writes approximate bands to a new draft file (default `.impeccable/build/regions.draft.json`), without modifying the measured spec or build state; `--print` prints the compact spec; `--crop <id> [--out f] [--scale n] [--raw]` writes a reference crop; `--plate-prompt <id>` prints the regeneration prompt. `--spec <path>` overrides the spec path (default `.impeccable/build/spec.json`). Validation refusals (stderr, exit 1) are the JS strings verbatim: a region with no id / duplicate id / no note, a code-kind region whose note names painted material, a code region over 25% of the comp, a grid span that is not `<colrow>:<colrow>`, uncovered ink cells without `allowUncovered`. spec.json is byte-identical to the JS output.
Automatic drafts require decomposition into actual visible elements before measurement; a draft flag blocks accidental submission, and existing draft files are never overwritten. Successful `--regions` measurements record the source path and SHA-256. Spec and plate gates reject changed or missing source files, so a failed region edit cannot silently reuse the previous measurements. Legacy specs without source metadata remain readable.
Region inputs support three coordinate representations: inclusive `grid` cells, normalized `box: {x,y,w,h}`, or `pixelBox: {x,y,w,h}` in whole original-comp pixels. Pixel boxes cannot be combined with the other formats and must be positive-sized and contained in the comp; they avoid snapping an asset boundary to a neighbouring grid cell. Foreground UI excluded from a plate reference is excluded at the same aligned coordinates from the candidate during scoring; unmasked asset bytes still undergo provenance checks.
- **`comp-diff`** — `--comp <png> --build <png> [--spec spec.json] [--out-dir dir] [--align top|stretch|cover] [--label name] [--threshold t] [--json] [--no-files]`. Scores structure / color / detail / bands and per-region verdicts (`match`/`drift`/`missing`/`contradicted`); writes `side-by-side.png`, `heatmap.png`, `regions/<id>.png`, and `report.json` under `--out-dir` (unless `--no-files`); prints the text summary or, with `--json`, the report. Exit 0 measured, 1 usage/unreadable input, 3 below `--threshold`. The JSON report and text summary are byte-identical to the JS.
- **`font-match`** — `--measure <text-region-id>` fingerprints the comp crop of a text region (cap height, width/weight class, shape vector), records it on the region's `type` block in the spec, and prints the MEASURE line (pure; byte-identical to the JS). `--rank <id> [--candidates "Family:700,…"] [--text "…"] [--transform …] [--category …]` additionally renders candidate faces in a headless browser and ranks them by fingerprint distance, writing a stamped `chosen` face onto the region and a proof sheet under `.impeccable/build/font-match/`. **Browser**: an installed Chrome discovered and driven over CDP (the same browser the URL engine uses; the JS used Playwright/Puppeteer). With no browser resolvable, the catalog's nearest face is recorded (source `catalog`, estimated size) or, with no catalog either, the MEASURE line stands — matching the JS fallbacks; the sha1 `chosen` stamp is byte-identical. Screenshots vary by Chrome version, so the rendered ranking is not byte-stable.
+10
View File
@@ -191,3 +191,13 @@ files, measurements, exit codes and frozen function vectors were not replaced.
The plate gate applies reference UI exclusions symmetrically after alignment;
regressions separately verify hidden-pixel invariance, visible missing-art
rejection, raw comp-copy rejection and unchanged candidate-check state.
## Recorded 2026-09-18: draft region authoring and source binding
`comp-spec-usage` now describes --auto as a draft writer. In
`comp-spec-regions`, the only file-content addition is regionsSource with the
input path and fixture-derived SHA-256; all existing fields and measurements
were compared unchanged. No frozen function vectors changed. New Rust
regressions verify automatic drafts do not overwrite specs or existing drafts,
cannot be submitted unchanged, and a rejected/missing region-source revision
cannot advance the build using the last successful measurements.
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\nusage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp <png> --auto band regions when you have no regions file\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n",
"stdout": "REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\nusage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp <png> --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n",
"stderr": "",
"exit": 0,
"signal": null,