Skip to main content

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}