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}