Skip to main content

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/// Drop a model's DiskANN index, tolerating its absence (bulk-embedding
93/// setup, D-276).
94///
95/// Half of [`crate::Database::bulk_embeddings`]'s recipe, reachable through the
96/// actor and nowhere else: dropping the index is what makes a bulk load pay
97/// for table writes only (~5 µs/row, flat at every dimension measured) instead
98/// of DiskANN maintenance (~10 ms/row at dim 256 and growing with the index).
99/// `IF EXISTS` because the recipe is also the recovery path — a load that
100/// failed before the drop is still a load whose finish rebuilds.
101///
102/// **The window is the caller's to close.** Between this and
103/// [`rebuild_embedding_index`] the model's vectors are not searchable and not
104/// dimension-checked at the storage layer; only the crate's own encode-time
105/// check (`EmbeddingCodec::encode`) stands, and it stands for everything that
106/// reaches storage through the crate. That trade is D-276's measured decision,
107/// not a silent default — see the register entry for the numbers and the
108/// Rejected line for keeping the index during loads.
109#[doc(hidden)]
110pub async fn drop_embedding_index(conn: &libsql::Connection, model: &ModelName) -> Result<()> {
111    conn.execute(
112        &format!("DROP INDEX IF EXISTS {}", model.index()),
113        (),
114    )
115    .await
116    .map(|_| ())
117    .map_err(Into::into)
118}
119
120/// Rebuild a model's DiskANN index in one pass (bulk-embedding finish, D-276).
121///
122/// One `CREATE INDEX` statement over the table's contents — a **one-pass
123/// DiskANN build**, measured at **2.61 s** for 2,000 vectors at dim 64,
124/// **19.7 s** at dim 256 and **39.0 s** at dim 512, versus 3.78 / 31.0 / 56.0 s
125/// for the same rows inserted through the indexed path (this box, release
126/// build, medians of three). It is one statement and has no smaller unit, so
127/// the hold is what it is: the actor is busy for the build's whole duration,
128/// and a search arriving meanwhile reports the missing index rather than
129/// waiting. The same trade [`Database::analyze`] documents for its own
130/// indivisible statement (D-166), priced in bulk-embedding currency.
131///
132/// `IF NOT EXISTS` so the rebuild is idempotent — a caller who bulk-loads twice
133/// without an intervening drop, or a failure path that rebuilds twice, lands
134/// the same index either way.
135#[doc(hidden)]
136pub async fn rebuild_embedding_index(conn: &libsql::Connection, model: &ModelName) -> Result<()> {
137    conn.execute(&ddl::create_embeddings_index(model), ())
138        .await
139        .map(|_| ())
140        .map_err(Into::into)
141}
142
143/// `Ok(None)` when the model has no table; `Ok(Some(dim))` when it has one.
144async fn read_declared_dimension(
145    conn: &libsql::Connection,
146    model: &ModelName,
147) -> Result<Option<usize>> {
148    // `model` is a ModelName, so this splice is a bare identifier by construction.
149    let mut rows = conn
150        .query(&format!("PRAGMA table_info({})", model.table()), ())
151        .await?;
152
153    while let Some(row) = rows.next().await? {
154        let name: String = row.get(1)?;
155        if name == "embedding" {
156            let declared: String = row.get(2)?;
157            return parse_f32_blob_dim(&declared).map(Some).ok_or_else(|| {
158                DbError::ModelNotRegistered {
159                    model: model.to_string(),
160                    table: format!(
161                        "{} (column `embedding` is declared {declared:?}, not F32_BLOB(n))",
162                        model.table()
163                    ),
164                }
165            });
166        }
167    }
168    Ok(None)
169}
170
171/// Pull `n` out of a declared type of the form `F32_BLOB(n)`.
172fn parse_f32_blob_dim(declared: &str) -> Option<usize> {
173    let t = declared.trim();
174    if !t.get(..9)?.eq_ignore_ascii_case("F32_BLOB(") || !t.ends_with(')') {
175        return None;
176    }
177    t[9..t.len() - 1].trim().parse::<usize>().ok()
178}
179
180/// Every model registered in this database, in name order.
181///
182/// Derived from `sqlite_master` for the same reason as the dimension: the set of
183/// registered models is a fact about the schema, and asking the schema cannot
184/// drift from it.
185///
186/// # `GLOB` rather than `LIKE`, and one filter rather than two (0.15.19, C-15)
187///
188/// `LIKE` treats `_` as *any single character*, and both arms of this filter
189/// contained one. The prefix arm was harmless — `embeddingsX_foo` would have
190/// matched, and the `strip_prefix` below rejects it anyway — but the exclusion
191/// arm was not: `NOT LIKE '%_shadow'` reads as *"anything ending in shadow,
192/// with any character before it"*, so a perfectly ordinary model named
193/// `ashadow` or `bigshadow` was **silently missing from this list**, and from
194/// everything that asks it what is registered. A model that exists, works, and
195/// does not appear in its own registry is the shape of defect this function was
196/// written to make impossible.
197///
198/// `GLOB`'s `_` is a literal, so the prefix says what it looks like. It is also
199/// case-sensitive, which costs nothing here and is the stricter reading:
200/// [`ModelName`] admits lowercase ASCII, digits and `_` only, so every table
201/// this crate creates is `embeddings_<model>` in exactly that case.
202///
203/// The shadow exclusion is gone rather than escaped. libSQL's internals are
204/// `libsql_vector_meta_shadow` and `<index>_shadow`, and this crate's indexes
205/// are named `idx_embeddings_<model>_vec` — so none of them start with
206/// `embeddings_` and the prefix already excludes every one. Escaping the
207/// underscore would have kept a filter that can only ever fire on a real model
208/// whose name happens to end in `_shadow`, which is the same defect one
209/// character narrower.
210pub async fn registered_models(conn: &libsql::Connection) -> Result<Vec<ModelName>> {
211    let mut rows = conn
212        .query(
213            "SELECT name FROM sqlite_master
214              WHERE type = 'table'
215                AND name GLOB 'embeddings_*'
216              ORDER BY name",
217            (),
218        )
219        .await?;
220
221    let mut out = Vec::new();
222    while let Some(row) = rows.next().await? {
223        let table: String = row.get(0)?;
224        if let Some(suffix) = table.strip_prefix("embeddings_") {
225            // A table matching the pattern but not the naming rule was not
226            // created by `register_model`; skipping it is more honest than
227            // reporting a name this crate would refuse to accept back.
228            if let Ok(model) = ModelName::new(suffix) {
229                out.push(model);
230            }
231        }
232    }
233    Ok(out)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::parse_f32_blob_dim;
239
240    #[test]
241    fn reads_the_dimension_out_of_a_declared_type() {
242        assert_eq!(parse_f32_blob_dim("F32_BLOB(768)"), Some(768));
243        assert_eq!(parse_f32_blob_dim("f32_blob(1536)"), Some(1536));
244        assert_eq!(parse_f32_blob_dim("  F32_BLOB( 4 )  "), Some(4));
245    }
246
247    /// Anything that is not a dimensioned vector column must read as absent
248    /// rather than as some default, or a plain BLOB column would silently
249    /// acquire whatever dimension the caller happened to pass.
250    #[test]
251    fn refuses_to_invent_a_dimension() {
252        for bad in [
253            "BLOB",
254            "TEXT",
255            "F32_BLOB",
256            "F32_BLOB()",
257            "F32_BLOB(x)",
258            "F32_BLOB(4",
259            "",
260        ] {
261            assert_eq!(parse_f32_blob_dim(bad), None, "{bad:?} yielded a dimension");
262        }
263    }
264}