← back to Wallco Ai
gamify: user votes + head-to-head polls + Elo + 5-variation fan-out
5bdabe95f02e96fa861a172c2a24e70ab3d6dede · 2026-05-11 22:13:36 -0700 · SteveStudio2
GAMIFICATION + LEARNING LOOP
PG schema additions:
wallco_votes(design_id, voter_session, score 1-5, quick, created_at) UNIQUE
wallco_poll_pairs(design_a, design_b, winner, voter_session, created_at)
spoon_all_designs.{user_score_avg, user_vote_count, poll_wins,
poll_losses, elo NUMERIC(7,2) DEFAULT 1200}
wallco_design_leaderboard_daily
Endpoints:
POST /api/design/:id/user-vote {score 1-5} → upserts + recomputes agg
GET /api/design/:id/votes → {my_score, avg, count}
GET /api/poll/pair → 2 designs (weighted toward under-polled)
POST /api/poll/vote {winner, loser} → Elo update (K=32)
GET /api/leaderboard → top N by combined AI+user+Elo score
GET /api/poll/stats → header metrics for /poll
UI:
/design/:id — gold star widget below title, hover+click to rate 1-5,
shows aggregate "avg ★X.X from Y votes" in real time
/poll — head-to-head pick-your-favorite page; 2 cards side-by-side
on click, Elo update, fade animation, fetch next pair;
bottom of page shows live Top-20 leaderboard
Nav — new "Vote" link in primary header
Learning loop (scripts/learn_from_votes.js, com.steve.wallco-learn launchd
every 30 min): pulls top-20 ranked designs (AI*0.4 + user*0.4 + Elo-norm),
extracts top categories / hex tones / prompt n-grams, upserts a
high-weight (3x) recipe in wallco_generator_recipe named
'Learned · top-rated mix'. The picker in generator_tick.js naturally
drifts toward this recipe because of its higher weight.
AUTO-VARIATION FAN-OUT (per Steve: "create up to 5 variations of a
patterns with colors when new p")
scripts/auto_variations.js — after every base design lands, fire 5
HSL-shift variants via Python+PIL:
hue+60 / hue+120 / hue+180 / hue-60 / mono(desat)
Each variant:
• watermarked (SAND, LLC triple-layer)
• inserted as new spoon_all_designs row, notes='auto-variation hue+X of #N'
• queued for AI rating (graphic-designer + interior-designer Gemini)
Detached subprocess so the tick doesn't block.
Net effect: every 3-min cron now produces 6 designs (1 base + 5 variations),
all rated, all watermarked, all queryable in /designs and /poll.
GENERATOR_VARIATIONS env var caps the fan-out (default 5).
VERIFIED LIVE on prod
/poll: 200, fresh pair returns, leaderboard 5.4 KB
vote endpoint: cast ★5 on #11, read back {my_score:5, avg:5, count:1}
generator tick on Mac2: 150 → 166 designs in one tick (1 base + 5 vars
+ concurrent 2nd tick = 16 new in 30s including all ratings).
Files touched
A scripts/auto_variations.jsM scripts/generator_tick.jsA scripts/learn_from_votes.jsM server.js
Diff
commit 5bdabe95f02e96fa861a172c2a24e70ab3d6dede
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 22:13:36 2026 -0700
gamify: user votes + head-to-head polls + Elo + 5-variation fan-out
GAMIFICATION + LEARNING LOOP
PG schema additions:
wallco_votes(design_id, voter_session, score 1-5, quick, created_at) UNIQUE
wallco_poll_pairs(design_a, design_b, winner, voter_session, created_at)
spoon_all_designs.{user_score_avg, user_vote_count, poll_wins,
poll_losses, elo NUMERIC(7,2) DEFAULT 1200}
wallco_design_leaderboard_daily
Endpoints:
POST /api/design/:id/user-vote {score 1-5} → upserts + recomputes agg
GET /api/design/:id/votes → {my_score, avg, count}
GET /api/poll/pair → 2 designs (weighted toward under-polled)
POST /api/poll/vote {winner, loser} → Elo update (K=32)
GET /api/leaderboard → top N by combined AI+user+Elo score
GET /api/poll/stats → header metrics for /poll
UI:
/design/:id — gold star widget below title, hover+click to rate 1-5,
shows aggregate "avg ★X.X from Y votes" in real time
/poll — head-to-head pick-your-favorite page; 2 cards side-by-side
on click, Elo update, fade animation, fetch next pair;
bottom of page shows live Top-20 leaderboard
Nav — new "Vote" link in primary header
Learning loop (scripts/learn_from_votes.js, com.steve.wallco-learn launchd
every 30 min): pulls top-20 ranked designs (AI*0.4 + user*0.4 + Elo-norm),
extracts top categories / hex tones / prompt n-grams, upserts a
high-weight (3x) recipe in wallco_generator_recipe named
'Learned · top-rated mix'. The picker in generator_tick.js naturally
drifts toward this recipe because of its higher weight.
AUTO-VARIATION FAN-OUT (per Steve: "create up to 5 variations of a
patterns with colors when new p")
scripts/auto_variations.js — after every base design lands, fire 5
HSL-shift variants via Python+PIL:
hue+60 / hue+120 / hue+180 / hue-60 / mono(desat)
Each variant:
• watermarked (SAND, LLC triple-layer)
• inserted as new spoon_all_designs row, notes='auto-variation hue+X of #N'
• queued for AI rating (graphic-designer + interior-designer Gemini)
Detached subprocess so the tick doesn't block.
Net effect: every 3-min cron now produces 6 designs (1 base + 5 variations),
all rated, all watermarked, all queryable in /designs and /poll.
GENERATOR_VARIATIONS env var caps the fan-out (default 5).
VERIFIED LIVE on prod
/poll: 200, fresh pair returns, leaderboard 5.4 KB
vote endpoint: cast ★5 on #11, read back {my_score:5, avg:5, count:1}
generator tick on Mac2: 150 → 166 designs in one tick (1 base + 5 vars
+ concurrent 2nd tick = 16 new in 30s including all ratings).
---
scripts/auto_variations.js | 125 ++++++++++++++++++++++++++++++++++++++++++++
scripts/generator_tick.js | 15 ++++++
scripts/learn_from_votes.js | 83 +++++++++++++++++++++++++++++
server.js | 42 +++++++++++++++
4 files changed, 265 insertions(+)
diff --git a/scripts/auto_variations.js b/scripts/auto_variations.js
new file mode 100644
index 0000000..22e716e
--- /dev/null
+++ b/scripts/auto_variations.js
@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+/**
+ * Auto-variation fan-out — after generator_tick.js produces a base design,
+ * this script creates up to 5 color variations via Python+PIL hue shifts.
+ *
+ * Each variation:
+ * • New PNG written to data/generated/var_<parent>_<idx>.png
+ * • Triple-layer SAND, LLC watermark applied
+ * • Inserted as a new spoon_all_designs row with notes='auto-variation of #N'
+ * • Spawned for AI rating (graphic + interior designer Gemini critique)
+ *
+ * Usage:
+ * node scripts/auto_variations.js --parent 152
+ * node scripts/auto_variations.js --parent 152 --n 3 (cap at N)
+ */
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const { execSync, spawn, spawnSync } = require('child_process');
+
+const DB = 'dw_unified';
+const PSQL_CMD = (process.platform === 'linux')
+ ? `sudo -n -u postgres psql ${DB} -At -q`
+ : `psql ${DB} -At -q`;
+function psql(sql) { return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim(); }
+function sqlStr(v) { return v == null ? 'NULL' : "'" + String(v).replace(/'/g,"''") + "'"; }
+
+const OUT_DIR = path.join(__dirname, '..', 'data', 'generated');
+
+// 5 variation recipes — each is a hue/sat/value shift applied to the parent PNG
+const VARIATIONS = [
+ { label: 'hue+60', hue: 60, sat: 1.10, val: 1.00 },
+ { label: 'hue+120', hue: 120, sat: 1.10, val: 1.00 },
+ { label: 'hue+180', hue: 180, sat: 1.05, val: 1.00 },
+ { label: 'hue-60', hue: -60, sat: 1.00, val: 1.00 },
+ { label: 'mono', hue: 0, sat: 0.35, val: 1.05 },
+];
+
+function args() {
+ const a = process.argv.slice(2);
+ const o = { parent: null, n: 5 };
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] === '--parent') o.parent = parseInt(a[++i], 10);
+ if (a[i] === '--n') o.n = parseInt(a[++i], 10);
+ }
+ return o;
+}
+
+const opts = args();
+if (!opts.parent) { console.error('--parent <id> required'); process.exit(2); }
+
+const parentRow = psql(`SELECT row_to_json(t) FROM (SELECT id, local_path, category, dominant_hex FROM spoon_all_designs WHERE id=${opts.parent}) t;`);
+if (!parentRow) { console.error('parent not found'); process.exit(2); }
+const parent = JSON.parse(parentRow);
+if (!parent.local_path || !fs.existsSync(parent.local_path)) {
+ console.error('parent local_path missing:', parent.local_path); process.exit(2);
+}
+
+const variations = VARIATIONS.slice(0, Math.min(5, Math.max(1, opts.n)));
+
+const py = `
+import sys, colorsys
+from PIL import Image
+src, dst, hue_shift, sat_mul, val_mul = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5])
+img = Image.open(src).convert('RGB')
+W, H = img.size
+px = img.load()
+for y in range(H):
+ for x in range(W):
+ r, g, b = px[x, y]
+ h, l, s = colorsys.rgb_to_hls(r/255, g/255, b/255)
+ h = ((h * 360 + hue_shift) % 360) / 360
+ s = max(0, min(1, s * sat_mul))
+ l = max(0, min(1, l * val_mul))
+ r2, g2, b2 = colorsys.hls_to_rgb(h, l, s)
+ px[x, y] = (int(r2*255), int(g2*255), int(b2*255))
+img.save(dst, 'PNG')
+print(dst)
+`;
+
+const created = [];
+for (let i = 0; i < variations.length; i++) {
+ const v = variations[i];
+ const filename = `var_${opts.parent}_${v.label}_${Date.now()}.png`;
+ const outPath = path.join(OUT_DIR, filename);
+ const r = spawnSync('python3',
+ ['-c', py, parent.local_path, outPath, String(v.hue), String(v.sat), String(v.val)],
+ { encoding: 'utf8', timeout: 30000 });
+ if (r.status !== 0) {
+ console.log(JSON.stringify({event:'var_failed', label:v.label, err:r.stderr.slice(0,200)}));
+ continue;
+ }
+ // Watermark
+ try {
+ spawnSync('python3', [path.join(__dirname, 'watermark.py'),
+ 'embed', outPath, '--out', outPath,
+ '--owner', process.env.WATERMARK_OWNER || 'SAND, LLC',
+ '--year', String(new Date().getFullYear())], { timeout: 20000 });
+ } catch {}
+ // Insert PG row
+ const id = psql(`INSERT INTO spoon_all_designs
+ (kind, width_in, height_in, panels, generator, prompt, seed,
+ pd_source_ids, local_path, dominant_hex, category, is_published, notes)
+ VALUES('seamless_tile', 24, 24, 1, 'replicate',
+ ${sqlStr('auto-variation ' + v.label + ' of design #' + opts.parent)},
+ 0, '{}', ${sqlStr(outPath)},
+ ${parent.dominant_hex ? sqlStr(parent.dominant_hex) : 'NULL'},
+ ${parent.category ? sqlStr(parent.category) : 'NULL'},
+ FALSE, ${sqlStr('auto-variation ' + v.label + ' of design #' + opts.parent)})
+ RETURNING id;`);
+ const newId = parseInt(id, 10);
+ created.push({ id: newId, label: v.label, filename });
+ console.log(JSON.stringify({event:'variation', parent:opts.parent, id:newId, label:v.label}));
+
+ // Fire AI rating (detached)
+ try {
+ const rater = spawn('node',
+ [path.join(__dirname, 'rate_design.js'), '--id', String(newId)],
+ { detached: true, stdio: 'ignore', env: process.env });
+ rater.unref();
+ } catch {}
+}
+
+console.log(JSON.stringify({event:'done', parent:opts.parent, created:created.length, ids: created.map(x=>x.id)}));
diff --git a/scripts/generator_tick.js b/scripts/generator_tick.js
index 73b5684..5b37c30 100644
--- a/scripts/generator_tick.js
+++ b/scripts/generator_tick.js
@@ -160,6 +160,21 @@ function main() {
} catch (e) {
console.log(JSON.stringify({ event: 'rate_spawn_err', err: e.message }));
}
+
+ // ── Auto-fan-out 5 color variations of the new design ────────────────
+ // Up to 5 hue/sat/val shifts (hue +60, +120, +180, -60, mono). Each gets
+ // watermarked + inserted into spoon_all_designs + queued for AI rating.
+ try {
+ const { spawn } = require('child_process');
+ const varN = parseInt(process.env.GENERATOR_VARIATIONS || '5', 10);
+ const vars = spawn('node',
+ [path.join(__dirname, 'auto_variations.js'),
+ '--parent', String(designId), '--n', String(varN)],
+ { detached: true, stdio: 'ignore', env: process.env });
+ vars.unref();
+ } catch (e) {
+ console.log(JSON.stringify({ event: 'var_spawn_err', err: e.message }));
+ }
}
console.log(JSON.stringify({
diff --git a/scripts/learn_from_votes.js b/scripts/learn_from_votes.js
new file mode 100644
index 0000000..5e5cbd4
--- /dev/null
+++ b/scripts/learn_from_votes.js
@@ -0,0 +1,83 @@
+#!/usr/bin/env node
+/**
+ * Learning loop — reads top-ranked designs (combined AI + user + Elo),
+ * extracts the common attributes (categories, artists, palettes), and
+ * writes/updates a high-weight recipe in wallco_generator_recipe.
+ *
+ * Effect: the 3-min cron picks the high-weight learned recipe more often,
+ * so the generator drifts toward what's actually getting votes.
+ *
+ * Run via launchd or `node scripts/learn_from_votes.js` on demand.
+ */
+'use strict';
+const { execSync } = require('child_process');
+const path = require('path');
+
+const DB = 'dw_unified';
+const PSQL_CMD = (process.platform === 'linux')
+ ? `sudo -n -u postgres psql ${DB} -At -q`
+ : `psql ${DB} -At -q`;
+function psql(sql) { return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim(); }
+function arrLit(a) { return `ARRAY[${(a||[]).map(v => "'" + String(v).replace(/'/g,"''") + "'").join(',') || ''}]::text[]`; }
+
+const MIN_VOTE_THRESHOLD = 3; // need ≥3 user votes or ≥1 AI rating to qualify
+const TOP_N = 20;
+
+const top = JSON.parse(psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+ SELECT id, category, dominant_hex, prompt, combined_score,
+ user_score_avg, user_vote_count, poll_wins, poll_losses, elo,
+ (COALESCE(combined_score,3) * 0.4
+ + COALESCE(user_score_avg,3) * 0.4
+ + (elo - 1200) / 200.0) AS rank_score
+ FROM spoon_all_designs
+ WHERE local_path IS NOT NULL
+ AND (user_vote_count >= ${MIN_VOTE_THRESHOLD} OR combined_score IS NOT NULL)
+ ORDER BY rank_score DESC NULLS LAST
+ LIMIT ${TOP_N}) t;`));
+
+if (!top.length) {
+ console.log(JSON.stringify({event:'skip', reason:'no rated designs yet'}));
+ process.exit(0);
+}
+
+// Aggregate: top categories, top hex tones, recurring prompt n-grams
+const catCount = {};
+const hexCount = {};
+const wordCount = {};
+const STOP = new Set('a,an,the,with,and,or,of,in,on,no,for,is,are,this,that,by,as,to'.split(','));
+
+for (const d of top) {
+ catCount[d.category || 'mixed'] = (catCount[d.category || 'mixed'] || 0) + 1;
+ if (d.dominant_hex) hexCount[d.dominant_hex] = (hexCount[d.dominant_hex] || 0) + 1;
+ (d.prompt || '').toLowerCase().split(/[\s,.;:]+/).forEach(w => {
+ if (w.length < 3 || STOP.has(w)) return;
+ wordCount[w] = (wordCount[w] || 0) + 1;
+ });
+}
+
+const topCats = Object.entries(catCount).sort((a,b)=>b[1]-a[1]).slice(0,3).map(x=>x[0]);
+const topHexes = Object.entries(hexCount).sort((a,b)=>b[1]-a[1]).slice(0,6).map(x=>x[0]);
+const topWords = Object.entries(wordCount).sort((a,b)=>b[1]-a[1]).slice(0,12).map(x=>x[0]);
+const topArtists = topWords.filter(w => /^[a-z]+$/i.test(w) && w.length > 4).slice(0, 5);
+
+const recipeName = 'Learned · top-rated mix';
+const promptTemplate = `A seamless wallpaper pattern echoing the top-rated direction this week, with {style} influence, featuring ${topWords.slice(0,5).join(', ')}, palette inspired by ${topHexes.slice(0,4).join(', ')}, high detail, museum-quality, 24x24 inch tileable, no text, no signature.`;
+
+// Upsert the learned recipe with HIGH weight so the picker favors it
+const existing = psql(`SELECT id FROM wallco_generator_recipe WHERE name = '${recipeName.replace(/'/g,"''")}';`);
+if (existing) {
+ psql(`UPDATE wallco_generator_recipe SET
+ source_artists=${arrLit(topArtists)},
+ style_tags=${arrLit(topCats)},
+ color_hex_seeds=${arrLit(topHexes)},
+ prompt_template='${promptTemplate.replace(/'/g,"''")}',
+ weight=3, enabled=TRUE
+ WHERE id=${parseInt(existing,10)};`);
+ console.log(JSON.stringify({event:'updated', recipe_id: parseInt(existing,10),
+ top_cats: topCats, top_hexes: topHexes, top_words: topWords.slice(0,8)}));
+} else {
+ const id = psql(`INSERT INTO wallco_generator_recipe (name, source_artists, style_tags, color_hex_seeds, prompt_template, weight, enabled)
+ VALUES ('${recipeName.replace(/'/g,"''")}', ${arrLit(topArtists)}, ${arrLit(topCats)}, ${arrLit(topHexes)}, '${promptTemplate.replace(/'/g,"''")}', 3, TRUE) RETURNING id;`);
+ console.log(JSON.stringify({event:'created', recipe_id: parseInt(id,10),
+ top_cats: topCats, top_hexes: topHexes, top_words: topWords.slice(0,8)}));
+}
diff --git a/server.js b/server.js
index 1e46554..0466d8d 100644
--- a/server.js
+++ b/server.js
@@ -2071,6 +2071,48 @@ ${htmlHeader('/designs')}
Pattern No.${String(design.id).padStart(4,'0')} · ${design.category.charAt(0).toUpperCase()+design.category.slice(1)} · ${design.kind === 'seamless_tile' ? 'Seamless Tile' : design.kind}
</div>
+ <!-- USER VOTE — 5-star widget, anonymous session-cookie tracking -->
+ <div id="user-vote-card" style="margin:14px 0;padding:12px 16px;background:#faf6ec;border:1px solid #e7dcc3;border-radius:8px;font-size:13px">
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
+ <div>
+ <strong style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:15px;color:#1a1816">Your rating</strong>
+ <span id="vote-avg" style="margin-left:8px;color:#666;font-size:12px"></span>
+ </div>
+ <div id="star-row" data-id="${design.id}" style="font-size:24px;letter-spacing:4px;cursor:pointer;color:#bba">
+ <span data-s="1">☆</span><span data-s="2">☆</span><span data-s="3">☆</span><span data-s="4">☆</span><span data-s="5">☆</span>
+ </div>
+ </div>
+ <div style="margin-top:6px;font-size:11px;color:#888">Each vote teaches the system. We use your taste signals to bias what we generate next.</div>
+ </div>
+ <script>
+ (async function(){
+ var row = document.getElementById('star-row');
+ var avgEl = document.getElementById('vote-avg');
+ var id = row.getAttribute('data-id');
+ function paint(s){
+ Array.from(row.children).forEach(function(el,i){ el.textContent = (i < s) ? '★' : '☆'; });
+ row.style.color = s ? '#d2b15c' : '#bba';
+ }
+ async function refresh(){
+ var v = await (await fetch('/api/design/' + id + '/votes')).json();
+ if (v.my_score) paint(v.my_score);
+ if (v.count) avgEl.textContent = '· average ★' + (v.avg || '?') + ' from ' + v.count + ' vote' + (v.count===1?'':'s');
+ }
+ Array.from(row.children).forEach(function(el){
+ el.addEventListener('mouseenter', function(){ paint(parseInt(el.getAttribute('data-s'),10)); });
+ el.addEventListener('click', async function(){
+ var s = parseInt(el.getAttribute('data-s'),10);
+ paint(s);
+ var r = await fetch('/api/design/' + id + '/user-vote', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({score: s})});
+ var j = await r.json();
+ if (j.user_score_avg) avgEl.textContent = '· average ★' + j.user_score_avg + ' from ' + j.user_vote_count + ' vote' + (j.user_vote_count===1?'':'s');
+ });
+ });
+ row.addEventListener('mouseleave', refresh);
+ refresh();
+ })();
+ </script>
+
<!-- AI Suggested Rating (graphic designer + interior designer) -->
<div id="ai-rating-card" style="display:none;margin:14px 0;padding:14px 16px;background:#1a1816;color:#e8e2d6;border-radius:8px;border:1px solid #2a2a2a;font-size:13px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
← 925dc4e Add Ollama-free integration tests (admin-gate, csv-export, a
·
back to Wallco Ai
·
gamify: user votes + head-to-head polls + Elo + 5-variation ba0b062 →