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::{params, Connection, Result};
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 = conn.prepare("SELECT hash, lower(COALESCE(ext, '')), mime FROM file_hashes")?;
76    // (hash, ext, mime) so the decision uses content when known.
77    let rows: Vec<(String, String, Option<String>)> = stmt
78        .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
79        .collect::<rusqlite::Result<_>>()?;
80    let ext_by_hash: std::collections::HashMap<String, (String, Option<String>)> =
81        rows.into_iter().map(|(h, e, m)| (h, (e, m))).collect();
82
83    Ok(hashes
84        .iter()
85        .filter(|hash| {
86            !ext_by_hash.get(*hash).is_some_and(|(ext, mime)| {
87                crate::mime_probe::effective_mime(mime.as_deref(), ext)
88                    .is_some_and(crate::mime_probe::is_video_mime)
89            })
90        })
91        .cloned()
92        .collect())
93}
94
95/// Upsert a batch of (hash, category, confidence) rows inside one transaction.
96pub fn insert_classifications(
97    conn: &Connection,
98    model_id: &str,
99    items: &[(String, &str, f32)],
100) -> Result<()> {
101    let tx = conn.unchecked_transaction()?;
102    {
103        let mut stmt = tx.prepare(
104            "INSERT OR REPLACE INTO classifications
105                (model_id, hash, category, confidence, classified_at)
106             VALUES (?1, ?2, ?3, ?4, datetime('now'))",
107        )?;
108        for (hash, category, confidence) in items {
109            stmt.execute(params![model_id, hash, category, confidence])?;
110        }
111    }
112    tx.commit()
113}
114
115/// (path, hash) pairs for every file classified as `category`, one entry per
116/// on-disk path of a matched hash (same duplicate-path convention as
117/// `embeddings::paths_for_hash`).
118pub fn paths_for_category(
119    conn: &Connection,
120    model_id: &str,
121    category: &str,
122) -> Result<Vec<(String, String)>> {
123    let mut stmt = conn.prepare(
124        "SELECT file_hashes.path, file_hashes.hash FROM file_hashes
125         JOIN classifications ON file_hashes.hash = classifications.hash
126         WHERE classifications.model_id = ?1 AND classifications.category = ?2
127         ORDER BY file_hashes.path",
128    )?;
129    let rows = stmt.query_map(params![model_id, category], |row| {
130        Ok((row.get(0)?, row.get(1)?))
131    })?;
132    rows.collect()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    /// Main database with `file_hashes` and `classifications`, plus a real
140    /// attached model database holding `embeddings`. Faking the split with a
141    /// plain local table would hide the very thing under test.
142    fn test_db_attached(tag: &str) -> Connection {
143        let lib = crate::embeddings_db::test_library(tag);
144        let conn = Connection::open_in_memory().unwrap();
145        conn.execute_batch(
146            "CREATE TABLE file_hashes (
147                path TEXT PRIMARY KEY,
148                hash TEXT NOT NULL,
149                ext  TEXT
150            );",
151        )
152        .unwrap();
153        crate::db::ensure_file_hashes_columns(&conn);
154        ensure_classifications_table(&conn).unwrap();
155        crate::embeddings_db::attach(&conn, &lib, "test-model", true).unwrap();
156        conn
157    }
158
159    fn insert_file(conn: &Connection, path: &str, hash: &str) {
160        conn.execute(
161            "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, ?2, 'jpg')",
162            rusqlite::params![path, hash],
163        )
164        .unwrap();
165    }
166
167    fn insert_file_with_ext(conn: &Connection, path: &str, hash: &str, ext: &str) {
168        conn.execute(
169            "INSERT INTO file_hashes (path, hash, ext) VALUES (?1, ?2, ?3)",
170            rusqlite::params![path, hash, ext],
171        )
172        .unwrap();
173    }
174
175    fn insert_embedding(conn: &Connection, hash: &str, model_id: &str) {
176        conn.execute(
177            "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
178             VALUES (?1, ?2, X'00', datetime('now'))",
179            rusqlite::params![hash, model_id],
180        )
181        .unwrap();
182    }
183
184    #[test]
185    fn pending_hashes_excludes_video_extensions() {
186        let conn = test_db_attached("cls_video");
187        insert_file_with_ext(&conn, "/a/1.jpg", "h1", "jpg");
188        insert_file_with_ext(&conn, "/a/clip.mp4", "h2", "mp4");
189        insert_file_with_ext(&conn, "/a/clip.mov", "h3", "mov");
190        insert_embedding(&conn, "h1", "test-model");
191        insert_embedding(&conn, "h2", "test-model");
192        insert_embedding(&conn, "h3", "test-model");
193
194        let pending = pending_hashes(&conn, "test-model").unwrap();
195        assert_eq!(pending, vec!["h1".to_string()]);
196    }
197
198    #[test]
199    fn exclude_video_hashes_filters_out_mov_and_mp4() {
200        let conn = test_db_attached("cls_filter");
201        insert_file_with_ext(&conn, "/a/1.jpg", "h1", "jpg");
202        insert_file_with_ext(&conn, "/a/clip.mp4", "h2", "mp4");
203        insert_file_with_ext(&conn, "/a/clip.mov", "h3", "mov");
204
205        let all = vec!["h1".to_string(), "h2".to_string(), "h3".to_string()];
206        let filtered = exclude_video_hashes(&conn, &all).unwrap();
207        assert_eq!(filtered, vec!["h1".to_string()]);
208    }
209
210    #[test]
211    fn exclude_video_hashes_keeps_hashes_with_no_file_hashes_row() {
212        // A hash present in `embeddings` but with no matching `file_hashes` row
213        // (e.g. the file was pruned) has no ext to check. Keep it rather than
214        // silently dropping it, since it isn't known to be a video.
215        let conn = test_db_attached("cls_orphan");
216        let all = vec!["orphan-hash".to_string()];
217        let filtered = exclude_video_hashes(&conn, &all).unwrap();
218        assert_eq!(filtered, vec!["orphan-hash".to_string()]);
219    }
220
221    #[test]
222    fn pending_hashes_returns_work_for_a_second_model() {
223        // The bug this fixes: with a single-column primary key, any hash
224        // already classified by model A was excluded for every model, so a
225        // second model silently found zero pending work and classified
226        // nothing while reporting success.
227        //
228        // Each model owns a separate database, so this genuinely swaps the
229        // attached one rather than putting two model_ids in one table, which
230        // the hash primary key would not allow anyway.
231        let lib = crate::embeddings_db::test_library("cls_secondmodel");
232        let conn = Connection::open_in_memory().unwrap();
233        conn.execute_batch(
234            "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL, ext TEXT);",
235        )
236        .unwrap();
237        crate::db::ensure_file_hashes_columns(&conn);
238        ensure_classifications_table(&conn).unwrap();
239
240        crate::embeddings_db::attach(&conn, &lib, "model-a", true).unwrap();
241        insert_embedding(&conn, "h1", "model-a");
242        insert_classifications(&conn, "model-a", &[("h1".to_string(), "photo", 0.9)]).unwrap();
243        assert!(pending_hashes(&conn, "model-a").unwrap().is_empty());
244        crate::embeddings_db::detach(&conn).unwrap();
245
246        crate::embeddings_db::attach(&conn, &lib, "model-b", true).unwrap();
247        insert_embedding(&conn, "h1", "model-b");
248        assert_eq!(
249            pending_hashes(&conn, "model-b").unwrap(),
250            vec!["h1".to_string()],
251            "model-b must still have work to do"
252        );
253    }
254
255    #[test]
256    fn the_same_hash_can_hold_one_row_per_model() {
257        let conn = test_db_attached("cls_perlmodel");
258        insert_classifications(&conn, "model-a", &[("h1".to_string(), "photo", 0.9)]).unwrap();
259        insert_classifications(&conn, "model-b", &[("h1".to_string(), "meme", 0.7)]).unwrap();
260
261        let n: i64 = conn
262            .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
263            .unwrap();
264        assert_eq!(n, 2);
265    }
266
267    #[test]
268    fn paths_for_category_is_scoped_to_one_model() {
269        let conn = test_db_attached("cls_catmodel");
270        insert_file(&conn, "/a/1.jpg", "h1");
271        insert_classifications(&conn, "model-a", &[("h1".to_string(), "screenshot", 0.8)]).unwrap();
272        insert_classifications(&conn, "model-b", &[("h1".to_string(), "photo", 0.8)]).unwrap();
273
274        assert_eq!(
275            paths_for_category(&conn, "model-a", "screenshot")
276                .unwrap()
277                .len(),
278            1
279        );
280        assert!(paths_for_category(&conn, "model-b", "screenshot")
281            .unwrap()
282            .is_empty());
283    }
284
285    #[test]
286    fn a_legacy_table_without_model_id_is_dropped_and_recreated() {
287        // Rebuilding costs minutes (pure vector arithmetic, no image
288        // decoding). Guessing a model_id to stamp would produce data that
289        // looks valid and is not.
290        let conn = test_db_attached("cls_legacy");
291        conn.execute_batch("DROP TABLE classifications;").unwrap();
292        conn.execute_batch(
293            "CREATE TABLE classifications (
294                hash TEXT PRIMARY KEY NOT NULL, category TEXT NOT NULL,
295                confidence REAL NOT NULL, classified_at TEXT NOT NULL
296            );
297            INSERT INTO classifications VALUES ('h1', 'photo', 0.9, 'now');",
298        )
299        .unwrap();
300
301        ensure_classifications_table(&conn).unwrap();
302
303        let n: i64 = conn
304            .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
305            .unwrap();
306        assert_eq!(n, 0, "legacy rows are dropped, not silently mislabeled");
307        conn.execute(
308            "INSERT INTO classifications VALUES ('m', 'h1', 'photo', 0.9, 'now')",
309            [],
310        )
311        .expect("recreated table must have the 5-column shape");
312    }
313
314    #[test]
315    fn ensure_classifications_table_is_idempotent() {
316        let conn = test_db_attached("cls_idem");
317        ensure_classifications_table(&conn).unwrap();
318        ensure_classifications_table(&conn).unwrap();
319    }
320
321    #[test]
322    fn pending_hashes_returns_embedded_but_unclassified() {
323        let conn = test_db_attached("cls_pending");
324        insert_embedding(&conn, "h1", "test-model");
325        insert_embedding(&conn, "h2", "test-model");
326        insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
327
328        let pending = pending_hashes(&conn, "test-model").unwrap();
329        assert_eq!(pending, vec!["h2".to_string()]);
330    }
331
332    #[test]
333    fn pending_hashes_is_model_aware() {
334        let conn = test_db_attached("cls_modelaware");
335        insert_embedding(&conn, "h1", "model-a");
336
337        assert_eq!(
338            pending_hashes(&conn, "model-a").unwrap(),
339            vec!["h1".to_string()]
340        );
341        assert!(pending_hashes(&conn, "model-b").unwrap().is_empty());
342    }
343
344    #[test]
345    fn insert_classifications_upserts_on_conflict() {
346        let conn = test_db_attached("cls_upsert");
347        insert_classifications(
348            &conn,
349            "test-model",
350            &[("h1".to_string(), "screenshot", 0.4)],
351        )
352        .unwrap();
353        insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
354
355        let category: String = conn
356            .query_row(
357                "SELECT category FROM classifications WHERE hash = 'h1'",
358                [],
359                |r| r.get(0),
360            )
361            .unwrap();
362        assert_eq!(category, "photo");
363        let count: i64 = conn
364            .query_row("SELECT COUNT(*) FROM classifications", [], |r| r.get(0))
365            .unwrap();
366        assert_eq!(count, 1); // upsert, not a second row
367    }
368
369    #[test]
370    fn paths_for_category_returns_matching_paths_with_hash() {
371        let conn = test_db_attached("cls_paths");
372        insert_file(&conn, "/a/1.jpg", "h1");
373        insert_file(&conn, "/b/1-copy.jpg", "h1");
374        insert_file(&conn, "/a/2.png", "h2");
375        insert_classifications(
376            &conn,
377            "test-model",
378            &[
379                ("h1".to_string(), "screenshot", 0.8),
380                ("h2".to_string(), "photo", 0.9),
381            ],
382        )
383        .unwrap();
384
385        let hits = paths_for_category(&conn, "test-model", "screenshot").unwrap();
386        assert_eq!(hits.len(), 2);
387        assert!(hits.iter().all(|(_, hash)| hash == "h1"));
388    }
389
390    #[test]
391    fn paths_for_category_returns_empty_for_unmatched_category() {
392        let conn = test_db_attached("cls_empty");
393        insert_file(&conn, "/a/1.jpg", "h1");
394        insert_classifications(&conn, "test-model", &[("h1".to_string(), "photo", 0.9)]).unwrap();
395
396        assert!(paths_for_category(&conn, "test-model", "meme")
397            .unwrap()
398            .is_empty());
399    }
400}