Fix comp gate evidence and stale plate approvals

Measure declared region bounds, keep raw and effective verdicts distinct,
and use active blockers for repair feedback. Revalidate plate receipts
against current asset, region, and comp hashes; require an explicit quoted
comp-authority downgrade for force overrides.

Add regressions for missing assets, copied comp pixels, repeated failures,
generic delegation, and stale reports. Preserve fidelity thresholds.

AI assistance: implemented and validated with OpenAI Codex.
This commit is contained in:
Paul Bakaus
2026-09-11 13:39:42 -07:00
parent cd12f8660e
commit 91a4df5093
9 changed files with 538 additions and 79 deletions
Generated
+1
View File
@@ -595,6 +595,7 @@ dependencies = [
"serde",
"serde_json",
"sha1",
"sha2",
]
[[package]]
+1
View File
@@ -20,6 +20,7 @@ serde_json = { workspace = true, features = ["preserve_order", "float_roundtrip"
regex = { workspace = true }
once_cell = { workspace = true }
sha1 = "0.10"
sha2 = "0.10"
[dev-dependencies]
serde_json = { workspace = true, features = ["preserve_order", "float_roundtrip"] }
+146 -54
View File
@@ -18,7 +18,7 @@ use impeccable_comp::raster::{self as r, Image};
use regex::Regex;
use serde_json::{json, Map, Value};
use crate::comp_diff::{align_build, best_shift, build_report, compare, write_artifacts, Score};
use crate::comp_diff::{align_build, best_shift, build_report, compare, write_artifacts, write_region_artifacts, CompareResult, Score};
use crate::comp_spec::{load_spec, plate_reference, BUILD_DIR, SPEC_PATH};
use crate::font_match::choice_stamped;
use crate::util::{self, arg, flag, round, to_fixed};
@@ -163,6 +163,7 @@ struct Gate {
worst_crops: Vec<Value>,
advisories: Vec<String>,
region_verdicts: Map<String, Value>,
region_reasons: Map<String, Value>,
// comps: approved comp path
approved: Option<String>,
// plates: per-plate rows
@@ -191,6 +192,7 @@ impl Gate {
worst_crops: vec![],
advisories: vec![],
region_verdicts: Map::new(),
region_reasons: Map::new(),
approved: None,
plates: None,
error: false,
@@ -201,6 +203,7 @@ impl Gate {
let mut m = Map::new();
m.insert("ok".into(), json!(self.ok));
m.insert("reasons".into(), json!(self.reasons));
if !self.region_reasons.is_empty() { m.insert("regionReasons".into(), json!(self.region_reasons)); }
if let Some(s) = &self.summary {
m.insert("summary".into(), json!(s));
}
@@ -481,6 +484,7 @@ fn gate_plates(io: &Io) -> Gate {
let mut reasons: Vec<String> = Vec::new();
let mut plates: Vec<Value> = Vec::new();
for rr in &raster_regions {
let reasons_before = reasons.len();
let id = rr.get("id").and_then(Value::as_str).unwrap_or("").to_string();
let file = rr.get("plate").and_then(Value::as_str).map(String::from);
let Some(file) = file.clone().filter(|f| abs(io, f).exists()) else {
@@ -550,7 +554,10 @@ fn gate_plates(io: &Io) -> Gate {
}
}
plates.push(json!({
"id": id, "file": file, "status": "ok",
"id": id, "file": file, "status": if reasons.len() == reasons_before { "ok" } else { "invalid" },
"assetHash": sha256_file(io, &file),
"regionHash": sha256_bytes(util::json_pretty(rr).as_bytes()),
"compHash": spec.get("comp").and_then(Value::as_str).and_then(|p| sha256_file(io, p)),
"size": format!("{}x{}", img.image.width, img.image.height),
"score": score_val.map(util::num).unwrap_or(Value::Null)
}));
@@ -562,6 +569,37 @@ fn gate_plates(io: &Io) -> Gate {
g
}
fn sha256_bytes(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(bytes))
}
fn sha256_file(io: &Io, file: &str) -> Option<String> {
std::fs::read(abs(io, file)).ok().map(|bytes| sha256_bytes(&bytes))
}
fn save_plate_receipts(state: &mut Value, gate: &Gate) {
if let Some(plates) = &gate.plates {
let receipts: Map<String, Value> = plates.iter().filter_map(|p| {
Some((p.get("id")?.as_str()?.to_string(), p.clone()))
}).collect();
state["plates"] = Value::Object(receipts);
}
}
fn plate_receipt_current(io: &Io, state: &Value, spec: &Value, region: &Value) -> bool {
let Some(id) = region.get("id").and_then(Value::as_str) else { return false; };
let Some(receipt) = state.get("plates").and_then(|p| p.get(id)) else { return false; };
let Some(file) = region.get("plate").and_then(Value::as_str) else { return false; };
let Some(comp) = spec.get("comp").and_then(Value::as_str) else { return false; };
receipt.get("status").and_then(Value::as_str) == Some("ok")
&& receipt.get("score").and_then(Value::as_f64).map(|s| s.is_finite()).unwrap_or(false)
&& receipt.get("file").and_then(Value::as_str) == Some(file)
&& sha256_file(io, file).as_deref().is_some_and(|h| receipt.get("assetHash").and_then(Value::as_str) == Some(h))
&& sha256_file(io, comp).as_deref().is_some_and(|h| receipt.get("compHash").and_then(Value::as_str) == Some(h))
&& receipt.get("regionHash").and_then(Value::as_str) == Some(sha256_bytes(util::json_pretty(region).as_bytes()).as_str())
}
fn hex_rgba(hex: &str) -> Option<[u8; 4]> {
let re = regex_hex();
let caps = re.captures(hex)?;
@@ -1001,7 +1039,7 @@ fn rscore_opt(r: &Value, k: &str) -> Option<f64> {
}
/// Run comp-diff in-process (JS spawned comp-diff.mjs --json), returning its report.
fn hero_diff(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, out_dir: &str) -> Result<Value, String> {
fn hero_diff(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, out_dir: &str) -> Result<(Value, CompareResult), String> {
let comp = load_raster(io, comp_path)?;
let build = load_raster(io, build_path)?;
let res = compare(&comp, &build, spec, "top", "hero", None);
@@ -1016,17 +1054,48 @@ fn hero_diff(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, o
});
let report = build_report(&res, Some(&files), &meta);
let _ = std::fs::write(abs(io, &format!("{out_dir}/report.json")), util::json_pretty(&report));
Ok(report)
Ok((report, res))
}
#[allow(clippy::too_many_arguments)]
fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &str, artifact: Option<&str>, organic_scan: OrganicScan) -> Gate {
let mut gate = gate_hero_inner(io, state, build_path, min, out_dir, artifact, organic_scan);
if gate.score.is_none() {
// An earlier capture must not masquerade as evidence for a failed preflight.
let report = json!({ "interpretation": "hero-gate", "measurementsAvailable": false,
"regions": [], "gate": { "ok": gate.ok, "reasons": gate.reasons, "advisories": gate.advisories,
"unscopedReasons": gate.reasons } });
let result = std::fs::create_dir_all(abs(io, out_dir)).and_then(|_| {
std::fs::write(abs(io, &format!("{out_dir}/report.json")), util::json_pretty(&report))
});
if let Err(e) = result {
gate.ok = false;
gate.reasons.push(format!("cannot persist hero gate evidence: {e}"));
}
}
gate
}
#[allow(clippy::too_many_arguments)]
fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &str, artifact: Option<&str>, organic_scan: OrganicScan) -> Gate {
let s = self_cmd(io);
if !abs(io, build_path).exists() {
let bp = state.get("breakpoint").and_then(Value::as_str).map(String::from).unwrap_or_else(|| "comp size".into());
return Gate::fail(vec![format!("no hero capture at {build_path}: screenshot the first viewport at the comp's own dimensions ({bp}) into that path")]);
}
let spec_gate = gate_spec(io, state);
if !spec_gate.ok { return spec_gate; }
let spec_for_refs = load_spec(&abs(io, SPEC_PATH));
if let Some(spec) = &spec_for_refs {
let stale = spec_regions(spec).iter()
.filter(|r| r.get("medium").and_then(Value::as_str) == Some("raster"))
.any(|r| !plate_receipt_current(io, state, spec, r));
if stale {
let plates = gate_plates(io);
save_plate_receipts(state, &plates);
if !plates.ok { return plates; }
}
}
// resolve the page
let mut page_file: Option<String> = artifact.map(String::from).or_else(|| state.get("artifact").and_then(Value::as_str).map(String::from));
if page_file.as_ref().map(|p| !abs(io, p).exists()).unwrap_or(true) {
@@ -1062,13 +1131,14 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
);
}
let comp_path = state.get("comp").and_then(Value::as_str).unwrap_or("").to_string();
let report = match hero_diff(io, &comp_path, build_path, spec_for_refs.as_ref(), out_dir) {
let (mut report, mut measured) = match hero_diff(io, &comp_path, build_path, spec_for_refs.as_ref(), out_dir) {
Ok(r) => r,
Err(e) => return Gate::fail(vec![format!("comp-diff failed: {e}")]),
};
let mut regions: Vec<Value> = report.get("regions").and_then(Value::as_array).cloned().unwrap_or_default();
let mut reasons: Vec<String> = Vec::new();
let mut advisories: Vec<String> = Vec::new();
let mut region_reasons = Map::new();
let overall = report.get("overall").and_then(Value::as_f64).unwrap_or(0.0);
let sc = |k: &str| report.pointer(&format!("/scores/{k}")).and_then(Value::as_f64).unwrap_or(0.0);
// capture-frame check
@@ -1145,18 +1215,16 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
});
if ink_present {
r["verdict"] = json!("drift");
r["verdictReason"] = json!("texture with overlapping code-drawn ink present");
} else {
missing_ids.push(id);
}
}
// passed-plate placement notes
let passed_plate = |id: &str| -> bool {
state
.pointer(&format!("/plates/{id}"))
.map(|p| {
p.get("status").and_then(Value::as_str) == Some("ok")
&& p.get("score").map(|s| s.is_null() || s.as_f64().map(|v| v >= PLATE_MIN).unwrap_or(false)).unwrap_or(true)
})
spec_regions_v.iter().find(|r| r.get("id").and_then(Value::as_str) == Some(id))
.map(|r| spec_for_refs.as_ref().is_some_and(|spec| plate_receipt_current(io, state, spec, r))
&& state.get("plates").and_then(|p| p.get(id)).and_then(|p| p.get("score")).and_then(Value::as_f64).is_some_and(|s| s >= PLATE_MIN))
.unwrap_or(false)
};
let mut placement_notes: Vec<String> = Vec::new();
@@ -1180,6 +1248,7 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
}
r["verdict"] = json!("drift");
r["placed"] = json!(true);
r["verdictReason"] = json!("current plate passed asset validation and rendered presence check; placement remains reviewable");
let ic = r.pointer("/inkBox/comp").cloned().unwrap_or(Value::Null);
let ib = r.pointer("/inkBox/build").cloned().unwrap_or(Value::Null);
if !ic.is_null() && !ib.is_null() {
@@ -1198,7 +1267,7 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
for id in &missing_ids {
if let Some(r) = regions.iter().find(|r| r.get("id").and_then(Value::as_str) == Some(id.as_str())) {
if r.get("verdict").and_then(Value::as_str) == Some("missing") {
reasons.push(format!(
push_region_blocker(&mut reasons, &mut region_reasons, id, format!(
"region {id} is missing (detail {}%, structure {}%): the comp shows material the build does not",
pct0(rscore(r, "detail")), pct0(rscore(r, "structure"))
));
@@ -1229,7 +1298,7 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
} else {
format!("the plate here does not read as the comp region; regenerate it with the crop as reference ({s} generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png> for {id}) and place it at its box")
};
reasons.push(format!(
push_region_blocker(&mut reasons, &mut region_reasons, id, format!(
"region {id} ({kind}) is contradicted (structure {}%, detail added {}%): {tail}",
pct0(rscore(r, "structure")), pct0(rscore(r, "detailAdded"))
));
@@ -1239,7 +1308,7 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
continue;
}
let id = r.get("id").and_then(Value::as_str).unwrap_or("");
reasons.push(format!(
push_region_blocker(&mut reasons, &mut region_reasons, id, format!(
"control {id} drifts to {}% (structure {}%, color {}%): its chrome differs from the comp's; open {} and match the border, fill, radius, chevron or arrow, and label size",
pct0(rscore(r, "overall")), pct0(rscore(r, "structure")), pct0(rscore(r, "color")),
format!("{out_dir}/regions/{id}.png")
@@ -1279,7 +1348,7 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
if above_bar {
advisories.push(format!("(advisory, above the {}% bar) {msg}", pct0(min)));
} else {
reasons.push(msg);
push_region_blocker(&mut reasons, &mut region_reasons, r.get("id").and_then(Value::as_str).unwrap_or(""), msg);
}
}
}
@@ -1432,6 +1501,7 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
let mut g = Gate::blank();
g.ok = reasons.is_empty();
g.reasons = reasons;
g.region_reasons = region_reasons;
g.summary = Some(format!("hero {}% ({})", pct0(overall), report.get("verdict").and_then(Value::as_str).unwrap_or("")));
g.score = Some(overall);
g.verdict = report.get("verdict").and_then(Value::as_str).map(String::from);
@@ -1454,18 +1524,61 @@ fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &s
.iter()
.filter_map(|r| Some((r.get("id")?.as_str()?.to_string(), json!(r.get("verdict")?.as_str()?))))
.collect();
let raw_path = format!("{out_dir}/raw-report.json");
let raw = report.clone();
apply_gate_evidence(&mut report, &mut measured, &regions, &g);
report["rawReport"] = json!(raw_path);
let evidence_write = std::fs::write(abs(io, &raw_path), util::json_pretty(&raw))
.and_then(|_| std::fs::write(abs(io, &format!("{out_dir}/report.json")), util::json_pretty(&report)));
if let Err(e) = evidence_write {
g.ok = false;
g.reasons.push(format!("cannot persist hero gate evidence: {e}"));
} else {
write_region_artifacts(&measured, &abs(io, out_dir), report.get("regions").and_then(Value::as_array).map(Vec::as_slice));
}
g
}
fn push_region_blocker(reasons: &mut Vec<String>, regions: &mut Map<String, Value>, id: &str, message: String) {
regions.entry(id.to_string()).or_insert_with(|| json!([])).as_array_mut().unwrap().push(json!(message));
reasons.push(message);
}
/// Raw scores never change during interpretation. Only gate verdicts and their
/// basis are published alongside them; the original report is retained separately.
fn apply_gate_evidence(report: &mut Value, measured: &mut CompareResult, regions: &[Value], gate: &Gate) {
let unscoped: Vec<&String> = gate.reasons.iter().filter(|reason| {
!gate.region_reasons.values().any(|v| v.as_array().is_some_and(|a| a.iter().any(|m| m.as_str() == Some(reason.as_str()))))
}).collect();
let mut effective = regions.to_vec();
for region in &mut effective {
let id = region.get("id").and_then(Value::as_str).unwrap_or("").to_string();
let blockers = gate.region_reasons.get(&id).cloned().unwrap_or_else(|| json!([]));
region["blocking"] = if !blockers.as_array().unwrap().is_empty() { json!(true) } else if unscoped.is_empty() { json!(false) } else { Value::Null };
region["blockingReasons"] = blockers;
if let Some(raw) = measured.regions.iter_mut().find(|r| r.id == id) {
region["rawVerdict"] = json!(raw.verdict);
if let Some(verdict) = region.get("verdict").and_then(Value::as_str) {
raw.verdict = verdict.into();
}
}
}
report["regions"] = json!(effective);
report["interpretation"] = json!("hero-gate");
report["measurementsAvailable"] = json!(true);
report["gate"] = json!({ "ok": gate.ok, "reasons": gate.reasons, "advisories": gate.advisories, "unscopedReasons": unscoped });
}
/// JS: heroLoopVerdict(state, gate, artifactPath).
fn hero_loop_verdict(state: &mut Value, gate: &Gate, artifact_path: &str, io: &Io) -> Option<String> {
let s = self_cmd(io);
let hero = state.pointer_mut("/phases/hero")?.as_object_mut()?;
let mut history: Vec<Value> = hero.get("history").and_then(Value::as_array).cloned().unwrap_or_default();
let entry = json!({
"at": now(),
"score": gate.score.map(util::num).unwrap_or(Value::Null),
"worstIds": gate.worst_ids,
"blockingReasons": gate.reasons,
"regionVerdicts": Value::Object(gate.region_verdicts.clone()),
"artifactHash": hash_file(io, artifact_path).map(Value::from).unwrap_or(Value::Null),
});
@@ -1477,16 +1590,11 @@ fn hero_loop_verdict(state: &mut Value, gate: &Gate, artifact_path: &str, io: &I
return None;
}
let last3 = &history[history.len() - 3..];
let first_worst = last3[0].pointer("/worstIds/0").and_then(Value::as_str);
let stuck = first_worst.is_some() && last3.iter().all(|h| h.pointer("/worstIds/0").and_then(Value::as_str) == first_worst);
let scores: Vec<f64> = last3.iter().map(|h| h.get("score").and_then(Value::as_f64).unwrap_or(0.0)).collect();
let no_progress = scores.iter().cloned().fold(f64::MIN, f64::max) - scores.iter().cloned().fold(f64::MAX, f64::min) < 0.03;
if stuck && no_progress {
let w = first_worst.unwrap();
return Some(format!(
"region {w} has been the worst region for three attempts and the score moved less than 3 points: value edits are not reaching it. Open {} and rebuild that region from the comp crop (place its plate, or produce one with {s} generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png>, or re-derive its structure from the spec box), then recapture.",
format!(".impeccable/review/diff/hero/regions/{w}.png")
));
let current = json!(gate.reasons);
let stuck = !gate.ok && !gate.reasons.is_empty()
&& last3.iter().all(|h| h.get("blockingReasons") == Some(&current));
if stuck {
return Some("The same hero gate checks remain unresolved after three attempts. The blocking reasons below still apply.".into());
}
None
}
@@ -1614,25 +1722,14 @@ fn force_allowed(reason: Option<&str>) -> bool {
if reason.trim().chars().count() < 20 {
return false;
}
static ERRORED: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)gate \w+ errored").unwrap());
if ERRORED.is_match(reason) {
return true;
}
static NAMES_USER: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\buser\b|\bthey (said|asked|told|chose|picked)\b|\bpaul\b").unwrap());
static ABOUT_COMP: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b(comp|mock|mockup|composition|fidelity|plate|region)\b").unwrap());
static TRANS1: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)truthful|semantic|pixel-level|prioriti[sz]e (facts|semantics|accessibility)").unwrap());
static TRANS2: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)(drop|skip|remove|without|not needed|don't need|do not need|ignore) (the )?(comp|plate|region|fidelity)").unwrap());
static DOWNGRADES: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b(don't|do not|doesn't|does not|no longer|not) (need|have to|want|care|require|match|follow|hold)|\b(drop|skip|remove|ignore|waive|relax|override|approve|approved|accept|accepted|fine|okay|ok|good enough|ship it|move on|proceed|go ahead|instead of|rather than)\b").unwrap());
static REPORTED: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)["'\u{201c}\u{2018}].{6,}["'\u{201d}\u{2019}]|\b(user|they|paul) (said|says|asked|asks|told|wrote|replied|answered|chose|picked|approved|confirmed)\b"#).unwrap());
static BRIEF1: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b(should feel|feel like|not a .* page|extension of)\b").unwrap());
static BRIEF2: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b(comp|mock|fidelity|gate|plate)\b.*\b(approved|accept|fine|ok|okay|skip|drop|waive|relax|override|move on|proceed)\b").unwrap());
let names_user = NAMES_USER.is_match(reason);
let about_comp = ABOUT_COMP.is_match(reason);
let is_translation_dodge = TRANS1.is_match(reason) && !TRANS2.is_match(reason);
let downgrades = DOWNGRADES.is_match(reason);
let reported = REPORTED.is_match(reason);
let brief_quote_only = BRIEF1.is_match(reason) && !BRIEF2.is_match(reason);
names_user && about_comp && downgrades && reported && !is_translation_dodge && !brief_quote_only
static QUOTE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""([^"]+)"|“([^”]+)”|'([^']+)'|([^]+)"#).unwrap());
static DOWNGRADE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^\s*(please\s+)?(ignore|waive|relax|skip|drop|disregard) (the )?(approved )?(comp|mockup|fidelity|plate|region)\b|^\s*(the )?(comp|mockup|fidelity|plate|region)\b[^.!?;\n]{0,40}\b(is optional|is not required|does not need to match|doesn't need to match|need not match|can differ|can be skipped)\b").unwrap());
if !NAMES_USER.is_match(reason) { return false; }
QUOTE.captures_iter(reason).any(|capture| {
(1..=4).filter_map(|i| capture.get(i)).any(|q| DOWNGRADE.is_match(q.as_str()))
})
}
struct AdvanceResult {
@@ -1676,16 +1773,7 @@ fn advance(io: &Io, state: &mut Value, force: bool, reason: Option<&str>, opts:
if let Some(p) = state.pointer_mut(&format!("/phases/{phase}")).and_then(|p| p.as_object_mut()) {
p.insert("gate".into(), gate.record_json(&now()));
}
if phase == "plates" {
if let Some(plates) = &gate.plates {
let mut m = Map::new();
for pl in plates {
let id = pl.get("id").and_then(Value::as_str).unwrap_or("").to_string();
m.insert(id, json!({ "status": pl.get("status").cloned().unwrap_or(Value::Null), "score": pl.get("score").cloned().unwrap_or(Value::Null), "size": pl.get("size").cloned().unwrap_or(Value::Null) }));
}
state.as_object_mut().unwrap().insert("plates".into(), Value::Object(m));
}
}
if phase == "plates" { save_plate_receipts(state, &gate); }
if !gate.ok && force && !force_allowed(reason) {
if let Some(p) = state.pointer_mut(&format!("/phases/{phase}")).and_then(|p| p.as_object_mut()) {
p.insert("status".into(), json!("open"));
@@ -2154,3 +2242,7 @@ pub fn run(argv: &[String], io: &mut Io, organic_scan: OrganicScan) -> i32 {
}
}
}
#[cfg(test)]
#[path = "build_phase/integrity_tests.rs"]
mod integrity_tests;
@@ -0,0 +1,269 @@
use super::*;
#[test]
fn delegation_is_not_authority_to_override_comp() {
for reason in [
"The user said 'Use your judgment to fill in missing product details from my original request.' Proceeding past the comp fidelity gate.",
"The user answered 'Please proceed with the implementation.' I accept the comp differences.",
"The user said \"Do not ignore the comp fidelity requirement.\"",
"gate hero errored after the screenshot tool failed repeatedly",
"The user says the page should feel like a bookshop, so relax the comp gate",
] {
assert!(!force_allowed(Some(reason)), "{reason}");
}
assert!(force_allowed(Some(
"The user said \"Ignore the comp fidelity requirement; ship this version.\""
)));
}
#[test]
fn stall_feedback_does_not_rebuild_a_nonblocking_plate() {
let (io, _) = Io::captured("", std::env::temp_dir(), Default::default());
let mut state = json!({"phases":{"hero":{"history":[]}}});
let mut gate = Gate::fail(vec![
"control meaning-card drifts to 60%: match the comp".into()
]);
gate.score = Some(0.7524);
gate.worst_ids = vec!["accepted-fox".into()];
for _ in 0..3 {
if let Some(message) = hero_loop_verdict(&mut state, &gate, "missing.html", &io) {
assert!(!message.contains("accepted-fox"), "{message}");
assert!(!message.contains("generate-image"), "{message}");
}
assert!(!gate.ok);
assert_eq!(gate.reasons.len(), 1);
}
}
struct Workspace {
path: PathBuf,
}
impl Workspace {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"comp-integrity-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
std::fs::create_dir_all(&path).unwrap();
Self { path }
}
fn io(&self) -> Io {
Io::captured("", self.path.clone(), Default::default()).0
}
fn write(&self, file: &str, bytes: &[u8]) {
let p = self.path.join(file);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, bytes).unwrap();
}
}
impl Drop for Workspace {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn plate_approval_is_bound_to_current_asset_region_and_comp() {
let ws = Workspace::new();
ws.write("art.png", b"accepted asset bytes");
ws.write("comp.png", b"approved comp bytes");
let io = ws.io();
let region =
json!({"id":"art", "kind":"plate", "plate":"art.png", "box":{"x":0,"y":0,"w":1,"h":1}});
let spec = json!({"comp":"comp.png", "regions":[region.clone()]});
let receipt = json!({"id":"art", "status":"ok", "score":0.81, "file":"art.png",
"assetHash":sha256_file(&io,"art.png"), "compHash":sha256_file(&io,"comp.png"),
"regionHash":sha256_bytes(util::json_pretty(&region).as_bytes())});
let mut state = json!({"plates":{"art":receipt}});
assert!(plate_receipt_current(&io, &state, &spec, &region));
ws.write("art.png", b"replacement");
assert!(!plate_receipt_current(&io, &state, &spec, &region));
ws.write("art.png", b"accepted asset bytes");
let mut smaller = region.clone();
smaller["box"]["w"] = json!(0.1);
assert!(!plate_receipt_current(&io, &state, &spec, &smaller));
ws.write("comp.png", b"different comp");
assert!(!plate_receipt_current(&io, &state, &spec, &region));
ws.write("comp.png", b"approved comp bytes");
state["plates"]["art"]["status"] = json!("invalid");
assert!(!plate_receipt_current(&io, &state, &spec, &region));
state["plates"]["art"] = json!({"status":"ok", "score":1.0});
assert!(
!plate_receipt_current(&io, &state, &spec, &region),
"legacy scores must be revalidated"
);
std::fs::remove_file(ws.path.join("art.png")).unwrap();
assert!(!plate_receipt_current(&io, &state, &spec, &region));
}
#[test]
fn copied_comp_does_not_earn_an_ok_plate_receipt_or_advance() {
let ws = Workspace::new();
let mut comp = r::create_image(64, 64, [230, 220, 200, 255]);
for y in 12..52 {
for x in 12..52 {
let p = (y * 64 + x) * 4;
comp.data[p..p + 4].copy_from_slice(&[90, 40, 20, 255]);
}
}
ws.write("comp.png", &png_io::encode_png(&comp, &[]).unwrap());
ws.write(
"art.png",
&png_io::encode_png(&r::resize(&comp, 128.0, 128.0), &[]).unwrap(),
);
let spec = json!({"comp":"comp.png", "regions":[{"id":"art","kind":"plate","medium":"raster","plate":"art.png",
"box":{"x":0,"y":0,"w":1,"h":1},"px":{"x":0,"y":0,"w":64,"h":64},"detail":{"energy":20}}]});
ws.write(SPEC_PATH, util::json_pretty(&spec).as_bytes());
let io = ws.io();
let gate = gate_plates(&io);
assert!(!gate.ok);
assert!(
gate.reasons.iter().any(|r| r.contains("comp crop")),
"{:?}",
gate.reasons
);
assert_eq!(gate.plates.as_ref().unwrap()[0]["status"], "invalid");
let mut state =
json!({"phase":"plates","comp":"comp.png","phases":{"plates":{"attempts":0},"hero":{}}});
let opts = GateOpts {
build_path: None,
min: None,
artifact: None,
};
for _ in 0..5 {
let result = advance(&io, &mut state, false, None, &opts, &no_organic_scan);
assert!(!result.ok);
assert_eq!(state["phase"], "plates");
assert_eq!(state["plates"]["art"]["status"], "invalid");
}
}
#[test]
fn gate_report_keeps_raw_measurements_and_does_not_turn_drift_into_a_pass() {
let comp = r::create_image(64, 64, [230, 220, 200, 255]);
let spec = json!({"regions":[{"id":"fox", "kind":"plate", "x":0,"y":0,"w":1,"h":1}]});
let mut measured = compare(&comp, &comp, Some(&spec), "top", "hero", None);
measured.regions[0].verdict = "missing".into();
let mut report = build_report(&measured, None, &json!({}));
let original = report.clone();
let mut regions = report["regions"].as_array().unwrap().clone();
regions[0]["verdict"] = json!("drift");
regions[0]["placed"] = json!(true);
let gate = Gate::fail(vec!["control meaning-card drifts to 60%".into()]);
apply_gate_evidence(&mut report, &mut measured, &regions, &gate);
assert_eq!(report["regions"][0]["rawVerdict"], "missing");
assert_eq!(report["regions"][0]["verdict"], "drift");
assert_eq!(
measured.regions[0].verdict, "drift",
"the image writer uses the same effective verdict"
);
assert_eq!(
report["regions"][0]["score"],
original["regions"][0]["score"]
);
assert_eq!(report["gate"]["ok"], false);
assert_eq!(
report["gate"]["reasons"][0],
"control meaning-card drifts to 60%"
);
assert_eq!(original["regions"][0]["verdict"], "missing");
}
#[test]
fn accepted_file_hidden_in_render_still_blocks_hero() {
let ws = Workspace::new();
let mut comp = r::create_image(64, 64, [230, 220, 200, 255]);
for y in 8..56 {
for x in 8..56 {
let p = (y * 64 + x) * 4;
comp.data[p..p + 4].copy_from_slice(&[40, 40, 40, 255]);
}
}
ws.write("comp.png", &png_io::encode_png(&comp, &[]).unwrap());
ws.write("art.png", &png_io::encode_png(&comp, &[]).unwrap());
ws.write(
"blank.png",
&png_io::encode_png(&r::create_image(64, 64, [230, 220, 200, 255]), &[]).unwrap(),
);
ws.write(
"index.html",
b"<img src=\"art.png\" style=\"display:none\">",
);
let region = json!({"id":"art","kind":"plate","medium":"raster","plate":"art.png", "box":{"x":0,"y":0,"w":1,"h":1},"px":{"x":0,"y":0,"w":64,"h":64}});
let spec = json!({"comp":"comp.png","regions":[region.clone()]});
ws.write(SPEC_PATH, util::json_pretty(&spec).as_bytes());
let io = ws.io();
// Model an already accepted current asset; rendered presence is still required.
let receipt = json!({"status":"ok","score":0.9,"file":"art.png","assetHash":sha256_file(&io,"art.png"),"compHash":sha256_file(&io,"comp.png"),"regionHash":sha256_bytes(util::json_pretty(&region).as_bytes())});
let mut state =
json!({"comp":"comp.png","plates":{"art":receipt},"phases":{"hero":{"attempts":0}}});
for _ in 0..4 {
let g = gate_hero(
&io,
&mut state,
"blank.png",
HERO_MIN,
"diff",
Some("index.html"),
&no_organic_scan,
);
assert!(!g.ok);
assert!(
g.reasons.iter().any(|r| r.contains("missing")),
"{:?}",
g.reasons
);
let report: Value =
serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap())
.unwrap();
assert_eq!(report["gate"]["ok"], false);
assert_eq!(report["regions"][0]["blocking"], true);
assert_eq!(report["regions"][0]["verdict"], "missing");
assert!(ws.path.join("diff/raw-report.json").exists());
}
}
#[test]
fn shrinking_or_retyping_regions_does_not_disable_spec_checks() {
let bytes = std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../comp/tests/fixtures/comp.png"),
)
.unwrap();
let comp = png_io::decode_png(&bytes).unwrap().image;
let tiny = json!({"regions":[{"id":"only","kind":"chrome","note":"a small control", "box":{"x":0,"y":0,"w":0.02,"h":0.02}}]});
assert!(crate::comp_spec::measure_regions(&comp, &tiny, "comp.png").is_err());
let retyped = json!({"regions":[{"id":"art","kind":"chrome","note":"a painted illustration", "box":{"x":0,"y":0,"w":0.1,"h":0.1}}]});
assert!(
crate::comp_spec::measure_regions(&comp, &retyped, "comp.png")
.unwrap_err()
.contains("painted material")
);
}
#[test]
fn preflight_failure_replaces_stale_success_report() {
let ws = Workspace::new();
ws.write(
"diff/report.json",
br#"{"gate":{"ok":true},"regions":[{"id":"old"}]}"#,
);
let mut state = json!({"comp":"missing.png"});
let gate = gate_hero(
&ws.io(),
&mut state,
"missing-build.png",
HERO_MIN,
"diff",
None,
&no_organic_scan,
);
assert!(!gate.ok);
let report: Value =
serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap()).unwrap();
assert_eq!(report["gate"]["ok"], false);
assert_eq!(report["measurementsAvailable"], false);
assert_eq!(report["regions"], json!([]));
assert_eq!(report["gate"]["reasons"], json!(gate.reasons));
}
+44 -23
View File
@@ -284,20 +284,9 @@ pub fn resolve_regions(comp: &Image, spec: Option<&Value>) -> Vec<RegionBox> {
/// JS: regionCrop(img, r).
fn region_crop(img: &Image, rr: &RegionBox) -> Image {
let min_px = 48f64;
let mut x = rr.x * img.width as f64;
let mut y = rr.y * img.height as f64;
let mut w = rr.w * img.width as f64;
let mut h = rr.h * img.height as f64;
if h < min_px {
y -= (min_px - h) / 2.0;
h = min_px;
}
if w < min_px {
x -= (min_px - w) / 2.0;
w = min_px;
}
r::crop(img, x, y, w, h)
// Sampling support must not add neighbouring elements to a declared region.
r::crop(img, rr.x * img.width as f64, rr.y * img.height as f64,
rr.w * img.width as f64, rr.h * img.height as f64)
}
fn ink_box_json(b: &Option<InkBox>) -> Value {
@@ -507,7 +496,7 @@ fn render_heatmap(comp: &Image, build: &Image) -> Image {
}
/// JS: renderRegionPair(compCrop, buildCrop, id, score).
fn render_region_pair(comp_crop: &Image, build_crop: &Image, id: &str, score: &Score) -> Image {
fn render_region_pair(comp_crop: &Image, build_crop: &Image, id: &str, score: &Score, verdict: &str, gate_region: Option<&Value>) -> Image {
let gap = 16f64;
let pad = 12f64;
let max_w = 700f64;
@@ -520,11 +509,16 @@ fn render_region_pair(comp_crop: &Image, build_crop: &Image, id: &str, score: &S
);
r::blit(&mut out, &a, pad, pad + 30.0);
r::blit(&mut out, &b, pad + a.width as f64 + gap, pad + 30.0);
let v = verdict_for(score, None);
let v = verdict;
let gate_label = gate_region.map(|region| match region.get("blocking").and_then(Value::as_bool) {
Some(true) => " / BLOCKING",
Some(false) => " / NONBLOCKING",
None => " / CHECK GATE",
}).unwrap_or("");
r::draw_label(&mut out, &format!("{} COMP", id.to_uppercase()), pad, pad, [255.0, 255.0, 255.0, 255.0], [0.0, 0.0, 0.0, 220.0], 2.0, 4.0);
r::draw_label(
&mut out,
&format!("BUILD {} {}%", v.to_uppercase(), to_fixed(score.overall * 100.0, 0)),
&format!("BUILD {} {}%{gate_label}", v.to_uppercase(), to_fixed(score.overall * 100.0, 0)),
pad + a.width as f64 + gap,
pad,
[255.0, 255.0, 255.0, 255.0],
@@ -551,12 +545,7 @@ pub fn write_artifacts(result: &CompareResult, comp: &Image, out_dir: &Path) ->
let _ = write_png(&side_path, &side);
let heat_path = out_dir.join("heatmap.png");
let _ = write_png(&heat_path, &render_heatmap(comp, &result.aligned));
let mut region_files: Vec<Value> = Vec::new();
for rg in &result.regions {
let file = out_dir.join("regions").join(format!("{}.png", rg.id));
let _ = write_png(&file, &render_region_pair(&rg.a, &rg.b, &rg.id, &rg.score));
region_files.push(json!(path_str(&file)));
}
let region_files = write_region_artifacts(result, out_dir, None);
json!({
"sideBySide": path_str(&side_path),
"heatmap": path_str(&heat_path),
@@ -564,6 +553,18 @@ pub fn write_artifacts(result: &CompareResult, comp: &Image, out_dir: &Path) ->
})
}
/// Refresh labels after gate interpretation, without rerunning measurements.
pub fn write_region_artifacts(result: &CompareResult, out_dir: &Path, gate_regions: Option<&[Value]>) -> Vec<Value> {
let mut region_files: Vec<Value> = Vec::new();
for rg in &result.regions {
let file = out_dir.join("regions").join(format!("{}.png", rg.id));
let gate_region = gate_regions.and_then(|regions| regions.iter().find(|r| r.get("id").and_then(Value::as_str) == Some(rg.id.as_str())));
let _ = write_png(&file, &render_region_pair(&rg.a, &rg.b, &rg.id, &rg.score, &rg.verdict, gate_region));
region_files.push(json!(path_str(&file)));
}
region_files
}
fn path_str(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
@@ -773,3 +774,23 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
}
0
}
#[cfg(test)]
mod region_isolation_regression {
use super::*;
#[test]
fn small_region_does_not_sample_neighbours() {
let a = r::create_image(256, 128, [240, 220, 190, 255]);
let mut b = a.clone();
for y in 40..53 { for x in 60..180 {
let p = (y * 256 + x) * 4;
b.data[p..p+4].copy_from_slice(&[20,20,20,255]);
}}
let rr = RegionBox { id: "toolbar".into(), x:60.0/256.0, y:53.0/128.0,
w:120.0/256.0, h:22.0/128.0, kind:Some("chrome".into()) };
let ac = region_crop(&a, &rr);
let bc = region_crop(&b, &rr);
assert_eq!(ac.height, 22, "crop must honor the declared box");
assert_eq!(ac.data, bc.data, "neighbours cannot change the target pixels");
}
}
+63
View File
@@ -0,0 +1,63 @@
# Comp gate evidence and integrity
Comp fidelity still has two independent requirements: the numeric score and the
absence of blocking findings. A high overall score, a repeated attempt, or an
existing plate file does not satisfy a missing-region check.
## Measurements and decisions
`comp-diff` measures the declared region bounds. It does not enlarge a narrow
region to include neighbouring elements. Coverage and region-kind checks remain
the responsibility of `comp-spec`; this change does not authorize omitting
regions or shrinking their bounds to exclude required work.
A standalone `comp-diff` report contains raw verdicts. The hero gate can interpret
those measurements using current plate validation and rendered presence. For
hero evidence:
- `raw-report.json` retains uninterpreted metrics and verdicts.
- `report.json` retains the same scores, adds `rawVerdict` to each region, and
publishes the effective `verdict` used by the gate and paired image label.
- `gate.ok` and `gate.reasons` describe whether advancement is allowed and all
unresolved blockers. `drift` alone does not mean a region is nonblocking.
- Paired images label their gate status as `BLOCKING`, `NONBLOCKING`, or
`CHECK GATE`, using the same report fields.
- Region-specific blockers are recorded as `blockingReasons`. `blocking: null`
means an unscoped blocking finding prevents attributing a clean bill of health
to that region. Unscoped findings remain in `gate.unscopedReasons`; they are
not hidden or waived.
When capture, spec, or plate validation fails before measurement, the current
report has `measurementsAvailable: false`, no region results, and the failed
gate reasons. It does not present the preceding capture as current evidence.
Stall feedback follows repeated blocking reasons. It never chooses an asset to
regenerate solely because that asset has the lowest raw score. The feedback is
additional context; it does not clear a finding or advance the phase.
## Plate validation
Successful plate receipts include SHA-256 fingerprints of the asset bytes,
measured region, and comp. A changed or deleted file, a changed region, or a
changed comp invalidates the receipt. Legacy score-only receipts are revalidated.
An invalid plate cannot receive an `ok` receipt merely because its PNG decoded.
Rendered presence is still checked after asset validation, so a file hidden in
the page does not count as placed.
## Overrides
A `--force --reason` must contain a direct quoted downgrade of comp authority
attributed to the user. Generic delegation, the builder's surrounding claim that
it may proceed, or a gate exception does not establish that authorization.
The quote parser is deliberately conservative. It cannot authenticate a quote:
the calling harness must retain the actual user answer and assess provenance.
Neither local receipts nor caller-written state are a security boundary against
an agent with arbitrary write access to all files and engine code.
## Validation
The comp-verbs regressions cover narrow-region isolation, changed/deleted plate
receipts, comp-crop reuse, hidden rendered assets, repeated failed attempts,
ambiguous overrides, spec coverage and kind refusals, and raw/effective report
agreement. They do not certify semantic equivalence of arbitrary artwork or
justify relaxing a fidelity threshold.
+1 -1
View File
@@ -109,7 +109,7 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured
1. **spec.** Measure the comp: `impeccable comp-spec --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank <region> --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. <!-- rule:skill-comp-spec -->
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop <id>` writes the reference; save `impeccable comp-spec --plate-prompt <id> --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt <plate> --prompt-file <prompt.txt>`. The API fallback is `impeccable generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png> --size <WxH> --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. <!-- rule:skill-plates-before-page -->
3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. <!-- rule:skill-hero-gate -->
3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. <!-- rule:skill-hero-gate -->
4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system.
5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered.
6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame.
+12
View File
@@ -164,3 +164,15 @@ installed. The binary's `CLI_VERSION` moves from `3.6.0` to `4.0.0` with the
CLI 4.0.0 release; it is what the binary prints when run directly.
- `cli-version`.
## Recorded 2026-09-11: comp regions no longer include neighbouring pixels
The `comp-diff-no-spec` golden now measures the automatic bands at their actual
bounds rather than enlarging bands under 48px. Reviewed changes are confined to
regional scores and ink boxes: the second band's overall is 0.6755 (was 0.6951),
the fourth is 1.0 (was 0.9468), and narrow-band ink boxes use the corrected crop
coordinates. Whole-frame scores, verdicts, region definitions, exit status, and
stderr are unchanged. The golden was updated to enforce these exact results;
this is not an open-ended accepted delta. Frozen function call vectors remain
unchanged. The Rust narrow-region regression independently checks that changing
only neighbouring pixels leaves the measured crop identical.
File diff suppressed because one or more lines are too long