From bfa60147968a03147fa35cc246f58d46de7dcdbc Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 16 Aug 2026 14:35:42 -0700 Subject: [PATCH] Round-2 sim fixes: one plate rule, plate size from the gate's floor, missing means empty plateVerdict() is shared by the plates gate and generate-image's PLATE-WARN so they cannot disagree; --plate picks a frame that clears the 1.5x width floor (a square region wider than 682px takes the 1536 landscape frame); 'missing' on text/chrome/control regions requires the build region to be near-empty, so a 12px rule a few pixels off reads as contradicted or drift, not missing; the responsive gate does not re-litigate a plate that passed the hero; record hero after close does not inflate the attempt count. AI-assisted (Claude). Co-Authored-By: Claude --- skill/scripts/build-phase.mjs | 32 ++++++++++++++++++++++++++------ skill/scripts/comp-diff.mjs | 6 ++++++ skill/scripts/generate-image.mjs | 18 ++++++++++++++---- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index faf94398d..bc9b7e366 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -195,6 +195,25 @@ export function gateSpec(state, { specPath = SPEC_PATH } = {}) { return { ok: true, reasons: [], summary: `${spec.regions.length} regions, ${plates} plates` }; } +/** + * The one plate rule, shared by the plates gate and generate-image's + * PLATE-WARN so they never disagree. `score` is compare().whole against the + * masked comp crop under cover alignment. + */ +export function plateVerdict(region, score) { + const isTexture = region.kind === 'texture'; + const reasons = []; + if (isTexture) { + const effective = 0.5 * score.color + 0.5 * Math.min(1, score.detail / 0.6); + if (effective < PLATE_MIN) reasons.push(`scores ${(effective * 100).toFixed(0)}% as the material of region ${region.id} (color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); crop a clean patch of the comp region (comp-spec.mjs --crop ${region.id} --raw) and mirror-tile it, generate only when no clean patch exists`); + return { ok: reasons.length === 0, reasons, effective }; + } + if (score.detailAdded > 0.45) reasons.push(`carries detail the comp region ${region.id} does not have (added-detail ${(score.detailAdded * 100).toFixed(0)}% of cells): noise, grain, or a busier subject where the comp is calm; regenerate from the crop reference without adding texture`); + if (score.structure < PLATE_STRUCTURE_MIN) reasons.push(`structure ${(score.structure * 100).toFixed(0)}% against the comp region ${region.id}: the composition of the plate is not the region's (different subject, orientation, or crop); regenerate with comp-spec.mjs --crop ${region.id} as the reference image`); + if (score.overall < PLATE_MIN) reasons.push(`scores ${(score.overall * 100).toFixed(0)}% against the comp region ${region.id} (structure ${(score.structure * 100).toFixed(0)}%, color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); regenerate with the crop as --ref and the comp-spec plate prompt`); + return { ok: reasons.length === 0, reasons, effective: score.overall }; +} + export function gatePlates(state, { specPath = SPEC_PATH } = {}) { const spec = loadSpec(specPath); if (!spec) return { ok: false, reasons: ['no spec'] }; @@ -221,10 +240,8 @@ export function gatePlates(state, { specPath = SPEC_PATH } = {}) { const ref = plateReference(comp, spec, r); const res = compare({ comp: ref, build: img, align: 'cover', spec: null, kind: r.kind }); score = res.whole; - const effective = isTexture ? 0.5 * score.color + 0.5 * Math.min(1, score.detail / 0.6) : score.overall; - if (!isTexture && score.detailAdded > 0.45) reasons.push(`plate ${file} carries detail the comp region ${r.id} does not have (added-detail ${(score.detailAdded * 100).toFixed(0)}% of cells): noise, grain, or a busier subject where the comp is calm. Regenerate from the crop reference; do not add texture the comp does not show.`); - if (!isTexture && score.structure < PLATE_STRUCTURE_MIN) reasons.push(`plate ${file} has structure ${(score.structure * 100).toFixed(0)}% against the comp region ${r.id}: the composition of the plate is not the region's (a different subject, orientation, or crop). Regenerate with comp-spec.mjs --crop ${r.id} as the reference image; a plate that only shares the palette and busyness is not this plate.`); - if (effective < PLATE_MIN) reasons.push(`plate ${file} scores ${(effective * 100).toFixed(0)}% against the comp region ${r.id} (structure ${(score.structure * 100).toFixed(0)}%, color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); it does not read as the same ${isTexture ? 'material' : 'region'}. Regenerate with the crop as --ref and the comp-spec plate prompt${isTexture ? ', or crop a clean patch of the comp region and tile it' : ''}.`); + const v = plateVerdict(r, score); + for (const reason of v.reasons) reasons.push(`plate ${file}: ${reason}`); } plates.push({ id: r.id, file, status: 'ok', size: `${img.width}x${img.height}`, score: score ? score.overall : null }); } @@ -401,7 +418,10 @@ export function gateResponsive(state, { specPath = SPEC_PATH, min = RESPONSIVE_M let report; try { report = JSON.parse(res.stdout); } catch { return { ok: false, reasons: [`comp-diff failed on ${desktop}: ${res.stderr || res.stdout}`] }; } const missing = report.regions.filter((r) => r.verdict === 'missing'); - const contradictedDirection = report.regions.filter((r) => r.verdict === 'contradicted' && (r.kind === 'plate' || r.kind === 'image' || r.kind === 'text')); + // A plate placed and passed at the hero is not re-litigated at 1440: the + // rescale alone drops SSIM on a busy region. Text can still contradict + // (a wrapped headline is a different composition). + const contradictedDirection = report.regions.filter((r) => r.verdict === 'contradicted' && r.kind === 'text'); if (report.overall < min) reasons.push(`the desktop capture (${report.buildSize}) scores ${(report.overall * 100).toFixed(0)}% against the comp, under ${(min * 100).toFixed(0)}%: the first viewport does not survive a common desktop width. The hero passed at ${state.breakpoint || 'the comp size'}; the layout must hold from ~1280 up, not only at the comp's exact width (grid columns in fr / minmax, not fixed px that overflow and wrap).`); for (const r of missing) reasons.push(`at desktop width, region ${r.id} is missing`); for (const r of contradictedDirection) reasons.push(`at desktop width, region ${r.id} (${r.kind}) is contradicted (structure ${(r.score.structure * 100).toFixed(0)}%)`); @@ -542,7 +562,7 @@ async function main() { const which = process.argv[3]; if (which !== 'hero') { console.error('build-phase: record hero --build '); process.exit(1); } const gate = gateHero(state, { buildPath: arg('build', HERO_REPRO), min: arg('min') ? parseFloat(arg('min')) : HERO_MIN }); - state.phases.hero.attempts += 1; + if (state.phases.hero.status !== 'closed') state.phases.hero.attempts += 1; state.phases.hero.gate = { ...gate, at: now() }; saveState(state); // record is the look, advance is the gate: on the plates-only capture, diff --git a/skill/scripts/comp-diff.mjs b/skill/scripts/comp-diff.mjs index 1c5d3fd91..76bc197e0 100644 --- a/skill/scripts/comp-diff.mjs +++ b/skill/scripts/comp-diff.mjs @@ -103,6 +103,7 @@ export function scorePair(a, b, kind = null) { colorIntersection: r4(color.intersection), paletteMatch: r4(color.paletteMatch), detail: r4(detail.score), + detailRaw: r4(detail.rawScore ?? detail.score), detailAdded: r4(detail.addedFraction), bands: r4(bands), _detail: detail, @@ -133,6 +134,11 @@ export function bestShift(comp, build, workWidth = 256) { export function verdictFor(s, kind = null) { const painted = kind === 'plate' || kind === 'image' || kind === 'texture'; if (painted && s.detail < 0.5) return 'missing'; + // For text, chrome, and controls "missing" means the build has nothing + // there, not that a thin strip sits a few pixels off: require the build's + // own energy to be near zero relative to the comp (rawScore, before the + // added-detail penalty), and drift for a mere misalignment. + if (!painted && s.detail < 0.35 && s.structure < 0.6) return (s.detailRaw != null && s.detailRaw < 0.2) ? 'missing' : 'contradicted'; if (s.detail < 0.35 && s.structure < 0.6) return 'missing'; // Structure is the one thing a wrong-but-busy region cannot fake: noise, // a mirrored crop, a swapped column, a tile shuffle all keep color and diff --git a/skill/scripts/generate-image.mjs b/skill/scripts/generate-image.mjs index e0a4de78b..2aa65139f 100644 --- a/skill/scripts/generate-image.mjs +++ b/skill/scripts/generate-image.mjs @@ -222,9 +222,18 @@ if (plateId) { fs.writeFileSync(refPath, encodePng(ref, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } })); const out = arg('out', region.plate); fs.mkdirSync(path.dirname(out), { recursive: true }); - // closest supported size to the region's aspect; the page crops the rest with object-fit + // Closest supported size to the region's aspect; the page crops the rest + // with object-fit. The plates gate demands >= 1.5x the region's width + // (capped at 1536), so a square region wider than 682px cannot ship from + // 1024x1024: take the 1536-wide landscape frame instead and let cover crop. const aspect = region.px.w / region.px.h; - const size = arg('size') || (aspect > 1.2 ? '1536x1024' : aspect < 0.83 ? '1024x1536' : '1024x1024'); + const needW = Math.min(1536, Math.ceil(region.px.w * 1.5)); + let size = arg('size'); + if (!size) { + if (aspect > 1.2) size = '1536x1024'; + else if (aspect < 0.83) size = needW > 1024 ? '1536x1024' : '1024x1536'; + else size = needW > 1024 ? '1536x1024' : '1024x1024'; + } const extra = arg('prompt') || (arg('prompt-file') ? fs.readFileSync(arg('prompt-file'), 'utf8') : ''); const prompt = [platePrompt(spec, region), extra].filter(Boolean).join(' '); plateCtx = { spec, specPath, region, ref, refPath, out, size, prompt, comp, encodePng, resize }; @@ -249,8 +258,9 @@ async function scorePlate(ctx, outFile) { const min = arg('min') ? parseFloat(arg('min')) : null; const line = `PLATE-SCORE ${ctx.region.id} ${(s.overall * 100).toFixed(0)}% against the comp region (structure ${(s.structure * 100).toFixed(0)}%, color ${(s.color * 100).toFixed(0)}%, detail ${(s.detail * 100).toFixed(0)}%)`; console.log(line); - const bad = s.structure < 0.4 || s.overall < 0.4; - if (bad) console.log(`PLATE-WARN the plate does not read as region ${ctx.region.id} (structure ${(s.structure * 100).toFixed(0)}% must be >= 40%, overall >= 40%); open ${outFile} beside ${ctx.refPath} and regenerate with a stricter prompt before building on it. The plates gate will refuse it as it stands.`); + const { plateVerdict } = await import('./build-phase.mjs'); + const v = plateVerdict(ctx.region, s); + if (!v.ok) console.log(`PLATE-WARN the plate does not read as region ${ctx.region.id}: ${v.reasons.join('; ')}. Open ${outFile} beside ${ctx.refPath} and regenerate before building on it; the plates gate refuses it as it stands.`); if (min != null && s.overall < min) { console.log(`PLATE-REJECTED below --min ${(min * 100).toFixed(0)}%`); process.exit(3); } } catch (e) { console.log(`PLATE-SCORE unavailable: ${e.message}`);