macrame/vector/registry.rs
1use crate::error::{DbError, Result};
2use crate::schema::ddl;
3use crate::vector::ModelName;
4
5/// Create a model's embedding table and its DiskANN index, if absent (§4.1).
6///
7/// Both objects, or neither. The index is not an optimisation that can be added
8/// later at leisure: until it exists the engine accepts vectors of any length
9/// into the column (see [`ddl::create_embeddings_index`]), so a table without
10/// its index is a table with no dimension enforcement at all. Creating them in
11/// one transaction is what makes "registered" mean "enforcing".
12///
13/// Idempotent, so an application may call it on every start.
14///
15/// # Prefer [`crate::Database::register_model`]
16///
17/// This takes a **bare connection** and is therefore §4.7 invariant 2's third
18/// hole: a write that does not cross the actor's channel. Hidden from the docs
19/// alongside [`crate::Database::raw`] (D-091) so the documented path is the one
20/// that preserves the single-writer property; still public, because
21/// [D-068](../../docs/architecture/s13-decision-register.md#d-068)'s argument
22/// applies unchanged — the file is reachable by any SQLite client, so removing
23/// the supported way to do this would buy the appearance of a guarantee.
24#[doc(hidden)]
25pub async fn register_model(
26 conn: &libsql::Connection,
27 model: &ModelName,
28 dim: usize,
29) -> Result<()> {
30 if dim == 0 {
31 return Err(DbError::DimMismatch {
32 got: 0,
33 expected: 1,
34 model: model.to_string(),
35 });
36 }
37
38 // If it already exists at a different dimension, say so rather than
39 // no-opping through `IF NOT EXISTS` and leaving the caller believing the
40 // dimension they asked for is the one in force.
41 if let Some(existing) = read_declared_dimension(conn, model).await? {
42 if existing != dim {
43 return Err(DbError::DimMismatch {
44 got: dim,
45 expected: existing,
46 model: model.to_string(),
47 });
48 }
49 return Ok(());
50 }
51
52 let tx = conn
53 .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
54 .await?;
55 let res: Result<()> = async {
56 tx.execute(&ddl::create_embeddings_table(model, dim), ())
57 .await?;
58 tx.execute(&ddl::create_embeddings_index(model), ()).await?;
59 Ok(())
60 }
61 .await;
62
63 match res {
64 Ok(()) => {
65 tx.commit().await?;
66 Ok(())
67 }
68 Err(e) => {
69 let _ = tx.rollback().await;
70 Err(e)
71 }
72 }
73}
74
75/// The dimension a registered model's table declares, read from the schema.
76///
77/// The crate keeps no registry of its own. `F32_BLOB(768)` in the column type
78/// *is* the declaration, `PRAGMA table_info` reports it verbatim, and reading it
79/// back means the Rust-side dimension check and the storage-layer one cannot
80/// disagree — there is only one number. Keeping a `HashMap<model, dim>` beside
81/// the database would be the same mistake D-035 fixed in `archive()`: a second
82/// hand-maintained description of a set the schema already defines.
83pub async fn declared_dimension(conn: &libsql::Connection, model: &ModelName) -> Result<usize> {
84 read_declared_dimension(conn, model)
85 .await?
86 .ok_or_else(|| DbError::ModelNotRegistered {
87 model: model.to_string(),
88 table: model.table(),
89 })
90}
91
92/// `Ok(None)` when the model has no table; `Ok(Some(dim))` when it has one.
93async fn read_declared_dimension(
94 conn: &libsql::Connection,
95 model: &ModelName,
96) -> Result<Option<usize>> {
97 // `model` is a ModelName, so this splice is a bare identifier by construction.
98 let mut rows = conn
99 .query(&format!("PRAGMA table_info({})", model.table()), ())
100 .await?;
101
102 while let Some(row) = rows.next().await? {
103 let name: String = row.get(1)?;
104 if name == "embedding" {
105 let declared: String = row.get(2)?;
106 return parse_f32_blob_dim(&declared).map(Some).ok_or_else(|| {
107 DbError::ModelNotRegistered {
108 model: model.to_string(),
109 table: format!(
110 "{} (column `embedding` is declared {declared:?}, not F32_BLOB(n))",
111 model.table()
112 ),
113 }
114 });
115 }
116 }
117 Ok(None)
118}
119
120/// Pull `n` out of a declared type of the form `F32_BLOB(n)`.
121fn parse_f32_blob_dim(declared: &str) -> Option<usize> {
122 let t = declared.trim();
123 if !t.get(..9)?.eq_ignore_ascii_case("F32_BLOB(") || !t.ends_with(')') {
124 return None;
125 }
126 t[9..t.len() - 1].trim().parse::<usize>().ok()
127}
128
129/// Every model registered in this database, in name order.
130///
131/// Derived from `sqlite_master` for the same reason as the dimension: the set of
132/// registered models is a fact about the schema, and asking the schema cannot
133/// drift from it.
134///
135/// # `GLOB` rather than `LIKE`, and one filter rather than two (0.15.19, C-15)
136///
137/// `LIKE` treats `_` as *any single character*, and both arms of this filter
138/// contained one. The prefix arm was harmless — `embeddingsX_foo` would have
139/// matched, and the `strip_prefix` below rejects it anyway — but the exclusion
140/// arm was not: `NOT LIKE '%_shadow'` reads as *"anything ending in shadow,
141/// with any character before it"*, so a perfectly ordinary model named
142/// `ashadow` or `bigshadow` was **silently missing from this list**, and from
143/// everything that asks it what is registered. A model that exists, works, and
144/// does not appear in its own registry is the shape of defect this function was
145/// written to make impossible.
146///
147/// `GLOB`'s `_` is a literal, so the prefix says what it looks like. It is also
148/// case-sensitive, which costs nothing here and is the stricter reading:
149/// [`ModelName`] admits lowercase ASCII, digits and `_` only, so every table
150/// this crate creates is `embeddings_<model>` in exactly that case.
151///
152/// The shadow exclusion is gone rather than escaped. libSQL's internals are
153/// `libsql_vector_meta_shadow` and `<index>_shadow`, and this crate's indexes
154/// are named `idx_embeddings_<model>_vec` — so none of them start with
155/// `embeddings_` and the prefix already excludes every one. Escaping the
156/// underscore would have kept a filter that can only ever fire on a real model
157/// whose name happens to end in `_shadow`, which is the same defect one
158/// character narrower.
159pub async fn registered_models(conn: &libsql::Connection) -> Result<Vec<ModelName>> {
160 let mut rows = conn
161 .query(
162 "SELECT name FROM sqlite_master
163 WHERE type = 'table'
164 AND name GLOB 'embeddings_*'
165 ORDER BY name",
166 (),
167 )
168 .await?;
169
170 let mut out = Vec::new();
171 while let Some(row) = rows.next().await? {
172 let table: String = row.get(0)?;
173 if let Some(suffix) = table.strip_prefix("embeddings_") {
174 // A table matching the pattern but not the naming rule was not
175 // created by `register_model`; skipping it is more honest than
176 // reporting a name this crate would refuse to accept back.
177 if let Ok(model) = ModelName::new(suffix) {
178 out.push(model);
179 }
180 }
181 }
182 Ok(out)
183}
184
185#[cfg(test)]
186mod tests {
187 use super::parse_f32_blob_dim;
188
189 #[test]
190 fn reads_the_dimension_out_of_a_declared_type() {
191 assert_eq!(parse_f32_blob_dim("F32_BLOB(768)"), Some(768));
192 assert_eq!(parse_f32_blob_dim("f32_blob(1536)"), Some(1536));
193 assert_eq!(parse_f32_blob_dim(" F32_BLOB( 4 ) "), Some(4));
194 }
195
196 /// Anything that is not a dimensioned vector column must read as absent
197 /// rather than as some default, or a plain BLOB column would silently
198 /// acquire whatever dimension the caller happened to pass.
199 #[test]
200 fn refuses_to_invent_a_dimension() {
201 for bad in [
202 "BLOB",
203 "TEXT",
204 "F32_BLOB",
205 "F32_BLOB()",
206 "F32_BLOB(x)",
207 "F32_BLOB(4",
208 "",
209 ] {
210 assert_eq!(parse_f32_blob_dim(bad), None, "{bad:?} yielded a dimension");
211 }
212 }
213}