← back to Whatsmystyle
scripts/embed-drift-check.js
123 lines
/**
* 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 runDriftSweep({ db, maxItems = MAX_ITEMS, threshold = THRESHOLD, offset = 0 } = {}) {
if (!(await ollamaLlavaUp())) {
return { ok: false, reason: 'ollama+llava not available locally; skipped' };
}
// Only items that already have a non-null embedding worth comparing against.
// OFFSET so the cron can roll through the catalog instead of re-scanning the same head.
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 ? OFFSET ?`
).all(maxItems, offset);
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 });
}
return {
ok: true,
threshold,
scanned: results.length,
drifted: results.filter(r => r.drifted).length,
results,
};
}
async function main() {
const db = new Database(path.join(__dirname, '..', 'data', 'whatsmystyle.db'));
const summary = await runDriftSweep({ db });
console.log(JSON.stringify(summary, null, 2));
}
if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
module.exports = { cosine, runDriftSweep, ollamaLlavaUp };