Skip to main content

writerslogic_warm/core/engine/
document_api.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{anyhow, ensure, Result};
5use rayon::prelude::*;
6use std::sync::Arc;
7
8use super::{
9    mutation::{validate_id, Mutation},
10    ShardSet, WarmCore,
11};
12use crate::core::documents::{
13    self, DocumentInput, DocumentResult, IngestOptions, SearchOptions, VectorIndexMode,
14    VectorIndexStatus, VectorQueryOptions, VectorState,
15};
16
17/// Documents per `EncodeDocument` transaction of a lazy encoding pass (the journal takes at
18/// most 4096 mutations per frame).
19const ENCODE_BATCH: usize = 1024;
20use crate::core::types::MemorizeBatchItem;
21
22impl WarmCore {
23    pub fn security_status(&self) -> serde_json::Value {
24        serde_json::json!({
25            "securityCompiled": cfg!(feature = "security"),
26            "encryptionActive": self.config.security.encryption_enabled,
27            "signingActive": self.config.security.signing_enabled,
28            "auditActive": self.config.security.audit_enabled,
29            "differentialPrivacyActive": self.config.privacy.dp_enabled,
30            "epsilon": self.config.privacy.epsilon,
31            "privacyMechanism": "clipped-full-domain-laplace-top-k",
32            "embeddingSpace": self.config.embedding_space,
33        })
34    }
35
36    pub fn memorize_document(&self, input: DocumentInput) -> Result<u32> {
37        self.memorize_document_with(input, &IngestOptions::default())
38    }
39
40    /// [`Self::memorize_document`] with per-call options: [`IngestOptions::encode`] chooses
41    /// whether the chunk hypervectors are computed now, on first vector use, or never.
42    pub fn memorize_document_with(
43        &self,
44        input: DocumentInput,
45        options: &IngestOptions,
46    ) -> Result<u32> {
47        #[cfg(feature = "local-models")]
48        if let Some(stages) = self.model_stages() {
49            return self.memorize_document_with_models(input, &stages, options);
50        }
51        #[cfg(feature = "local-embed")]
52        let input = {
53            let mut input = input;
54            self.embed_document_inputs(std::slice::from_mut(&mut input))?;
55            input
56        };
57        // The store's kept interner, as batches use: a single write otherwise stems and copies
58        // every word of the document afresh. It is a pure cache; the terms do not depend on it.
59        let mut memo = self.stem_memos.take();
60        let document = {
61            let _memo = memo.install();
62            documents::prepare_with_options(
63                input,
64                self.dimensions,
65                self.config.embedding_space.as_ref().map(|s| s.dimensions),
66                None,
67                self.config.text_encoder,
68                options,
69            )?
70        };
71        drop(memo);
72        let count = document.chunks.len() as u32;
73        self.commit(&[Mutation::Document(Arc::new(document))])?;
74        Ok(count)
75    }
76
77    pub fn memorize_documents(&self, inputs: Vec<DocumentInput>) -> Result<Vec<u32>> {
78        self.memorize_documents_with(inputs, &IngestOptions::default())
79    }
80
81    /// [`Self::memorize_documents`] with per-call options. Documents are prepared in parallel
82    /// and committed as one transaction in input order; the encoder's per-thread memo tables
83    /// are pure caches, so the stored vectors do not depend on the thread count.
84    pub fn memorize_documents_with(
85        &self,
86        inputs: Vec<DocumentInput>,
87        options: &IngestOptions,
88    ) -> Result<Vec<u32>> {
89        let (mutations, payload) = self.prepare_batch(inputs, options)?;
90        let counts = mutations
91            .iter()
92            .map(|mutation| match mutation {
93                Mutation::Document(document) => document.chunks.len() as u32,
94                _ => unreachable!("document preparation produced a non-document mutation"),
95            })
96            .collect();
97        self.commit_serialized(&mutations, &payload)?;
98        Ok(counts)
99    }
100
101    /// The prepared documents of a batch, in input order, and their serialized journal
102    /// transaction.
103    pub(super) fn prepare_batch(
104        &self,
105        inputs: Vec<DocumentInput>,
106        options: &IngestOptions,
107    ) -> Result<(Vec<Mutation>, Vec<u8>)> {
108        #[cfg(feature = "local-models")]
109        let stages = self.model_stages();
110        #[cfg(feature = "local-models")]
111        let inferred_dimensions = stages
112            .as_ref()
113            .and_then(|stages| stages.embedder.as_ref())
114            .map(|embedder| embedder.dimensions());
115        #[cfg(not(feature = "local-models"))]
116        let inferred_dimensions = None;
117        #[cfg(feature = "local-embed")]
118        let inferred_dimensions =
119            inferred_dimensions.or_else(|| self.text_embedder().map(|e| e.dimensions()));
120        let spans =
121            super::document_batch::validate_inputs_with(&inputs, inferred_dimensions, options)?;
122        #[cfg(feature = "local-embed")]
123        let inputs = {
124            let mut inputs = inputs;
125            self.embed_document_inputs(&mut inputs)?;
126            inputs
127        };
128        let mutations = inputs
129            .into_par_iter()
130            .zip(spans)
131            .map_init(
132                || self.stem_memos.take(),
133                |memo, (input, spans)| {
134                    let _memo = memo.install();
135                    #[cfg(feature = "local-models")]
136                    if let Some(stages) = stages.as_deref() {
137                        return self
138                            .prepare_document_with_models(input, stages, options)
139                            .map(|document| Mutation::Document(Arc::new(document)));
140                    }
141                    documents::prepare_spans(
142                        input,
143                        spans,
144                        self.dimensions,
145                        self.config.embedding_space.as_ref().map(|s| s.dimensions),
146                        None,
147                        self.config.text_encoder,
148                        options,
149                    )
150                    .map(|document| Mutation::Document(Arc::new(document)))
151                },
152            )
153            .collect::<Result<Vec<_>>>();
154        // Every split has returned its interner; keep the warmest within the memory bound.
155        self.stem_memos.trim();
156        let mutations = mutations?;
157        let payload = super::document_batch::serialize_transaction(self, &mutations)?;
158        Ok((mutations, payload))
159    }
160
161    pub fn search_documents(
162        &self,
163        text: &str,
164        options: &SearchOptions,
165    ) -> Result<Vec<DocumentResult>> {
166        // A text query goes through the installed model stages; a vector-only query does not.
167        #[cfg(feature = "local-models")]
168        if let Some(stages) = self.model_stages().filter(|_| !text.trim().is_empty()) {
169            return self.search_documents_with_models(text, "", options, &stages);
170        }
171        #[cfg(feature = "local-embed")]
172        let embedded = self
173            .embed_search_text(text, options)?
174            .map(|embedding| SearchOptions {
175                embedding: Some(embedding),
176                ..options.clone()
177            });
178        #[cfg(feature = "local-embed")]
179        let options = embedded.as_ref().unwrap_or(options);
180        let prepared = documents::prepare_search(
181            text,
182            options,
183            self.config.embedding_space.as_ref().map(|s| s.dimensions),
184        )?;
185        Ok(self.run_searches(vec![prepared])?.pop().expect("one query"))
186    }
187
188    /// Captures `prepared`, ranks each query with no lock held, then reads the hits' text under
189    /// the mutation gate, which excludes the writes that scrub replaced or deleted text. If a
190    /// hit's text was scrubbed since the capture, the queries run again with the gate held
191    /// throughout, so a result never pairs a version with empty or other text. Queries a store
192    /// served from its checkpoint answers are captured from it without rebuilding the store.
193    fn run_searches(
194        &self,
195        prepared: Vec<documents::PreparedSearch>,
196    ) -> Result<Vec<Vec<DocumentResult>>> {
197        let mut prepared = Some(prepared);
198        let from_checkpoint = self.capture_from_checkpoint(|store| {
199            let queries = prepared.as_deref().expect("queries not taken yet");
200            documents::overlay_serves(store, queries).then(|| {
201                documents::capture_overlaid_search(store, prepared.take().expect("taken once"))
202            })
203        });
204        let captured = match from_checkpoint {
205            Some(captured) => captured?,
206            None => {
207                self.hydrate_documents()?;
208                let _transaction = self.mutation_gate.read();
209                let prepared = prepared.expect("queries not taken");
210                documents::capture_search(&self.documents.read(), prepared)
211            }
212        };
213        let ranked: Vec<_> = (0..captured.len())
214            .map(|i| documents::rank_search(&captured, i))
215            .collect();
216        {
217            let _transaction = self.mutation_gate.read();
218            let read: Option<Vec<_>> = ranked
219                .into_iter()
220                .enumerate()
221                .map(|(i, hits)| documents::read_ranked(&captured, i, hits))
222                .collect();
223            if let Some(results) = read {
224                return Ok(results);
225            }
226        }
227        self.hydrate_documents()?;
228        let _transaction = self.mutation_gate.read();
229        let captured = documents::capture_search(&self.documents.read(), captured.into_prepared());
230        Ok((0..captured.len())
231            .map(|i| documents::execute_search(&captured, i))
232            .collect())
233    }
234
235    /// The number of documents containing at least one word of `text`: every document an
236    /// unfiltered lexical [`Self::search_documents`] can return, counted exactly rather than
237    /// ranked.
238    pub fn count_documents(&self, text: &str) -> Result<usize> {
239        self.hydrate_documents()?;
240        let _transaction = self.mutation_gate.read();
241        documents::count_matches(&self.documents.read(), text)
242    }
243
244    /// Executes an explicit Boolean/phrase query over one live document generation.
245    pub fn search_lexical(
246        &self,
247        query: &documents::LexicalQuery,
248        options: &documents::LexicalOptions,
249    ) -> Result<documents::LexicalResults> {
250        self.hydrate_documents()?;
251        let _transaction = self.mutation_gate.read();
252        documents::execute_lexical(&self.documents.read(), query, options)
253    }
254
255    /// Counts matching live parent documents without scoring.
256    pub fn count_lexical(&self, query: &documents::LexicalQuery) -> Result<usize> {
257        let result = self.search_lexical(
258            query,
259            &documents::LexicalOptions {
260                k: 0,
261                count: true,
262                ..Default::default()
263            },
264        )?;
265        Ok(result.count.expect("count requested"))
266    }
267
268    pub fn document_index_trained(&self) -> bool {
269        self.documents().dense_index_trained()
270    }
271
272    pub fn document_embedding_count(&self) -> usize {
273        self.documents().embedded_count()
274    }
275
276    /// Nearest embedded chunks to `embedding` by cosine, best first; `score` and
277    /// `semantic_score` both carry the cosine. Unfiltered vector queries are answered from the
278    /// vector snapshot (`documents::vector_index`) when one is built: the graph index under
279    /// `Auto`/`Graph`, the contiguous exact scan under `Exact`. Without a snapshot, or when the
280    /// snapshot is too stale for this query, the generic exact path answers. `Graph` without a
281    /// graph index fails with [`documents::GraphUnavailable`].
282    pub fn search_vectors(
283        &self,
284        embedding: &[f64],
285        k: usize,
286        options: &VectorQueryOptions,
287    ) -> Result<Vec<DocumentResult>> {
288        let Some(space) = &self.config.embedding_space else {
289            anyhow::bail!("configure an embedding space before vector search");
290        };
291        ensure!(
292            (1..=documents::MAX_K).contains(&k),
293            "require 1 <= k <= {}",
294            documents::MAX_K
295        );
296        ensure!(
297            (1..=documents::MAX_EF).contains(&options.ef),
298            "require 1 <= ef <= {}",
299            documents::MAX_EF
300        );
301        ensure!(
302            options.min_score.is_finite() && (-1.0..=1.0).contains(&options.min_score),
303            "minScore must be in [-1,1]"
304        );
305        let normalized = documents::normalize_embedding(embedding, space.dimensions)?;
306        let query: Vec<f32> = normalized.iter().map(|&value| value as f32).collect();
307        match self.vector_index.get() {
308            Some(snapshot) => {
309                if let Some(results) = documents::search_vector_snapshot(
310                    &snapshot,
311                    &self.documents,
312                    &query,
313                    &normalized,
314                    k,
315                    options,
316                )? {
317                    return Ok(results);
318                }
319            }
320            None if options.mode == VectorIndexMode::Graph => {
321                return Err(anyhow::Error::new(documents::GraphUnavailable));
322            }
323            None => {
324                // First vector query over a store without a snapshot: publish one now so an
325                // unfiltered query answers from the same scan before and after maintenance
326                // (the fallback below scores `f64` rows and would differ in the last digits).
327                if self.rebuild_vector_index(documents::DEFAULT_GRAPH_MIN_ROWS)? {
328                    if let Some(snapshot) = self.vector_index.get() {
329                        if let Some(results) = documents::search_vector_snapshot(
330                            &snapshot,
331                            &self.documents,
332                            &query,
333                            &normalized,
334                            k,
335                            options,
336                        )? {
337                            return Ok(results);
338                        }
339                    }
340                }
341            }
342        }
343        let fallback = SearchOptions {
344            k: Some(k as u32),
345            candidate_limit: Some((k as u32).max(100)),
346            embedding: Some(embedding.to_vec()),
347            lexical_weight: Some(0.0),
348            semantic_weight: Some(1.0),
349            min_semantic_score: Some(options.min_score),
350            ..SearchOptions::default()
351        };
352        let mut hits = self.search_documents("", &fallback)?;
353        for hit in &mut hits {
354            hit.score = hit.semantic_score.unwrap_or(hit.score);
355        }
356        Ok(hits)
357    }
358
359    pub fn vector_index_status(&self) -> VectorIndexStatus {
360        let snapshot = self.vector_index.get();
361        documents::vector_index_status(snapshot.as_deref(), &self.documents())
362    }
363
364    /// Captures the embedded chunks and publishes a fresh vector snapshot, with the graph
365    /// index when the store has at least `graph_min_rows` of them. `false` when the store has
366    /// no embedding space or too few embedded chunks (the snapshot is dropped then), or when
367    /// another build is running (the published snapshot stays). The capture holds the store's
368    /// read lock; the graph build holds no lock.
369    pub fn rebuild_vector_index(&self, graph_min_rows: usize) -> Result<bool> {
370        if self.config.embedding_space.is_none() {
371            return Ok(false);
372        }
373        let Some(_building) = self.vector_index.try_build() else {
374            return Ok(self.vector_index.get().is_some());
375        };
376        self.rebuild_vector_index_locked(graph_min_rows)
377    }
378
379    /// [`Self::rebuild_vector_index`] that waits for a running build instead of keeping its
380    /// result, so the snapshot published on return was captured after the call began.
381    pub(super) fn rebuild_vector_index_waiting(&self, graph_min_rows: usize) -> Result<bool> {
382        let _building = self.vector_index.build();
383        self.rebuild_vector_index_locked(graph_min_rows)
384    }
385
386    fn rebuild_vector_index_locked(&self, graph_min_rows: usize) -> Result<bool> {
387        let Some(space) = &self.config.embedding_space else {
388            return Ok(false);
389        };
390        let capture = {
391            let _transaction = self.mutation_gate.read();
392            documents::capture_vector_rows(&self.documents(), space.dimensions)?
393        };
394        let Some(capture) = capture else {
395            self.vector_index.clear();
396            return Ok(false);
397        };
398        let snapshot = documents::build_vector_snapshot(capture, graph_min_rows)?;
399        self.vector_index.publish(Arc::new(snapshot));
400        Ok(true)
401    }
402
403    /// [`Self::rebuild_vector_index`] when the status recommends it (no snapshot over a store
404    /// with enough embedded chunks, or `REBUILD_AFTER_MUTATIONS` mutations since the capture);
405    /// otherwise reports whether a snapshot exists.
406    pub fn maintain_vector_index(&self, graph_min_rows: usize) -> Result<bool> {
407        if self.vector_index_status().rebuild_recommended {
408            return self.rebuild_vector_index(graph_min_rows);
409        }
410        Ok(self.vector_index.get().is_some())
411    }
412
413    pub fn train_document_index(&self) -> Result<()> {
414        self.encode_pending_documents()?;
415        self.maintain_vector_index(documents::DEFAULT_GRAPH_MIN_ROWS)?;
416        self.train_dense_index()
417    }
418
419    /// Trains and publishes the dense document index over the current embedded chunks.
420    pub(super) fn train_dense_index(&self) -> Result<()> {
421        let Some(space) = &self.config.embedding_space else {
422            return Ok(());
423        };
424        let _building = self.vector_index.try_build().ok_or_else(|| {
425            anyhow!("document index maintenance already in progress; retry maintenance")
426        })?;
427        self.hydrate_documents()?;
428        let snapshot = {
429            let _transaction = self.mutation_gate.read();
430            documents::capture_dense_index(&self.documents.read(), space.dimensions)?
431        };
432        let Some(snapshot) = snapshot else {
433            return Ok(());
434        };
435        let Some(trained) = documents::build_dense_index(snapshot)? else {
436            return Ok(());
437        };
438        let published = {
439            let _transaction = self.mutation_gate.read();
440            documents::publish_dense_index(&mut self.documents.write(), &trained)
441        };
442        ensure!(
443            published,
444            "store changed during document index training; retry maintenance"
445        );
446        Ok(())
447    }
448
449    /// Encodes and persists the hypervectors of every document memorized with
450    /// [`documents::EncodeMode::Deferred`] that is still awaiting them, and returns how many
451    /// documents that was. Vectors are computed from the stored chunk text exactly as eager
452    /// ingest computes them. Idempotent; concurrent callers serialize on the store's encode
453    /// gate and the second one finds nothing left. A vector query runs this first, so pending
454    /// documents are never silently missing from it.
455    pub fn encode_pending_documents(&self) -> Result<usize> {
456        // A checkpoint holds no pending encodings, so a store served from one is read as it is
457        // (its pending documents are all among those written since).
458        if self.documents.read().pending_vector_count() == 0 {
459            return Ok(0);
460        }
461        let gate = self.documents.read().encode_gate();
462        let _encoding = gate.lock();
463        let encoder = self.config.text_encoder;
464        ensure!(
465            !encoder.is_embedding(),
466            "deferred encoding needs a text encoder, not {}",
467            encoder.name()
468        );
469        // The snapshot: each pending document with the text of its pending chunks and the
470        // fingerprint the installed vectors are bound to.
471        let pending: Vec<(Arc<documents::StoredDocument>, u64, Vec<Option<String>>)> = {
472            let _transaction = self.mutation_gate.read();
473            let store = self.documents.read();
474            if store.pending_vector_count() == 0 {
475                return Ok(0);
476            }
477            let mut pending = Vec::new();
478            for document in store.shared_values() {
479                if !document
480                    .chunks
481                    .iter()
482                    .any(|chunk| chunk.vector_state == VectorState::Pending)
483                {
484                    continue;
485                }
486                let mut texts = Vec::with_capacity(document.chunks.len());
487                for chunk in &document.chunks {
488                    texts.push(if chunk.vector_state == VectorState::Pending {
489                        Some(store.chunk_text(chunk).ok_or_else(|| {
490                            anyhow!(
491                                "document {} awaits deferred encoding but has no stored text",
492                                document.id
493                            )
494                        })?)
495                    } else {
496                        None
497                    });
498                }
499                let fingerprint = documents::document_fingerprint(&store, &document)
500                    .ok_or_else(|| anyhow!("document {} has no stored text", document.id))?;
501                pending.push((document, fingerprint, texts));
502            }
503            pending
504        };
505        let dimensions = self.dimensions;
506        let mut encoded = 0;
507        for batch in pending.chunks(ENCODE_BATCH) {
508            let mutations: Vec<Mutation> = batch
509                .par_iter()
510                .map(|(document, fingerprint, texts)| {
511                    let vectors = document
512                        .chunks
513                        .iter()
514                        .zip(texts)
515                        .map(|(chunk, text)| match text {
516                            Some(text) => encoder.encode(text, dimensions),
517                            None => chunk.vector.clone(),
518                        })
519                        .collect();
520                    Mutation::EncodeDocument {
521                        id: document.id.clone(),
522                        version: document.version.clone(),
523                        fingerprint: *fingerprint,
524                        vectors,
525                    }
526                })
527                .collect();
528            self.commit(&mutations)?;
529            encoded += batch.len();
530        }
531        Ok(encoded)
532    }
533
534    /// Whether a document with this id currently resolves in the opened store.
535    pub fn has_document(&self, id: &str) -> bool {
536        self.documents().contains_key(id)
537    }
538
539    /// Whether the word interner built while preparing a document batch is kept for the next
540    /// batch (the default). Keeping it saves re-stemming a corpus's vocabulary across batches at
541    /// a cost bounded by 2^18 distinct words (tens of MB at worst); a process holding many open
542    /// stores, such as a multi-tenant server, can turn it off to free each batch's interner
543    /// with the batch. Turning it off frees the kept interner now.
544    pub fn set_keep_ingest_interner(&self, keep: bool) {
545        self.stem_memos.set_keep(keep);
546    }
547
548    /// Number of stored documents.
549    pub fn document_count(&self) -> usize {
550        documents::count(&self.documents())
551    }
552
553    /// One stored document with its text, metadata and version.
554    pub fn get_document(&self, id: &str) -> Option<documents::DocumentRecord> {
555        let _transaction = self.mutation_gate.read();
556        documents::get_record(&self.documents(), id)
557    }
558
559    /// Up to `limit` stored documents with ids at or after `from`, in id order. Resume after id
560    /// `X` with `from = X + "\0"` (document ids cannot contain NUL).
561    pub fn list_documents(
562        &self,
563        from: Option<&str>,
564        limit: usize,
565    ) -> Vec<documents::DocumentRecord> {
566        let _transaction = self.mutation_gate.read();
567        documents::list_records(&self.documents(), from, limit)
568    }
569
570    pub fn delete_document(&self, id: &str) -> Result<bool> {
571        validate_id(id)?;
572        // The store answers for a checkpoint it is served from without being rebuilt.
573        let exists = self.documents.read().contains_key(id);
574        self.commit(&[Mutation::DeleteDocument { id: id.into() }])?;
575        Ok(exists)
576    }
577
578    /// Makes scrubbed text records durable. Deleting zeroes them in the page cache, like the
579    /// journal frame that records the delete; `forget` and a scrubbing compaction call this so
580    /// the zeros are on stable storage before they return.
581    pub(crate) fn sync_text_segment(&self) -> Result<()> {
582        let segment = self.documents().text_segment();
583        segment.map_or(Ok(()), |segment| segment.sync())
584    }
585
586    pub(super) fn remove_document_from_memory(&self, id: &str, shards: &ShardSet) -> Result<()> {
587        // A store served from a checkpoint marks a checkpoint document dead (its text leaves
588        // with the checkpoint at the next compaction) without being rebuilt.
589        // The chunk vectors leave with the document even when zeroing its text fails.
590        let mut store = self.documents.write();
591        let removed = store.remove(id);
592        if let Some(old) = &removed {
593            for chunk in &old.chunks {
594                shards.remove(&chunk.id, self.dimensions)?;
595            }
596        }
597        store.scrub_removed(removed).map(drop)
598    }
599
600    /// One bounded transaction; either every item is logged or none is.
601    pub fn memorize_batch(&self, items: &[MemorizeBatchItem]) -> Result<()> {
602        ensure!(
603            !items.is_empty() && items.len() <= 4096,
604            "batch requires 1..=4096 items"
605        );
606        ensure!(
607            items
608                .iter()
609                .map(|item| item.id.len() + item.text.len())
610                .sum::<usize>()
611                <= documents::MAX_DOCUMENT_BYTES,
612            "batch exceeds 8 MiB; split the input into bounded batches"
613        );
614        for item in items {
615            super::mutation::validate_public_id(&item.id)?;
616            ensure!(
617                !item.id.starts_with(documents::CHUNK_PREFIX),
618                "chunk IDs are reserved"
619            );
620        }
621        let texts: Vec<_> = items.iter().map(|item| item.text.as_str()).collect();
622        let vectors = self.try_encode_texts(&texts)?;
623        let mutations: Vec<_> = items
624            .iter()
625            .zip(vectors)
626            .map(|(item, vector)| Mutation::Vector {
627                id: item.id.clone(),
628                vector,
629            })
630            .collect();
631        self.commit(&mutations)
632    }
633
634    pub fn query_vector(&self, dense: &[f32], k: u32) -> Result<Vec<crate::RetrievalResult>> {
635        self.validate_dense(dense)?;
636        let vector = crate::EntangledHVec::from_dense(dense, self.dimensions);
637        Ok(self.query(&vector, k))
638    }
639
640    pub(super) fn validate_dense(&self, dense: &[f32]) -> Result<()> {
641        ensure!(
642            !dense.is_empty()
643                && dense.len() <= 65536
644                && dense.iter().all(|v| v.is_finite())
645                && dense.iter().any(|&v| v != 0.0),
646            "dense vectors require 1..=65536 finite values and nonzero magnitude"
647        );
648        if let Some(space) = &self.config.embedding_space {
649            ensure!(
650                dense.len() == space.dimensions,
651                "dense embedding dimensions do not match the store schema"
652            );
653        }
654        Ok(())
655    }
656}
657
658#[cfg(feature = "local-models")]
659impl WarmCore {
660    /// Installs (or with `None` removes) the on-device model stages `memorize_document` and
661    /// `search_documents` use. An embedder must match the store's embedding space.
662    pub fn set_model_stages(
663        &self,
664        stages: Option<std::sync::Arc<crate::core::models::ModelStages>>,
665    ) -> Result<()> {
666        if let Some(stages) = &stages {
667            stages.params.validate()?;
668        }
669        if let Some(e) = stages.as_ref().and_then(|s| s.embedder.as_ref()) {
670            ensure!(
671                self.config.embedding_space.as_ref() == Some(&e.space()),
672                "the embedder's effective model identity does not match the store embedding space; reopen with the original model or re-embed original source documents into a new store"
673            );
674        }
675        *self.model_stages.write() = stages;
676        Ok(())
677    }
678
679    pub fn model_stages(&self) -> Option<std::sync::Arc<crate::core::models::ModelStages>> {
680        self.model_stages.read().clone()
681    }
682
683    fn memorize_document_with_models(
684        &self,
685        input: DocumentInput,
686        stages: &crate::core::models::ModelStages,
687        options: &IngestOptions,
688    ) -> Result<u32> {
689        let document = self.prepare_document_with_models(input, stages, options)?;
690        let count = document.chunks.len() as u32;
691        self.commit(&[Mutation::Document(Arc::new(document))])?;
692        Ok(count)
693    }
694
695    fn prepare_document_with_models(
696        &self,
697        mut input: DocumentInput,
698        stages: &crate::core::models::ModelStages,
699        options: &IngestOptions,
700    ) -> Result<documents::StoredDocument> {
701        let chunks: Vec<String> = documents::chunk_document(&input)?
702            .into_iter()
703            .map(|c| c.text)
704            .collect();
705        let keys = stages.fact_keys(&chunks)?;
706        if let (Some(embedder), None) = (&stages.embedder, &input.embeddings) {
707            let texts = keys.as_deref().unwrap_or(&chunks);
708            input.embeddings = Some(
709                embedder
710                    .embed(texts, false)?
711                    .into_iter()
712                    .map(|e| e.into_iter().map(f64::from).collect())
713                    .collect(),
714            );
715        }
716        documents::prepare_with_options(
717            input,
718            self.dimensions,
719            self.config.embedding_space.as_ref().map(|s| s.dimensions),
720            keys.as_deref(),
721            self.config.text_encoder,
722            options,
723        )
724    }
725
726    /// Search through the model stages: the optional LLM rewrite (`today` is the date relative
727    /// times resolve against), a hybrid search per query variant with on-device query
728    /// embeddings, weighted RRF across variants, then the re-rank of the fused head.
729    pub fn search_documents_with_models(
730        &self,
731        text: &str,
732        today: &str,
733        options: &SearchOptions,
734        stages: &crate::core::models::ModelStages,
735    ) -> Result<Vec<DocumentResult>> {
736        stages.params.validate()?;
737        let k = options.k.unwrap_or(10) as usize;
738        ensure!((1..=1000).contains(&k), "require 1 <= k <= 1000");
739        let pool = k.max(stages.params.rerank_n) as u32;
740        let variants = stages.query_variants(text, today)?;
741        let embeddings = match (&stages.embedder, &options.embedding) {
742            (Some(e), None) => {
743                let queries: Vec<&str> = variants.iter().map(|v| v.0.as_str()).collect();
744                Some(e.embed(&queries, true)?)
745            }
746            _ => None,
747        };
748        let mut prepared = Vec::with_capacity(variants.len());
749        for (i, (query, _)) in variants.iter().enumerate() {
750            let mut opts = options.clone();
751            opts.k = Some(pool);
752            opts.lexical_weight = opts.lexical_weight.or(Some(stages.params.lexical_weight));
753            opts.semantic_weight = opts.semantic_weight.or(Some(stages.params.semantic_weight));
754            if let Some(e) = &embeddings {
755                opts.embedding = Some(e[i].iter().copied().map(f64::from).collect());
756            }
757            prepared.push(documents::prepare_search(
758                query,
759                &opts,
760                self.config.embedding_space.as_ref().map(|s| s.dimensions),
761            )?);
762        }
763        let lists = variants
764            .iter()
765            .map(|(_, weight)| *weight)
766            .zip(self.run_searches(prepared)?)
767            .collect();
768        stages.fuse(text, lists, k)
769    }
770}