← back to Wallco Ai
scripts/batch-regen-settlement.js
211 lines
#!/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
*
* Concurrency (DTD verdict C, 2026-06-01): runs CONCURRENCY designs in parallel
* (default 2). The id-grab race is already eliminated by reading the new id from
* generate_designs.js's deterministic stdout 'IDs:' line (NOT MAX(id)), so the
* gen lock is unnecessary for correctness — the worker no longer takes it. The
* 2-design cap is a pure concurrency budget: it cuts wall-clock ~2x vs serial
* while leaving Replicate/CPU headroom for the standing curator generator to
* interleave on its 30-min tick.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawn, 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 CONCURRENCY = parseInt(process.env.CONCURRENCY || '2', 10); // DTD-C: 2 designs in flight
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;
}
// Run ONE design through the worker (async). Resolves with its ledger record.
function runDesign(item, idx) {
return new Promise((resolve) => {
const srcId = String(item.id);
const cq = conceptQuestion(item.category, item.prompt);
log(`[${idx}] START #${srcId} cat=${item.category} verdict=${item.verdict} — regen (cap ${MAX_ROLLS})`);
const T0 = Date.now();
const child = spawn('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' },
stdio: ['ignore', 'inherit', 'inherit'],
});
const killT = setTimeout(() => { try { child.kill('SIGTERM'); } catch (_) {} }, 1000 * 60 * 120); // 120 min ceiling/design
child.on('exit', (code, signal) => {
clearTimeout(killT);
const dtMin = ((Date.now() - T0) / 60000).toFixed(1);
let rolls = 0;
try { rolls = parseInt(fs.readFileSync(`/tmp/regen-4gate-${srcId}.rolls`, 'utf8').trim(), 10) || 0; } catch (_) {}
let outcome, healId = null, reason = '';
const winnerFile = `/tmp/regen-4gate-${srcId}.winner`;
if (code === 0 && fs.existsSync(winnerFile)) {
healId = parseInt(fs.readFileSync(winnerFile, 'utf8').trim(), 10);
try {
promoteStaged(srcId, healId);
outcome = 'winner';
reason = `staged heal #${healId} (is_published=FALSE), original user_removed`;
} catch (e) { outcome = 'error'; reason = 'promote failed: ' + e.message; }
} else if (code === 2) {
outcome = 'abandoned';
reason = `hit ${MAX_ROLLS}-roll cap without settlement-OK winner (likely legally/structurally unwinnable)`;
} else {
outcome = 'error';
reason = `worker exit ${code}${signal ? ' signal=' + signal : ''}`;
}
resolve({ srcId, item, outcome, healId, rolls, dtMin: Number(dtMin), reason });
});
});
}
async 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();
// build the queue (skip non-integer ids + already-terminal designs + --limit)
const queue = [];
for (const item of work) {
const srcId = String(item.id);
if (!/^[0-9]+$/.test(srcId)) { log(`SKIP non-integer id "${srcId}"`); continue; }
if (done.has(srcId)) continue;
queue.push(item);
if (queue.length >= LIMIT) break;
}
let totalRolls = 0, winners = 0, abandoned = 0, errors = 0, processed = 0;
log(`=== batch-regen-settlement START · ${work.length} in worklist · ${queue.length} to process · ${done.size} already done · CONCURRENCY=${CONCURRENCY} · MAX_ROLLS=${MAX_ROLLS} · cost/roll≈$${COST_PER_ROLL} ===`);
if (DRY) {
queue.forEach((item, i) => log(` DRY [${i + 1}] #${item.id} cat=${item.category} concept="${conceptQuestion(item.category, item.prompt).slice(0, 60)}…"`));
log(`=== DRY DONE · ${queue.length} planned ===`);
return;
}
let cursor = 0;
function finish(rec) {
processed++;
totalRolls += rec.rolls;
if (rec.outcome === 'winner') winners++;
else if (rec.outcome === 'abandoned') abandoned++;
else errors++;
const estCost = +(totalRolls * COST_PER_ROLL).toFixed(3);
ledgerAppend({
ts: new Date().toISOString(), src_id: Number(rec.srcId), category: rec.item.category,
original_verdict: rec.item.verdict, outcome: rec.outcome, heal_id: rec.healId,
rolls: rec.rolls, dt_min: rec.dtMin, cumulative_rolls: totalRolls, est_cost_usd: estCost, reason: rec.reason,
});
log(` → ${rec.outcome.toUpperCase()} #${rec.srcId}${rec.healId ? ' heal=' + rec.healId : ''} · rolls=${rec.rolls} · ${rec.dtMin}min · [${processed}/${queue.length}] cum_rolls=${totalRolls} · est_cost≈$${estCost} · ${rec.reason}`);
}
// simple N-slot pool
async function worker() {
while (cursor < queue.length) {
const myIdx = cursor++;
const rec = await runDesign(queue[myIdx], myIdx + 1);
finish(rec);
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, () => worker()));
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();