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)
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
227pub fn attach_for_read(conn: &Connection, db_path: &Path, model_id: &str) -> Result<()> {
235 attach(conn, db_path, model_id, false)
236}
237
238pub fn detach(conn: &Connection) -> Result<()> {
241 conn.execute(&format!("DETACH DATABASE {ATTACH_ALIAS}"), [])
242 .context("detach embeddings database")?;
243 Ok(())
244}
245
246#[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#[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 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 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 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 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(); 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 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 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 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 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}