Skip to main content

plugmem_core/
config.rs

1//! Engine configuration.
2//!
3//! Every knob that changes how bytes are interpreted lives here, because
4//! the config is persisted inside the snapshot: opening an existing
5//! database with an incompatible config (different `dim`, different shard
6//! counts) is a typed error, not a silent reinterpretation.
7
8use alloc::vec::Vec;
9
10use crate::error::Error;
11
12/// Serialized width of one `u64`-encoded size field.
13const U64_BYTES: usize = core::mem::size_of::<u64>();
14/// Serialized width of one `f32` field.
15const F32_BYTES: usize = core::mem::size_of::<f32>();
16/// Serialized width of one `u32` field.
17const U32_BYTES: usize = core::mem::size_of::<u32>();
18/// Serialized width of the `db_uuid` field (`u128`).
19const UUID_BYTES: usize = core::mem::size_of::<u128>();
20
21/// Number of `usize` fields in the encoded block (stored as `u64`).
22const USIZE_FIELDS: usize = 14;
23/// Number of `f32` fields in the encoded block.
24const F32_FIELDS: usize = 10;
25/// Number of `u32` fields in the encoded block.
26const U32_FIELDS: usize = 3;
27/// Byte offset of the `f32` field group.
28const F32S_AT: usize = USIZE_FIELDS * U64_BYTES;
29/// Byte offset of the `u32` field group.
30const U32S_AT: usize = F32S_AT + F32_FIELDS * F32_BYTES;
31/// Byte offset of the `db_uuid` field.
32const DB_UUID_AT: usize = U32S_AT + U32_FIELDS * U32_BYTES;
33/// Byte offset of the reserved zero tail (directly after `db_uuid`).
34pub const RESERVED_AT: usize = DB_UUID_AT + UUID_BYTES;
35/// Length of the reserved zero tail.
36const RESERVED_LEN: usize = 8;
37/// Exact byte length of the encoded config block (see [`Config::encode`]).
38pub const ENCODED_LEN: usize = RESERVED_AT + RESERVED_LEN;
39
40/// Full engine configuration with the defaults.
41///
42/// Plain data: construct with [`Config::default`], override fields, then
43/// let the engine call [`Config::validate`] (it is also callable directly —
44/// useful for surfacing config errors early in wrappers).
45#[derive(Clone, Debug, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[non_exhaustive]
48pub struct Config {
49    /// Vector dimension; `0` disables the vector layer entirely. Max 4096.
50    pub dim: usize,
51    /// Total ceiling for all byte pools (the wasm32 passport: ≤ 2 GiB).
52    pub max_bytes: usize,
53    /// Maximum fact text length in bytes.
54    pub max_text: usize,
55    /// Maximum single blob length in bytes.
56    pub max_blob: usize,
57    /// Shard count of the facts arena (power of two).
58    pub shards_facts: usize,
59    /// Shard count of the entities arena (power of two).
60    pub shards_entities: usize,
61    /// Shard count of each edge arena (power of two).
62    pub shards_edges: usize,
63    /// Shard count of the temporal arena (power of two).
64    pub shards_temporal: usize,
65    /// Shard count of the postings arena (power of two).
66    pub shards_postings: usize,
67    /// BM25 `k1` (term-frequency saturation).
68    pub bm25_k1: f32,
69    /// BM25 `b` (length normalization), in `[0, 1]`.
70    pub bm25_b: f32,
71    /// The RRF rank constant (`score += w / (rrf_k + rank)`).
72    pub rrf_k: u32,
73    /// RRF weight of the lexical (BM25) source.
74    pub w_bm25: f32,
75    /// RRF weight of the vector source.
76    pub w_vec: f32,
77    /// RRF weight of the graph source.
78    pub w_graph: f32,
79    /// RRF weight of the temporal-range source.
80    pub w_time: f32,
81    /// Strength of the recency boost (`0` disables it).
82    pub w_recency: f32,
83    /// Recency half-life in days.
84    pub half_life_days: u32,
85    /// Graph expansion depth limit.
86    pub graph_depth: u32,
87    /// Per-hop weight decay of graph candidates, in `(0, 1]`.
88    pub graph_decay: f32,
89    /// Cosine threshold for vector-based similar-detection, in `[0, 1]`.
90    pub similar_cos: f32,
91    /// Jaccard threshold for lexical similar-detection, in `[0, 1]`.
92    pub similar_jaccard: f32,
93    /// HNSW: neighbors per node on upper levels.
94    pub hnsw_m: usize,
95    /// HNSW: neighbors per node on level 0.
96    pub hnsw_m0: usize,
97    /// HNSW: beam width during construction.
98    pub hnsw_ef_construction: usize,
99    /// HNSW: default beam width during search (per-query override exists).
100    pub hnsw_ef_search: usize,
101    /// Vector count at which `maintain` switches Flat → HNSW.
102    pub flat_to_hnsw: usize,
103    /// Database lineage identity. Minted **once** by the
104    /// host at creation (the `no_std` core has no RNG) and persisted in
105    /// every snapshot; it survives `maintain` and re-saves, so external
106    /// holders of ids can tell "same database" from "a different one".
107    /// `0` means an unnamed (ephemeral/test) database. On open, `0` here
108    /// adopts whatever the snapshot stores; a nonzero value must match
109    /// the stored one or the open fails with `ConfigMismatch`.
110    pub db_uuid: u128,
111}
112
113impl Default for Config {
114    /// The defaults table.
115    fn default() -> Self {
116        Self {
117            dim: 0,
118            max_bytes: 2 * 1024 * 1024 * 1024,
119            max_text: 4096,
120            max_blob: 64 * 1024,
121            shards_facts: 1024,
122            shards_entities: 256,
123            shards_edges: 512,
124            shards_temporal: 512,
125            shards_postings: 2048,
126            bm25_k1: 1.2,
127            bm25_b: 0.75,
128            rrf_k: 60,
129            w_bm25: 1.0,
130            w_vec: 1.0,
131            w_graph: 1.0,
132            w_time: 1.0,
133            w_recency: 0.25,
134            half_life_days: 180,
135            graph_depth: 2,
136            graph_decay: 0.5,
137            similar_cos: 0.85,
138            similar_jaccard: 0.5,
139            hnsw_m: 16,
140            hnsw_m0: 32,
141            hnsw_ef_construction: 200,
142            hnsw_ef_search: 64,
143            flat_to_hnsw: 24_000,
144            db_uuid: 0,
145        }
146    }
147}
148
149/// One weight-range check: finite and non-negative.
150fn check_weight(v: f32, what: &'static str) -> Result<(), Error> {
151    if v.is_finite() && v >= 0.0 {
152        Ok(())
153    } else {
154        Err(Error::ConfigMismatch(what))
155    }
156}
157
158/// One unit-interval check: finite and inside `[0, 1]`.
159fn check_unit(v: f32, what: &'static str) -> Result<(), Error> {
160    if v.is_finite() && (0.0..=1.0).contains(&v) {
161        Ok(())
162    } else {
163        Err(Error::ConfigMismatch(what))
164    }
165}
166
167impl Config {
168    /// Checks every field against its documented range.
169    ///
170    /// Returns [`Error::ConfigMismatch`] naming the offending field. The
171    /// engine calls this on every construction path; wrappers may call it
172    /// earlier to fail fast.
173    pub fn validate(&self) -> Result<(), Error> {
174        if self.dim > 4096 {
175            return Err(Error::ConfigMismatch("dim must be <= 4096"));
176        }
177        for (shards, what) in [
178            (self.shards_facts, "shards_facts must be a power of two"),
179            (
180                self.shards_entities,
181                "shards_entities must be a power of two",
182            ),
183            (self.shards_edges, "shards_edges must be a power of two"),
184            (
185                self.shards_temporal,
186                "shards_temporal must be a power of two",
187            ),
188            (
189                self.shards_postings,
190                "shards_postings must be a power of two",
191            ),
192        ] {
193            if !shards.is_power_of_two() {
194                return Err(Error::ConfigMismatch(what));
195            }
196        }
197        if self.max_text == 0 || self.max_text > self.max_blob {
198            return Err(Error::ConfigMismatch("max_text must be in 1..=max_blob"));
199        }
200        if self.max_blob > self.max_bytes {
201            return Err(Error::ConfigMismatch("max_blob must be <= max_bytes"));
202        }
203        if !(self.bm25_k1.is_finite() && self.bm25_k1 > 0.0) {
204            return Err(Error::ConfigMismatch("bm25_k1 must be positive"));
205        }
206        check_unit(self.bm25_b, "bm25_b must be in [0, 1]")?;
207        if self.rrf_k == 0 {
208            return Err(Error::ConfigMismatch("rrf_k must be >= 1"));
209        }
210        check_weight(self.w_bm25, "w_bm25 must be finite and >= 0")?;
211        check_weight(self.w_vec, "w_vec must be finite and >= 0")?;
212        check_weight(self.w_graph, "w_graph must be finite and >= 0")?;
213        check_weight(self.w_time, "w_time must be finite and >= 0")?;
214        check_weight(self.w_recency, "w_recency must be finite and >= 0")?;
215        if self.half_life_days == 0 {
216            return Err(Error::ConfigMismatch("half_life_days must be >= 1"));
217        }
218        if self.graph_depth > 4 {
219            return Err(Error::ConfigMismatch("graph_depth must be <= 4"));
220        }
221        if !(self.graph_decay.is_finite() && self.graph_decay > 0.0 && self.graph_decay <= 1.0) {
222            return Err(Error::ConfigMismatch("graph_decay must be in (0, 1]"));
223        }
224        check_unit(self.similar_cos, "similar_cos must be in [0, 1]")?;
225        check_unit(self.similar_jaccard, "similar_jaccard must be in [0, 1]")?;
226        if self.hnsw_m < 2 {
227            return Err(Error::ConfigMismatch("hnsw_m must be >= 2"));
228        }
229        if self.hnsw_m0 < self.hnsw_m {
230            return Err(Error::ConfigMismatch("hnsw_m0 must be >= hnsw_m"));
231        }
232        if self.hnsw_ef_construction < self.hnsw_m {
233            return Err(Error::ConfigMismatch(
234                "hnsw_ef_construction must be >= hnsw_m",
235            ));
236        }
237        if self.hnsw_ef_search == 0 {
238            return Err(Error::ConfigMismatch("hnsw_ef_search must be >= 1"));
239        }
240        if self.flat_to_hnsw == 0 {
241            return Err(Error::ConfigMismatch("flat_to_hnsw must be >= 1"));
242        }
243        Ok(())
244    }
245
246    /// Appends the fixed binary form of the config to `out` — the config
247    /// block of the snapshot. Layout, all little-endian, in
248    /// field-declaration order: `usize` fields as `u64`, `f32` fields as
249    /// their IEEE 754 bits, then `rrf_k`/`half_life_days`/`graph_depth` as
250    /// `u32`, `db_uuid` as a `u128`, then 8 reserved zero bytes; exactly
251    /// [`ENCODED_LEN`] bytes. Encoding is lossless and canonical (float bits
252    /// round-trip exactly).
253    pub fn encode(&self, out: &mut Vec<u8>) {
254        out.reserve(ENCODED_LEN);
255        for v in [
256            self.dim,
257            self.max_bytes,
258            self.max_text,
259            self.max_blob,
260            self.shards_facts,
261            self.shards_entities,
262            self.shards_edges,
263            self.shards_temporal,
264            self.shards_postings,
265            self.hnsw_m,
266            self.hnsw_m0,
267            self.hnsw_ef_construction,
268            self.hnsw_ef_search,
269            self.flat_to_hnsw,
270        ] {
271            out.extend_from_slice(&(v as u64).to_le_bytes());
272        }
273        for v in [
274            self.bm25_k1,
275            self.bm25_b,
276            self.w_bm25,
277            self.w_vec,
278            self.w_graph,
279            self.w_time,
280            self.w_recency,
281            self.graph_decay,
282            self.similar_cos,
283            self.similar_jaccard,
284        ] {
285            out.extend_from_slice(&v.to_le_bytes());
286        }
287        for v in [self.rrf_k, self.half_life_days, self.graph_depth] {
288            out.extend_from_slice(&v.to_le_bytes());
289        }
290        out.extend_from_slice(&self.db_uuid.to_le_bytes());
291        out.extend_from_slice(&[0u8; RESERVED_LEN]);
292    }
293
294    /// Decodes a config block written by [`Config::encode`] and runs
295    /// [`Config::validate`] on the result.
296    ///
297    /// The input is untrusted: a wrong length or nonzero reserved bytes are
298    /// [`Error::Corrupt`]; out-of-range field values surface as the same
299    /// [`Error::ConfigMismatch`] a hand-built config would get.
300    ///
301    /// All size fields are stored as fixed-width `u64`, so the block is
302    /// identical on 32-bit and 64-bit builds of the engine. A value that
303    /// overflows this platform's `usize` means the database was created
304    /// with limits only a 64-bit address space can hold (e.g. `max_bytes`
305    /// beyond 4 GiB on a wasm64 or native host) — the file is not corrupt,
306    /// this host is too small for it, hence [`Error::ConfigMismatch`].
307    pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
308        if bytes.len() != ENCODED_LEN {
309            return Err(Error::Corrupt("config block length mismatch"));
310        }
311        let mut at = 0usize;
312        let mut take_usize = || -> Result<usize, Error> {
313            let v = u64::from_le_bytes(bytes[at..at + U64_BYTES].try_into().unwrap());
314            at += U64_BYTES;
315            usize::try_from(v)
316                .map_err(|_| Error::ConfigMismatch("database requires a 64-bit address space"))
317        };
318        let dim = take_usize()?;
319        let max_bytes = take_usize()?;
320        let max_text = take_usize()?;
321        let max_blob = take_usize()?;
322        let shards_facts = take_usize()?;
323        let shards_entities = take_usize()?;
324        let shards_edges = take_usize()?;
325        let shards_temporal = take_usize()?;
326        let shards_postings = take_usize()?;
327        let hnsw_m = take_usize()?;
328        let hnsw_m0 = take_usize()?;
329        let hnsw_ef_construction = take_usize()?;
330        let hnsw_ef_search = take_usize()?;
331        let flat_to_hnsw = take_usize()?;
332        let mut at = F32S_AT;
333        let mut take_f32 = || {
334            let v = f32::from_le_bytes(bytes[at..at + F32_BYTES].try_into().unwrap());
335            at += F32_BYTES;
336            v
337        };
338        let bm25_k1 = take_f32();
339        let bm25_b = take_f32();
340        let w_bm25 = take_f32();
341        let w_vec = take_f32();
342        let w_graph = take_f32();
343        let w_time = take_f32();
344        let w_recency = take_f32();
345        let graph_decay = take_f32();
346        let similar_cos = take_f32();
347        let similar_jaccard = take_f32();
348        let mut at = U32S_AT;
349        let mut take_u32 = || {
350            let v = u32::from_le_bytes(bytes[at..at + U32_BYTES].try_into().unwrap());
351            at += U32_BYTES;
352            v
353        };
354        let rrf_k = take_u32();
355        let half_life_days = take_u32();
356        let graph_depth = take_u32();
357        let db_uuid = u128::from_le_bytes(bytes[DB_UUID_AT..RESERVED_AT].try_into().unwrap());
358        if bytes[RESERVED_AT..ENCODED_LEN] != [0u8; RESERVED_LEN] {
359            return Err(Error::Corrupt("reserved config bytes must be zero"));
360        }
361        let cfg = Self {
362            dim,
363            max_bytes,
364            max_text,
365            max_blob,
366            shards_facts,
367            shards_entities,
368            shards_edges,
369            shards_temporal,
370            shards_postings,
371            bm25_k1,
372            bm25_b,
373            rrf_k,
374            w_bm25,
375            w_vec,
376            w_graph,
377            w_time,
378            w_recency,
379            half_life_days,
380            graph_depth,
381            graph_decay,
382            similar_cos,
383            similar_jaccard,
384            hnsw_m,
385            hnsw_m0,
386            hnsw_ef_construction,
387            hnsw_ef_search,
388            flat_to_hnsw,
389            db_uuid,
390        };
391        cfg.validate()?;
392        Ok(cfg)
393    }
394}