Skip to main content

videre_core/
embeddings_db.rs

1//! Per-model embedding databases: one SQLite file per (library, model) pair,
2//! attached to the main connection as `emb`.
3//!
4//! Embeddings used to live in an `embeddings` table inside the main library
5//! database, tagged with a `model_id` column. That allowed exactly one model
6//! to be usable at a time (every read filters on `model_id`, so switching
7//! models made the whole library look unembedded) and left the main database
8//! roughly three-quarters vectors. See
9//! docs/superpowers/specs/2026-08-05-multi-model-embeddings-split-design.md.
10
11use anyhow::{Context, Result};
12use rusqlite::Connection;
13use std::path::{Path, PathBuf};
14
15/// Schema alias the model database is attached under.
16pub const ATTACH_ALIAS: &str = "emb";
17
18/// Filename extension for a model database.
19const DB_EXT: &str = "db";
20
21/// `google/siglip2-base-patch16-384` -> `google--siglip2-base-patch16-384`.
22///
23/// Mirrors the Hugging Face cache convention already on disk at
24/// `~/.cache/huggingface/hub/models--google--siglip2-base-patch16-384`, so the
25/// two directories read the same way.
26pub fn model_slug(model_id: &str) -> String {
27    model_id.replace('/', "--")
28}
29
30/// Inverse of `model_slug`. Only the first separator is restored: HF ids are
31/// `owner/name` with exactly one `/`, and a name may legitimately contain `--`.
32pub fn model_from_slug(slug: &str) -> String {
33    slug.replacen("--", "/", 1)
34}
35
36/// Directory holding every model database for one library:
37/// `<home>/embeddings/<db stem>-<hash16>`.
38///
39/// The hash of the canonical path is load-bearing, not decoration, exactly as
40/// in `pipeline_runs::lock_path_for`: two libraries can both be named
41/// `photos.db` in different directories, and keying on the stem alone would
42/// silently merge their embeddings. Canonicalizing first also collapses a
43/// symlink and a relative path to one directory.
44///
45/// Path only; creating the directory is the caller's job, so readers never
46/// bring videre's home into existence just by looking.
47pub fn library_dir(db_path: &Path) -> Result<PathBuf> {
48    use std::hash::{Hash, Hasher};
49    let canonical = db_path
50        .canonicalize()
51        .with_context(|| format!("canonicalize {}", db_path.display()))?;
52    let mut hasher = std::collections::hash_map::DefaultHasher::new();
53    canonical.hash(&mut hasher);
54    let stem = canonical
55        .file_stem()
56        .map(|s| s.to_string_lossy().to_string())
57        .unwrap_or_else(|| "db".to_string());
58    Ok(crate::home::videre_home()?
59        .join("embeddings")
60        .join(format!("{stem}-{:016x}", hasher.finish())))
61}
62
63/// Full path to one model's database for one library. Path only; creates
64/// nothing.
65pub fn db_path(db_path: &Path, model_id: &str) -> Result<PathBuf> {
66    Ok(library_dir(db_path)?.join(format!("{}.{DB_EXT}", model_slug(model_id))))
67}
68
69/// Page size for model databases, overriding SQLite's 4096 default.
70///
71/// Measured 2026-08-05 over 20,000 synthetic rows, extrapolated to 70,587:
72///
73/// | page_size | 1152-dim | 768-dim |
74/// |-----------|----------|---------|
75/// | 4096      | 282 MB   | 143 MB  |
76/// | 8192      | 189 MB   | 143 MB  |
77/// | 16384     | 189 MB   | 128 MB  |
78/// | 32768     | 175 MB   | 122 MB  |
79///
80/// 8192 recovers a third of a 1152-dimension model's footprint but does
81/// nothing at all for 768-dimension models, which is where future data goes.
82/// 16384 is the first size that improves both. 32768 buys a further 5% at the
83/// cost of reading 32KB to touch one vector.
84pub const PAGE_SIZE: i64 = 16384;
85
86/// Initialise a new model database at `path`: page size, WAL, schema.
87///
88/// Done on a standalone connection, before any ATTACH, because `page_size`
89/// only takes effect on an empty database and must be set before
90/// `journal_mode = WAL` and before any table exists. Setting it later is
91/// silently ignored and needs a full VACUUM to apply.
92fn init_model_db(path: &Path) -> Result<()> {
93    if let Some(parent) = path.parent() {
94        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
95    }
96    let conn = Connection::open(path).with_context(|| format!("create {}", path.display()))?;
97    conn.pragma_update(None, "page_size", PAGE_SIZE)
98        .context("set page_size")?;
99    conn.pragma_update(None, "journal_mode", "WAL")
100        .context("set journal_mode")?;
101    conn.execute_batch(
102        "CREATE TABLE IF NOT EXISTS embeddings (
103            hash        TEXT PRIMARY KEY NOT NULL,
104            model_id    TEXT NOT NULL,
105            embedding   BLOB NOT NULL,
106            embedded_at TEXT NOT NULL
107        );",
108    )
109    .with_context(|| format!("create embeddings table in {}", path.display()))?;
110    Ok(())
111}
112
113/// ATTACH the model database for `(db_path, model_id)` as `emb`.
114///
115/// With `create`, a missing file is initialised first; this is reached only
116/// from `videre embed`. Without, a missing file is an error naming the models
117/// that do exist, so the user is never left guessing why search is empty.
118pub fn attach(conn: &Connection, db_path: &Path, model_id: &str, create: bool) -> Result<()> {
119    let path = self::db_path(db_path, model_id)?;
120    if !path.exists() {
121        if !create {
122            let available = list_models(db_path).unwrap_or_default();
123            let available = if available.is_empty() {
124                "(none)".to_string()
125            } else {
126                available.join(", ")
127            };
128            anyhow::bail!(
129                "no embeddings for {model_id} in this library\n  \
130                 expected: {}\n  available: {available}\n  \
131                 run: videre embed --model {model_id}",
132                path.display()
133            );
134        }
135        init_model_db(&path)?;
136    }
137    conn.execute(
138        &format!("ATTACH DATABASE ?1 AS {ATTACH_ALIAS}"),
139        [path.to_string_lossy().as_ref()],
140    )
141    .with_context(|| format!("attach {}", path.display()))?;
142    if create {
143        // `path.exists()` above is not sufficient on its own: a file can exist
144        // without the table, if initialisation was interrupted by a crash or a
145        // full disk, or if two processes create it at once. Attaching such a
146        // file leaves it broken forever, since every later call sees the file
147        // and skips init. Re-asserting the schema is idempotent and cheap.
148        //
149        // page_size is deliberately NOT set here: it only takes effect on an
150        // empty database, so it belongs in `init_model_db` before any table
151        // exists. A recovered file keeps whatever page size it was born with.
152        conn.execute_batch(
153            "CREATE TABLE IF NOT EXISTS emb.embeddings (
154                hash        TEXT PRIMARY KEY NOT NULL,
155                model_id    TEXT NOT NULL,
156                embedding   BLOB NOT NULL,
157                embedded_at TEXT NOT NULL
158            );",
159        )
160        .with_context(|| format!("ensure schema in {}", path.display()))?;
161    }
162    Ok(())
163}
164
165/// Model ids with an existing database for this library, sorted.
166///
167/// A missing directory is an empty list, not an error: a library that has
168/// never been embedded is a normal state, not a fault.
169pub fn list_models(db_path: &Path) -> Result<Vec<String>> {
170    let dir = library_dir(db_path)?;
171    let entries = match std::fs::read_dir(&dir) {
172        Ok(e) => e,
173        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
174        Err(e) => return Err(e).with_context(|| format!("read {}", dir.display())),
175    };
176    let mut models: Vec<String> = entries
177        .filter_map(|e| e.ok())
178        .map(|e| e.path())
179        .filter(|p| p.extension().is_some_and(|x| x == DB_EXT))
180        .filter_map(|p| p.file_stem().map(|s| model_from_slug(&s.to_string_lossy())))
181        .collect();
182    models.sort();
183    Ok(models)
184}
185
186/// One model's embedding inventory, for `videre stats`.
187#[derive(Debug, Clone, PartialEq, serde::Serialize)]
188pub struct ModelEmbeddingCount {
189    pub model_id: String,
190    pub count: i64,
191    /// Vector dimensions, derived from stored blob length rather than a
192    /// hardcoded per-model table, so an unfamiliar model still reports
193    /// honestly. 0 when the database holds no rows yet.
194    pub dims: i64,
195    pub size_bytes: i64,
196}
197
198/// Row count, dimensions, and file size for every model in this library.
199pub fn counts_by_model(db_path: &Path) -> Result<Vec<ModelEmbeddingCount>> {
200    let mut out = Vec::new();
201    for model_id in list_models(db_path)? {
202        let path = self::db_path(db_path, &model_id)?;
203        let size_bytes = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0);
204        let conn = Connection::open(&path).with_context(|| format!("open {}", path.display()))?;
205        let count: i64 = conn
206            .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
207            .unwrap_or(0);
208        let dims: i64 = conn
209            .query_row(
210                "SELECT LENGTH(embedding) / 2 FROM embeddings LIMIT 1",
211                [],
212                |r| r.get(0),
213            )
214            .unwrap_or(0);
215        out.push(ModelEmbeddingCount {
216            model_id,
217            count,
218            dims,
219            size_bytes,
220        });
221    }
222    Ok(out)
223}
224
225/// Attach for a reader: a missing model database is an error naming the models
226/// that do exist.
227///
228/// Kept as a distinct name from `attach(.., false)` so call sites read as
229/// intent rather than as a boolean. It previously tolerated a missing database
230/// when the main one still held pre-0.10 rows; that fallback was removed in
231/// 0.11.0, as `LEGACY_FALLBACK_REMOVE_IN` scheduled.
232pub fn attach_for_read(conn: &Connection, db_path: &Path, model_id: &str) -> Result<()> {
233    attach(conn, db_path, model_id, false)
234}
235
236/// DETACH the model database. Needed before attaching a different model on
237/// the same connection, since the alias may bind only one file at a time.
238pub fn detach(conn: &Connection) -> Result<()> {
239    conn.execute(&format!("DETACH DATABASE {ATTACH_ALIAS}"), [])
240        .context("detach embeddings database")?;
241    Ok(())
242}
243
244/// The one `VIDERE_HOME` for this test binary, set exactly once.
245///
246/// Shared by every module's tests rather than each setting its own. Tests
247/// share a process and run in parallel, so a per-test `set_var` races every
248/// concurrent `getenv`, and deleting that directory afterwards pulls the home
249/// out from under unrelated tests mid-run. Doing exactly that made two
250/// `pipeline_runs` lock tests fail. Isolation comes from per-test
251/// subdirectories instead, which suffices because `library_dir` keys on the
252/// database's canonical path.
253#[cfg(test)]
254pub(crate) fn test_home() -> &'static Path {
255    static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
256    HOME.get_or_init(|| {
257        let dir = std::env::temp_dir().join(format!("videre-embdb-home-{}", std::process::id()));
258        std::fs::create_dir_all(&dir).expect("create isolated test home");
259        std::env::set_var("VIDERE_HOME", &dir);
260        dir
261    })
262}
263
264/// Test-only: a fresh library directory plus an empty database file at
265/// `<test home>/<tag>/<tag>.db`, ready to hand to `attach`.
266///
267/// **`tag` must be unique across the whole test binary.** This wipes its
268/// directory on entry, so two tests sharing a tag delete each other's database
269/// mid-run and fail intermittently with `canonicalize ...: No such file or
270/// directory`. That happened once already, by reusing `emb_dng`.
271#[cfg(test)]
272pub(crate) fn test_library(tag: &str) -> PathBuf {
273    let dir = test_home().join(tag);
274    let _ = std::fs::remove_dir_all(&dir);
275    std::fs::create_dir_all(&dir).unwrap();
276    let lib = dir.join(format!("{tag}.db"));
277    std::fs::write(&lib, b"").unwrap();
278    lib
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    /// Gives one test its own directory under the shared per-binary
286    /// `VIDERE_HOME`. See `test_home` for why the home is not set per test.
287    fn with_home<T>(tag: &str, f: impl FnOnce(&Path) -> T) -> T {
288        let dir = test_home().join(tag);
289        let _ = std::fs::remove_dir_all(&dir);
290        std::fs::create_dir_all(&dir).unwrap();
291        f(&dir)
292    }
293
294    fn touch_db(dir: &Path, name: &str) -> PathBuf {
295        let p = dir.join(name);
296        std::fs::write(&p, b"").unwrap();
297        p
298    }
299
300    #[test]
301    fn model_slug_replaces_the_owner_separator() {
302        assert_eq!(
303            model_slug("google/siglip2-base-patch16-384"),
304            "google--siglip2-base-patch16-384"
305        );
306    }
307
308    #[test]
309    fn model_slug_round_trips_through_model_from_slug() {
310        for id in [
311            "google/siglip2-base-patch16-384",
312            "google/siglip-so400m-patch14-384",
313            "google/siglip-base-patch16-224",
314        ] {
315            assert_eq!(model_from_slug(&model_slug(id)), id);
316        }
317    }
318
319    #[test]
320    fn model_slug_contains_no_path_separator() {
321        // The slug becomes a filename; a surviving '/' would silently create
322        // a nested directory instead of the intended file.
323        assert!(!model_slug("google/siglip2-base-patch16-384").contains('/'));
324    }
325
326    #[test]
327    fn two_libraries_sharing_a_stem_get_different_directories() {
328        with_home("stem", |home| {
329            let a_dir = home.join("a");
330            let b_dir = home.join("b");
331            std::fs::create_dir_all(&a_dir).unwrap();
332            std::fs::create_dir_all(&b_dir).unwrap();
333            let a = touch_db(&a_dir, "photos.db");
334            let b = touch_db(&b_dir, "photos.db");
335
336            let da = library_dir(&a).unwrap();
337            let db_ = library_dir(&b).unwrap();
338            assert_ne!(da, db_, "same stem in different dirs must not collide");
339            assert!(da
340                .file_name()
341                .unwrap()
342                .to_string_lossy()
343                .starts_with("photos-"));
344        });
345    }
346
347    #[test]
348    fn relative_and_absolute_paths_resolve_to_one_directory() {
349        with_home("canon", |home| {
350            let abs = touch_db(home, "hashes.db");
351            let canonical_home = home.canonicalize().unwrap();
352            let rel = canonical_home.join(".").join("hashes.db");
353            assert_eq!(library_dir(&abs).unwrap(), library_dir(&rel).unwrap());
354        });
355    }
356
357    #[test]
358    fn db_path_joins_library_dir_and_model_slug() {
359        with_home("dbpath", |home| {
360            let lib = touch_db(home, "hashes.db");
361            let p = db_path(&lib, "google/siglip2-base-patch16-384").unwrap();
362            assert_eq!(p.file_name().unwrap(), "google--siglip2-base-patch16-384.db");
363            assert_eq!(p.parent().unwrap(), library_dir(&lib).unwrap());
364        });
365    }
366
367    #[test]
368    fn attach_with_create_makes_a_database_with_the_chosen_page_size() {
369        with_home("create", |home| {
370            let lib = touch_db(home, "hashes.db");
371            let conn = Connection::open_in_memory().unwrap();
372            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
373
374            // Read the pragma back rather than assuming the write took: a
375            // page_size set after the file has content is silently ignored.
376            let ps: i64 = conn
377                .query_row("PRAGMA emb.page_size", [], |r| r.get(0))
378                .unwrap();
379            assert_eq!(ps, PAGE_SIZE);
380        });
381    }
382
383    #[test]
384    fn attach_with_create_is_idempotent_and_preserves_rows() {
385        with_home("idem", |home| {
386            let lib = touch_db(home, "hashes.db");
387            let model = "google/siglip2-base-patch16-384";
388
389            let c1 = Connection::open_in_memory().unwrap();
390            attach(&c1, &lib, model, true).unwrap();
391            c1.execute(
392                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
393                 VALUES ('h1', ?1, X'0102', '2026-08-05T00:00:00')",
394                [model],
395            )
396            .unwrap();
397            detach(&c1).unwrap();
398            drop(c1);
399
400            let c2 = Connection::open_in_memory().unwrap();
401            attach(&c2, &lib, model, true).unwrap();
402            let n: i64 = c2
403                .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
404                .unwrap();
405            assert_eq!(n, 1, "re-attaching must not clobber existing rows");
406        });
407    }
408
409    #[test]
410    fn attach_with_create_repairs_a_file_that_exists_without_the_table() {
411        // `path.exists()` alone is not enough: initialisation can be cut short
412        // by a crash or a full disk, and attaching the resulting file leaves it
413        // broken forever because every later call sees the file and skips init.
414        // Surfaced as an intermittent "no such table: emb.embeddings" in tests.
415        with_home("repair", |home| {
416            let lib = touch_db(home, "hashes.db");
417            let model = "google/siglip2-base-patch16-384";
418            let path = db_path(&lib, model).unwrap();
419            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
420            std::fs::write(&path, b"").unwrap(); // exists, but empty
421
422            let conn = Connection::open_in_memory().unwrap();
423            attach(&conn, &lib, model, true).unwrap();
424            let n: i64 = conn
425                .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
426                .expect("the table must exist after attach(create: true)");
427            assert_eq!(n, 0);
428        });
429    }
430
431    #[test]
432    fn attach_without_create_errors_and_names_available_models() {
433        with_home("missing", |home| {
434            let lib = touch_db(home, "hashes.db");
435            let conn = Connection::open_in_memory().unwrap();
436            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
437            detach(&conn).unwrap();
438
439            let err = attach(&conn, &lib, "google/siglip-base-patch16-224", false).unwrap_err();
440            let msg = format!("{err:#}");
441            assert!(
442                msg.contains("no embeddings for google/siglip-base-patch16-224"),
443                "{msg}"
444            );
445            assert!(
446                msg.contains("google/siglip2-base-patch16-384"),
447                "error must list what IS available: {msg}"
448            );
449            assert!(msg.contains("videre embed --model"), "{msg}");
450        });
451    }
452
453    #[test]
454    fn two_models_do_not_see_each_others_rows() {
455        with_home("isolate", |home| {
456            let lib = touch_db(home, "hashes.db");
457            let a = "google/siglip2-base-patch16-384";
458            let b = "google/siglip-base-patch16-224";
459
460            let conn = Connection::open_in_memory().unwrap();
461            attach(&conn, &lib, a, true).unwrap();
462            conn.execute(
463                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
464                 VALUES ('h1', ?1, X'0102', 'now')",
465                [a],
466            )
467            .unwrap();
468            detach(&conn).unwrap();
469
470            attach(&conn, &lib, b, true).unwrap();
471            let n: i64 = conn
472                .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
473                .unwrap();
474            assert_eq!(n, 0, "model b must not see model a's rows");
475        });
476    }
477
478    #[test]
479    fn attached_table_is_visible_through_emb_sqlite_master() {
480        // Regression guard. `sqlite_master` is per-database: the unqualified
481        // form returns 0 once the table is attached, and every caller treats
482        // 0 as "not embedded yet" rather than as an error, so the failure is
483        // silent. This test fails against the unqualified query.
484        with_home("master", |home| {
485            let lib = touch_db(home, "hashes.db");
486            let conn = Connection::open_in_memory().unwrap();
487            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
488
489            let found: i64 = conn
490                .query_row(
491                    "SELECT COUNT(*) FROM emb.sqlite_master
492                     WHERE type='table' AND name='embeddings'",
493                    [],
494                    |r| r.get(0),
495                )
496                .unwrap();
497            assert_eq!(found, 1);
498
499            let unqualified: i64 = conn
500                .query_row(
501                    "SELECT COUNT(*) FROM sqlite_master
502                     WHERE type='table' AND name='embeddings'",
503                    [],
504                    |r| r.get(0),
505                )
506                .unwrap();
507            assert_eq!(unqualified, 0, "documents exactly why emb. is required");
508        });
509    }
510
511    #[test]
512    fn attach_for_read_still_errors_when_there_is_nothing_at_all_to_read() {
513        with_home("readnothing", |home| {
514            let lib = home.join("hashes.db");
515            std::fs::write(&lib, b"").unwrap();
516            let conn = Connection::open_in_memory().unwrap();
517
518            let err = attach_for_read(&conn, &lib, "google/siglip2-base-patch16-384").unwrap_err();
519            assert!(format!("{err:#}").contains("no embeddings for"), "{err:#}");
520        });
521    }
522
523    #[test]
524    fn detach_allows_attaching_a_different_model_on_the_same_connection() {
525        with_home("reattach", |home| {
526            let lib = touch_db(home, "hashes.db");
527            let conn = Connection::open_in_memory().unwrap();
528            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
529            detach(&conn).unwrap();
530            attach(&conn, &lib, "google/siglip-base-patch16-224", true).unwrap();
531            detach(&conn).unwrap();
532        });
533    }
534
535    #[test]
536    fn list_models_returns_sorted_ids_and_ignores_unrelated_files() {
537        with_home("list", |home| {
538            let lib = touch_db(home, "hashes.db");
539            let conn = Connection::open_in_memory().unwrap();
540            for m in [
541                "google/siglip2-base-patch16-384",
542                "google/siglip-base-patch16-224",
543            ] {
544                attach(&conn, &lib, m, true).unwrap();
545                detach(&conn).unwrap();
546            }
547            // WAL sidecars and stray files must not be mistaken for models.
548            let dir = library_dir(&lib).unwrap();
549            std::fs::write(dir.join("notes.txt"), b"x").unwrap();
550
551            let models = list_models(&lib).unwrap();
552            assert_eq!(
553                models,
554                vec![
555                    "google/siglip-base-patch16-224".to_string(),
556                    "google/siglip2-base-patch16-384".to_string(),
557                ]
558            );
559        });
560    }
561
562    #[test]
563    fn list_models_on_a_library_with_no_embeddings_is_empty_not_an_error() {
564        with_home("listempty", |home| {
565            let lib = touch_db(home, "hashes.db");
566            assert!(list_models(&lib).unwrap().is_empty());
567        });
568    }
569
570    #[test]
571    fn counts_by_model_reports_rows_dims_and_size() {
572        with_home("counts", |home| {
573            let lib = touch_db(home, "hashes.db");
574            let model = "google/siglip2-base-patch16-384";
575            let conn = Connection::open_in_memory().unwrap();
576            attach(&conn, &lib, model, true).unwrap();
577            // 768 dims f16 = 1536 bytes
578            conn.execute(
579                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
580                 VALUES ('h1', ?1, zeroblob(1536), 'now')",
581                [model],
582            )
583            .unwrap();
584            detach(&conn).unwrap();
585
586            let counts = counts_by_model(&lib).unwrap();
587            assert_eq!(counts.len(), 1);
588            assert_eq!(counts[0].model_id, model);
589            assert_eq!(counts[0].count, 1);
590            assert_eq!(
591                counts[0].dims, 768,
592                "dims derive from blob length, not a table"
593            );
594            assert!(counts[0].size_bytes > 0);
595        });
596    }
597
598    #[test]
599    fn counts_by_model_reports_zero_dims_for_an_empty_model_database() {
600        with_home("countsempty", |home| {
601            let lib = touch_db(home, "hashes.db");
602            let conn = Connection::open_in_memory().unwrap();
603            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
604            detach(&conn).unwrap();
605
606            let counts = counts_by_model(&lib).unwrap();
607            assert_eq!(counts.len(), 1);
608            assert_eq!(counts[0].count, 0);
609            assert_eq!(counts[0].dims, 0);
610        });
611    }
612
613    #[test]
614    fn path_computation_creates_nothing() {
615        // Readers must be able to ask "which models exist" without bringing
616        // videre's home into existence, same rule locks_dir follows.
617        with_home("nocreate", |home| {
618            let lib = touch_db(home, "hashes.db");
619            let dir = library_dir(&lib).unwrap();
620            let _ = db_path(&lib, "google/siglip2-base-patch16-384").unwrap();
621            assert!(!dir.exists(), "path computation must not create {dir:?}");
622        });
623    }
624}