Skip to main content

plugmem_core/
memory.rs

1//! The engine: data model state, verbs, journaling and replay
2//! (/03, /05).
3//!
4//! All state lives in the flat structures of `plugmem-arena`; every
5//! mutating verb journals itself through the caller's
6//! [`Storage`] and is replayed through the same
7//! internal `apply_*` path on open — the journal's ids are authoritative,
8//! so replay is deterministic and idempotent over a reapplied tail.
9//!
10//! The engine carries the structural sources (facts, entities, edges,
11//! tags, BM25, temporal) and — when `Config::dim > 0` — the vector layer:
12//! quantized vectors in a flat [`VecPool`], searched as a fourth recall
13//! source and used by similar-detection. `maintain` (compaction) still
14//! lands separately.
15
16use alloc::format;
17use alloc::string::String;
18use alloc::vec::Vec;
19
20use plugmem_arena::{
21    Arena, ArenaCfg, BlobHeap, BlobHeapCfg, BlobId, ChunkPool, ChunkPoolCfg, Interner, ListHandle,
22    ShardMode, TermId, key,
23};
24
25use crate::config::Config;
26use crate::error::Error;
27use crate::id::{EntityId, FactId, NONE_U32};
28use crate::index::IdListIndex;
29use crate::index::bm25::Bm25Index;
30use crate::index::hnsw::HnswGraph;
31use crate::index::vecpool::VecPool;
32use crate::journal::{JournalScan, Op, scan};
33use crate::model::{
34    EdgeSlot, EntityByName, EntityRecord, FactAux, FactRecord, TemporalSlot, VALID_TO_OPEN,
35    fact_flags,
36};
37use crate::storage::Storage;
38use crate::tokenizer::Tokenizer;
39
40/// Most recent same-entity facts examined by similar-detection.
41const SIMILAR_CANDIDATE_CAP: usize = 32;
42
43mod maintain;
44mod persist;
45mod recall;
46
47pub use maintain::MaintainReport;
48pub use recall::{RecallQuery, RecallResult, RecallScratch, RecalledEdge, RecalledFact, source};
49
50/// Input of `remember` and `revise`.
51#[derive(Clone, Copy, Debug)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize))]
53pub struct RememberInput<'a> {
54    /// Host timestamp, unix milliseconds.
55    pub now: u64,
56    /// Fact text, ≤ `Config::max_text` bytes.
57    pub text: &'a str,
58    /// Subject entity name; created lazily on first mention.
59    pub entity: Option<&'a str>,
60    /// Tags, verbatim strings, ≤ 32.
61    pub tags: &'a [&'a str],
62    /// `(rel, target_entity)` pairs, ≤ 16; edges go subject → target.
63    pub links: &'a [(&'a str, &'a str)],
64    /// Optional embedding, `len == Config::dim`; quantized on the way in.
65    /// Requires `dim > 0` and is dropped by the engine's own re-quantized
66    /// replay, so nothing float-nondeterministic reaches the state.
67    pub vector: Option<&'a [f32]>,
68    /// Validity start; defaults to `now`.
69    pub valid_from: Option<u64>,
70    /// Optional metadata as key→value pairs (UTF-8). Passed in any order; the
71    /// engine canonicalizes (sorts keys, rejects duplicates) and stores them as
72    /// one opaque blob (see [`crate::metadata`]). `None` or an empty slice = no
73    /// metadata. The engine never interprets the values.
74    pub metadata: Option<&'a [(&'a str, &'a str)]>,
75}
76
77impl<'a> RememberInput<'a> {
78    /// A minimal input: text only.
79    pub fn text(now: u64, text: &'a str) -> Self {
80        Self {
81            now,
82            text,
83            entity: None,
84            tags: &[],
85            links: &[],
86            vector: None,
87            valid_from: None,
88            metadata: None,
89        }
90    }
91}
92
93/// Input of `link`.
94#[derive(Clone, Copy, Debug)]
95#[cfg_attr(feature = "serde", derive(serde::Serialize))]
96pub struct LinkInput<'a> {
97    /// Host timestamp, unix milliseconds.
98    pub now: u64,
99    /// Source entity name (created lazily).
100    pub src: &'a str,
101    /// Relation term, verbatim (`"works_at"`, …).
102    pub rel: &'a str,
103    /// Destination entity name (created lazily).
104    pub dst: &'a str,
105    /// Optional provenance fact.
106    pub provenance: Option<FactId>,
107}
108
109/// Result of `remember`/`revise`.
110#[derive(Clone, Debug, PartialEq)]
111#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
112pub struct RememberOutcome {
113    /// The new fact's id.
114    pub id: FactId,
115    /// The subject entity (resolved or created), if one was named.
116    pub entity: Option<EntityId>,
117    /// Similar / potentially conflicting **live** facts, best match
118    /// first (≤ 8). The engine never revises on its own — it surfaces
119    /// the candidates, the agent judges: `revise`, keep both, or
120    /// `forget`.
121    pub similar: Vec<Similar>,
122}
123
124/// One similar-fact hint.
125#[derive(Clone, Copy, Debug, PartialEq)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127pub struct Similar {
128    /// The existing fact.
129    pub id: FactId,
130    /// Match strength (for the lexical detector: the Jaccard overlap of
131    /// the term sets, in `(similar_jaccard, 1]`).
132    pub score: f32,
133    /// What triggered the hint.
134    pub reason: SimilarReason,
135}
136
137/// Why a fact was flagged as similar.
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
140pub enum SimilarReason {
141    /// Same subject entity and a term-set overlap above the configured
142    /// Jaccard threshold.
143    LexicalOverlap,
144    /// Same subject entity and a quantized-vector cosine above the
145    /// configured `similar_cos` threshold.
146    VectorCosine,
147}
148
149/// A read view of one fact.
150#[derive(Clone, Copy, Debug)]
151#[cfg_attr(feature = "serde", derive(serde::Serialize))]
152pub struct FactView<'a> {
153    /// The raw record (temporality, flags, references).
154    pub record: FactRecord,
155    /// The fact text.
156    pub text: &'a str,
157}
158
159/// The per-fact content problem [`Memory::faulty_facts`] attributes to a
160/// fact — the salvage predicate `recover` uses. It mirrors the
161/// content checks [`Memory::verify`] runs, split out per fact so a caller can
162/// drop the individual bad records instead of failing the whole image.
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
165pub enum FactFault {
166    /// The fact's stored text is not valid UTF-8.
167    Text,
168    /// The fact is flagged with a vector but its slot is out of range or does
169    /// not name the fact back (the fact↔slot bijection is broken).
170    Vector,
171    /// The fact's metadata blob is out of range or does not decode to a
172    /// well-formed key→value map.
173    Metadata,
174}
175
176/// Engine size counters. Every field is an O(1) read; the
177/// struct is `#[non_exhaustive]` so later stages (database identity
178/// markers, HNSW state) can extend it without a breaking change.
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181#[non_exhaustive]
182pub struct Stats {
183    /// Fact records currently stored (live, closed, and tombstoned facts
184    /// awaiting `maintain`).
185    pub facts: usize,
186    /// Entities.
187    pub entities: usize,
188    /// Interned terms (tokens, tags, relations, normalized names).
189    pub terms: usize,
190    /// Directed edges (each `(src, rel, dst)` counted once; the mirrored
191    /// in-arena is an internal detail).
192    pub edges: usize,
193    /// Quantized vector slots.
194    pub vectors: usize,
195    /// The next fact id to be assigned. Ids below it are in use or burned
196    /// (forgotten and purged) — never reissued.
197    pub next_fact: u32,
198    /// The next entity id to be assigned.
199    pub next_entity: u32,
200    /// The database lineage identity ([`Config::db_uuid`]); `0` for an
201    /// unnamed database.
202    pub db_uuid: u128,
203    /// Total bytes held by the engine's pools (arenas, blob heaps, chunk
204    /// pools, the term dictionary and the vector pool).
205    pub pool_bytes: usize,
206}
207
208/// Report of an `open`: what the journal replay found.
209#[derive(Clone, Debug, Default, PartialEq, Eq)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
211pub struct OpenReport {
212    /// Journal records applied.
213    pub replayed: usize,
214    /// Journal records skipped as already contained in the snapshot.
215    pub skipped: usize,
216    /// A torn tail record was dropped (crash between appends).
217    pub truncated_tail: bool,
218}
219
220/// The memory engine. See the module docs for staging.
221///
222/// The lifetime `'a` is the provenance of the engine's byte pools. Every
223/// owned constructor (`new`/`open`/`from_bytes`) yields a `Memory<'static>`
224/// that owns its bytes; the read-only [`Memory::from_bytes_borrowed`] path
225/// borrows them from an mmap'd snapshot instead of copying.
226pub struct Memory<'a> {
227    cfg: Config,
228    // -- data model --
229    facts: Arena<'a, FactRecord>,
230    fact_aux: Arena<'a, FactAux>,
231    entities: Arena<'a, EntityRecord>,
232    by_name: Arena<'a, EntityByName>,
233    edges_out: Arena<'a, EdgeSlot>,
234    edges_in: Arena<'a, EdgeSlot>,
235    temporal: Arena<'a, TemporalSlot>,
236    /// Fact texts and canonical entity names.
237    texts: BlobHeap<'a>,
238    /// Per-fact metadata blobs (canonical key→value, see [`crate::metadata`]),
239    /// referenced by [`FactAux::meta`]. Empty and inert until a fact carries
240    /// metadata; kept out of `texts` so this cold pool stays non-resident on an
241    /// mmap'd base until `show`/`export` touches it.
242    metas: BlobHeap<'a>,
243    /// Terms: tokens, tags (verbatim), relation names, normalized entity
244    /// names.
245    terms: Interner<'a>,
246    /// Per-fact tag lists (`TermId` values), handles in [`FactAux`].
247    tag_lists: ChunkPool<'a>,
248    // -- indexes --
249    bm25: Bm25Index<'a>,
250    tags_idx: IdListIndex<'a>,
251    entity_facts: IdListIndex<'a>,
252    /// Quantized vectors (empty and inert when `cfg.dim == 0`).
253    vecs: VecPool<'a>,
254    /// HNSW graph over the pool; empty until `maintain` crosses
255    /// `Config::flat_to_hnsw`. Slots past its `indexed` mark are the flat
256    /// tail searched by scan.
257    hnsw: HnswGraph<'a>,
258    // -- id allocation (derived from the arenas on load) --
259    next_fact: u32,
260    next_entity: u32,
261    // -- reusable scratches --
262    tokenizer: Tokenizer,
263    tf_scratch: Vec<(u32, u8)>,
264    name_scratch: String,
265}
266
267impl<'a> Memory<'a> {
268    /// Creates an empty database.
269    ///
270    /// # Errors
271    ///
272    /// [`Error::ConfigMismatch`] for an invalid config (see
273    /// [`Config::validate`]).
274    pub fn new(cfg: Config) -> Result<Self, Error> {
275        cfg.validate()?;
276        let uni =
277            |shards: usize| ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(cfg.max_bytes);
278        let ord =
279            |shards: usize| ArenaCfg::new(shards, ShardMode::Ordered).with_max_bytes(cfg.max_bytes);
280        let blob = BlobHeapCfg::new()
281            .with_max_bytes(cfg.max_bytes)
282            .with_max_blob(cfg.max_blob);
283        Ok(Self {
284            facts: Arena::new(uni(cfg.shards_facts))?,
285            fact_aux: Arena::new(uni(cfg.shards_facts))?,
286            entities: Arena::new(uni(cfg.shards_entities))?,
287            by_name: Arena::new(ord(cfg.shards_entities))?,
288            edges_out: Arena::new(ord(cfg.shards_edges))?,
289            edges_in: Arena::new(ord(cfg.shards_edges))?,
290            temporal: Arena::new(ord(cfg.shards_temporal))?,
291            texts: BlobHeap::new(blob),
292            metas: BlobHeap::new(blob),
293            terms: Interner::new(blob),
294            tag_lists: ChunkPool::new(ChunkPoolCfg::new().with_max_bytes(cfg.max_bytes)),
295            bm25: Bm25Index::new(cfg.shards_postings, cfg.max_bytes)?,
296            tags_idx: IdListIndex::new(cfg.shards_postings, cfg.max_bytes)?,
297            entity_facts: IdListIndex::new(cfg.shards_entities, cfg.max_bytes)?,
298            vecs: VecPool::new(cfg.dim, cfg.max_bytes),
299            hnsw: HnswGraph::new(cfg.hnsw_m, cfg.hnsw_m0, cfg.max_bytes)?,
300            next_fact: 0,
301            next_entity: 0,
302            tokenizer: Tokenizer::new(),
303            tf_scratch: Vec::new(),
304            name_scratch: String::new(),
305            cfg,
306        })
307    }
308
309    /// Opens a database from a storage: journal replay over an empty
310    /// state (the snapshot fast-path lands with the section composition;
311    /// a non-empty snapshot is rejected rather than half-read).
312    pub fn open<S: Storage>(store: &mut S, cfg: Config) -> Result<(Self, OpenReport), Error> {
313        let snapshot = store
314            .read_snapshot()
315            .map_err(|e| Error::Storage(format!("{e:?}")))?;
316        let journal = store
317            .read_journal()
318            .map_err(|e| Error::Storage(format!("{e:?}")))?;
319        Self::from_bytes(snapshot.as_deref(), &journal, cfg)
320    }
321
322    /// Opens from raw bytes (the wasm path: the host already read them).
323    pub fn from_bytes(
324        snapshot: Option<&[u8]>,
325        journal: &[u8],
326        cfg: Config,
327    ) -> Result<(Self, OpenReport), Error> {
328        let mut mem = match snapshot {
329            Some(bytes) => Self::load_snapshot(bytes, cfg)?,
330            None => Self::new(cfg)?,
331        };
332        let report = mem.replay(journal)?;
333        Ok((mem, report))
334    }
335
336    /// Opens a database read-only over a borrowed snapshot: the
337    /// engine's byte pools borrow `snapshot` (typically an mmap'd file)
338    /// instead of copying it, so a large database residents only the pages
339    /// actually read. The returned engine is tied to `snapshot`'s lifetime.
340    ///
341    /// A **non-empty journal is rejected**: the read-only handle exposes no
342    /// write verbs, so a snapshot with un-checkpointed journal tail would open
343    /// stale. Checkpoint the database read-write once first — or use
344    /// [`Memory::from_bytes_overlay`], which replays the journal into the
345    /// overlay. The caller (the host) owns the map and the exclusive lock.
346    pub fn from_bytes_borrowed(
347        snapshot: &'a [u8],
348        journal: &[u8],
349        cfg: Config,
350    ) -> Result<Self, Error> {
351        let mem = Self::load_snapshot_borrowed(snapshot, cfg)?;
352        let JournalScan { entries, .. } = scan(journal)?;
353        if !entries.is_empty() {
354            return Err(Error::Invalid(
355                "read-only open requires a checkpointed (empty) journal",
356            ));
357        }
358        Ok(mem)
359    }
360
361    /// Opens a database read-**write** over a borrowed snapshot (the overlay
362    /// write path): like [`Memory::from_bytes_borrowed`] the byte
363    /// pools borrow `snapshot` instead of copying it, but the journal is
364    /// **replayed** and the engine stays fully mutable. Mutations do not clone
365    /// the borrowed base: the flat structures land appends in an owned tail and
366    /// copy only the pages they rewrite (per-page copy-on-write), so a
367    /// multi-gigabyte mapped database is opened and written to while resident
368    /// only in the pages it actually touches.
369    ///
370    /// This is the write sibling of [`Memory::from_bytes`] (which owns its
371    /// bytes) — the same `snapshot + journal replay`, over a borrowed base, and
372    /// it returns the same [`OpenReport`] describing the replay. The returned
373    /// engine is tied to `snapshot`'s lifetime; the caller (the host) owns the
374    /// map and the exclusive lock.
375    pub fn from_bytes_overlay(
376        snapshot: &'a [u8],
377        journal: &[u8],
378        cfg: Config,
379    ) -> Result<(Self, OpenReport), Error> {
380        let mut mem = Self::load_snapshot_borrowed(snapshot, cfg)?;
381        let report = mem.replay(journal)?;
382        Ok((mem, report))
383    }
384
385    /// Applies every journal record on top of the current state (
386    /// replay rules: assigned ids are authoritative; records whose
387    /// assigned id is already present are skipped, which makes reapplying
388    /// a tail idempotent).
389    fn replay(&mut self, journal: &[u8]) -> Result<OpenReport, Error> {
390        let JournalScan {
391            entries,
392            truncated_tail,
393        } = scan(journal)?;
394        let mut report = OpenReport {
395            truncated_tail,
396            ..OpenReport::default()
397        };
398        for entry in entries {
399            let op = Op::decode(entry.op, entry.payload)?;
400            match op {
401                Op::Remember {
402                    now,
403                    valid_from,
404                    entity,
405                    text,
406                    ref tags,
407                    ref links,
408                    ref vector,
409                    ref metadata,
410                    revises,
411                    assigned,
412                } => {
413                    if assigned.0 < self.next_fact {
414                        report.skipped += 1;
415                        continue;
416                    }
417                    if assigned.0 != self.next_fact {
418                        return Err(Error::Corrupt("journal fact ids are not contiguous"));
419                    }
420                    if !vector.is_empty() && vector.len() != self.cfg.dim {
421                        return Err(Error::Corrupt(
422                            "journal vector dimension disagrees with dim",
423                        ));
424                    }
425                    if let Some(target) = revises.some() {
426                        self.check_revisable(target)
427                            .map_err(|_| Error::Corrupt("journal revises an unrevisable fact"))?;
428                    }
429                    self.apply_remember(
430                        &RememberInput {
431                            now,
432                            text,
433                            entity,
434                            tags: &tags.to_vec(),
435                            links: &links.to_vec(),
436                            vector: (!vector.is_empty()).then_some(vector.as_slice()),
437                            valid_from: Some(valid_from),
438                            metadata: (!metadata.is_empty()).then_some(metadata.as_slice()),
439                        },
440                        revises,
441                    )?;
442                    if let Some(target) = revises.some() {
443                        self.close_target(target, valid_from);
444                    }
445                    report.replayed += 1;
446                }
447                Op::Forget { fact, .. } => {
448                    // Idempotent; a missing fact in a valid journal is
449                    // corruption, but re-tombstoning is fine.
450                    match self.apply_forget(fact) {
451                        Ok(_) => report.replayed += 1,
452                        Err(Error::NotFound(_)) => {
453                            return Err(Error::Corrupt("journal forgets an unknown fact"));
454                        }
455                        Err(e) => return Err(e),
456                    }
457                }
458                Op::Link {
459                    now,
460                    src,
461                    rel,
462                    dst,
463                    provenance,
464                } => {
465                    self.apply_link(now, src, rel, dst, provenance)?;
466                    report.replayed += 1;
467                }
468                Op::Maintain { .. } => {
469                    // Re-execute the compaction deterministically, so a
470                    // replayed image matches one snapshotted after a live
471                    // maintain byte for byte.
472                    self.replay_maintain()?;
473                    report.replayed += 1;
474                }
475            }
476        }
477        Ok(report)
478    }
479
480    /// Remembers a new fact.
481    pub fn remember<S: Storage>(
482        &mut self,
483        store: &mut S,
484        input: RememberInput<'_>,
485    ) -> Result<RememberOutcome, Error> {
486        self.validate_input(&input)?;
487        let mut outcome = self.apply_remember(&input, FactId::NONE)?;
488        self.find_similar(&mut outcome);
489        self.journal_remember(store, &input, FactId::NONE, outcome.id)?;
490        Ok(outcome)
491    }
492
493    /// Batch import: a sequence of remembers in one call, journaled
494    /// individually. `skip_similar` turns off the
495    /// similar-detection pass — imports don't need hints, and skipping
496    /// them makes a bulk load cheaper.
497    ///
498    /// Stops at the first error; already-applied inputs stay applied and
499    /// journaled (replay reproduces exactly the applied prefix).
500    pub fn remember_batch<S: Storage>(
501        &mut self,
502        store: &mut S,
503        inputs: &[RememberInput<'_>],
504        skip_similar: bool,
505    ) -> Result<Vec<RememberOutcome>, Error> {
506        let mut out = Vec::with_capacity(inputs.len());
507        for input in inputs {
508            self.validate_input(input)?;
509            let mut outcome = self.apply_remember(input, FactId::NONE)?;
510            if !skip_similar {
511                self.find_similar(&mut outcome);
512            }
513            self.journal_remember(store, input, FactId::NONE, outcome.id)?;
514            out.push(outcome);
515        }
516        Ok(out)
517    }
518
519    /// Lexical similar-detection: live facts of the same
520    /// entity whose term sets overlap the new fact's above the
521    /// `similar_jaccard` threshold. Bounded: the entity's most recent
522    /// [`SIMILAR_CANDIDATE_CAP`] facts are compared (a hub's full list is
523    /// not re-tokenized), candidate texts are tokenized against the
524    /// term-frequency scratch the new fact just filled.
525    fn find_similar(&mut self, outcome: &mut RememberOutcome) {
526        let Some(entity) = outcome.entity else { return };
527        // The new fact's term set is still in tf_scratch (apply_remember
528        // filled it); snapshot the term ids.
529        let new_terms: Vec<u32> = self.tf_scratch.iter().map(|&(t, _)| t).collect();
530        // The new fact's vector slot, if it has one (for the cosine signal).
531        let new_vec = self
532            .fact(outcome.id)
533            .filter(|r| r.has_vector())
534            .map(|r| r.vector);
535        if new_terms.is_empty() && new_vec.is_none() {
536            return;
537        }
538        // Most recent candidates of the entity (ring over the list).
539        let mut ring = [FactId::NONE; SIMILAR_CANDIDATE_CAP];
540        let mut n = 0usize;
541        for (fact, _) in self.entity_facts.entries(entity.0) {
542            if fact != outcome.id {
543                ring[n % SIMILAR_CANDIDATE_CAP] = fact;
544                n += 1;
545            }
546        }
547        let mut cand_terms: Vec<u32> = Vec::new();
548        for &fact in ring.iter().take(n.min(SIMILAR_CANDIDATE_CAP)) {
549            let Some(record) = self.fact(fact) else {
550                continue;
551            };
552            if record.is_tombstone() || record.is_closed() {
553                continue;
554            }
555
556            // Lexical signal: term-set Jaccard over the entity's text.
557            let mut lexical = None;
558            // Deferred validation: a load no longer scans the
559            // text pool, so an unreadable text simply yields no lexical signal.
560            if !new_terms.is_empty()
561                && let Ok(text) = core::str::from_utf8(self.texts.get(record.text))
562            {
563                cand_terms.clear();
564                let terms = &self.terms;
565                let cand = &mut cand_terms;
566                self.tokenizer.tokenize(text, &mut |token| {
567                    if let Some(term) = terms.lookup(token)
568                        && !cand.contains(&term.0)
569                    {
570                        cand.push(term.0);
571                    }
572                });
573                if !cand_terms.is_empty() {
574                    let both = cand_terms.iter().filter(|t| new_terms.contains(t)).count();
575                    let union = cand_terms.len() + new_terms.len() - both;
576                    let jaccard = both as f32 / union as f32;
577                    if jaccard > self.cfg.similar_jaccard {
578                        lexical = Some(jaccard);
579                    }
580                }
581            }
582
583            // Vector signal: quantized cosine when both facts carry one.
584            let mut vector = None;
585            if let (Some(a), true) = (new_vec, record.has_vector()) {
586                let cos = self.vecs.cosine_slots(a, record.vector);
587                if cos > self.cfg.similar_cos {
588                    vector = Some(cos);
589                }
590            }
591
592            // Keep the stronger signal; a tie prefers the lexical reason.
593            let best = match (lexical, vector) {
594                (Some(l), Some(v)) if v > l => Some((v, SimilarReason::VectorCosine)),
595                (Some(l), _) => Some((l, SimilarReason::LexicalOverlap)),
596                (None, Some(v)) => Some((v, SimilarReason::VectorCosine)),
597                (None, None) => None,
598            };
599            if let Some((score, reason)) = best {
600                outcome.similar.push(Similar {
601                    id: fact,
602                    score,
603                    reason,
604                });
605            }
606        }
607        outcome
608            .similar
609            .sort_unstable_by(|a, b| b.score.total_cmp(&a.score).then(a.id.cmp(&b.id)));
610        outcome.similar.truncate(8);
611    }
612
613    /// Revises `target`: closes its validity at the new fact's
614    /// `valid_from` and records the new fact with `revises = target`
615    /// (rule 2).
616    pub fn revise<S: Storage>(
617        &mut self,
618        store: &mut S,
619        target: FactId,
620        input: RememberInput<'_>,
621    ) -> Result<RememberOutcome, Error> {
622        self.validate_input(&input)?;
623        // Check first, mutate last: the target closes only after the new
624        // fact exists, so a mid-operation failure (capacity) never leaves
625        // a closed fact without its successor.
626        self.check_revisable(target)?;
627        let outcome = self.apply_remember(&input, target)?;
628        let valid_from = input.valid_from.unwrap_or(input.now);
629        self.close_target(target, valid_from);
630        self.journal_remember(store, &input, target, outcome.id)?;
631        Ok(outcome)
632    }
633
634    /// Tombstones a fact: it disappears from every query immediately, the
635    /// bytes go with the next `maintain` (rule 3). `Ok(false)`
636    /// when it was already tombstoned.
637    pub fn forget<S: Storage>(
638        &mut self,
639        store: &mut S,
640        now: u64,
641        id: FactId,
642    ) -> Result<bool, Error> {
643        let fresh = self.apply_forget(id)?;
644        let mut entry = Vec::new();
645        Op::Forget { now, fact: id }.encode(&mut entry);
646        store
647            .append_journal(&entry)
648            .map_err(|e| Error::Storage(format!("{e:?}")))?;
649        Ok(fresh)
650    }
651
652    /// Upserts a typed edge between two entities (created lazily);
653    /// re-linking an existing `(src, rel, dst)` updates its provenance.
654    pub fn link<S: Storage>(&mut self, store: &mut S, input: LinkInput<'_>) -> Result<(), Error> {
655        self.apply_link(
656            input.now,
657            input.src,
658            input.rel,
659            input.dst,
660            FactId::from_opt(input.provenance),
661        )?;
662        let mut entry = Vec::new();
663        Op::Link {
664            now: input.now,
665            src: input.src,
666            rel: input.rel,
667            dst: input.dst,
668            provenance: FactId::from_opt(input.provenance),
669        }
670        .encode(&mut entry);
671        store
672            .append_journal(&entry)
673            .map_err(|e| Error::Storage(format!("{e:?}")))?;
674        Ok(())
675    }
676
677    /// Returns a fact unless it is tombstoned (closed facts are
678    /// returned — their interval says so).
679    pub fn get(&self, id: FactId) -> Option<FactView<'_>> {
680        let record = self.fact(id)?;
681        if record.is_tombstone() {
682            return None;
683        }
684        // Deferred validation: a load no longer scans the text
685        // pool, so tolerate invalid bytes here — a corrupt text hides the fact
686        // rather than panicking. `verify()` reports it explicitly.
687        let text = core::str::from_utf8(self.texts.get(record.text)).ok()?;
688        Some(FactView { record, text })
689    }
690
691    /// Appends the tag terms of a fact to `out`. A tombstoned or unknown
692    /// fact contributes nothing.
693    pub fn tags_of(&self, id: FactId, out: &mut Vec<TermId>) {
694        let Some(record) = self.fact(id) else { return };
695        if record.is_tombstone() {
696            return;
697        }
698        let Some(aux) = self.fact_aux.get(&id.0.to_be_bytes()) else {
699            return;
700        };
701        for chunk in self.tag_lists.iter(&aux.tags) {
702            for raw in chunk.chunks_exact(4) {
703                out.push(TermId(u32::from_be_bytes(raw.try_into().unwrap())));
704            }
705        }
706    }
707
708    /// Fills `out` with the fact's metadata as key→value pairs in canonical
709    /// (ascending-key) order — the same order the blob was written and the same
710    /// order a host `BTreeMap` yields, so no layer sorts twice. Returns `true`
711    /// when the fact carries metadata (even an empty map is `false`, since an
712    /// empty map is stored as no blob).
713    ///
714    /// A tombstoned or unknown fact, a fact with no metadata blob, or a blob
715    /// that fails to decode (deferred validation, — `verify`
716    /// reports it) all leave `out` empty and return `false`; this accessor never
717    /// panics on bad bytes.
718    pub fn metadata_of<'s>(&'s self, id: FactId, out: &mut Vec<(&'s str, &'s str)>) -> bool {
719        out.clear();
720        let Some(record) = self.fact(id) else {
721            return false;
722        };
723        if record.is_tombstone() {
724            return false;
725        }
726        let Some(aux) = self.fact_aux.get(&id.0.to_be_bytes()) else {
727            return false;
728        };
729        if aux.meta.0 == NONE_U32 || aux.meta.0 >= self.metas.len() as u32 {
730            return false;
731        }
732        crate::metadata::decode(self.metas.get(aux.meta), out).is_ok() && !out.is_empty()
733    }
734
735    /// Resolves an entity by name (normalized), without creating it.
736    pub fn entity(&mut self, name: &str) -> Option<EntityId> {
737        let mut norm = core::mem::take(&mut self.name_scratch);
738        normalize_name(&mut self.tokenizer, name, &mut norm);
739        let found = if norm.is_empty() {
740            None
741        } else {
742            // Only resolve an *existing* term: interning would create it.
743            self.lookup_entity_by_norm(&norm)
744        };
745        self.name_scratch = norm;
746        found
747    }
748
749    /// Resolves the string behind a term id (tags, relations).
750    pub fn term(&self, id: TermId) -> &str {
751        self.terms.resolve(id)
752    }
753
754    /// The canonical (verbatim) name of an entity, or `None` for an
755    /// unknown id. The read-side inverse of [`Memory::entity`]: it lets a
756    /// host export a fact with its subject *name* instead of the internal
757    /// [`EntityId`] carried by [`FactRecord`].
758    pub fn entity_name(&self, id: EntityId) -> Option<&str> {
759        let record = self.entities.get(&id.0.to_be_bytes())?;
760        // Tolerate invalid bytes (deferred validation): an
761        // unreadable name reads as `None`, never a panic.
762        core::str::from_utf8(self.texts.get(record.name)).ok()
763    }
764
765    /// Number of fact records currently stored (tombstoned facts count
766    /// until `maintain` removes them). Purged ids stay burned, so this can
767    /// be below [`Stats::next_fact`].
768    pub fn facts_len(&self) -> usize {
769        self.facts.len()
770    }
771
772    /// Number of entities.
773    pub fn entities_len(&self) -> usize {
774        self.entities.len()
775    }
776
777    /// The engine configuration.
778    pub fn cfg(&self) -> &Config {
779        &self.cfg
780    }
781
782    /// Size counters of the engine. O(1).
783    pub fn stats(&self) -> Stats {
784        Stats {
785            facts: self.facts.len(),
786            entities: self.entities.len(),
787            terms: self.terms.len(),
788            edges: self.edges_out.len(),
789            vectors: self.vecs.len(),
790            next_fact: self.next_fact,
791            next_entity: self.next_entity,
792            db_uuid: self.cfg.db_uuid,
793            pool_bytes: self.facts.pool_bytes()
794                + self.fact_aux.pool_bytes()
795                + self.entities.pool_bytes()
796                + self.by_name.pool_bytes()
797                + self.edges_out.pool_bytes()
798                + self.edges_in.pool_bytes()
799                + self.temporal.pool_bytes()
800                + self.texts.pool_bytes()
801                + self.terms.pool_bytes()
802                + self.tag_lists.pool_bytes()
803                + self.bm25.pool_bytes()
804                + self.tags_idx.pool_bytes()
805                + self.entity_facts.pool_bytes()
806                + self.vecs.pool_bytes()
807                + self.hnsw.pool_bytes(),
808        }
809    }
810
811    // ---- internals ----
812
813    fn fact(&self, id: FactId) -> Option<FactRecord> {
814        self.facts.get(&id.0.to_be_bytes())
815    }
816
817    /// Size-limit checks shared by remember and revise.
818    fn validate_input(&self, input: &RememberInput<'_>) -> Result<(), Error> {
819        if input.text.len() > self.cfg.max_text {
820            return Err(Error::TooLarge {
821                what: "text",
822                len: input.text.len(),
823                max: self.cfg.max_text,
824            });
825        }
826        if input.tags.len() > 32 {
827            return Err(Error::TooLarge {
828                what: "tags",
829                len: input.tags.len(),
830                max: 32,
831            });
832        }
833        if input.links.len() > 16 {
834            return Err(Error::TooLarge {
835                what: "links",
836                len: input.links.len(),
837                max: 16,
838            });
839        }
840        if input.tags.iter().any(|t| t.is_empty()) {
841            return Err(Error::Invalid("empty tag"));
842        }
843        if !input.links.is_empty() && input.entity.is_none() {
844            return Err(Error::Invalid("links require a subject entity"));
845        }
846        if let Some(v) = input.vector {
847            if self.cfg.dim == 0 {
848                return Err(Error::Invalid("vector given but dim is 0"));
849            }
850            if v.len() != self.cfg.dim {
851                return Err(Error::DimMismatch {
852                    got: v.len(),
853                    want: self.cfg.dim,
854                });
855            }
856        }
857        Ok(())
858    }
859
860    /// `Ok` when `target` exists, is not tombstoned and not yet closed.
861    fn check_revisable(&self, target: FactId) -> Result<(), Error> {
862        let record = self.fact(target).ok_or(Error::NotFound(target))?;
863        if record.is_tombstone() {
864            return Err(Error::NotFound(target));
865        }
866        if record.is_closed() {
867            return Err(Error::AlreadyClosed(target));
868        }
869        Ok(())
870    }
871
872    /// Closes a revision target (validity ends where the successor
873    /// starts). The caller checked [`Memory::check_revisable`] first.
874    fn close_target(&mut self, target: FactId, valid_to: u64) {
875        let record = self.fact(target).expect("checked revisable");
876        let payload = self
877            .facts
878            .payload_mut(&target.0.to_be_bytes())
879            .expect("record fetched above");
880        // Payload offsets = slot offsets - KEY_LEN(4): flags at 4..6,
881        // valid_to at 36..44.
882        let flags = record.flags | fact_flags::CLOSED;
883        payload[4..6].copy_from_slice(&flags.to_be_bytes());
884        payload[36..44].copy_from_slice(&valid_to.to_be_bytes());
885    }
886
887    /// The one execution path of a new fact — called by the public verbs
888    /// and by replay with identical effects.
889    fn apply_remember(
890        &mut self,
891        input: &RememberInput<'_>,
892        revises: FactId,
893    ) -> Result<RememberOutcome, Error> {
894        let id = FactId(self.next_fact);
895        let entity = match input.entity {
896            Some(name) => Some(self.resolve_or_create_entity(name, input.now)?),
897            None => None,
898        };
899        let text_id = self.texts.push(input.text.as_bytes())?;
900
901        // Tokenize into (term, tf) pairs in the reusable scratch.
902        let mut tfs = core::mem::take(&mut self.tf_scratch);
903        tfs.clear();
904        let terms = &mut self.terms;
905        let mut intern_err = None;
906        self.tokenizer.tokenize(input.text, &mut |token| {
907            if intern_err.is_some() {
908                return;
909            }
910            match terms.intern(token) {
911                Ok(term) => match tfs.iter_mut().find(|(t, _)| *t == term.0) {
912                    Some((_, tf)) => *tf = tf.saturating_add(1),
913                    None => tfs.push((term.0, 1)),
914                },
915                Err(e) => intern_err = Some(e),
916            }
917        });
918        if let Some(e) = intern_err {
919            self.tf_scratch = tfs;
920            return Err(Error::Arena(e));
921        }
922        self.bm25.index_doc(id, &tfs)?;
923        self.tf_scratch = tfs;
924
925        // Metadata: canonicalized (keys sorted, dups rejected) and stored as one
926        // opaque blob; an absent or empty map leaves the sentinel. Pushed before
927        // the fact record so a capacity/validation failure aborts the whole op.
928        let meta = match input.metadata {
929            Some(pairs) if !pairs.is_empty() => {
930                self.metas.push(&crate::metadata::encode(pairs)?)?
931            }
932            _ => BlobId(NONE_U32),
933        };
934
935        // Tags: interned verbatim, deduplicated, listed on the fact and
936        // inverted.
937        let mut aux = FactAux {
938            id,
939            tags: ListHandle::EMPTY,
940            meta,
941        };
942        let mut seen_tags: [u32; 32] = [NONE_U32; 32];
943        let mut seen_cnt = 0usize;
944        for tag in input.tags {
945            let term = self.terms.intern(tag)?;
946            if seen_tags[..seen_cnt].contains(&term.0) {
947                continue;
948            }
949            seen_tags[seen_cnt] = term.0;
950            seen_cnt += 1;
951            self.tag_lists.push(&mut aux.tags, &term.0.to_be_bytes())?;
952            self.tags_idx.push(term.0, id, 0)?;
953        }
954        self.fact_aux.insert(&aux)?;
955
956        // Links: subject → target edges with this fact as provenance.
957        if let Some(src) = entity {
958            for &(rel, dst_name) in input.links {
959                let dst = self.resolve_or_create_entity(dst_name, input.now)?;
960                let rel = self.terms.intern(rel)?;
961                self.upsert_edge(src, rel, dst, id)?;
962            }
963            self.entity_facts.push(src.0, id, 0)?;
964        }
965
966        // Vector: quantized into the flat pool; the fact keeps the slot
967        // index. Pushed before the record so a capacity failure aborts the
968        // whole op (replay rebuilds consistently, like the other indexes).
969        let (vector, flags) = match input.vector {
970            Some(v) => (self.vecs.push(id, v)?, fact_flags::HAS_VECTOR),
971            None => (NONE_U32, 0),
972        };
973
974        let recorded_at = input.now;
975        let valid_from = input.valid_from.unwrap_or(input.now);
976        self.facts.insert(&FactRecord {
977            id,
978            entity: EntityId::from_opt(entity),
979            flags,
980            kind: 0,
981            text: text_id,
982            vector,
983            revises,
984            recorded_at,
985            valid_from,
986            valid_to: VALID_TO_OPEN,
987        })?;
988        self.temporal.insert(&TemporalSlot {
989            recorded_at,
990            fact: id,
991        })?;
992        self.next_fact += 1;
993        Ok(RememberOutcome {
994            id,
995            entity,
996            similar: Vec::new(),
997        })
998    }
999
1000    fn apply_forget(&mut self, id: FactId) -> Result<bool, Error> {
1001        let record = self.fact(id).ok_or(Error::NotFound(id))?;
1002        if record.is_tombstone() {
1003            return Ok(false);
1004        }
1005        let payload = self
1006            .facts
1007            .payload_mut(&id.0.to_be_bytes())
1008            .expect("record fetched above");
1009        let flags = record.flags | fact_flags::TOMBSTONE;
1010        payload[4..6].copy_from_slice(&flags.to_be_bytes());
1011        Ok(true)
1012    }
1013
1014    fn apply_link(
1015        &mut self,
1016        now: u64,
1017        src: &str,
1018        rel: &str,
1019        dst: &str,
1020        provenance: FactId,
1021    ) -> Result<(), Error> {
1022        let src = self.resolve_or_create_entity(src, now)?;
1023        let dst = self.resolve_or_create_entity(dst, now)?;
1024        let rel = self.terms.intern(rel)?;
1025        self.upsert_edge(src, rel, dst, provenance)
1026    }
1027
1028    fn upsert_edge(
1029        &mut self,
1030        src: EntityId,
1031        rel: TermId,
1032        dst: EntityId,
1033        fact: FactId,
1034    ) -> Result<(), Error> {
1035        for (arena, a, b) in [
1036            (&mut self.edges_out, src, dst),
1037            (&mut self.edges_in, dst, src),
1038        ] {
1039            let slot = EdgeSlot { a, rel, b, fact };
1040            if !arena.insert(&slot)? {
1041                let mut kb = [0u8; 12];
1042                key::write_u32(&mut kb, a.0);
1043                key::write_u32(&mut kb[4..], rel.0);
1044                key::write_u32(&mut kb[8..], b.0);
1045                let payload = arena.payload_mut(&kb).expect("insert reported a duplicate");
1046                payload.copy_from_slice(&fact.0.to_be_bytes());
1047            }
1048        }
1049        Ok(())
1050    }
1051
1052    /// Looks an entity up by its already-normalized name (read-only:
1053    /// neither the vocabulary nor the arenas change on a miss).
1054    fn lookup_entity_by_norm(&self, norm: &str) -> Option<EntityId> {
1055        let term = self.terms.lookup(norm)?;
1056        let mut from = [0u8; 8];
1057        key::write_u32(&mut from, term.0);
1058        let mut to = [0u8; 8];
1059        key::write_u32(&mut to, term.0);
1060        to[4..].copy_from_slice(&u32::MAX.to_be_bytes());
1061        self.by_name.range(&from, &to).next().map(|e| e.id)
1062    }
1063
1064    fn resolve_or_create_entity(&mut self, name: &str, now: u64) -> Result<EntityId, Error> {
1065        let mut norm = core::mem::take(&mut self.name_scratch);
1066        normalize_name(&mut self.tokenizer, name, &mut norm);
1067        if norm.is_empty() {
1068            self.name_scratch = norm;
1069            return Err(Error::Invalid("entity name has no indexable characters"));
1070        }
1071        let result = (|| {
1072            if let Some(found) = self.lookup_entity_by_norm(&norm) {
1073                return Ok(found);
1074            }
1075            let term = self.terms.intern(&norm)?;
1076            let id = EntityId(self.next_entity);
1077            let name_id = self.texts.push(name.as_bytes())?;
1078            self.entities.insert(&EntityRecord {
1079                id,
1080                name: name_id,
1081                name_term: term,
1082                created_at: now,
1083                flags: 0,
1084            })?;
1085            self.by_name.insert(&EntityByName {
1086                name_term: term,
1087                id,
1088            })?;
1089            self.next_entity += 1;
1090            Ok(id)
1091        })();
1092        self.name_scratch = norm;
1093        result
1094    }
1095
1096    fn journal_remember<S: Storage>(
1097        &mut self,
1098        store: &mut S,
1099        input: &RememberInput<'_>,
1100        revises: FactId,
1101        assigned: FactId,
1102    ) -> Result<(), Error> {
1103        let mut entry = Vec::new();
1104        Op::Remember {
1105            now: input.now,
1106            valid_from: input.valid_from.unwrap_or(input.now),
1107            entity: input.entity,
1108            text: input.text,
1109            tags: input.tags.to_vec(),
1110            links: input.links.to_vec(),
1111            vector: input.vector.map(<[f32]>::to_vec).unwrap_or_default(),
1112            metadata: input.metadata.map(<[_]>::to_vec).unwrap_or_default(),
1113            revises,
1114            assigned,
1115        }
1116        .encode(&mut entry);
1117        store
1118            .append_journal(&entry)
1119            .map_err(|e| Error::Storage(format!("{e:?}")))
1120    }
1121}
1122
1123impl core::fmt::Debug for Memory<'_> {
1124    /// Summary only — the contents are the user's memory, not ours to
1125    /// print.
1126    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1127        f.debug_struct("Memory")
1128            .field("facts", &self.facts.len())
1129            .field("entities", &self.entities.len())
1130            .field("terms", &self.terms.len())
1131            .finish()
1132    }
1133}
1134
1135/// Normalizes an entity name: its tokens joined by single spaces
1136/// ("  Проект   PlugMem " → "проект plugmem"). Deterministic and aligned
1137/// with search-time tokenization.
1138fn normalize_name(tokenizer: &mut Tokenizer, name: &str, out: &mut String) {
1139    out.clear();
1140    tokenizer.tokenize(name, &mut |token| {
1141        if !out.is_empty() {
1142            out.push(' ');
1143        }
1144        out.push_str(token);
1145    });
1146}