[object Object]

← back to Whatsmystyle

yolo tick 13: catalog re-embed drift checker (cosine vs stored vec, ledger to embedding_drifts)

30fe227f721ae85d121d495f2532b2fa6e2499f3 · 2026-05-12 01:53:25 -0700 · SteveStudio2

Files touched

Diff

commit 30fe227f721ae85d121d495f2532b2fa6e2499f3
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 01:53:25 2026 -0700

    yolo tick 13: catalog re-embed drift checker (cosine vs stored vec, ledger to embedding_drifts)
---
 scripts/embed-drift-check.js | 118 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 118 insertions(+)

diff --git a/scripts/embed-drift-check.js b/scripts/embed-drift-check.js
new file mode 100644
index 0000000..5d881ae
--- /dev/null
+++ b/scripts/embed-drift-check.js
@@ -0,0 +1,118 @@
+/**
+ * Catalog re-embed drift check (tick 13).
+ *
+ * For each item with a stored embedding, re-run the llava → 32-d projection
+ * pipeline and compute cosine similarity vs the stored vector. Any item
+ * whose new-vs-old similarity drops below DRIFT_THRESHOLD (default 0.80)
+ * gets a row written to the `embedding_drifts` ledger.
+ *
+ * Why this exists: llava's tag output is temperature-controlled but not
+ * deterministic. If the model is updated locally, or a vendor swaps a
+ * product image URL silently, an item's vector can shift enough to break
+ * outfit-match scores. The ledger gives us a paper trail before users
+ * notice.
+ *
+ * Idempotent — the UNIQUE(item_id, checked_at) constraint blocks dupes
+ * within the same second; we use INSERT OR IGNORE so re-runs in the same
+ * second are no-ops.
+ *
+ * Env:
+ *   DRIFT_THRESHOLD     — cosine similarity floor (default 0.80)
+ *   DRIFT_MAX_ITEMS     — cap items scanned per run (default 8 to keep
+ *                          autonomous ticks under ~2min)
+ *
+ * Run:
+ *   node scripts/embed-drift-check.js
+ */
+const Database = require('better-sqlite3');
+const path = require('path');
+const { embedItem, projectTags } = require('./embed-items');
+
+const fetch = require('node-fetch');
+const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
+
+const THRESHOLD = Number(process.env.DRIFT_THRESHOLD || 0.80);
+const MAX_ITEMS = Number(process.env.DRIFT_MAX_ITEMS || 8);
+
+function cosine(a, b) {
+  if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return 0;
+  let dot = 0, na = 0, nb = 0;
+  for (let i = 0; i < a.length; i++) {
+    dot += a[i] * b[i];
+    na  += a[i] * a[i];
+    nb  += b[i] * b[i];
+  }
+  const denom = Math.sqrt(na) * Math.sqrt(nb);
+  return denom > 0 ? dot / denom : 0;
+}
+
+async function ollamaLlavaUp() {
+  try {
+    const r = await fetch(`${OLLAMA}/api/tags`, { timeout: 4000 });
+    if (!r.ok) return false;
+    const j = await r.json();
+    return (j.models || []).some(m => m.name?.startsWith('llava'));
+  } catch { return false; }
+}
+
+async function main() {
+  const db = new Database(path.join(__dirname, '..', 'data', 'whatsmystyle.db'));
+  if (!(await ollamaLlavaUp())) {
+    console.log(JSON.stringify({ ok: false, reason: 'ollama+llava not available locally; skipped' }));
+    return;
+  }
+
+  // Only items that already have a non-null embedding worth comparing against.
+  const rows = db.prepare(
+    `SELECT id, title, brand, category, color, pattern, material, image_url, embedding
+       FROM items
+      WHERE embedding IS NOT NULL AND embedding != ''
+      ORDER BY id
+      LIMIT ?`
+  ).all(MAX_ITEMS);
+
+  const insert = db.prepare(
+    `INSERT OR IGNORE INTO embedding_drifts (item_id, old_vec, new_vec, similarity, checked_at)
+     VALUES (?, ?, ?, ?, datetime('now'))`
+  );
+
+  const results = [];
+  for (const row of rows) {
+    let oldVec;
+    try { oldVec = JSON.parse(row.embedding); } catch { results.push({ id: row.id, skipped: 'unparseable old vec' }); continue; }
+
+    // We can't easily call describeOnce → projectTags without re-fetching the image,
+    // so let embedItem do the work — but stash the existing vec first so we can compare.
+    // embedItem writes the NEW vec back to the items row. We snapshot oldVec above.
+    let newVec;
+    try {
+      const out = await embedItem(db, row);
+      if (out.skipped) { results.push({ id: row.id, skipped: out.skipped }); continue; }
+      const updated = db.prepare('SELECT embedding FROM items WHERE id = ?').get(row.id);
+      newVec = JSON.parse(updated.embedding);
+    } catch (e) {
+      results.push({ id: row.id, err: e.message });
+      continue;
+    }
+
+    const sim = cosine(oldVec, newVec);
+    const drifted = sim < THRESHOLD;
+    if (drifted) {
+      insert.run(row.id, JSON.stringify(oldVec), JSON.stringify(newVec), sim);
+    }
+    results.push({ id: row.id, sim: Number(sim.toFixed(4)), drifted });
+  }
+
+  const summary = {
+    ok: true,
+    threshold: THRESHOLD,
+    scanned: results.length,
+    drifted: results.filter(r => r.drifted).length,
+    results,
+  };
+  console.log(JSON.stringify(summary, null, 2));
+}
+
+if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
+
+module.exports = { cosine };

← 477ba99 yolo tick 13: couples + embedding_drifts schema; closet.priv  ·  back to Whatsmystyle  ·  yolo tick 14: couples invite/accept/exit API (flag-gated) + ca023c6 →