1use anyhow::{Context, Result};
12use rusqlite::Connection;
13use std::path::{Path, PathBuf};
14
15pub const ATTACH_ALIAS: &str = "emb";
17
18const DB_EXT: &str = "db";
20
21pub fn model_slug(model_id: &str) -> String {
27 model_id.replace('/', "--")
28}
29
30pub fn model_from_slug(slug: &str) -> String {
33 slug.replacen("--", "/", 1)
34}
35
36pub 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
63pub 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
69pub const PAGE_SIZE: i64 = 16384;
85
86fn 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
113pub 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 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
165pub 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#[derive(Debug, Clone, PartialEq, serde::Serialize)]
188pub struct ModelEmbeddingCount {
189 pub model_id: String,
190 pub count: i64,
191 pub dims: i64,
195 pub size_bytes: i64,
196}
197
198pub 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
225pub fn attach_for_read(conn: &Connection, db_path: &Path, model_id: &str) -> Result<()> {
233 attach(conn, db_path, model_id, false)
234}
235
236pub fn detach(conn: &Connection) -> Result<()> {
239 conn.execute(&format!("DETACH DATABASE {ATTACH_ALIAS}"), [])
240 .context("detach embeddings database")?;
241 Ok(())
242}
243
244#[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#[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 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 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 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 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(); 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 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 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 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 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}