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}