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)
204            .map(|m| m.len() as i64)
205            .unwrap_or(0);
206        let conn = Connection::open(&path).with_context(|| format!("open {}", path.display()))?;
207        let count: i64 = conn
208            .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
209            .unwrap_or(0);
210        let dims: i64 = conn
211            .query_row(
212                "SELECT LENGTH(embedding) / 2 FROM embeddings LIMIT 1",
213                [],
214                |r| r.get(0),
215            )
216            .unwrap_or(0);
217        out.push(ModelEmbeddingCount {
218            model_id,
219            count,
220            dims,
221            size_bytes,
222        });
223    }
224    Ok(out)
225}
226
227/// Attach for a reader: a missing model database is an error naming the models
228/// that do exist.
229///
230/// Kept as a distinct name from `attach(.., false)` so call sites read as
231/// intent rather than as a boolean. It previously tolerated a missing database
232/// when the main one still held pre-0.10 rows; that fallback was removed in
233/// 0.11.0, as `LEGACY_FALLBACK_REMOVE_IN` scheduled.
234pub fn attach_for_read(conn: &Connection, db_path: &Path, model_id: &str) -> Result<()> {
235    attach(conn, db_path, model_id, false)
236}
237
238/// DETACH the model database. Needed before attaching a different model on
239/// the same connection, since the alias may bind only one file at a time.
240pub fn detach(conn: &Connection) -> Result<()> {
241    conn.execute(&format!("DETACH DATABASE {ATTACH_ALIAS}"), [])
242        .context("detach embeddings database")?;
243    Ok(())
244}
245
246/// The one `VIDERE_HOME` for this test binary, set exactly once.
247///
248/// Shared by every module's tests rather than each setting its own. Tests
249/// share a process and run in parallel, so a per-test `set_var` races every
250/// concurrent `getenv`, and deleting that directory afterwards pulls the home
251/// out from under unrelated tests mid-run. Doing exactly that made two
252/// `pipeline_runs` lock tests fail. Isolation comes from per-test
253/// subdirectories instead, which suffices because `library_dir` keys on the
254/// database's canonical path.
255#[cfg(test)]
256pub(crate) fn test_home() -> &'static Path {
257    static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
258    HOME.get_or_init(|| {
259        let dir = std::env::temp_dir().join(format!("videre-embdb-home-{}", std::process::id()));
260        std::fs::create_dir_all(&dir).expect("create isolated test home");
261        std::env::set_var("VIDERE_HOME", &dir);
262        dir
263    })
264}
265
266/// Test-only: a fresh library directory plus an empty database file at
267/// `<test home>/<tag>/<tag>.db`, ready to hand to `attach`.
268///
269/// **`tag` must be unique across the whole test binary.** This wipes its
270/// directory on entry, so two tests sharing a tag delete each other's database
271/// mid-run and fail intermittently with `canonicalize ...: No such file or
272/// directory`. That happened once already, by reusing `emb_dng`.
273#[cfg(test)]
274pub(crate) fn test_library(tag: &str) -> PathBuf {
275    let dir = test_home().join(tag);
276    let _ = std::fs::remove_dir_all(&dir);
277    std::fs::create_dir_all(&dir).unwrap();
278    let lib = dir.join(format!("{tag}.db"));
279    std::fs::write(&lib, b"").unwrap();
280    lib
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    /// Gives one test its own directory under the shared per-binary
288    /// `VIDERE_HOME`. See `test_home` for why the home is not set per test.
289    fn with_home<T>(tag: &str, f: impl FnOnce(&Path) -> T) -> T {
290        let dir = test_home().join(tag);
291        let _ = std::fs::remove_dir_all(&dir);
292        std::fs::create_dir_all(&dir).unwrap();
293        f(&dir)
294    }
295
296    fn touch_db(dir: &Path, name: &str) -> PathBuf {
297        let p = dir.join(name);
298        std::fs::write(&p, b"").unwrap();
299        p
300    }
301
302    #[test]
303    fn model_slug_replaces_the_owner_separator() {
304        assert_eq!(
305            model_slug("google/siglip2-base-patch16-384"),
306            "google--siglip2-base-patch16-384"
307        );
308    }
309
310    #[test]
311    fn model_slug_round_trips_through_model_from_slug() {
312        for id in [
313            "google/siglip2-base-patch16-384",
314            "google/siglip-so400m-patch14-384",
315            "google/siglip-base-patch16-224",
316        ] {
317            assert_eq!(model_from_slug(&model_slug(id)), id);
318        }
319    }
320
321    #[test]
322    fn model_slug_contains_no_path_separator() {
323        // The slug becomes a filename; a surviving '/' would silently create
324        // a nested directory instead of the intended file.
325        assert!(!model_slug("google/siglip2-base-patch16-384").contains('/'));
326    }
327
328    #[test]
329    fn two_libraries_sharing_a_stem_get_different_directories() {
330        with_home("stem", |home| {
331            let a_dir = home.join("a");
332            let b_dir = home.join("b");
333            std::fs::create_dir_all(&a_dir).unwrap();
334            std::fs::create_dir_all(&b_dir).unwrap();
335            let a = touch_db(&a_dir, "photos.db");
336            let b = touch_db(&b_dir, "photos.db");
337
338            let da = library_dir(&a).unwrap();
339            let db_ = library_dir(&b).unwrap();
340            assert_ne!(da, db_, "same stem in different dirs must not collide");
341            assert!(da
342                .file_name()
343                .unwrap()
344                .to_string_lossy()
345                .starts_with("photos-"));
346        });
347    }
348
349    #[test]
350    fn relative_and_absolute_paths_resolve_to_one_directory() {
351        with_home("canon", |home| {
352            let abs = touch_db(home, "hashes.db");
353            let canonical_home = home.canonicalize().unwrap();
354            let rel = canonical_home.join(".").join("hashes.db");
355            assert_eq!(library_dir(&abs).unwrap(), library_dir(&rel).unwrap());
356        });
357    }
358
359    #[test]
360    fn db_path_joins_library_dir_and_model_slug() {
361        with_home("dbpath", |home| {
362            let lib = touch_db(home, "hashes.db");
363            let p = db_path(&lib, "google/siglip2-base-patch16-384").unwrap();
364            assert_eq!(
365                p.file_name().unwrap(),
366                "google--siglip2-base-patch16-384.db"
367            );
368            assert_eq!(p.parent().unwrap(), library_dir(&lib).unwrap());
369        });
370    }
371
372    #[test]
373    fn attach_with_create_makes_a_database_with_the_chosen_page_size() {
374        with_home("create", |home| {
375            let lib = touch_db(home, "hashes.db");
376            let conn = Connection::open_in_memory().unwrap();
377            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
378
379            // Read the pragma back rather than assuming the write took: a
380            // page_size set after the file has content is silently ignored.
381            let ps: i64 = conn
382                .query_row("PRAGMA emb.page_size", [], |r| r.get(0))
383                .unwrap();
384            assert_eq!(ps, PAGE_SIZE);
385        });
386    }
387
388    #[test]
389    fn attach_with_create_is_idempotent_and_preserves_rows() {
390        with_home("idem", |home| {
391            let lib = touch_db(home, "hashes.db");
392            let model = "google/siglip2-base-patch16-384";
393
394            let c1 = Connection::open_in_memory().unwrap();
395            attach(&c1, &lib, model, true).unwrap();
396            c1.execute(
397                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
398                 VALUES ('h1', ?1, X'0102', '2026-08-05T00:00:00')",
399                [model],
400            )
401            .unwrap();
402            detach(&c1).unwrap();
403            drop(c1);
404
405            let c2 = Connection::open_in_memory().unwrap();
406            attach(&c2, &lib, model, true).unwrap();
407            let n: i64 = c2
408                .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
409                .unwrap();
410            assert_eq!(n, 1, "re-attaching must not clobber existing rows");
411        });
412    }
413
414    #[test]
415    fn attach_with_create_repairs_a_file_that_exists_without_the_table() {
416        // `path.exists()` alone is not enough: initialisation can be cut short
417        // by a crash or a full disk, and attaching the resulting file leaves it
418        // broken forever because every later call sees the file and skips init.
419        // Surfaced as an intermittent "no such table: emb.embeddings" in tests.
420        with_home("repair", |home| {
421            let lib = touch_db(home, "hashes.db");
422            let model = "google/siglip2-base-patch16-384";
423            let path = db_path(&lib, model).unwrap();
424            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
425            std::fs::write(&path, b"").unwrap(); // exists, but empty
426
427            let conn = Connection::open_in_memory().unwrap();
428            attach(&conn, &lib, model, true).unwrap();
429            let n: i64 = conn
430                .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
431                .expect("the table must exist after attach(create: true)");
432            assert_eq!(n, 0);
433        });
434    }
435
436    #[test]
437    fn attach_without_create_errors_and_names_available_models() {
438        with_home("missing", |home| {
439            let lib = touch_db(home, "hashes.db");
440            let conn = Connection::open_in_memory().unwrap();
441            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
442            detach(&conn).unwrap();
443
444            let err = attach(&conn, &lib, "google/siglip-base-patch16-224", false).unwrap_err();
445            let msg = format!("{err:#}");
446            assert!(
447                msg.contains("no embeddings for google/siglip-base-patch16-224"),
448                "{msg}"
449            );
450            assert!(
451                msg.contains("google/siglip2-base-patch16-384"),
452                "error must list what IS available: {msg}"
453            );
454            assert!(msg.contains("videre embed --model"), "{msg}");
455        });
456    }
457
458    #[test]
459    fn two_models_do_not_see_each_others_rows() {
460        with_home("isolate", |home| {
461            let lib = touch_db(home, "hashes.db");
462            let a = "google/siglip2-base-patch16-384";
463            let b = "google/siglip-base-patch16-224";
464
465            let conn = Connection::open_in_memory().unwrap();
466            attach(&conn, &lib, a, true).unwrap();
467            conn.execute(
468                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
469                 VALUES ('h1', ?1, X'0102', 'now')",
470                [a],
471            )
472            .unwrap();
473            detach(&conn).unwrap();
474
475            attach(&conn, &lib, b, true).unwrap();
476            let n: i64 = conn
477                .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
478                .unwrap();
479            assert_eq!(n, 0, "model b must not see model a's rows");
480        });
481    }
482
483    #[test]
484    fn attached_table_is_visible_through_emb_sqlite_master() {
485        // Regression guard. `sqlite_master` is per-database: the unqualified
486        // form returns 0 once the table is attached, and every caller treats
487        // 0 as "not embedded yet" rather than as an error, so the failure is
488        // silent. This test fails against the unqualified query.
489        with_home("master", |home| {
490            let lib = touch_db(home, "hashes.db");
491            let conn = Connection::open_in_memory().unwrap();
492            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
493
494            let found: i64 = conn
495                .query_row(
496                    "SELECT COUNT(*) FROM emb.sqlite_master
497                     WHERE type='table' AND name='embeddings'",
498                    [],
499                    |r| r.get(0),
500                )
501                .unwrap();
502            assert_eq!(found, 1);
503
504            let unqualified: i64 = conn
505                .query_row(
506                    "SELECT COUNT(*) FROM sqlite_master
507                     WHERE type='table' AND name='embeddings'",
508                    [],
509                    |r| r.get(0),
510                )
511                .unwrap();
512            assert_eq!(unqualified, 0, "documents exactly why emb. is required");
513        });
514    }
515
516    #[test]
517    fn attach_for_read_still_errors_when_there_is_nothing_at_all_to_read() {
518        with_home("readnothing", |home| {
519            let lib = home.join("hashes.db");
520            std::fs::write(&lib, b"").unwrap();
521            let conn = Connection::open_in_memory().unwrap();
522
523            let err = attach_for_read(&conn, &lib, "google/siglip2-base-patch16-384").unwrap_err();
524            assert!(format!("{err:#}").contains("no embeddings for"), "{err:#}");
525        });
526    }
527
528    #[test]
529    fn detach_allows_attaching_a_different_model_on_the_same_connection() {
530        with_home("reattach", |home| {
531            let lib = touch_db(home, "hashes.db");
532            let conn = Connection::open_in_memory().unwrap();
533            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
534            detach(&conn).unwrap();
535            attach(&conn, &lib, "google/siglip-base-patch16-224", true).unwrap();
536            detach(&conn).unwrap();
537        });
538    }
539
540    #[test]
541    fn list_models_returns_sorted_ids_and_ignores_unrelated_files() {
542        with_home("list", |home| {
543            let lib = touch_db(home, "hashes.db");
544            let conn = Connection::open_in_memory().unwrap();
545            for m in [
546                "google/siglip2-base-patch16-384",
547                "google/siglip-base-patch16-224",
548            ] {
549                attach(&conn, &lib, m, true).unwrap();
550                detach(&conn).unwrap();
551            }
552            // WAL sidecars and stray files must not be mistaken for models.
553            let dir = library_dir(&lib).unwrap();
554            std::fs::write(dir.join("notes.txt"), b"x").unwrap();
555
556            let models = list_models(&lib).unwrap();
557            assert_eq!(
558                models,
559                vec![
560                    "google/siglip-base-patch16-224".to_string(),
561                    "google/siglip2-base-patch16-384".to_string(),
562                ]
563            );
564        });
565    }
566
567    #[test]
568    fn list_models_on_a_library_with_no_embeddings_is_empty_not_an_error() {
569        with_home("listempty", |home| {
570            let lib = touch_db(home, "hashes.db");
571            assert!(list_models(&lib).unwrap().is_empty());
572        });
573    }
574
575    #[test]
576    fn counts_by_model_reports_rows_dims_and_size() {
577        with_home("counts", |home| {
578            let lib = touch_db(home, "hashes.db");
579            let model = "google/siglip2-base-patch16-384";
580            let conn = Connection::open_in_memory().unwrap();
581            attach(&conn, &lib, model, true).unwrap();
582            // 768 dims f16 = 1536 bytes
583            conn.execute(
584                "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
585                 VALUES ('h1', ?1, zeroblob(1536), 'now')",
586                [model],
587            )
588            .unwrap();
589            detach(&conn).unwrap();
590
591            let counts = counts_by_model(&lib).unwrap();
592            assert_eq!(counts.len(), 1);
593            assert_eq!(counts[0].model_id, model);
594            assert_eq!(counts[0].count, 1);
595            assert_eq!(
596                counts[0].dims, 768,
597                "dims derive from blob length, not a table"
598            );
599            assert!(counts[0].size_bytes > 0);
600        });
601    }
602
603    #[test]
604    fn counts_by_model_reports_zero_dims_for_an_empty_model_database() {
605        with_home("countsempty", |home| {
606            let lib = touch_db(home, "hashes.db");
607            let conn = Connection::open_in_memory().unwrap();
608            attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
609            detach(&conn).unwrap();
610
611            let counts = counts_by_model(&lib).unwrap();
612            assert_eq!(counts.len(), 1);
613            assert_eq!(counts[0].count, 0);
614            assert_eq!(counts[0].dims, 0);
615        });
616    }
617
618    #[test]
619    fn path_computation_creates_nothing() {
620        // Readers must be able to ask "which models exist" without bringing
621        // videre's home into existence, same rule locks_dir follows.
622        with_home("nocreate", |home| {
623            let lib = touch_db(home, "hashes.db");
624            let dir = library_dir(&lib).unwrap();
625            let _ = db_path(&lib, "google/siglip2-base-patch16-384").unwrap();
626            assert!(!dir.exists(), "path computation must not create {dir:?}");
627        });
628    }
629}