← 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
A scripts/embed-drift-check.js
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 →