Skip to main content

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            .as_chunks::<2>()
578            .0
579            .iter()
580            .map(|pair| from_f16(u16::from_le_bytes([pair[0], pair[1]])))
581            .collect(),
582    )
583}
584
585/// `f32` little-endian bytes as lowercase hex — what earlier versions wrote.
586///
587/// Kept only so a legacy store can be constructed in a test and proved to still
588/// load; nothing writes this format any more.
589#[cfg(test)]
590fn to_hex(vector: &[f32]) -> String {
591    let mut out = String::with_capacity(vector.len() * 8);
592    for value in vector {
593        for byte in value.to_le_bytes() {
594            out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
595            out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0'));
596        }
597    }
598    out
599}
600
601/// Decode the legacy lowercase-hex `f32` payload.
602fn from_hex(hex: &str) -> Option<Vec<f32>> {
603    if !hex.len().is_multiple_of(8) {
604        return None;
605    }
606    let bytes: Option<Vec<u8>> = hex
607        .as_bytes()
608        .as_chunks::<2>()
609        .0
610        .iter()
611        .map(|pair| {
612            let hi = (pair[0] as char).to_digit(16)?;
613            let lo = (pair[1] as char).to_digit(16)?;
614            Some(((hi << 4) | lo) as u8)
615        })
616        .collect();
617    Some(
618        bytes?
619            .as_chunks::<4>()
620            .0
621            .iter()
622            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
623            .collect(),
624    )
625}
626
627#[async_trait]
628impl<S: crate::okf::OkfStore> VectorStore for OkfVectorStore<S> {
629    async fn load(&self) -> Result<Vec<(MemoryId, String, Vec<f32>)>, MemoryError> {
630        let mut out = Vec::new();
631        for path in self.store.list(&self.prefix).await? {
632            let Some(body) = self.store.read(&path).await? else {
633                continue;
634            };
635            let Some((hash, payload)) = body.split_once('\n') else {
636                continue;
637            };
638            let Some(vector) = decode(payload.trim()) else {
639                // A corrupt file is skipped rather than failed on: the record
640                // simply gets re-embedded, which is slow but correct. Failing
641                // the load would make one bad file cost the whole index.
642                continue;
643            };
644            let id = path
645                .rsplit('/')
646                .next()
647                .and_then(|f| f.strip_suffix(".vec"))
648                .map(MemoryId::new);
649            if let Some(id) = id {
650                out.push((id, hash.to_string(), vector));
651            }
652        }
653        Ok(out)
654    }
655
656    async fn save(&self, id: &MemoryId, hash: &str, vector: &[f32]) -> Result<(), MemoryError> {
657        self.store
658            .write(&self.path(id), &format!("{hash}\n{}", encode(vector)))
659            .await
660    }
661
662    async fn remove(&self, id: &MemoryId) -> Result<(), MemoryError> {
663        self.store.remove(&self.path(id)).await
664    }
665}
666
667#[async_trait]
668impl SemanticFallback for PrecomputedSemanticIndex {
669    /// Bring the index in line with the active corpus.
670    ///
671    /// Idempotent by construction: `active` is the whole desired state, so this
672    /// embeds the ids it does not hold, drops the ids no longer present, and
673    /// leaves the rest alone. Calling it twice costs one pass over a hash set
674    /// the second time.
675    ///
676    /// Only genuinely new records are embedded, which is the difference between
677    /// a correction costing one 259 ms round trip and costing one per record in
678    /// the corpus. The lock is not held across any of those awaits — embedding
679    /// happens first, and the index is only taken for the swap at the end — so
680    /// a recall running concurrently sees either the old set or the new one and
681    /// never blocks on the network.
682    async fn reconcile(
683        &self,
684        active: &[(MemoryId, String)],
685        revision: u64,
686    ) -> Result<(), MemoryError> {
687        // Held for the whole operation, not just the write, and it carries the
688        // newest revision applied so far.
689        //
690        // Two things have to be true and neither is free. The calls must not
691        // interleave — `active` is a whole desired state, so a `retain` running
692        // against a set read before another call finished deletes that call's
693        // records from the index and from the store. And a call must not apply
694        // a *stale* set even when it does not interleave, which serialising
695        // alone does not prevent: two sessions sealing at once both snapshot
696        // the corpus first, and if the older snapshot takes the lock second it
697        // is still older. So the revision is checked here and recorded on
698        // success, under the same guard.
699        let mut applied = self.reconciling.lock().await;
700        // `<` not `<=`: re-applying the same revision is idempotent and
701        // harmless, and a caller with nothing to order by passes 0 every time.
702        if revision != 0 && revision < *applied {
703            return Ok(());
704        }
705
706        // What is already held, and for which wording. A record whose text has
707        // changed is *not* a hit: its stored vector describes the old wording,
708        // which is precisely the situation a correction creates.
709        //
710        // Read *after* taking the lock, so it reflects any reconcile that just
711        // finished rather than a snapshot from before the wait.
712        let held: std::collections::HashMap<MemoryId, String> = {
713            let entries = self.entries.read();
714            entries
715                .iter()
716                .map(|e| (e.id.clone(), e.hash.clone()))
717                .collect()
718        };
719        let wanted: std::collections::HashSet<MemoryId> =
720            active.iter().map(|(id, _)| id.clone()).collect();
721
722        let mut fresh: Vec<(MemoryId, String, Vec<f32>)> = Vec::new();
723        for (id, text) in active {
724            let hash = crate::core::stable_hash(text);
725            if held.get(id) == Some(&hash) {
726                continue;
727            }
728            // Embedding is the expensive step — 259 ms of network — so it is the
729            // last resort, after both the in-memory index and the store.
730            let vector = self.embedder.embed(text).await?;
731            if let Some(store) = &self.store {
732                store.save(id, &hash, &vector).await?;
733            }
734            fresh.push((id.clone(), hash, vector));
735        }
736
737        // A record dropped from the corpus loses its stored vector too;
738        // otherwise the store grows forever and a restored index would
739        // resurrect facts the user has superseded.
740        if let Some(store) = &self.store {
741            for (id, _) in held.iter().filter(|(id, _)| !wanted.contains(id)) {
742                store.remove(id).await?;
743            }
744        }
745
746        // An index that started empty learns its width here, from the first
747        // vector it is ever given.
748        if let Some((_, _, first)) = fresh.first() {
749            let _ = self.words.compare_exchange(
750                0,
751                first.len().div_ceil(64),
752                std::sync::atomic::Ordering::AcqRel,
753                std::sync::atomic::Ordering::Acquire,
754            );
755        }
756        let words = self.words.load(std::sync::atomic::Ordering::Acquire);
757
758        let mut entries = self.entries.write();
759        entries.retain(|entry| wanted.contains(&entry.id));
760        for (id, hash, vector) in fresh {
761            // Replace rather than duplicate: a re-embedded record is already in
762            // `entries` under its old hash.
763            entries.retain(|e| e.id != id);
764            let packed = pack(&vector, words);
765            entries.push(Entry {
766                id,
767                hash,
768                exact: if self.rerank { vector } else { Vec::new() },
769                packed,
770            });
771        }
772        *applied = (*applied).max(revision);
773        Ok(())
774    }
775
776    /// Embed the query, then scan.
777    ///
778    /// The embed is the only network call on this path, and on the interactive
779    /// budget it is almost certainly too slow — 259 ms measured against 10 ms.
780    /// The retriever bounds it with a timeout and treats a miss as "no semantic
781    /// opinion", so an over-budget embedder degrades to lexical results rather
782    /// than delaying the turn. That is a real degradation, not a free one: with
783    /// a remote embedder the semantic layer effectively only runs on the
784    /// speculative path, and only if that budget is raised past the round trip.
785    async fn search(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>, MemoryError> {
786        let vector = self.embedder.embed(query).await?;
787        Ok(self.search_vector(&vector, limit))
788    }
789}
790
791/// An embedder backed by a fixed table, for tests and offline replay.
792///
793/// Returns an error for text it has never seen, rather than a zero vector: a
794/// silently-wrong vector ranks silently-wrong results, and a test that does
795/// that passes while measuring nothing.
796pub struct StaticEmbedder {
797    table: HashMap<String, Vec<f32>>,
798}
799
800impl StaticEmbedder {
801    /// Build from text/vector pairs.
802    pub fn new(table: HashMap<String, Vec<f32>>) -> Self {
803        Self { table }
804    }
805}
806
807#[async_trait]
808impl Embedder for StaticEmbedder {
809    async fn embed(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
810        self.table.get(text).cloned().ok_or_else(|| {
811            MemoryError::Retrieval(format!(
812                "StaticEmbedder has no vector for {text:?} — the table must cover \
813                 every text the test embeds, or the result measures nothing"
814            ))
815        })
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    /// Deterministic pseudo-vectors, normalised, so ranking is meaningful
824    /// without a network call.
825    fn vector(seed: u64, width: usize) -> Vec<f32> {
826        let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
827        let mut out: Vec<f32> = (0..width)
828            .map(|_| {
829                state ^= state << 13;
830                state ^= state >> 7;
831                state ^= state << 17;
832                (state as i32 as f32) / (i32::MAX as f32)
833            })
834            .collect();
835        let norm = out.iter().map(|v| v * v).sum::<f32>().sqrt();
836        for value in &mut out {
837            *value /= norm;
838        }
839        out
840    }
841
842    fn index(count: usize, width: usize) -> PrecomputedSemanticIndex {
843        let vectors: Vec<(MemoryId, String, Vec<f32>)> = (0..count)
844            .map(|i| {
845                (
846                    MemoryId::new(format!("mem_{i}")),
847                    format!("record {i}"),
848                    vector(i as u64 + 1, width),
849                )
850            })
851            .collect();
852        PrecomputedSemanticIndex::from_vectors(
853            vectors,
854            std::sync::Arc::new(StaticEmbedder::new(HashMap::new())),
855        )
856    }
857
858    #[test]
859    fn a_vector_finds_itself_first() {
860        let built = index(200, 768);
861        let query = vector(43, 768);
862        let hits = built.search_vector(&query, 5);
863        assert_eq!(
864            hits.first().map(crate::core::ids::MemoryId::as_str),
865            Some("mem_42"),
866            "a record queried by its own vector must rank first"
867        );
868    }
869
870    /// The rerank is the reason the float vectors are kept, so it has to be
871    /// doing something: without it, sign quantization alone should sometimes
872    /// order the shortlist differently.
873    #[test]
874    fn reranking_restores_the_exact_ordering() {
875        let built = index(500, 768);
876        let query = vector(77, 768);
877        let exact_first = built.search_vector(&query, 1);
878
879        let packed_only = index(500, 768).without_rerank();
880        assert!(
881            !packed_only.search_vector(&query, 10).is_empty(),
882            "the packed scan must still return candidates"
883        );
884        assert_eq!(
885            exact_first.first().map(crate::core::ids::MemoryId::as_str),
886            Some("mem_76"),
887            "the reranked top hit must be the exact nearest neighbour"
888        );
889    }
890
891    #[test]
892    fn dropping_the_rerank_drops_the_memory_it_was_costing() {
893        let with = index(100, 768);
894        let without = index(100, 768).without_rerank();
895        assert_eq!(with.bytes_per_record(), 96 + 768 * 4);
896        assert_eq!(
897            without.bytes_per_record(),
898            96,
899            "packed codes only: 768 bits is 96 bytes, a 32x reduction"
900        );
901        assert!(without.bytes_per_record() * 30 < with.bytes_per_record());
902    }
903
904    #[test]
905    fn an_empty_index_answers_without_panicking() {
906        let empty = index(0, 768);
907        assert!(empty.is_empty());
908        assert!(empty.search_vector(&vector(1, 768), 5).is_empty());
909    }
910
911    #[test]
912    fn asking_for_more_than_exists_returns_what_exists() {
913        let built = index(3, 768);
914        assert_eq!(built.search_vector(&vector(1, 768), 50).len(), 3);
915    }
916
917    /// Reconcile is the path a correction takes into the index, so its
918    /// contract is worth pinning directly rather than only through the
919    /// end-to-end test.
920    #[tokio::test]
921    async fn reconcile_adds_what_is_new_and_drops_what_is_gone() {
922        let mut table = HashMap::new();
923        table.insert("fresh record".to_string(), vector(999, 768));
924        let built = PrecomputedSemanticIndex::from_vectors(
925            vec![
926                (
927                    MemoryId::new("mem_keep"),
928                    "already held".into(),
929                    vector(1, 768),
930                ),
931                (
932                    MemoryId::new("mem_retire"),
933                    "retired".into(),
934                    vector(2, 768),
935                ),
936            ],
937            std::sync::Arc::new(StaticEmbedder::new(table)),
938        );
939        assert_eq!(built.len(), 2);
940
941        // The desired state: keep one, retire one, add one.
942        built
943            .reconcile(
944                &[
945                    (MemoryId::new("mem_keep"), "already held".into()),
946                    (MemoryId::new("mem_new"), "fresh record".into()),
947                ],
948                0,
949            )
950            .await
951            .expect("reconcile");
952
953        assert_eq!(built.len(), 2, "one retired, one added");
954        let ids = built.search_vector(&vector(999, 768), 10);
955        let ids: Vec<&str> = ids.iter().map(crate::core::ids::MemoryId::as_str).collect();
956        assert!(ids.contains(&"mem_new"), "the new record was not embedded");
957        assert!(
958            ids.contains(&"mem_keep"),
959            "a still-active record was dropped"
960        );
961        assert!(
962            !ids.contains(&"mem_retire"),
963            "a record no longer active is still in the index"
964        );
965    }
966
967    /// The already-held record's text is deliberately absent from the
968    /// embedder's table, so this fails loudly if reconcile re-embeds it. That
969    /// is the difference between a correction costing one round trip and one
970    /// per record in the corpus.
971    #[tokio::test]
972    async fn reconcile_does_not_re_embed_what_it_already_holds() {
973        let built = PrecomputedSemanticIndex::from_vectors(
974            vec![(
975                MemoryId::new("mem_keep"),
976                "never embeddable".into(),
977                vector(1, 768),
978            )],
979            std::sync::Arc::new(StaticEmbedder::new(HashMap::new())),
980        );
981        built
982            .reconcile(&[(MemoryId::new("mem_keep"), "never embeddable".into())], 0)
983            .await
984            .expect("reconcile must not embed a record it already holds");
985        assert_eq!(built.len(), 1);
986    }
987
988    /// A stale corpus snapshot must not undo a newer one.
989    ///
990    /// The scenario is two sessions on one engine sealing at nearly the same
991    /// moment: each snapshots the corpus, then reconciles. Session A saw two
992    /// records; session B, starting later, saw three. If A's call lands second
993    /// — which is entirely ordinary, since the two race through an embedding
994    /// round trip — a backend that simply applies whatever it is handed
995    /// `retain`s away B's third record and deletes its vector from the store.
996    ///
997    /// Serialising the calls does not prevent this: A's snapshot is stale
998    /// whenever it is applied, not only when it interleaves. Ordering by
999    /// revision does.
1000    #[tokio::test]
1001    async fn a_reconcile_from_an_older_corpus_does_not_undo_a_newer_one() {
1002        let mut table = HashMap::new();
1003        for (i, text) in ["first fact", "second fact", "third fact"]
1004            .iter()
1005            .enumerate()
1006        {
1007            table.insert((*text).to_string(), vector(i as u64 + 1, 768));
1008        }
1009        let built = PrecomputedSemanticIndex::from_vectors(
1010            Vec::new(),
1011            std::sync::Arc::new(StaticEmbedder::new(table)),
1012        );
1013
1014        let older = vec![
1015            (MemoryId::new("mem_first"), "first fact".to_string()),
1016            (MemoryId::new("mem_second"), "second fact".to_string()),
1017        ];
1018        let newer = vec![
1019            (MemoryId::new("mem_first"), "first fact".to_string()),
1020            (MemoryId::new("mem_second"), "second fact".to_string()),
1021            (MemoryId::new("mem_third"), "third fact".to_string()),
1022        ];
1023
1024        // The newer corpus lands first...
1025        built.reconcile(&newer, 7).await.expect("newer");
1026        assert_eq!(built.len(), 3);
1027
1028        // ...and the older one, still in flight, lands second.
1029        built.reconcile(&older, 5).await.expect("older");
1030        assert_eq!(
1031            built.len(),
1032            3,
1033            "a reconcile from revision 5 removed a record that revision 7 had \
1034             already added — the stale snapshot won"
1035        );
1036
1037        // And the index is not frozen: a genuinely newer state still applies.
1038        let newest = vec![(MemoryId::new("mem_first"), "first fact".to_string())];
1039        built.reconcile(&newest, 9).await.expect("newest");
1040        assert_eq!(built.len(), 1, "revision 9 should apply");
1041    }
1042
1043    /// Revision `0` means "nothing to order by" and must not gate anything —
1044    /// otherwise the first call would set a floor that silently rejects the
1045    /// rest.
1046    #[tokio::test]
1047    async fn revision_zero_disables_the_ordering_check() {
1048        let mut table = HashMap::new();
1049        table.insert("only fact".to_string(), vector(4, 768));
1050        let built = PrecomputedSemanticIndex::from_vectors(
1051            Vec::new(),
1052            std::sync::Arc::new(StaticEmbedder::new(table)),
1053        );
1054        let desired = vec![(MemoryId::new("mem_one"), "only fact".to_string())];
1055        built.reconcile(&desired, 12).await.expect("first");
1056        built
1057            .reconcile(&desired, 0)
1058            .await
1059            .expect("an unordered reconcile must still apply");
1060        assert_eq!(built.len(), 1);
1061    }
1062
1063    /// Reconciling twice with the same desired state must be a no-op.
1064    #[tokio::test]
1065    async fn reconcile_is_idempotent() {
1066        let mut table = HashMap::new();
1067        table.insert("one".to_string(), vector(7, 768));
1068        let built = PrecomputedSemanticIndex::from_vectors(
1069            Vec::new(),
1070            std::sync::Arc::new(StaticEmbedder::new(table)),
1071        );
1072        let desired = [(MemoryId::new("mem_one"), "one".to_string())];
1073        built.reconcile(&desired, 0).await.expect("first");
1074        let after_first = built.len();
1075        built.reconcile(&desired, 0).await.expect("second");
1076        assert_eq!(
1077            built.len(),
1078            after_first,
1079            "the second pass changed the index"
1080        );
1081    }
1082
1083    /// The cold-start path: an index that starts empty and receives its first
1084    /// fact through reconcile must be searchable afterwards.
1085    ///
1086    /// This failed when the code width was fixed at construction — an empty
1087    /// index had zero words, packed every vector to nothing, and scored every
1088    /// record identically. It ranked at random and said nothing about it, which
1089    /// is precisely the shape of bug a new user would hit and nobody would see.
1090    #[tokio::test]
1091    async fn an_index_that_starts_empty_becomes_searchable_after_its_first_fact() {
1092        let mut table = HashMap::new();
1093        table.insert("first fact".to_string(), vector(11, 768));
1094        table.insert("second fact".to_string(), vector(22, 768));
1095        let built = PrecomputedSemanticIndex::from_vectors(
1096            Vec::new(),
1097            std::sync::Arc::new(StaticEmbedder::new(table)),
1098        );
1099        assert!(built.is_empty());
1100
1101        built
1102            .reconcile(
1103                &[
1104                    (MemoryId::new("mem_first"), "first fact".into()),
1105                    (MemoryId::new("mem_second"), "second fact".into()),
1106                ],
1107                0,
1108            )
1109            .await
1110            .expect("reconcile");
1111
1112        assert_eq!(built.len(), 2);
1113        let hits = built.search_vector(&vector(22, 768), 1);
1114        assert_eq!(
1115            hits.first().map(crate::core::ids::MemoryId::as_str),
1116            Some("mem_second"),
1117            "an index built empty must rank properly once it has been filled"
1118        );
1119    }
1120
1121    /// The stored format is lossy, so "round-trips exactly" is the wrong bar.
1122    /// What has to hold is that it round-trips to the *nearest `f16`* — the
1123    /// value is stable under a second trip, and every coordinate lands within
1124    /// one `f16` step of where it started.
1125    #[test]
1126    fn vectors_survive_the_round_trip_to_the_nearest_f16() {
1127        let original = vector(5, 768);
1128        let restored = decode(&encode(&original)).expect("valid payload");
1129        assert_eq!(restored.len(), original.len());
1130        for (before, after) in original.iter().zip(&restored) {
1131            // f16 carries 11 significant bits, so a relative step is 2⁻¹⁰.
1132            let tolerance = before.abs() * 2f32.powi(-10) + f32::MIN_POSITIVE;
1133            assert!(
1134                (before - after).abs() <= tolerance,
1135                "{before} stored and read back as {after}, further than one f16 step"
1136            );
1137            assert_eq!(
1138                before >= &0.0,
1139                after >= &0.0,
1140                "sign must survive: the packed scan is nothing but sign bits"
1141            );
1142        }
1143        // Idempotent, so a record rewritten without changing does not drift
1144        // further each time it is saved.
1145        assert_eq!(
1146            decode(&encode(&restored)).expect("valid payload"),
1147            restored,
1148            "storing an already-stored vector must not move it again"
1149        );
1150    }
1151
1152    /// Every `f16` bit pattern has to survive `f16` → `f32` → `f16`, which is
1153    /// the property the idempotence above rests on. Exhaustive, because it can
1154    /// be: there are only 65,536 of them.
1155    #[test]
1156    fn every_f16_survives_a_round_trip() {
1157        for bits in 0u16..=u16::MAX {
1158            let value = from_f16(bits);
1159            if value.is_nan() {
1160                continue;
1161            }
1162            assert_eq!(
1163                to_f16(value),
1164                bits,
1165                "f16 {bits:#06x} ({value}) did not survive"
1166            );
1167        }
1168        // Ties to even, which is what keeps the rounding unbiased — a converter
1169        // that always rounded away from zero would pass the loop above and
1170        // quietly stretch every vector.
1171        assert_eq!(from_f16(to_f16(1.0 + 2f32.powi(-11))), 1.0);
1172        assert_eq!(from_f16(to_f16(0.1)), 0.099_975_586);
1173        // Out of range in both directions, sign intact at the bottom.
1174        assert!(from_f16(to_f16(70_000.0)).is_infinite());
1175        assert!(from_f16(to_f16(-1e-9)).is_sign_negative());
1176    }
1177
1178    /// Rounding has to be unbiased, or every dot product bends the same way.
1179    ///
1180    /// Worth its own test because a converter that truncated toward zero would
1181    /// pass the exhaustive round trip above — truncation is still idempotent —
1182    /// while quietly shrinking every vector it stored.
1183    #[test]
1184    fn rounding_is_unbiased_rather_than_toward_zero() {
1185        // At the scale a normalised 768d coordinate actually occupies: around
1186        // 1/sqrt(768) ≈ 0.036.
1187        let mut state = 0x2545_F491_4F6C_DD1Du64;
1188        let (mut drift, mut magnitude) = (0.0f64, 0.0f64);
1189        for _ in 0..100_000 {
1190            state ^= state << 13;
1191            state ^= state >> 7;
1192            state ^= state << 17;
1193            let value = ((state >> 11) as f64 / (1u64 << 53) as f64 - 0.5) as f32 * 0.1;
1194            let error = (from_f16(to_f16(value)) - value) as f64;
1195            drift += error;
1196            magnitude += error.abs();
1197        }
1198        assert!(
1199            drift.abs() < magnitude * 0.05,
1200            "rounding drifted {drift:.3e} against {magnitude:.3e} of total error — \
1201             that is a bias, not noise"
1202        );
1203    }
1204
1205    /// A store written before the format changed must still load, or upgrading
1206    /// silently invalidates every vector and re-embedding a 16,000-record
1207    /// corpus is over an hour of wall clock.
1208    #[test]
1209    fn a_legacy_hex_payload_still_decodes() {
1210        let original = vector(7, 768);
1211        let restored = decode(&to_hex(&original)).expect("legacy hex must still load");
1212        assert_eq!(
1213            original, restored,
1214            "the legacy format was exact and must still decode exactly"
1215        );
1216    }
1217
1218    #[test]
1219    fn malformed_payloads_are_rejected_rather_than_truncated() {
1220        assert!(from_hex("abc").is_none(), "odd length");
1221        assert!(from_hex("zzzzzzzz").is_none(), "not hex");
1222        assert!(from_hex("abcdef").is_none(), "not a whole f32");
1223        assert!(decode("f16b64:!!!!").is_none(), "not base64");
1224        assert!(
1225            decode(&format!("{F16_B64}{}", "AAAAA")).is_none(),
1226            "not a whole f16"
1227        );
1228    }
1229
1230    /// The saving that motivated the change, asserted rather than claimed.
1231    #[test]
1232    fn the_encoding_is_a_third_of_what_hex_f32_cost() {
1233        let original = vector(11, 768);
1234        let now = encode(&original).len();
1235        let before = to_hex(&original).len();
1236        assert_eq!(before, 768 * 8, "hex f32 is 8 characters per coordinate");
1237        assert!(
1238            now * 3 <= before + F16_B64.len() * 3,
1239            "base64 f16 is {now} characters against hex f32's {before}; the point of \
1240             the change was a threefold saving"
1241        );
1242    }
1243
1244    /// The point of persistence: a restart must not re-embed.
1245    ///
1246    /// The embedder's table is deliberately empty, so any embedding attempt is
1247    /// an error rather than a slow success — which is what makes this a test of
1248    /// the cache and not of the network.
1249    #[tokio::test]
1250    async fn a_restored_index_answers_without_embedding_anything() {
1251        let store = std::sync::Arc::new(OkfVectorStore::new(std::sync::Arc::new(
1252            crate::okf::MemoryStore::default(),
1253        )));
1254        let mut table = HashMap::new();
1255        table.insert("the only fact".to_string(), vector(3, 768));
1256
1257        // First process: embeds once and persists.
1258        let first = PrecomputedSemanticIndex::from_vectors(
1259            Vec::new(),
1260            std::sync::Arc::new(StaticEmbedder::new(table)),
1261        )
1262        .with_store(store.clone());
1263        first
1264            .reconcile(&[(MemoryId::new("mem_one"), "the only fact".into())], 0)
1265            .await
1266            .expect("first reconcile");
1267        assert_eq!(first.len(), 1);
1268
1269        // Second process: same store, an embedder that can embed nothing.
1270        let second = PrecomputedSemanticIndex::restore(
1271            store.clone(),
1272            std::sync::Arc::new(StaticEmbedder::new(HashMap::new())),
1273        )
1274        .await
1275        .expect("restore");
1276        assert_eq!(second.len(), 1, "the vector did not survive the restart");
1277        second
1278            .reconcile(&[(MemoryId::new("mem_one"), "the only fact".into())], 0)
1279            .await
1280            .expect("a restored vector must not be re-embedded");
1281
1282        let hits = second.search_vector(&vector(3, 768), 1);
1283        assert_eq!(
1284            hits.first().map(crate::core::ids::MemoryId::as_str),
1285            Some("mem_one")
1286        );
1287    }
1288
1289    /// And the safety property that makes the cache trustworthy: a record whose
1290    /// text has changed must be re-embedded, not restored from its old vector.
1291    ///
1292    /// This is exactly a correction. Keying the store on the id alone would
1293    /// restore the vector for the wording the user just replaced.
1294    #[tokio::test]
1295    async fn a_record_whose_text_changed_is_re_embedded_rather_than_restored() {
1296        let store = std::sync::Arc::new(OkfVectorStore::new(std::sync::Arc::new(
1297            crate::okf::MemoryStore::default(),
1298        )));
1299        let mut table = HashMap::new();
1300        table.insert("the original wording".to_string(), vector(11, 768));
1301        table.insert("the corrected wording".to_string(), vector(22, 768));
1302        let embedder = std::sync::Arc::new(StaticEmbedder::new(table));
1303
1304        let index = PrecomputedSemanticIndex::from_vectors(Vec::new(), embedder.clone())
1305            .with_store(store.clone());
1306        index
1307            .reconcile(
1308                &[(MemoryId::new("mem_one"), "the original wording".into())],
1309                0,
1310            )
1311            .await
1312            .expect("first");
1313
1314        // Same id, new text — a correction.
1315        index
1316            .reconcile(
1317                &[(MemoryId::new("mem_one"), "the corrected wording".into())],
1318                0,
1319            )
1320            .await
1321            .expect("second");
1322
1323        assert_eq!(index.len(), 1, "the record must not be duplicated");
1324        let hits = index.search_vector(&vector(22, 768), 1);
1325        assert_eq!(
1326            hits.first().map(crate::core::ids::MemoryId::as_str),
1327            Some("mem_one"),
1328            "the index still holds the vector for the superseded wording"
1329        );
1330    }
1331
1332    /// A record dropped from the corpus must lose its stored vector, or a
1333    /// restore resurrects facts the user superseded.
1334    #[tokio::test]
1335    async fn retiring_a_record_removes_it_from_the_store_too() {
1336        let backing = std::sync::Arc::new(crate::okf::MemoryStore::default());
1337        let store = std::sync::Arc::new(OkfVectorStore::new(backing.clone()));
1338        let mut table = HashMap::new();
1339        table.insert("kept".to_string(), vector(1, 768));
1340        table.insert("dropped".to_string(), vector(2, 768));
1341
1342        let index = PrecomputedSemanticIndex::from_vectors(
1343            Vec::new(),
1344            std::sync::Arc::new(StaticEmbedder::new(table)),
1345        )
1346        .with_store(store.clone());
1347        index
1348            .reconcile(
1349                &[
1350                    (MemoryId::new("mem_kept"), "kept".into()),
1351                    (MemoryId::new("mem_dropped"), "dropped".into()),
1352                ],
1353                0,
1354            )
1355            .await
1356            .expect("first");
1357        assert_eq!(store.load().await.expect("load").len(), 2);
1358
1359        index
1360            .reconcile(&[(MemoryId::new("mem_kept"), "kept".into())], 0)
1361            .await
1362            .expect("second");
1363        let remaining = store.load().await.expect("load");
1364        assert_eq!(remaining.len(), 1, "the dropped vector is still on disk");
1365        assert_eq!(remaining[0].0.as_str(), "mem_kept");
1366    }
1367
1368    #[tokio::test]
1369    async fn an_unknown_query_is_an_error_rather_than_a_silent_zero_vector() {
1370        let built = index(10, 768);
1371        assert!(built.search("never embedded", 5).await.is_err());
1372    }
1373}