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}