[object Object]

← back to Wallco Ai

Stage 4: scan-composition.js + gen-poodle-demo.js

a992ad5cab47c133afab28a4f5fc67fbaaf05eed · 2026-05-24 21:16:35 -0700 · Steve Abrams

scan-composition.js — sweeps spoon_all_designs via lib/composition-detector
to find centered-hero composition defects across the catalog. Sister to
scan-ghost-layer.js with the same arg shape (--limit, --concurrency,
--category, --source, --min-instances, --stable), same resume semantics
(skips ids already in results.jsonl), same vendor-cat filter (Steve's
standing rule — wallco-AI generations only).

Output:
  data/composition-scan-results.jsonl — { id, is_repeating_field,
    instance_count, hero_centered, confidence, what_you_see, ... }
  data/composition-scan-flagged.jsonl — fails by hero_centered=true OR
    instance_count<min_instances, with flagged_reason + threshold encoded
    inline so the log can be re-thresholded later without re-running Gemini.

Smoke-tested on 20 most-recent designs: 20/20 PASS rep, none flagged
(expected — top of the catalog is mostly fresh smartfix-v2 outputs).

gen-poodle-demo.js — one-off demo that produces a fresh wallpaper tile
using the new lib/repeat-prompt + lib/composition-detector pipeline
end-to-end. Inserts as a new PG row. Used to validate the full pipeline
on a never-seen motif (poodle); successfully produced design 39347 with
9 instances in diamond half-drop, first attempt.

Files touched

Diff

commit a992ad5cab47c133afab28a4f5fc67fbaaf05eed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 21:16:35 2026 -0700

    Stage 4: scan-composition.js + gen-poodle-demo.js
    
    scan-composition.js — sweeps spoon_all_designs via lib/composition-detector
    to find centered-hero composition defects across the catalog. Sister to
    scan-ghost-layer.js with the same arg shape (--limit, --concurrency,
    --category, --source, --min-instances, --stable), same resume semantics
    (skips ids already in results.jsonl), same vendor-cat filter (Steve's
    standing rule — wallco-AI generations only).
    
    Output:
      data/composition-scan-results.jsonl — { id, is_repeating_field,
        instance_count, hero_centered, confidence, what_you_see, ... }
      data/composition-scan-flagged.jsonl — fails by hero_centered=true OR
        instance_count<min_instances, with flagged_reason + threshold encoded
        inline so the log can be re-thresholded later without re-running Gemini.
    
    Smoke-tested on 20 most-recent designs: 20/20 PASS rep, none flagged
    (expected — top of the catalog is mostly fresh smartfix-v2 outputs).
    
    gen-poodle-demo.js — one-off demo that produces a fresh wallpaper tile
    using the new lib/repeat-prompt + lib/composition-detector pipeline
    end-to-end. Inserts as a new PG row. Used to validate the full pipeline
    on a never-seen motif (poodle); successfully produced design 39347 with
    9 instances in diamond half-drop, first attempt.
---
 scripts/gen-poodle-demo.js  | 120 ++++++++++++++++++++++++
 scripts/scan-composition.js | 218 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 338 insertions(+)

diff --git a/scripts/gen-poodle-demo.js b/scripts/gen-poodle-demo.js
new file mode 100644
index 0000000..44dd11c
--- /dev/null
+++ b/scripts/gen-poodle-demo.js
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+// gen-poodle-demo — generate ONE new design using the canonical
+// lib/repeat-prompt builder + composition gate. Demonstrates the new
+// generation schema end-to-end and inserts the result into PG.
+//
+// Usage:  node scripts/gen-poodle-demo.js
+//
+// Motif fixed to "standard poodle silhouette" so the result is comparable
+// to the broken 39328 / 37987 outputs (both centered-hero defects).
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { buildRepeatPrompt, inferDensity } = require('../lib/repeat-prompt');
+const { gateComposition } = require('../lib/composition-detector');
+
+const ROOT = path.join(__dirname, '..');
+const GEN_DIR = path.join(ROOT, 'data', 'generated');
+
+function readKey() {
+  if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
+  const env = fs.readFileSync(path.join(ROOT, '.env'), 'utf8');
+  const m = env.match(/^GEMINI_API_KEY=(.+)$/m);
+  if (!m) throw new Error('GEMINI_API_KEY missing');
+  return m[1].trim();
+}
+
+async function geminiImage(prompt) {
+  const KEY = readKey();
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${KEY}`;
+  const r = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { responseModalities: ['IMAGE'] },
+    }),
+  });
+  if (!r.ok) throw new Error(`gemini ${r.status}: ${(await r.text()).slice(0, 200)}`);
+  const j = await r.json();
+  const part = j?.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+  const b64 = part?.inline_data?.data || part?.inlineData?.data;
+  if (!b64) throw new Error('gemini returned no image');
+  return b64;
+}
+
+function psqlInsert(sql) {
+  const r = spawnSync('psql', ['dw_unified', '-At', '-q'], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(r.stderr || 'psql failed');
+  return r.stdout.trim();
+}
+
+(async () => {
+  // Motif description — written exactly as the smart-fix `detect` step would
+  // emit for an existing poodle design. Lib/repeat-prompt wraps it into
+  // the multi-instance language.
+  const motifDescription = 'a standard poodle silhouette standing in three-quarter profile, classic show clip with rounded pompoms on legs and tail, single solid color silhouette';
+  const groundHex = '#f5efe5';
+  const groundDesc = 'antique cream';
+  const density = inferDensity(motifDescription);
+  console.log(`[gen-poodle] motif: ${motifDescription.slice(0, 70)}...`);
+  console.log(`[gen-poodle] inferred density: ${density}`);
+  console.log(`[gen-poodle] ground: ${groundDesc} ${groundHex}`);
+
+  let candidate = null, gate = null, attempt = 0;
+  const MAX = 3;
+  while (attempt < MAX) {
+    const prompt = buildRepeatPrompt({ motifDescription, groundHex, groundDesc, density, attempt });
+    console.log(`\n[gen-poodle] attempt ${attempt} · layout=${['diamond','brick','drop-half','grid'][attempt%4]}`);
+    const t0 = Date.now();
+    const b64 = await geminiImage(prompt);
+    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+    console.log(`  gen: ${elapsed}s · ${b64.length} b64 chars`);
+    // Composition gate
+    gate = await gateComposition(b64, { minInstances: 3 });
+    console.log(`  composition: ${gate.ok ? 'PASS' : 'FAIL'} · ${gate.reason} · n=${gate.result.instance_count} hero=${gate.result.hero_centered}`);
+    console.log(`  what_you_see: ${gate.result.what_you_see}`);
+    if (gate.ok) { candidate = b64; break; }
+    attempt++;
+  }
+  if (!candidate) {
+    console.log(`\n[gen-poodle] gate failed all ${MAX} attempts — accepting last candidate anyway`);
+    // Re-fetch the last for saving
+    const fallbackPrompt = buildRepeatPrompt({ motifDescription, groundHex, groundDesc, density, attempt: MAX - 1 });
+    candidate = await geminiImage(fallbackPrompt);
+  }
+
+  const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+  const filename = `poodle_demo_${Date.now()}_${newSeed}.png`;
+  const outPath = path.join(GEN_DIR, filename);
+  fs.writeFileSync(outPath, Buffer.from(candidate, 'base64'));
+  console.log(`\n[gen-poodle] saved → ${outPath}`);
+
+  const esc = v => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+  const sql = `
+INSERT INTO spoon_all_designs
+  (kind, brand, width_in, height_in, generator, prompt, seed, image_url, local_path,
+   dominant_hex, palette, motifs, tags, category, is_published)
+VALUES
+  ('seamless_tile', 'wallco.ai', 24, 24,
+   'gemini-2.5-flash-image-repeat-v2',
+   ${esc('repeat-v2 demo: ' + motifDescription)},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc(groundHex)},
+   ${esc(JSON.stringify([groundHex]))}::jsonb,
+   NULL, NULL,
+   ${esc('designer-zoo-calm')},
+   TRUE)
+RETURNING id;`;
+  const newId = parseInt(psqlInsert(sql), 10);
+  if (!newId) throw new Error('insert returned no id');
+  spawnSync('psql', ['dw_unified', '-q', '-c',
+    `UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}' WHERE id=${newId}`],
+    { encoding: 'utf8' });
+  console.log(`[gen-poodle] PG id: ${newId}`);
+  console.log(`[gen-poodle] view: http://127.0.0.1:9877/design/${newId}`);
+  console.log(`[gen-poodle] composition: ${JSON.stringify(gate?.result || {})}`);
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
diff --git a/scripts/scan-composition.js b/scripts/scan-composition.js
new file mode 100644
index 0000000..6cd12cd
--- /dev/null
+++ b/scripts/scan-composition.js
@@ -0,0 +1,218 @@
+#!/usr/bin/env node
+// scan-composition — sweep the catalog for centered-hero composition defects.
+//
+// Sister script to scan-ghost-layer. Iterates spoon_all_designs (published,
+// has local_path), classifies each image via lib/composition-detector,
+// appends one JSONL row per design to data/composition-scan-results.jsonl.
+// Resumable — skips IDs already in the results file so SIGINT + restart is safe.
+//
+// A "composition defect" = the design is a single centered hero motif with
+// wrap-half artefacts at the corners, NOT a multi-instance repeating field.
+// Both can be mathematically tileable; only the latter is a real wallpaper.
+//
+// Usage:
+//   node scripts/scan-composition.js                 # all published designs
+//   node scripts/scan-composition.js --limit 200     # smoke test
+//   node scripts/scan-composition.js --concurrency 8 # parallel workers (default 4)
+//   node scripts/scan-composition.js --category cactus  # restrict by category
+//   node scripts/scan-composition.js --min-instances 4  # raise the bar (default 3)
+//   node scripts/scan-composition.js --stable         # 3-vote majority per item (3x cost)
+//
+// Output:
+//   data/composition-scan-results.jsonl — one line per design:
+//     { id, ts, is_repeating_field, instance_count, hero_centered, confidence,
+//       what_you_see, category, source, image_url }
+//   data/composition-scan-flagged.jsonl — append-only flagged subset
+//     (centered hero OR instance_count < min_instances).
+//
+// Cost estimate: ~$0.0005/check × N items. For 22k items at concurrency 4,
+// roughly $11 + 1-2 hours wall time. Add --stable for 3x cost.
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { analyzeComposition, analyzeCompositionStable } = require('../lib/composition-detector');
+
+// ── args ────────────────────────────────────────────────────────────────
+const ARGS = process.argv.slice(2);
+function arg(name, def) {
+  const i = ARGS.indexOf('--' + name);
+  return i >= 0 ? ARGS[i + 1] : def;
+}
+const LIMIT         = parseInt(arg('limit', '0'), 10);
+const CONCURRENCY   = Math.max(1, parseInt(arg('concurrency', '4'), 10));
+const CATEGORY      = arg('category', null);
+const MIN_INSTANCES = Math.max(2, parseInt(arg('min-instances', '3'), 10));
+const STABLE        = ARGS.includes('--stable');
+const SOURCE        = (arg('source', 'both') || 'both').toLowerCase();
+
+const ROOT = path.join(__dirname, '..');
+const RESULTS = path.join(ROOT, 'data', 'composition-scan-results.jsonl');
+const FLAGGED = path.join(ROOT, 'data', 'composition-scan-flagged.jsonl');
+
+// Vendor-imported categories — Steve's standing rule: scan only wallco-AI
+// generations. Real designer products from Shopify/etc. keep their (intentional)
+// composition choices. Mirrors scan-ghost-layer.js after the 2026-05-24
+// false-positive incident on real vendor SKUs.
+const VENDOR_CATS = new Set([
+  'thibaut','dolce-gabbana','koroseal','graduate-collection','traditional-whimsy',
+  'phillipe-romano','coordonn-','ralph-lauren','mind-the-gap','designer-wallcoverings',
+  'malibu-wallpaper','maya-romanoff','roberto-cavalli-wallpaper','china-seas',
+  'daisy-bennett','glitter-walls','dw-shopify',
+]);
+
+function psql(sql) {
+  const onLinux = process.platform === 'linux';
+  const cmd  = onLinux ? 'sudo' : 'psql';
+  const args = onLinux
+    ? ['-n', '-u', 'postgres', 'psql', 'dw_unified', '-At', '-q']
+    : ['dw_unified', '-At', '-q'];
+  const r = spawnSync(cmd, args, { input: sql, encoding: 'utf8', maxBuffer: 50_000_000 });
+  if (r.status !== 0) throw new Error(r.stderr || r.stdout || 'psql failed');
+  return r.stdout;
+}
+
+function loadScanned() {
+  if (!fs.existsSync(RESULTS)) return new Set();
+  const ids = new Set();
+  for (const line of fs.readFileSync(RESULTS, 'utf8').split('\n')) {
+    if (!line.trim()) continue;
+    try { ids.add(JSON.parse(line).id); } catch {}
+  }
+  return ids;
+}
+
+function fetchPgTargets() {
+  const where = [
+    'is_published = TRUE',
+    'user_removed IS NOT TRUE',
+    'local_path IS NOT NULL',
+  ];
+  if (CATEGORY) where.push(`category = '${CATEGORY.replace(/'/g, "''")}'`);
+  const sql = `SELECT id, category, local_path
+               FROM spoon_all_designs
+               WHERE ${where.join(' AND ')}
+               ORDER BY id DESC`;
+  const raw = psql(sql);
+  const rows = [];
+  for (const line of raw.split('\n')) {
+    if (!line.trim()) continue;
+    const [id, category, local_path] = line.split('|');
+    if (VENDOR_CATS.has((category || '').toLowerCase())) continue;
+    rows.push({ id: parseInt(id, 10), category, local_path, source: 'pg' });
+  }
+  return rows;
+}
+
+const GENERATED_DIR = path.join(ROOT, 'data', 'generated');
+
+function fetchJsonTargets(excludeIds) {
+  const f = path.join(ROOT, 'data', 'designs.json');
+  if (!fs.existsSync(f)) return [];
+  const arr = JSON.parse(fs.readFileSync(f, 'utf8'));
+  const rows = [];
+  for (const d of arr) {
+    if (excludeIds.has(d.id)) continue;
+    if (!d.id) continue;
+    if (CATEGORY && d.category !== CATEGORY) continue;
+    if (VENDOR_CATS.has((d.category || '').toLowerCase())) continue;
+    let diskPath = null;
+    if (d.image_url && d.image_url.startsWith('/designs/img/')) {
+      const fn = d.image_url.replace(/^\/designs\/img\//, '');
+      const candidate = path.join(GENERATED_DIR, fn);
+      if (fs.existsSync(candidate)) diskPath = candidate;
+    }
+    if (!diskPath) continue;
+    rows.push({ id: d.id, category: d.category, local_path: diskPath, image_url: d.image_url, source: 'json' });
+  }
+  return rows;
+}
+
+function fetchTargets() {
+  let rows = [];
+  if (SOURCE === 'pg' || SOURCE === 'both') rows = rows.concat(fetchPgTargets());
+  if (SOURCE === 'json' || SOURCE === 'both') {
+    const have = new Set(rows.map(r => r.id));
+    rows = rows.concat(fetchJsonTargets(have));
+  }
+  if (LIMIT > 0) rows = rows.slice(0, LIMIT);
+  return rows;
+}
+
+async function processOne(row) {
+  if (!row.local_path || !fs.existsSync(row.local_path)) {
+    return { id: row.id, ts: new Date().toISOString(), error: 'file_missing', category: row.category, source: row.source };
+  }
+  try {
+    const r = STABLE
+      ? await analyzeCompositionStable(row.local_path)
+      : await analyzeComposition(row.local_path);
+    return {
+      id: row.id, ts: new Date().toISOString(),
+      is_repeating_field: r.is_repeating_field,
+      instance_count:     r.instance_count,
+      hero_centered:      r.hero_centered,
+      confidence:         r.confidence,
+      what_you_see:       r.what_you_see,
+      ...(STABLE ? { unanimous: r.unanimous, votes: r.votes } : {}),
+      category:           row.category,
+      source:             row.source,
+      image_url:          `/designs/img/by-id/${row.id}`,
+    };
+  } catch (e) {
+    return { id: row.id, ts: new Date().toISOString(), error: e.message.slice(0, 200), category: row.category, source: row.source };
+  }
+}
+
+async function runPool(rows, workers, onResult) {
+  let cursor = 0;
+  const next = () => (cursor < rows.length ? rows[cursor++] : null);
+  async function workerLoop() {
+    while (true) {
+      const r = next();
+      if (!r) return;
+      const out = await processOne(r);
+      onResult(out);
+    }
+  }
+  await Promise.all(Array.from({ length: workers }, workerLoop));
+}
+
+(async () => {
+  console.log(`[scan-composition] starting · limit=${LIMIT || 'all'} concurrency=${CONCURRENCY} category=${CATEGORY || 'all'} minInstances=${MIN_INSTANCES} stable=${STABLE}`);
+  const scanned = loadScanned();
+  console.log(`[scan-composition] already-scanned: ${scanned.size}`);
+  const all = fetchTargets();
+  const todo = all.filter(r => !scanned.has(r.id));
+  console.log(`[scan-composition] eligible: ${all.length}, todo: ${todo.length}`);
+
+  let n = 0, flagged = 0, errored = 0;
+  const t0 = Date.now();
+  const onResult = (out) => {
+    n++;
+    fs.appendFileSync(RESULTS, JSON.stringify(out) + '\n');
+    // Flag if NOT a repeating field per OUR threshold (count < MIN_INSTANCES
+    // OR hero_centered). Don't trust the detector's own is_repeating_field
+    // flag — compute it from raw observations + our local threshold so the
+    // scan log can be re-thresholded later without re-running Gemini.
+    const failsByCount = Number.isFinite(out.instance_count) && out.instance_count < MIN_INSTANCES;
+    const failsByHero  = out.hero_centered === true;
+    if (!out.error && (failsByCount || failsByHero)) {
+      flagged++;
+      fs.appendFileSync(FLAGGED, JSON.stringify({
+        ...out,
+        flagged_reason: failsByHero ? 'centered-hero' : `too-few-instances-(${out.instance_count}<${MIN_INSTANCES})`,
+        min_instances_threshold: MIN_INSTANCES,
+      }) + '\n');
+    }
+    if (out.error) errored++;
+    if (n % 25 === 0 || n === todo.length) {
+      const rate = n / ((Date.now() - t0) / 1000);
+      const eta  = ((todo.length - n) / rate).toFixed(0);
+      console.log(`[scan-composition] ${n}/${todo.length} · ${flagged} flagged · ${errored} errored · ${rate.toFixed(1)}/s · ETA ${eta}s`);
+    }
+  };
+  await runPool(todo, CONCURRENCY, onResult);
+  console.log(`[scan-composition] done · scanned=${n} flagged=${flagged} errored=${errored}`);
+  console.log(`[scan-composition] flagged log: ${FLAGGED}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });

← f5a6676 /design/:id → 302 to newest published child via parent_desig  ·  back to Wallco Ai  ·  remove broken /admin/fixes-feed viewer 5138c6e →