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}