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