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, ToString};
18use alloc::vec::Vec;
19
20use plugmem_arena::{
21    Arena, ArenaCfg, BlobHeap, BlobHeapCfg, BlobId, ChunkPool, ChunkPoolCfg, Interner, ListHandle,
22    ShardMode, Slot, TermId, key,
23};
24
25use crate::config::Config;
26use crate::error::Error;
27use crate::id::{EdgeId, 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    EdgeHistorySlot, EdgeSlot, EntityByName, EntityRecord, FactAux, FactRecord, TemporalSlot,
35    VALID_TO_OPEN, close_edge_history_payload, edge_history_key, edge_key, fact_flags,
36};
37use crate::storage::Storage;
38use crate::tokenizer::Tokenizer;
39
40use maintain::TOKENIZER_INDEX_VERSION;
41
42/// Most recent same-entity facts examined by similar-detection.
43const SIMILAR_CANDIDATE_CAP: usize = 32;
44
45mod maintain;
46mod migrations;
47mod persist;
48mod recall;
49mod reembed;
50mod shards;
51mod tags;
52
53pub use maintain::{MaintainReport, MaintenanceMode, MaintenanceOptions};
54pub use recall::{RecallQuery, RecallResult, RecallScratch, RecalledEdge, RecalledFact, source};
55pub use reembed::{ReembedError, ReembedReport};
56pub use shards::ShardLayout;
57pub use tags::{DEFAULT_TAG_PAGE_LIMIT, MAX_TAG_PAGE_LIMIT, TagPage, TagQuery, TagSummary};
58
59/// Maximum UTF-8 byte length of a persisted embedding-space identity.
60pub const MAX_VECTOR_SPACE_ID_BYTES: usize = 256;
61
62/// Input of `remember` and `revise`.
63#[derive(Clone, Copy, Debug)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize))]
65pub struct RememberInput<'a> {
66    /// Host timestamp, unix milliseconds.
67    pub now: u64,
68    /// Fact text, ≤ `Config::max_text` bytes.
69    pub text: &'a str,
70    /// Subject entity name; created lazily on first mention.
71    pub entity: Option<&'a str>,
72    /// Tags, verbatim strings, ≤ 32.
73    pub tags: &'a [&'a str],
74    /// `(rel, target_entity)` pairs, ≤ 16; edges go subject → target.
75    pub links: &'a [(&'a str, &'a str)],
76    /// Optional embedding, `len == Config::dim`; quantized on the way in.
77    /// Requires `dim > 0` and is dropped by the engine's own re-quantized
78    /// replay, so nothing float-nondeterministic reaches the state.
79    pub vector: Option<&'a [f32]>,
80    /// Validity start; defaults to `now`.
81    pub valid_from: Option<u64>,
82    /// Optional metadata as key→value pairs (UTF-8). Passed in any order; the
83    /// engine canonicalizes (sorts keys, rejects duplicates) and stores them as
84    /// one opaque blob (see `crate::metadata`). `None` or an empty slice = no
85    /// metadata. The engine never interprets the values.
86    pub metadata: Option<&'a [(&'a str, &'a str)]>,
87}
88
89impl<'a> RememberInput<'a> {
90    /// A minimal input: text only.
91    pub fn text(now: u64, text: &'a str) -> Self {
92        Self {
93            now,
94            text,
95            entity: None,
96            tags: &[],
97            links: &[],
98            vector: None,
99            valid_from: None,
100            metadata: None,
101        }
102    }
103}
104
105/// Input of `link`.
106#[derive(Clone, Copy, Debug)]
107#[cfg_attr(feature = "serde", derive(serde::Serialize))]
108pub struct LinkInput<'a> {
109    /// Host timestamp, unix milliseconds.
110    pub now: u64,
111    /// Source entity name (created lazily).
112    pub src: &'a str,
113    /// Relation term, verbatim (`"works_at"`, …).
114    pub rel: &'a str,
115    /// Destination entity name (created lazily).
116    pub dst: &'a str,
117    /// Optional provenance fact.
118    pub provenance: Option<FactId>,
119}
120
121/// Input of `unlink`.
122#[derive(Clone, Copy, Debug)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize))]
124pub struct UnlinkInput<'a> {
125    /// Host timestamp, unix milliseconds.
126    pub now: u64,
127    /// Source entity name.
128    pub src: &'a str,
129    /// Relation term, verbatim.
130    pub rel: &'a str,
131    /// Destination entity name.
132    pub dst: &'a str,
133}
134
135/// Result of `remember`/`revise`.
136#[derive(Clone, Debug, PartialEq)]
137#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
138pub struct RememberOutcome {
139    /// The new fact's id.
140    pub id: FactId,
141    /// The subject entity (resolved or created), if one was named.
142    pub entity: Option<EntityId>,
143    /// Similar / potentially conflicting **live** facts, best match
144    /// first (≤ 8). The engine never revises on its own — it surfaces
145    /// the candidates, the agent judges: `revise`, keep both, or
146    /// `forget`.
147    pub similar: Vec<Similar>,
148}
149
150/// Result of [`Memory::remember_guarded`].
151///
152/// The two variants are intentionally disjoint: a blocked call has no fact id
153/// because it did not allocate one, mutate an index, or append a journal
154/// record. A caller that decides the candidates are compatible can make that
155/// choice explicit by following with ordinary [`Memory::remember`].
156///
157/// `Stored` additionally reports whether a check happened at all - see
158/// [`GuardedRememberOutcome::Stored::checked`]. Without it the two answers
159/// "nothing similar was found" and "nothing could be compared" are the same
160/// value, and a caller reaching for this verb specifically to avoid duplicates
161/// has no way to learn it did not get one.
162#[derive(Clone, Debug, PartialEq)]
163#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
164#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
165pub enum GuardedRememberOutcome {
166    /// No candidate crossed the configured similarity thresholds; the fact was
167    /// stored normally.
168    Stored {
169        /// The committed fact.
170        outcome: RememberOutcome,
171        /// Whether the similarity detector actually had candidates to compare
172        /// against.
173        ///
174        /// `false` means the write was stored **unguarded**: the detector is
175        /// scoped to the fact's entity and walks that entity's most recent
176        /// live facts, so an input carrying no `entity` has no candidate set
177        /// and cannot block anything, now or after any number of later
178        /// writes. The fact is stored, exactly as an ordinary
179        /// [`Memory::remember`] would have stored it.
180        ///
181        /// `true` says a comparison took place, including the case where the
182        /// entity is new and its candidate set was empty - that is a real
183        /// "nothing similar is stored", and the same call will be compared
184        /// properly from the entity's second fact onwards.
185        ///
186        /// This is not a failure and not a fallback: comparing against every
187        /// live fact instead is precisely the unbounded work the per-entity
188        /// candidate cap exists to avoid. It is reported because the
189        /// alternative is silence, and silence here reads as "checked, and it
190        /// was clear".
191        checked: bool,
192    },
193    /// At least one live same-entity fact crossed a similarity threshold; no
194    /// mutation was made.
195    Blocked {
196        /// Similar / potentially conflicting live facts, best match first
197        /// (at most eight).
198        similar: Vec<Similar>,
199    },
200}
201
202/// Result of removing one tag from every current fact.
203#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
204#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
205pub struct RemoveTagReport {
206    /// Current facts superseded by an otherwise-identical revision without
207    /// the tag.
208    pub affected: u32,
209}
210
211/// One similar-fact hint.
212#[derive(Clone, Copy, Debug, PartialEq)]
213#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
214pub struct Similar {
215    /// The existing fact.
216    pub id: FactId,
217    /// Match strength (for the lexical detector: the Jaccard overlap of
218    /// the term sets, in `(similar_jaccard, 1]`).
219    pub score: f32,
220    /// What triggered the hint.
221    pub reason: SimilarReason,
222}
223
224/// Why a fact was flagged as similar.
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
227pub enum SimilarReason {
228    /// Same subject entity and a term-set overlap above the configured
229    /// Jaccard threshold.
230    LexicalOverlap,
231    /// Same subject entity and a quantized-vector cosine above the
232    /// configured `similar_cos` threshold.
233    VectorCosine,
234}
235
236/// Reusable write-side buffers for similarity detection. Kept outside the
237/// persistent pools: after warm-up guarded writes do not allocate scratch on
238/// every call, and none of these bytes enters a snapshot.
239#[derive(Debug, Default)]
240struct SimilarityScratch {
241    /// Incoming terms already present in the interner.
242    new_terms: Vec<u32>,
243    /// Unknown incoming tokens concatenated for exact deduplication without
244    /// growing the interner during a read-only guard check.
245    unknown: String,
246    /// Byte ranges in `unknown`, one per distinct unknown token.
247    unknown_ranges: Vec<(usize, usize)>,
248    /// Candidate term ids, reused across the bounded scan.
249    candidate_terms: Vec<u32>,
250    /// Quantized incoming vector slot, never appended to the vector pool.
251    vector: Vec<u8>,
252}
253
254#[derive(Clone, Copy)]
255enum SimilarVector<'s> {
256    None,
257    Stored(u32),
258    Encoded(&'s [u8]),
259}
260
261#[derive(Clone, Copy)]
262struct SimilarityQuery<'s> {
263    entity: EntityId,
264    exclude: Option<FactId>,
265    terms: &'s [u32],
266    term_count: usize,
267    vector: SimilarVector<'s>,
268}
269
270/// A read view of one fact.
271#[derive(Clone, Copy, Debug)]
272#[cfg_attr(feature = "serde", derive(serde::Serialize))]
273pub struct FactView<'a> {
274    /// The raw record (temporality, flags, references).
275    pub record: FactRecord,
276    /// The fact text.
277    pub text: &'a str,
278}
279
280/// The per-fact content problem [`Memory::faulty_facts`] attributes to a
281/// fact — the salvage predicate `recover` uses. It mirrors the
282/// content checks [`Memory::verify`] runs, split out per fact so a caller can
283/// drop the individual bad records instead of failing the whole image.
284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
286pub enum FactFault {
287    /// The fact's stored text is not valid UTF-8.
288    Text,
289    /// The fact is flagged with a vector but its slot is out of range or does
290    /// not name the fact back (the fact↔slot bijection is broken).
291    Vector,
292    /// The fact's metadata blob is out of range or does not decode to a
293    /// well-formed key→value map.
294    Metadata,
295}
296
297/// Engine size counters. Every field is an O(1) read; the
298/// struct is `#[non_exhaustive]` so later stages (database identity
299/// markers, HNSW state) can extend it without a breaking change.
300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
302#[non_exhaustive]
303pub struct Stats {
304    /// Fact records currently stored (live, closed, and tombstoned facts
305    /// awaiting `maintain`).
306    pub facts: usize,
307    /// Entities.
308    pub entities: usize,
309    /// Interned terms (tokens, tags, relations, normalized names).
310    pub terms: usize,
311    /// Directed edges (each `(src, rel, dst)` counted once; the mirrored
312    /// in-arena is an internal detail).
313    pub edges: usize,
314    /// Historical edge versions, including closed versions.
315    pub edge_versions: usize,
316    /// Quantized vector slots.
317    pub vectors: usize,
318    /// Tombstoned fact records awaiting physical purge.
319    pub tombstones: usize,
320    /// Vector slots already covered by the HNSW graph; slots after this are
321    /// searched as the flat tail.
322    pub hnsw_indexed: u32,
323    /// The next fact id to be assigned. Ids below it are in use or burned
324    /// (forgotten and purged) — never reissued.
325    pub next_fact: u32,
326    /// The next entity id to be assigned.
327    pub next_entity: u32,
328    /// The next edge-version id to be assigned.
329    pub next_edge: u32,
330    /// The database lineage identity ([`Config::db_uuid`]); `0` for an
331    /// unnamed database.
332    pub db_uuid: u128,
333    /// Total bytes held by the engine's pools (arenas, blob heaps, chunk
334    /// pools, the term dictionary and the vector pool).
335    pub pool_bytes: usize,
336    /// How the arenas are currently sharded.
337    ///
338    /// The engine chooses this from what it holds and moves it during
339    /// `maintain`, so it is state to observe rather than a setting to pick —
340    /// see [`Config::shards_facts`](crate::Config::shards_facts).
341    pub shards: ShardLayout,
342}
343
344/// Report of an `open`: what the journal replay found.
345#[derive(Clone, Debug, Default, PartialEq, Eq)]
346#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
347pub struct OpenReport {
348    /// Journal records applied.
349    pub replayed: usize,
350    /// Journal records skipped as already contained in the snapshot.
351    pub skipped: usize,
352    /// A torn tail record was dropped (crash between appends).
353    pub truncated_tail: bool,
354}
355
356/// The memory engine. See the module docs for staging.
357///
358/// The lifetime `'a` is the provenance of the engine's byte pools. Every
359/// owned constructor (`new`/`open`/`from_bytes`) yields a `Memory<'static>`
360/// that owns its bytes; the read-only [`Memory::from_bytes_borrowed`] path
361/// borrows them from an mmap'd snapshot instead of copying.
362pub struct Memory<'a> {
363    cfg: Config,
364    // -- data model --
365    facts: Arena<'a, FactRecord>,
366    fact_aux: Arena<'a, FactAux>,
367    entities: Arena<'a, EntityRecord>,
368    by_name: Arena<'a, EntityByName>,
369    edges_out: Arena<'a, EdgeSlot>,
370    edges_in: Arena<'a, EdgeSlot>,
371    edges_hist_out: Arena<'a, EdgeHistorySlot>,
372    edges_hist_in: Arena<'a, EdgeHistorySlot>,
373    temporal: Arena<'a, TemporalSlot>,
374    /// Fact texts and canonical entity names.
375    texts: BlobHeap<'a>,
376    /// Per-fact metadata blobs (canonical key→value, see `crate::metadata`),
377    /// referenced by [`FactAux::meta`]. Empty and inert until a fact carries
378    /// metadata; kept out of `texts` so this cold pool stays non-resident on an
379    /// mmap'd base until `show`/`export` touches it.
380    metas: BlobHeap<'a>,
381    /// Terms: tokens, tags (verbatim), relation names, normalized entity
382    /// names.
383    terms: Interner<'a>,
384    /// Per-fact tag lists (`TermId` values), handles in [`FactAux`].
385    tag_lists: ChunkPool<'a>,
386    // -- indexes --
387    bm25: Bm25Index<'a>,
388    tags_idx: IdListIndex<'a>,
389    /// Current tag counts: merged snapshot base plus changes since it.
390    tag_catalog: tags::TagCatalog,
391    entity_facts: IdListIndex<'a>,
392    /// Quantized vectors (empty and inert when `cfg.dim == 0`).
393    vecs: VecPool<'a>,
394    /// HNSW graph over the pool; empty until `maintain` crosses
395    /// `Config::flat_to_hnsw`. Slots past its `indexed` mark are the flat
396    /// tail searched by scan.
397    hnsw: HnswGraph<'a>,
398    /// Human-readable identity of the model/vector space that produced every
399    /// slot in `vecs`. `None` is legacy/untracked state, never permission to
400    /// mix a configured model into a non-empty pool.
401    vector_space: Option<String>,
402    // -- id allocation (derived from the arenas on load) --
403    next_fact: u32,
404    next_entity: u32,
405    next_edge: u32,
406    // -- maintenance state --
407    tombstones: usize,
408    bm25_tokenizer_version: u32,
409    // -- reusable scratches --
410    tokenizer: Tokenizer,
411    tf_scratch: Vec<(u32, u8)>,
412    name_scratch: String,
413    similarity_scratch: SimilarityScratch,
414}
415
416impl<'a> Memory<'a> {
417    /// Creates an empty database.
418    ///
419    /// # Errors
420    ///
421    /// [`Error::ConfigMismatch`] for an invalid config (see
422    /// [`Config::validate`]).
423    pub fn new(cfg: Config) -> Result<Self, Error> {
424        cfg.validate()?;
425        let uni =
426            |shards: usize| ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(cfg.max_bytes);
427        let ord =
428            |shards: usize| ArenaCfg::new(shards, ShardMode::Ordered).with_max_bytes(cfg.max_bytes);
429        let blob = BlobHeapCfg::new()
430            .with_max_bytes(cfg.max_bytes)
431            .with_max_blob(cfg.max_blob);
432        Ok(Self {
433            facts: Arena::new(uni(cfg.shards_facts))?,
434            fact_aux: Arena::new(uni(cfg.shards_facts))?,
435            entities: Arena::new(uni(cfg.shards_entities))?,
436            by_name: Arena::new(ord(cfg.shards_entities))?,
437            edges_out: Arena::new(ord(cfg.shards_edges))?,
438            edges_in: Arena::new(ord(cfg.shards_edges))?,
439            edges_hist_out: Arena::new(ord(cfg.shards_edges))?,
440            edges_hist_in: Arena::new(ord(cfg.shards_edges))?,
441            temporal: Arena::new(ord(cfg.shards_temporal))?,
442            texts: BlobHeap::new(blob),
443            metas: BlobHeap::new(blob),
444            terms: Interner::new(blob),
445            tag_lists: ChunkPool::new(ChunkPoolCfg::new().with_max_bytes(cfg.max_bytes)),
446            bm25: Bm25Index::new(cfg.shards_postings, cfg.max_bytes)?,
447            tags_idx: IdListIndex::new(cfg.shards_postings, cfg.max_bytes)?,
448            tag_catalog: tags::TagCatalog::new(),
449            entity_facts: IdListIndex::new(cfg.shards_entities, cfg.max_bytes)?,
450            vecs: VecPool::new(cfg.dim, cfg.max_bytes),
451            hnsw: HnswGraph::new(cfg.hnsw_m, cfg.hnsw_m0, cfg.max_bytes)?,
452            vector_space: None,
453            next_fact: 0,
454            next_entity: 0,
455            next_edge: 0,
456            tombstones: 0,
457            bm25_tokenizer_version: maintain::TOKENIZER_INDEX_VERSION,
458            tokenizer: Tokenizer::new(),
459            tf_scratch: Vec::new(),
460            name_scratch: String::new(),
461            similarity_scratch: SimilarityScratch::default(),
462            cfg,
463        })
464    }
465
466    /// Opens a database from a storage: journal replay over an empty
467    /// state (the snapshot fast-path lands with the section composition;
468    /// a non-empty snapshot is rejected rather than half-read).
469    pub fn open<S: Storage>(store: &mut S, cfg: Config) -> Result<(Self, OpenReport), Error> {
470        let snapshot = store
471            .read_snapshot()
472            .map_err(|e| Error::Storage(format!("{e:?}")))?;
473        let journal = store
474            .read_journal()
475            .map_err(|e| Error::Storage(format!("{e:?}")))?;
476        Self::from_bytes(snapshot.as_deref(), &journal, cfg)
477    }
478
479    /// Opens from raw bytes (the wasm path: the host already read them).
480    pub fn from_bytes(
481        snapshot: Option<&[u8]>,
482        journal: &[u8],
483        cfg: Config,
484    ) -> Result<(Self, OpenReport), Error> {
485        let mut mem = match snapshot {
486            Some(bytes) => Self::load_snapshot(bytes, cfg)?,
487            None => Self::new(cfg)?,
488        };
489        let report = mem.replay(journal)?;
490        Ok((mem, report))
491    }
492
493    /// Opens a database read-only over a borrowed snapshot: the
494    /// engine's byte pools borrow `snapshot` (typically an mmap'd file)
495    /// instead of copying it, so a large database residents only the pages
496    /// actually read. The returned engine is tied to `snapshot`'s lifetime.
497    ///
498    /// A **non-empty journal is rejected**: the read-only handle exposes no
499    /// write verbs, so a snapshot with un-checkpointed journal tail would open
500    /// stale. Checkpoint the database read-write once first — or use
501    /// [`Memory::from_bytes_overlay`], which replays the journal into the
502    /// overlay. The caller (the host) owns the map and the exclusive lock.
503    pub fn from_bytes_borrowed(
504        snapshot: &'a [u8],
505        journal: &[u8],
506        cfg: Config,
507    ) -> Result<Self, Error> {
508        let mem = Self::load_snapshot_borrowed(snapshot, cfg)?;
509        let JournalScan { entries, .. } = scan(journal)?;
510        if !entries.is_empty() {
511            return Err(Error::Invalid(
512                "read-only open requires a checkpointed (empty) journal",
513            ));
514        }
515        Ok(mem)
516    }
517
518    /// Opens a database read-**write** over a borrowed snapshot (the overlay
519    /// write path): like [`Memory::from_bytes_borrowed`] the byte
520    /// pools borrow `snapshot` instead of copying it, but the journal is
521    /// **replayed** and the engine stays fully mutable. Mutations do not clone
522    /// the borrowed base: the flat structures land appends in an owned tail and
523    /// copy only the pages they rewrite (per-page copy-on-write), so a
524    /// multi-gigabyte mapped database is opened and written to while resident
525    /// only in the pages it actually touches.
526    ///
527    /// This is the write sibling of [`Memory::from_bytes`] (which owns its
528    /// bytes) — the same `snapshot + journal replay`, over a borrowed base, and
529    /// it returns the same [`OpenReport`] describing the replay. The returned
530    /// engine is tied to `snapshot`'s lifetime; the caller (the host) owns the
531    /// map and the exclusive lock.
532    pub fn from_bytes_overlay(
533        snapshot: &'a [u8],
534        journal: &[u8],
535        cfg: Config,
536    ) -> Result<(Self, OpenReport), Error> {
537        let mut mem = Self::load_snapshot_borrowed(snapshot, cfg)?;
538        let report = mem.replay(journal)?;
539        Ok((mem, report))
540    }
541
542    /// Applies every journal record on top of the current state (
543    /// replay rules: assigned ids are authoritative; records whose
544    /// assigned id is already present are skipped, which makes reapplying
545    /// a tail idempotent).
546    fn replay(&mut self, journal: &[u8]) -> Result<OpenReport, Error> {
547        let JournalScan {
548            entries,
549            truncated_tail,
550        } = scan(journal)?;
551        let mut report = OpenReport {
552            truncated_tail,
553            ..OpenReport::default()
554        };
555        for entry in entries {
556            let op = Op::decode(entry.op, entry.payload)?;
557            match op {
558                Op::Remember {
559                    now,
560                    valid_from,
561                    entity,
562                    text,
563                    ref tags,
564                    ref links,
565                    ref vector,
566                    ref metadata,
567                    revises,
568                    assigned,
569                } => {
570                    if assigned.0 < self.next_fact {
571                        report.skipped += 1;
572                        continue;
573                    }
574                    if assigned.0 != self.next_fact {
575                        return Err(Error::Corrupt("journal fact ids are not contiguous"));
576                    }
577                    if !vector.is_empty() && vector.len() != self.cfg.dim {
578                        return Err(Error::Corrupt(
579                            "journal vector dimension disagrees with dim",
580                        ));
581                    }
582                    if let Some(target) = revises.some() {
583                        self.check_revisable(target)
584                            .map_err(|_| Error::Corrupt("journal revises an unrevisable fact"))?;
585                    }
586                    self.apply_remember(
587                        &RememberInput {
588                            now,
589                            text,
590                            entity,
591                            tags: &tags.to_vec(),
592                            links: &links.to_vec(),
593                            vector: (!vector.is_empty()).then_some(vector.as_slice()),
594                            valid_from: Some(valid_from),
595                            metadata: (!metadata.is_empty()).then_some(metadata.as_slice()),
596                        },
597                        revises,
598                        None,
599                    )?;
600                    if let Some(target) = revises.some() {
601                        self.close_target(target, valid_from);
602                    }
603                    report.replayed += 1;
604                }
605                Op::Forget { fact, .. } => {
606                    // Idempotent; a missing fact in a valid journal is
607                    // corruption, but re-tombstoning is fine.
608                    match self.apply_forget(fact) {
609                        Ok(_) => report.replayed += 1,
610                        Err(Error::NotFound(_)) => {
611                            return Err(Error::Corrupt("journal forgets an unknown fact"));
612                        }
613                        Err(e) => return Err(e),
614                    }
615                }
616                Op::Link {
617                    now,
618                    src,
619                    rel,
620                    dst,
621                    provenance,
622                } => {
623                    self.apply_link(now, src, rel, dst, provenance)?;
624                    report.replayed += 1;
625                }
626                Op::Unlink { now, src, rel, dst } => {
627                    self.apply_unlink(now, src, rel, dst)?;
628                    report.replayed += 1;
629                }
630                Op::RemoveTag { now, tag } => {
631                    self.apply_remove_tag(now, tag)?;
632                    report.replayed += 1;
633                }
634                Op::SetVectorSpace { space } => {
635                    self.apply_set_vector_space(space)?;
636                    report.replayed += 1;
637                }
638                Op::Maintain {
639                    mode,
640                    max_hnsw_inserts,
641                    ..
642                } => {
643                    // Re-execute the compaction deterministically, so a
644                    // replayed image matches one snapshotted after a live
645                    // maintain byte for byte.
646                    let options =
647                        maintain::MaintenanceOptions::from_journal(mode, max_hnsw_inserts)?;
648                    self.replay_maintain_with_options(options)?;
649                    report.replayed += 1;
650                }
651            }
652        }
653        Ok(report)
654    }
655
656    /// The semantic identity persisted with the current vector pool.
657    ///
658    /// `None` means either that no automatic vector has been written yet, or
659    /// that this is a legacy image whose old model was not recorded.
660    pub fn vector_space(&self) -> Option<&str> {
661        self.vector_space.as_deref()
662    }
663
664    /// Assigns `space` to an empty vector pool and journals the assignment.
665    /// Repeating the same claim is a no-op; claiming a different or untracked
666    /// non-empty pool is refused. Explicit reembed is the only operation that
667    /// may replace a non-empty pool's identity.
668    pub fn claim_vector_space<S: Storage>(
669        &mut self,
670        store: &mut S,
671        space: &str,
672    ) -> Result<bool, Error> {
673        let needs_claim = self.check_vector_space_claim(space)?;
674        if !needs_claim {
675            return Ok(false);
676        }
677        let mut entry = Vec::new();
678        Op::SetVectorSpace { space }.encode(&mut entry);
679        store
680            .append_journal(&entry)
681            .map_err(|e| Error::Storage(format!("{e:?}")))?;
682        self.vector_space = Some(space.into());
683        Ok(true)
684    }
685
686    /// Validates a vector-space claim without changing state. `true` means an
687    /// empty pool needs its first persisted identity.
688    fn check_vector_space_claim(&self, space: &str) -> Result<bool, Error> {
689        Self::validate_vector_space(space)?;
690        if let Some(stored) = &self.vector_space {
691            if stored == space {
692                return Ok(false);
693            }
694            if !self.vecs.is_empty() {
695                return Err(Error::VectorSpaceMismatch {
696                    stored: stored.clone(),
697                    requested: space.into(),
698                });
699            }
700        }
701        if !self.vecs.is_empty() {
702            return Err(Error::UntrackedVectorSpace);
703        }
704        Ok(true)
705    }
706
707    fn apply_set_vector_space(&mut self, space: &str) -> Result<(), Error> {
708        Self::validate_vector_space(space)?;
709        if let Some(stored) = &self.vector_space {
710            if stored == space {
711                return Ok(());
712            }
713            if !self.vecs.is_empty() {
714                return Err(Error::Corrupt(
715                    "journal changes an established vector space",
716                ));
717            }
718        }
719        if !self.vecs.is_empty() {
720            return Err(Error::Corrupt(
721                "journal assigns a vector space after vector records",
722            ));
723        }
724        self.vector_space = Some(space.into());
725        Ok(())
726    }
727
728    pub(super) fn validate_vector_space(space: &str) -> Result<(), Error> {
729        if space.is_empty() {
730            return Err(Error::Invalid("vector space must not be empty"));
731        }
732        if space.len() > MAX_VECTOR_SPACE_ID_BYTES {
733            return Err(Error::TooLarge {
734                what: "vector space",
735                len: space.len(),
736                max: MAX_VECTOR_SPACE_ID_BYTES,
737            });
738        }
739        if space.bytes().any(|b| b < b' ' || b == 0x7f) {
740            return Err(Error::Invalid(
741                "vector space must not contain control bytes",
742            ));
743        }
744        Ok(())
745    }
746
747    /// Remembers a new fact.
748    pub fn remember<S: Storage>(
749        &mut self,
750        store: &mut S,
751        input: RememberInput<'_>,
752    ) -> Result<RememberOutcome, Error> {
753        self.validate_input(&input)?;
754        let mut outcome = self.apply_remember(&input, FactId::NONE, None)?;
755        self.find_similar(&mut outcome);
756        self.journal_remember(store, &input, FactId::NONE, outcome.id)?;
757        Ok(outcome)
758    }
759
760    /// Stores a fact only when the ordinary `remember` similarity detector
761    /// finds no live same-entity candidate above its configured lexical or
762    /// vector threshold.
763    ///
764    /// The check and possible mutation are one engine operation. A blocked
765    /// call does not allocate a fact id, touch an index, or append to the
766    /// journal. The detector is exactly the one that fills
767    /// [`RememberOutcome::similar`] after an unconditional [`remember`](Self::remember);
768    /// hybrid [`recall`](Self::recall) ranking is deliberately not involved.
769    pub fn remember_guarded<S: Storage>(
770        &mut self,
771        store: &mut S,
772        input: RememberInput<'_>,
773    ) -> Result<GuardedRememberOutcome, Error> {
774        self.remember_guarded_with_vector_space(store, input, None)
775    }
776
777    /// Host integration for [`remember_guarded`](Self::remember_guarded):
778    /// claims an automatic embedder's semantic vector space only if the fact
779    /// will actually be stored. A blocked call therefore remains entirely
780    /// non-mutating, including vector-space metadata and its journal record.
781    #[doc(hidden)]
782    pub fn remember_guarded_with_vector_space<S: Storage>(
783        &mut self,
784        store: &mut S,
785        input: RememberInput<'_>,
786        vector_space: Option<&str>,
787    ) -> Result<GuardedRememberOutcome, Error> {
788        self.validate_input(&input)?;
789        if let Some(space) = vector_space {
790            self.check_vector_space_claim(space)?;
791        }
792        // Whether a check was POSSIBLE, which is a property of the call rather
793        // than of what the memory currently holds: the detector is scoped to
794        // the input's entity, so an input naming none has no candidate set now
795        // and would have none however many facts were stored later. An input
796        // that does name one is checked - against an empty set when it is that
797        // entity's first fact, which is a real "nothing similar" rather than an
798        // absent comparison.
799        let checked = input.entity.is_some();
800        let similar = self.find_similar_input(&input)?;
801        if !similar.is_empty() {
802            return Ok(GuardedRememberOutcome::Blocked { similar });
803        }
804        if let Some(space) = vector_space {
805            self.claim_vector_space(store, space)?;
806        }
807        let outcome = self.apply_remember(&input, FactId::NONE, None)?;
808        self.journal_remember(store, &input, FactId::NONE, outcome.id)?;
809        Ok(GuardedRememberOutcome::Stored { outcome, checked })
810    }
811
812    /// Batch import: a sequence of remembers in one call, journaled
813    /// individually. `skip_similar` turns off the
814    /// similar-detection pass — imports don't need hints, and skipping
815    /// them makes a bulk load cheaper.
816    ///
817    /// Stops at the first error; already-applied inputs stay applied and
818    /// journaled (replay reproduces exactly the applied prefix).
819    pub fn remember_batch<S: Storage>(
820        &mut self,
821        store: &mut S,
822        inputs: &[RememberInput<'_>],
823        skip_similar: bool,
824    ) -> Result<Vec<RememberOutcome>, Error> {
825        let mut out = Vec::with_capacity(inputs.len());
826        for input in inputs {
827            self.validate_input(input)?;
828            let mut outcome = self.apply_remember(input, FactId::NONE, None)?;
829            if !skip_similar {
830                self.find_similar(&mut outcome);
831            }
832            self.journal_remember(store, input, FactId::NONE, outcome.id)?;
833            out.push(outcome);
834        }
835        Ok(out)
836    }
837
838    /// Runs similarity detection against an incoming, not-yet-stored fact.
839    /// Unknown tokens are counted in the Jaccard union but never interned:
840    /// a blocked call is observationally a read and cannot grow vocabulary.
841    fn find_similar_input(&mut self, input: &RememberInput<'_>) -> Result<Vec<Similar>, Error> {
842        let Some(name) = input.entity else {
843            return Ok(Vec::new());
844        };
845        let Some(entity) = self.lookup_entity_name(name) else {
846            return Ok(Vec::new());
847        };
848
849        let mut scratch = core::mem::take(&mut self.similarity_scratch);
850        scratch.new_terms.clear();
851        scratch.unknown.clear();
852        scratch.unknown_ranges.clear();
853        scratch.candidate_terms.clear();
854        scratch.vector.clear();
855
856        let terms = &self.terms;
857        let new_terms = &mut scratch.new_terms;
858        let unknown = &mut scratch.unknown;
859        let unknown_ranges = &mut scratch.unknown_ranges;
860        self.tokenizer.tokenize(input.text, &mut |token| {
861            if let Some(term) = terms.lookup(token) {
862                if !new_terms.contains(&term.0) {
863                    new_terms.push(term.0);
864                }
865                return;
866            }
867            if unknown_ranges
868                .iter()
869                .any(|&(start, end)| &unknown[start..end] == token)
870            {
871                return;
872            }
873            let start = unknown.len();
874            unknown.push_str(token);
875            unknown_ranges.push((start, unknown.len()));
876        });
877        let new_term_count = scratch.new_terms.len() + scratch.unknown_ranges.len();
878
879        let mut similar = Vec::new();
880        let result = (|| {
881            let encoded = match input.vector {
882                Some(vector) => {
883                    self.vecs
884                        .encode_slot_into(FactId::NONE, vector, &mut scratch.vector)?;
885                    SimilarVector::Encoded(&scratch.vector)
886                }
887                None => SimilarVector::None,
888            };
889            self.scan_similar(
890                SimilarityQuery {
891                    entity,
892                    exclude: None,
893                    terms: &scratch.new_terms,
894                    term_count: new_term_count,
895                    vector: encoded,
896                },
897                &mut scratch.candidate_terms,
898                &mut similar,
899            );
900            Ok::<(), Error>(())
901        })();
902        self.similarity_scratch = scratch;
903        result.map(|()| similar)
904    }
905
906    /// Similarity detection for an already-stored ordinary remember. This
907    /// feeds the same scanner as `remember_guarded`, but reuses the term ids and
908    /// quantized vector slot the write has just produced.
909    fn find_similar(&mut self, outcome: &mut RememberOutcome) {
910        let Some(entity) = outcome.entity else { return };
911        let new_vec = self
912            .fact(outcome.id)
913            .filter(|record| record.has_vector())
914            .map(|record| record.vector);
915        let mut scratch = core::mem::take(&mut self.similarity_scratch);
916        scratch.new_terms.clear();
917        scratch
918            .new_terms
919            .extend(self.tf_scratch.iter().map(|&(term, _)| term));
920        let new_term_count = scratch.new_terms.len();
921        self.scan_similar(
922            SimilarityQuery {
923                entity,
924                exclude: Some(outcome.id),
925                terms: &scratch.new_terms,
926                term_count: new_term_count,
927                vector: new_vec.map_or(SimilarVector::None, SimilarVector::Stored),
928            },
929            &mut scratch.candidate_terms,
930            &mut outcome.similar,
931        );
932        self.similarity_scratch = scratch;
933    }
934
935    /// Lexical similar-detection: live facts of the same
936    /// entity whose term sets overlap the new fact's above the
937    /// `similar_jaccard` threshold. Bounded: the entity's most recent
938    /// [`SIMILAR_CANDIDATE_CAP`] facts are compared (a hub's full list is
939    /// not re-tokenized).
940    ///
941    /// Comparing two term sets exactly means having both, and only the new
942    /// fact's is at hand — the candidate's would have to be recovered by
943    /// reading its text and running it back through the tokenizer. Doing that
944    /// for every candidate of every write is what the term-set summary in
945    /// [`DocLenSlot`](crate::index::bm25::DocLenSlot) exists to avoid: it
946    /// bounds the overlap from above, and a bound below the threshold settles
947    /// the question, because Jaccard rises with the intersection. Only a
948    /// candidate that survives the bound is read and tokenized, so the
949    /// answer is the same one the exhaustive comparison gives.
950    ///
951    /// The bound is trusted only while the index was built by the current
952    /// tokenizer. A stale index may hold terms today's tokenizer would not
953    /// produce, which would make the summary describe a different term set
954    /// than the comparison uses; then every candidate is read, exactly as
955    /// before the summary existed.
956    fn scan_similar(
957        &mut self,
958        query: SimilarityQuery<'_>,
959        candidate_terms: &mut Vec<u32>,
960        out: &mut Vec<Similar>,
961    ) {
962        out.clear();
963        if query.term_count == 0 && matches!(query.vector, SimilarVector::None) {
964            return;
965        }
966        // Most recent candidates of the entity (ring over the list).
967        let mut ring = [FactId::NONE; SIMILAR_CANDIDATE_CAP];
968        let mut n = 0usize;
969        for (fact, _) in self.entity_facts.entries(query.entity.0) {
970            if query.exclude == Some(fact) {
971                continue;
972            }
973            ring[n % SIMILAR_CANDIDATE_CAP] = fact;
974            n += 1;
975        }
976        let summaries_trustworthy = self.bm25_tokenizer_version == TOKENIZER_INDEX_VERSION;
977        // Without a vector on the new fact, a lexical overlap is the only hint
978        // a candidate can produce, so one ruled out by the summary contributes
979        // nothing whatever its record says — and its record is the second
980        // arena lookup of the pair this loop would otherwise pay per
981        // candidate. (`new_terms` is non-empty here: the two being empty
982        // together returned above.)
983        let lexical_only = matches!(query.vector, SimilarVector::None);
984        for &fact in ring.iter().take(n.min(SIMILAR_CANDIDATE_CAP)) {
985            let may_overlap = !query.terms.is_empty()
986                && self.overlap_possible(
987                    fact,
988                    query.terms,
989                    query.term_count,
990                    summaries_trustworthy,
991                );
992            if lexical_only && !may_overlap {
993                continue;
994            }
995            let Some(record) = self.fact(fact) else {
996                continue;
997            };
998            if record.is_tombstone() || record.is_closed() {
999                continue;
1000            }
1001
1002            // Lexical signal: term-set Jaccard over the entity's text.
1003            let mut lexical = None;
1004            // Deferred validation: a load no longer scans the
1005            // text pool, so an unreadable text simply yields no lexical signal.
1006            if may_overlap && let Ok(text) = core::str::from_utf8(self.texts.get(record.text)) {
1007                candidate_terms.clear();
1008                let terms = &self.terms;
1009                let cand = &mut *candidate_terms;
1010                self.tokenizer.tokenize(text, &mut |token| {
1011                    if let Some(term) = terms.lookup(token)
1012                        && !cand.contains(&term.0)
1013                    {
1014                        cand.push(term.0);
1015                    }
1016                });
1017                if !candidate_terms.is_empty() {
1018                    let both = candidate_terms
1019                        .iter()
1020                        .filter(|term| query.terms.contains(term))
1021                        .count();
1022                    let union = candidate_terms.len() + query.term_count - both;
1023                    let jaccard = both as f32 / union as f32;
1024                    if jaccard > self.cfg.similar_jaccard {
1025                        lexical = Some(jaccard);
1026                    }
1027                }
1028            }
1029
1030            // Vector signal: quantized cosine when both facts carry one.
1031            let mut vector = None;
1032            if record.has_vector() {
1033                let cos = match query.vector {
1034                    SimilarVector::None => 0.0,
1035                    SimilarVector::Stored(slot) => self.vecs.cosine_slots(slot, record.vector),
1036                    SimilarVector::Encoded(encoded) => {
1037                        self.vecs.cosine_encoded_slot(encoded, record.vector)
1038                    }
1039                };
1040                if cos > self.cfg.similar_cos {
1041                    vector = Some(cos);
1042                }
1043            }
1044
1045            // Keep the stronger signal; a tie prefers the lexical reason.
1046            let best = match (lexical, vector) {
1047                (Some(l), Some(v)) if v > l => Some((v, SimilarReason::VectorCosine)),
1048                (Some(l), _) => Some((l, SimilarReason::LexicalOverlap)),
1049                (None, Some(v)) => Some((v, SimilarReason::VectorCosine)),
1050                (None, None) => None,
1051            };
1052            if let Some((score, reason)) = best {
1053                out.push(Similar {
1054                    id: fact,
1055                    score,
1056                    reason,
1057                });
1058            }
1059        }
1060        out.sort_unstable_by(|a, b| b.score.total_cmp(&a.score).then(a.id.cmp(&b.id)));
1061        out.truncate(8);
1062    }
1063
1064    /// Whether `candidate` could possibly overlap `new_terms` above
1065    /// `similar_jaccard` — the cheap half of [`Memory::find_similar`].
1066    ///
1067    /// `false` is a proof, not a guess. The candidate's summary marks a term
1068    /// absent only when it really is absent, so the terms it *might* share is
1069    /// an upper bound `u` on the true intersection `i`. Jaccard
1070    /// `i / (|A| + |B| - i)` is increasing in `i`, so `u` at or below the
1071    /// threshold puts the true value there too. `true` means "unknown" and
1072    /// sends the caller to the exact comparison — which is also what an
1073    /// unsummarized document and an untrustworthy index return.
1074    fn overlap_possible(
1075        &self,
1076        candidate: FactId,
1077        new_terms: &[u32],
1078        new_term_count: usize,
1079        trust_summary: bool,
1080    ) -> bool {
1081        if !trust_summary {
1082            return true;
1083        }
1084        let Some(doc) = self.bm25.doc(candidate) else {
1085            return true;
1086        };
1087        if !doc.has_signature() {
1088            return true;
1089        }
1090        let bound = doc.overlap_bound(new_terms);
1091        // `overlap_bound` counts a subset of `new_terms`, so the subtraction
1092        // is grouped to happen where it provably cannot go below zero. The
1093        // union is then at least `distinct`, which `has_signature` just
1094        // established is non-zero — no underflow and no division by zero, on a
1095        // 64-bit `usize` or a 32-bit one.
1096        debug_assert!(
1097            bound <= new_terms.len(),
1098            "the overlap bound counts query terms, so it cannot exceed them"
1099        );
1100        let union = (new_term_count - bound) + usize::from(doc.distinct);
1101        bound as f32 / union as f32 > self.cfg.similar_jaccard
1102    }
1103
1104    /// Revises `target`: closes its validity at the new fact's
1105    /// `valid_from` and records the new fact with `revises = target`
1106    /// (rule 2).
1107    pub fn revise<S: Storage>(
1108        &mut self,
1109        store: &mut S,
1110        target: FactId,
1111        input: RememberInput<'_>,
1112    ) -> Result<RememberOutcome, Error> {
1113        self.validate_input(&input)?;
1114        // Check first, mutate last: the target closes only after the new
1115        // fact exists, so a mid-operation failure (capacity) never leaves
1116        // a closed fact without its successor.
1117        self.check_revisable(target)?;
1118        let outcome = self.apply_remember(&input, target, None)?;
1119        let valid_from = input.valid_from.unwrap_or(input.now);
1120        self.close_target(target, valid_from);
1121        self.journal_remember(store, &input, target, outcome.id)?;
1122        Ok(outcome)
1123    }
1124
1125    /// Tombstones a fact: it disappears from every query immediately, the
1126    /// bytes go with the next `maintain` (rule 3). `Ok(false)`
1127    /// when it was already tombstoned.
1128    pub fn forget<S: Storage>(
1129        &mut self,
1130        store: &mut S,
1131        now: u64,
1132        id: FactId,
1133    ) -> Result<bool, Error> {
1134        let fresh = self.apply_forget(id)?;
1135        let mut entry = Vec::new();
1136        Op::Forget { now, fact: id }.encode(&mut entry);
1137        store
1138            .append_journal(&entry)
1139            .map_err(|e| Error::Storage(format!("{e:?}")))?;
1140        Ok(fresh)
1141    }
1142
1143    /// Removes `tag` from every current fact without deleting knowledge.
1144    ///
1145    /// Each affected fact is closed and replaced by an otherwise-identical
1146    /// successor without the tag. Historical `as_of` queries keep seeing the
1147    /// old classification; current recall and [`Memory::list_tags`] stop
1148    /// seeing it immediately. The interned string is stable historical residue
1149    /// and is not physically reclaimed.
1150    pub fn remove_tag<S: Storage>(
1151        &mut self,
1152        store: &mut S,
1153        now: u64,
1154        tag: &str,
1155    ) -> Result<RemoveTagReport, Error> {
1156        if tag.is_empty() {
1157            return Err(Error::Invalid("empty tag"));
1158        }
1159        let report = self.apply_remove_tag(now, tag)?;
1160        if report.affected != 0 {
1161            let mut entry = Vec::new();
1162            Op::RemoveTag { now, tag }.encode(&mut entry);
1163            store
1164                .append_journal(&entry)
1165                .map_err(|e| Error::Storage(format!("{e:?}")))?;
1166        }
1167        Ok(report)
1168    }
1169
1170    /// Upserts a typed edge between two entities (created lazily);
1171    /// re-linking an existing `(src, rel, dst)` updates its provenance.
1172    pub fn link<S: Storage>(&mut self, store: &mut S, input: LinkInput<'_>) -> Result<(), Error> {
1173        self.apply_link(
1174            input.now,
1175            input.src,
1176            input.rel,
1177            input.dst,
1178            FactId::from_opt(input.provenance),
1179        )?;
1180        let mut entry = Vec::new();
1181        Op::Link {
1182            now: input.now,
1183            src: input.src,
1184            rel: input.rel,
1185            dst: input.dst,
1186            provenance: FactId::from_opt(input.provenance),
1187        }
1188        .encode(&mut entry);
1189        store
1190            .append_journal(&entry)
1191            .map_err(|e| Error::Storage(format!("{e:?}")))?;
1192        Ok(())
1193    }
1194
1195    /// Closes the current typed edge between two entities. Returns `false`
1196    /// when the edge is already absent.
1197    pub fn unlink<S: Storage>(
1198        &mut self,
1199        store: &mut S,
1200        input: UnlinkInput<'_>,
1201    ) -> Result<bool, Error> {
1202        let fresh = self.apply_unlink(input.now, input.src, input.rel, input.dst)?;
1203        let mut entry = Vec::new();
1204        Op::Unlink {
1205            now: input.now,
1206            src: input.src,
1207            rel: input.rel,
1208            dst: input.dst,
1209        }
1210        .encode(&mut entry);
1211        store
1212            .append_journal(&entry)
1213            .map_err(|e| Error::Storage(format!("{e:?}")))?;
1214        Ok(fresh)
1215    }
1216
1217    /// Returns a fact unless it is tombstoned (closed facts are
1218    /// returned — their interval says so).
1219    pub fn get(&self, id: FactId) -> Option<FactView<'_>> {
1220        let record = self.fact(id)?;
1221        if record.is_tombstone() {
1222            return None;
1223        }
1224        // Deferred validation: a load no longer scans the text
1225        // pool, so tolerate invalid bytes here — a corrupt text hides the fact
1226        // rather than panicking. `verify()` reports it explicitly.
1227        let text = core::str::from_utf8(self.texts.get(record.text)).ok()?;
1228        Some(FactView { record, text })
1229    }
1230
1231    /// Appends the tag terms of a fact to `out`. A tombstoned or unknown
1232    /// fact contributes nothing.
1233    pub fn tags_of(&self, id: FactId, out: &mut Vec<TermId>) {
1234        let Some(record) = self.fact(id) else { return };
1235        if record.is_tombstone() {
1236            return;
1237        }
1238        let Some(aux) = self.fact_aux.get(&id.0.to_be_bytes()) else {
1239            return;
1240        };
1241        for chunk in self.tag_lists.iter(&aux.tags) {
1242            for raw in chunk.chunks_exact(4) {
1243                out.push(TermId(u32::from_be_bytes(raw.try_into().unwrap())));
1244            }
1245        }
1246    }
1247
1248    /// Returns one bounded, stable page of current tags in lexical order.
1249    ///
1250    /// A cursor is tied to the tag catalog state and prefix. If a concurrent
1251    /// mutation changes any current tag count, continuing returns
1252    /// [`Error::StaleCursor`] rather than silently skipping or duplicating a
1253    /// tag.
1254    pub fn list_tags(&self, query: TagQuery<'_>) -> Result<TagPage, Error> {
1255        self.tag_catalog.page(&self.terms, self.cfg.db_uuid, query)
1256    }
1257
1258    /// Fills `out` with the fact's metadata as key→value pairs in canonical
1259    /// (ascending-key) order — the same order the blob was written and the same
1260    /// order a host `BTreeMap` yields, so no layer sorts twice. Returns `true`
1261    /// when the fact carries metadata (even an empty map is `false`, since an
1262    /// empty map is stored as no blob).
1263    ///
1264    /// A tombstoned or unknown fact, a fact with no metadata blob, or a blob
1265    /// that fails to decode (deferred validation, — `verify`
1266    /// reports it) all leave `out` empty and return `false`; this accessor never
1267    /// panics on bad bytes.
1268    pub fn metadata_of<'s>(&'s self, id: FactId, out: &mut Vec<(&'s str, &'s str)>) -> bool {
1269        out.clear();
1270        let Some(record) = self.fact(id) else {
1271            return false;
1272        };
1273        if record.is_tombstone() {
1274            return false;
1275        }
1276        let Some(aux) = self.fact_aux.get(&id.0.to_be_bytes()) else {
1277            return false;
1278        };
1279        if aux.meta.0 == NONE_U32 || aux.meta.0 >= self.metas.len() as u32 {
1280            return false;
1281        }
1282        crate::metadata::decode(self.metas.get(aux.meta), out).is_ok() && !out.is_empty()
1283    }
1284
1285    /// Resolves an entity by name (normalized), without creating it.
1286    pub fn entity(&mut self, name: &str) -> Option<EntityId> {
1287        let mut norm = core::mem::take(&mut self.name_scratch);
1288        normalize_name(&mut self.tokenizer, name, &mut norm);
1289        let found = if norm.is_empty() {
1290            None
1291        } else {
1292            // Only resolve an *existing* term: interning would create it.
1293            self.lookup_entity_by_norm(&norm)
1294        };
1295        self.name_scratch = norm;
1296        found
1297    }
1298
1299    /// Resolves the string behind a term id (tags, relations).
1300    pub fn term(&self, id: TermId) -> &str {
1301        self.terms.resolve(id)
1302    }
1303
1304    /// The canonical (verbatim) name of an entity, or `None` for an
1305    /// unknown id. The read-side inverse of [`Memory::entity`]: it lets a
1306    /// host export a fact with its subject *name* instead of the internal
1307    /// [`EntityId`] carried by [`FactRecord`].
1308    pub fn entity_name(&self, id: EntityId) -> Option<&str> {
1309        let record = self.entities.get(&id.0.to_be_bytes())?;
1310        // Tolerate invalid bytes (deferred validation): an
1311        // unreadable name reads as `None`, never a panic.
1312        core::str::from_utf8(self.texts.get(record.name)).ok()
1313    }
1314
1315    /// Visits every currently-open edge once, as
1316    /// `(source name, relation, destination name, provenance)`.
1317    ///
1318    /// Reads the out-arena only: each edge is mirrored in both arenas, and the
1319    /// out-arena holds it keyed `(src, rel, dst)`, which is the direction a
1320    /// caller wants to write back. Closed versions live in the history arenas
1321    /// and are not visited — this is the *current* graph, matching what a fact
1322    /// export does for facts.
1323    ///
1324    /// Names are borrowed from the interner and the text pool, so a caller that
1325    /// writes them straight out allocates nothing per edge. An edge whose
1326    /// endpoint name is unreadable is skipped rather than reported with a
1327    /// placeholder: deferred validation means a damaged image must not turn
1328    /// into plausible-looking output.
1329    ///
1330    /// `visit` returning `false` stops the walk, so a caller can bound its own
1331    /// work without materializing the graph.
1332    pub fn edges_each(&self, mut visit: impl FnMut(&str, &str, &str, FactId) -> bool) {
1333        for slot in self.edges_out.iter() {
1334            let (Some(src), Some(dst)) = (self.entity_name(slot.a), self.entity_name(slot.b))
1335            else {
1336                continue;
1337            };
1338            if !visit(src, self.term(slot.rel), dst, slot.fact) {
1339                return;
1340            }
1341        }
1342    }
1343
1344    /// Number of fact records currently stored (tombstoned facts count
1345    /// until `maintain` removes them). Purged ids stay burned, so this can
1346    /// be below [`Stats::next_fact`].
1347    pub fn facts_len(&self) -> usize {
1348        self.facts.len()
1349    }
1350
1351    /// Number of entities.
1352    pub fn entities_len(&self) -> usize {
1353        self.entities.len()
1354    }
1355
1356    /// The engine configuration.
1357    pub fn cfg(&self) -> &Config {
1358        &self.cfg
1359    }
1360
1361    /// Size counters of the engine. O(1).
1362    pub fn stats(&self) -> Stats {
1363        Stats {
1364            facts: self.facts.len(),
1365            entities: self.entities.len(),
1366            terms: self.terms.len(),
1367            edges: self.edges_out.len(),
1368            edge_versions: self.edges_hist_out.len(),
1369            vectors: self.vecs.len(),
1370            tombstones: self.tombstones,
1371            hnsw_indexed: self.hnsw.indexed(),
1372            next_fact: self.next_fact,
1373            next_entity: self.next_entity,
1374            next_edge: self.next_edge,
1375            db_uuid: self.cfg.db_uuid,
1376            pool_bytes: self.facts.pool_bytes()
1377                + self.fact_aux.pool_bytes()
1378                + self.entities.pool_bytes()
1379                + self.by_name.pool_bytes()
1380                + self.edges_out.pool_bytes()
1381                + self.edges_in.pool_bytes()
1382                + self.edges_hist_out.pool_bytes()
1383                + self.edges_hist_in.pool_bytes()
1384                + self.temporal.pool_bytes()
1385                + self.texts.pool_bytes()
1386                + self.terms.pool_bytes()
1387                + self.tag_lists.pool_bytes()
1388                + self.bm25.pool_bytes()
1389                + self.tags_idx.pool_bytes()
1390                + self.tag_catalog.pool_bytes()
1391                + self.entity_facts.pool_bytes()
1392                + self.vecs.pool_bytes()
1393                + self.hnsw.pool_bytes(),
1394            shards: shards::ShardLayout::of_config(&self.cfg),
1395        }
1396    }
1397
1398    // ---- internals ----
1399
1400    fn fact(&self, id: FactId) -> Option<FactRecord> {
1401        self.facts.get(&id.0.to_be_bytes())
1402    }
1403
1404    /// Size-limit checks shared by remember and revise.
1405    fn validate_input(&self, input: &RememberInput<'_>) -> Result<(), Error> {
1406        if input.text.len() > self.cfg.max_text {
1407            return Err(Error::TooLarge {
1408                what: "text",
1409                len: input.text.len(),
1410                max: self.cfg.max_text,
1411            });
1412        }
1413        if input.tags.len() > 32 {
1414            return Err(Error::TooLarge {
1415                what: "tags",
1416                len: input.tags.len(),
1417                max: 32,
1418            });
1419        }
1420        if input.links.len() > 16 {
1421            return Err(Error::TooLarge {
1422                what: "links",
1423                len: input.links.len(),
1424                max: 16,
1425            });
1426        }
1427        if input.tags.iter().any(|t| t.is_empty()) {
1428            return Err(Error::Invalid("empty tag"));
1429        }
1430        if !input.links.is_empty() && input.entity.is_none() {
1431            return Err(Error::Invalid("links require a subject entity"));
1432        }
1433        if let Some(v) = input.vector {
1434            if self.cfg.dim == 0 {
1435                return Err(Error::Invalid("vector given but dim is 0"));
1436            }
1437            if v.len() != self.cfg.dim {
1438                return Err(Error::DimMismatch {
1439                    got: v.len(),
1440                    want: self.cfg.dim,
1441                });
1442            }
1443        }
1444        Ok(())
1445    }
1446
1447    /// `Ok` when `target` exists, is not tombstoned and not yet closed.
1448    fn check_revisable(&self, target: FactId) -> Result<(), Error> {
1449        let record = self.fact(target).ok_or(Error::NotFound(target))?;
1450        if record.is_tombstone() {
1451            return Err(Error::NotFound(target));
1452        }
1453        if record.is_closed() {
1454            return Err(Error::AlreadyClosed(target));
1455        }
1456        Ok(())
1457    }
1458
1459    /// Closes a revision target (validity ends where the successor
1460    /// starts). The caller checked [`Memory::check_revisable`] first.
1461    fn close_target(&mut self, target: FactId, valid_to: u64) {
1462        let record = self.fact(target).expect("checked revisable");
1463        let payload = self
1464            .facts
1465            .payload_mut(&target.0.to_be_bytes())
1466            .expect("record fetched above");
1467        // Payload offsets = slot offsets - KEY_LEN(4): flags at 4..6,
1468        // valid_to at 36..44.
1469        let flags = record.flags | fact_flags::CLOSED;
1470        payload[4..6].copy_from_slice(&flags.to_be_bytes());
1471        payload[36..44].copy_from_slice(&valid_to.to_be_bytes());
1472        self.change_catalog_for_fact(target, -1);
1473    }
1474
1475    /// The one execution path of a new fact — called by the public verbs
1476    /// and by replay with identical effects.
1477    fn apply_remember(
1478        &mut self,
1479        input: &RememberInput<'_>,
1480        revises: FactId,
1481        copy_vector: Option<u32>,
1482    ) -> Result<RememberOutcome, Error> {
1483        let id = FactId(self.next_fact);
1484        let entity = match input.entity {
1485            Some(name) => Some(self.resolve_or_create_entity(name, input.now)?),
1486            None => None,
1487        };
1488        let text_id = self.texts.push(input.text.as_bytes())?;
1489
1490        // Tokenize into (term, tf) pairs in the reusable scratch.
1491        let mut tfs = core::mem::take(&mut self.tf_scratch);
1492        tfs.clear();
1493        let terms = &mut self.terms;
1494        let mut intern_err = None;
1495        self.tokenizer.tokenize(input.text, &mut |token| {
1496            if intern_err.is_some() {
1497                return;
1498            }
1499            match terms.intern(token) {
1500                Ok(term) => match tfs.iter_mut().find(|(t, _)| *t == term.0) {
1501                    Some((_, tf)) => *tf = tf.saturating_add(1),
1502                    None => tfs.push((term.0, 1)),
1503                },
1504                Err(e) => intern_err = Some(e),
1505            }
1506        });
1507        if let Some(e) = intern_err {
1508            self.tf_scratch = tfs;
1509            return Err(Error::Arena(e));
1510        }
1511        self.bm25.index_doc(id, &tfs)?;
1512        self.tf_scratch = tfs;
1513
1514        // Metadata: canonicalized (keys sorted, dups rejected) and stored as one
1515        // opaque blob; an absent or empty map leaves the sentinel. Pushed before
1516        // the fact record so a capacity/validation failure aborts the whole op.
1517        let meta = match input.metadata {
1518            Some(pairs) if !pairs.is_empty() => {
1519                self.metas.push(&crate::metadata::encode(pairs)?)?
1520            }
1521            _ => BlobId(NONE_U32),
1522        };
1523
1524        // Tags: interned verbatim, deduplicated, listed on the fact and
1525        // inverted.
1526        let mut aux = FactAux {
1527            id,
1528            tags: ListHandle::EMPTY,
1529            meta,
1530        };
1531        let mut seen_tags: [u32; 32] = [NONE_U32; 32];
1532        let mut seen_cnt = 0usize;
1533        for tag in input.tags {
1534            let term = self.terms.intern(tag)?;
1535            if seen_tags[..seen_cnt].contains(&term.0) {
1536                continue;
1537            }
1538            seen_tags[seen_cnt] = term.0;
1539            seen_cnt += 1;
1540            self.tag_lists.push(&mut aux.tags, &term.0.to_be_bytes())?;
1541            self.tags_idx.push(term.0, id, 0)?;
1542        }
1543        self.fact_aux.insert(&aux)?;
1544
1545        // Links: subject → target edges with this fact as provenance.
1546        if let Some(src) = entity {
1547            for &(rel, dst_name) in input.links {
1548                let dst = self.resolve_or_create_entity(dst_name, input.now)?;
1549                let rel = self.terms.intern(rel)?;
1550                self.open_edge(input.now, src, rel, dst, id)?;
1551            }
1552            self.entity_facts.push(src.0, id, 0)?;
1553        }
1554
1555        // Vector: quantized into the flat pool; the fact keeps the slot
1556        // index. Pushed before the record so a capacity failure aborts the
1557        // whole op (replay rebuilds consistently, like the other indexes).
1558        let (vector, flags) = match (input.vector, copy_vector) {
1559            (Some(v), None) => (self.vecs.push(id, v)?, fact_flags::HAS_VECTOR),
1560            (None, Some(source)) => (
1561                self.vecs.clone_slot_for_fact(id, source)?,
1562                fact_flags::HAS_VECTOR,
1563            ),
1564            (None, None) => (NONE_U32, 0),
1565            (Some(_), Some(_)) => unreachable!("retag does not provide a raw vector"),
1566        };
1567
1568        let recorded_at = input.now;
1569        let valid_from = input.valid_from.unwrap_or(input.now);
1570        self.facts.insert(&FactRecord {
1571            id,
1572            entity: EntityId::from_opt(entity),
1573            flags,
1574            kind: 0,
1575            text: text_id,
1576            vector,
1577            revises,
1578            recorded_at,
1579            valid_from,
1580            valid_to: VALID_TO_OPEN,
1581        })?;
1582        self.temporal.insert(&TemporalSlot {
1583            recorded_at,
1584            fact: id,
1585        })?;
1586        for &term in &seen_tags[..seen_cnt] {
1587            self.tag_catalog.change(&self.terms, TermId(term), 1);
1588        }
1589        self.next_fact += 1;
1590        Ok(RememberOutcome {
1591            id,
1592            entity,
1593            similar: Vec::new(),
1594        })
1595    }
1596
1597    fn apply_forget(&mut self, id: FactId) -> Result<bool, Error> {
1598        let record = self.fact(id).ok_or(Error::NotFound(id))?;
1599        if record.is_tombstone() {
1600            return Ok(false);
1601        }
1602        let payload = self
1603            .facts
1604            .payload_mut(&id.0.to_be_bytes())
1605            .expect("record fetched above");
1606        let flags = record.flags | fact_flags::TOMBSTONE;
1607        payload[4..6].copy_from_slice(&flags.to_be_bytes());
1608        self.tombstones += 1;
1609        if !record.is_closed() {
1610            self.change_catalog_for_fact(id, -1);
1611        }
1612        Ok(true)
1613    }
1614
1615    fn apply_remove_tag(&mut self, now: u64, tag: &str) -> Result<RemoveTagReport, Error> {
1616        let Some(term) = self.terms.lookup(tag) else {
1617            return Ok(RemoveTagReport::default());
1618        };
1619        let targets: Vec<FactId> = self
1620            .tags_idx
1621            .entries(term.0)
1622            .map(|(id, _)| id)
1623            .filter(|&id| {
1624                self.fact(id)
1625                    .is_some_and(|record| !record.is_tombstone() && !record.is_closed())
1626            })
1627            .collect();
1628        let mut affected = 0u32;
1629        for target in targets {
1630            self.retag_without(now, target, tag)?;
1631            affected = affected.saturating_add(1);
1632        }
1633        Ok(RemoveTagReport { affected })
1634    }
1635
1636    fn retag_without(&mut self, now: u64, target: FactId, removed: &str) -> Result<(), Error> {
1637        let record = self.fact(target).ok_or(Error::NotFound(target))?;
1638        let view = self.get(target).ok_or(Error::NotFound(target))?;
1639        let text = view.text.to_string();
1640        let entity = record
1641            .entity
1642            .some()
1643            .and_then(|id| self.entity_name(id))
1644            .map(ToString::to_string);
1645
1646        let mut tag_terms = Vec::new();
1647        self.tags_of(target, &mut tag_terms);
1648        let tags: Vec<String> = tag_terms
1649            .into_iter()
1650            .map(|term| self.term(term))
1651            .filter(|name| *name != removed)
1652            .map(ToString::to_string)
1653            .collect();
1654        let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1655
1656        let mut metadata = Vec::new();
1657        self.metadata_of(target, &mut metadata);
1658        let metadata: Vec<(String, String)> = metadata
1659            .into_iter()
1660            .map(|(key, value)| (key.to_string(), value.to_string()))
1661            .collect();
1662        let metadata_refs: Vec<(&str, &str)> = metadata
1663            .iter()
1664            .map(|(key, value)| (key.as_str(), value.as_str()))
1665            .collect();
1666        let vector = (record.flags & fact_flags::HAS_VECTOR != 0).then_some(record.vector);
1667        let input = RememberInput {
1668            now,
1669            text: &text,
1670            entity: entity.as_deref(),
1671            tags: &tag_refs,
1672            links: &[],
1673            vector: None,
1674            valid_from: Some(now),
1675            metadata: (!metadata_refs.is_empty()).then_some(metadata_refs.as_slice()),
1676        };
1677        self.apply_remember(&input, target, vector)?;
1678        self.close_target(target, now);
1679        Ok(())
1680    }
1681
1682    /// Applies one current-count change for every tag attached to `id`.
1683    /// Facts accept at most 32 distinct tags, so this uses fixed stack storage
1684    /// and never allocates on revise/forget.
1685    fn change_catalog_for_fact(&mut self, id: FactId, delta: i32) {
1686        let Some(aux) = self.fact_aux.get(&id.0.to_be_bytes()) else {
1687            return;
1688        };
1689        let mut ids = [NONE_U32; 32];
1690        let mut len = 0usize;
1691        for chunk in self.tag_lists.iter(&aux.tags) {
1692            for raw in chunk.chunks_exact(4) {
1693                if len == ids.len() {
1694                    break;
1695                }
1696                ids[len] = u32::from_be_bytes(raw.try_into().unwrap());
1697                len += 1;
1698            }
1699        }
1700        for &term in &ids[..len] {
1701            self.tag_catalog.change(&self.terms, TermId(term), delta);
1702        }
1703    }
1704
1705    fn apply_link(
1706        &mut self,
1707        now: u64,
1708        src: &str,
1709        rel: &str,
1710        dst: &str,
1711        provenance: FactId,
1712    ) -> Result<(), Error> {
1713        let src = self.resolve_or_create_entity(src, now)?;
1714        let dst = self.resolve_or_create_entity(dst, now)?;
1715        let rel = self.terms.intern(rel)?;
1716        self.open_edge(now, src, rel, dst, provenance)
1717    }
1718
1719    fn apply_unlink(&mut self, now: u64, src: &str, rel: &str, dst: &str) -> Result<bool, Error> {
1720        let Some(src) = self.lookup_entity_name(src) else {
1721            return Ok(false);
1722        };
1723        let Some(dst) = self.lookup_entity_name(dst) else {
1724            return Ok(false);
1725        };
1726        let Some(rel) = self.terms.lookup(rel) else {
1727            return Ok(false);
1728        };
1729        self.close_current_edge(now, src, rel, dst)
1730    }
1731
1732    fn open_edge(
1733        &mut self,
1734        now: u64,
1735        src: EntityId,
1736        rel: TermId,
1737        dst: EntityId,
1738        fact: FactId,
1739    ) -> Result<(), Error> {
1740        if let Some(current) = self.current_edge(src, rel, dst) {
1741            if current.fact == fact {
1742                return Ok(());
1743            }
1744            self.close_current_edge(now, src, rel, dst)?;
1745        }
1746        let edge = EdgeId(self.next_edge);
1747        let history = EdgeHistorySlot {
1748            a: src,
1749            rel,
1750            b: dst,
1751            edge,
1752            fact,
1753            flags: 0,
1754            kind: 0,
1755            recorded_at: now,
1756            valid_from: now,
1757            valid_to: VALID_TO_OPEN,
1758        };
1759        self.insert_history_edge(history)?;
1760        self.insert_current_edge(src, rel, dst, fact, edge, now)?;
1761        self.next_edge += 1;
1762        Ok(())
1763    }
1764
1765    fn insert_current_edge(
1766        &mut self,
1767        src: EntityId,
1768        rel: TermId,
1769        dst: EntityId,
1770        fact: FactId,
1771        edge: EdgeId,
1772        valid_from: u64,
1773    ) -> Result<(), Error> {
1774        for (arena, a, b) in [
1775            (&mut self.edges_out, src, dst),
1776            (&mut self.edges_in, dst, src),
1777        ] {
1778            let slot = EdgeSlot {
1779                a,
1780                rel,
1781                b,
1782                fact,
1783                edge,
1784                valid_from,
1785            };
1786            if !arena.insert(&slot)? {
1787                let payload = arena
1788                    .payload_mut(&edge_key(a, rel, b))
1789                    .expect("insert reported a duplicate");
1790                let mut full = [0u8; EdgeSlot::SIZE];
1791                slot.write(&mut full);
1792                payload.copy_from_slice(&full[EdgeSlot::KEY_LEN..]);
1793            }
1794        }
1795        Ok(())
1796    }
1797
1798    fn insert_history_edge(&mut self, edge: EdgeHistorySlot) -> Result<(), Error> {
1799        self.edges_hist_out.insert(&edge)?;
1800        self.edges_hist_in.insert(&EdgeHistorySlot {
1801            a: edge.b,
1802            b: edge.a,
1803            ..edge
1804        })?;
1805        Ok(())
1806    }
1807
1808    /// Closes the open version of `(src, rel, dst)` and drops it from the
1809    /// current graph. Returns `false` when there is no such edge.
1810    ///
1811    /// The current slot names its own history record — `edge` and `valid_from`
1812    /// are that record's key tail — so both mirrors are addressed directly.
1813    fn close_current_edge(
1814        &mut self,
1815        now: u64,
1816        src: EntityId,
1817        rel: TermId,
1818        dst: EntityId,
1819    ) -> Result<bool, Error> {
1820        let Some(current) = self.current_edge(src, rel, dst) else {
1821            return Ok(false);
1822        };
1823        // A version never ends before it began, even if the caller's clock
1824        // moved backwards between the link and the unlink.
1825        let close_at = now.max(current.valid_from);
1826        let out_key = edge_history_key(src, current.valid_from, current.edge);
1827        let in_key = edge_history_key(dst, current.valid_from, current.edge);
1828        close_edge_history_payload(
1829            self.edges_hist_out
1830                .payload_mut(&out_key)
1831                .ok_or(Error::Corrupt("missing outgoing edge history"))?,
1832            close_at,
1833        );
1834        close_edge_history_payload(
1835            self.edges_hist_in
1836                .payload_mut(&in_key)
1837                .ok_or(Error::Corrupt("missing incoming edge history"))?,
1838            close_at,
1839        );
1840        let out_removed = self.edges_out.remove(&edge_key(src, rel, dst));
1841        let in_removed = self.edges_in.remove(&edge_key(dst, rel, src));
1842        if out_removed != in_removed {
1843            return Err(Error::Corrupt("edge mirrors disagree"));
1844        }
1845        Ok(out_removed)
1846    }
1847
1848    fn current_edge(&self, src: EntityId, rel: TermId, dst: EntityId) -> Option<EdgeSlot> {
1849        self.edges_out
1850            .get_slot(&edge_key(src, rel, dst))
1851            .map(EdgeSlot::read)
1852    }
1853
1854    /// Looks an entity up by its already-normalized name (read-only:
1855    /// neither the vocabulary nor the arenas change on a miss).
1856    fn lookup_entity_by_norm(&self, norm: &str) -> Option<EntityId> {
1857        let term = self.terms.lookup(norm)?;
1858        let mut from = [0u8; 8];
1859        key::write_u32(&mut from, term.0);
1860        let mut to = [0u8; 8];
1861        key::write_u32(&mut to, term.0);
1862        to[4..].copy_from_slice(&u32::MAX.to_be_bytes());
1863        self.by_name.range(&from, &to).next().map(|e| e.id)
1864    }
1865
1866    fn lookup_entity_name(&mut self, name: &str) -> Option<EntityId> {
1867        let mut norm = core::mem::take(&mut self.name_scratch);
1868        normalize_name(&mut self.tokenizer, name, &mut norm);
1869        let result = (!norm.is_empty())
1870            .then(|| self.lookup_entity_by_norm(&norm))
1871            .flatten();
1872        self.name_scratch = norm;
1873        result
1874    }
1875
1876    fn resolve_or_create_entity(&mut self, name: &str, now: u64) -> Result<EntityId, Error> {
1877        let mut norm = core::mem::take(&mut self.name_scratch);
1878        normalize_name(&mut self.tokenizer, name, &mut norm);
1879        if norm.is_empty() {
1880            self.name_scratch = norm;
1881            return Err(Error::Invalid("entity name has no indexable characters"));
1882        }
1883        let result = (|| {
1884            if let Some(found) = self.lookup_entity_by_norm(&norm) {
1885                return Ok(found);
1886            }
1887            let term = self.terms.intern(&norm)?;
1888            let id = EntityId(self.next_entity);
1889            let name_id = self.texts.push(name.as_bytes())?;
1890            self.entities.insert(&EntityRecord {
1891                id,
1892                name: name_id,
1893                name_term: term,
1894                created_at: now,
1895                flags: 0,
1896            })?;
1897            self.by_name.insert(&EntityByName {
1898                name_term: term,
1899                id,
1900            })?;
1901            self.next_entity += 1;
1902            Ok(id)
1903        })();
1904        self.name_scratch = norm;
1905        result
1906    }
1907
1908    fn journal_remember<S: Storage>(
1909        &mut self,
1910        store: &mut S,
1911        input: &RememberInput<'_>,
1912        revises: FactId,
1913        assigned: FactId,
1914    ) -> Result<(), Error> {
1915        let mut entry = Vec::new();
1916        Op::Remember {
1917            now: input.now,
1918            valid_from: input.valid_from.unwrap_or(input.now),
1919            entity: input.entity,
1920            text: input.text,
1921            tags: input.tags.to_vec(),
1922            links: input.links.to_vec(),
1923            vector: input.vector.map(<[f32]>::to_vec).unwrap_or_default(),
1924            metadata: input.metadata.map(<[_]>::to_vec).unwrap_or_default(),
1925            revises,
1926            assigned,
1927        }
1928        .encode(&mut entry);
1929        store
1930            .append_journal(&entry)
1931            .map_err(|e| Error::Storage(format!("{e:?}")))
1932    }
1933}
1934
1935impl core::fmt::Debug for Memory<'_> {
1936    /// Summary only — the contents are the user's memory, not ours to
1937    /// print.
1938    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1939        f.debug_struct("Memory")
1940            .field("facts", &self.facts.len())
1941            .field("entities", &self.entities.len())
1942            .field("terms", &self.terms.len())
1943            .finish()
1944    }
1945}
1946
1947/// Normalizes an entity name: its tokens joined by single spaces
1948/// ("  Проект   PlugMem " → "проект plugmem"). Deterministic and aligned
1949/// with search-time tokenization.
1950fn normalize_name(tokenizer: &mut Tokenizer, name: &str, out: &mut String) {
1951    out.clear();
1952    tokenizer.tokenize(name, &mut |token| {
1953        if !out.is_empty() {
1954            out.push(' ');
1955        }
1956        out.push_str(token);
1957    });
1958}