← 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
M data/waitlist.csvM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM server.js
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 →