Skip to main content

writerslogic_warm/core/
documents.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{bail, ensure, Result};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::ops::Deref;
9use std::sync::{Arc, OnceLock};
10
11use super::{encoding::TextEncoder, entangled::EntangledHVec};
12
13pub(crate) mod binary;
14#[cfg(test)]
15mod compact_term_tests;
16mod packed_terms;
17use packed_terms::StoredTerms;
18
19#[cfg(test)]
20mod census;
21mod dense;
22mod image;
23mod index;
24mod lexical;
25mod listing;
26mod memory;
27mod overlay;
28mod positions;
29mod postings;
30mod search;
31mod text;
32#[cfg(not(target_arch = "wasm32"))]
33mod vector_index;
34pub(crate) use dense::{build_dense_index, capture_dense_index, publish_dense_index};
35pub(crate) use image::{
36    binding as checkpoint_binding, recorded_crc as checkpoint_crc,
37    representable as checkpoint_representable, Backing, ServingImage,
38    CRC_START as CHECKPOINT_CRC_START, HEADER_LEN as CHECKPOINT_HEADER_LEN,
39    MAGIC as CHECKPOINT_MAGIC,
40};
41pub(crate) use index::DocumentWithText;
42pub(crate) use index::{canonical_staging_path, opaque_counter_start, DocumentStore};
43pub(crate) use lexical::execute as execute_lexical;
44pub use lexical::{LexicalExecution, LexicalHit, LexicalOptions, LexicalQuery, LexicalResults};
45pub use listing::DocumentRecord;
46pub(crate) use listing::{count, get_record, list_records};
47pub use memory::{MemoryDocumentStore, MemoryStoreStats};
48pub(crate) use overlay::Base as CheckpointBase;
49pub(crate) use search::{
50    capture_search, count_matches, execute_search, prepare_search, rank_search, read_ranked,
51    validate_search, CapturedSearch, PreparedSearch,
52};
53pub(crate) use text::TextSegment;
54#[cfg(not(target_arch = "wasm32"))]
55pub(crate) use vector_index::{
56    build_vector_snapshot, capture_vector_rows, search_vector_snapshot, vector_index_status,
57    VectorIndexSlot,
58};
59#[cfg(all(test, not(target_arch = "wasm32")))]
60pub(crate) use vector_index::{rank_vector_snapshot, read_vector_hits};
61#[cfg(not(target_arch = "wasm32"))]
62pub use vector_index::{
63    GraphUnavailable, VectorIndex, VectorIndexKind, VectorIndexMode, VectorIndexStatus,
64    VectorQueryOptions, DEFAULT_EF, DEFAULT_GRAPH_MIN_ROWS, MAX_EF, MAX_K, REBUILD_AFTER_MUTATIONS,
65    SNAPSHOT_MIN_ROWS,
66};
67
68/// Documents, and chunks, one `memorize_documents_with` call accepts.
69pub const MAX_BATCH_ITEMS: usize = 4096;
70
71/// A document batch over one of the engine's batch limits (items, chunks, source or metadata
72/// bytes, embedding values, transaction bytes). Nothing was written; a smaller batch can succeed.
73#[derive(Debug, Clone, Copy)]
74pub struct BatchTooLarge(pub &'static str);
75
76impl std::fmt::Display for BatchTooLarge {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.write_str(self.0)
79    }
80}
81
82impl std::error::Error for BatchTooLarge {}
83pub(crate) const MAX_DOCUMENT_BYTES: usize = 8 * 1024 * 1024;
84pub(crate) const MAX_CHUNK_BYTES: usize = 64 * 1024;
85pub(crate) const CHUNK_PREFIX: &str = "hms:chunk:";
86
87#[cfg_attr(feature = "node-api", napi_derive::napi(object))]
88#[derive(Clone, Debug, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct DocumentInput {
91    pub id: String,
92    pub text: String,
93    pub source_uri: Option<String>,
94    pub version: Option<String>,
95    pub metadata: Option<Value>,
96    pub chunk_words: Option<u32>,
97    pub overlap_words: Option<u32>,
98    pub store_text: Option<bool>,
99    pub embeddings: Option<Vec<Vec<f64>>>,
100    /// Counts the terms of the text's first line this many times in the first chunk's term
101    /// frequencies and word count, as if the title were repeated; 1 or absent leaves the
102    /// document exactly as before this option existed.
103    pub title_weight: Option<u32>,
104}
105
106/// When a document's chunk hypervectors are computed. Terms, postings and text are stored at
107/// ingest in every mode, so lexical search sees the document at once; only the vector side
108/// (`WarmCore::query` over chunk vectors) differs.
109#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum EncodeMode {
112    /// Encode at ingest: the behavior before this option existed.
113    #[default]
114    Eager,
115    /// Commit the document lexically searchable now; its vectors are encoded from the stored
116    /// text on the first vector query, by `maintain_indices`, `train_document_index` or
117    /// `encode_pending_documents`, and persisted then. Needs a text encoder and the chunk text
118    /// (`storeText` not false).
119    Deferred,
120    /// Never encode: the document stays outside vector search until memorized again.
121    Lexical,
122}
123
124impl EncodeMode {
125    pub fn name(self) -> &'static str {
126        match self {
127            Self::Eager => "eager",
128            Self::Deferred => "deferred",
129            Self::Lexical => "lexical",
130        }
131    }
132}
133
134impl std::str::FromStr for EncodeMode {
135    type Err = anyhow::Error;
136
137    fn from_str(name: &str) -> Result<Self> {
138        match name {
139            "eager" => Ok(Self::Eager),
140            "deferred" => Ok(Self::Deferred),
141            "lexical" => Ok(Self::Lexical),
142            _ => bail!("encode mode must be \"eager\", \"deferred\" or \"lexical\", not {name:?}"),
143        }
144    }
145}
146
147/// Options of one `memorize_documents_with` call; the default is exactly `memorize_documents`.
148#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase", default)]
150pub struct IngestOptions {
151    pub encode: EncodeMode,
152    /// Index each word lowercased but not stemmed, so a document matches only the query words
153    /// it contains as written. The default stems every word with the English Snowball stemmer.
154    /// Query terms are matched in both forms, so one store may hold documents of either kind.
155    pub unstemmed: bool,
156    /// Retain token positions for exact, ordered phrase queries. Off by default.
157    pub positions: bool,
158    /// Index one whole article as one lexical chunk (up to 8 MiB). Requires lexical
159    /// encoding and positions; chunkWords/overlapWords do not split the article.
160    pub whole_document: bool,
161}
162
163/// Persisted state of a chunk's hypervector. Written only when not `Encoded`, so a store
164/// without deferred or lexical documents serializes byte for byte as before the flag existed,
165/// and a chunk without the field reads as `Encoded`.
166#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "lowercase")]
168pub(crate) enum VectorState {
169    #[default]
170    Encoded,
171    /// Memorized with [`EncodeMode::Deferred`]; `vector` is empty and the chunk is not in the
172    /// vector index until `WarmCore::encode_pending_documents` installs its encoding.
173    Pending,
174    /// Memorized with [`EncodeMode::Lexical`]; `vector` is empty for good.
175    Lexical,
176}
177
178impl VectorState {
179    pub(crate) fn is_encoded(&self) -> bool {
180        *self == Self::Encoded
181    }
182}
183
184#[cfg_attr(feature = "node-api", napi_derive::napi(object))]
185#[derive(Clone, Debug, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct DocumentChunk {
188    pub id: String,
189    pub text: String,
190    pub start_byte: u32,
191    pub end_byte: u32,
192}
193
194#[cfg_attr(feature = "node-api", napi_derive::napi(object))]
195#[derive(Clone, Debug, Default, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase", default)]
197pub struct SearchOptions {
198    pub k: Option<u32>,
199    pub candidate_limit: Option<u32>,
200    pub filter: Option<Value>,
201    pub source_uri: Option<String>,
202    pub document_ids: Option<Vec<String>>,
203    pub embedding: Option<Vec<f64>>,
204    pub lexical_weight: Option<f64>,
205    pub semantic_weight: Option<f64>,
206    pub min_semantic_score: Option<f64>,
207    /// How the lexical and semantic lists combine: `"blend"` (default) adds each list's
208    /// min-max normalized scores over its top `candidateLimit`, weighted by `lexicalWeight`
209    /// and `semanticWeight`; `"rrf"` is reciprocal rank fusion with k = 60.
210    pub fusion: Option<String>,
211    /// BM25 term-frequency saturation `k1`; the default scores exactly as before this option
212    /// existed (1.2).
213    pub bm25_k1: Option<f64>,
214    /// BM25 length normalization `b` in `[0, 1]`; the default scores exactly as before (0.75).
215    pub bm25_b: Option<f64>,
216}
217
218/// BM25 constants as the scorer multiplies them: `k1 + 1`, `k1`, `1 - b` and `b`. The default
219/// keeps the literal constants the scorer always used, so default scores stay bit-identical.
220#[derive(Clone, Copy, Debug, PartialEq)]
221pub(crate) struct Bm25 {
222    pub(crate) k1_plus_one: f64,
223    pub(crate) k1: f64,
224    pub(crate) one_minus_b: f64,
225    pub(crate) b: f64,
226}
227
228impl Bm25 {
229    pub(crate) const DEFAULT: Self = Self {
230        k1_plus_one: 2.2,
231        k1: 1.2,
232        one_minus_b: 0.25,
233        b: 0.75,
234    };
235
236    pub(crate) fn from_options(options: &SearchOptions) -> Result<Self> {
237        let (Some(k1), Some(b)) = (
238            options.bm25_k1.or(Some(Self::DEFAULT.k1)),
239            options.bm25_b.or(Some(Self::DEFAULT.b)),
240        ) else {
241            unreachable!("defaults fill both parameters");
242        };
243        if options.bm25_k1.is_none() && options.bm25_b.is_none() {
244            return Ok(Self::DEFAULT);
245        }
246        ensure!(
247            k1.is_finite() && k1 >= 0.0 && (0.0..=1.0).contains(&b),
248            "bm25K1 must be finite and nonnegative and bm25B in [0, 1]"
249        );
250        Ok(Self {
251            k1_plus_one: k1 + 1.0,
252            k1,
253            one_minus_b: 1.0 - b,
254            b,
255        })
256    }
257
258    /// The length normalization of a chunk of `word_count` words against `average`.
259    #[inline]
260    pub(crate) fn norm(self, word_count: f64, average: f64) -> f64 {
261        self.k1 * (self.one_minus_b + self.b * word_count / average)
262    }
263
264    /// The inverse document frequency of a term in `df` of `n` chunks.
265    #[inline]
266    pub(crate) fn idf(n: f64, df: f64) -> f64 {
267        (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
268    }
269
270    /// The weight of a term with inverse document frequency `idf` seen `tf` times in a chunk
271    /// whose length normalization is `norm`.
272    #[inline]
273    pub(crate) fn weight(self, idf: f64, tf: f64, norm: f64) -> f64 {
274        if tf == 0.0 {
275            0.0
276        } else {
277            idf * tf * self.k1_plus_one / (tf + norm)
278        }
279    }
280}
281
282#[cfg_attr(feature = "node-api", napi_derive::napi(object))]
283#[derive(Clone, Debug, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct DocumentResult {
286    pub id: String,
287    pub document_id: String,
288    pub source_uri: Option<String>,
289    pub version: String,
290    pub metadata: Value,
291    pub text: Option<String>,
292    pub start_byte: u32,
293    pub end_byte: u32,
294    pub score: f64,
295    pub lexical_score: f64,
296    pub semantic_score: Option<f64>,
297}
298
299/// A chunk's dense embedding as stored: the l2-normalised values rounded to `f32`, in one
300/// shared allocation that the store and every vector snapshot covering the chunk hold
301/// together, so a snapshot adds no copy of the matrix (4 bytes per value in memory, against
302/// the 8 of the `f64` form plus a 4-byte snapshot copy before this type).
303///
304/// Scoring: every path (the generic search, the dense index, the vector snapshot's exact
305/// re-score) calls [`Embedding::cosine`], which widens each stored value to `f64` and
306/// accumulates in `f64`, so one chunk has one semantic score however it was reached. The
307/// score is the exact cosine of the stored (`f32`) vector; it differs from the cosine of the
308/// client's `f64` vector by at most the rounding of the stored values, `2^-24` relative per
309/// coordinate (about `6e-8` worst case over an l2-normalised pair, `2e-9` typical at 384
310/// dimensions).
311///
312/// Serialized form: a JSON array of the `f32` values (shortest round-trip decimals, about
313/// half the bytes of the `f64` form). A record written before this type held an array of
314/// `f64` values under the same key; both read as an array of numbers rounded to the nearest
315/// `f32`, so a store from either version opens, and a store written now still opens with
316/// the previous release (which reads the array as `f64`). Chunks without an embedding
317/// serialize byte for byte as before.
318#[derive(Clone, Debug, PartialEq)]
319pub(crate) struct Embedding(Arc<[f32]>);
320
321impl Embedding {
322    /// Rounds l2-normalised `f64` values to the stored `f32` form.
323    pub(crate) fn from_f64(values: &[f64]) -> Self {
324        Self(values.iter().map(|&value| value as f32).collect())
325    }
326
327    pub(crate) fn from_f32(values: Vec<f32>) -> Self {
328        Self(values.into())
329    }
330
331    /// The stored values widened to `f64` (exact).
332    pub(crate) fn to_f64(&self) -> Vec<f64> {
333        self.0.iter().map(|&value| f64::from(value)).collect()
334    }
335
336    /// The semantic score of this chunk against an l2-normalised `f64` query: the sequential
337    /// `f64` dot product over the widened stored values, clamped to the cosine range.
338    #[inline]
339    pub(crate) fn cosine(&self, query: &[f64]) -> f64 {
340        query
341            .iter()
342            .zip(self.0.iter())
343            .map(|(q, &v)| q * f64::from(v))
344            .sum::<f64>()
345            .clamp(-1.0, 1.0)
346    }
347
348    /// Whether both handles share one allocation (a snapshot row and the store's chunk).
349    #[cfg(test)]
350    pub(crate) fn shares_allocation(&self, other: &Self) -> bool {
351        Arc::ptr_eq(&self.0, &other.0)
352    }
353}
354
355impl Deref for Embedding {
356    type Target = [f32];
357
358    fn deref(&self) -> &[f32] {
359        &self.0
360    }
361}
362
363impl Serialize for Embedding {
364    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
365        serializer.collect_seq(self.0.iter())
366    }
367}
368
369impl<'de> Deserialize<'de> for Embedding {
370    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
371        Vec::<f32>::deserialize(deserializer).map(Self::from_f32)
372    }
373}
374
375#[derive(Clone, Serialize, Deserialize)]
376pub(crate) struct StoredChunk {
377    pub id: String,
378    /// Inline text, or none when the store moved it to its text segment (`text_at`) or the
379    /// document was stored without text.
380    pub text: Option<String>,
381    /// Location of the text in the store's text segment, for the process that installed it.
382    #[serde(skip)]
383    pub text_at: Option<(u64, u32)>,
384    pub start_byte: u32,
385    pub end_byte: u32,
386    pub vector: EntangledHVec,
387    pub embedding: Option<Embedding>,
388    pub terms: StoredTerms,
389    pub word_count: usize,
390    /// Whether `terms` were stemmed at ingest; chunks stored before stemming existed match
391    /// the unstemmed query terms.
392    #[serde(default)]
393    pub stemmed: bool,
394    /// Whether `vector` is the chunk's encoding, awaits deferred encoding, or is deliberately
395    /// absent; `Pending` and `Lexical` chunks carry an empty vector and are not in the vector
396    /// index. Absent from the serialized form when `Encoded`.
397    #[serde(default, skip_serializing_if = "VectorState::is_encoded")]
398    pub vector_state: VectorState,
399}
400
401#[derive(Clone, Serialize, Deserialize)]
402pub(crate) struct StoredDocument {
403    #[serde(deserialize_with = "deserialize_compact_string")]
404    pub id: String,
405    #[serde(default, deserialize_with = "deserialize_compact_uri")]
406    pub source_uri: Option<String>,
407    #[serde(deserialize_with = "deserialize_compact_string")]
408    pub version: String,
409    #[serde(deserialize_with = "deserialize_compact_metadata")]
410    pub metadata: Value,
411    #[serde(deserialize_with = "deserialize_compact_chunks")]
412    pub chunks: Vec<StoredChunk>,
413}
414
415fn deserialize_compact_chunks<'de, D>(deserializer: D) -> Result<Vec<StoredChunk>, D::Error>
416where
417    D: serde::Deserializer<'de>,
418{
419    let mut chunks = Vec::<StoredChunk>::deserialize(deserializer)?;
420    compact_chunks(&mut chunks);
421    Ok(chunks)
422}
423
424fn deserialize_compact_string<'de, D>(deserializer: D) -> Result<String, D::Error>
425where
426    D: serde::Deserializer<'de>,
427{
428    let mut value = String::deserialize(deserializer)?;
429    compact_string(&mut value);
430    Ok(value)
431}
432
433fn deserialize_compact_uri<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
434where
435    D: serde::Deserializer<'de>,
436{
437    let mut value = Option::<String>::deserialize(deserializer)?;
438    if let Some(value) = &mut value {
439        compact_string(value);
440    }
441    Ok(value)
442}
443
444fn deserialize_compact_metadata<'de, D>(deserializer: D) -> Result<Value, D::Error>
445where
446    D: serde::Deserializer<'de>,
447{
448    let mut value = Value::deserialize(deserializer)?;
449    compact_metadata(&mut value);
450    Ok(value)
451}
452
453fn compact_vec<T>(values: &mut Vec<T>) {
454    if values.capacity() != values.len() {
455        *values = std::mem::take(values).into_boxed_slice().into_vec();
456    }
457}
458
459fn compact_string(value: &mut String) {
460    if value.capacity() != value.len() {
461        *value = std::mem::take(value).into_boxed_str().into_string();
462    }
463}
464
465fn compact_metadata(value: &mut Value) {
466    match value {
467        Value::Array(values) => {
468            for value in values.iter_mut() {
469                compact_metadata(value);
470            }
471            compact_vec(values);
472        }
473        Value::Object(values) => {
474            if values.keys().any(|key| key.capacity() != key.len()) {
475                *values = std::mem::take(values)
476                    .into_iter()
477                    .map(|(mut key, value)| {
478                        compact_string(&mut key);
479                        (key, value)
480                    })
481                    .collect();
482            }
483            for value in values.values_mut() {
484                compact_metadata(value);
485            }
486        }
487        Value::String(value) => compact_string(value),
488        _ => {}
489    }
490}
491
492fn compact_chunks(chunks: &mut Vec<StoredChunk>) {
493    for chunk in chunks.iter_mut() {
494        compact_string(&mut chunk.id);
495        if let Some(text) = &mut chunk.text {
496            compact_string(text);
497        }
498        compact_vec(&mut chunk.vector.indices);
499    }
500    compact_vec(chunks);
501}
502
503pub fn chunk_document(input: &DocumentInput) -> Result<Vec<DocumentChunk>> {
504    Ok(chunk_spans(input)?
505        .into_iter()
506        .enumerate()
507        .map(|(index, (start_byte, end_byte))| DocumentChunk {
508            id: chunk_id(&input.id, index),
509            text: input.text[start_byte as usize..end_byte as usize].into(),
510            start_byte,
511            end_byte,
512        })
513        .collect())
514}
515
516fn chunk_id(document: &str, index: usize) -> String {
517    let mut length = [0u8; 20];
518    let mut ordinal = [0u8; 20];
519    let (length, ordinal) = (
520        decimal(document.len(), &mut length),
521        decimal(index, &mut ordinal),
522    );
523    // Sized exactly: the id is stored as is, and a grown buffer would be reallocated to fit.
524    let mut id = String::with_capacity(
525        CHUNK_PREFIX.len() + length.len() + document.len() + ordinal.len() + 2,
526    );
527    for part in [CHUNK_PREFIX, length, ":", document, ":", ordinal] {
528        id.push_str(part);
529    }
530    id
531}
532
533/// Whether `id` is [`chunk_id`]`(document, index)`, without building it.
534fn is_chunk_id(id: &str, document: &str, index: usize) -> bool {
535    let mut length = [0u8; 20];
536    let mut ordinal = [0u8; 20];
537    let (length, ordinal) = (
538        decimal(document.len(), &mut length),
539        decimal(index, &mut ordinal),
540    );
541    let mut rest = id;
542    for part in [CHUNK_PREFIX, length, ":", document, ":", ordinal] {
543        match rest.strip_prefix(part) {
544            Some(after) => rest = after,
545            None => return false,
546        }
547    }
548    rest.is_empty()
549}
550
551/// `value` in decimal, written to the end of `buffer`.
552fn decimal(mut value: usize, buffer: &mut [u8; 20]) -> &str {
553    let mut at = buffer.len();
554    loop {
555        at -= 1;
556        buffer[at] = b'0' + (value % 10) as u8;
557        value /= 10;
558        if value == 0 {
559            break;
560        }
561    }
562    std::str::from_utf8(&buffer[at..]).expect("ASCII digits")
563}
564
565/// A JSON sink that counts bytes and fails once they pass `limit`, so a size check allocates
566/// nothing.
567struct SizeLimit {
568    bytes: usize,
569    limit: usize,
570}
571
572impl std::io::Write for SizeLimit {
573    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
574        let next = self
575            .bytes
576            .checked_add(bytes.len())
577            .filter(|&n| n <= self.limit);
578        match next {
579            Some(next) => {
580                self.bytes = next;
581                Ok(bytes.len())
582            }
583            None => Err(std::io::Error::new(
584                std::io::ErrorKind::InvalidInput,
585                "document batch size limit exceeded",
586            )),
587        }
588    }
589
590    fn flush(&mut self) -> std::io::Result<()> {
591        Ok(())
592    }
593}
594
595/// The length of `value` as JSON, or an error once it passes `limit` bytes.
596pub(crate) fn serialized_size(value: &impl serde::Serialize, limit: usize) -> Result<usize> {
597    let mut sink = SizeLimit { bytes: 0, limit };
598    serde_json::to_writer(&mut sink, value)?;
599    Ok(sink.bytes)
600}
601
602/// The byte span of every chunk [`chunk_document`] cuts `input` into, with the same checks and
603/// errors, without copying any text.
604pub(crate) fn chunk_spans_with(
605    input: &DocumentInput,
606    options: &IngestOptions,
607) -> Result<Vec<(u32, u32)>> {
608    if !options.whole_document {
609        return chunk_spans(input);
610    }
611    ensure!(
612        options.encode == EncodeMode::Lexical && options.positions,
613        "whole-document ingest requires lexical encoding and positions"
614    );
615    validate_document_source(input)?;
616    ensure!(
617        input.text.chars().any(|c| !c.is_whitespace()),
618        "document must contain text"
619    );
620    Ok(vec![(0, input.text.len() as u32)])
621}
622
623fn validate_document_source(input: &DocumentInput) -> Result<()> {
624    ensure!(
625        !input.id.is_empty() && input.id.len() <= 4096 && !input.id.contains('\0'),
626        "document id must contain 1..=4096 bytes"
627    );
628    ensure!(
629        input.text.len() <= MAX_DOCUMENT_BYTES,
630        "document exceeds 8 MiB"
631    );
632    Ok(())
633}
634
635pub(crate) fn chunk_spans(input: &DocumentInput) -> Result<Vec<(u32, u32)>> {
636    validate_document_source(input)?;
637    let size = input.chunk_words.unwrap_or(256) as usize;
638    let overlap = input
639        .overlap_words
640        .unwrap_or(32.min(size.saturating_sub(1)) as u32) as usize;
641    ensure!(
642        (1..=4096).contains(&size) && overlap < size,
643        "chunkWords must be 1..=4096 and overlapWords smaller than chunkWords"
644    );
645    let mut words = Vec::with_capacity(input.text.len() / 6);
646    let mut start = None;
647    let mut boundary = |i: usize, whitespace: bool| {
648        if whitespace {
649            if let Some(s) = start.take() {
650                words.push((s, i));
651            }
652        } else if start.is_none() {
653            start = Some(i);
654        }
655    };
656    if input.text.is_ascii() {
657        // `char::is_whitespace` on ASCII is exactly 0x09..=0x0D and the space.
658        for (i, b) in input.text.bytes().enumerate() {
659            boundary(i, matches!(b, b' ' | b'\t'..=b'\r'));
660        }
661    } else {
662        for (i, c) in input.text.char_indices() {
663            boundary(i, c.is_whitespace());
664        }
665    }
666    if let Some(s) = start {
667        words.push((s, input.text.len()));
668    }
669    ensure!(!words.is_empty(), "document must contain text");
670    let mut chunks = Vec::new();
671    let mut word = 0;
672    while word < words.len() {
673        ensure!(
674            chunks.len() < 4096,
675            "document exceeds 4096 chunks; increase chunkWords"
676        );
677        let end_word = (word + size).min(words.len());
678        let (start_byte, end_byte) = (words[word].0, words[end_word - 1].1);
679        ensure!(
680            end_byte - start_byte <= MAX_CHUNK_BYTES,
681            "chunk exceeds 64 KiB; reduce chunkWords or split long tokens"
682        );
683        chunks.push((start_byte as u32, end_byte as u32));
684        if end_word == words.len() {
685            break;
686        }
687        word += size - overlap;
688    }
689    Ok(chunks)
690}
691
692/// Validates `values` (the store's dimensions, finite, not all zero) and returns them
693/// l2-normalised in `f64`. Accepts the client's `f64` values and a stored [`Embedding`]'s
694/// `f32` values, which widen exactly.
695pub(crate) fn normalize_embedding<T: Copy + Into<f64>>(
696    values: &[T],
697    dimensions: usize,
698) -> Result<Vec<f64>> {
699    let values: Vec<f64> = values.iter().map(|&x| x.into()).collect();
700    ensure!(
701        values.len() == dimensions && values.iter().all(|x| x.is_finite()),
702        "embedding dimensions must match the store and values must be finite"
703    );
704    let scale = values.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
705    ensure!(scale > 0.0, "zero embeddings are not supported");
706    let norm = values
707        .iter()
708        .map(|x| (x / scale).powi(2))
709        .sum::<f64>()
710        .sqrt();
711    Ok(values.iter().map(|x| (x / scale) / norm).collect())
712}
713
714fn stemmer() -> &'static rust_stemmers::Stemmer {
715    static STEMMER: OnceLock<rust_stemmers::Stemmer> = OnceLock::new();
716    STEMMER.get_or_init(|| rust_stemmers::Stemmer::create(rust_stemmers::Algorithm::English))
717}
718
719/// Lowercased alphanumeric tokens and their counts; with `stem`, each token is reduced by
720/// the English Snowball (Porter 2) stemmer, which is what the published BM25 baselines do.
721/// The distinct words of a query in one form.
722pub(crate) fn query_words(text: &str, stem: bool) -> std::collections::BTreeSet<String> {
723    terms(text, stem).into_keys().collect()
724}
725
726pub(crate) fn terms(text: &str, stem: bool) -> BTreeMap<String, u32> {
727    // Counted in a hash map keyed by borrowed text (one allocation per distinct term, not per
728    // occurrence), then sorted once into the ordered map the postings expect.
729    // Seeded per process: the keys are caller text.
730    let mut counts: std::collections::HashMap<String, u32, foldhash::fast::RandomState> =
731        std::collections::HashMap::with_capacity_and_hasher(text.len() / 16, Default::default());
732    let mut lower = String::new();
733    let mut add = |key: &str| match counts.get_mut(key) {
734        Some(count) => *count += 1,
735        None => {
736            counts.insert(key.to_owned(), 1);
737        }
738    };
739    for word in text
740        .split(|c: char| !c.is_alphanumeric())
741        .filter(|s| !s.is_empty())
742    {
743        lower.clear();
744        if word.is_ascii() {
745            lower.push_str(word);
746            lower.make_ascii_lowercase();
747        } else {
748            // `str::to_lowercase`, not per-char mapping: it applies the final-sigma rule.
749            lower.push_str(&word.to_lowercase());
750        }
751        if stem {
752            add(&stemmer().stem(&lower));
753        } else {
754            add(&lower);
755        }
756    }
757    // Keys are unique, so an unstable sort is exact; the map's own stable sort then sees one
758    // ascending run and finishes in a single pass.
759    let mut sorted: Vec<(String, u32)> = counts.into_iter().collect();
760    sorted.sort_unstable_by(|a, b| a.0.cmp(&b.0));
761    sorted.into_iter().collect()
762}
763
764/// Words a batch interner maps before it starts over; bounds memory on corpora with
765/// unbounded vocabularies while keeping the common-word hit rate.
766const STEM_MEMO_CAPACITY: usize = 1 << 18;
767
768/// Hash map keyed by caller text, so seeded per process.
769type SeededMap<K, V> = std::collections::HashMap<K, V, foldhash::fast::RandomState>;
770
771/// The lexical state of ingest on one thread, carried across a store's batches
772/// ([`StemMemoPool`]): every distinct word seen maps to the id of its term (lowercased,
773/// Snowball-stemmed), so a repeated word costs one hash lookup and no copy, stemming runs
774/// once per distinct word, and a chunk's counts accumulate in an array by term id.
775#[derive(Default)]
776struct Interner {
777    /// Word exactly as it appears in the text -> term id.
778    words: WordMap,
779    /// Term -> id, so the words of one stem count as one term.
780    ids: SeededMap<Box<str>, u32>,
781    /// Term by id, and its first eight bytes big-endian (zero-padded): a term never contains
782    /// a NUL byte, so comparing keys orders terms as their bytes do except on equal keys.
783    terms: Vec<Box<str>>,
784    keys: Vec<u64>,
785    /// Count per term id for the chunk being counted, and the ids counted so far. Zero and
786    /// empty between chunks.
787    counts: Vec<u32>,
788    counted: Vec<u32>,
789    /// The term id of each word of the chunk, in text order, while `record` is set.
790    sequence: Vec<u32>,
791    record: bool,
792    lower: String,
793    /// Terms are the lowercased words themselves rather than their stems.
794    unstemmed: bool,
795}
796
797/// Word -> term id. A word of at most 16 bytes (nearly every word) is keyed by its bytes
798/// zero-padded into a `u128`, unambiguous because a word holds no NUL byte, so a lookup
799/// compares integers rather than following a pointer to compare strings.
800#[derive(Default)]
801struct WordMap {
802    short: SeededMap<u128, u32>,
803    long: SeededMap<Box<str>, u32>,
804}
805
806impl WordMap {
807    #[inline]
808    fn short_key(word: &str) -> Option<u128> {
809        debug_assert!(!word.contains('\0'));
810        let bytes = word.as_bytes();
811        (bytes.len() <= 16).then(|| {
812            let mut key = [0u8; 16];
813            key[..bytes.len()].copy_from_slice(bytes);
814            u128::from_le_bytes(key)
815        })
816    }
817
818    #[inline]
819    fn get(&self, word: &str) -> Option<u32> {
820        match Self::short_key(word) {
821            Some(key) => self.short.get(&key).copied(),
822            None => self.long.get(word).copied(),
823        }
824    }
825
826    fn insert(&mut self, word: &str, id: u32) {
827        match Self::short_key(word) {
828            Some(key) => self.short.insert(key, id),
829            None => self.long.insert(word.into(), id),
830        };
831    }
832
833    fn len(&self) -> usize {
834        self.short.len() + self.long.len()
835    }
836}
837
838impl Interner {
839    fn term_id(&mut self, word: &str, ascii: bool) -> u32 {
840        if let Some(id) = self.words.get(word) {
841            return id;
842        }
843        self.lower.clear();
844        if ascii {
845            self.lower.push_str(word);
846            self.lower.make_ascii_lowercase();
847        } else {
848            // `str::to_lowercase`, not per-char mapping: it applies the final-sigma rule.
849            self.lower.push_str(&word.to_lowercase());
850        }
851        let stem = if self.unstemmed {
852            std::borrow::Cow::Borrowed(self.lower.as_str())
853        } else {
854            stemmer().stem(&self.lower)
855        };
856        let id = match self.ids.get(stem.as_ref()) {
857            Some(&id) => id,
858            None => {
859                let id = u32::try_from(self.terms.len()).expect("batch vocabulary over u32");
860                let term: Box<str> = stem.into();
861                let mut key = [0u8; 8];
862                let prefix = term.len().min(8);
863                key[..prefix].copy_from_slice(&term.as_bytes()[..prefix]);
864                self.keys.push(u64::from_be_bytes(key));
865                self.ids.insert(term.clone(), id);
866                self.terms.push(term);
867                self.counts.push(0);
868                id
869            }
870        };
871        self.words.insert(word, id);
872        id
873    }
874
875    /// Counts the words of `text` `weight` times each, as `terms(text, true)` splits them
876    /// (maximal runs of alphanumeric characters); returns how many were counted, weighted.
877    fn count(&mut self, text: &str, weight: u32) -> usize {
878        let bytes = text.as_bytes();
879        let mut counted = 0;
880        let mut at = 0;
881        let char_at = |at: usize| text[at..].chars().next().expect("char boundary");
882        while at < bytes.len() {
883            if bytes[at] < 0x80 {
884                if !bytes[at].is_ascii_alphanumeric() {
885                    at += 1;
886                    continue;
887                }
888            } else {
889                let c = char_at(at);
890                if !c.is_alphanumeric() {
891                    at += c.len_utf8();
892                    continue;
893                }
894            }
895            let start = at;
896            let mut ascii = true;
897            while at < bytes.len() {
898                if bytes[at] < 0x80 {
899                    if !bytes[at].is_ascii_alphanumeric() {
900                        break;
901                    }
902                    at += 1;
903                } else {
904                    let c = char_at(at);
905                    if !c.is_alphanumeric() {
906                        break;
907                    }
908                    ascii = false;
909                    at += c.len_utf8();
910                }
911            }
912            let id = self.term_id(&text[start..at], ascii);
913            if self.record {
914                self.sequence.push(id);
915            }
916            let count = &mut self.counts[id as usize];
917            if *count == 0 {
918                self.counted.push(id);
919            }
920            *count += weight;
921            counted += weight as usize;
922        }
923        counted
924    }
925
926    /// The term table of `text` with its first line's terms counted `title_weight` times in
927    /// total, and the weighted word count: what `terms(text, true)` plus the title weighting
928    /// produce.
929    /// With `positions` (`Some(whole_document)`), the table also carries each term's word
930    /// positions, the bytes the reference analyzer (`Positions::from_text`) writes.
931    fn chunk_terms(
932        &mut self,
933        text: &str,
934        title_weight: u32,
935        stem: bool,
936        positions: Option<bool>,
937    ) -> (StoredTerms, usize) {
938        if self.words.len() >= STEM_MEMO_CAPACITY || self.unstemmed == stem {
939            *self = Self {
940                unstemmed: !stem,
941                ..Self::default()
942            };
943        }
944        self.sequence.clear();
945        self.record = positions.is_some();
946        let mut words = self.count(text, 1);
947        self.record = false;
948        if title_weight > 1 {
949            words += self.count(text.lines().next().unwrap_or_default(), title_weight - 1);
950        }
951        let (terms, keys, counts) = (&self.terms, &self.keys, &mut self.counts);
952        self.counted.sort_unstable_by(|&a, &b| {
953            let (a, b) = (a as usize, b as usize);
954            keys[a]
955                .cmp(&keys[b])
956                .then_with(|| terms[a].as_bytes().cmp(terms[b].as_bytes()))
957        });
958        let bytes = self
959            .counted
960            .iter()
961            .map(|&id| terms[id as usize].len())
962            .sum();
963        let mut table = StoredTerms::from_sorted_refs(
964            self.counted.len(),
965            bytes,
966            self.counted
967                .iter()
968                .map(|&id| (&*terms[id as usize], counts[id as usize])),
969        );
970        if let Some(whole_document) = positions {
971            // Each word's term as its rank among the chunk's sorted terms, through `counts`
972            // (cleared below).
973            for (rank, &id) in self.counted.iter().enumerate() {
974                counts[id as usize] = rank as u32;
975            }
976            for id in &mut self.sequence {
977                *id = counts[*id as usize];
978            }
979            table.positions = Some(Arc::new(positions::Positions::from_ranks(
980                &self.sequence,
981                self.counted.len(),
982                whole_document,
983            )));
984            self.sequence.clear();
985        }
986        for &id in &self.counted {
987            counts[id as usize] = 0;
988        }
989        self.counted.clear();
990        (table, words)
991    }
992}
993
994thread_local! {
995    static ACTIVE_STEM_MEMO: std::cell::RefCell<Option<Interner>> =
996        const { std::cell::RefCell::new(None) };
997}
998
999/// The word interner of an ingest batch. It is installed on a thread only while that thread
1000/// prepares documents for the batch and is kept by the store between batches
1001/// ([`StemMemoPool`]), so no thread keeps it and queries never use it.
1002#[derive(Default)]
1003pub(crate) struct StemMemo(Interner);
1004
1005pub(crate) struct StemMemoGuard<'a>(&'a mut StemMemo);
1006
1007impl StemMemo {
1008    pub(crate) fn install(&mut self) -> StemMemoGuard<'_> {
1009        let table = std::mem::take(&mut self.0);
1010        ACTIVE_STEM_MEMO.with(|active| *active.borrow_mut() = Some(table));
1011        StemMemoGuard(self)
1012    }
1013}
1014
1015impl Drop for StemMemoGuard<'_> {
1016    fn drop(&mut self) {
1017        if let Some(table) = ACTIVE_STEM_MEMO.with(|active| active.borrow_mut().take()) {
1018            self.0 .0 = table;
1019        }
1020    }
1021}
1022
1023/// The batch interners a store keeps between its document batches, so a corpus loaded in
1024/// consecutive batches stems each distinct word once rather than once per batch and rayon
1025/// split. While a batch runs, each split takes an idle interner (the one that has seen the
1026/// most words) and returns it when done, so a worker's later splits reuse what its earlier
1027/// ones stemmed. When the batch ends ([`StemMemoPool::trim`]) only the largest is kept,
1028/// bounded by [`STEM_MEMO_CAPACITY`] words. With keeping turned off
1029/// ([`StemMemoPool::set_keep`]) each split's interner is freed with the split.
1030#[derive(Default)]
1031pub(crate) struct StemMemoPool {
1032    idle: crate::core::sync::Mutex<Vec<StemMemo>>,
1033    release: std::sync::atomic::AtomicBool,
1034}
1035
1036impl StemMemoPool {
1037    /// The idle interner that has seen the most words, or a new one; it goes back to the pool
1038    /// when the result drops.
1039    pub(crate) fn take(&self) -> PooledStemMemo<'_> {
1040        let memo = {
1041            let mut idle = self.idle.lock();
1042            let largest = (0..idle.len()).max_by_key(|&i| idle[i].0.words.len());
1043            largest.map(|i| idle.swap_remove(i))
1044        };
1045        PooledStemMemo {
1046            pool: self,
1047            memo: memo.unwrap_or_default(),
1048        }
1049    }
1050
1051    /// Keeps the idle interners that have seen the most words, together at most
1052    /// [`STEM_MEMO_CAPACITY`] words (always the largest one); the others are freed.
1053    pub(crate) fn trim(&self) {
1054        let mut idle = std::mem::take(&mut *self.idle.lock());
1055        idle.sort_unstable_by_key(|memo| std::cmp::Reverse(memo.0.words.len()));
1056        let mut words = 0;
1057        let keep = idle
1058            .iter()
1059            .position(|memo| {
1060                words += memo.0.words.len();
1061                words > STEM_MEMO_CAPACITY
1062            })
1063            .unwrap_or(idle.len())
1064            .max(1)
1065            .min(idle.len());
1066        let freed = idle.split_off(keep);
1067        self.idle.lock().append(&mut idle);
1068        // The freed interners drop here, with the lock released.
1069        drop(freed);
1070    }
1071
1072    /// Whether interners outlive their batch; turning it off frees the idle ones now.
1073    pub(crate) fn set_keep(&self, keep: bool) {
1074        self.release
1075            .store(!keep, std::sync::atomic::Ordering::Relaxed);
1076        if !keep {
1077            let freed = std::mem::take(&mut *self.idle.lock());
1078            drop(freed);
1079        }
1080    }
1081}
1082
1083pub(crate) struct PooledStemMemo<'a> {
1084    pool: &'a StemMemoPool,
1085    memo: StemMemo,
1086}
1087
1088impl PooledStemMemo<'_> {
1089    pub(crate) fn install(&mut self) -> StemMemoGuard<'_> {
1090        self.memo.install()
1091    }
1092}
1093
1094impl Drop for PooledStemMemo<'_> {
1095    fn drop(&mut self) {
1096        let memo = std::mem::take(&mut self.memo);
1097        if self.pool.release.load(std::sync::atomic::Ordering::Relaxed) {
1098            return;
1099        }
1100        self.pool.idle.lock().push(memo);
1101    }
1102}
1103
1104/// [`Interner::chunk_terms`] through the batch interner installed on this thread, or a
1105/// one-off one.
1106#[cfg(test)]
1107fn chunk_terms(text: &str, title_weight: u32, stem: bool) -> (StoredTerms, usize) {
1108    analyze_chunk(text, title_weight, stem, None)
1109}
1110
1111/// A chunk's term table through the batch interner installed on this thread, or a one-off
1112/// one, with each term's word positions when `positions` is `Some(whole_document)`.
1113fn analyze_chunk(
1114    text: &str,
1115    title_weight: u32,
1116    stem: bool,
1117    positions: Option<bool>,
1118) -> (StoredTerms, usize) {
1119    ACTIVE_STEM_MEMO.with(|active| match active.borrow_mut().as_mut() {
1120        Some(interner) => interner.chunk_terms(text, title_weight, stem, positions),
1121        None => Interner::default().chunk_terms(text, title_weight, stem, positions),
1122    })
1123}
1124
1125pub(crate) fn prepare(
1126    input: DocumentInput,
1127    dimensions: usize,
1128    embedding_dimensions: Option<usize>,
1129    encoder: TextEncoder,
1130) -> Result<StoredDocument> {
1131    prepare_with_options(
1132        input,
1133        dimensions,
1134        embedding_dimensions,
1135        None,
1136        encoder,
1137        &IngestOptions::default(),
1138    )
1139}
1140
1141/// [`prepare`] with an optional lexical key per chunk (for example the chunk prefixed with
1142/// facts extracted from it; stored text and byte offsets stay the chunk's own) and the chunk vectors encoded now, later or never (`mode`). The
1143/// lexical side of the document is the same in every mode.
1144pub(crate) fn prepare_with_options(
1145    input: DocumentInput,
1146    dimensions: usize,
1147    embedding_dimensions: Option<usize>,
1148    keys: Option<&[String]>,
1149    encoder: TextEncoder,
1150    options: &IngestOptions,
1151) -> Result<StoredDocument> {
1152    check_mode(&input, encoder, options.encode)?;
1153    let spans = chunk_spans_with(&input, options)?;
1154    prepare_spans(
1155        input,
1156        spans,
1157        dimensions,
1158        embedding_dimensions,
1159        keys,
1160        encoder,
1161        options,
1162    )
1163}
1164
1165fn check_mode(input: &DocumentInput, encoder: TextEncoder, mode: EncodeMode) -> Result<()> {
1166    ensure!(
1167        mode != EncodeMode::Deferred || !encoder.is_embedding(),
1168        "deferred encoding applies to text encoders; {} vectors come from embeddings",
1169        encoder.name()
1170    );
1171    ensure!(
1172        mode != EncodeMode::Deferred || input.store_text.unwrap_or(true),
1173        "deferred encoding needs the chunk text; set storeText or encode eagerly"
1174    );
1175    Ok(())
1176}
1177
1178/// [`prepare_with_options`] of an input whose chunk spans ([`chunk_spans`]) are known.
1179pub(crate) fn prepare_spans(
1180    input: DocumentInput,
1181    chunks: Vec<(u32, u32)>,
1182    dimensions: usize,
1183    embedding_dimensions: Option<usize>,
1184    keys: Option<&[String]>,
1185    encoder: TextEncoder,
1186    options: &IngestOptions,
1187) -> Result<StoredDocument> {
1188    let mode = options.encode;
1189    check_mode(&input, encoder, mode)?;
1190    ensure!(
1191        keys.is_none_or(|k| k.len() == chunks.len()),
1192        "provide one key per chunk"
1193    );
1194    ensure!(
1195        input.metadata.as_ref().is_none_or(Value::is_object),
1196        "metadata must be a JSON object"
1197    );
1198    ensure!(
1199        serialized_size(&input.metadata, 64 * 1024).is_ok(),
1200        "metadata exceeds 64 KiB"
1201    );
1202    ensure!(
1203        input.source_uri.as_ref().is_none_or(|s| s.len() <= 8192),
1204        "sourceUri exceeds 8192 bytes"
1205    );
1206    ensure!(
1207        input
1208            .version
1209            .as_ref()
1210            .is_none_or(|s| !s.is_empty() && s.len() <= 1024),
1211        "version requires 1..=1024 bytes"
1212    );
1213    if let Some(embeddings) = &input.embeddings {
1214        ensure!(
1215            embedding_dimensions.is_some() && embeddings.len() == chunks.len(),
1216            "provide one embedding per chunk and configure the store embedding space"
1217        );
1218        ensure!(
1219            embeddings.iter().map(Vec::len).sum::<usize>() <= 1_048_576,
1220            "document exceeds 1048576 embedding values; split the document"
1221        );
1222    }
1223    let mut stored = Vec::with_capacity(chunks.len());
1224    let title_weight = input.title_weight.unwrap_or(1);
1225    ensure!(
1226        (1..=16).contains(&title_weight),
1227        "titleWeight must be between 1 and 16"
1228    );
1229    for (i, &(start_byte, end_byte)) in chunks.iter().enumerate() {
1230        let text = &input.text[start_byte as usize..end_byte as usize];
1231        let source = keys.map_or(text, |k| k[i].as_str());
1232        ensure!(
1233            !options.positions || keys.is_none(),
1234            "positions require the original chunk text as the lexical key"
1235        );
1236        let (terms, word_count) = analyze_chunk(
1237            source,
1238            if i == 0 { title_weight } else { 1 },
1239            !options.unstemmed,
1240            options.positions.then_some(options.whole_document),
1241        );
1242        let embedding = input
1243            .embeddings
1244            .as_ref()
1245            .map(|all| {
1246                normalize_embedding(&all[i], embedding_dimensions.unwrap_or(0))
1247                    .map(|values| Embedding::from_f64(&values))
1248            })
1249            .transpose()?;
1250        let (vector, vector_state) = match (mode, encoder.is_embedding(), &embedding) {
1251            (EncodeMode::Lexical, _, _) => (
1252                EntangledHVec::from_indices(Vec::new(), dimensions),
1253                VectorState::Lexical,
1254            ),
1255            (EncodeMode::Deferred, _, _) => (
1256                EntangledHVec::from_indices(Vec::new(), dimensions),
1257                VectorState::Pending,
1258            ),
1259            (EncodeMode::Eager, false, _) => {
1260                (encoder.encode(text, dimensions), VectorState::Encoded)
1261            }
1262            (EncodeMode::Eager, true, Some(e)) => (
1263                EntangledHVec::from_dense(e, dimensions),
1264                VectorState::Encoded,
1265            ),
1266            (EncodeMode::Eager, true, None) => bail!(
1267                "the {} text encoder needs a dense embedding for every chunk; install a text embedder",
1268                encoder.name()
1269            ),
1270        };
1271        stored.push(StoredChunk {
1272            vector,
1273            vector_state,
1274            text: input.store_text.unwrap_or(true).then(|| text.to_owned()),
1275            text_at: None,
1276            id: chunk_id(&input.id, i),
1277            start_byte,
1278            end_byte,
1279            embedding,
1280            terms,
1281            word_count,
1282            stemmed: !options.unstemmed,
1283        });
1284    }
1285    let mut document = StoredDocument {
1286        id: input.id,
1287        source_uri: input.source_uri,
1288        version: input.version.unwrap_or_else(|| "1".into()),
1289        metadata: input.metadata.unwrap_or_else(|| serde_json::json!({})),
1290        chunks: stored,
1291    };
1292    compact_string(&mut document.id);
1293    compact_string(&mut document.version);
1294    if let Some(uri) = &mut document.source_uri {
1295        compact_string(uri);
1296    }
1297    compact_metadata(&mut document.metadata);
1298    compact_chunks(&mut document.chunks);
1299    Ok(document)
1300}
1301
1302/// Identity of the content deferred encoding reads: the version, byte spans and text of every
1303/// chunk, mixed with the crate's splitmix64 chain. `None` when a chunk's text is unavailable.
1304/// `Mutation::EncodeDocument` carries it so vectors computed for one revision of a document
1305/// are never installed on another that reused its id, version and chunk count.
1306pub(crate) fn document_fingerprint(
1307    store: &DocumentStore,
1308    document: &StoredDocument,
1309) -> Option<u64> {
1310    let mut h = super::encoding::hash_str_seed(&document.version, 0x5EED_D0C5_F1A6_0001);
1311    for chunk in &document.chunks {
1312        let text = store.chunk_text(chunk)?;
1313        h = super::entangled::hash_u64(h, u64::from(chunk.start_byte));
1314        h = super::entangled::hash_u64(h, u64::from(chunk.end_byte));
1315        h = super::encoding::hash_str_seed(&text, h);
1316    }
1317    Some(h)
1318}
1319
1320/// Whether every query in `prepared` ranks from the checkpoint under `store` and the store's
1321/// own documents (see `search::overlay_serves`).
1322pub(crate) fn overlay_serves(store: &DocumentStore, prepared: &[PreparedSearch]) -> bool {
1323    prepared
1324        .iter()
1325        .all(|query| search::overlay_serves(query, store))
1326}
1327
1328/// [`capture_search`] over the checkpoint under `store` and the store's own documents, for
1329/// queries [`overlay_serves`] accepts.
1330pub(crate) fn capture_overlaid_search(
1331    store: &DocumentStore,
1332    prepared: Vec<PreparedSearch>,
1333) -> Result<CapturedSearch> {
1334    search::capture_overlaid_search(store, prepared)
1335}
1336
1337/// The checkpoint of `store` for the generation whose name has CRC32 `generation`; `None` when
1338/// a chunk is not lexical-only or the store would not fit the layout.
1339pub(crate) fn encode_checkpoint(
1340    store: &DocumentStore,
1341    generation: u32,
1342    dimensions: usize,
1343) -> Result<Option<Vec<u8>>> {
1344    image::encode(store, generation, dimensions)
1345}
1346#[cfg(test)]
1347pub(crate) fn search(
1348    store: &DocumentStore,
1349    text: &str,
1350    options: &SearchOptions,
1351    embedding_dimensions: Option<usize>,
1352) -> Result<Vec<DocumentResult>> {
1353    let prepared = prepare_search(text, options, embedding_dimensions)?;
1354    let captured = capture_search(store, vec![prepared]);
1355    Ok(execute_search(&captured, 0))
1356}
1357
1358#[cfg(test)]
1359fn eligible_chunks<'a>(
1360    documents: impl Iterator<Item = &'a StoredDocument>,
1361    options: &SearchOptions,
1362) -> Vec<(&'a StoredDocument, &'a StoredChunk)> {
1363    documents
1364        .filter(|document| matches_document(document, options))
1365        .flat_map(|d| d.chunks.iter().map(move |chunk| (d, chunk)))
1366        .collect()
1367}
1368
1369#[cfg(test)]
1370fn matches_document(document: &StoredDocument, options: &SearchOptions) -> bool {
1371    options
1372        .document_ids
1373        .as_ref()
1374        .is_none_or(|ids| ids.contains(&document.id))
1375        && matches_document_metadata(document, options)
1376}
1377
1378fn matches_document_metadata(document: &StoredDocument, options: &SearchOptions) -> bool {
1379    options
1380        .source_uri
1381        .as_ref()
1382        .is_none_or(|uri| document.source_uri.as_ref() == Some(uri))
1383        && options
1384            .filter
1385            .as_ref()
1386            .and_then(Value::as_object)
1387            .is_none_or(|filter| {
1388                filter
1389                    .iter()
1390                    .all(|(key, value)| document.metadata.get(key) == Some(value))
1391            })
1392}
1393
1394fn top_ranked<T>(
1395    values: &mut Vec<T>,
1396    limit: usize,
1397    mut compare: impl FnMut(&T, &T) -> std::cmp::Ordering,
1398) {
1399    if values.len() > limit {
1400        values.select_nth_unstable_by(limit - 1, &mut compare);
1401        values.truncate(limit);
1402    }
1403    values.sort_unstable_by(compare);
1404}
1405
1406#[cfg(test)]
1407fn search_impl(
1408    store: &DocumentStore,
1409    text: &str,
1410    options: &SearchOptions,
1411    embedding_dimensions: Option<usize>,
1412    indexed: bool,
1413) -> Result<Vec<DocumentResult>> {
1414    if indexed {
1415        return search(store, text, options, embedding_dimensions);
1416    }
1417    ensure!(text.len() <= MAX_DOCUMENT_BYTES, "query exceeds 8 MiB");
1418    let k = options.k.unwrap_or(10) as usize;
1419    let limit = options.candidate_limit.unwrap_or(100.max(k as u32)) as usize;
1420    ensure!(
1421        (1..=1000).contains(&k) && (k..=4096).contains(&limit),
1422        "require 1 <= k <= 1000 and k <= candidateLimit <= 4096"
1423    );
1424    ensure!(
1425        options.filter.as_ref().is_none_or(Value::is_object),
1426        "filter must be a JSON object of exact metadata matches"
1427    );
1428    let lexical_weight = options.lexical_weight.unwrap_or(1.0);
1429    let semantic_weight = options.semantic_weight.unwrap_or(1.0);
1430    ensure!(
1431        [lexical_weight, semantic_weight]
1432            .iter()
1433            .all(|v| v.is_finite() && *v >= 0.0)
1434            && lexical_weight + semantic_weight > 0.0,
1435        "search weights must be finite, nonnegative and not both zero"
1436    );
1437    let bm25 = Bm25::from_options(options)?;
1438    let minimum = options.min_semantic_score.unwrap_or(0.0);
1439    ensure!(
1440        minimum.is_finite() && (-1.0..=1.0).contains(&minimum),
1441        "minSemanticScore must be in [-1,1]"
1442    );
1443    let rrf = match options.fusion.as_deref() {
1444        None | Some("blend") => false,
1445        Some("rrf") => true,
1446        Some(other) => anyhow::bail!("fusion must be \"blend\" or \"rrf\", not {other:?}"),
1447    };
1448    let query_embedding = options
1449        .embedding
1450        .as_ref()
1451        .map(|v| {
1452            ensure!(
1453                embedding_dimensions.is_some(),
1454                "configure an embedding space before semantic search"
1455            );
1456            normalize_embedding(v, embedding_dimensions.unwrap_or(0))
1457        })
1458        .transpose()?;
1459    // Unstemmed and stemmed query forms: a chunk is scored with the form it was stored in.
1460    let query_terms = [query_words(text, false), query_words(text, true)];
1461    let candidates = if indexed {
1462        store.candidate_ids(options)
1463    } else {
1464        None
1465    };
1466    let eligible = match candidates {
1467        Some(ids) => eligible_chunks(
1468            ids.into_iter().map(|id| {
1469                store
1470                    .documents
1471                    .get(id)
1472                    .expect("document candidate is absent from store")
1473                    .as_ref()
1474            }),
1475            options,
1476        ),
1477        None => eligible_chunks(store.values(), options),
1478    };
1479    if eligible.is_empty() {
1480        return Ok(Vec::new());
1481    }
1482    let n = eligible.len() as f64;
1483    let average = eligible.iter().map(|(_, c)| c.word_count).sum::<usize>() as f64 / n;
1484    let posting_work: usize = query_terms
1485        .iter()
1486        .enumerate()
1487        .map(|(form, terms)| {
1488            terms
1489                .iter()
1490                .map(|word| store.postings[form].get(word).map_or(0, |p| p.len()))
1491                .sum::<usize>()
1492        })
1493        .sum();
1494    let scan_work = eligible.len().saturating_mul(
1495        query_terms
1496            .iter()
1497            .map(std::collections::BTreeSet::len)
1498            .sum(),
1499    );
1500    let use_postings = indexed && posting_work.saturating_add(store.slot_count) < scan_work;
1501    let mut positions = Vec::new();
1502    if use_postings {
1503        positions.resize(store.slot_count, usize::MAX);
1504        let mut previous = "";
1505        let mut chunk = 0;
1506        for (i, (document, _)) in eligible.iter().enumerate() {
1507            if document.id != previous {
1508                previous = &document.id;
1509                chunk = 0;
1510            }
1511            positions[store.slots[&document.id][chunk]] = i;
1512            chunk += 1;
1513        }
1514    }
1515    let idf: [BTreeMap<&String, f64>; 2] = std::array::from_fn(|form| {
1516        let stemmed = form == 1;
1517        query_terms[form]
1518            .iter()
1519            .map(|word| {
1520                let df = if use_postings {
1521                    store.postings[form].get(word).map_or(0, |posting| {
1522                        posting
1523                            .iter()
1524                            .filter(|&(slot, _)| positions[slot] != usize::MAX)
1525                            .count()
1526                    })
1527                } else {
1528                    eligible
1529                        .iter()
1530                        .filter(|(_, c)| c.stemmed == stemmed && c.terms.contains_key(word))
1531                        .count()
1532                } as f64;
1533                (word, Bm25::idf(n, df))
1534            })
1535            .collect()
1536    });
1537    let mut scores: Vec<(f64, Option<f64>)> = eligible
1538        .iter()
1539        .map(|(_, c)| {
1540            let lexical = if use_postings {
1541                0.0
1542            } else {
1543                idf[usize::from(c.stemmed)]
1544                    .iter()
1545                    .map(|(word, idf)| {
1546                        let tf = c.terms.get(word).copied().unwrap_or(0) as f64;
1547                        bm25.weight(*idf, tf, bm25.norm(c.word_count as f64, average.max(1.0)))
1548                    })
1549                    .sum()
1550            };
1551            let semantic = query_embedding
1552                .as_ref()
1553                .zip(c.embedding.as_ref())
1554                .map(|(q, v)| v.cosine(q));
1555            (lexical, semantic)
1556        })
1557        .collect();
1558    if use_postings {
1559        for (form, weights) in idf.iter().enumerate() {
1560            for (word, idf) in weights {
1561                if let Some(posting) = store.postings[form].get(word) {
1562                    for (slot, frequency) in posting.iter() {
1563                        let i = positions[slot];
1564                        if i == usize::MAX {
1565                            continue;
1566                        }
1567                        let c = eligible[i].1;
1568                        let tf = f64::from(frequency);
1569                        scores[i].0 +=
1570                            bm25.weight(*idf, tf, bm25.norm(c.word_count as f64, average.max(1.0)));
1571                    }
1572                }
1573            }
1574        }
1575    }
1576    let mut lexical: Vec<usize> = (0..scores.len())
1577        .filter(|&i| scores[i].0 > 0.0 && lexical_weight > 0.0)
1578        .collect();
1579    let mut semantic: Vec<usize> = (0..scores.len())
1580        .filter(|&i| semantic_weight > 0.0 && scores[i].1.is_some_and(|s| s >= minimum))
1581        .collect();
1582    top_ranked(&mut lexical, limit, |&a, &b| {
1583        scores[b].0.total_cmp(&scores[a].0).then(a.cmp(&b))
1584    });
1585    top_ranked(&mut semantic, limit, |&a, &b| {
1586        scores[b]
1587            .1
1588            .unwrap_or(-1.0)
1589            .total_cmp(&scores[a].1.unwrap_or(-1.0))
1590            .then(a.cmp(&b))
1591    });
1592    let mut fused = BTreeMap::<usize, f64>::new();
1593    for (list, (indices, weight)) in [(&lexical, lexical_weight), (&semantic, semantic_weight)]
1594        .into_iter()
1595        .enumerate()
1596    {
1597        let top = &indices[..indices.len().min(limit)];
1598        if rrf {
1599            for (rank, &i) in top.iter().enumerate() {
1600                *fused.entry(i).or_default() += weight / (60.0 + rank as f64 + 1.0);
1601            }
1602            continue;
1603        }
1604        // Min-max over the list's own top candidates; a flat list contributes its weight.
1605        let value = |i: usize| {
1606            if list == 0 {
1607                scores[i].0
1608            } else {
1609                scores[i].1.unwrap_or(-1.0)
1610            }
1611        };
1612        let (hi, lo) = match (top.first(), top.last()) {
1613            (Some(&f), Some(&l)) => (value(f), value(l)),
1614            _ => continue,
1615        };
1616        for &i in top {
1617            let norm = if hi > lo {
1618                (value(i) - lo) / (hi - lo)
1619            } else {
1620                1.0
1621            };
1622            *fused.entry(i).or_default() += weight * norm;
1623        }
1624    }
1625    let mut ranked: Vec<_> = fused.into_iter().collect();
1626    ranked.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
1627    Ok(ranked
1628        .into_iter()
1629        .take(k)
1630        .map(|(i, score)| {
1631            let (d, c) = eligible[i];
1632            DocumentResult {
1633                id: c.id.clone(),
1634                document_id: d.id.clone(),
1635                source_uri: d.source_uri.clone(),
1636                version: d.version.clone(),
1637                metadata: d.metadata.clone(),
1638                text: c.text.clone(),
1639                start_byte: c.start_byte,
1640                end_byte: c.end_byte,
1641                score,
1642                lexical_score: scores[i].0,
1643                semantic_score: scores[i].1,
1644            }
1645        })
1646        .collect())
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651    use super::*;
1652    use serde_json::json;
1653
1654    /// The chunk id keeps the journal's text form, and recognising it agrees with comparing
1655    /// against it, near misses included.
1656    #[test]
1657    fn chunk_ids_match_their_text_form() {
1658        for document in ["d", "a:b", "x:1:2", "ünï", ""] {
1659            for index in [0, 9, 10, 99, 100, 4095, usize::MAX] {
1660                let id = chunk_id(document, index);
1661                let text = format!("{CHUNK_PREFIX}{}:{document}:{index}", document.len());
1662                assert_eq!(id, text);
1663                assert_eq!(id.capacity(), id.len());
1664                assert!(is_chunk_id(&id, document, index));
1665                for other in [
1666                    format!("{id}0"),
1667                    id[..id.len() - 1].to_owned(),
1668                    chunk_id(document, index.wrapping_add(1)),
1669                    chunk_id(&format!("{document}x"), index),
1670                    id.replacen(CHUNK_PREFIX, "", 1),
1671                ] {
1672                    assert_eq!(
1673                        is_chunk_id(&other, document, index),
1674                        other == id,
1675                        "{other:?}"
1676                    );
1677                }
1678            }
1679        }
1680    }
1681
1682    fn document(i: usize, revision: usize) -> StoredDocument {
1683        let input: DocumentInput = serde_json::from_value(json!({
1684            "id":format!("d{i:03}"), "version":revision.to_string(),
1685            "text":format!("running common topic{} rare{i} shared birds fly distant place revision{revision}",i%7),
1686            "sourceUri":format!("source{}",i%3), "metadata":{"group":i%5,"empty":null,"flag":i.is_multiple_of(2),"text":"n","fraction":-0.0,"nested":{"key":i%5}},
1687            "chunkWords":5,"overlapWords":1,
1688            "embeddings":[[1.0,0.1,0.0],[0.0,1.0,0.1],[0.1,0.0,1.0]]
1689        })).unwrap();
1690        let mut doc = prepare(input, 1024, Some(3), TextEncoder::default()).unwrap();
1691        if i.is_multiple_of(2) {
1692            for chunk in &mut doc.chunks {
1693                chunk.terms = terms(chunk.text.as_deref().unwrap(), false).into();
1694                chunk.stemmed = false;
1695            }
1696        }
1697        doc
1698    }
1699
1700    #[test]
1701    fn bm25_options_change_scores_only_when_set() {
1702        let mut store = DocumentStore::default();
1703        for i in 0..12 {
1704            let doc = document(i, 1);
1705            store.insert(doc.id.clone(), doc);
1706        }
1707        let lexical = |k1: Option<f64>, b: Option<f64>| {
1708            let options = SearchOptions {
1709                k: Some(5),
1710                lexical_weight: Some(1.0),
1711                semantic_weight: Some(0.0),
1712                bm25_k1: k1,
1713                bm25_b: b,
1714                ..Default::default()
1715            };
1716            search_impl(&store, "rare3 shared", &options, Some(3), true).map(|hits| {
1717                hits.into_iter()
1718                    .map(|h| (h.id, h.lexical_score))
1719                    .collect::<Vec<_>>()
1720            })
1721        };
1722        let default = lexical(None, None).unwrap();
1723        assert_eq!(default, lexical(Some(1.2), Some(0.75)).unwrap());
1724        // k1 = 0 saturates every term at its idf, so the top hit scores the sum of its idfs.
1725        let flat = lexical(Some(0.0), Some(0.5)).unwrap();
1726        assert_ne!(flat, default);
1727        let n = store.chunk_count as f64;
1728        let idf = |word: &str| {
1729            let df = store.postings[1].get(word).map_or(0, |list| list.len()) as f64;
1730            (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
1731        };
1732        let stem = |word: &str| terms(word, true).into_keys().next().unwrap();
1733        assert!((flat[0].1 - (idf(&stem("rare3")) + idf(&stem("shared")))).abs() < 1e-12);
1734        assert!(lexical(Some(-1.0), None).is_err());
1735        assert!(lexical(None, Some(1.5)).is_err());
1736        assert!(lexical(Some(f64::NAN), None).is_err());
1737        assert_scan_parity(&store);
1738    }
1739
1740    fn assert_scan_parity(store: &DocumentStore) {
1741        for query in [
1742            "running rare3 missing absent unknown extra terms for selective postings",
1743            "running rare3",
1744            "birds distant",
1745            "common",
1746            "missing token",
1747            "",
1748        ] {
1749            for fusion in ["blend", "rrf"] {
1750                for filter in [
1751                    None,
1752                    Some(json!({"group":3})),
1753                    Some(json!({"group":99})),
1754                    Some(json!({"empty":null,"flag":true,"text":"n"})),
1755                    Some(json!({"fraction":0.0,"nested":{"key":3}})),
1756                ] {
1757                    for limit in [9, 10, 11, 100] {
1758                        for embedding in [None, Some(vec![1., 0., 0.])] {
1759                            let opts = SearchOptions {
1760                                k: Some(9),
1761                                candidate_limit: Some(limit),
1762                                filter: filter.clone(),
1763                                fusion: Some(fusion.into()),
1764                                embedding,
1765                                min_semantic_score: Some(-1.),
1766                                ..Default::default()
1767                            };
1768                            let indexed = search_impl(store, query, &opts, Some(3), true).unwrap();
1769                            let scanned = search_impl(store, query, &opts, Some(3), false).unwrap();
1770                            assert_eq!(
1771                                serde_json::to_value(indexed).unwrap(),
1772                                serde_json::to_value(scanned).unwrap()
1773                            );
1774                        }
1775                    }
1776                }
1777            }
1778        }
1779        let opts = SearchOptions {
1780            source_uri: Some("source1".into()),
1781            document_ids: Some(vec![
1782                "d010".into(),
1783                "d003".into(),
1784                "d010".into(),
1785                "absent".into(),
1786            ]),
1787            ..Default::default()
1788        };
1789        assert_eq!(
1790            serde_json::to_value(search_impl(store, "running", &opts, Some(3), true).unwrap())
1791                .unwrap(),
1792            serde_json::to_value(search_impl(store, "running", &opts, Some(3), false).unwrap())
1793                .unwrap()
1794        );
1795    }
1796
1797    #[test]
1798    fn postings_preserve_filtered_mixed_tokenization_scores_after_slot_reuse() {
1799        let mut store = DocumentStore::default();
1800        for i in 0..40 {
1801            let doc = document(i, 1);
1802            store.insert(doc.id.clone(), doc);
1803        }
1804        let query = "running rare3 missing absent unknown extra terms for selective postings";
1805        let forms = [query_words(query, false), query_words(query, true)];
1806        let posting_work: usize = forms
1807            .iter()
1808            .enumerate()
1809            .map(|(form, terms)| {
1810                terms
1811                    .iter()
1812                    .map(|word| store.postings[form].get(word).map_or(0, |p| p.len()))
1813                    .sum::<usize>()
1814            })
1815            .sum();
1816        let eligible = store
1817            .values()
1818            .filter(|d| d.metadata["group"] == 3)
1819            .map(|d| d.chunks.len())
1820            .sum::<usize>();
1821        assert!(
1822            posting_work + store.slot_count
1823                < eligible
1824                    * forms
1825                        .iter()
1826                        .map(std::collections::BTreeSet::len)
1827                        .sum::<usize>()
1828        );
1829        assert_scan_parity(&store);
1830        for i in (0..40).step_by(2) {
1831            store.remove(&format!("d{i:03}"));
1832        }
1833        for i in 0..20 {
1834            let mut doc = document(i, 2);
1835            if i == 3 {
1836                doc.metadata["group"] = json!(22);
1837                doc.source_uri = Some("replacement-source".into());
1838            }
1839            store.insert(doc.id.clone(), doc);
1840        }
1841        assert_scan_parity(&store);
1842        let ids: Vec<_> = store.values().map(|doc| doc.id.clone()).collect();
1843        for id in ids {
1844            store.remove(&id);
1845        }
1846        assert_eq!(store.slot_count, 0);
1847        assert!(store.postings.iter().all(|p| p.term_count() == 0));
1848        let doc = document(0, 3);
1849        store.insert(doc.id.clone(), doc);
1850        assert_scan_parity(&store);
1851    }
1852
1853    #[test]
1854    fn deleting_low_slots_reclaims_postings_with_high_slot_survivors() {
1855        for n in [1023, 1024, 1025] {
1856            let mut store = DocumentStore::default();
1857            for i in 0..n {
1858                let mut doc = document(i, 1);
1859                doc.chunks.truncate(1);
1860                store.insert(doc.id.clone(), doc);
1861            }
1862            for i in 0..n - 1 {
1863                store.remove(&format!("d{i:03}"));
1864            }
1865            assert!(store.slot_count <= 1024);
1866            assert_eq!(store.values().count(), 1);
1867            for form in &store.postings {
1868                form.assert_valid();
1869            }
1870            assert_scan_parity(&store);
1871        }
1872    }
1873
1874    #[test]
1875    fn restored_zero_frequency_terms_do_not_become_lexical_candidates() {
1876        let mut store = DocumentStore::default();
1877        let mut document = document(0, 1);
1878        for chunk in &mut document.chunks {
1879            chunk.terms.insert("ghost".into(), 0);
1880        }
1881        store.insert(document.id.clone(), document);
1882        for fusion in ["blend", "rrf"] {
1883            let options = SearchOptions {
1884                fusion: Some(fusion.into()),
1885                ..Default::default()
1886            };
1887            assert!(search_impl(&store, "ghost", &options, None, true)
1888                .unwrap()
1889                .is_empty());
1890            for query in ["ghost common", "ghost rare0"] {
1891                assert_eq!(
1892                    serde_json::to_value(search_impl(&store, query, &options, None, true).unwrap())
1893                        .unwrap(),
1894                    serde_json::to_value(
1895                        search_impl(&store, query, &options, None, false).unwrap()
1896                    )
1897                    .unwrap()
1898                );
1899            }
1900        }
1901        // Counts agree: a zero-frequency posting is no match, through the multichunk bitmap
1902        // and through the one-chunk-per-document shortcut.
1903        assert_eq!(count_matches(&store, "ghost").unwrap(), 0);
1904        assert_eq!(count_matches(&store, "ghost common").unwrap(), 1);
1905        let mut single = DocumentStore::default();
1906        // `document` names the moved local here; the helper is reached by path.
1907        let mut document = self::document(1, 1);
1908        document.chunks.truncate(1);
1909        document.chunks[0].terms.insert("ghost".into(), 0);
1910        single.insert(document.id.clone(), document);
1911        assert_eq!(count_matches(&single, "ghost").unwrap(), 0);
1912        assert_eq!(count_matches(&single, "ghost common").unwrap(), 1);
1913    }
1914
1915    #[test]
1916    fn embeddings_round_to_f32_and_read_legacy_f64_records() {
1917        let legacy = "[0.1, 0.2, 0.30000000000000004, -0.7071067811865476]";
1918        let read: Embedding = serde_json::from_str(legacy).unwrap();
1919        assert_eq!(
1920            read,
1921            Embedding::from_f32(vec![0.1, 0.2, 0.3, -std::f32::consts::FRAC_1_SQRT_2])
1922        );
1923        let written = serde_json::to_string(&read).unwrap();
1924        assert_eq!(written, "[0.1,0.2,0.3,-0.70710677]");
1925        let again: Embedding = serde_json::from_str(&written).unwrap();
1926        assert_eq!(again, read);
1927        assert_eq!(
1928            read.to_f64(),
1929            read.iter().map(|&v| f64::from(v)).collect::<Vec<_>>()
1930        );
1931
1932        let stored = document(5, 1);
1933        let embedded = stored.chunks[0].embedding.as_ref().unwrap();
1934        let reference = normalize_embedding(&[1.0, 0.1, 0.0], 3).unwrap();
1935        assert_eq!(
1936            &embedded[..],
1937            &reference.iter().map(|&v| v as f32).collect::<Vec<_>>()[..]
1938        );
1939        let query = normalize_embedding(&[0.3, -0.2, 0.9], 3).unwrap();
1940        let exact: f64 = query.iter().zip(&reference).map(|(q, v)| q * v).sum();
1941        assert!((embedded.cosine(&query) - exact).abs() < 1e-6);
1942        let widened: f64 = query
1943            .iter()
1944            .zip(embedded.iter())
1945            .map(|(q, &v)| q * f64::from(v))
1946            .sum();
1947        assert_eq!(embedded.cosine(&query), widened.clamp(-1.0, 1.0));
1948
1949        let json = serde_json::to_value(&stored).unwrap();
1950        assert!(json["chunks"][0]["embedding"].is_array());
1951        let parsed: StoredDocument = serde_json::from_value(json).unwrap();
1952        assert_eq!(parsed.chunks[0].embedding, stored.chunks[0].embedding);
1953        let without: StoredChunk = {
1954            let mut chunk = stored.chunks[0].clone();
1955            chunk.embedding = None;
1956            chunk
1957        };
1958        assert_eq!(
1959            serde_json::to_value(&without).unwrap()["embedding"],
1960            Value::Null
1961        );
1962    }
1963
1964    #[test]
1965    fn replacements_preserve_frequency_filters_embeddings_and_retained_views() {
1966        for chunks in [1, 2, 3] {
1967            for change_tokenization in [false, true] {
1968                let mut store = DocumentStore::default();
1969                for i in 0..20 {
1970                    let mut doc = document(i, 1);
1971                    if i == 3 {
1972                        doc.chunks.truncate(2);
1973                    }
1974                    store.insert(doc.id.clone(), doc);
1975                }
1976                store.remove("d000");
1977                let options = SearchOptions {
1978                    k: Some(100),
1979                    candidate_limit: Some(100),
1980                    embedding: Some(vec![1.0, 0.0, 0.0]),
1981                    ..Default::default()
1982                };
1983                let prepared = prepare_search("running common ghost", &options, Some(3)).unwrap();
1984                let retained = capture_search(&store, vec![prepared]);
1985                let old = serde_json::to_value(execute_search(&retained, 0)).unwrap();
1986                let mut replacement = document(3, 2);
1987                replacement.chunks.truncate(chunks);
1988                replacement.metadata = json!({"group":99, "flag":false});
1989                replacement.source_uri = Some("replacement-source".into());
1990                for chunk in &mut replacement.chunks {
1991                    chunk.terms.insert("common".into(), (chunks - 1) as u32);
1992                    chunk.terms.insert("ghost".into(), 2);
1993                    chunk.terms.remove("run");
1994                    chunk.word_count += 1;
1995                    chunk.embedding = None;
1996                    if change_tokenization {
1997                        chunk.stemmed = !chunk.stemmed;
1998                    }
1999                }
2000                store.insert(replacement.id.clone(), replacement);
2001                assert_scan_parity(&store);
2002                let captured = capture_search(
2003                    &store,
2004                    vec![prepare_search("running common ghost", &options, Some(3)).unwrap()],
2005                );
2006                assert_eq!(
2007                    serde_json::to_value(execute_search(&captured, 0)).unwrap(),
2008                    serde_json::to_value(
2009                        search_impl(&store, "running common ghost", &options, Some(3), false)
2010                            .unwrap()
2011                    )
2012                    .unwrap()
2013                );
2014                assert_eq!(
2015                    serde_json::to_value(execute_search(&retained, 0)).unwrap(),
2016                    old
2017                );
2018                assert_eq!(
2019                    store.embedded_count(),
2020                    store
2021                        .values()
2022                        .flat_map(|d| &d.chunks)
2023                        .filter(|c| c.embedding.is_some())
2024                        .count()
2025                );
2026                assert_eq!(
2027                    store.word_count,
2028                    store
2029                        .values()
2030                        .flat_map(|d| &d.chunks)
2031                        .map(|c| c.word_count)
2032                        .sum::<usize>()
2033                );
2034                assert_eq!(
2035                    store.chunk_count,
2036                    store.values().map(|d| d.chunks.len()).sum::<usize>()
2037                );
2038                for form in &store.postings {
2039                    form.assert_valid();
2040                }
2041                let selected = search_impl(
2042                    &store,
2043                    "ghost",
2044                    &SearchOptions {
2045                        source_uri: Some("replacement-source".into()),
2046                        filter: Some(json!({"group":99})),
2047                        k: Some(100),
2048                        candidate_limit: Some(100),
2049                        ..Default::default()
2050                    },
2051                    Some(3),
2052                    true,
2053                )
2054                .unwrap();
2055                assert_eq!(selected.len(), chunks);
2056                assert!(selected.iter().all(|r| r.document_id == "d003"
2057                    && r.version == "2"
2058                    && r.semantic_score.is_none()));
2059                let mut embedded = document(3, 3);
2060                embedded.chunks.truncate(chunks);
2061                store.insert(embedded.id.clone(), embedded);
2062                assert_scan_parity(&store);
2063                assert!(store.embedded_documents.contains("d003"));
2064                assert_eq!(
2065                    store.embedded_count(),
2066                    store
2067                        .values()
2068                        .flat_map(|d| &d.chunks)
2069                        .filter(|c| c.embedding.is_some())
2070                        .count()
2071                );
2072                store.remove("d003");
2073                assert_scan_parity(&store);
2074                assert_eq!(
2075                    serde_json::to_value(execute_search(&retained, 0)).unwrap(),
2076                    old
2077                );
2078            }
2079        }
2080    }
2081}