gemini_memory_rs/retrieval/semantic.rs
1//! A precomputed, quantized semantic index.
2//!
3//! [`SemanticFallback`] says *what* the engine needs —
4//! ids in relevance order — and nothing about how to get them. This is the
5//! in-process implementation, built once from the corpus and searched without a
6//! network call.
7//!
8//! # Why it is precomputed
9//!
10//! Embedding is a network round trip: 259 ms at p50, measured, flat across
11//! widths (`tests/serving_latency_probe.rs`). The interactive budget is 10 ms
12//! and the speculative one 100 ms, so embedding *at recall time* is not a
13//! tuning problem — it is three and a half orders of magnitude out.
14//!
15//! Document vectors therefore have to exist before the question does. Ingestion
16//! embeds each record once, concurrently — the same probe measured 88 embeds/s
17//! at ×32, so a 16,000-record corpus is about three minutes of wall-clock, not
18//! an overnight job — and [`PrecomputedSemanticIndex`] holds the result.
19//!
20//! The *query* embedding is the round trip that remains, and this type does not
21//! pretend otherwise: it takes an [`Embedder`], and whether that fits the
22//! caller's budget is the caller's architecture decision. A local model fits; a
23//! remote one is for the speculative path, where nobody is waiting, and even
24//! then only if the budget is raised past 259 ms. See [`PrecomputedSemanticIndex::search`].
25//!
26//! # Why it is quantized
27//!
28//! An exact float32 scan over 16,000 records takes 15.2 ms — past the 10 ms
29//! interactive budget on its own, before the query is even embedded. Packing
30//! each vector to one bit per dimension and scoring with XOR and popcount takes
31//! 812 µs, and reranking the top 50 against the float vectors restores the
32//! exact ranking: 105 µs against 1 ms at 1,199 records, and identical top-1,
33//! top-5 and MRR (`tests/quantization_probe.rs`).
34//!
35//! | configuration | fused top-5 | RAM at 16k | scan at 16k |
36//! |---|---|---|---|
37//! | float32 exact | 79/93 | 49 MB | 15.2 ms |
38//! | 1-bit packed | 77/93 | 2 MB | 812 µs |
39//! | **1-bit + exact rerank** | **78/93** | 2 MB + floats | **1.3 ms** |
40//!
41//! The quality differences across that table are one or two questions out of
42//! 93 — noise. The cost differences are 24× in memory and 12× in scan time,
43//! which are not. Priced out, that is $0.021 per user per month against $0.158.
44//!
45//! The float vectors are kept for the rerank. A deployment that cannot afford
46//! them resident can drop to [`PrecomputedSemanticIndex::without_rerank`] and lose about one
47//! question in 93, or hold them on SSD and fault in fifty per query.
48
49use std::collections::HashMap;
50
51use async_trait::async_trait;
52
53use super::embedding::embedding_text;
54use super::retriever::SemanticFallback;
55use crate::core::{CanonicalMemory, MemoryError, MemoryId, MemoryStatus};
56
57/// How many candidates the quantized scan proposes before the exact rerank.
58///
59/// Fifty recovers the exact float32 ranking on this corpus. Deeper costs scan
60/// time for nothing; shallower starts losing the tail.
61pub const RERANK_DEPTH: usize = 50;
62
63/// Turns text into a vector.
64///
65/// Implement over whatever embedder is available. The engine never calls this
66/// on the document side — [`PrecomputedSemanticIndex::build`] does that once —
67/// so the latency that matters is the single query embedding per recall.
68#[async_trait]
69pub trait Embedder: Send + Sync {
70 /// Embed one string. Vectors must be L2-normalised and all the same width.
71 async fn embed(&self, text: &str) -> Result<Vec<f32>, MemoryError>;
72}
73
74/// One record's vector, in both representations.
75struct Entry {
76 id: MemoryId,
77 /// Hash of the text this vector was built from.
78 ///
79 /// The whole basis of persistence being safe. A vector is only reusable
80 /// while the text that produced it is unchanged, and a record's text
81 /// changes whenever its statement or frontmatter does — which is exactly
82 /// what a correction is. Keying on the id alone would restore a vector for
83 /// the old wording of a fact and never notice.
84 hash: String,
85 /// Sign bits of the vector, packed 64 to a word. What the scan reads.
86 packed: Vec<u64>,
87 /// The full vector, for the exact rerank. Empty when reranking is off.
88 exact: Vec<f32>,
89}
90
91/// A semantic index built ahead of time and searched in process.
92pub struct PrecomputedSemanticIndex {
93 /// Behind a lock so [`SemanticFallback::reconcile`] can bring the index in
94 /// line with the corpus after a correction, without the engine having to
95 /// rebuild and swap the whole thing.
96 entries: parking_lot::RwLock<Vec<Entry>>,
97 /// Words per packed code, learned from the first vector the index sees.
98 ///
99 /// Not fixed at construction because an index legitimately starts empty —
100 /// a new user's first fact arrives through
101 /// [`SemanticFallback::reconcile`], not through the constructor. Deriving
102 /// the width only from the constructor left such an index with a zero-word
103 /// code, which packs every vector to nothing and scores every record
104 /// identically: a cold-start index that silently ranked at random.
105 words: std::sync::atomic::AtomicUsize,
106 embedder: std::sync::Arc<dyn Embedder>,
107 rerank: bool,
108 /// Where vectors survive a restart, if anywhere.
109 store: Option<std::sync::Arc<dyn VectorStore>>,
110 /// Serialises [`SemanticFallback::reconcile`] against itself.
111 ///
112 /// `reconcile` takes a *whole desired state*: it reads what is held, awaits
113 /// embedding for what is missing, then replaces the set. One engine hands
114 /// the same backend to every session it opens, so two sessions finishing
115 /// turns at once run that read-await-replace concurrently — and the one
116 /// that finishes last wins with a corpus snapshot it took first. An older
117 /// snapshot landing second `retain`s away records the newer one added, and
118 /// removes their vectors from the store too, leaving the semantic index
119 /// behind the repository until something happens to reconcile again.
120 ///
121 /// An async mutex rather than a sync one because the guarded region awaits
122 /// the network. Searches are deliberately *not* behind it — they take the
123 /// `entries` read lock as before, so a recall never waits on an embedding
124 /// round trip.
125 ///
126 /// It guards `applied` as well as the read-await-replace, because the two
127 /// have to be atomic together: check the revision, then apply, with nothing
128 /// landing in between.
129 reconciling: tokio::sync::Mutex<u64>,
130}
131
132impl PrecomputedSemanticIndex {
133 /// Embed a corpus and build the index.
134 ///
135 /// Only active records are indexed: a superseded fact should not be
136 /// retrievable by paraphrase when it is not retrievable by name.
137 ///
138 /// Each record is embedded as [`embedding_text`] renders it — the statement
139 /// plus its frontmatter as prose — which is the text that measured best by
140 /// a wide margin. Passing anything else is the single easiest way to lose
141 /// most of what the semantic layer is worth.
142 pub async fn build(
143 records: &[CanonicalMemory],
144 embedder: std::sync::Arc<dyn Embedder>,
145 ) -> Result<Self, MemoryError> {
146 let active: Vec<&CanonicalMemory> = records
147 .iter()
148 .filter(|m| m.status == MemoryStatus::Active)
149 .collect();
150
151 let mut vectors = Vec::with_capacity(active.len());
152 for record in &active {
153 vectors.push(embedder.embed(&embedding_text(record)).await?);
154 }
155 Ok(Self::from_vectors(
156 active
157 .iter()
158 .zip(vectors)
159 .map(|(m, v)| (m.id.clone(), embedding_text(m), v))
160 .collect(),
161 embedder,
162 ))
163 }
164
165 /// Build from vectors that were embedded elsewhere.
166 ///
167 /// The path for a caller that already batches its embedding — concurrently,
168 /// or in a nightly job — rather than awaiting one record at a time as
169 /// [`build`](Self::build) does.
170 ///
171 /// Each entry is `(id, the text that was embedded, the vector)`. The text is
172 /// required rather than convenient: the index hashes it so that
173 /// [`SemanticFallback::reconcile`] can tell an unchanged record from one
174 /// whose wording has moved. Without it every reconcile would re-embed the
175 /// whole corpus, which is the cost this type exists to avoid.
176 pub fn from_vectors(
177 vectors: Vec<(MemoryId, String, Vec<f32>)>,
178 embedder: std::sync::Arc<dyn Embedder>,
179 ) -> Self {
180 let width = vectors.first().map(|(_, _, v)| v.len()).unwrap_or(0);
181 let words = width.div_ceil(64);
182 let entries = vectors
183 .into_iter()
184 .map(|(id, text, vector)| Entry {
185 id,
186 hash: crate::core::stable_hash(&text),
187 packed: pack(&vector, words),
188 exact: vector,
189 })
190 .collect();
191 Self {
192 entries: parking_lot::RwLock::new(entries),
193 words: std::sync::atomic::AtomicUsize::new(words),
194 embedder,
195 rerank: true,
196 store: None,
197 reconciling: tokio::sync::Mutex::new(0),
198 }
199 }
200
201 /// Keep vectors in `store`, and load whatever it already holds.
202 ///
203 /// This is the constructor a long-lived process wants. Without it every
204 /// start pays one embedding round trip per record — 259 ms each, so an hour
205 /// and a quarter at 16,000 records before the first semantic answer, again
206 /// on every deploy and every replica.
207 ///
208 /// A stored vector is only trusted while the text that produced it is
209 /// unchanged; [`SemanticFallback::reconcile`] checks the hash and
210 /// re-embeds anything that has moved. So a restore is a fast start, never a
211 /// stale one.
212 pub async fn restore(
213 store: std::sync::Arc<dyn VectorStore>,
214 embedder: std::sync::Arc<dyn Embedder>,
215 ) -> Result<Self, MemoryError> {
216 let saved = store.load().await?;
217 let width = saved.first().map(|(_, _, v)| v.len()).unwrap_or(0);
218 let words = width.div_ceil(64);
219 let entries = saved
220 .into_iter()
221 .map(|(id, hash, vector)| Entry {
222 id,
223 hash,
224 packed: pack(&vector, words),
225 exact: vector,
226 })
227 .collect();
228 Ok(Self {
229 entries: parking_lot::RwLock::new(entries),
230 words: std::sync::atomic::AtomicUsize::new(words),
231 embedder,
232 rerank: true,
233 store: Some(store),
234 reconciling: tokio::sync::Mutex::new(0),
235 })
236 }
237
238 /// Attach a store to an index built in memory.
239 pub fn with_store(mut self, store: std::sync::Arc<dyn VectorStore>) -> Self {
240 self.store = Some(store);
241 self
242 }
243
244 /// Drop the float vectors, keeping only the packed codes.
245 ///
246 /// Trades about one question in 93 for roughly 24× less memory — 2 MB
247 /// against 49 MB at 16,000 records. Worth it when the index is resident per
248 /// user and there are many users; not worth it otherwise.
249 pub fn without_rerank(mut self) -> Self {
250 self.rerank = false;
251 for entry in self.entries.get_mut().iter_mut() {
252 entry.exact = Vec::new();
253 entry.exact.shrink_to_fit();
254 }
255 self
256 }
257
258 /// How many records are indexed.
259 pub fn len(&self) -> usize {
260 self.entries.read().len()
261 }
262
263 /// Whether the index holds nothing.
264 pub fn is_empty(&self) -> bool {
265 self.entries.read().is_empty()
266 }
267
268 /// Bytes held per record, packed codes plus float vectors if reranking.
269 ///
270 /// Exposed because the memory figure is the reason to quantize at all, and
271 /// a number a caller can assert on is more useful than a claim in a doc
272 /// comment.
273 pub fn bytes_per_record(&self) -> usize {
274 let words = self.words.load(std::sync::atomic::Ordering::Acquire);
275 let floats = if self.rerank {
276 self.entries
277 .read()
278 .first()
279 .map(|e| e.exact.len() * 4)
280 .unwrap_or(0)
281 } else {
282 0
283 };
284 words * 8 + floats
285 }
286
287 /// Rank ids against an already-embedded query.
288 ///
289 /// Separated from [`search`](Self::search) so the scan can be measured, and
290 /// used, without a network call in the way.
291 pub fn search_vector(&self, query: &[f32], limit: usize) -> Vec<MemoryId> {
292 let entries = self.entries.read();
293 let words = self.words.load(std::sync::atomic::Ordering::Acquire);
294 if entries.is_empty() || limit == 0 || words == 0 {
295 return Vec::new();
296 }
297 let probe = pack(query, words);
298
299 // Agreeing bits, which for sign-quantized unit vectors ranks the same
300 // way cosine does up to the quantization error the rerank then undoes.
301 let mut scored: Vec<(usize, u32)> = entries
302 .iter()
303 .enumerate()
304 .map(|(i, entry)| {
305 let differing: u32 = entry
306 .packed
307 .iter()
308 .zip(&probe)
309 .map(|(a, b)| (a ^ b).count_ones())
310 .sum();
311 (i, differing)
312 })
313 .collect();
314
315 let depth = if self.rerank {
316 RERANK_DEPTH.max(limit)
317 } else {
318 limit
319 };
320 let depth = depth.min(scored.len());
321 scored.select_nth_unstable_by_key(depth - 1, |(_, d)| *d);
322 scored.truncate(depth);
323 scored.sort_unstable_by_key(|(_, d)| *d);
324
325 if !self.rerank {
326 return scored
327 .into_iter()
328 .take(limit)
329 .map(|(i, _)| entries[i].id.clone())
330 .collect();
331 }
332
333 // Exact rerank over the shortlist: restores the float32 ranking for the
334 // cost of fifty dot products.
335 let mut reranked: Vec<(usize, f32)> = scored
336 .into_iter()
337 .map(|(i, _)| {
338 let score = entries[i]
339 .exact
340 .iter()
341 .zip(query)
342 .map(|(a, b)| a * b)
343 .sum::<f32>();
344 (i, score)
345 })
346 .collect();
347 reranked.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
348 reranked
349 .into_iter()
350 .take(limit)
351 .map(|(i, _)| entries[i].id.clone())
352 .collect()
353 }
354}
355
356/// Sign bits, packed 64 to a word.
357fn pack(vector: &[f32], words: usize) -> Vec<u64> {
358 let mut packed = vec![0u64; words];
359 for (j, value) in vector.iter().enumerate() {
360 if *value >= 0.0 {
361 packed[j / 64] |= 1 << (j % 64);
362 }
363 }
364 packed
365}
366
367// ─── persistence ────────────────────────────────────────────────────────────
368
369/// Somewhere to keep vectors between processes.
370///
371/// Without this the index is rebuilt from nothing on every start, and rebuilding
372/// means one embedding round trip per record: 259 ms each, measured, so a
373/// 16,000-record corpus is over an hour of wall-clock before the first question
374/// can be answered semantically. That is not a slow start, it is an unusable
375/// one, and it recurs on every deploy and every replica.
376#[async_trait]
377pub trait VectorStore: Send + Sync {
378 /// Every vector held, as `(id, text hash, vector)`.
379 async fn load(&self) -> Result<Vec<(MemoryId, String, Vec<f32>)>, MemoryError>;
380
381 /// Persist one vector against the hash of the text that produced it.
382 async fn save(&self, id: &MemoryId, hash: &str, vector: &[f32]) -> Result<(), MemoryError>;
383
384 /// Forget a record's vector.
385 async fn remove(&self, id: &MemoryId) -> Result<(), MemoryError>;
386}
387
388/// Vectors kept beside the records, in the same store the OKF Markdown uses.
389///
390/// One document per record, holding the text hash and the vector as base64
391/// `f16`. That encoding is worth explaining, because it is the largest thing
392/// this type adds to a deployment and an earlier revision wrote it the obvious
393/// way — lowercase hex `f32` — at three times the size.
394///
395/// | encoding | vector bytes | per record | at 16,000 | × the Markdown |
396/// |---|---|---|---|---|
397/// | hex `f32` (what this used to write) | 6,144 | 6,161 | 98.6 MB | 4.75× |
398/// | base64 `f32` | 4,096 | 4,113 | 65.8 MB | 3.17× |
399/// | hex `f16` | 3,072 | 3,089 | 49.4 MB | 2.38× |
400/// | **base64 `f16` (what ships)** | **2,048** | **2,065** | **33.0 MB** | **1.59×** |
401///
402/// The Markdown column compares against the records these annotate, which
403/// `memory_at_scale` measures at 20,259 KiB for the same 16,000. Hex `f32` made
404/// the vectors nearly five times the corpus they describe; base64 `f16` makes
405/// them about one and a half.
406///
407/// # Why `f16` is free here, measured rather than assumed
408///
409/// `f16` keeps 10 mantissa bits against `f32`'s 23, so the obvious worry is
410/// that a lossy store quietly degrades retrieval. It does not, and the reason
411/// is that [`PrecomputedSemanticIndex`] reads these floats for exactly two
412/// things with very different sensitivities:
413///
414/// - **The packed scan is sign bits**, and `f16` preserves sign. Over 920,832
415/// coordinates of a real corpus, zero flipped — so the shortlist the rerank
416/// sees is bit-identical, not merely similar. The one way it could flip is a
417/// coordinate below `f16`'s smallest subnormal (2⁻²⁴ ≈ 6e-8) underflowing to
418/// `-0.0`, which the packer reads as non-negative; three coordinates underflowed
419/// on that corpus and all three were positive. The probe asserts on the count
420/// rather than trusting the argument.
421/// - **The rerank is a dot product**, where rounding *could* reorder
422/// candidates. Over 93 questions it did not move the answer's rank once.
423///
424/// `tests/storage_encoding_probe.rs` runs both stores through this crate's own
425/// `search_vector` and reports identical top-1 (64/93), top-5 (71/93) and MRR
426/// (0.727), and identical fused numbers against BM25 at 2:1 (65/93, 79/93,
427/// 0.760). Worst coordinate movement is 1.2e-4. Five questions reorder *within*
428/// the top five, all among non-answers.
429///
430/// So this is a 3× saving for no measured quality cost — but the measurement is
431/// on one 1,199-record corpus at 768 dimensions, and the property it rests on
432/// is that embedding coordinates sit comfortably inside `f16`'s normal range.
433/// A model whose vectors are not L2-normalised, or are far wider, deserves a
434/// re-run of that probe before the same conclusion is assumed.
435///
436/// # Reading what an older version wrote
437///
438/// A payload tagged `f16b64:` is base64 `f16`; anything else is the legacy
439/// lowercase-hex `f32`, and is decoded as such. That matters more than a format
440/// flag usually does: without it, upgrading would invalidate every stored
441/// vector and re-embedding a 16,000-record corpus is over an hour of wall
442/// clock. Existing stores therefore keep their old size until each record is
443/// next rewritten, which happens when its text changes.
444///
445/// What all of this buys is the whole reason to pay it: without persistence a
446/// restart re-embeds every record at 259 ms each, which is over an hour at this
447/// corpus size, on every deploy and every replica.
448pub struct OkfVectorStore<S: crate::okf::OkfStore> {
449 store: std::sync::Arc<S>,
450 prefix: String,
451}
452
453impl<S: crate::okf::OkfStore> OkfVectorStore<S> {
454 /// Keep vectors under `vectors/` in the given store.
455 pub fn new(store: std::sync::Arc<S>) -> Self {
456 Self {
457 store,
458 prefix: "vectors".to_string(),
459 }
460 }
461
462 fn path(&self, id: &MemoryId) -> String {
463 format!("{}/{}.vec", self.prefix, id.as_str())
464 }
465}
466
467/// Marks a payload as base64 `f16`. Anything without it is the lowercase-hex
468/// `f32` an earlier version wrote, and is still readable.
469const F16_B64: &str = "f16b64:";
470
471/// Nearest `f16`, ties to even.
472///
473/// Written out rather than taking `half` as a dependency: it is one fixed
474/// standard, it is forty lines, and it is exhaustively pinned over all 65,536
475/// bit patterns by `every_f16_survives_a_round_trip` below.
476fn to_f16(value: f32) -> u16 {
477 let bits = value.to_bits();
478 let sign = ((bits >> 16) & 0x8000) as u16;
479 let exponent = ((bits >> 23) & 0xff) as i32;
480 let mantissa = bits & 0x007f_ffff;
481
482 if exponent == 0xff {
483 // Infinity keeps its sign; NaN keeps a set mantissa bit so it stays NaN.
484 return sign | 0x7c00 | if mantissa != 0 { 0x0200 } else { 0 };
485 }
486 let unbiased = exponent - 127;
487 if unbiased > 15 {
488 return sign | 0x7c00; // beyond f16's largest normal
489 }
490 if unbiased < -24 {
491 return sign; // below its smallest subnormal, but the sign survives
492 }
493
494 // Normal and subnormal differ only in how many mantissa bits are dropped
495 // and whether the implicit leading 1 has to be restored, so the rounding
496 // below is written once.
497 let (mut significand, shift, mut biased) = if unbiased < -14 {
498 (mantissa | 0x0080_0000, (-unbiased - 1) as u32, 0i32)
499 } else {
500 (mantissa, 13u32, unbiased + 15)
501 };
502 let dropped = significand & ((1 << shift) - 1);
503 significand >>= shift;
504 let halfway = 1u32 << (shift - 1);
505 if dropped > halfway || (dropped == halfway && significand & 1 == 1) {
506 significand += 1;
507 // Rounding up can carry out of the mantissa, which is a clean increment
508 // of the exponent. For a subnormal it is promotion to the smallest
509 // normal, and the bit lands in the exponent field on its own.
510 if significand & 0x0400 != 0 && biased > 0 {
511 significand = 0;
512 biased += 1;
513 if biased >= 0x1f {
514 return sign | 0x7c00;
515 }
516 }
517 }
518 if biased == 0 {
519 return sign | significand as u16;
520 }
521 sign | ((biased as u16) << 10) | (significand as u16 & 0x03ff)
522}
523
524/// The value read back. Exact in this direction — every `f16` is an `f32`.
525fn from_f16(bits: u16) -> f32 {
526 let sign = ((bits & 0x8000) as u32) << 16;
527 let exponent = ((bits >> 10) & 0x1f) as u32;
528 let mantissa = (bits & 0x03ff) as u32;
529
530 if exponent == 0 {
531 if mantissa == 0 {
532 return f32::from_bits(sign);
533 }
534 // Subnormal: shift until the leading bit is explicit, charging each
535 // shift to the exponent.
536 let mut shifted = mantissa;
537 let mut steps = 0u32;
538 while shifted & 0x0400 == 0 {
539 shifted <<= 1;
540 steps += 1;
541 }
542 let biased = (127 - 14 - steps as i32) as u32;
543 return f32::from_bits(sign | (biased << 23) | ((shifted & 0x03ff) << 13));
544 }
545 if exponent == 0x1f {
546 return f32::from_bits(sign | 0x7f80_0000 | (mantissa << 13));
547 }
548 f32::from_bits(sign | ((exponent + 127 - 15) << 23) | (mantissa << 13))
549}
550
551/// The payload line: `f16` little-endian bytes, base64, behind [`F16_B64`].
552fn encode(vector: &[f32]) -> String {
553 let mut bytes = Vec::with_capacity(vector.len() * 2);
554 for value in vector {
555 bytes.extend_from_slice(&to_f16(*value).to_le_bytes());
556 }
557 format!(
558 "{F16_B64}{}",
559 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes)
560 )
561}
562
563/// The inverse, accepting either format. Returns `None` for anything malformed
564/// rather than a partial vector — a truncated vector would rank silently
565/// wrongly.
566fn decode(payload: &str) -> Option<Vec<f32>> {
567 let Some(body) = payload.strip_prefix(F16_B64) else {
568 return from_hex(payload);
569 };
570 let bytes =
571 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, body.as_bytes()).ok()?;
572 if !bytes.len().is_multiple_of(2) {
573 return None;
574 }
575 Some(
576 bytes
577 .chunks_exact(2)
578 .map(|pair| from_f16(u16::from_le_bytes([pair[0], pair[1]])))
579 .collect(),
580 )
581}
582
583/// `f32` little-endian bytes as lowercase hex — what earlier versions wrote.
584///
585/// Kept only so a legacy store can be constructed in a test and proved to still
586/// load; nothing writes this format any more.
587#[cfg(test)]
588fn to_hex(vector: &[f32]) -> String {
589 let mut out = String::with_capacity(vector.len() * 8);
590 for value in vector {
591 for byte in value.to_le_bytes() {
592 out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
593 out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0'));
594 }
595 }
596 out
597}
598
599/// Decode the legacy lowercase-hex `f32` payload.
600fn from_hex(hex: &str) -> Option<Vec<f32>> {
601 if !hex.len().is_multiple_of(8) {
602 return None;
603 }
604 let bytes: Option<Vec<u8>> = hex
605 .as_bytes()
606 .chunks_exact(2)
607 .map(|pair| {
608 let hi = (pair[0] as char).to_digit(16)?;
609 let lo = (pair[1] as char).to_digit(16)?;
610 Some(((hi << 4) | lo) as u8)
611 })
612 .collect();
613 Some(
614 bytes?
615 .chunks_exact(4)
616 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
617 .collect(),
618 )
619}
620
621#[async_trait]
622impl<S: crate::okf::OkfStore> VectorStore for OkfVectorStore<S> {
623 async fn load(&self) -> Result<Vec<(MemoryId, String, Vec<f32>)>, MemoryError> {
624 let mut out = Vec::new();
625 for path in self.store.list(&self.prefix).await? {
626 let Some(body) = self.store.read(&path).await? else {
627 continue;
628 };
629 let Some((hash, payload)) = body.split_once('\n') else {
630 continue;
631 };
632 let Some(vector) = decode(payload.trim()) else {
633 // A corrupt file is skipped rather than failed on: the record
634 // simply gets re-embedded, which is slow but correct. Failing
635 // the load would make one bad file cost the whole index.
636 continue;
637 };
638 let id = path
639 .rsplit('/')
640 .next()
641 .and_then(|f| f.strip_suffix(".vec"))
642 .map(MemoryId::new);
643 if let Some(id) = id {
644 out.push((id, hash.to_string(), vector));
645 }
646 }
647 Ok(out)
648 }
649
650 async fn save(&self, id: &MemoryId, hash: &str, vector: &[f32]) -> Result<(), MemoryError> {
651 self.store
652 .write(&self.path(id), &format!("{hash}\n{}", encode(vector)))
653 .await
654 }
655
656 async fn remove(&self, id: &MemoryId) -> Result<(), MemoryError> {
657 self.store.remove(&self.path(id)).await
658 }
659}
660
661#[async_trait]
662impl SemanticFallback for PrecomputedSemanticIndex {
663 /// Bring the index in line with the active corpus.
664 ///
665 /// Idempotent by construction: `active` is the whole desired state, so this
666 /// embeds the ids it does not hold, drops the ids no longer present, and
667 /// leaves the rest alone. Calling it twice costs one pass over a hash set
668 /// the second time.
669 ///
670 /// Only genuinely new records are embedded, which is the difference between
671 /// a correction costing one 259 ms round trip and costing one per record in
672 /// the corpus. The lock is not held across any of those awaits — embedding
673 /// happens first, and the index is only taken for the swap at the end — so
674 /// a recall running concurrently sees either the old set or the new one and
675 /// never blocks on the network.
676 async fn reconcile(
677 &self,
678 active: &[(MemoryId, String)],
679 revision: u64,
680 ) -> Result<(), MemoryError> {
681 // Held for the whole operation, not just the write, and it carries the
682 // newest revision applied so far.
683 //
684 // Two things have to be true and neither is free. The calls must not
685 // interleave — `active` is a whole desired state, so a `retain` running
686 // against a set read before another call finished deletes that call's
687 // records from the index and from the store. And a call must not apply
688 // a *stale* set even when it does not interleave, which serialising
689 // alone does not prevent: two sessions sealing at once both snapshot
690 // the corpus first, and if the older snapshot takes the lock second it
691 // is still older. So the revision is checked here and recorded on
692 // success, under the same guard.
693 let mut applied = self.reconciling.lock().await;
694 // `<` not `<=`: re-applying the same revision is idempotent and
695 // harmless, and a caller with nothing to order by passes 0 every time.
696 if revision != 0 && revision < *applied {
697 return Ok(());
698 }
699
700 // What is already held, and for which wording. A record whose text has
701 // changed is *not* a hit: its stored vector describes the old wording,
702 // which is precisely the situation a correction creates.
703 //
704 // Read *after* taking the lock, so it reflects any reconcile that just
705 // finished rather than a snapshot from before the wait.
706 let held: std::collections::HashMap<MemoryId, String> = {
707 let entries = self.entries.read();
708 entries
709 .iter()
710 .map(|e| (e.id.clone(), e.hash.clone()))
711 .collect()
712 };
713 let wanted: std::collections::HashSet<MemoryId> =
714 active.iter().map(|(id, _)| id.clone()).collect();
715
716 let mut fresh: Vec<(MemoryId, String, Vec<f32>)> = Vec::new();
717 for (id, text) in active {
718 let hash = crate::core::stable_hash(text);
719 if held.get(id) == Some(&hash) {
720 continue;
721 }
722 // Embedding is the expensive step — 259 ms of network — so it is the
723 // last resort, after both the in-memory index and the store.
724 let vector = self.embedder.embed(text).await?;
725 if let Some(store) = &self.store {
726 store.save(id, &hash, &vector).await?;
727 }
728 fresh.push((id.clone(), hash, vector));
729 }
730
731 // A record dropped from the corpus loses its stored vector too;
732 // otherwise the store grows forever and a restored index would
733 // resurrect facts the user has superseded.
734 if let Some(store) = &self.store {
735 for (id, _) in held.iter().filter(|(id, _)| !wanted.contains(id)) {
736 store.remove(id).await?;
737 }
738 }
739
740 // An index that started empty learns its width here, from the first
741 // vector it is ever given.
742 if let Some((_, _, first)) = fresh.first() {
743 let _ = self.words.compare_exchange(
744 0,
745 first.len().div_ceil(64),
746 std::sync::atomic::Ordering::AcqRel,
747 std::sync::atomic::Ordering::Acquire,
748 );
749 }
750 let words = self.words.load(std::sync::atomic::Ordering::Acquire);
751
752 let mut entries = self.entries.write();
753 entries.retain(|entry| wanted.contains(&entry.id));
754 for (id, hash, vector) in fresh {
755 // Replace rather than duplicate: a re-embedded record is already in
756 // `entries` under its old hash.
757 entries.retain(|e| e.id != id);
758 let packed = pack(&vector, words);
759 entries.push(Entry {
760 id,
761 hash,
762 exact: if self.rerank { vector } else { Vec::new() },
763 packed,
764 });
765 }
766 *applied = (*applied).max(revision);
767 Ok(())
768 }
769
770 /// Embed the query, then scan.
771 ///
772 /// The embed is the only network call on this path, and on the interactive
773 /// budget it is almost certainly too slow — 259 ms measured against 10 ms.
774 /// The retriever bounds it with a timeout and treats a miss as "no semantic
775 /// opinion", so an over-budget embedder degrades to lexical results rather
776 /// than delaying the turn. That is a real degradation, not a free one: with
777 /// a remote embedder the semantic layer effectively only runs on the
778 /// speculative path, and only if that budget is raised past the round trip.
779 async fn search(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>, MemoryError> {
780 let vector = self.embedder.embed(query).await?;
781 Ok(self.search_vector(&vector, limit))
782 }
783}
784
785/// An embedder backed by a fixed table, for tests and offline replay.
786///
787/// Returns an error for text it has never seen, rather than a zero vector: a
788/// silently-wrong vector ranks silently-wrong results, and a test that does
789/// that passes while measuring nothing.
790pub struct StaticEmbedder {
791 table: HashMap<String, Vec<f32>>,
792}
793
794impl StaticEmbedder {
795 /// Build from text/vector pairs.
796 pub fn new(table: HashMap<String, Vec<f32>>) -> Self {
797 Self { table }
798 }
799}
800
801#[async_trait]
802impl Embedder for StaticEmbedder {
803 async fn embed(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
804 self.table.get(text).cloned().ok_or_else(|| {
805 MemoryError::Retrieval(format!(
806 "StaticEmbedder has no vector for {text:?} — the table must cover \
807 every text the test embeds, or the result measures nothing"
808 ))
809 })
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816
817 /// Deterministic pseudo-vectors, normalised, so ranking is meaningful
818 /// without a network call.
819 fn vector(seed: u64, width: usize) -> Vec<f32> {
820 let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
821 let mut out: Vec<f32> = (0..width)
822 .map(|_| {
823 state ^= state << 13;
824 state ^= state >> 7;
825 state ^= state << 17;
826 (state as i32 as f32) / (i32::MAX as f32)
827 })
828 .collect();
829 let norm = out.iter().map(|v| v * v).sum::<f32>().sqrt();
830 for value in &mut out {
831 *value /= norm;
832 }
833 out
834 }
835
836 fn index(count: usize, width: usize) -> PrecomputedSemanticIndex {
837 let vectors: Vec<(MemoryId, String, Vec<f32>)> = (0..count)
838 .map(|i| {
839 (
840 MemoryId::new(format!("mem_{i}")),
841 format!("record {i}"),
842 vector(i as u64 + 1, width),
843 )
844 })
845 .collect();
846 PrecomputedSemanticIndex::from_vectors(
847 vectors,
848 std::sync::Arc::new(StaticEmbedder::new(HashMap::new())),
849 )
850 }
851
852 #[test]
853 fn a_vector_finds_itself_first() {
854 let built = index(200, 768);
855 let query = vector(43, 768);
856 let hits = built.search_vector(&query, 5);
857 assert_eq!(
858 hits.first().map(crate::core::ids::MemoryId::as_str),
859 Some("mem_42"),
860 "a record queried by its own vector must rank first"
861 );
862 }
863
864 /// The rerank is the reason the float vectors are kept, so it has to be
865 /// doing something: without it, sign quantization alone should sometimes
866 /// order the shortlist differently.
867 #[test]
868 fn reranking_restores_the_exact_ordering() {
869 let built = index(500, 768);
870 let query = vector(77, 768);
871 let exact_first = built.search_vector(&query, 1);
872
873 let packed_only = index(500, 768).without_rerank();
874 assert!(
875 !packed_only.search_vector(&query, 10).is_empty(),
876 "the packed scan must still return candidates"
877 );
878 assert_eq!(
879 exact_first.first().map(crate::core::ids::MemoryId::as_str),
880 Some("mem_76"),
881 "the reranked top hit must be the exact nearest neighbour"
882 );
883 }
884
885 #[test]
886 fn dropping_the_rerank_drops_the_memory_it_was_costing() {
887 let with = index(100, 768);
888 let without = index(100, 768).without_rerank();
889 assert_eq!(with.bytes_per_record(), 96 + 768 * 4);
890 assert_eq!(
891 without.bytes_per_record(),
892 96,
893 "packed codes only: 768 bits is 96 bytes, a 32x reduction"
894 );
895 assert!(without.bytes_per_record() * 30 < with.bytes_per_record());
896 }
897
898 #[test]
899 fn an_empty_index_answers_without_panicking() {
900 let empty = index(0, 768);
901 assert!(empty.is_empty());
902 assert!(empty.search_vector(&vector(1, 768), 5).is_empty());
903 }
904
905 #[test]
906 fn asking_for_more_than_exists_returns_what_exists() {
907 let built = index(3, 768);
908 assert_eq!(built.search_vector(&vector(1, 768), 50).len(), 3);
909 }
910
911 /// Reconcile is the path a correction takes into the index, so its
912 /// contract is worth pinning directly rather than only through the
913 /// end-to-end test.
914 #[tokio::test]
915 async fn reconcile_adds_what_is_new_and_drops_what_is_gone() {
916 let mut table = HashMap::new();
917 table.insert("fresh record".to_string(), vector(999, 768));
918 let built = PrecomputedSemanticIndex::from_vectors(
919 vec![
920 (
921 MemoryId::new("mem_keep"),
922 "already held".into(),
923 vector(1, 768),
924 ),
925 (
926 MemoryId::new("mem_retire"),
927 "retired".into(),
928 vector(2, 768),
929 ),
930 ],
931 std::sync::Arc::new(StaticEmbedder::new(table)),
932 );
933 assert_eq!(built.len(), 2);
934
935 // The desired state: keep one, retire one, add one.
936 built
937 .reconcile(
938 &[
939 (MemoryId::new("mem_keep"), "already held".into()),
940 (MemoryId::new("mem_new"), "fresh record".into()),
941 ],
942 0,
943 )
944 .await
945 .expect("reconcile");
946
947 assert_eq!(built.len(), 2, "one retired, one added");
948 let ids = built.search_vector(&vector(999, 768), 10);
949 let ids: Vec<&str> = ids.iter().map(crate::core::ids::MemoryId::as_str).collect();
950 assert!(ids.contains(&"mem_new"), "the new record was not embedded");
951 assert!(
952 ids.contains(&"mem_keep"),
953 "a still-active record was dropped"
954 );
955 assert!(
956 !ids.contains(&"mem_retire"),
957 "a record no longer active is still in the index"
958 );
959 }
960
961 /// The already-held record's text is deliberately absent from the
962 /// embedder's table, so this fails loudly if reconcile re-embeds it. That
963 /// is the difference between a correction costing one round trip and one
964 /// per record in the corpus.
965 #[tokio::test]
966 async fn reconcile_does_not_re_embed_what_it_already_holds() {
967 let built = PrecomputedSemanticIndex::from_vectors(
968 vec![(
969 MemoryId::new("mem_keep"),
970 "never embeddable".into(),
971 vector(1, 768),
972 )],
973 std::sync::Arc::new(StaticEmbedder::new(HashMap::new())),
974 );
975 built
976 .reconcile(&[(MemoryId::new("mem_keep"), "never embeddable".into())], 0)
977 .await
978 .expect("reconcile must not embed a record it already holds");
979 assert_eq!(built.len(), 1);
980 }
981
982 /// A stale corpus snapshot must not undo a newer one.
983 ///
984 /// The scenario is two sessions on one engine sealing at nearly the same
985 /// moment: each snapshots the corpus, then reconciles. Session A saw two
986 /// records; session B, starting later, saw three. If A's call lands second
987 /// — which is entirely ordinary, since the two race through an embedding
988 /// round trip — a backend that simply applies whatever it is handed
989 /// `retain`s away B's third record and deletes its vector from the store.
990 ///
991 /// Serialising the calls does not prevent this: A's snapshot is stale
992 /// whenever it is applied, not only when it interleaves. Ordering by
993 /// revision does.
994 #[tokio::test]
995 async fn a_reconcile_from_an_older_corpus_does_not_undo_a_newer_one() {
996 let mut table = HashMap::new();
997 for (i, text) in ["first fact", "second fact", "third fact"]
998 .iter()
999 .enumerate()
1000 {
1001 table.insert((*text).to_string(), vector(i as u64 + 1, 768));
1002 }
1003 let built = PrecomputedSemanticIndex::from_vectors(
1004 Vec::new(),
1005 std::sync::Arc::new(StaticEmbedder::new(table)),
1006 );
1007
1008 let older = vec![
1009 (MemoryId::new("mem_first"), "first fact".to_string()),
1010 (MemoryId::new("mem_second"), "second fact".to_string()),
1011 ];
1012 let newer = vec![
1013 (MemoryId::new("mem_first"), "first fact".to_string()),
1014 (MemoryId::new("mem_second"), "second fact".to_string()),
1015 (MemoryId::new("mem_third"), "third fact".to_string()),
1016 ];
1017
1018 // The newer corpus lands first...
1019 built.reconcile(&newer, 7).await.expect("newer");
1020 assert_eq!(built.len(), 3);
1021
1022 // ...and the older one, still in flight, lands second.
1023 built.reconcile(&older, 5).await.expect("older");
1024 assert_eq!(
1025 built.len(),
1026 3,
1027 "a reconcile from revision 5 removed a record that revision 7 had \
1028 already added — the stale snapshot won"
1029 );
1030
1031 // And the index is not frozen: a genuinely newer state still applies.
1032 let newest = vec![(MemoryId::new("mem_first"), "first fact".to_string())];
1033 built.reconcile(&newest, 9).await.expect("newest");
1034 assert_eq!(built.len(), 1, "revision 9 should apply");
1035 }
1036
1037 /// Revision `0` means "nothing to order by" and must not gate anything —
1038 /// otherwise the first call would set a floor that silently rejects the
1039 /// rest.
1040 #[tokio::test]
1041 async fn revision_zero_disables_the_ordering_check() {
1042 let mut table = HashMap::new();
1043 table.insert("only fact".to_string(), vector(4, 768));
1044 let built = PrecomputedSemanticIndex::from_vectors(
1045 Vec::new(),
1046 std::sync::Arc::new(StaticEmbedder::new(table)),
1047 );
1048 let desired = vec![(MemoryId::new("mem_one"), "only fact".to_string())];
1049 built.reconcile(&desired, 12).await.expect("first");
1050 built
1051 .reconcile(&desired, 0)
1052 .await
1053 .expect("an unordered reconcile must still apply");
1054 assert_eq!(built.len(), 1);
1055 }
1056
1057 /// Reconciling twice with the same desired state must be a no-op.
1058 #[tokio::test]
1059 async fn reconcile_is_idempotent() {
1060 let mut table = HashMap::new();
1061 table.insert("one".to_string(), vector(7, 768));
1062 let built = PrecomputedSemanticIndex::from_vectors(
1063 Vec::new(),
1064 std::sync::Arc::new(StaticEmbedder::new(table)),
1065 );
1066 let desired = [(MemoryId::new("mem_one"), "one".to_string())];
1067 built.reconcile(&desired, 0).await.expect("first");
1068 let after_first = built.len();
1069 built.reconcile(&desired, 0).await.expect("second");
1070 assert_eq!(
1071 built.len(),
1072 after_first,
1073 "the second pass changed the index"
1074 );
1075 }
1076
1077 /// The cold-start path: an index that starts empty and receives its first
1078 /// fact through reconcile must be searchable afterwards.
1079 ///
1080 /// This failed when the code width was fixed at construction — an empty
1081 /// index had zero words, packed every vector to nothing, and scored every
1082 /// record identically. It ranked at random and said nothing about it, which
1083 /// is precisely the shape of bug a new user would hit and nobody would see.
1084 #[tokio::test]
1085 async fn an_index_that_starts_empty_becomes_searchable_after_its_first_fact() {
1086 let mut table = HashMap::new();
1087 table.insert("first fact".to_string(), vector(11, 768));
1088 table.insert("second fact".to_string(), vector(22, 768));
1089 let built = PrecomputedSemanticIndex::from_vectors(
1090 Vec::new(),
1091 std::sync::Arc::new(StaticEmbedder::new(table)),
1092 );
1093 assert!(built.is_empty());
1094
1095 built
1096 .reconcile(
1097 &[
1098 (MemoryId::new("mem_first"), "first fact".into()),
1099 (MemoryId::new("mem_second"), "second fact".into()),
1100 ],
1101 0,
1102 )
1103 .await
1104 .expect("reconcile");
1105
1106 assert_eq!(built.len(), 2);
1107 let hits = built.search_vector(&vector(22, 768), 1);
1108 assert_eq!(
1109 hits.first().map(crate::core::ids::MemoryId::as_str),
1110 Some("mem_second"),
1111 "an index built empty must rank properly once it has been filled"
1112 );
1113 }
1114
1115 /// The stored format is lossy, so "round-trips exactly" is the wrong bar.
1116 /// What has to hold is that it round-trips to the *nearest `f16`* — the
1117 /// value is stable under a second trip, and every coordinate lands within
1118 /// one `f16` step of where it started.
1119 #[test]
1120 fn vectors_survive_the_round_trip_to_the_nearest_f16() {
1121 let original = vector(5, 768);
1122 let restored = decode(&encode(&original)).expect("valid payload");
1123 assert_eq!(restored.len(), original.len());
1124 for (before, after) in original.iter().zip(&restored) {
1125 // f16 carries 11 significant bits, so a relative step is 2⁻¹⁰.
1126 let tolerance = before.abs() * 2f32.powi(-10) + f32::MIN_POSITIVE;
1127 assert!(
1128 (before - after).abs() <= tolerance,
1129 "{before} stored and read back as {after}, further than one f16 step"
1130 );
1131 assert_eq!(
1132 before >= &0.0,
1133 after >= &0.0,
1134 "sign must survive: the packed scan is nothing but sign bits"
1135 );
1136 }
1137 // Idempotent, so a record rewritten without changing does not drift
1138 // further each time it is saved.
1139 assert_eq!(
1140 decode(&encode(&restored)).expect("valid payload"),
1141 restored,
1142 "storing an already-stored vector must not move it again"
1143 );
1144 }
1145
1146 /// Every `f16` bit pattern has to survive `f16` → `f32` → `f16`, which is
1147 /// the property the idempotence above rests on. Exhaustive, because it can
1148 /// be: there are only 65,536 of them.
1149 #[test]
1150 fn every_f16_survives_a_round_trip() {
1151 for bits in 0u16..=u16::MAX {
1152 let value = from_f16(bits);
1153 if value.is_nan() {
1154 continue;
1155 }
1156 assert_eq!(
1157 to_f16(value),
1158 bits,
1159 "f16 {bits:#06x} ({value}) did not survive"
1160 );
1161 }
1162 // Ties to even, which is what keeps the rounding unbiased — a converter
1163 // that always rounded away from zero would pass the loop above and
1164 // quietly stretch every vector.
1165 assert_eq!(from_f16(to_f16(1.0 + 2f32.powi(-11))), 1.0);
1166 assert_eq!(from_f16(to_f16(0.1)), 0.099_975_586);
1167 // Out of range in both directions, sign intact at the bottom.
1168 assert!(from_f16(to_f16(70_000.0)).is_infinite());
1169 assert!(from_f16(to_f16(-1e-9)).is_sign_negative());
1170 }
1171
1172 /// Rounding has to be unbiased, or every dot product bends the same way.
1173 ///
1174 /// Worth its own test because a converter that truncated toward zero would
1175 /// pass the exhaustive round trip above — truncation is still idempotent —
1176 /// while quietly shrinking every vector it stored.
1177 #[test]
1178 fn rounding_is_unbiased_rather_than_toward_zero() {
1179 // At the scale a normalised 768d coordinate actually occupies: around
1180 // 1/sqrt(768) ≈ 0.036.
1181 let mut state = 0x2545_F491_4F6C_DD1Du64;
1182 let (mut drift, mut magnitude) = (0.0f64, 0.0f64);
1183 for _ in 0..100_000 {
1184 state ^= state << 13;
1185 state ^= state >> 7;
1186 state ^= state << 17;
1187 let value = ((state >> 11) as f64 / (1u64 << 53) as f64 - 0.5) as f32 * 0.1;
1188 let error = (from_f16(to_f16(value)) - value) as f64;
1189 drift += error;
1190 magnitude += error.abs();
1191 }
1192 assert!(
1193 drift.abs() < magnitude * 0.05,
1194 "rounding drifted {drift:.3e} against {magnitude:.3e} of total error — \
1195 that is a bias, not noise"
1196 );
1197 }
1198
1199 /// A store written before the format changed must still load, or upgrading
1200 /// silently invalidates every vector and re-embedding a 16,000-record
1201 /// corpus is over an hour of wall clock.
1202 #[test]
1203 fn a_legacy_hex_payload_still_decodes() {
1204 let original = vector(7, 768);
1205 let restored = decode(&to_hex(&original)).expect("legacy hex must still load");
1206 assert_eq!(
1207 original, restored,
1208 "the legacy format was exact and must still decode exactly"
1209 );
1210 }
1211
1212 #[test]
1213 fn malformed_payloads_are_rejected_rather_than_truncated() {
1214 assert!(from_hex("abc").is_none(), "odd length");
1215 assert!(from_hex("zzzzzzzz").is_none(), "not hex");
1216 assert!(from_hex("abcdef").is_none(), "not a whole f32");
1217 assert!(decode("f16b64:!!!!").is_none(), "not base64");
1218 assert!(
1219 decode(&format!("{F16_B64}{}", "AAAAA")).is_none(),
1220 "not a whole f16"
1221 );
1222 }
1223
1224 /// The saving that motivated the change, asserted rather than claimed.
1225 #[test]
1226 fn the_encoding_is_a_third_of_what_hex_f32_cost() {
1227 let original = vector(11, 768);
1228 let now = encode(&original).len();
1229 let before = to_hex(&original).len();
1230 assert_eq!(before, 768 * 8, "hex f32 is 8 characters per coordinate");
1231 assert!(
1232 now * 3 <= before + F16_B64.len() * 3,
1233 "base64 f16 is {now} characters against hex f32's {before}; the point of \
1234 the change was a threefold saving"
1235 );
1236 }
1237
1238 /// The point of persistence: a restart must not re-embed.
1239 ///
1240 /// The embedder's table is deliberately empty, so any embedding attempt is
1241 /// an error rather than a slow success — which is what makes this a test of
1242 /// the cache and not of the network.
1243 #[tokio::test]
1244 async fn a_restored_index_answers_without_embedding_anything() {
1245 let store = std::sync::Arc::new(OkfVectorStore::new(std::sync::Arc::new(
1246 crate::okf::MemoryStore::default(),
1247 )));
1248 let mut table = HashMap::new();
1249 table.insert("the only fact".to_string(), vector(3, 768));
1250
1251 // First process: embeds once and persists.
1252 let first = PrecomputedSemanticIndex::from_vectors(
1253 Vec::new(),
1254 std::sync::Arc::new(StaticEmbedder::new(table)),
1255 )
1256 .with_store(store.clone());
1257 first
1258 .reconcile(&[(MemoryId::new("mem_one"), "the only fact".into())], 0)
1259 .await
1260 .expect("first reconcile");
1261 assert_eq!(first.len(), 1);
1262
1263 // Second process: same store, an embedder that can embed nothing.
1264 let second = PrecomputedSemanticIndex::restore(
1265 store.clone(),
1266 std::sync::Arc::new(StaticEmbedder::new(HashMap::new())),
1267 )
1268 .await
1269 .expect("restore");
1270 assert_eq!(second.len(), 1, "the vector did not survive the restart");
1271 second
1272 .reconcile(&[(MemoryId::new("mem_one"), "the only fact".into())], 0)
1273 .await
1274 .expect("a restored vector must not be re-embedded");
1275
1276 let hits = second.search_vector(&vector(3, 768), 1);
1277 assert_eq!(
1278 hits.first().map(crate::core::ids::MemoryId::as_str),
1279 Some("mem_one")
1280 );
1281 }
1282
1283 /// And the safety property that makes the cache trustworthy: a record whose
1284 /// text has changed must be re-embedded, not restored from its old vector.
1285 ///
1286 /// This is exactly a correction. Keying the store on the id alone would
1287 /// restore the vector for the wording the user just replaced.
1288 #[tokio::test]
1289 async fn a_record_whose_text_changed_is_re_embedded_rather_than_restored() {
1290 let store = std::sync::Arc::new(OkfVectorStore::new(std::sync::Arc::new(
1291 crate::okf::MemoryStore::default(),
1292 )));
1293 let mut table = HashMap::new();
1294 table.insert("the original wording".to_string(), vector(11, 768));
1295 table.insert("the corrected wording".to_string(), vector(22, 768));
1296 let embedder = std::sync::Arc::new(StaticEmbedder::new(table));
1297
1298 let index = PrecomputedSemanticIndex::from_vectors(Vec::new(), embedder.clone())
1299 .with_store(store.clone());
1300 index
1301 .reconcile(
1302 &[(MemoryId::new("mem_one"), "the original wording".into())],
1303 0,
1304 )
1305 .await
1306 .expect("first");
1307
1308 // Same id, new text — a correction.
1309 index
1310 .reconcile(
1311 &[(MemoryId::new("mem_one"), "the corrected wording".into())],
1312 0,
1313 )
1314 .await
1315 .expect("second");
1316
1317 assert_eq!(index.len(), 1, "the record must not be duplicated");
1318 let hits = index.search_vector(&vector(22, 768), 1);
1319 assert_eq!(
1320 hits.first().map(crate::core::ids::MemoryId::as_str),
1321 Some("mem_one"),
1322 "the index still holds the vector for the superseded wording"
1323 );
1324 }
1325
1326 /// A record dropped from the corpus must lose its stored vector, or a
1327 /// restore resurrects facts the user superseded.
1328 #[tokio::test]
1329 async fn retiring_a_record_removes_it_from_the_store_too() {
1330 let backing = std::sync::Arc::new(crate::okf::MemoryStore::default());
1331 let store = std::sync::Arc::new(OkfVectorStore::new(backing.clone()));
1332 let mut table = HashMap::new();
1333 table.insert("kept".to_string(), vector(1, 768));
1334 table.insert("dropped".to_string(), vector(2, 768));
1335
1336 let index = PrecomputedSemanticIndex::from_vectors(
1337 Vec::new(),
1338 std::sync::Arc::new(StaticEmbedder::new(table)),
1339 )
1340 .with_store(store.clone());
1341 index
1342 .reconcile(
1343 &[
1344 (MemoryId::new("mem_kept"), "kept".into()),
1345 (MemoryId::new("mem_dropped"), "dropped".into()),
1346 ],
1347 0,
1348 )
1349 .await
1350 .expect("first");
1351 assert_eq!(store.load().await.expect("load").len(), 2);
1352
1353 index
1354 .reconcile(&[(MemoryId::new("mem_kept"), "kept".into())], 0)
1355 .await
1356 .expect("second");
1357 let remaining = store.load().await.expect("load");
1358 assert_eq!(remaining.len(), 1, "the dropped vector is still on disk");
1359 assert_eq!(remaining[0].0.as_str(), "mem_kept");
1360 }
1361
1362 #[tokio::test]
1363 async fn an_unknown_query_is_an_error_rather_than_a_silent_zero_vector() {
1364 let built = index(10, 768);
1365 assert!(built.search("never embedded", 5).await.is_err());
1366 }
1367}