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