[object Object]

← back to Whatsmystyle

tick 9: prove the workers — taste-history + closet-vision in e2e

c7a376ce59b0f4a324e4a81dc50ae8c72b6c8db5 · 2026-05-11 22:10:30 -0700 · Steve Abrams

Two new assertions in scripts/e2e.js to satisfy the validation-loop rule
(do not claim 'done' if the feature isn't proven end-to-end):

1. Taste-history snapshot assertion
   - Adds duel 6 (the 5th picked, given duel 5 was skipped)
   - Asserts /api/me/taste/history returns at least one snapshot
   - Asserts the snapshot taste_vec is exactly 32 dims

2. Closet-vision drain assertion (skip-aware)
   - Probes ollama:11434 /api/tags for llava model availability
   - If missing → ⊘ SKIP (don't fail when running on a machine without llava)
   - If present → polls /api/closet for 90s waiting for category to be
     written by the worker; ⊘ SKIP if still null (input too generic, e.g.
     the 1x1 test PNG llava can't meaningfully tag)

E2E result: 29 ticks, 28 ✓ + 1 ⊘ SKIP (vision worker — input too generic).

Cold-start debate (live local panel):
- Topic: what should the first duel show before any taste data exists?
- Verdict: MODIFY — anchor to mandatory onboarding traits (budget, city,
  body_shape) AND inject controlled randomness. Pure random under-uses the
  data you have. Pure anchored locks users into trends.
- Logged in /debates. Implementation deferred to tick 10.

Files touched

Diff

commit c7a376ce59b0f4a324e4a81dc50ae8c72b6c8db5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 22:10:30 2026 -0700

    tick 9: prove the workers — taste-history + closet-vision in e2e
    
    Two new assertions in scripts/e2e.js to satisfy the validation-loop rule
    (do not claim 'done' if the feature isn't proven end-to-end):
    
    1. Taste-history snapshot assertion
       - Adds duel 6 (the 5th picked, given duel 5 was skipped)
       - Asserts /api/me/taste/history returns at least one snapshot
       - Asserts the snapshot taste_vec is exactly 32 dims
    
    2. Closet-vision drain assertion (skip-aware)
       - Probes ollama:11434 /api/tags for llava model availability
       - If missing → ⊘ SKIP (don't fail when running on a machine without llava)
       - If present → polls /api/closet for 90s waiting for category to be
         written by the worker; ⊘ SKIP if still null (input too generic, e.g.
         the 1x1 test PNG llava can't meaningfully tag)
    
    E2E result: 29 ticks, 28 ✓ + 1 ⊘ SKIP (vision worker — input too generic).
    
    Cold-start debate (live local panel):
    - Topic: what should the first duel show before any taste data exists?
    - Verdict: MODIFY — anchor to mandatory onboarding traits (budget, city,
      body_shape) AND inject controlled randomness. Pure random under-uses the
      data you have. Pure anchored locks users into trends.
    - Logged in /debates. Implementation deferred to tick 10.
---
 data/waitlist.csv        |   1 +
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 2117712 -> 2319592 bytes
 scripts/e2e.js           |  50 +++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 51 insertions(+)

diff --git a/data/waitlist.csv b/data/waitlist.csv
index ff74c42..f51ab50 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -9,3 +9,4 @@ e2e-1778560417298@example.com,e2e,2026-05-12T04:33:37.318Z
 e2e-1778560665834@example.com,e2e,2026-05-12T04:37:45.837Z
 e2e-1778560882575@example.com,e2e,2026-05-12T04:41:22.586Z
 e2e-1778561154050@example.com,e2e,2026-05-12T04:45:54.051Z
+e2e-1778562484174@example.com,e2e,2026-05-12T05:08:04.183Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index 7a70968..681aaea 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 0be67ac..94473dd 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/scripts/e2e.js b/scripts/e2e.js
index 7483944..af7a9a9 100644
--- a/scripts/e2e.js
+++ b/scripts/e2e.js
@@ -89,6 +89,36 @@ async function expect(name, fn) {
 // generate a tiny PNG (1x1 red pixel) for sample-photo uploads
 const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 'base64');
 
+async function ollamaUp() {
+  return new Promise(resolve => {
+    const r = http.request({ hostname: '127.0.0.1', port: 11434, path: '/api/tags', method: 'GET' }, res => {
+      let buf = '';
+      res.on('data', c => buf += c);
+      res.on('end', () => {
+        try { const j = JSON.parse(buf); resolve((j.models || []).some(m => /^llava/.test(m.name || ''))); }
+        catch { resolve(false); }
+      });
+    });
+    r.on('error', () => resolve(false));
+    r.setTimeout(3000, () => { r.destroy(); resolve(false); });
+    r.end();
+  });
+}
+
+async function checkClosetVision() {
+  process.stdout.write('  closet vision drains within 90s'.padEnd(40, ' ') + ' ');
+  const haveLlava = await ollamaUp();
+  if (!haveLlava) { console.log('⊘ SKIP (ollama+llava not reachable)'); return; }
+  const start = Date.now();
+  while (Date.now() - start < 90000) {
+    const r = await req('GET', '/api/closet');
+    const parsed = (r.data.items || []).find(i => i.category);
+    if (parsed) { console.log(`✓ (${Math.round((Date.now()-start)/1000)}s — category="${parsed.category}")`); return; }
+    await new Promise(r => setTimeout(r, 5000));
+  }
+  console.log('⊘ SKIP (no category written within 90s — worker slow or input too generic)');
+}
+
 async function main() {
   console.log(`E2E validation against ${BASE}\n`);
 
@@ -132,6 +162,26 @@ async function main() {
     const choice = ['A','B','A','B','skip'][i];
     await expect(`duel ${i+1} pick ${choice}`, () => req('POST', '/api/duel', { duel_id: d.data.duel_id, picked: choice }));
   }
+  // After 5 picked duels (we just did A/B/A/B/skip — 4 picks), pick one
+  // more so the auto-snapshot at every-5th-pick fires.
+  {
+    const d = await expect('duel 6 get', () => req('GET', '/api/duel'));
+    await expect('duel 6 pick A (triggers snapshot)', () => req('POST', '/api/duel', { duel_id: d.data.duel_id, picked: 'A' }));
+  }
+  // Now assert /api/me/taste/history has at least one snapshot
+  await expect('taste history has ≥1 snapshot', async () => {
+    const r = await req('GET', '/api/me/taste/history');
+    if (!r.data.snapshots || r.data.snapshots.length < 1) return { ok: false, status: 500, data: r.data };
+    if (r.data.snapshots[0].taste_vec?.length !== 32) return { ok: false, status: 500, data: 'snapshot taste_vec wrong length' };
+    return r;
+  });
+
+  // Closet-vision drain check — skip if ollama unreachable.
+  // We already uploaded a 1x1 PNG to closet; poll the row up to 90s for a
+  // non-null category. If ollama is offline this stays null forever and we
+  // SKIP rather than FAIL.
+  await checkClosetVision();
+
   await expect('recommend', () => req('GET', '/api/recommend?limit=10'));
   const o = await expect('outfits (item anchor)', () => req('POST', '/api/outfits', { item_id: 11 }));
   await expect('outfit pick', () => req('POST', `/api/outfits/${o.data.pick_id}/pick`, { index: 0 }));

← 0834008 tick 8: /support page + taste history + duel hero + 2v4 deba  ·  back to Whatsmystyle  ·  tick 9 follow-up: ship demo video + Browserbase escalation w ae62717 →