Skip to main content

plugmem_core/memory/
reembed.rs

1//! Explicit replacement of the complete vector axis.
2//!
3//! The core never calls a model. A host supplies one bounded batch callback;
4//! this module validates and quantizes its output, streams the new vector pool
5//! through [`Scratch`], rebuilds HNSW, and emits a new snapshot whose other
6//! indexes and temporal history are borrowed unchanged from the source.
7
8use alloc::{format, string::String, vec::Vec};
9
10use plugmem_arena::{Arena, ArenaCfg, ShardMode};
11
12use crate::error::Error;
13use crate::id::NONE_U32;
14use crate::index::hnsw::{HnswGraph, HnswScratch};
15use crate::index::vecpool::VecPool;
16use crate::model::{FactRecord, fact_flags};
17use crate::snapshot::SnapshotSink;
18use crate::storage::Scratch;
19
20use super::Memory;
21use super::persist::Sections;
22use super::shards::ShardLayout;
23
24/// Result of replacing every retained fact's embedding.
25#[derive(Clone, Debug, PartialEq, Eq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct ReembedReport {
28    /// Space recorded before the operation (`None` for a legacy/untracked DB).
29    pub previous_space: Option<String>,
30    /// Space recorded with the newly published vectors.
31    pub new_space: String,
32    /// Previous vector dimension.
33    pub previous_dim: usize,
34    /// New vector dimension, supplied by the target embedder.
35    pub new_dim: usize,
36    /// Non-tombstoned facts embedded, including closed historical revisions.
37    pub embedded: usize,
38    /// Tombstoned records kept but deliberately not sent to the provider.
39    pub tombstones_skipped: usize,
40    /// New quantized vector-pool bytes before snapshot framing.
41    pub vector_bytes: u64,
42    /// Slots inserted into the rebuilt HNSW graph (zero in the flat regime).
43    pub hnsw_indexed: u32,
44}
45
46/// Failure from either the engine-side rewrite or the caller's embedder.
47#[derive(Debug)]
48pub enum ReembedError<E> {
49    /// Validation, capacity, scratch, or snapshot failure.
50    Engine(Error),
51    /// Error returned by the host-provided embedding callback.
52    Embedder(E),
53}
54
55fn engine<E>(error: Error) -> ReembedError<E> {
56    ReembedError::Engine(error)
57}
58
59#[allow(clippy::too_many_arguments)]
60fn flush_batch<V, E, F>(
61    encoder: &VecPool<'_>,
62    vec_scratch: &mut V,
63    facts: &mut Arena<'static, FactRecord>,
64    records: &mut Vec<FactRecord>,
65    texts: &mut Vec<&str>,
66    slot_buf: &mut Vec<u8>,
67    next_slot: &mut u32,
68    embed: &mut F,
69) -> Result<(), ReembedError<E>>
70where
71    V: Scratch,
72    F: FnMut(&[&str]) -> Result<Vec<Vec<f32>>, E>,
73{
74    if records.is_empty() {
75        return Ok(());
76    }
77    let vectors = embed(texts).map_err(ReembedError::Embedder)?;
78    if vectors.len() != records.len() {
79        return Err(engine(Error::Invalid(
80            "embedder returned the wrong number of vectors",
81        )));
82    }
83    for (mut record, vector) in records.drain(..).zip(vectors) {
84        encoder
85            .encode_slot_into(record.id, &vector, slot_buf)
86            .map_err(engine)?;
87        vec_scratch
88            .write(slot_buf)
89            .map_err(|e| engine(Error::Storage(format!("{e:?}"))))?;
90        record.vector = *next_slot;
91        record.flags |= fact_flags::HAS_VECTOR;
92        facts.insert(&record).map_err(Error::from).map_err(engine)?;
93        *next_slot = next_slot.checked_add(1).ok_or_else(|| {
94            engine(Error::CapacityExceeded {
95                what: "vector slots",
96            })
97        })?;
98    }
99    texts.clear();
100    Ok(())
101}
102
103impl Memory<'_> {
104    /// Streams a snapshot with a completely new vector pool and vector-space
105    /// identity. The source memory is not mutated.
106    ///
107    /// `embed` is invoked with at most `batch_size` borrowed fact texts. It is
108    /// also called once with an empty probe string when the database has no
109    /// retained fact, so the target embedder still validates its advertised
110    /// dimension. Tombstones are never sent to it; closed revisions are,
111    /// because historical/as-of recall must stay vector-searchable.
112    ///
113    /// # Errors
114    ///
115    /// [`ReembedError::Embedder`] preserves a callback failure. Engine errors
116    /// cover an invalid space/dimension, malformed provider output, corrupt
117    /// source text, capacity, scratch I/O, HNSW, and snapshot output.
118    #[allow(clippy::too_many_arguments)]
119    pub fn write_reembedded_snapshot<V, Sk, E, F>(
120        &self,
121        created_at: u64,
122        target_dim: usize,
123        target_space: &str,
124        batch_size: usize,
125        vec_scratch: &mut V,
126        sink: Sk,
127        mut embed: F,
128    ) -> Result<ReembedReport, ReembedError<E>>
129    where
130        V: Scratch,
131        Sk: SnapshotSink,
132        F: FnMut(&[&str]) -> Result<Vec<Vec<f32>>, E>,
133    {
134        Self::validate_vector_space(target_space).map_err(engine)?;
135        if target_dim == 0 {
136            return Err(engine(Error::Invalid(
137                "reembed target dimension must be nonzero",
138            )));
139        }
140        if batch_size == 0 {
141            return Err(engine(Error::Invalid("reembed batch size must be nonzero")));
142        }
143        let mut target_cfg = self.cfg.clone();
144        target_cfg.dim = target_dim;
145        target_cfg.validate().map_err(engine)?;
146
147        let arena = ArenaCfg::new(target_cfg.shards_facts, ShardMode::Uniform)
148            .with_max_bytes(target_cfg.max_bytes);
149        let mut facts = Arena::new(arena).map_err(Error::from).map_err(engine)?;
150        let encoder = VecPool::new(target_dim, target_cfg.max_bytes);
151        let mut records = Vec::with_capacity(batch_size);
152        let mut texts = Vec::with_capacity(batch_size);
153        let mut slot_buf = Vec::with_capacity(encoder.stride());
154        let mut next_slot = 0u32;
155        let mut tombstones_skipped = 0usize;
156
157        for fid in self.fact_ids_ascending() {
158            let Some(mut record) = self.facts.get(&fid.to_be_bytes()) else {
159                continue;
160            };
161            if record.is_tombstone() {
162                record.flags &= !fact_flags::HAS_VECTOR;
163                record.vector = NONE_U32;
164                facts.insert(&record).map_err(Error::from).map_err(engine)?;
165                tombstones_skipped += 1;
166                continue;
167            }
168            let text = core::str::from_utf8(self.texts.get(record.text))
169                .map_err(|_| engine(Error::Corrupt("reembed: fact text is not UTF-8")))?;
170            records.push(record);
171            texts.push(text);
172            if records.len() == batch_size {
173                flush_batch(
174                    &encoder,
175                    vec_scratch,
176                    &mut facts,
177                    &mut records,
178                    &mut texts,
179                    &mut slot_buf,
180                    &mut next_slot,
181                    &mut embed,
182                )?;
183            }
184        }
185        flush_batch(
186            &encoder,
187            vec_scratch,
188            &mut facts,
189            &mut records,
190            &mut texts,
191            &mut slot_buf,
192            &mut next_slot,
193            &mut embed,
194        )?;
195
196        if next_slot == 0 {
197            let probe = embed(&[""]).map_err(ReembedError::Embedder)?;
198            if probe.len() != 1 {
199                return Err(engine(Error::Invalid(
200                    "embedder returned the wrong number of probe vectors",
201                )));
202            }
203            encoder
204                .encode_slot_into(crate::FactId(0), &probe[0], &mut slot_buf)
205                .map_err(engine)?;
206        }
207
208        let vector_bytes = vec_scratch.len();
209        let vec_bytes = vec_scratch
210            .freeze()
211            .map_err(|e| engine(Error::Storage(format!("{e:?}"))))?;
212        let vecs = VecPool::from_parts_borrowed(target_dim, target_cfg.max_bytes, vec_bytes)
213            .map_err(engine)?;
214        debug_assert_eq!(vecs.len(), next_slot as usize);
215
216        let mut hnsw = HnswGraph::new(target_cfg.hnsw_m, target_cfg.hnsw_m0, target_cfg.max_bytes)
217            .map_err(engine)?;
218        if vecs.len() >= target_cfg.flat_to_hnsw {
219            let mut scratch = HnswScratch::default();
220            hnsw.insert_bulk(
221                &vecs,
222                next_slot,
223                target_cfg.hnsw_ef_construction,
224                &mut scratch,
225            )
226            .map_err(engine)?;
227        }
228
229        let sections = Sections {
230            facts: &facts,
231            fact_aux: &self.fact_aux,
232            entities: &self.entities,
233            by_name: &self.by_name,
234            temporal: &self.temporal,
235            texts: &self.texts,
236            metas: &self.metas,
237            tag_lists: &self.tag_lists,
238            bm25: &self.bm25,
239            tags_idx: &self.tags_idx,
240            entity_facts: &self.entity_facts,
241            vecs: &vecs,
242            hnsw: &hnsw,
243            edges_out: &self.edges_out,
244            edges_in: &self.edges_in,
245            edges_hist_out: &self.edges_hist_out,
246            edges_hist_in: &self.edges_hist_in,
247            layout: ShardLayout::of_config(&self.cfg),
248        };
249        self.write_snapshot_reconfigured(
250            &sections,
251            &target_cfg,
252            Some(target_space),
253            created_at,
254            sink,
255        )
256        .map_err(engine)?;
257
258        Ok(ReembedReport {
259            previous_space: self.vector_space.clone(),
260            new_space: target_space.into(),
261            previous_dim: self.cfg.dim,
262            new_dim: target_dim,
263            embedded: next_slot as usize,
264            tombstones_skipped,
265            vector_bytes,
266            hnsw_indexed: hnsw.indexed(),
267        })
268    }
269}