← back to Wallco Ai
batch-regen driver: 4-gate (seam+color+concept+settlement-OK) settlement-blocked re-gen, staged-only, race-free per-roll gen lock
55c907325b8e8b3d5becab38dd02bbe20a32739d · 2026-06-01 12:31:18 -0700 · Steve Abrams
Files touched
A scripts/batch-regen-settlement.jsA scripts/regen-4gate-settlement.sh
Diff
commit 55c907325b8e8b3d5becab38dd02bbe20a32739d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 1 12:31:18 2026 -0700
batch-regen driver: 4-gate (seam+color+concept+settlement-OK) settlement-blocked re-gen, staged-only, race-free per-roll gen lock
---
scripts/batch-regen-settlement.js | 175 ++++++++++++++++++++++++++++++++++++++
scripts/regen-4gate-settlement.sh | 172 +++++++++++++++++++++++++++++++++++++
2 files changed, 347 insertions(+)
diff --git a/scripts/batch-regen-settlement.js b/scripts/batch-regen-settlement.js
new file mode 100644
index 0000000..d88d3c4
--- /dev/null
+++ b/scripts/batch-regen-settlement.js
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * batch-regen-settlement.js — background batch driver for the 284 settlement-
+ * blocked designs (2026-06-01, Steve-approved).
+ *
+ * Walks /tmp/regen_worklist.json. For each design:
+ * 1. derives a concept question from the prompt subject (category-aware)
+ * 2. runs scripts/regen-4gate-settlement.sh (seam + flat-color + concept-vision
+ * + SETTLEMENT-OK), capped at MAX_ROLLS rolls
+ * 3. on a 4-gate winner → promotes it to STAGED (is_published=FALSE) by POSTing
+ * /api/seam-debug/heal/approve {src_id, heal_id}. Because every original is
+ * currently unpublished, the heal inherits is_published=FALSE — NOTHING goes
+ * live. The original becomes user_removed=TRUE (recoverable).
+ * 4. on cap-reached → logs ABANDONED (e.g. legally-blocked Merian damask).
+ *
+ * GUARDRAILS (Steve's hard rules — enforced here):
+ * - NEVER publishes live. Winners are staged only; the heal/approve route
+ * inherits the original's (unpublished) state.
+ * - NEVER overrides the settlement gate. The 4th gate is a genuine OK.
+ * - Per-design MAX_ROLLS cap so an unwinnable design is abandoned, not an
+ * infinite Replicate burn.
+ * - Cumulative roll count + est cost (~$0.006/roll) tracked in the ledger.
+ *
+ * Ledger: data/batch-regen-settlement-ledger.jsonl (one JSON line per design).
+ * Live log: /tmp/batch-regen-settlement.live.log (driver-level progress).
+ *
+ * Resumable: skips any design already terminal (winner|abandoned) in the ledger.
+ *
+ * Usage:
+ * node scripts/batch-regen-settlement.js # full worklist
+ * node scripts/batch-regen-settlement.js --limit 3 # smoke test first N
+ * node scripts/batch-regen-settlement.js --dry # plan only, no gen
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const { spawnSync, execSync } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const WORKLIST = process.env.WORKLIST || '/tmp/regen_worklist.json';
+const LEDGER = path.join(ROOT, 'data', 'batch-regen-settlement-ledger.jsonl');
+const LIVE_LOG = '/tmp/batch-regen-settlement.live.log';
+const WORKER = path.join(ROOT, 'scripts', 'regen-4gate-settlement.sh');
+const PORT = process.env.PORT || '9905';
+const MAX_ROLLS = parseInt(process.env.MAX_ROLLS || '20', 10);
+const COST_PER_ROLL = parseFloat(process.env.COST_PER_ROLL || '0.006'); // Replicate SDXL est.
+
+const args = process.argv.slice(2);
+const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1], 10) : Infinity;
+const DRY = args.includes('--dry');
+
+function log(msg) {
+ const line = `[${new Date().toISOString()}] ${msg}`;
+ console.log(line);
+ try { fs.appendFileSync(LIVE_LOG, line + '\n'); } catch (_) {}
+}
+function ledgerAppend(obj) {
+ fs.appendFileSync(LEDGER, JSON.stringify(obj) + '\n');
+}
+function alreadyDone() {
+ const done = new Set();
+ if (!fs.existsSync(LEDGER)) return done;
+ for (const ln of fs.readFileSync(LEDGER, 'utf8').split('\n')) {
+ if (!ln.trim()) continue;
+ try { const o = JSON.parse(ln); if (o.outcome === 'winner' || o.outcome === 'abandoned') done.add(String(o.src_id)); } catch (_) {}
+ }
+ return done;
+}
+
+// Derive a concept question from the prompt + category. Drunk/zoo categories →
+// "is there a recognizable animal". Damask/scenic/floral → "does the motif match
+// the intended subject" (the pattern style itself). The concept gate just needs
+// the rendered tile to actually contain the prompted subject, not a blob.
+function conceptQuestion(category, prompt) {
+ const cat = (category || '').toLowerCase();
+ const p = (prompt || '').toLowerCase();
+ if (cat.includes('drunk') || cat.includes('zoo') || cat.includes('animal')) {
+ return 'Does this wallpaper tile contain at least one clearly recognizable animal (a mammal, bird, reptile, or zoo-style creature with a discernible body/head/limbs) as a real subject — NOT just an abstract blob, smear, or plants/leaves alone?';
+ }
+ if (cat.includes('damask')) {
+ return 'Does this wallpaper tile show a clear, deliberate repeating damask/ornamental motif (a structured symmetrical decorative pattern) — NOT an abstract blob, noise, or empty dead space?';
+ }
+ if (cat.includes('floral') || p.includes('floral') || p.includes('flower') || p.includes('tulip') || p.includes('rose')) {
+ return 'Does this wallpaper tile contain clearly recognizable flowers/botanical motifs arranged as a deliberate repeating pattern — NOT an abstract blob or empty dead space?';
+ }
+ if (cat.includes('scenic') || cat.includes('mural')) {
+ return 'Does this tile contain a coherent, deliberately composed decorative scene/motif matching a wallpaper design — NOT an abstract blob, smear, or empty dead space?';
+ }
+ // generic fallback — just demand a real deliberate motif, not a blob
+ return 'Does this wallpaper tile contain a clear, deliberate repeating decorative motif — NOT an abstract blob, smear, noise, or empty dead space?';
+}
+
+function promoteStaged(srcId, healId) {
+ // localhost → admin-gate grants full admin with no token. Stages the winner
+ // (heal inherits the original's UNPUBLISHED state → never goes live).
+ const body = JSON.stringify({ src_id: Number(srcId), heal_id: Number(healId) });
+ const out = execSync(
+ `curl -s -X POST "http://127.0.0.1:${PORT}/api/seam-debug/heal/approve" ` +
+ `-H 'Content-Type: application/json' -d '${body}'`,
+ { encoding: 'utf8', timeout: 30000 }
+ );
+ let j; try { j = JSON.parse(out); } catch (_) { throw new Error('approve route returned non-JSON: ' + out.slice(0, 200)); }
+ if (!j.ok) throw new Error('approve failed: ' + out.slice(0, 200));
+ if (j.promoted_to_published) throw new Error('GUARDRAIL TRIPPED: winner was promoted to PUBLISHED — original was published. Aborting.');
+ return j;
+}
+
+function main() {
+ if (!fs.existsSync(WORKLIST)) { console.error('worklist not found: ' + WORKLIST); process.exit(1); }
+ const work = JSON.parse(fs.readFileSync(WORKLIST, 'utf8'));
+ const done = alreadyDone();
+
+ let totalRolls = 0, winners = 0, abandoned = 0, errors = 0, processed = 0;
+ log(`=== batch-regen-settlement START · ${work.length} in worklist · MAX_ROLLS=${MAX_ROLLS} · cost/roll≈$${COST_PER_ROLL} · ${done.size} already done ===`);
+
+ for (const item of work) {
+ if (processed >= LIMIT) { log(`--limit ${LIMIT} reached, stopping`); break; }
+ const srcId = String(item.id);
+ if (!/^[0-9]+$/.test(srcId)) { log(`SKIP non-integer id "${srcId}"`); continue; }
+ if (done.has(srcId)) { log(`skip #${srcId} (already terminal in ledger)`); continue; }
+
+ const cq = conceptQuestion(item.category, item.prompt);
+ processed++;
+ log(`[${processed}] #${srcId} cat=${item.category} verdict=${item.verdict} — regen (cap ${MAX_ROLLS})`);
+
+ if (DRY) { log(` DRY: would run worker with concept="${cq.slice(0, 60)}…"`); continue; }
+
+ const T0 = Date.now();
+ const r = spawnSync('bash', [WORKER, srcId], {
+ cwd: ROOT,
+ env: { ...process.env, SRC_ID: srcId, CATEGORY: item.category, MAX_ROLLS: String(MAX_ROLLS), CONCEPT_Q: cq, GEN_BACKEND: process.env.GEN_BACKEND || 'replicate' },
+ encoding: 'utf8',
+ stdio: ['ignore', 'inherit', 'inherit'],
+ timeout: 1000 * 60 * 90, // 90 min hard ceiling per design
+ });
+ const dtMin = ((Date.now() - T0) / 60000).toFixed(1);
+
+ // rolls actually spent (worker writes per-design roll count)
+ let rolls = 0;
+ try { rolls = parseInt(fs.readFileSync(`/tmp/regen-4gate-${srcId}.rolls`, 'utf8').trim(), 10) || 0; } catch (_) {}
+ totalRolls += rolls;
+ const estCost = +(totalRolls * COST_PER_ROLL).toFixed(3);
+
+ let outcome, healId = null, reason = '';
+ const winnerFile = `/tmp/regen-4gate-${srcId}.winner`;
+ if (r.status === 0 && fs.existsSync(winnerFile)) {
+ healId = parseInt(fs.readFileSync(winnerFile, 'utf8').trim(), 10);
+ try {
+ promoteStaged(srcId, healId);
+ outcome = 'winner'; winners++;
+ reason = `staged heal #${healId} (is_published=FALSE), original user_removed`;
+ } catch (e) {
+ outcome = 'error'; errors++; reason = 'promote failed: ' + e.message;
+ }
+ } else if (r.status === 2) {
+ outcome = 'abandoned'; abandoned++;
+ reason = `hit ${MAX_ROLLS}-roll cap without settlement-OK winner (likely legally/structurally unwinnable)`;
+ } else {
+ outcome = 'error'; errors++;
+ reason = `worker exit ${r.status}${r.signal ? ' signal=' + r.signal : ''}`;
+ }
+
+ ledgerAppend({
+ ts: new Date().toISOString(), src_id: Number(srcId), category: item.category,
+ original_verdict: item.verdict, outcome, heal_id: healId, rolls, dt_min: Number(dtMin),
+ cumulative_rolls: totalRolls, est_cost_usd: estCost, reason,
+ });
+ log(` → ${outcome.toUpperCase()} #${srcId}${healId ? ' heal=' + healId : ''} · rolls=${rolls} · ${dtMin}min · cum_rolls=${totalRolls} · est_cost≈$${estCost} · ${reason}`);
+ }
+
+ const estCost = +(totalRolls * COST_PER_ROLL).toFixed(3);
+ log(`=== DONE · processed=${processed} winners=${winners} abandoned=${abandoned} errors=${errors} · total_rolls=${totalRolls} · est_cost≈$${estCost} ===`);
+}
+
+main();
diff --git a/scripts/regen-4gate-settlement.sh b/scripts/regen-4gate-settlement.sh
new file mode 100755
index 0000000..ca5e484
--- /dev/null
+++ b/scripts/regen-4gate-settlement.sh
@@ -0,0 +1,172 @@
+#!/usr/bin/env bash
+# regen-4gate-settlement.sh — 4-gate regen worker for the settlement-blocked
+# batch re-generation (2026-06-01, Steve-approved batch of 284 designs).
+#
+# Builds on ~/.claude/skills/regen-until-clean/scripts/regen-3-gate.sh but adds:
+# Gate 4 (SETTLEMENT): after a roll passes the existing 3 gates (seam ≤5,
+# ≤4 flat colors, concept-vision yes), run the image-side settlement check
+# (scripts/settlement_postgen_vision_check.js). Only a literal "VERDICT: OK"
+# counts as a winner — "NEEDS REVIEW" and "BLOCK" are FAIL-CLOSED (skipped,
+# never a pass). This is Steve's hard rule: the 4th gate is a GENUINE
+# settlement OK, never an override.
+#
+# Two correctness fixes over the 3-gate parent (DTD verdict B, 2026-06-01):
+# 1. newid is read from generate_designs.js's deterministic stdout line
+# ("Created X/N designs · IDs: a,b") — NOT a racy SELECT MAX(id). This
+# eliminates the id-grab race with the standing curator generator, which
+# also calls generate_designs.js.
+# 2. each generation call takes the shared "wallco-gen" mkdir-mutex (BLOCKING,
+# with stale-PID recovery) so the standing curator and this worker never
+# run generate_designs.js concurrently. The lock is held ONLY around the
+# ~30-60s gen call, never the whole multi-roll regen, so the curator's
+# 30-min auto-publish tick is never starved for long.
+#
+# GUARDRAILS (Steve's standing rules — do NOT relax):
+# - This worker only PRODUCES a winner id + writes it to the .winner file.
+# The actual staged promotion (is_published=FALSE swap) is done by the
+# batch driver via /api/seam-debug/heal/approve. NOTHING here publishes
+# live, and the settlement DB trigger is never overridden.
+# - MAX_ROLLS cap abandons designs that can't pass settlement (e.g. the
+# legally-blocked Merian-insect damask) instead of burning infinite spend.
+#
+# Usage:
+# SRC_ID=55289 CATEGORY=drunk-animals MAX_ROLLS=20 \
+# CONCEPT_Q="Does this tile contain a recognizable animal?" \
+# bash scripts/regen-4gate-settlement.sh
+#
+# Exit 0 = settlement-OK winner written to /tmp/regen-4gate-<src_id>.winner
+# Exit 2 = MAX_ROLLS cap reached without a 4-gate winner (ABANDON)
+# Exit 1 = fatal (bad src id / no prompt)
+set -uo pipefail
+cd ~/Projects/wallco-ai || { echo "must run inside ~/Projects/wallco-ai"; exit 1; }
+
+SRC_ID=${1:-${SRC_ID:-}}
+[ -z "$SRC_ID" ] && { echo "usage: regen-4gate-settlement.sh <src_id>"; exit 1; }
+case "$SRC_ID" in (''|*[!0-9]*) echo "[fatal] src_id must be a positive integer, got: '$SRC_ID'"; exit 1;; esac
+
+MAX_ROLLS=${MAX_ROLLS:-20}
+export GEN_BACKEND=${GEN_BACKEND:-replicate} # replicate ~30-60s
+# Disable generate_designs.js's internal quality retries — the outer gate loop
+# is the only gate that matters; let the inner gen emit one image per call.
+export WALLCO_SEAMLESS_GATE=${WALLCO_SEAMLESS_GATE:-0}
+export WALLCO_FRAME_GATE=${WALLCO_FRAME_GATE:-0}
+export WALLCO_GHOST_GATE=${WALLCO_GHOST_GATE:-0}
+CATEGORY=${CATEGORY:-}
+CONCEPT_Q=${CONCEPT_Q:-"Does this wallpaper tile contain at least one clearly recognizable animal silhouette (a mammal, bird, or zoo-style creature with limbs/body/head — NOT just plants, leaves, or branches)?"}
+SEAM_MAX=${SEAM_MAX:-5}
+COLOR_CAP=${COLOR_CAP:-4}
+COLOR_PCT=${COLOR_PCT:-0.03}
+LOCK_NAME=${LOCK_NAME:-wallco-gen}
+LOCK_DIR="/tmp/wallco-ai-${LOCK_NAME}.lock"
+LOCK_WAIT_MAX=${LOCK_WAIT_MAX:-1800} # max seconds to wait for the gen lock per roll
+PY=scripts/vectorize/.venv/bin/python3
+LOG=/tmp/regen-4gate-${SRC_ID}.log
+WINNER_FILE=/tmp/regen-4gate-${SRC_ID}.winner
+ROLLS_FILE=/tmp/regen-4gate-${SRC_ID}.rolls # per-design roll count for cost accounting
+: > "$LOG"; rm -f "$WINNER_FILE"; echo 0 > "$ROLLS_FILE"
+
+# pull the original prompt + category from PG (trust-auth psql, same path the
+# server uses; reads all_designs which is fully accessible).
+prompt=$(psql -d dw_unified -tAc "SELECT prompt FROM all_designs WHERE id=$SRC_ID")
+[ -z "$prompt" ] && { echo "[fatal] src #$SRC_ID has no prompt or doesn't exist"; exit 1; }
+if [ -z "$CATEGORY" ]; then
+ CATEGORY=$(psql -d dw_unified -tAc "SELECT category FROM all_designs WHERE id=$SRC_ID")
+fi
+title=$(psql -d dw_unified -tAc "SELECT COALESCE(title, category || ' #' || id) FROM all_designs WHERE id=$SRC_ID")
+
+# ── shared gen mutex (BLOCKING acquire, stale-PID recovery) ──────────────────
+# Mirrors scripts/with-lock.sh's mkdir mutex but BLOCKS (retries) instead of
+# silently exiting, so a roll is never dropped — it waits for the curator.
+gen_lock_acquire() {
+ local waited=0
+ while true; do
+ if mkdir "$LOCK_DIR" 2>/dev/null; then echo $$ > "$LOCK_DIR/pid"; return 0; fi
+ # stale-lock recovery: holder dead?
+ local old; old=$(cat "$LOCK_DIR/pid" 2>/dev/null)
+ if [ -n "$old" ] && ! kill -0 "$old" 2>/dev/null; then
+ rm -rf "$LOCK_DIR" 2>/dev/null; continue
+ fi
+ sleep 3; waited=$((waited+3))
+ if [ "$waited" -ge "$LOCK_WAIT_MAX" ]; then
+ echo "[warn] gen-lock wait exceeded ${LOCK_WAIT_MAX}s — proceeding without lock" | tee -a "$LOG"
+ return 1
+ fi
+ done
+}
+gen_lock_release() {
+ local own; own=$(cat "$LOCK_DIR/pid" 2>/dev/null)
+ [ "$own" = "$$" ] && rm -rf "$LOCK_DIR" 2>/dev/null
+}
+
+echo "[start] $(date '+%Y-%m-%d %H:%M:%S') src=$SRC_ID category=$CATEGORY backend=$GEN_BACKEND cap=$MAX_ROLLS" | tee -a "$LOG"
+echo "[gates] seam ≤$SEAM_MAX + ≤$COLOR_CAP major-colors + concept-vision + SETTLEMENT==OK" | tee -a "$LOG"
+echo "[concept] $CONCEPT_Q" | tee -a "$LOG"
+
+for n in $(seq 1 "$MAX_ROLLS"); do
+ echo "$n" > "$ROLLS_FILE"
+ T0=$(date +%s)
+ # ── locked gen call ──
+ gen_lock_acquire
+ genout=$(node scripts/generate_designs.js --n 1 --kind seamless_tile --category "$CATEGORY" --prompts "$prompt" 2>&1)
+ gen_lock_release
+ dt=$(( $(date +%s) - T0 ))
+ # newid from the deterministic stdout line: "Created X/N designs · IDs: a,b"
+ newid=$(printf '%s\n' "$genout" | grep -oE 'IDs: [0-9,]+' | tail -1 | grep -oE '[0-9]+' | tail -1)
+ if [ -z "$newid" ]; then
+ echo "roll $n: GEN FAILED (no IDs line; dt=${dt}s)" | tee -a "$LOG"
+ printf '%s\n' "$genout" | tail -3 >> "$LOG"
+ continue
+ fi
+
+ # gate 1: seam
+ seam=$($PY -c "
+import json, subprocess
+o=subprocess.run(['$PY','scripts/seam-defect-boxes.py','--id','$newid'],capture_output=True,text=True)
+j=json.loads(o.stdout); print(j['verdict']+'|'+str(round(j['scores']['overall_max'],2)))" 2>/dev/null)
+ v=${seam%%|*}; om=${seam##*|}
+ if [ -z "$v" ] || ! printf '%s' "$om" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
+ echo "roll $n: #$newid SEAM SCORER FAILED (verdict='$v' om='$om') — skip (dt=${dt}s)" | tee -a "$LOG"; continue
+ fi
+
+ # gate 2: flat color
+ lp=$(psql -d dw_unified -tAc "SELECT local_path FROM all_designs WHERE id=$newid")
+ major=$($PY -c "
+import numpy as np
+from PIL import Image
+im=np.asarray(Image.open('$lp').convert('RGB'))
+b=(im>>4).reshape(-1,3)
+_,c=np.unique(b,axis=0,return_counts=True)
+print(int((c>=c.sum()*$COLOR_PCT).sum()))" 2>/dev/null)
+ if ! printf '%s' "$major" | grep -qE '^[0-9]+$'; then
+ echo "roll $n: #$newid COLOR SCORER FAILED (major='$major') — skip (dt=${dt}s)" | tee -a "$LOG"; continue
+ fi
+
+ seam_ok=$(awk -v a="$om" -v b="$SEAM_MAX" 'BEGIN{print (a+0 <= b+0) ? 1 : 0}')
+
+ # gate 3 (concept) — only if seam+color pass
+ if [ "$seam_ok" = "1" ] && [ "$major" -le "$COLOR_CAP" ]; then
+ has=$($PY scripts/vision-contains.py "$lp" "$CONCEPT_Q" 2>/dev/null)
+ else
+ has="(skip)"
+ fi
+
+ # gate 4 (SETTLEMENT) — only if 1+2+3 pass. Image-side Gemini settlement check.
+ # FAIL-CLOSED: only literal "VERDICT: OK" wins. NEEDS REVIEW / BLOCK / crash =
+ # not a winner. Never overrides the gate.
+ settle="(skip)"
+ if [ "$seam_ok" = "1" ] && [ "$major" -le "$COLOR_CAP" ] && [ "$has" = "yes" ]; then
+ sout=$(node scripts/settlement_postgen_vision_check.js "$lp" "$title" 2>&1)
+ settle=$(printf '%s\n' "$sout" | grep -oE 'VERDICT: (OK|NEEDS REVIEW|BLOCK)' | tail -1 | sed 's/VERDICT: //')
+ [ -z "$settle" ] && settle="(error)"
+ fi
+
+ echo "roll $n: #$newid seam=$v($om) colors=$major concept=$has settlement=$settle (dt=${dt}s)" | tee -a "$LOG"
+
+ if [ "$seam_ok" = "1" ] && [ "$major" -le "$COLOR_CAP" ] && [ "$has" = "yes" ] && [ "$settle" = "OK" ]; then
+ echo "$newid" > "$WINNER_FILE"
+ echo "[done] ALL 4 GATES PASS on roll $n — winner #$newid (saved to $WINNER_FILE)" | tee -a "$LOG"
+ exit 0
+ fi
+done
+echo "[stop] hit $MAX_ROLLS-roll cap without a 4-gate (settlement-OK) winner — ABANDON #$SRC_ID" | tee -a "$LOG"
+exit 2
← e04b3d1 feat(designs): add Color (group similar hues, H→L) + Style s
·
back to Wallco Ai
·
styleguides.wallco.ai → admin-only; revert public metering o 5b012f9 →