1use anyhow::{bail, ensure, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeSet;
12use std::sync::Arc;
13
14use super::{
15 build_dense_index, capture_dense_index, capture_search, execute_search, prepare,
16 prepare_search, publish_dense_index, DocumentInput, DocumentResult, DocumentStore,
17 SearchOptions, StoredDocument,
18};
19use crate::core::encoding::TextEncoder;
20
21const MAGIC: &[u8; 4] = b"HMSD";
22const FORMAT_VERSION: u8 = 1;
23const HEADER_BYTES: usize = 4 + 1 + 4 + 4 + 4 + 8 + 4;
26const MAX_SNAPSHOT_BYTES: usize = 1 << 30;
29const MAX_DIMENSIONS: usize = 1 << 20;
30const MAX_BATCH: usize = 4096;
31const MAX_CHUNK_WORDS: u64 = 1 << 16;
33const MAX_STORE_WORDS: u64 = (i32::MAX / 2) as u64;
35
36#[derive(Serialize, Deserialize)]
37struct Snapshot {
38 encoder: String,
39 documents: Vec<StoredDocument>,
40}
41
42#[derive(Serialize)]
43struct SnapshotRef<'a> {
44 encoder: &'a str,
45 documents: Vec<&'a StoredDocument>,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct MemoryStoreStats {
52 pub documents: usize,
53 pub chunks: usize,
54 pub embedded_chunks: usize,
55 pub dimensions: usize,
56 pub embedding_dimensions: Option<usize>,
57 pub index_trained: bool,
58}
59
60pub struct MemoryDocumentStore {
61 dimensions: usize,
62 embedding_dimensions: Option<usize>,
63 encoder: TextEncoder,
64 store: DocumentStore,
65}
66
67impl MemoryDocumentStore {
68 pub fn new(dimensions: usize, embedding_dimensions: Option<usize>) -> Result<Self> {
71 ensure!(
72 (1..=MAX_DIMENSIONS).contains(&dimensions),
73 "dimensions must be in 1..={MAX_DIMENSIONS}"
74 );
75 ensure!(
76 embedding_dimensions.is_none_or(|d| (1..=MAX_DIMENSIONS).contains(&d)),
77 "embedding dimensions must be in 1..={MAX_DIMENSIONS}"
78 );
79 Ok(Self {
80 dimensions,
81 embedding_dimensions,
82 encoder: TextEncoder::default(),
83 store: DocumentStore::default(),
84 })
85 }
86
87 pub fn dimensions(&self) -> usize {
88 self.dimensions
89 }
90
91 pub fn embedding_dimensions(&self) -> Option<usize> {
92 self.embedding_dimensions
93 }
94
95 pub fn memorize(&mut self, input: DocumentInput) -> Result<u32> {
97 let document = self.prepare(input)?;
98 let count = document.chunks.len() as u32;
99 self.store.insert(document.id.clone(), document);
100 Ok(count)
101 }
102
103 pub fn memorize_many(&mut self, inputs: Vec<DocumentInput>) -> Result<Vec<u32>> {
105 ensure!(
106 inputs.len() <= MAX_BATCH,
107 "a batch holds at most {MAX_BATCH} documents"
108 );
109 let mut seen = BTreeSet::new();
110 for input in &inputs {
111 ensure!(
112 seen.insert(input.id.as_str()),
113 "document id {:?} appears twice in the batch",
114 input.id
115 );
116 }
117 let documents = inputs
118 .into_iter()
119 .map(|input| self.prepare(input))
120 .collect::<Result<Vec<_>>>()?;
121 let counts = documents.iter().map(|d| d.chunks.len() as u32).collect();
122 for document in documents {
123 self.store.insert(document.id.clone(), document);
124 }
125 Ok(counts)
126 }
127
128 fn prepare(&self, input: DocumentInput) -> Result<StoredDocument> {
129 prepare(
130 input,
131 self.dimensions,
132 self.embedding_dimensions,
133 self.encoder,
134 )
135 }
136
137 pub fn search(&self, text: &str, options: &SearchOptions) -> Result<Vec<DocumentResult>> {
138 let prepared = prepare_search(text, options, self.embedding_dimensions)?;
139 let captured = capture_search(&self.store, vec![prepared]);
140 Ok(execute_search(&captured, 0))
141 }
142
143 pub fn delete(&mut self, id: &str) -> bool {
145 self.store.remove(id).is_some()
146 }
147
148 pub fn contains(&self, id: &str) -> bool {
149 self.store.contains_key(id)
150 }
151
152 pub fn train_index(&mut self) -> Result<bool> {
155 let Some(dimensions) = self.embedding_dimensions else {
156 return Ok(false);
157 };
158 let Some(snapshot) = capture_dense_index(&self.store, dimensions)? else {
159 return Ok(self.store.dense_index_trained());
160 };
161 let Some(trained) = build_dense_index(snapshot)? else {
162 return Ok(self.store.dense_index_trained());
163 };
164 ensure!(
165 publish_dense_index(&mut self.store, &trained),
166 "store changed during index training"
167 );
168 Ok(true)
169 }
170
171 pub fn index_trained(&self) -> bool {
172 self.store.dense_index_trained()
173 }
174
175 pub fn stats(&self) -> MemoryStoreStats {
176 let documents = self.store.shared_values().count();
177 let chunks = self.store.shared_values().map(|d| d.chunks.len()).sum();
178 MemoryStoreStats {
179 documents,
180 chunks,
181 embedded_chunks: self.store.embedded_count(),
182 dimensions: self.dimensions,
183 embedding_dimensions: self.embedding_dimensions,
184 index_trained: self.store.dense_index_trained(),
185 }
186 }
187
188 pub fn ids(&self) -> Vec<String> {
190 let mut ids: Vec<String> = self.store.shared_values().map(|d| d.id.clone()).collect();
191 ids.sort_unstable();
192 ids
193 }
194
195 pub fn export(&self) -> Result<Vec<u8>> {
197 let held: Vec<Arc<StoredDocument>> = self.store.shared_values().collect();
198 let mut documents: Vec<&StoredDocument> = held.iter().map(Arc::as_ref).collect();
199 documents.sort_unstable_by(|a, b| a.id.cmp(&b.id));
200 let count = documents.len();
201 let snapshot = SnapshotRef {
202 encoder: self.encoder.name(),
203 documents,
204 };
205 let payload = serde_json::to_vec(&snapshot).context("serializing the store")?;
206 ensure!(
207 payload.len() <= MAX_SNAPSHOT_BYTES,
208 "the store exceeds the {MAX_SNAPSHOT_BYTES}-byte snapshot limit"
209 );
210 let compressed = lz4_flex::block::compress(&payload);
211 let mut out = Vec::with_capacity(HEADER_BYTES + compressed.len());
212 out.extend_from_slice(MAGIC);
213 out.push(FORMAT_VERSION);
214 out.extend_from_slice(&(self.dimensions as u32).to_le_bytes());
215 out.extend_from_slice(&(self.embedding_dimensions.unwrap_or(0) as u32).to_le_bytes());
216 out.extend_from_slice(&(count as u32).to_le_bytes());
217 out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
218 out.extend_from_slice(&crc32fast::hash(&payload).to_le_bytes());
219 out.extend_from_slice(&compressed);
220 Ok(out)
221 }
222
223 pub fn import(bytes: &[u8]) -> Result<Self> {
226 let header: &[u8; HEADER_BYTES] = bytes.first_chunk().context("snapshot is truncated")?;
227 ensure!(&bytes[..4] == MAGIC, "not a writerslogic-warm snapshot");
228 ensure!(
229 bytes[4] == FORMAT_VERSION,
230 "unsupported snapshot version {}",
231 bytes[4]
232 );
233 let word = |at: usize| u32::from_le_bytes(*header[at..].first_chunk().expect("in header"));
234 let dimensions = word(5) as usize;
235 let embedding_dimensions = Some(word(9) as usize).filter(|&d| d != 0);
236 let expected_documents = word(13) as usize;
237 let length = u64::from_le_bytes(*header[17..].first_chunk().expect("in header"));
238 let checksum = word(25);
239 let length = usize::try_from(length)
240 .ok()
241 .filter(|&n| n <= MAX_SNAPSHOT_BYTES)
242 .filter(|&n| n as u64 <= (bytes.len() - HEADER_BYTES) as u64 * 255)
245 .context("snapshot payload length is out of range")?;
246 let payload = lz4_flex::block::decompress(&bytes[HEADER_BYTES..], length)
247 .map_err(|e| anyhow::anyhow!("snapshot payload is corrupt: {e}"))?;
248 ensure!(
249 payload.len() == length && crc32fast::hash(&payload) == checksum,
250 "snapshot checksum mismatch"
251 );
252 let snapshot: Snapshot =
253 serde_json::from_slice(&payload).context("snapshot payload is malformed")?;
254 let encoder: TextEncoder = snapshot.encoder.parse()?;
255 ensure!(
256 snapshot.documents.len() == expected_documents,
257 "snapshot document count mismatch"
258 );
259 let mut store = Self::new(dimensions, embedding_dimensions)?;
260 store.encoder = encoder;
261 let mut seen = BTreeSet::new();
262 let mut total_words = 0u64;
263 for document in snapshot.documents {
264 store.validate_imported(&document, &mut total_words)?;
265 if !seen.insert(document.id.clone()) {
266 bail!("snapshot holds document {:?} twice", document.id);
267 }
268 store.store.insert(document.id.clone(), document);
269 }
270 Ok(store)
271 }
272
273 fn validate_imported(&self, document: &StoredDocument, total_words: &mut u64) -> Result<()> {
276 ensure!(
277 !document.id.is_empty() && document.id.len() <= 4096 && !document.id.contains('\0'),
278 "snapshot holds an invalid document id"
279 );
280 ensure!(
281 !document.chunks.is_empty() && document.chunks.len() <= 4096,
282 "snapshot document {:?} has an invalid chunk count",
283 document.id
284 );
285 for (index, chunk) in document.chunks.iter().enumerate() {
286 ensure!(
287 chunk.id
288 == format!(
289 "{}{}:{}:{}",
290 super::CHUNK_PREFIX,
291 document.id.len(),
292 document.id,
293 index
294 ),
295 "snapshot chunk id does not match its document and position"
296 );
297 ensure!(
298 chunk.vector.dim == self.dimensions
299 && chunk.vector.indices.windows(2).all(|w| w[0] < w[1])
300 && chunk
301 .vector
302 .indices
303 .last()
304 .is_none_or(|&i| (i as usize) < self.dimensions),
305 "snapshot chunk vector is malformed"
306 );
307 ensure!(
308 chunk.embedding.as_ref().is_none_or(|e| {
309 Some(e.len()) == self.embedding_dimensions && e.iter().all(|x| x.is_finite())
310 }),
311 "snapshot embedding does not match the store's embedding space"
312 );
313 ensure!(
314 chunk.start_byte <= chunk.end_byte,
315 "snapshot chunk has inverted byte offsets"
316 );
317 ensure!(
318 !chunk.terms.is_whole_document()
319 || (chunk.vector_state == super::VectorState::Lexical
320 && document.chunks.len() == 1
321 && chunk.start_byte == 0
322 && chunk.end_byte as usize <= super::MAX_DOCUMENT_BYTES),
323 "snapshot whole-document postings require one bounded lexical chunk"
324 );
325 let mut words = 0u64;
326 for (_, &frequency) in &chunk.terms {
327 ensure!(frequency > 0, "snapshot term has a zero frequency");
328 words += u64::from(frequency);
329 }
330 ensure!(
331 words == chunk.word_count as u64
332 && words
333 <= if chunk.terms.is_whole_document() {
334 super::MAX_DOCUMENT_BYTES as u64
335 } else {
336 MAX_CHUNK_WORDS
337 },
338 "snapshot chunk word count disagrees with its terms"
339 );
340 *total_words += words;
341 ensure!(
342 *total_words <= MAX_STORE_WORDS,
343 "snapshot exceeds the store word limit"
344 );
345 }
346 Ok(())
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn input(id: &str, text: &str) -> DocumentInput {
355 DocumentInput {
356 id: id.into(),
357 text: text.into(),
358 source_uri: Some(format!("file:///{id}")),
359 version: None,
360 metadata: Some(serde_json::json!({"kind": "note", "n": 3})),
361 chunk_words: Some(8),
362 overlap_words: Some(2),
363 store_text: None,
364 embeddings: None,
365 title_weight: None,
366 }
367 }
368
369 fn corpus(store: &mut MemoryDocumentStore) {
370 let texts = [
371 ("a", "Rust ownership rules prevent data races at compile time without a garbage collector and keep memory use predictable across threads"),
372 ("b", "The holographic reduced representation binds vectors with circular convolution and superposes many bindings into one trace"),
373 ("c", "Okapi BM25 ranks passages by term frequency saturation and length normalization over an inverse document frequency weight"),
374 ("d", "Gardening tips for spring include pruning roses and planting tomatoes after the last frost has passed in your region"),
375 ];
376 for (id, text) in texts {
377 store.memorize(input(id, text)).unwrap();
378 }
379 }
380
381 fn rendered(results: &[DocumentResult]) -> String {
382 serde_json::to_string(results).unwrap()
383 }
384
385 #[test]
386 fn search_ranks_filters_and_deletes() {
387 let mut store = MemoryDocumentStore::new(1024, None).unwrap();
388 corpus(&mut store);
389 let hits = store
390 .search("BM25 term frequency", &SearchOptions::default())
391 .unwrap();
392 assert_eq!(hits[0].document_id, "c");
393 let only_d = store
394 .search(
395 "the",
396 &SearchOptions {
397 document_ids: Some(vec!["d".into()]),
398 ..Default::default()
399 },
400 )
401 .unwrap();
402 assert!(only_d.iter().all(|h| h.document_id == "d"));
403 assert!(store.delete("c"));
404 assert!(!store.delete("c"));
405 let after = store
406 .search("BM25 term frequency", &SearchOptions::default())
407 .unwrap();
408 assert!(after.iter().all(|h| h.document_id != "c"));
409 assert_eq!(store.stats().documents, 3);
410 }
411
412 #[test]
413 fn bm25_options_and_title_weight_change_scores() {
414 let mut store = MemoryDocumentStore::new(1024, None).unwrap();
415 corpus(&mut store);
416 let base = store.search("memory", &SearchOptions::default()).unwrap();
417 let tuned = store
418 .search(
419 "memory",
420 &SearchOptions {
421 bm25_k1: Some(2.0),
422 bm25_b: Some(0.2),
423 ..Default::default()
424 },
425 )
426 .unwrap();
427 assert_ne!(rendered(&base), rendered(&tuned));
428 assert!(store
429 .search(
430 "x",
431 &SearchOptions {
432 bm25_b: Some(1.5),
433 ..Default::default()
434 }
435 )
436 .is_err());
437
438 let build = |title_weight: Option<u32>| {
439 let mut store = MemoryDocumentStore::new(1024, None).unwrap();
440 let filler = "alpha beta gamma delta epsilon theta iota kappa lambda sigma";
441 store
442 .memorize(DocumentInput {
443 title_weight,
444 ..input("titled", &format!("Zebra\n{filler}"))
445 })
446 .unwrap();
447 store
448 .memorize(input("body", &format!("{filler} zebra")))
449 .unwrap();
450 store.memorize(input("other", filler)).unwrap();
451 store.search("zebra", &SearchOptions::default()).unwrap()
452 };
453 let plain = build(None);
454 let weighted = build(Some(8));
455 let score = |hits: &[DocumentResult], id: &str| {
456 hits.iter().find(|h| h.document_id == id).unwrap().score
457 };
458 assert_eq!(weighted[0].document_id, "titled");
459 assert!(score(&weighted, "titled") > score(&plain, "titled"));
460 }
461
462 #[test]
463 fn export_import_round_trips_search_results_exactly() {
464 let mut store = MemoryDocumentStore::new(1024, Some(3)).unwrap();
465 corpus(&mut store);
466 let mut with_embedding = input("e", "dense vector document about retrieval");
467 with_embedding.embeddings = Some(vec![vec![0.2, 0.5, 0.1]]);
468 store.memorize(with_embedding).unwrap();
469 let bytes = store.export().unwrap();
470 let restored = MemoryDocumentStore::import(&bytes).unwrap();
471 assert_eq!(restored.stats(), store.stats());
472 for query in ["holographic binding", "frost tomatoes", "BM25"] {
473 let options = SearchOptions {
474 embedding: Some(vec![0.2, 0.5, 0.1]),
475 ..Default::default()
476 };
477 assert_eq!(
478 rendered(&store.search(query, &options).unwrap()),
479 rendered(&restored.search(query, &options).unwrap())
480 );
481 }
482 assert_eq!(restored.export().unwrap(), bytes);
483 }
484
485 #[cfg(not(target_arch = "wasm32"))]
486 #[test]
487 fn matches_the_engine_document_path() {
488 let dir = tempfile::tempdir().unwrap();
489 let engine =
490 crate::core::WarmCore::new(1024, Some(dir.path().display().to_string()), None).unwrap();
491 let mut store = MemoryDocumentStore::new(1024, None).unwrap();
492 corpus(&mut store);
493 for (id, text) in [
494 ("a", "Rust ownership rules prevent data races at compile time without a garbage collector and keep memory use predictable across threads"),
495 ("b", "The holographic reduced representation binds vectors with circular convolution and superposes many bindings into one trace"),
496 ("c", "Okapi BM25 ranks passages by term frequency saturation and length normalization over an inverse document frequency weight"),
497 ("d", "Gardening tips for spring include pruning roses and planting tomatoes after the last frost has passed in your region"),
498 ] {
499 engine.memorize_document(input(id, text)).unwrap();
500 }
501 for query in [
502 "BM25 term frequency",
503 "holographic trace",
504 "frost",
505 "memory threads",
506 ] {
507 let options = SearchOptions::default();
508 assert_eq!(
509 rendered(&store.search(query, &options).unwrap()),
510 rendered(&engine.search_documents(query, &options).unwrap()),
511 "query {query:?}"
512 );
513 }
514 }
515
516 #[test]
517 fn import_rejects_tampered_and_truncated_snapshots() {
518 let mut store = MemoryDocumentStore::new(1024, None).unwrap();
519 corpus(&mut store);
520 let bytes = store.export().unwrap();
521 assert!(MemoryDocumentStore::import(&bytes[..bytes.len() - 1]).is_err());
522 assert!(MemoryDocumentStore::import(&bytes[..HEADER_BYTES - 1]).is_err());
523 let mut flipped = bytes.clone();
524 let last = flipped.len() - 1;
525 flipped[last] ^= 0x55;
526 assert!(MemoryDocumentStore::import(&flipped).is_err());
527 let mut huge = bytes.clone();
528 huge[17..25].copy_from_slice(&u64::MAX.to_le_bytes());
529 assert!(MemoryDocumentStore::import(&huge).is_err());
530 let mut inflated = bytes[..HEADER_BYTES + 1].to_vec();
533 inflated[17..25].copy_from_slice(&(1u64 << 30).to_le_bytes());
534 let error = MemoryDocumentStore::import(&inflated).err().unwrap();
535 assert!(error.to_string().contains("out of range"), "{error}");
536 let mut wrong_magic = bytes;
537 wrong_magic[0] = b'X';
538 assert!(MemoryDocumentStore::import(&wrong_magic).is_err());
539 }
540
541 fn reseal(original: &[u8], edit: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
542 let length = u64::from_le_bytes(original[17..25].try_into().unwrap()) as usize;
543 let payload = lz4_flex::block::decompress(&original[HEADER_BYTES..], length).unwrap();
544 let mut json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
545 edit(&mut json);
546 let payload = serde_json::to_vec(&json).unwrap();
547 let mut out = original[..17].to_vec();
548 out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
549 out.extend_from_slice(&crc32fast::hash(&payload).to_le_bytes());
550 out.extend_from_slice(&lz4_flex::block::compress(&payload));
551 out
552 }
553
554 fn first_term(
555 json: &mut serde_json::Value,
556 ) -> (&mut serde_json::Map<String, serde_json::Value>, String) {
557 let terms = json["documents"][0]["chunks"][0]["terms"]
558 .as_object_mut()
559 .unwrap();
560 let first = terms.keys().next().unwrap().clone();
561 (terms, first)
562 }
563
564 #[test]
565 fn forged_snapshots_with_valid_checksums_are_rejected_not_asserted() {
566 let mut store = MemoryDocumentStore::new(1024, Some(3)).unwrap();
567 corpus(&mut store);
568 let mut embedded = input("e", "dense vector document");
569 embedded.embeddings = Some(vec![vec![0.2, 0.5, 0.1]]);
570 store.memorize(embedded).unwrap();
571 let good = store.export().unwrap();
572 let resealed = MemoryDocumentStore::import(&reseal(&good, |_| {})).unwrap();
573 assert_eq!(resealed.stats(), store.stats());
574 type Edit = fn(&mut serde_json::Value);
575 let cases: [(&str, Edit); 9] = [
576 ("huge word count", |j| {
577 j["documents"][0]["chunks"][0]["word_count"] = 4_000_000_000u64.into()
578 }),
579 ("word count mismatch", |j| {
580 j["documents"][0]["chunks"][0]["word_count"] = 1.into()
581 }),
582 ("zero term frequency", |j| {
583 let (terms, first) = first_term(j);
584 terms.insert(first, 0.into());
585 }),
586 ("term frequency over u32", |j| {
587 let (terms, first) = first_term(j);
588 terms.insert(first, 4_000_000_000u64.into());
589 }),
590 ("foreign chunk id", |j| {
591 j["documents"][0]["chunks"][0]["id"] = "other".into()
592 }),
593 ("vector index past dimension", |j| {
594 j["documents"][0]["chunks"][0]["vector"]["indices"] = serde_json::json!([5000]);
595 }),
596 ("unsorted vector", |j| {
597 j["documents"][0]["chunks"][0]["vector"]["indices"] = serde_json::json!([9, 3]);
598 }),
599 ("duplicate document", |j| {
600 let first = j["documents"][0].clone();
601 j["documents"].as_array_mut().unwrap().push(first);
602 }),
603 ("wrong embedding length", |j| {
604 let docs = j["documents"].as_array_mut().unwrap();
605 let last = docs.last_mut().unwrap();
606 last["chunks"][0]["embedding"] = serde_json::json!([1.0]);
607 }),
608 ];
609 for (name, edit) in cases {
610 let forged = reseal(&good, edit);
611 let outcome = std::panic::catch_unwind(|| MemoryDocumentStore::import(&forged));
612 match outcome {
613 Ok(result) => assert!(result.is_err(), "{name}: accepted"),
614 Err(_) => panic!("{name}: import panicked"),
615 }
616 }
617 }
618
619 #[test]
620 fn a_failing_batch_stores_nothing() {
621 let mut store = MemoryDocumentStore::new(1024, None).unwrap();
622 let bad = input("", "no id");
623 assert!(store
624 .memorize_many(vec![input("ok", "fine text"), bad])
625 .is_err());
626 assert_eq!(store.stats().documents, 0);
627 assert!(store
628 .memorize_many(vec![input("same", "one"), input("same", "two")])
629 .is_err());
630 }
631}