Skip to main content

videre_core/
classify.rs

1//! Classifications table: one row per unique content hash (photo/screenshot/
2//! document/meme/unknown), keyed to embeddings.hash. Zero-shot classification
3//! reuses embeddings `videre embed` already computed. See
4//! docs/superpowers/specs/2026-07-29-screenshot-document-classification-design.md.
5
6use rusqlite::{Connection, Result, params};
7
8/// Create `classifications`, keyed by `(model_id, hash)`.
9///
10/// A pre-existing table without `model_id` is dropped and recreated rather
11/// than migrated. Classifications are pure vector arithmetic over embeddings
12/// that already exist, with no image decoding, so rebuilding costs minutes.
13/// Guessing which model produced the legacy rows would instead produce data
14/// that looks valid and is not.
15pub fn ensure_classifications_table(conn: &Connection) -> Result<()> {
16    if crate::db::table_exists(conn, "classifications")? {
17        let has_model_id = conn
18            .prepare("SELECT model_id FROM classifications LIMIT 0")
19            .is_ok();
20        if !has_model_id {
21            eprintln!(
22                "note: the classifications table predates multi-model support and has been \
23                 reset. Re-run 'videre classify' to rebuild it (minutes, no image decoding)."
24            );
25            conn.execute_batch("DROP TABLE classifications;")?;
26        }
27    }
28    conn.execute_batch(
29        "CREATE TABLE IF NOT EXISTS classifications (
30            model_id      TEXT NOT NULL,
31            hash          TEXT NOT NULL,
32            category      TEXT NOT NULL,
33            confidence    REAL NOT NULL,
34            classified_at TEXT NOT NULL,
35            PRIMARY KEY (model_id, hash)
36        );",
37    )
38}
39
40/// Hashes that have an embedding under `model_id` but no classification yet.
41/// Excludes video hashes (`.mov`/`.mp4`), none of the four zero-shot
42/// categories (photo/screenshot/document/meme) fit a video frame well, so
43/// videos are never classified, per the video-embedding design's decision.
44pub fn pending_hashes(conn: &Connection, model_id: &str) -> Result<Vec<String>> {
45    let mut stmt = conn.prepare(
46        "SELECT hash FROM emb.embeddings
47         WHERE model_id = ?1
48           AND NOT EXISTS (
49               SELECT 1 FROM classifications c
50               WHERE c.hash = emb.embeddings.hash AND c.model_id = ?1
51           )
52           AND NOT EXISTS (
53               SELECT 1 FROM file_hashes fh
54               WHERE fh.hash = emb.embeddings.hash
55                 AND (fh.mime IN ('video/quicktime', 'video/mp4')
56                      OR (fh.mime IS NULL AND lower(fh.ext) IN ('mov', 'mp4')))
57           )
58         ORDER BY hash",
59    )?;
60    let rows = stmt.query_map(params![model_id], |row| row.get(0))?;
61    rows.collect()
62}
63
64/// Filters `hashes` down to non-video ones, for callers (like `--reprocess`)
65/// that build their hash list independently of `pending_hashes` and need the
66/// same video exclusion applied so the two paths can't drift apart. A hash
67/// with no matching `file_hashes` row (nothing known about its extension) is
68/// kept, not excluded, only a *confirmed* video extension is filtered out.
69/// Loads the full hash->ext mapping in one query rather than one query per
70/// hash, since callers can pass every embedded hash in the library (tens of
71/// thousands). See `pending_hashes` above for the equivalent single-query
72/// exclusion used when the caller list comes from `embeddings` directly
73/// rather than being pre-built like it is here.
74pub fn exclude_video_hashes(conn: &Connection, hashes: &[String]) -> Result<Vec<String>> {
75    let mut stmt =
76        conn.prepare("SELECT hash, lower(COALESCE(ext, '')), mime FROM file_hashes")?;
77    // (hash, ext, mime) so the decision uses content when known.
78    let rows: Vec<(String, String, Option<String>)> = stmt
79        .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
80        .collect::<rusqlite::Result<_>>()?;
81    let ext_by_hash: std::collections::HashMap<String, (String, Option<String>)> =
82        rows.into_iter().map(|(h, e, m)| (h, (e, m))).collect();
83
84    Ok(hashes
85        .iter()
86        .filter(|hash| {
87            !ext_by_hash.get(*hash).is_some_and(|(ext, mime)| {
88                crate::mime_probe::effective_mime(mime.as_deref(), ext)
89                    .is_some_and(crate::mime_probe::is_video_mime)
90            })
91        })
92        .cloned()
93        .collect())
94}
95
96/// Upsert a batch of (hash, category, confidence) rows inside one transaction.
97pub fn insert_classifications(
98    conn: &Connection,
99    model_id: &str,
100    items: &[(String, &str, f32)],
101) -> Result<()> {
102    let tx = conn.unchecked_transaction()?;
103    {
104        let mut stmt = tx.prepare(
105            "INSERT OR REPLACE INTO classifications
106                (model_id, hash, category, confidence, classified_at)
107             VALUES (?1, ?2, ?3, ?4, datetime('now'))",
108        )?;
109        for (hash, category, confidence) in items {
110            stmt.execute(params![model_id, hash, category, confidence])?;
111        }
112    }
113    tx.commit()
114}
115
116/// (path, hash) pairs for every file classified as `category`, one entry per
117/// on-disk path of a matched hash (same duplicate-path convention as
118/// `embeddings::paths_for_hash`).
119pub fn paths_for_category(
120    conn: &Connection,
121    model_id: &str,
122    category: &str,
123) -> Result<Vec<(String, String)>> {
124    let mut stmt = conn.prepare(
125        "SELECT file_hashes.path, file_hashes.hash FROM file_hashes
126         JOIN classifications ON file_hashes.hash = classifications.hash
127         WHERE classifications.model_id = ?1 AND classifications.category = ?2
128         ORDER BY file_hashes.path",
129    )?;
130    let rows = stmt.query_map(params![model_id, category], |row| {
131        Ok((row.get(0)?, row.get(1)?))
132    })?;
133    rows.collect()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    /// Main database with `file_hashes` and `classifications`, plus a real
141    /// attached model database holding `embeddings`. Faking the split with a
142    /// plain local table would hide the very thing under test.
143    fn test_db_attached(tag: &str) -> Connection {
144        let lib = crate::embeddings_db::test_library(tag);
145        let conn = Connection::open_in_memory().unwrap();
146        conn.execute_batch(
147            "CREATE TABLE file_hashes (
148                path TEXT PRIMARY KEY,
149                hash TEXT NOT NULL,
150                ext  TEXT
151            );",
152        )
153        .unwrap();
154        crate::db::ensure_file_hashes_columns(&conn);
155        ensure_classifications_table(&conn).unwrap();
156        crate::embeddings_db::attach(&conn, &lib, "test-model", true).unwrap();
157        conn
158    }
159
160    fn insert_file(conn: &Connection, path: &str, hash: &str) {
161        conn.execute(
162            "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, ?2, 'jpg')",
163            rusqlite::params![path, hash],
164        )
165        .unwrap();
166    }
167
168    fn insert_file_with_ext(conn: &Connection, path: &str, hash: &str, ext: &str) {
169        conn.execute(
170            "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, ?2, ?3)",
171            rusqlite::params![path, hash, ext],
172        )
173        .unwrap();
174    }
175
176    fn insert_embedding(conn: &Connection, hash: &str, model_id: &str) {
177        conn.execute(
178            "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
179             VALUES (?1, ?2, X'00', datetime('now'))",
180            rusqlite::params![hash, model_id],
181        )
182        .unwrap();
183    }
184
185    #[test]
186    fn pending_hashes_excludes_video_extensions() {
187        let conn = test_db_attached("cls_video");
188        insert_file_with_ext(&conn, "/a/1.jpg", "h1", "jpg");
189        insert_file_with_ext(&conn, "/a/clip.mp4", "h2", "mp4");
190        insert_file_with_ext(&conn, "/a/clip.mov", "h3", "mov");
191        insert_embedding(&conn, "h1", "test-model");
192        insert_embedding(&conn, "h2", "test-model");
193        insert_embedding(&conn, "h3", "test-model");
194
195        let pending = pending_hashes(&conn, "test-model").unwrap();
196        assert_eq!(pending, vec!["h1".to_string()]);
197    }
198
199    #[test]
200    fn exclude_video_hashes_filters_out_mov_and_mp4() {
201        let conn = test_db_attached("cls_filter");
202        insert_file_with_ext(&conn, "/a/1.jpg", "h1", "jpg");
203        insert_file_with_ext(&conn, "/a/clip.mp4", "h2", "mp4");
204        insert_file_with_ext(&conn, "/a/clip.mov", "h3", "mov");
205
206        let all = vec!["h1".to_string(), "h2".to_string(), "h3".to_string()];
207        let filtered = exclude_video_hashes(&conn, &all).unwrap();
208        assert_eq!(filtered, vec!["h1".to_string()]);
209    }
210
211    #[test]
212    fn exclude_video_hashes_keeps_hashes_with_no_file_hashes_row() {
213        // A hash present in `embeddings` but with no matching `file_hashes` row
214        // (e.g. the file was pruned) has no ext to check. Keep it rather than
215        // silently dropping it, since it isn't known to be a video.
216        let conn = test_db_attached("cls_orphan");
217        let all = vec!["orphan-hash".to_string()];
218        let filtered = exclude_video_hashes(&conn, &all).unwrap();
219        assert_eq!(filtered, vec!["orphan-hash".to_string()]);
220    }
221
222    #[test]
223    fn pending_hashes_returns_work_for_a_second_model() {
224        // The bug this fixes: with a single-column primary key, any hash
225        // already classified by model A was excluded for every model, so a
226        // second model silently found zero pending work and classified
227        // nothing while reporting success.
228        //
229        // Each model owns a separate database, so this genuinely swaps the
230        // attached one rather than putting two model_ids in one table, which
231        // the hash primary key would not allow anyway.
232        let lib = crate::embeddings_db::test_library("cls_secondmodel");
233        let conn = Connection::open_in_memory().unwrap();
234        conn.execute_batch(
235            "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL, ext TEXT);",
236        )
237        .unwrap();
238        crate::db::ensure_file_hashes_columns(&conn);
239        ensure_classifications_table(&conn).unwrap();
240
241        crate::embeddings_db::attach(&conn, &lib, "model-a", true).unwrap();
242        insert_embedding(&conn, "h1", "model-a");
243        insert_classifications(&conn, "model-a", &[("h1".to_string(), "photo", 0.9)]).unwrap();
244        assert!(pending_hashes(&conn, "model-a").unwrap().is_empty());
245        crate::embeddings_db::detach(&conn).unwrap();
246
247        crate::embeddings_db::attach(&conn, &lib, "model-b", true).unwrap();
248        insert_embedding(&conn, "h1", "model-b");
249        assert_eq!(
250            pending_hashes(&conn, "model-b").unwrap(),
251            vec!["h1".to_string()],
252            "model-b must still have work to do"
253        );
254    }
255
256    #[test]
257    fn the_same_hash_can_hold_one_row_per_model() {
258        let conn = test_db_attached("cls_perlmodel");
259        insert_classifications(&conn, "model-a", &[("h1".to_string(), "photo", 0.9)]).unwrap();
260        insert_classifications(&conn, "model-b", &[("h1".to_string(), "meme", 0.7)]).unwrap();
261
262        let n: i64 = conn
263            .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
264            .unwrap();
265        assert_eq!(n, 2);
266    }
267
268    #[test]
269    fn paths_for_category_is_scoped_to_one_model() {
270        let conn = test_db_attached("cls_catmodel");
271        insert_file(&conn, "/a/1.jpg", "h1");
272        insert_classifications(&conn, "model-a", &[("h1".to_string(), "screenshot", 0.8)]).unwrap();
273        insert_classifications(&conn, "model-b", &[("h1".to_string(), "photo", 0.8)]).unwrap();
274
275        assert_eq!(
276            paths_for_category(&conn, "model-a", "screenshot").unwrap().len(),
277            1
278        );
279        assert!(paths_for_category(&conn, "model-b", "screenshot")
280            .unwrap()
281            .is_empty());
282    }
283
284    #[test]
285    fn a_legacy_table_without_model_id_is_dropped_and_recreated() {
286        // Rebuilding costs minutes (pure vector arithmetic, no image
287        // decoding). Guessing a model_id to stamp would produce data that
288        // looks valid and is not.
289        let conn = test_db_attached("cls_legacy");
290        conn.execute_batch("DROP TABLE classifications;").unwrap();
291        conn.execute_batch(
292            "CREATE TABLE classifications (
293                hash TEXT PRIMARY KEY NOT NULL, category TEXT NOT NULL,
294                confidence REAL NOT NULL, classified_at TEXT NOT NULL
295            );
296            INSERT INTO classifications VALUES ('h1', 'photo', 0.9, 'now');",
297        )
298        .unwrap();
299
300        ensure_classifications_table(&conn).unwrap();
301
302        let n: i64 = conn
303            .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
304            .unwrap();
305        assert_eq!(n, 0, "legacy rows are dropped, not silently mislabeled");
306        conn.execute(
307            "INSERT INTO classifications VALUES ('m', 'h1', 'photo', 0.9, 'now')",
308            [],
309        )
310        .expect("recreated table must have the 5-column shape");
311    }
312
313    #[test]
314    fn ensure_classifications_table_is_idempotent() {
315        let conn = test_db_attached("cls_idem");
316        ensure_classifications_table(&conn).unwrap();
317        ensure_classifications_table(&conn).unwrap();
318    }
319
320    #[test]
321    fn pending_hashes_returns_embedded_but_unclassified() {
322        let conn = test_db_attached("cls_pending");
323        insert_embedding(&conn, "h1", "test-model");
324        insert_embedding(&conn, "h2", "test-model");
325        insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
326
327        let pending = pending_hashes(&conn, "test-model").unwrap();
328        assert_eq!(pending, vec!["h2".to_string()]);
329    }
330
331    #[test]
332    fn pending_hashes_is_model_aware() {
333        let conn = test_db_attached("cls_modelaware");
334        insert_embedding(&conn, "h1", "model-a");
335
336        assert_eq!(pending_hashes(&conn, "model-a").unwrap(), vec!["h1".to_string()]);
337        assert!(pending_hashes(&conn, "model-b").unwrap().is_empty());
338    }
339
340    #[test]
341    fn insert_classifications_upserts_on_conflict() {
342        let conn = test_db_attached("cls_upsert");
343        insert_classifications(&conn, "test-model", &[("h1".to_string(), "screenshot", 0.4)]).unwrap();
344        insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
345
346        let category: String = conn
347            .query_row("SELECT category FROM classifications WHERE hash = 'h1'", [], |r| r.get(0))
348            .unwrap();
349        assert_eq!(category, "photo");
350        let count: i64 = conn
351            .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
352            .unwrap();
353        assert_eq!(count, 1); // upsert, not a second row
354    }
355
356    #[test]
357    fn paths_for_category_returns_matching_paths_with_hash() {
358        let conn = test_db_attached("cls_paths");
359        insert_file(&conn, "/a/1.jpg", "h1");
360        insert_file(&conn, "/b/1-copy.jpg", "h1");
361        insert_file(&conn, "/a/2.png", "h2");
362        insert_classifications(
363            &conn,
364            "test-model",
365            &[
366                ("h1".to_string(), "screenshot", 0.8),
367                ("h2".to_string(), "photo", 0.9),
368            ],
369        )
370        .unwrap();
371
372        let hits = paths_for_category(&conn, "test-model", "screenshot").unwrap();
373        assert_eq!(hits.len(), 2);
374        assert!(hits.iter().all(|(_, hash)| hash == "h1"));
375    }
376
377    #[test]
378    fn paths_for_category_returns_empty_for_unmatched_category() {
379        let conn = test_db_attached("cls_empty");
380        insert_file(&conn, "/a/1.jpg", "h1");
381        insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
382
383        assert!(paths_for_category(&conn, "test-model", "meme").unwrap().is_empty());
384    }
385}