gonzalo_core/vector_manifest.rs
1//! The identity layer over a durable vector index: which shards exist, which
2//! blob holds each, and which embedding space they are all in (ADR 0027).
3//!
4//! Structurally this mirrors [`Manifest`](crate::Manifest) — a record body
5//! mapping identifiers to [`ContentHash`]es of out-of-line content — but it is
6//! *not* regenerable. A code-graph manifest can be rebuilt by re-reading source;
7//! a vector manifest cannot, because with caller-supplied embeddings gonzalo
8//! never sees the model that produced the vectors. That difference is why this
9//! kind is [`MergeClass::Opaque`](crate::MergeClass::Opaque) while a graph
10//! manifest is `Derived`.
11
12use crate::{Body, ContentHash, CoreError, RecordKey, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16/// The collection segment under which every vector index's manifest is addressed.
17const MANIFEST_COLLECTION: &str = "vector-manifest";
18
19/// A durable vector index's manifest body.
20///
21/// `entries` is a [`BTreeMap`] so serialization has deterministic key order: a
22/// manifest with the same contents always hashes identically, keeping its record
23/// revision stable under content-addressed dedup.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct VectorManifest {
26 /// Caller-declared embedding space, e.g. `"bge-small-en-v1.5"`.
27 ///
28 /// Gonzalo cannot verify a vector came from this model — it never sees the
29 /// model. This records what the writer *declared*, which is enough to catch
30 /// configuration drift when a reader declares something else.
31 pub space: String,
32 /// Vector dimension. Every entry in every shard carries exactly this many floats.
33 pub dim: usize,
34 /// Number of shards, fixed when the index is created.
35 pub shards: u16,
36 /// Shard id -> the blob holding that shard's vectors.
37 pub entries: BTreeMap<u16, ContentHash>,
38}
39
40impl VectorManifest {
41 /// An empty manifest for a new index.
42 pub fn new(space: impl Into<String>, dim: usize, shards: u16) -> Self {
43 Self {
44 space: space.into(),
45 dim,
46 shards,
47 entries: BTreeMap::new(),
48 }
49 }
50
51 /// The stable [`RecordKey`] addressing the index `index_id` in `namespace`.
52 pub fn key(namespace: impl Into<String>, index_id: impl Into<String>) -> RecordKey {
53 RecordKey::new(namespace, MANIFEST_COLLECTION, index_id)
54 }
55
56 /// The collection segment every vector manifest is addressed under. A
57 /// [`KeyPrefix`](crate::KeyPrefix) with this collection and no namespace
58 /// lists every index's manifest. This is a naming convention for callers,
59 /// not the set GC unions: GC marks by
60 /// [`RecordKind::VectorManifest`](crate::RecordKind::VectorManifest) while
61 /// scanning every record regardless of collection, and a durable index's
62 /// `open` accepts a manifest [`key`](Self::key) under any namespace.
63 pub fn collection() -> &'static str {
64 MANIFEST_COLLECTION
65 }
66
67 /// Serialize into an inline record [`Body`] (deterministic key order).
68 pub fn to_body(&self) -> Body {
69 Body::Inline(serde_json::to_vec(self).expect("VectorManifest serializes"))
70 }
71
72 /// Reconstruct from a record [`Body`]. Errors if the bytes are not a valid
73 /// serialized manifest.
74 pub fn from_body(body: &Body) -> Result<Self> {
75 serde_json::from_slice(body.bytes()).map_err(|e| CoreError::Serde(e.to_string()))
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::{MergeClass, RecordKind};
83
84 fn hash(s: &str) -> ContentHash {
85 ContentHash::of(s.as_bytes())
86 }
87
88 #[test]
89 fn body_round_trips() {
90 let mut m = VectorManifest::new("bge-small-en-v1.5", 384, 256);
91 m.entries.insert(0, hash("shard-0"));
92 m.entries.insert(17, hash("shard-17"));
93
94 assert_eq!(VectorManifest::from_body(&m.to_body()).unwrap(), m);
95 }
96
97 #[test]
98 fn body_bytes_are_deterministic_regardless_of_insert_order() {
99 let mut a = VectorManifest::new("space", 8, 4);
100 a.entries.insert(3, hash("x"));
101 a.entries.insert(1, hash("y"));
102
103 let mut b = VectorManifest::new("space", 8, 4);
104 b.entries.insert(1, hash("y"));
105 b.entries.insert(3, hash("x"));
106
107 assert_eq!(a.to_body().bytes(), b.to_body().bytes());
108 }
109
110 #[test]
111 fn from_body_rejects_non_manifest_bytes() {
112 let garbage = Body::Inline(b"not json at all".to_vec());
113 assert!(matches!(
114 VectorManifest::from_body(&garbage),
115 Err(CoreError::Serde(_))
116 ));
117 }
118
119 #[test]
120 fn key_addresses_namespace_and_index_id() {
121 let k = VectorManifest::key("acme", "memories");
122 assert_eq!(k.namespace, "acme");
123 assert_eq!(k.collection, VectorManifest::collection());
124 assert_eq!(k.id, "memories");
125 }
126
127 // Caller-supplied vectors cannot be re-derived from source, so `Derived`
128 // (which resolves a divergence in favour of one side without a merge) would
129 // silently discard the other side's vectors for good. `Structured` fails the
130 // same way: two writers touching one shard produce different blob hashes and
131 // a field merge keeps only one. Opaque surfaces it instead.
132 #[test]
133 fn vector_manifest_kind_is_opaque_not_derived() {
134 assert_eq!(RecordKind::VectorManifest.merge_class(), MergeClass::Opaque);
135 }
136}