macrame/vector/model.rs
1use crate::error::{DbError, Result};
2
3/// A validated embedding-model name, and therefore a safe SQL identifier.
4///
5/// Per-model tables (D-005) mean the model name reaches SQL as part of a table
6/// name — `embeddings_nomic_v1` — and SQLite cannot bind an identifier as a
7/// parameter, so the name is necessarily spliced into the statement text. That
8/// splice is only safe if the name cannot contain anything but identifier
9/// characters, which is what this type establishes once, at the boundary,
10/// instead of at each of the several call sites that build a statement.
11///
12/// Before this existed, `search_vector` built its table name with
13/// `format!("embeddings_{model}")` from an unvalidated `&str` argument.
14///
15/// The rule is deliberately narrower than SQLite's: lowercase ASCII, digits and
16/// underscore, first character a letter, 48 characters at most. Rejecting
17/// `nomic-v1` rather than quietly rewriting it to `nomic_v1` is the same
18/// principle §4.1 applies to timestamps — a silent repair here becomes two
19/// models sharing one table later, which is precisely the outcome per-model
20/// tables exist to prevent.
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct ModelName(String);
23
24/// Longest accepted model name. SQLite has no meaningful identifier limit; this
25/// is a sanity bound so a pathological name cannot produce unreadable DDL.
26const MAX_MODEL_NAME: usize = 48;
27
28impl ModelName {
29 /// Validate `raw` as a model name, or explain why it is not one.
30 pub fn new(raw: impl AsRef<str>) -> Result<Self> {
31 let raw = raw.as_ref();
32 let invalid = || DbError::InvalidModelName(raw.to_string());
33
34 if raw.is_empty() || raw.len() > MAX_MODEL_NAME {
35 return Err(invalid());
36 }
37 let mut chars = raw.chars();
38 match chars.next() {
39 Some(c) if c.is_ascii_lowercase() => {}
40 _ => return Err(invalid()),
41 }
42 if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
43 return Err(invalid());
44 }
45 Ok(Self(raw.to_string()))
46 }
47
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51
52 /// The per-model embedding table, `embeddings_<model>`.
53 pub fn table(&self) -> String {
54 format!("embeddings_{}", self.0)
55 }
56
57 /// The DiskANN index over that table's vector column.
58 ///
59 /// Named, and named predictably, because `vector_top_k` takes the index by
60 /// name as a string argument rather than resolving one from the query plan.
61 pub fn index(&self) -> String {
62 format!("idx_embeddings_{}_vec", self.0)
63 }
64}
65
66impl std::fmt::Display for ModelName {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.write_str(&self.0)
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 #[test]
77 fn accepts_the_names_the_document_uses() {
78 for ok in ["nomic_v1", "e5", "bge_m3", "all_minilm_l6_v2", "m1"] {
79 assert_eq!(ModelName::new(ok).unwrap().as_str(), ok);
80 }
81 }
82
83 /// Each of these would otherwise be spliced into a table name.
84 #[test]
85 fn rejects_anything_that_is_not_a_bare_identifier() {
86 for bad in [
87 "",
88 "nomic-v1", // hyphen: the obvious near-miss
89 "Nomic", // uppercase: would make table names ambiguous
90 "1nomic", // leading digit
91 "_nomic", // leading underscore
92 "nomic v1", // space
93 "nomic\"; DROP TABLE links; --",
94 "nomic'",
95 "nomic;",
96 "nomic(1)",
97 "concepts\" --",
98 "näh", // non-ASCII
99 ] {
100 assert!(
101 matches!(ModelName::new(bad), Err(DbError::InvalidModelName(_))),
102 "{bad:?} was accepted as a model name"
103 );
104 }
105 assert!(ModelName::new("a".repeat(MAX_MODEL_NAME + 1)).is_err());
106 assert!(ModelName::new("a".repeat(MAX_MODEL_NAME)).is_ok());
107 }
108
109 /// The derived identifiers are what actually reaches SQL, so assert their
110 /// exact spelling rather than just the name they were built from.
111 #[test]
112 fn derives_the_table_and_index_identifiers() {
113 let m = ModelName::new("nomic_v1").unwrap();
114 assert_eq!(m.table(), "embeddings_nomic_v1");
115 assert_eq!(m.index(), "idx_embeddings_nomic_v1_vec");
116 }
117
118 /// A model table must never be able to collide with a ledger table, or
119 /// registering a model would put a vector column where the ledger lives.
120 #[test]
121 fn no_model_name_can_collide_with_a_ledger_table() {
122 for ledger in ["concepts", "links", "links_current", "transaction_log"] {
123 assert!(
124 ModelName::new(ledger).unwrap().table() != ledger,
125 "model {ledger:?} produced a ledger table name"
126 );
127 }
128 }
129}