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 Ok(())
143}
144
145pub fn list_models(db_path: &Path) -> Result<Vec<String>> {
150 let dir = library_dir(db_path)?;
151 let entries = match std::fs::read_dir(&dir) {
152 Ok(e) => e,
153 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
154 Err(e) => return Err(e).with_context(|| format!("read {}", dir.display())),
155 };
156 let mut models: Vec<String> = entries
157 .filter_map(|e| e.ok())
158 .map(|e| e.path())
159 .filter(|p| p.extension().is_some_and(|x| x == DB_EXT))
160 .filter_map(|p| p.file_stem().map(|s| model_from_slug(&s.to_string_lossy())))
161 .collect();
162 models.sort();
163 Ok(models)
164}
165
166#[derive(Debug, Clone, PartialEq, serde::Serialize)]
168pub struct ModelEmbeddingCount {
169 pub model_id: String,
170 pub count: i64,
171 pub dims: i64,
175 pub size_bytes: i64,
176}
177
178pub fn counts_by_model(db_path: &Path) -> Result<Vec<ModelEmbeddingCount>> {
180 let mut out = Vec::new();
181 for model_id in list_models(db_path)? {
182 let path = self::db_path(db_path, &model_id)?;
183 let size_bytes = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0);
184 let conn = Connection::open(&path).with_context(|| format!("open {}", path.display()))?;
185 let count: i64 = conn
186 .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
187 .unwrap_or(0);
188 let dims: i64 = conn
189 .query_row(
190 "SELECT LENGTH(embedding) / 2 FROM embeddings LIMIT 1",
191 [],
192 |r| r.get(0),
193 )
194 .unwrap_or(0);
195 out.push(ModelEmbeddingCount {
196 model_id,
197 count,
198 dims,
199 size_bytes,
200 });
201 }
202 Ok(out)
203}
204
205pub fn attach_for_read(conn: &Connection, db_path: &Path, model_id: &str) -> Result<()> {
218 match attach(conn, db_path, model_id, false) {
219 Ok(()) => Ok(()),
220 Err(e) => {
221 let legacy = crate::embeddings::legacy_main_db_embedding_count(conn).unwrap_or(0);
222 if legacy > 0 {
223 Ok(())
224 } else {
225 Err(e)
226 }
227 }
228 }
229}
230
231pub fn detach(conn: &Connection) -> Result<()> {
234 conn.execute(&format!("DETACH DATABASE {ATTACH_ALIAS}"), [])
235 .context("detach embeddings database")?;
236 Ok(())
237}
238
239#[cfg(test)]
249pub(crate) fn test_home() -> &'static Path {
250 static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
251 HOME.get_or_init(|| {
252 let dir = std::env::temp_dir().join(format!("videre-embdb-home-{}", std::process::id()));
253 std::fs::create_dir_all(&dir).expect("create isolated test home");
254 std::env::set_var("VIDERE_HOME", &dir);
255 dir
256 })
257}
258
259#[cfg(test)]
262pub(crate) fn test_library(tag: &str) -> PathBuf {
263 let dir = test_home().join(tag);
264 let _ = std::fs::remove_dir_all(&dir);
265 std::fs::create_dir_all(&dir).unwrap();
266 let lib = dir.join(format!("{tag}.db"));
267 std::fs::write(&lib, b"").unwrap();
268 lib
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn with_home<T>(tag: &str, f: impl FnOnce(&Path) -> T) -> T {
278 let dir = test_home().join(tag);
279 let _ = std::fs::remove_dir_all(&dir);
280 std::fs::create_dir_all(&dir).unwrap();
281 f(&dir)
282 }
283
284 fn touch_db(dir: &Path, name: &str) -> PathBuf {
285 let p = dir.join(name);
286 std::fs::write(&p, b"").unwrap();
287 p
288 }
289
290 #[test]
291 fn model_slug_replaces_the_owner_separator() {
292 assert_eq!(
293 model_slug("google/siglip2-base-patch16-384"),
294 "google--siglip2-base-patch16-384"
295 );
296 }
297
298 #[test]
299 fn model_slug_round_trips_through_model_from_slug() {
300 for id in [
301 "google/siglip2-base-patch16-384",
302 "google/siglip-so400m-patch14-384",
303 "google/siglip-base-patch16-224",
304 ] {
305 assert_eq!(model_from_slug(&model_slug(id)), id);
306 }
307 }
308
309 #[test]
310 fn model_slug_contains_no_path_separator() {
311 assert!(!model_slug("google/siglip2-base-patch16-384").contains('/'));
314 }
315
316 #[test]
317 fn two_libraries_sharing_a_stem_get_different_directories() {
318 with_home("stem", |home| {
319 let a_dir = home.join("a");
320 let b_dir = home.join("b");
321 std::fs::create_dir_all(&a_dir).unwrap();
322 std::fs::create_dir_all(&b_dir).unwrap();
323 let a = touch_db(&a_dir, "photos.db");
324 let b = touch_db(&b_dir, "photos.db");
325
326 let da = library_dir(&a).unwrap();
327 let db_ = library_dir(&b).unwrap();
328 assert_ne!(da, db_, "same stem in different dirs must not collide");
329 assert!(da
330 .file_name()
331 .unwrap()
332 .to_string_lossy()
333 .starts_with("photos-"));
334 });
335 }
336
337 #[test]
338 fn relative_and_absolute_paths_resolve_to_one_directory() {
339 with_home("canon", |home| {
340 let abs = touch_db(home, "hashes.db");
341 let canonical_home = home.canonicalize().unwrap();
342 let rel = canonical_home.join(".").join("hashes.db");
343 assert_eq!(library_dir(&abs).unwrap(), library_dir(&rel).unwrap());
344 });
345 }
346
347 #[test]
348 fn db_path_joins_library_dir_and_model_slug() {
349 with_home("dbpath", |home| {
350 let lib = touch_db(home, "hashes.db");
351 let p = db_path(&lib, "google/siglip2-base-patch16-384").unwrap();
352 assert_eq!(p.file_name().unwrap(), "google--siglip2-base-patch16-384.db");
353 assert_eq!(p.parent().unwrap(), library_dir(&lib).unwrap());
354 });
355 }
356
357 #[test]
358 fn attach_with_create_makes_a_database_with_the_chosen_page_size() {
359 with_home("create", |home| {
360 let lib = touch_db(home, "hashes.db");
361 let conn = Connection::open_in_memory().unwrap();
362 attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
363
364 let ps: i64 = conn
367 .query_row("PRAGMA emb.page_size", [], |r| r.get(0))
368 .unwrap();
369 assert_eq!(ps, PAGE_SIZE);
370 });
371 }
372
373 #[test]
374 fn attach_with_create_is_idempotent_and_preserves_rows() {
375 with_home("idem", |home| {
376 let lib = touch_db(home, "hashes.db");
377 let model = "google/siglip2-base-patch16-384";
378
379 let c1 = Connection::open_in_memory().unwrap();
380 attach(&c1, &lib, model, true).unwrap();
381 c1.execute(
382 "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
383 VALUES ('h1', ?1, X'0102', '2026-08-05T00:00:00')",
384 [model],
385 )
386 .unwrap();
387 detach(&c1).unwrap();
388 drop(c1);
389
390 let c2 = Connection::open_in_memory().unwrap();
391 attach(&c2, &lib, model, true).unwrap();
392 let n: i64 = c2
393 .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
394 .unwrap();
395 assert_eq!(n, 1, "re-attaching must not clobber existing rows");
396 });
397 }
398
399 #[test]
400 fn attach_without_create_errors_and_names_available_models() {
401 with_home("missing", |home| {
402 let lib = touch_db(home, "hashes.db");
403 let conn = Connection::open_in_memory().unwrap();
404 attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
405 detach(&conn).unwrap();
406
407 let err = attach(&conn, &lib, "google/siglip-base-patch16-224", false).unwrap_err();
408 let msg = format!("{err:#}");
409 assert!(
410 msg.contains("no embeddings for google/siglip-base-patch16-224"),
411 "{msg}"
412 );
413 assert!(
414 msg.contains("google/siglip2-base-patch16-384"),
415 "error must list what IS available: {msg}"
416 );
417 assert!(msg.contains("videre embed --model"), "{msg}");
418 });
419 }
420
421 #[test]
422 fn two_models_do_not_see_each_others_rows() {
423 with_home("isolate", |home| {
424 let lib = touch_db(home, "hashes.db");
425 let a = "google/siglip2-base-patch16-384";
426 let b = "google/siglip-base-patch16-224";
427
428 let conn = Connection::open_in_memory().unwrap();
429 attach(&conn, &lib, a, true).unwrap();
430 conn.execute(
431 "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
432 VALUES ('h1', ?1, X'0102', 'now')",
433 [a],
434 )
435 .unwrap();
436 detach(&conn).unwrap();
437
438 attach(&conn, &lib, b, true).unwrap();
439 let n: i64 = conn
440 .query_row("SELECT COUNT(*) FROM emb.embeddings", [], |r| r.get(0))
441 .unwrap();
442 assert_eq!(n, 0, "model b must not see model a's rows");
443 });
444 }
445
446 #[test]
447 fn attached_table_is_visible_through_emb_sqlite_master() {
448 with_home("master", |home| {
453 let lib = touch_db(home, "hashes.db");
454 let conn = Connection::open_in_memory().unwrap();
455 attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
456
457 let found: i64 = conn
458 .query_row(
459 "SELECT COUNT(*) FROM emb.sqlite_master
460 WHERE type='table' AND name='embeddings'",
461 [],
462 |r| r.get(0),
463 )
464 .unwrap();
465 assert_eq!(found, 1);
466
467 let unqualified: i64 = conn
468 .query_row(
469 "SELECT COUNT(*) FROM sqlite_master
470 WHERE type='table' AND name='embeddings'",
471 [],
472 |r| r.get(0),
473 )
474 .unwrap();
475 assert_eq!(unqualified, 0, "documents exactly why emb. is required");
476 });
477 }
478
479 #[test]
480 fn attach_for_read_tolerates_a_missing_model_db_when_legacy_rows_exist() {
481 with_home("readlegacy", |home| {
487 let lib = home.join("hashes.db");
488 std::fs::write(&lib, b"").unwrap();
489 let conn = Connection::open_in_memory().unwrap();
490 conn.execute_batch(
491 "CREATE TABLE embeddings (
492 hash TEXT PRIMARY KEY, model_id TEXT NOT NULL,
493 embedding BLOB NOT NULL, embedded_at TEXT NOT NULL
494 );
495 INSERT INTO embeddings VALUES ('h1', 'm', X'0102', 'now');",
496 )
497 .unwrap();
498
499 attach_for_read(&conn, &lib, "google/siglip2-base-patch16-384")
500 .expect("legacy rows must keep an unmigrated library working");
501 });
502 }
503
504 #[test]
505 fn attach_for_read_still_errors_when_there_is_nothing_at_all_to_read() {
506 with_home("readnothing", |home| {
507 let lib = home.join("hashes.db");
508 std::fs::write(&lib, b"").unwrap();
509 let conn = Connection::open_in_memory().unwrap();
510
511 let err = attach_for_read(&conn, &lib, "google/siglip2-base-patch16-384").unwrap_err();
512 assert!(format!("{err:#}").contains("no embeddings for"), "{err:#}");
513 });
514 }
515
516 #[test]
517 fn detach_allows_attaching_a_different_model_on_the_same_connection() {
518 with_home("reattach", |home| {
519 let lib = touch_db(home, "hashes.db");
520 let conn = Connection::open_in_memory().unwrap();
521 attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
522 detach(&conn).unwrap();
523 attach(&conn, &lib, "google/siglip-base-patch16-224", true).unwrap();
524 detach(&conn).unwrap();
525 });
526 }
527
528 #[test]
529 fn list_models_returns_sorted_ids_and_ignores_unrelated_files() {
530 with_home("list", |home| {
531 let lib = touch_db(home, "hashes.db");
532 let conn = Connection::open_in_memory().unwrap();
533 for m in [
534 "google/siglip2-base-patch16-384",
535 "google/siglip-base-patch16-224",
536 ] {
537 attach(&conn, &lib, m, true).unwrap();
538 detach(&conn).unwrap();
539 }
540 let dir = library_dir(&lib).unwrap();
542 std::fs::write(dir.join("notes.txt"), b"x").unwrap();
543
544 let models = list_models(&lib).unwrap();
545 assert_eq!(
546 models,
547 vec![
548 "google/siglip-base-patch16-224".to_string(),
549 "google/siglip2-base-patch16-384".to_string(),
550 ]
551 );
552 });
553 }
554
555 #[test]
556 fn list_models_on_a_library_with_no_embeddings_is_empty_not_an_error() {
557 with_home("listempty", |home| {
558 let lib = touch_db(home, "hashes.db");
559 assert!(list_models(&lib).unwrap().is_empty());
560 });
561 }
562
563 #[test]
564 fn counts_by_model_reports_rows_dims_and_size() {
565 with_home("counts", |home| {
566 let lib = touch_db(home, "hashes.db");
567 let model = "google/siglip2-base-patch16-384";
568 let conn = Connection::open_in_memory().unwrap();
569 attach(&conn, &lib, model, true).unwrap();
570 conn.execute(
572 "INSERT INTO emb.embeddings (hash, model_id, embedding, embedded_at)
573 VALUES ('h1', ?1, zeroblob(1536), 'now')",
574 [model],
575 )
576 .unwrap();
577 detach(&conn).unwrap();
578
579 let counts = counts_by_model(&lib).unwrap();
580 assert_eq!(counts.len(), 1);
581 assert_eq!(counts[0].model_id, model);
582 assert_eq!(counts[0].count, 1);
583 assert_eq!(
584 counts[0].dims, 768,
585 "dims derive from blob length, not a table"
586 );
587 assert!(counts[0].size_bytes > 0);
588 });
589 }
590
591 #[test]
592 fn counts_by_model_reports_zero_dims_for_an_empty_model_database() {
593 with_home("countsempty", |home| {
594 let lib = touch_db(home, "hashes.db");
595 let conn = Connection::open_in_memory().unwrap();
596 attach(&conn, &lib, "google/siglip2-base-patch16-384", true).unwrap();
597 detach(&conn).unwrap();
598
599 let counts = counts_by_model(&lib).unwrap();
600 assert_eq!(counts.len(), 1);
601 assert_eq!(counts[0].count, 0);
602 assert_eq!(counts[0].dims, 0);
603 });
604 }
605
606 #[test]
607 fn path_computation_creates_nothing() {
608 with_home("nocreate", |home| {
611 let lib = touch_db(home, "hashes.db");
612 let dir = library_dir(&lib).unwrap();
613 let _ = db_path(&lib, "google/siglip2-base-patch16-384").unwrap();
614 assert!(!dir.exists(), "path computation must not create {dir:?}");
615 });
616 }
617}