[object Object]

← back to Wallco Ai

haiku weekend test: yolo-queue runner (max-plan path) + SQL fixes

875105cabf057fe69bfa6f8ca3ae4587bc6cc893 · 2026-05-29 08:40:22 -0700 · Steve Abrams

new: scripts/queue-haiku-yolo-tasks.js — generates yolo-queue tasks
for the weekend recall test running under Steve's Max plan (Claude
Code via yolo loop, not developer API). Same sampling, same rubric,
JSONL output aligned with haiku-weekend-summary.js. Batches 15 designs
per task to stay under MAX_TURNS=30 + 25min wallclock; defaults to
3-batch smoke before committing to full sweep.

fix: SELECT DISTINCT + ORDER BY random() is illegal in PG ('ORDER BY
expressions must appear in select list'), but psql exits 0 silently
on SQL errors by default — bulk-tag queries were returning empty
strings, fetchBads returned just the 13 gold rows. Two-part fix:
(1) psql helper now passes -v ON_ERROR_STOP=1 so syntax errors fail
loudly, (2) bulk-tag query wraps DISTINCT in an inner subquery + sorts
random in the outer.

fix: live-queue query was hitting needs_fixing_at on spoon_all_designs
view (which doesn't expose that column — view is a strict subset of
all_designs). Switched FROM to all_designs directly + added user_removed
guard for the proper 'undecided' filter.

verified: dry-run now returns 500 bads + 226 goods + 200 live = 838
designs across 56 batches (vs previous broken 13+226+0=239).

Files touched

Diff

commit 875105cabf057fe69bfa6f8ca3ae4587bc6cc893
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 08:40:22 2026 -0700

    haiku weekend test: yolo-queue runner (max-plan path) + SQL fixes
    
    new: scripts/queue-haiku-yolo-tasks.js — generates yolo-queue tasks
    for the weekend recall test running under Steve's Max plan (Claude
    Code via yolo loop, not developer API). Same sampling, same rubric,
    JSONL output aligned with haiku-weekend-summary.js. Batches 15 designs
    per task to stay under MAX_TURNS=30 + 25min wallclock; defaults to
    3-batch smoke before committing to full sweep.
    
    fix: SELECT DISTINCT + ORDER BY random() is illegal in PG ('ORDER BY
    expressions must appear in select list'), but psql exits 0 silently
    on SQL errors by default — bulk-tag queries were returning empty
    strings, fetchBads returned just the 13 gold rows. Two-part fix:
    (1) psql helper now passes -v ON_ERROR_STOP=1 so syntax errors fail
    loudly, (2) bulk-tag query wraps DISTINCT in an inner subquery + sorts
    random in the outer.
    
    fix: live-queue query was hitting needs_fixing_at on spoon_all_designs
    view (which doesn't expose that column — view is a strict subset of
    all_designs). Switched FROM to all_designs directly + added user_removed
    guard for the proper 'undecided' filter.
    
    verified: dry-run now returns 500 bads + 226 goods + 200 live = 838
    designs across 56 batches (vs previous broken 13+226+0=239).
---
 scripts/queue-haiku-yolo-tasks.js    | 287 +++++++++++++++++++++++++++++++++++
 scripts/test-haiku-vision-weekend.js |  25 ++-
 2 files changed, 304 insertions(+), 8 deletions(-)

diff --git a/scripts/queue-haiku-yolo-tasks.js b/scripts/queue-haiku-yolo-tasks.js
new file mode 100644
index 0000000..85bc372
--- /dev/null
+++ b/scripts/queue-haiku-yolo-tasks.js
@@ -0,0 +1,287 @@
+#!/usr/bin/env node
+/**
+ * queue-haiku-yolo-tasks — fan the weekend defect-scan into ~/.claude/yolo-queue
+ * tasks so the loop processes them under Steve's Max plan (not the developer API).
+ *
+ * Why a separate runner from test-haiku-vision-weekend.js: the API runner uses
+ * `x-api-key` against /v1/messages. The Max plan can only be tapped via Claude
+ * Code (this loop). Same sampling logic, same rubric, same JSONL output — just
+ * a different execution substrate.
+ *
+ * Each generated task:
+ *   - Lists 15 designs (id, category, kind, abs path to PNG)
+ *   - Tells the spawned Claude Code worker to Read each PNG and classify it
+ *   - Writes one JSONL row per design appended to the shared JSONL
+ *
+ * Sample sizing — Max plan is rate-limited per 5-hour window, so each task
+ * burns ~100-200K context tokens (mostly image vision). At 15 designs/task,
+ * 1,200 designs = 80 tasks. The loop processes ~one task every 10-15 minutes,
+ * so the full sweep takes ~12-20 hours of loop time spread across a week.
+ *
+ * --smoke (default ON) — only queues 3 tasks (~45 designs) for the initial
+ * verification pass. After eyeballing those results, pass --full to commit
+ * to the rest.
+ *
+ * Usage:
+ *   node scripts/queue-haiku-yolo-tasks.js                       # dry-run preview, smoke scope
+ *   node scripts/queue-haiku-yolo-tasks.js --commit              # actually queue 3 smoke tasks
+ *   node scripts/queue-haiku-yolo-tasks.js --commit --full       # queue all ~80 tasks
+ *   node scripts/queue-haiku-yolo-tasks.js --commit --batch-size 10 --bads 200 --goods 200 --live 50
+ */
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+// ── args ──────────────────────────────────────────────────────────────
+const argv = process.argv.slice(2);
+const ARG = (k, d) => { const i = argv.indexOf('--' + k); return i >= 0 ? argv[i + 1] : d; };
+const HAS = k => argv.includes('--' + k);
+
+const opt = {
+  commit:      HAS('commit'),
+  full:        HAS('full'),
+  batchSize:   parseInt(ARG('batch-size', '15'), 10),
+  bads:        parseInt(ARG('bads', '500'), 10),
+  goods:       parseInt(ARG('goods', '500'), 10),
+  live:        parseInt(ARG('live', '200'), 10),
+  priority:    parseInt(ARG('priority', '60'), 10),    // task-filename prefix; lower runs first
+  smokeBatches: parseInt(ARG('smoke-batches', '3'), 10),
+  jsonl:       ARG('jsonl', `data/haiku-weekend-${new Date().toISOString().slice(0,10)}.jsonl`),
+  goodsSince:  ARG('goods-since', '2026-05-27'),
+  queueDir:    ARG('queue-dir', path.join(process.env.HOME, '.claude', 'yolo-queue', 'tasks')),
+};
+
+// ── PG ────────────────────────────────────────────────────────────────
+// ON_ERROR_STOP=1 — psql exits 0 by default even on SQL errors (silent corruption);
+// without this, a syntax error returns 0 rows and the caller sees an "empty" result.
+function psql(sql) {
+  const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-v', 'ON_ERROR_STOP=1'], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(`psql failed (exit ${r.status}): ${r.stderr}`);
+  return r.stdout.split('\n').filter(Boolean).map(line => line.split('|'));
+}
+
+// Same sampling logic as test-haiku-vision-weekend.js — keep them in sync.
+function fetchBads(target) {
+  const goldTags = ['repeat_break_lr', 'repeat_break_tb', 'auto_seam_break', 'manual_bad_region'];
+  const bulkTags = ['ghost', 'bleed', 'edges_fail'];
+
+  const goldSql = `
+    SELECT DISTINCT b.design_id, b.category, array_to_string(b.defect_tags,','), d.kind, b.local_path
+    FROM bad_examples b
+    JOIN spoon_all_designs d ON d.id = b.design_id
+    WHERE b.local_path IS NOT NULL
+      AND b.defect_tags && ARRAY['${goldTags.join("','")}']::text[]`;
+  const gold = psql(goldSql);
+  const goldIds = new Set(gold.map(r => r[0]));
+  const remaining = Math.max(0, target - gold.length);
+  const perTag = Math.ceil(remaining / bulkTags.length);
+
+  let bulk = [];
+  for (const tag of bulkTags) {
+    // SELECT DISTINCT + ORDER BY random() is illegal in PG ("ORDER BY expressions
+    // must appear in select list"), so DISTINCT in inner subquery, random sort outer.
+    const sql = `
+      SELECT * FROM (
+        SELECT DISTINCT b.design_id, b.category, array_to_string(b.defect_tags,','), d.kind, b.local_path
+        FROM bad_examples b
+        JOIN spoon_all_designs d ON d.id = b.design_id
+        WHERE b.local_path IS NOT NULL
+          AND '${tag}' = ANY(b.defect_tags)
+      ) dedup
+      ORDER BY random()
+      LIMIT ${perTag * 2}`;
+    const rows = psql(sql).filter(r => !goldIds.has(r[0]));
+    bulk = bulk.concat(rows.slice(0, perTag));
+    rows.slice(0, perTag).forEach(r => goldIds.add(r[0]));
+  }
+
+  return [...gold, ...bulk.slice(0, remaining)].map(r => ({
+    id: parseInt(r[0], 10), category: r[1] || null, defect_tags: r[2] ? r[2].split(',') : [],
+    kind: r[3] || null, local_path: r[4], bucket: 'bad',
+  }));
+}
+
+function fetchGoods(target) {
+  const sql = `
+    SELECT id, category, kind, local_path
+    FROM spoon_all_designs
+    WHERE is_published = TRUE AND local_path IS NOT NULL
+      AND created_at >= '${opt.goodsSince}'::date
+      AND id NOT IN (SELECT DISTINCT design_id FROM bad_examples)
+    ORDER BY random() LIMIT ${target}`;
+  return psql(sql).map(r => ({
+    id: parseInt(r[0], 10), category: r[1] || null, kind: r[2] || null,
+    local_path: r[3], defect_tags: [], bucket: 'good',
+  }));
+}
+
+function fetchLive(target) {
+  // spoon_all_designs is a view that doesn't expose needs_fixing_at — query the
+  // underlying table directly to get the "undecided" filter (not approved, not
+  // parked for fix, not user-removed).
+  const sql = `
+    SELECT id, category, kind, local_path
+    FROM all_designs
+    WHERE local_path IS NOT NULL
+      AND (is_published IS NULL OR is_published = FALSE)
+      AND needs_fixing_at IS NULL
+      AND (user_removed IS NULL OR user_removed = FALSE)
+      AND id NOT IN (SELECT DISTINCT design_id FROM bad_examples)
+    ORDER BY created_at DESC LIMIT ${target}`;
+  return psql(sql).map(r => ({
+    id: parseInt(r[0], 10), category: r[1] || null, kind: r[2] || null,
+    local_path: r[3], defect_tags: [], bucket: 'live',
+  }));
+}
+
+// ── existing JSONL (skip already-verdicted ids) ───────────────────────
+function loadDoneIds(jsonlPath) {
+  if (!fs.existsSync(jsonlPath)) return new Set();
+  const done = new Set();
+  for (const line of fs.readFileSync(jsonlPath, 'utf8').split('\n')) {
+    if (!line.trim()) continue;
+    try {
+      const r = JSON.parse(line);
+      if (r.id && r.verdict) done.add(r.id);
+    } catch {}
+  }
+  return done;
+}
+
+// ── task body ─────────────────────────────────────────────────────────
+const RUBRIC = `## Rubric (multi-label, one per design)
+
+- **OK** — clean, no production defects.
+- **GHOST** — translucent/partial-opacity layers, ghosted shapes behind main motif, multi-opacity print artifacts. Solid screen-print is the standard.
+- **FUZZY_SEAM** — a blurred/smeared horizontal/vertical band (~12 px) across the tile midline, or fuzzy heal artifacts at the edges. Distinct from a sharp clean seam.
+- **TONE_BREAK** — pattern broken at L/R or T/B edges; motifs sliced at the wrap and don't match across the seam. Subtle on tone-on-tone designs. (N/A for murals.)
+- **MURAL_MISCLASS** — image is actually a single-subject mural/scenic, NOT a repeating tile, despite being categorized as tile. (N/A if kind is already mural_panel/mural.)
+- **VENDOR_LEAK** — visible text containing a vendor brand name (Schumacher, Brunschwig, Lee Jofa, Thibaut, Kravet, Arte, Cole & Son, Wolf-Gordon, etc.).
+- **BLEED** — color bleeds outside motif boundary or smears at edges.
+- **OTHER** — some other quality issue; specify briefly in the reason.`;
+
+function buildTaskBody(batchIdx, totalBatches, items, jsonlAbsPath) {
+  const designLines = items.map((d, i) => {
+    const isMural = (d.kind === 'mural_panel' || d.kind === 'mural');
+    return `${i+1}. **#${d.id}** — bucket=${d.bucket}, category=\`${d.category || '(none)'}\`, kind=\`${d.kind || '(none)'}\` (${isMural ? 'MURAL — single image, not tileable' : 'TILE — should repeat seamlessly L↔R and T↔B'})${d.defect_tags?.length ? `, defect_tags=[${d.defect_tags.join(',')}]` : ''}\n   path=\`${d.local_path}\``;
+  }).join('\n\n');
+
+  return `# Haiku-vision weekend recall test — batch ${batchIdx + 1} of ${totalBatches}
+
+You are a wallpaper-design defect classifier for the wallco-ai weekend recall test (Steve 2026-05-29). This is a classification audit — **DO NOT modify any design file, DO NOT touch \`is_published\`, DO NOT call /api/admin/*, DO NOT regenerate or fix anything**. Just look, classify, append.
+
+## What to do
+
+For EACH of the ${items.length} designs below:
+
+1. Use the **Read** tool on the design's PNG path to view the image.
+2. Apply the rubric to classify it as EXACTLY ONE label.
+3. Compose a single JSONL row (one line, no pretty-printing) capturing the verdict.
+
+After classifying ALL ${items.length} designs, **append all rows** to:
+
+\`${jsonlAbsPath}\`
+
+via a single bash heredoc (\`cat <<'EOF' >> ${jsonlAbsPath} ... EOF\`). Use \`>>\` not \`>\` so you don't clobber prior batches. **Do this in one bash call** to keep the append atomic per task.
+
+## JSONL row schema (one line each)
+
+\`\`\`json
+{"id": <int>, "bucket": "<bad|good|live>", "category": "<str|null>", "kind": "<str|null>", "defect_tags": [<strings>], "verdict": "<LABEL>", "reason": "<one sentence, <=25 words>", "ts": "<ISO8601>", "via": "max-plan-yolo", "batch": ${batchIdx + 1}}
+\`\`\`
+
+${RUBRIC}
+
+## Designs to classify (${items.length})
+
+${designLines}
+
+## When done
+
+Output a single concluding **## What Landed** block:
+- count of verdicts written
+- verdict histogram (e.g. \`OK: 9, FUZZY_SEAM: 3, GHOST: 2, OTHER: 1\`)
+- the JSONL path
+- any designs you skipped and why (e.g. file missing on disk)
+
+That's the whole task. Do not invoke other skills, do not call /dtd, do not branch — just read, classify, append, summarize.
+`;
+}
+
+// ── main ──────────────────────────────────────────────────────────────
+function main() {
+  console.log(`\n══ queue-haiku-yolo-tasks ══`);
+  console.log(`  Mode:        ${opt.commit ? '🔴 COMMIT' : '🟡 DRY-RUN'}  ${opt.full ? '(FULL sweep)' : '(SMOKE only — ' + opt.smokeBatches + ' batches)'}`);
+  console.log(`  Batch size:  ${opt.batchSize} designs/task`);
+  console.log(`  Queue dir:   ${opt.queueDir}`);
+  console.log(`  JSONL:       ${opt.jsonl}\n`);
+
+  if (!fs.existsSync(opt.queueDir)) throw new Error(`queue dir not found: ${opt.queueDir} — is the yolo-loop installed?`);
+  const stopMarker = path.join(opt.queueDir, '..', 'STOP');
+  if (fs.existsSync(stopMarker)) console.log(`⚠  STOP marker exists at ${stopMarker} — loop will exit after current task. Tasks will pile up.`);
+
+  console.log(`Sampling…`);
+  const bads  = fetchBads(opt.bads);
+  const goods = fetchGoods(opt.goods);
+  const live  = fetchLive(opt.live);
+  console.log(`  bad_examples:  ${bads.length}`);
+  console.log(`  known-goods:   ${goods.length} (post-${opt.goodsSince})`);
+  console.log(`  live-queue:    ${live.length}`);
+
+  // Skip already-verdicted ids
+  const jsonlAbsPath = path.isAbsolute(opt.jsonl) ? opt.jsonl : path.join(__dirname, '..', opt.jsonl);
+  fs.mkdirSync(path.dirname(jsonlAbsPath), { recursive: true });
+  const done = loadDoneIds(jsonlAbsPath);
+  if (done.size) console.log(`  resumable: ${done.size} ids already verdicted in ${opt.jsonl}\n`);
+
+  const all = [...bads, ...goods, ...live]
+    .filter(d => !done.has(d.id))
+    .filter(d => fs.existsSync(d.local_path));  // orphan filter at queue time
+
+  console.log(`  total to queue (after skip+orphan-filter): ${all.length}`);
+
+  // Batch
+  const batches = [];
+  for (let i = 0; i < all.length; i += opt.batchSize) {
+    batches.push(all.slice(i, i + opt.batchSize));
+  }
+  const queueBatches = opt.full ? batches : batches.slice(0, opt.smokeBatches);
+  console.log(`  total batches: ${batches.length}  ·  queueing now: ${queueBatches.length}`);
+  console.log(`  designs in queue: ${queueBatches.reduce((s,b) => s + b.length, 0)} (of ${all.length})\n`);
+
+  if (!opt.commit) {
+    console.log(`🟡 DRY-RUN. Sample of first batch:`);
+    queueBatches.slice(0, 1)[0]?.slice(0, 3).forEach((d,i) => console.log(`  ${i+1}. #${d.id} ${d.bucket}/${d.kind || '?'}/${d.category || '?'} → ${d.local_path}`));
+    console.log(`\nPass --commit to actually write task files.`);
+    if (!opt.full) console.log(`Pass --commit --full to queue the entire sweep (${batches.length} batches).`);
+    return;
+  }
+
+  // Write tasks
+  const stamp = new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);  // YYYYMMDDTHHMM
+  let written = 0;
+  for (let i = 0; i < queueBatches.length; i++) {
+    const taskNum = String(i + 1).padStart(3, '0');
+    const fname = `${opt.priority}-haiku-vision-${stamp}-${taskNum}.md`;
+    const fpath = path.join(opt.queueDir, fname);
+    const body = buildTaskBody(i, queueBatches.length, queueBatches[i], jsonlAbsPath);
+    fs.writeFileSync(fpath, body);
+    written++;
+    if (i < 3 || i === queueBatches.length - 1) {
+      console.log(`  ✓ ${fname}  (${queueBatches[i].length} designs)`);
+    } else if (i === 3) {
+      console.log(`  … (${queueBatches.length - 4} more)`);
+    }
+  }
+  console.log(`\n══ ${written} task files written to ${opt.queueDir} ══`);
+  console.log(`\nThe yolo-loop will pick them up at ~one task every 10-15 minutes`);
+  console.log(`(currently polling every 60s, queue was empty).\n`);
+  console.log(`Monitor:`);
+  console.log(`  tail -f ~/.claude/yolo-queue/loop.log`);
+  console.log(`  wc -l ${opt.jsonl}                 # rows written so far`);
+  console.log(`  ls ~/.claude/yolo-queue/done/${opt.priority}-haiku-vision-* 2>/dev/null | wc -l    # tasks completed`);
+  console.log(`\nWhen ${opt.full ? 'all batches complete' : 'smoke completes (~30-45 min)'}, run:`);
+  console.log(`  node scripts/haiku-weekend-summary.js`);
+}
+
+try { main(); } catch (e) { console.error('fatal:', e.message); process.exit(1); }
diff --git a/scripts/test-haiku-vision-weekend.js b/scripts/test-haiku-vision-weekend.js
index 100dbd5..32e5acd 100644
--- a/scripts/test-haiku-vision-weekend.js
+++ b/scripts/test-haiku-vision-weekend.js
@@ -70,9 +70,11 @@ function loadApiKey() {
 }
 
 // ── PG helpers ────────────────────────────────────────────────────────
+// ON_ERROR_STOP=1 — psql exits 0 by default even on SQL errors (silent corruption);
+// without this, a syntax error returns 0 rows and the caller sees an "empty" result.
 function psql(sql) {
-  const r = spawnSync('psql', ['dw_unified', '-At', '-q'], { input: sql, encoding: 'utf8' });
-  if (r.status !== 0) throw new Error(`psql failed: ${r.stderr}`);
+  const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-v', 'ON_ERROR_STOP=1'], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(`psql failed (exit ${r.status}): ${r.stderr}`);
   return r.stdout.split('\n').filter(Boolean).map(line => line.split('|'));
 }
 
@@ -99,12 +101,16 @@ function fetchBads(target) {
   const perTag = Math.ceil(remaining / bulkTags.length);
   let bulk = [];
   for (const tag of bulkTags) {
+    // SELECT DISTINCT + ORDER BY random() is illegal in PG ("ORDER BY expressions
+    // must appear in select list"), so DISTINCT in inner subquery, random sort outer.
     const sql = `
-      SELECT DISTINCT b.design_id, b.category, array_to_string(b.defect_tags,','), d.kind, b.local_path
-      FROM bad_examples b
-      JOIN spoon_all_designs d ON d.id = b.design_id
-      WHERE b.local_path IS NOT NULL
-        AND '${tag}' = ANY(b.defect_tags)
+      SELECT * FROM (
+        SELECT DISTINCT b.design_id, b.category, array_to_string(b.defect_tags,','), d.kind, b.local_path
+        FROM bad_examples b
+        JOIN spoon_all_designs d ON d.id = b.design_id
+        WHERE b.local_path IS NOT NULL
+          AND '${tag}' = ANY(b.defect_tags)
+      ) dedup
       ORDER BY random()
       LIMIT ${perTag * 2}`;
     const rows = psql(sql).filter(r => !goldIds.has(r[0]));
@@ -136,12 +142,15 @@ function fetchGoods(target) {
 
 function fetchLive(target) {
   // Latest curator queue — neither flagged nor explicitly approved.
+  // spoon_all_designs is a view that doesn't expose needs_fixing_at — query the
+  // underlying table directly so the "undecided" filter actually works.
   const sql = `
     SELECT id, category, kind, local_path
-    FROM spoon_all_designs
+    FROM all_designs
     WHERE local_path IS NOT NULL
       AND (is_published IS NULL OR is_published = FALSE)
       AND needs_fixing_at IS NULL
+      AND (user_removed IS NULL OR user_removed = FALSE)
       AND id NOT IN (SELECT DISTINCT design_id FROM bad_examples)
     ORDER BY created_at DESC
     LIMIT ${target}`;

← d335cbc Add rollback-photoreal-rooms.py: regenerate living_room mock  ·  back to Wallco Ai  ·  feat(designs-grid): Contact sheet button on admin bulk-selec 9b7f1eb →