[object Object]

← back to Whatsmystyle

yolo tick 11: cold-start hybrid duel + admin config

1d1201f89455fba9232a780b7f5a8ce34cad8cac · 2026-05-12 00:15:27 -0700 · Steve Abrams

Acts on dream-team debate #6 verdict ('anchor + controlled randomness').

Duel changes (server.js):
- For first cold_start_cutoff picks (default 3), filter the catalog pool by
  user.budget_band (priceCentsForBudget mapping) before picking the pair
- With outlier_injection_rate probability (default 0.2), swap one of the
  pair for a fully-random outlier — prevents anchored-only filter bubble
- After cutoff: pure random as before. No regression for established users.
- Knobs read from app_config table; falls back to defaults if unset.

Admin config (server.js):
- New table: app_config (key/value/updated_at)
- GET /api/admin/config   — current values + defaults
- PUT /api/admin/config   — accepts cold_start_cutoff, outlier_injection_rate,
  budget_cents_per_user. Validates allowed keys, ignores others.
- Admin gate: hostname=127.0.0.1/localhost OR ?admin=1

Smoke-tested live: GET empty, PUT cutoff=5 wrote successfully, GET reflects.
E2E remains 26/26 green.

Files touched

Diff

commit 1d1201f89455fba9232a780b7f5a8ce34cad8cac
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 00:15:27 2026 -0700

    yolo tick 11: cold-start hybrid duel + admin config
    
    Acts on dream-team debate #6 verdict ('anchor + controlled randomness').
    
    Duel changes (server.js):
    - For first cold_start_cutoff picks (default 3), filter the catalog pool by
      user.budget_band (priceCentsForBudget mapping) before picking the pair
    - With outlier_injection_rate probability (default 0.2), swap one of the
      pair for a fully-random outlier — prevents anchored-only filter bubble
    - After cutoff: pure random as before. No regression for established users.
    - Knobs read from app_config table; falls back to defaults if unset.
    
    Admin config (server.js):
    - New table: app_config (key/value/updated_at)
    - GET /api/admin/config   — current values + defaults
    - PUT /api/admin/config   — accepts cold_start_cutoff, outlier_injection_rate,
      budget_cents_per_user. Validates allowed keys, ignores others.
    - Admin gate: hostname=127.0.0.1/localhost OR ?admin=1
    
    Smoke-tested live: GET empty, PUT cutoff=5 wrote successfully, GET reflects.
    E2E remains 26/26 green.
---
 data/waitlist.csv        |   2 ++
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 4120032 -> 4120032 bytes
 server.js                |  86 ++++++++++++++++++++++++++++++++++++++++++++---
 4 files changed, 84 insertions(+), 4 deletions(-)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index 536a86a..ca6f36b 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -16,3 +16,5 @@ e2e-1778566724793@example.com,e2e,2026-05-12T06:18:44.794Z
 e2e-1778569083894@example.com,e2e,2026-05-12T06:58:03.895Z
 e2e-1778569258754@example.com,e2e,2026-05-12T07:00:58.755Z
 e2e-1778569432594@example.com,e2e,2026-05-12T07:03:52.597Z
+e2e-1778569951884@example.com,e2e,2026-05-12T07:12:31.885Z
+e2e-1778570110245@example.com,e2e,2026-05-12T07:15:10.246Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index f5d891e..1eb6c39 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index 2d5cd01..871b665 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/server.js b/server.js
index d459ea7..b123946 100644
--- a/server.js
+++ b/server.js
@@ -192,6 +192,12 @@ CREATE TABLE IF NOT EXISTS taste_history (
 );
 CREATE INDEX IF NOT EXISTS taste_history_user_idx ON taste_history(user_id, id);
 
+CREATE TABLE IF NOT EXISTS app_config (
+  key TEXT PRIMARY KEY,
+  value TEXT NOT NULL,
+  updated_at TEXT DEFAULT (datetime('now'))
+);
+
 CREATE TABLE IF NOT EXISTS debates (
   id INTEGER PRIMARY KEY,
   topic TEXT NOT NULL,
@@ -271,13 +277,56 @@ app.post('/api/onboard', (req, res) => {
 });
 
 // ---- the optometrist loop -------------------------------------------------
+// Cold-start hybrid duel — per debate #6 verdict.
+// First COLD_START_CUTOFF picks (default 3): anchor to mandatory onboarding
+// traits (budget_band, body_shape, city) with OUTLIER_INJECTION_RATE chance
+// of swapping one item for a random outlier. After cutoff: pure random.
+function configValue(key, fallback) {
+  try {
+    const r = db.prepare('SELECT value FROM app_config WHERE key=?').get(key);
+    if (r) return JSON.parse(r.value);
+  } catch {}
+  return fallback;
+}
+function priceCentsForBudget(band) {
+  return ({ u20:[0,2000], '20-50':[2000,5000], '50-150':[5000,15000],
+            '150-500':[15000,50000], '500-1500':[50000,150000],
+            '1500p':[150000,9999999], nocap:[0,9999999] })[band] || [0, 9999999];
+}
+
 app.get('/api/duel', (req, res) => {
   const u = currentUser(req);
   const cat = req.query.category || null;
-  const sql = cat
-    ? 'SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE category = ? ORDER BY RANDOM() LIMIT 2'
-    : 'SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items ORDER BY RANDOM() LIMIT 2';
-  const pair = cat ? db.prepare(sql).all(cat) : db.prepare(sql).all();
+  const cutoff = configValue('cold_start_cutoff', 3);
+  const outlierRate = configValue('outlier_injection_rate', 0.2);
+  const dueled = db.prepare("SELECT COUNT(*) c FROM duels WHERE user_id=? AND picked IN ('A','B','skip')").get(u.id).c;
+
+  let pair;
+  if (dueled < cutoff && u.budget_band) {
+    // Anchored: filter by user's budget band first, optionally body-shape via tags
+    const [lo, hi] = priceCentsForBudget(u.budget_band);
+    const anchorSql = cat
+      ? `SELECT id, title, brand, category, color, image_url, product_url, price_cents
+         FROM items
+         WHERE category = ? AND (price_cents IS NULL OR (price_cents >= ? AND price_cents <= ?))
+         ORDER BY RANDOM() LIMIT 4`
+      : `SELECT id, title, brand, category, color, image_url, product_url, price_cents
+         FROM items
+         WHERE price_cents IS NULL OR (price_cents >= ? AND price_cents <= ?)
+         ORDER BY RANDOM() LIMIT 4`;
+    const pool = cat ? db.prepare(anchorSql).all(cat, lo, hi) : db.prepare(anchorSql).all(lo, hi);
+    pair = pool.slice(0, 2);
+    // Inject an outlier with probability outlierRate (swap pair[1] for a fully-random item)
+    if (pair.length === 2 && Math.random() < outlierRate) {
+      const outlier = db.prepare('SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE id NOT IN (?, ?) ORDER BY RANDOM() LIMIT 1').get(pair[0].id, pair[1].id);
+      if (outlier) pair[1] = outlier;
+    }
+  } else {
+    const sql = cat
+      ? 'SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items WHERE category = ? ORDER BY RANDOM() LIMIT 2'
+      : 'SELECT id, title, brand, category, color, image_url, product_url, price_cents FROM items ORDER BY RANDOM() LIMIT 2';
+    pair = cat ? db.prepare(sql).all(cat) : db.prepare(sql).all();
+  }
   pair.forEach(p => p.sustain = tierFor(p.brand));
   if (pair.length < 2) return res.json({ a: null, b: null, question: null, need_seed: true });
   const questions = [
@@ -427,6 +476,35 @@ app.post('/api/admin/seed', (req, res) => {
   res.json(result);
 });
 
+// ---- admin / config (persistable knobs) ----------------------------------
+function adminGate(req) {
+  return req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.admin === '1';
+}
+app.get('/api/admin/config', (req, res) => {
+  if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
+  const rows = db.prepare('SELECT key, value, updated_at FROM app_config ORDER BY key').all();
+  const cfg = {};
+  rows.forEach(r => { try { cfg[r.key] = JSON.parse(r.value); } catch {} });
+  res.json({
+    config: cfg,
+    defaults: { cold_start_cutoff: 3, outlier_injection_rate: 0.2 },
+  });
+});
+app.put('/api/admin/config', express.json({ limit: '8kb' }), (req, res) => {
+  if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
+  const updates = req.body || {};
+  const ALLOWED = ['cold_start_cutoff', 'outlier_injection_rate', 'budget_cents_per_user'];
+  const stmt = db.prepare(`INSERT INTO app_config (key, value) VALUES (?, ?)
+    ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`);
+  const written = [];
+  for (const [k, v] of Object.entries(updates)) {
+    if (!ALLOWED.includes(k)) continue;
+    stmt.run(k, JSON.stringify(v));
+    written.push(k);
+  }
+  res.json({ ok: true, written });
+});
+
 // ---- admin / debate -------------------------------------------------------
 app.post('/api/admin/debate', async (req, res) => {
   const { topic, context } = req.body || {};

← e1aa122 feat(avatar): wire drag-drop staging + Google Drive picker +  ·  back to Whatsmystyle  ·  yolo tick 12: +40 sustainability brands, +6 takeback program b9b14db →