Skip to main content

writerslogic_warm/core/documents/
memory.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! A self-contained, in-memory document store: the same chunking, stemmed BM25, metadata
5//! filters and optional dense retrieval as the engine's document path, without the journal,
6//! shards, locks or files. It is what the WebAssembly binding runs, and its serialized form
7//! (`export` / `import`) is the unit that persistence adapters store.
8
9use 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;
23/// Fixed header: magic, version, dimensions, embedding dimensions, document count,
24/// uncompressed length, crc32 of the uncompressed payload.
25const HEADER_BYTES: usize = 4 + 1 + 4 + 4 + 4 + 8 + 4;
26/// Upper bound on an uncompressed snapshot, so a forged length cannot request an unbounded
27/// allocation (a wasm32 linear memory tops out at 4 GiB).
28const MAX_SNAPSHOT_BYTES: usize = 1 << 30;
29const MAX_DIMENSIONS: usize = 1 << 20;
30const MAX_BATCH: usize = 4096;
31/// A chunk is at most 64 KiB of text, so no honest chunk counts more words than this.
32const MAX_CHUNK_WORDS: u64 = 1 << 16;
33/// Keeps word totals inside a 32-bit `usize`.
34const 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/// Counts describing a [`MemoryDocumentStore`].
49#[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    /// `dimensions` is the sparse text-vector dimension; `embedding_dimensions` enables
69    /// caller-supplied dense embeddings for semantic and hybrid search.
70    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    /// Stores or replaces a document and returns its chunk count.
96    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    /// Stores or replaces several documents; either all are stored or none is.
104    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    /// Removes a document; returns whether it existed.
144    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    /// Builds the dense index over the stored embeddings. A store with no embedding space or
153    /// too few embedded chunks is left as it is; returns whether an index is now current.
154    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    /// Ids of every stored document, sorted.
189    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    /// Serializes the store: a checksummed header and an LZ4-compressed JSON payload.
196    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    /// Restores a store written by [`MemoryDocumentStore::export`]. The bytes are validated
224    /// (magic, version, bounded length, checksum, per-document shape) before any is used.
225    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            // An LZ4 block expands at most 255 times: a larger declared length cannot be the
243            // payload's, and decompressing would allocate it first.
244            .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    /// Every field the store's own invariants assert on is checked here, because a forged
274    /// snapshot can carry a valid checksum and an assertion failure would abort the module.
275    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        // A declared length within the snapshot limit but beyond what the payload can expand
531        // to is refused before decompression allocates it.
532        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}