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 plugmem_arena::PAGE_BYTES;
11
12use crate::error::Error;
13
14/// Runtime metadata an arena keeps per shard: the `heads`, `tails` and
15/// `dir_at` vectors, one `u32` each.
16const SHARD_META_BYTES: usize = 3 * core::mem::size_of::<u32>();
17/// Pages per shard the engine aims for.
18///
19/// Two costs pull in opposite directions. A shard's page directory is sorted,
20/// so an insert lands mid-directory and memmoves the entries above it — more
21/// pages per shard, more memmove. But every *touched* shard also owns at least
22/// one whole page, so fewer records per shard means paying 4 KiB for a handful
23/// of bytes; that is what made a thousand facts occupy fourteen megabytes.
24///
25/// Measured, because the balance is not obvious: sweeping the shard counts
26/// across a fixed corpus (100k and 1M facts, 8 through 4096 shards) moved write
27/// throughput by less than the run-to-run noise — flat even at 1465 pages per
28/// shard — while resident bytes grew monotonically with the shard count, by
29/// 52 % at 100k facts between the loosest and the tightest setting. The
30/// directory memmove is simply cheap: an entry is 20 bytes, so even a thousand
31/// of them is one short `memmove`.
32///
33/// So the tradeoff is lopsided and this sits on the roomy side of it: 64 pages
34/// keeps the per-shard directory two orders of magnitude below where the sweep
35/// still measured nothing, and holds the page floor near 5 % of payload.
36pub const PAGES_PER_SHARD: usize = 64;
37/// Payload bytes one shard is meant to hold: [`PAGES_PER_SHARD`] pages.
38pub const SHARD_TARGET_BYTES: usize = PAGES_PER_SHARD * PAGE_BYTES;
39
40/// Largest neighbour degree the vector graph accepts.
41///
42/// Derived, like [`MAX_SHARDS`]: a node's level-0 neighbour block is `degree`
43/// `u32`s, and this is the degree at which that block fills exactly one
44/// [`PAGE_BYTES`] page — the allocation unit the rest of the engine works in.
45/// It is also far past useful: HNSW degrees live in the tens, and a list this
46/// long turns each hop into a linear scan. The bound exists because the value
47/// arrives in a snapshot and then multiplies the node count into an allocation
48/// size, which on wasm32 wraps a 32-bit `usize` well before any pool ceiling
49/// would notice.
50pub const MAX_HNSW_DEGREE: usize = PAGE_BYTES / core::mem::size_of::<u32>();
51
52/// Fewest shards any arena gets.
53///
54/// A shard is not free — it costs its own metadata and, once touched, a whole
55/// page — so a nearly empty database wants as few as possible. It wants more
56/// than one because the count is also the concurrency and locality unit, and
57/// because a database that starts at one shard would re-shard on its first
58/// handful of records.
59pub const MIN_SHARDS: usize = 4;
60
61/// Largest shard count any arena may be configured with.
62///
63/// A shard count arrives from an untrusted snapshot, and `Arena::new` turns it
64/// straight into three vectors plus a page pool — so it is an allocation size
65/// taken from a file, and those need a ceiling. This one is derived, not
66/// picked:
67///
68/// - `MAX_SHARDS * PAGE_BYTES` is 256 MiB, which fits a 32-bit `usize`, so page
69///   arithmetic cannot overflow on wasm32;
70/// - per-shard runtime metadata stays bounded at
71///   `MAX_SHARDS * SHARD_META_BYTES` (768 KiB) per arena;
72/// - it cannot bind on a database that can actually exist: the default 2 GiB
73///   pool ceiling at [`SHARD_TARGET_BYTES`] per shard justifies at most
74///   `2 GiB / SHARD_TARGET_BYTES` ≈ 43690 shards, which rounds up to exactly
75///   this value. Anything larger describes a database no pool could hold.
76pub const MAX_SHARDS: usize = 1 << 16;
77
78/// Ceiling for one arena's per-shard runtime metadata, which is what stops
79/// [`MAX_SHARDS`] from being a number someone can raise without noticing the
80/// cost: every arena pays this, and the engine builds roughly a dozen.
81const MAX_SHARD_META_BYTES: usize = 1024 * 1024;
82
83const _: () = {
84    assert!(MAX_SHARDS.is_power_of_two());
85    // The wasm32 bound: pages of every shard must be addressable there.
86    assert!(MAX_SHARDS <= u32::MAX as usize / PAGE_BYTES);
87    // The metadata bound.
88    assert!(MAX_SHARDS * SHARD_META_BYTES <= MAX_SHARD_META_BYTES);
89    // The "cannot bind in practice" bound, spelled out so a change to
90    // PAGES_PER_SHARD that invalidates it fails the build instead of silently
91    // turning MAX_SHARDS into a real limit.
92    assert!(MAX_SHARDS >= (2 * 1024 * 1024 * 1024usize) / SHARD_TARGET_BYTES);
93};
94
95/// Serialized width of one `u64`-encoded size field.
96const U64_BYTES: usize = core::mem::size_of::<u64>();
97/// Serialized width of one `f32` field.
98const F32_BYTES: usize = core::mem::size_of::<f32>();
99/// Serialized width of one `u32` field.
100const U32_BYTES: usize = core::mem::size_of::<u32>();
101/// Serialized width of the `db_uuid` field (`u128`).
102const UUID_BYTES: usize = core::mem::size_of::<u128>();
103
104/// Number of `usize` fields in the encoded block (stored as `u64`).
105const USIZE_FIELDS: usize = 14;
106/// Number of `f32` fields in the encoded block.
107const F32_FIELDS: usize = 10;
108/// Number of `u32` fields in the encoded block.
109const U32_FIELDS: usize = 3;
110/// Byte offset of the `f32` field group.
111const F32S_AT: usize = USIZE_FIELDS * U64_BYTES;
112/// Byte offset of the `u32` field group.
113const U32S_AT: usize = F32S_AT + F32_FIELDS * F32_BYTES;
114/// Byte offset of the `db_uuid` field.
115const DB_UUID_AT: usize = U32S_AT + U32_FIELDS * U32_BYTES;
116/// Byte offset of the reserved zero tail (directly after `db_uuid`).
117pub const RESERVED_AT: usize = DB_UUID_AT + UUID_BYTES;
118/// Length of the reserved zero tail.
119const RESERVED_LEN: usize = 8;
120/// Exact byte length of the encoded config block (see [`Config::encode`]).
121pub const ENCODED_LEN: usize = RESERVED_AT + RESERVED_LEN;
122
123/// Full engine configuration with the defaults.
124///
125/// Plain data: construct with [`Config::default`], override fields, then
126/// let the engine call [`Config::validate`] (it is also callable directly —
127/// useful for surfacing config errors early in wrappers).
128#[derive(Clone, Debug, PartialEq)]
129#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
130#[non_exhaustive]
131pub struct Config {
132    /// Vector dimension; `0` disables the vector layer entirely. Max 4096.
133    pub dim: usize,
134    /// Ceiling for **each** byte pool — not for their sum.
135    ///
136    /// Every pool the engine builds (the arenas' pages, the text and metadata
137    /// blob heaps, the tag and posting chunk pools, the vector pool) is given
138    /// this same figure as its own limit and refuses to grow past it with
139    /// [`Error::CapacityExceeded`]. A database's total therefore reaches
140    /// several times this number; the one that binds first is whichever pool
141    /// the workload fills, normally the fact texts.
142    ///
143    /// The default is the wasm32 passport rather than a capacity judgement: it
144    /// keeps every pool addressable where `usize` is 32 bits, so a database
145    /// written anywhere opens anywhere. Raising it is supported and costs
146    /// exactly that portability — a 32-bit host then refuses the file with a
147    /// typed `ConfigMismatch`, not with corruption.
148    pub max_bytes: usize,
149    /// Maximum fact text length in bytes.
150    pub max_text: usize,
151    /// Maximum single blob length in bytes.
152    pub max_blob: usize,
153    /// Shard count of the facts arena (power of two, ≤ [`MAX_SHARDS`]).
154    ///
155    /// **Engine-managed.** The five shard counts describe how an existing file
156    /// is laid out, not a preference: a new database starts at [`MIN_SHARDS`],
157    /// opening one adopts whatever the snapshot records, and `maintain` moves
158    /// the layout as the data grows or shrinks. Setting one here only affects a
159    /// database being created, and the next maintenance pass will overrule it.
160    pub shards_facts: usize,
161    /// Shard count of the entities arena. See [`Config::shards_facts`].
162    pub shards_entities: usize,
163    /// Shard count of each edge arena. See [`Config::shards_facts`].
164    pub shards_edges: usize,
165    /// Shard count of the temporal arena. See [`Config::shards_facts`].
166    pub shards_temporal: usize,
167    /// Shard count of the postings arenas. See [`Config::shards_facts`].
168    pub shards_postings: usize,
169    /// BM25 `k1` (term-frequency saturation).
170    pub bm25_k1: f32,
171    /// BM25 `b` (length normalization), in `[0, 1]`.
172    pub bm25_b: f32,
173    /// The RRF rank constant (`score += w / (rrf_k + rank)`).
174    pub rrf_k: u32,
175    /// RRF weight of the lexical (BM25) source.
176    pub w_bm25: f32,
177    /// RRF weight of the vector source.
178    pub w_vec: f32,
179    /// RRF weight of the graph source.
180    pub w_graph: f32,
181    /// RRF weight of the temporal-range source.
182    pub w_time: f32,
183    /// Strength of the recency boost (`0` disables it).
184    pub w_recency: f32,
185    /// Recency half-life in days.
186    pub half_life_days: u32,
187    /// Default graph expansion depth. A recall overrides it per call
188    /// (`RecallQuery::graph_depth`).
189    ///
190    /// Not capped: the cost of a walk is held by the entity and edge caps in
191    /// the recall path, so a hop ceiling would only forbid the case where hops
192    /// are cheapest — a sparse chain, one entity per hop.
193    pub graph_depth: u32,
194    /// Per-hop weight decay of graph candidates, in `(0, 1]`.
195    pub graph_decay: f32,
196    /// Cosine threshold for vector-based similar-detection, in `[0, 1]`.
197    pub similar_cos: f32,
198    /// Jaccard threshold for lexical similar-detection, in `[0, 1]`.
199    pub similar_jaccard: f32,
200    /// HNSW: neighbors per node on upper levels.
201    pub hnsw_m: usize,
202    /// HNSW: neighbors per node on level 0.
203    pub hnsw_m0: usize,
204    /// HNSW: beam width during construction.
205    pub hnsw_ef_construction: usize,
206    /// HNSW: default beam width during search (per-query override exists).
207    pub hnsw_ef_search: usize,
208    /// Vector count at which `maintain` switches Flat → HNSW.
209    pub flat_to_hnsw: usize,
210    /// Database lineage identity. Minted **once** by the
211    /// host at creation (the `no_std` core has no RNG) and persisted in
212    /// every snapshot; it survives `maintain` and re-saves, so external
213    /// holders of ids can tell "same database" from "a different one".
214    /// `0` means an unnamed (ephemeral/test) database. On open, `0` here
215    /// adopts whatever the snapshot stores; a nonzero value must match
216    /// the stored one or the open fails with `ConfigMismatch`.
217    pub db_uuid: u128,
218}
219
220impl Default for Config {
221    /// The defaults table.
222    fn default() -> Self {
223        Self {
224            dim: 0,
225            max_bytes: 2 * 1024 * 1024 * 1024,
226            max_text: 4096,
227            max_blob: 64 * 1024,
228            // A new database is empty, and the layout rule puts an empty
229            // database on the floor. It grows from here through `maintain`.
230            shards_facts: MIN_SHARDS,
231            shards_entities: MIN_SHARDS,
232            shards_edges: MIN_SHARDS,
233            shards_temporal: MIN_SHARDS,
234            shards_postings: MIN_SHARDS,
235            bm25_k1: 1.2,
236            bm25_b: 0.75,
237            rrf_k: 60,
238            w_bm25: 1.0,
239            w_vec: 1.0,
240            w_graph: 1.0,
241            w_time: 1.0,
242            w_recency: 0.25,
243            half_life_days: 180,
244            graph_depth: 2,
245            graph_decay: 0.5,
246            similar_cos: 0.85,
247            similar_jaccard: 0.5,
248            hnsw_m: 16,
249            hnsw_m0: 32,
250            hnsw_ef_construction: 200,
251            hnsw_ef_search: 64,
252            flat_to_hnsw: 24_000,
253            db_uuid: 0,
254        }
255    }
256}
257
258/// One weight-range check: finite and non-negative.
259fn check_weight(v: f32, what: &'static str) -> Result<(), Error> {
260    if v.is_finite() && v >= 0.0 {
261        Ok(())
262    } else {
263        Err(Error::ConfigMismatch(what))
264    }
265}
266
267/// One unit-interval check: finite and inside `[0, 1]`.
268fn check_unit(v: f32, what: &'static str) -> Result<(), Error> {
269    if v.is_finite() && (0.0..=1.0).contains(&v) {
270        Ok(())
271    } else {
272        Err(Error::ConfigMismatch(what))
273    }
274}
275
276impl Config {
277    /// Checks every field against its documented range.
278    ///
279    /// Returns [`Error::ConfigMismatch`] naming the offending field. The
280    /// engine calls this on every construction path; wrappers may call it
281    /// earlier to fail fast.
282    pub fn validate(&self) -> Result<(), Error> {
283        if self.dim > 4096 {
284            return Err(Error::ConfigMismatch("dim must be <= 4096"));
285        }
286        // Both checks matter for untrusted input: a snapshot supplies these,
287        // and `Arena::new` turns each straight into an allocation size.
288        for (shards, not_pow2, too_many) in [
289            (
290                self.shards_facts,
291                "shards_facts must be a power of two",
292                "shards_facts exceeds MAX_SHARDS",
293            ),
294            (
295                self.shards_entities,
296                "shards_entities must be a power of two",
297                "shards_entities exceeds MAX_SHARDS",
298            ),
299            (
300                self.shards_edges,
301                "shards_edges must be a power of two",
302                "shards_edges exceeds MAX_SHARDS",
303            ),
304            (
305                self.shards_temporal,
306                "shards_temporal must be a power of two",
307                "shards_temporal exceeds MAX_SHARDS",
308            ),
309            (
310                self.shards_postings,
311                "shards_postings must be a power of two",
312                "shards_postings exceeds MAX_SHARDS",
313            ),
314        ] {
315            if !shards.is_power_of_two() {
316                return Err(Error::ConfigMismatch(not_pow2));
317            }
318            if shards > MAX_SHARDS {
319                return Err(Error::ConfigMismatch(too_many));
320            }
321        }
322        if self.max_text == 0 || self.max_text > self.max_blob {
323            return Err(Error::ConfigMismatch("max_text must be in 1..=max_blob"));
324        }
325        if self.max_blob > self.max_bytes {
326            return Err(Error::ConfigMismatch("max_blob must be <= max_bytes"));
327        }
328        if !(self.bm25_k1.is_finite() && self.bm25_k1 > 0.0) {
329            return Err(Error::ConfigMismatch("bm25_k1 must be positive"));
330        }
331        check_unit(self.bm25_b, "bm25_b must be in [0, 1]")?;
332        if self.rrf_k == 0 {
333            return Err(Error::ConfigMismatch("rrf_k must be >= 1"));
334        }
335        check_weight(self.w_bm25, "w_bm25 must be finite and >= 0")?;
336        check_weight(self.w_vec, "w_vec must be finite and >= 0")?;
337        check_weight(self.w_graph, "w_graph must be finite and >= 0")?;
338        check_weight(self.w_time, "w_time must be finite and >= 0")?;
339        check_weight(self.w_recency, "w_recency must be finite and >= 0")?;
340        if self.half_life_days == 0 {
341            return Err(Error::ConfigMismatch("half_life_days must be >= 1"));
342        }
343        if !(self.graph_decay.is_finite() && self.graph_decay > 0.0 && self.graph_decay <= 1.0) {
344            return Err(Error::ConfigMismatch("graph_decay must be in (0, 1]"));
345        }
346        check_unit(self.similar_cos, "similar_cos must be in [0, 1]")?;
347        check_unit(self.similar_jaccard, "similar_jaccard must be in [0, 1]")?;
348        if self.hnsw_m < 2 {
349            return Err(Error::ConfigMismatch("hnsw_m must be >= 2"));
350        }
351        if self.hnsw_m0 < self.hnsw_m {
352            return Err(Error::ConfigMismatch("hnsw_m0 must be >= hnsw_m"));
353        }
354        // Checked here rather than where the graph is built: both degrees
355        // become factors of an allocation size, and a snapshot supplies them.
356        if self.hnsw_m > MAX_HNSW_DEGREE {
357            return Err(Error::ConfigMismatch("hnsw_m exceeds MAX_HNSW_DEGREE"));
358        }
359        if self.hnsw_m0 > MAX_HNSW_DEGREE {
360            return Err(Error::ConfigMismatch("hnsw_m0 exceeds MAX_HNSW_DEGREE"));
361        }
362        if self.hnsw_ef_construction < self.hnsw_m {
363            return Err(Error::ConfigMismatch(
364                "hnsw_ef_construction must be >= hnsw_m",
365            ));
366        }
367        if self.hnsw_ef_search == 0 {
368            return Err(Error::ConfigMismatch("hnsw_ef_search must be >= 1"));
369        }
370        if self.flat_to_hnsw == 0 {
371            return Err(Error::ConfigMismatch("flat_to_hnsw must be >= 1"));
372        }
373        Ok(())
374    }
375
376    /// Appends the fixed binary form of the config to `out` — the config
377    /// block of the snapshot. Layout, all little-endian, in
378    /// field-declaration order: `usize` fields as `u64`, `f32` fields as
379    /// their IEEE 754 bits, then `rrf_k`/`half_life_days`/`graph_depth` as
380    /// `u32`, `db_uuid` as a `u128`, then 8 reserved zero bytes; exactly
381    /// [`ENCODED_LEN`] bytes. Encoding is lossless and canonical (float bits
382    /// round-trip exactly).
383    pub fn encode(&self, out: &mut Vec<u8>) {
384        out.reserve(ENCODED_LEN);
385        for v in [
386            self.dim,
387            self.max_bytes,
388            self.max_text,
389            self.max_blob,
390            self.shards_facts,
391            self.shards_entities,
392            self.shards_edges,
393            self.shards_temporal,
394            self.shards_postings,
395            self.hnsw_m,
396            self.hnsw_m0,
397            self.hnsw_ef_construction,
398            self.hnsw_ef_search,
399            self.flat_to_hnsw,
400        ] {
401            out.extend_from_slice(&(v as u64).to_le_bytes());
402        }
403        for v in [
404            self.bm25_k1,
405            self.bm25_b,
406            self.w_bm25,
407            self.w_vec,
408            self.w_graph,
409            self.w_time,
410            self.w_recency,
411            self.graph_decay,
412            self.similar_cos,
413            self.similar_jaccard,
414        ] {
415            out.extend_from_slice(&v.to_le_bytes());
416        }
417        for v in [self.rrf_k, self.half_life_days, self.graph_depth] {
418            out.extend_from_slice(&v.to_le_bytes());
419        }
420        out.extend_from_slice(&self.db_uuid.to_le_bytes());
421        out.extend_from_slice(&[0u8; RESERVED_LEN]);
422    }
423
424    /// Decodes a config block written by [`Config::encode`] and runs
425    /// [`Config::validate`] on the result.
426    ///
427    /// The input is untrusted: a wrong length or nonzero reserved bytes are
428    /// [`Error::Corrupt`]; out-of-range field values surface as the same
429    /// [`Error::ConfigMismatch`] a hand-built config would get.
430    ///
431    /// All size fields are stored as fixed-width `u64`, so the block is
432    /// identical on 32-bit and 64-bit builds of the engine. A value that
433    /// overflows this platform's `usize` means the database was created
434    /// with limits only a 64-bit address space can hold (e.g. `max_bytes`
435    /// beyond 4 GiB on a wasm64 or native host) — the file is not corrupt,
436    /// this host is too small for it, hence [`Error::ConfigMismatch`].
437    pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
438        if bytes.len() != ENCODED_LEN {
439            return Err(Error::Corrupt("config block length mismatch"));
440        }
441        let mut at = 0usize;
442        let mut take_usize = || -> Result<usize, Error> {
443            let v = u64::from_le_bytes(bytes[at..at + U64_BYTES].try_into().unwrap());
444            at += U64_BYTES;
445            usize::try_from(v)
446                .map_err(|_| Error::ConfigMismatch("database requires a 64-bit address space"))
447        };
448        let dim = take_usize()?;
449        let max_bytes = take_usize()?;
450        let max_text = take_usize()?;
451        let max_blob = take_usize()?;
452        let shards_facts = take_usize()?;
453        let shards_entities = take_usize()?;
454        let shards_edges = take_usize()?;
455        let shards_temporal = take_usize()?;
456        let shards_postings = take_usize()?;
457        let hnsw_m = take_usize()?;
458        let hnsw_m0 = take_usize()?;
459        let hnsw_ef_construction = take_usize()?;
460        let hnsw_ef_search = take_usize()?;
461        let flat_to_hnsw = take_usize()?;
462        let mut at = F32S_AT;
463        let mut take_f32 = || {
464            let v = f32::from_le_bytes(bytes[at..at + F32_BYTES].try_into().unwrap());
465            at += F32_BYTES;
466            v
467        };
468        let bm25_k1 = take_f32();
469        let bm25_b = take_f32();
470        let w_bm25 = take_f32();
471        let w_vec = take_f32();
472        let w_graph = take_f32();
473        let w_time = take_f32();
474        let w_recency = take_f32();
475        let graph_decay = take_f32();
476        let similar_cos = take_f32();
477        let similar_jaccard = take_f32();
478        let mut at = U32S_AT;
479        let mut take_u32 = || {
480            let v = u32::from_le_bytes(bytes[at..at + U32_BYTES].try_into().unwrap());
481            at += U32_BYTES;
482            v
483        };
484        let rrf_k = take_u32();
485        let half_life_days = take_u32();
486        let graph_depth = take_u32();
487        let db_uuid = u128::from_le_bytes(bytes[DB_UUID_AT..RESERVED_AT].try_into().unwrap());
488        if bytes[RESERVED_AT..ENCODED_LEN] != [0u8; RESERVED_LEN] {
489            return Err(Error::Corrupt("reserved config bytes must be zero"));
490        }
491        let cfg = Self {
492            dim,
493            max_bytes,
494            max_text,
495            max_blob,
496            shards_facts,
497            shards_entities,
498            shards_edges,
499            shards_temporal,
500            shards_postings,
501            bm25_k1,
502            bm25_b,
503            rrf_k,
504            w_bm25,
505            w_vec,
506            w_graph,
507            w_time,
508            w_recency,
509            half_life_days,
510            graph_depth,
511            graph_decay,
512            similar_cos,
513            similar_jaccard,
514            hnsw_m,
515            hnsw_m0,
516            hnsw_ef_construction,
517            hnsw_ef_search,
518            flat_to_hnsw,
519            db_uuid,
520        };
521        cfg.validate()?;
522        Ok(cfg)
523    }
524}