Skip to main content

reddb_server/storage/vector/
introspection.rs

1//! Vector + TurboQuant introspection — issue #743.
2//!
3//! Red UI vector toolbars need to render, for every vector collection:
4//!
5//!   - "what is this collection?" — source column / payload field,
6//!     dimensions, metric, index type, row count, whether SEARCH is
7//!     currently answerable;
8//!   - "what is the artifact?" — build state, whether an encoded
9//!     artifact is on disk, the stable TurboQuant / TurboVec
10//!     parameters, whether we are serving searches off a scalar
11//!     fallback, rebuild progress (when one is in flight), and the
12//!     last error if the most recent build failed.
13//!
14//! The frontend must answer those questions **without** depending on
15//! the layout of `engine::vector_store`, `engine::turboquant::*`,
16//! segment-state enums, or any on-disk binary shape — those internals
17//! churn faster than the UI contract can. This module is the stable
18//! surface that mediates between them: a small set of plain-data types
19//! (`VectorMetadata`, `ArtifactMetadata`, the `ArtifactState` enum and
20//! its companions) plus an in-memory registry the runtime publishes to
21//! whenever a collection's vector or artifact state changes.
22//!
23//! Lifecycle model — the five states Red UI distinguishes:
24//!
25//! - [`ArtifactState::Unavailable`] — no artifact has ever been built
26//!   for this collection (e.g. it was just created, or the artifact
27//!   was explicitly dropped). SEARCH against it falls through to the
28//!   row store or returns NOT_READY depending on the kind.
29//! - [`ArtifactState::Building`] — a background rebuild is in flight.
30//!   `rebuild_progress_pct` may be populated when the builder can
31//!   estimate it. SEARCH callers see NOT_READY until the build
32//!   completes.
33//! - [`ArtifactState::Ready`] — the artifact is loaded, current with
34//!   the row store, and SEARCH is served from it.
35//! - [`ArtifactState::Failed`] — the last build attempt errored;
36//!   `last_error` carries the operator-facing message. SEARCH may
37//!   fall through to the scalar path if `scalar_fallback_active` is
38//!   true, otherwise it returns NOT_READY.
39//! - [`ArtifactState::Fallback`] — the artifact is intentionally not
40//!   the primary search path right now (e.g. dimensions changed and
41//!   we are serving scalar until the rebuild finishes). Distinct from
42//!   `Building` because the artifact on disk may itself be `Ready`
43//!   for *some* shape — the runtime has just decided not to use it.
44//!
45//! Independence from internal storage modules is the load-bearing
46//! property here. The registry stores `String` enum tags for index
47//! type / metric / param family rather than re-exporting the engine
48//! enums, precisely so a future internal rename in
49//! `engine::turboquant` does not force a Red UI release. The follow-up
50//! slice that wires concrete publish points from the engine into this
51//! registry is tracked in PRD #735; the public Rust surface here is
52//! the contract those publish points target and does not change when
53//! they land.
54
55use std::collections::HashMap;
56use std::sync::Mutex;
57
58/// Stable lifecycle bucket for a vector collection's search artifact.
59/// Snapshot consumers (Red UI, virtual tables) read this flag and
60/// never re-derive the rule from internal engine state.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ArtifactState {
63    /// No artifact has ever been built for this collection.
64    Unavailable,
65    /// A background rebuild is in flight.
66    Building,
67    /// Artifact is loaded and serving SEARCH.
68    Ready,
69    /// The most recent build attempt failed; see `last_error`.
70    Failed,
71    /// The runtime is intentionally serving SEARCH off a scalar
72    /// fallback path rather than the artifact (e.g. dimension drift,
73    /// codebook mismatch). Distinct from `Building` and `Failed`.
74    Fallback,
75}
76
77impl ArtifactState {
78    pub fn as_str(self) -> &'static str {
79        match self {
80            ArtifactState::Unavailable => "unavailable",
81            ArtifactState::Building => "building",
82            ArtifactState::Ready => "ready",
83            ArtifactState::Failed => "failed",
84            ArtifactState::Fallback => "fallback",
85        }
86    }
87}
88
89/// Where a vector collection's vectors come from. Red UI surfaces this
90/// so an operator can tell "this is a typed `VECTOR` column" apart
91/// from "this is a JSON payload field we lift at ingest time".
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum VectorSource {
94    /// Typed `VECTOR(<dim>)` column on a SQL collection. The string is
95    /// the column name.
96    Column(String),
97    /// Embedded payload field on a document/blob collection. The
98    /// string is the dotted path.
99    Payload(String),
100}
101
102impl VectorSource {
103    pub fn as_str(&self) -> &str {
104        match self {
105            VectorSource::Column(s) | VectorSource::Payload(s) => s.as_str(),
106        }
107    }
108
109    pub fn kind_str(&self) -> &'static str {
110        match self {
111            VectorSource::Column(_) => "column",
112            VectorSource::Payload(_) => "payload",
113        }
114    }
115}
116
117/// Operator-facing index kind tag. We deliberately ship this as a
118/// small string-backed enum rather than re-exporting
119/// `engine::IndexType`, so an internal rename of the engine enum does
120/// not break the UI contract.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum VectorIndexType {
123    Hnsw,
124    Ivf,
125    TurboQuant,
126    TurboVec,
127    /// Scalar / brute-force scan. Stable name even though "no index"
128    /// is the underlying truth.
129    Scalar,
130}
131
132impl VectorIndexType {
133    pub fn as_str(self) -> &'static str {
134        match self {
135            VectorIndexType::Hnsw => "hnsw",
136            VectorIndexType::Ivf => "ivf",
137            VectorIndexType::TurboQuant => "turboquant",
138            VectorIndexType::TurboVec => "turbovec",
139            VectorIndexType::Scalar => "scalar",
140        }
141    }
142}
143
144/// Distance metric, as a stable string contract independent of
145/// `engine::DistanceMetric`.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum DistanceMetric {
148    Cosine,
149    InnerProduct,
150    L2,
151}
152
153impl DistanceMetric {
154    pub fn as_str(self) -> &'static str {
155        match self {
156            DistanceMetric::Cosine => "cosine",
157            DistanceMetric::InnerProduct => "inner_product",
158            DistanceMetric::L2 => "l2",
159        }
160    }
161}
162
163/// Stable subset of the TurboQuant / TurboVec parameters Red UI is
164/// allowed to display. Anything that is not yet load-bearing or that
165/// the engine considers internal is intentionally omitted — adding a
166/// field here is a contract change.
167#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub struct TurboArtifactParams {
169    /// Family tag — `"turboquant"` or `"turbovec"`. Free string so
170    /// future variants don't force an enum migration on the UI.
171    pub family: String,
172    /// Number of codebook subspaces (`M` in PQ-style schemes).
173    pub subspaces: Option<u32>,
174    /// Bits per code symbol.
175    pub bits_per_code: Option<u32>,
176    /// Codebook entry count per subspace (typically `1 << bits_per_code`).
177    pub codebook_size: Option<u32>,
178}
179
180/// Per-collection metadata about the vector data itself. Independent
181/// of artifact state — a collection can have meaningful metadata even
182/// when its artifact is `Unavailable`.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct VectorMetadata {
185    pub collection: String,
186    pub source: VectorSource,
187    pub dimensions: u32,
188    pub metric: DistanceMetric,
189    pub index_type: VectorIndexType,
190    pub row_count: u64,
191    /// Whether SEARCH against this collection can return rows right
192    /// now. Convenience flag derived from artifact state + fallback
193    /// availability at publish time, so the UI does not have to
194    /// re-derive it from the artifact row.
195    pub search_capable: bool,
196}
197
198/// Per-collection metadata about the on-disk / in-memory artifact
199/// (TurboQuant / TurboVec / scalar fallback). Carries enough for Red
200/// UI to render the toolbar without inspecting the engine.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ArtifactMetadata {
203    pub collection: String,
204    pub state: ArtifactState,
205    /// True when an encoded artifact (e.g. a `.tv` snapshot) is
206    /// present and loadable; orthogonal to `state` because an
207    /// artifact can be present-but-`Fallback` or
208    /// present-but-`Building` (a newer one is being rebuilt over it).
209    pub encoded_artifact_present: bool,
210    /// Stable, operator-facing slice of the TurboQuant / TurboVec
211    /// parameters. `None` for scalar-only collections.
212    pub params: Option<TurboArtifactParams>,
213    /// Whether SEARCH is currently being answered (or could be) from
214    /// the scalar fallback path rather than the artifact.
215    pub scalar_fallback_active: bool,
216    /// 0..=100, when the builder can estimate it; `None` otherwise
217    /// (including outside `Building`).
218    pub rebuild_progress_pct: Option<u8>,
219    /// Operator-facing message from the most recent build failure.
220    /// Populated in `Failed`; may also be populated in `Fallback` to
221    /// explain *why* we are falling back. Cleared when the next build
222    /// succeeds.
223    pub last_error: Option<String>,
224}
225
226/// One row of vector + artifact introspection. Snapshot consumers get
227/// the two halves bundled so the UI does not have to join on
228/// `collection` itself.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct VectorIntrospection {
231    pub vector: VectorMetadata,
232    pub artifact: ArtifactMetadata,
233}
234
235#[derive(Debug, Clone)]
236struct Entry {
237    vector: VectorMetadata,
238    artifact: ArtifactMetadata,
239}
240
241/// Process-local registry the runtime publishes vector/artifact state
242/// into. The shape mirrors `storage::queue::presence::ConsumerPresenceRegistry`
243/// from issue #742: cheap mutex + small hashmap is the right fit
244/// because the cardinality is bounded by the operator's collection
245/// count (dozens, not millions) and reads are dominated by snapshot.
246#[derive(Debug, Default)]
247pub struct VectorIntrospectionRegistry {
248    entries: Mutex<HashMap<String, Entry>>,
249}
250
251impl VectorIntrospectionRegistry {
252    pub fn new() -> Self {
253        Self::default()
254    }
255
256    /// Publish (or replace) the full introspection row for a
257    /// collection. Callers in the engine compute the typed shape once
258    /// at the right moment (collection create, artifact build start /
259    /// finish, fallback toggle) and hand it over; the registry does
260    /// not try to derive anything.
261    pub fn publish(&self, vector: VectorMetadata, artifact: ArtifactMetadata) {
262        debug_assert_eq!(
263            vector.collection, artifact.collection,
264            "vector and artifact metadata must agree on collection name"
265        );
266        let key = vector.collection.clone();
267        let mut map = self.entries.lock().unwrap_or_else(|p| p.into_inner());
268        map.insert(key, Entry { vector, artifact });
269    }
270
271    /// Replace only the artifact half (build start/finish, fallback
272    /// toggle, error). No-op if the collection has not been published
273    /// yet, because the artifact row alone has no useful meaning
274    /// without the vector row it sits next to.
275    pub fn update_artifact(&self, artifact: ArtifactMetadata) -> bool {
276        let mut map = self.entries.lock().unwrap_or_else(|p| p.into_inner());
277        match map.get_mut(&artifact.collection) {
278            Some(entry) => {
279                // Keep `search_capable` consistent with the new
280                // artifact state. Specifically: a Ready artifact, or a
281                // Fallback / Failed with the scalar fallback active,
282                // can answer SEARCH; everything else cannot.
283                let capable = match artifact.state {
284                    ArtifactState::Ready => true,
285                    ArtifactState::Fallback | ArtifactState::Failed => {
286                        artifact.scalar_fallback_active
287                    }
288                    ArtifactState::Building | ArtifactState::Unavailable => {
289                        artifact.scalar_fallback_active
290                    }
291                };
292                entry.vector.search_capable = capable;
293                entry.artifact = artifact;
294                true
295            }
296            None => false,
297        }
298    }
299
300    /// Drop a collection's introspection row (e.g. on `DROP COLLECTION`).
301    pub fn forget(&self, collection: &str) -> bool {
302        let mut map = self.entries.lock().unwrap_or_else(|p| p.into_inner());
303        map.remove(collection).is_some()
304    }
305
306    /// Snapshot of every tracked collection, deterministically ordered
307    /// by `collection` so test assertions and Red UI tables both see
308    /// a stable shape.
309    pub fn snapshot(&self) -> Vec<VectorIntrospection> {
310        let map = self.entries.lock().unwrap_or_else(|p| p.into_inner());
311        let mut rows: Vec<VectorIntrospection> = map
312            .values()
313            .map(|e| VectorIntrospection {
314                vector: e.vector.clone(),
315                artifact: e.artifact.clone(),
316            })
317            .collect();
318        rows.sort_by(|a, b| a.vector.collection.cmp(&b.vector.collection));
319        rows
320    }
321
322    /// Single-collection lookup, for the per-collection metadata
323    /// endpoint Red UI hits when it opens one vector's toolbar.
324    pub fn get(&self, collection: &str) -> Option<VectorIntrospection> {
325        let map = self.entries.lock().unwrap_or_else(|p| p.into_inner());
326        map.get(collection).map(|e| VectorIntrospection {
327            vector: e.vector.clone(),
328            artifact: e.artifact.clone(),
329        })
330    }
331
332    pub fn len(&self) -> usize {
333        self.entries.lock().unwrap_or_else(|p| p.into_inner()).len()
334    }
335
336    pub fn is_empty(&self) -> bool {
337        self.len() == 0
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn ready_vector(collection: &str, dim: u32) -> VectorMetadata {
346        VectorMetadata {
347            collection: collection.into(),
348            source: VectorSource::Column("embedding".into()),
349            dimensions: dim,
350            metric: DistanceMetric::Cosine,
351            index_type: VectorIndexType::TurboQuant,
352            row_count: 1_024,
353            search_capable: true,
354        }
355    }
356
357    fn ready_artifact(collection: &str) -> ArtifactMetadata {
358        ArtifactMetadata {
359            collection: collection.into(),
360            state: ArtifactState::Ready,
361            encoded_artifact_present: true,
362            params: Some(TurboArtifactParams {
363                family: "turboquant".into(),
364                subspaces: Some(8),
365                bits_per_code: Some(8),
366                codebook_size: Some(256),
367            }),
368            scalar_fallback_active: false,
369            rebuild_progress_pct: None,
370            last_error: None,
371        }
372    }
373
374    /// Acceptance: "Tests cover a basic vector collection".
375    ///
376    /// A ready TurboQuant collection round-trips through the registry
377    /// with every field intact, surfaces as `search_capable`, and is
378    /// reachable by both `snapshot()` and `get()`.
379    #[test]
380    fn ready_collection_round_trips_through_registry() {
381        let reg = VectorIntrospectionRegistry::new();
382        reg.publish(ready_vector("docs", 384), ready_artifact("docs"));
383
384        assert_eq!(reg.len(), 1);
385        let row = reg.get("docs").expect("collection was published");
386        assert_eq!(row.vector.collection, "docs");
387        assert_eq!(row.vector.dimensions, 384);
388        assert_eq!(row.vector.metric, DistanceMetric::Cosine);
389        assert_eq!(row.vector.index_type, VectorIndexType::TurboQuant);
390        assert_eq!(row.vector.row_count, 1_024);
391        assert!(row.vector.search_capable);
392        assert!(matches!(row.vector.source, VectorSource::Column(ref c) if c == "embedding"));
393
394        assert_eq!(row.artifact.state, ArtifactState::Ready);
395        assert!(row.artifact.encoded_artifact_present);
396        assert!(!row.artifact.scalar_fallback_active);
397        assert!(row.artifact.last_error.is_none());
398        let params = row.artifact.params.expect("turbo params present");
399        assert_eq!(params.family, "turboquant");
400        assert_eq!(params.subspaces, Some(8));
401        assert_eq!(params.bits_per_code, Some(8));
402        assert_eq!(params.codebook_size, Some(256));
403
404        let snap = reg.snapshot();
405        assert_eq!(snap.len(), 1);
406        assert_eq!(snap[0].vector.collection, "docs");
407    }
408
409    /// Acceptance: "Tests cover ... at least one unavailable or
410    /// fallback artifact state."
411    ///
412    /// Encodes both: a freshly-created collection lands as
413    /// `Unavailable` and not search-capable; switching it to
414    /// `Fallback` with `scalar_fallback_active=true` flips
415    /// `search_capable` back on without losing the artifact row.
416    #[test]
417    fn unavailable_then_fallback_states_are_distinguishable() {
418        let reg = VectorIntrospectionRegistry::new();
419
420        let mut vector = ready_vector("embeddings", 128);
421        vector.row_count = 0;
422        vector.search_capable = false;
423        let unavailable = ArtifactMetadata {
424            collection: "embeddings".into(),
425            state: ArtifactState::Unavailable,
426            encoded_artifact_present: false,
427            params: None,
428            scalar_fallback_active: false,
429            rebuild_progress_pct: None,
430            last_error: None,
431        };
432        reg.publish(vector, unavailable);
433
434        let row = reg.get("embeddings").unwrap();
435        assert_eq!(row.artifact.state, ArtifactState::Unavailable);
436        assert!(!row.artifact.encoded_artifact_present);
437        assert!(row.artifact.params.is_none());
438        assert!(!row.vector.search_capable);
439
440        let fallback = ArtifactMetadata {
441            collection: "embeddings".into(),
442            state: ArtifactState::Fallback,
443            encoded_artifact_present: true,
444            params: Some(TurboArtifactParams {
445                family: "turbovec".into(),
446                subspaces: Some(4),
447                bits_per_code: Some(4),
448                codebook_size: Some(16),
449            }),
450            scalar_fallback_active: true,
451            rebuild_progress_pct: None,
452            last_error: Some("dimension drift; serving scalar until rebuild".into()),
453        };
454        assert!(reg.update_artifact(fallback));
455
456        let row = reg.get("embeddings").unwrap();
457        assert_eq!(row.artifact.state, ArtifactState::Fallback);
458        assert!(row.artifact.scalar_fallback_active);
459        assert!(row
460            .artifact
461            .last_error
462            .as_deref()
463            .unwrap()
464            .contains("scalar"));
465        assert!(
466            row.vector.search_capable,
467            "scalar fallback keeps SEARCH alive even when the artifact is in Fallback"
468        );
469    }
470
471    /// Acceptance: "The contract distinguishes unavailable, building,
472    /// ready, failed, and fallback states."
473    ///
474    /// Walks the artifact through Building → Failed → Ready and
475    /// verifies `search_capable` tracks the rules in
476    /// `update_artifact`: only Ready (or a state with the scalar
477    /// fallback active) keeps SEARCH alive.
478    #[test]
479    fn artifact_states_distinct_and_search_capability_tracks_them() {
480        let reg = VectorIntrospectionRegistry::new();
481        reg.publish(ready_vector("k", 64), ready_artifact("k"));
482
483        let building = ArtifactMetadata {
484            collection: "k".into(),
485            state: ArtifactState::Building,
486            encoded_artifact_present: false,
487            params: None,
488            scalar_fallback_active: false,
489            rebuild_progress_pct: Some(42),
490            last_error: None,
491        };
492        assert!(reg.update_artifact(building));
493        let row = reg.get("k").unwrap();
494        assert_eq!(row.artifact.state, ArtifactState::Building);
495        assert_eq!(row.artifact.rebuild_progress_pct, Some(42));
496        assert!(
497            !row.vector.search_capable,
498            "Building without fallback is not search-capable"
499        );
500
501        let failed = ArtifactMetadata {
502            collection: "k".into(),
503            state: ArtifactState::Failed,
504            encoded_artifact_present: false,
505            params: None,
506            scalar_fallback_active: false,
507            rebuild_progress_pct: None,
508            last_error: Some("codec error: subspace=3 page=12".into()),
509        };
510        assert!(reg.update_artifact(failed));
511        let row = reg.get("k").unwrap();
512        assert_eq!(row.artifact.state, ArtifactState::Failed);
513        assert!(!row.vector.search_capable);
514        assert_eq!(
515            row.artifact.last_error.as_deref(),
516            Some("codec error: subspace=3 page=12")
517        );
518
519        // Recover to Ready — search_capable must flip back on and the
520        // stale error must be cleared by the caller (the registry
521        // stores what it is handed, by design).
522        assert!(reg.update_artifact(ready_artifact("k")));
523        let row = reg.get("k").unwrap();
524        assert_eq!(row.artifact.state, ArtifactState::Ready);
525        assert!(row.vector.search_capable);
526        assert!(row.artifact.last_error.is_none());
527    }
528
529    #[test]
530    fn update_artifact_no_ops_for_unpublished_collection() {
531        let reg = VectorIntrospectionRegistry::new();
532        let orphan = ArtifactMetadata {
533            collection: "ghost".into(),
534            state: ArtifactState::Building,
535            encoded_artifact_present: false,
536            params: None,
537            scalar_fallback_active: false,
538            rebuild_progress_pct: None,
539            last_error: None,
540        };
541        assert!(!reg.update_artifact(orphan));
542        assert!(reg.is_empty());
543    }
544
545    #[test]
546    fn forget_drops_collection() {
547        let reg = VectorIntrospectionRegistry::new();
548        reg.publish(ready_vector("a", 8), ready_artifact("a"));
549        reg.publish(ready_vector("b", 8), ready_artifact("b"));
550        assert!(reg.forget("a"));
551        assert!(!reg.forget("a"), "second forget no-ops");
552        let names: Vec<_> = reg
553            .snapshot()
554            .into_iter()
555            .map(|r| r.vector.collection)
556            .collect();
557        assert_eq!(names, vec!["b".to_string()]);
558    }
559
560    #[test]
561    fn snapshot_is_deterministically_ordered() {
562        let reg = VectorIntrospectionRegistry::new();
563        // Insert shuffled.
564        reg.publish(ready_vector("zeta", 8), ready_artifact("zeta"));
565        reg.publish(ready_vector("alpha", 8), ready_artifact("alpha"));
566        reg.publish(ready_vector("mu", 8), ready_artifact("mu"));
567
568        let names: Vec<_> = reg
569            .snapshot()
570            .into_iter()
571            .map(|r| r.vector.collection)
572            .collect();
573        assert_eq!(
574            names,
575            vec!["alpha".to_string(), "mu".to_string(), "zeta".to_string()]
576        );
577    }
578
579    /// The five public `ArtifactState` variants must serialize to the
580    /// stable string tags Red UI relies on. Treat this as the contract
581    /// pin — changing any of these strings is a breaking change.
582    #[test]
583    fn artifact_state_strings_are_stable() {
584        assert_eq!(ArtifactState::Unavailable.as_str(), "unavailable");
585        assert_eq!(ArtifactState::Building.as_str(), "building");
586        assert_eq!(ArtifactState::Ready.as_str(), "ready");
587        assert_eq!(ArtifactState::Failed.as_str(), "failed");
588        assert_eq!(ArtifactState::Fallback.as_str(), "fallback");
589    }
590}