← 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 };