Skip to main content

velesdb_memory/
service.rs

1//! The memory service: five operations over the in-core Agent Memory SDK.
2//!
3//! # Role: the crate's assembler — declared, with a budget
4//!
5//! This module is the ONE place allowed to know every seam at once — store,
6//! embedder, extractor, fusion, autograph, online migration, the working
7//! context bridge — and wire them into the operations the adapters call.
8//! That role is why it is the crate's largest module (measured ~1 076 NLOC on
9//! 2026-08-17, against the ~500-NLOC file budget the child-module splits aim
10//! at), and why its size is a *declared* cost rather than an accident: an
11//! assembler that was split by size alone would scatter the wiring it exists
12//! to make readable.
13//!
14//! The budget still binds. Growth goes into child modules that keep private
15//! access (`fused_recall.rs` and `online_migration.rs` are the pattern —
16//! `#[path]` children of `service`, not siblings), never into this file; the
17//! next named cut is the working-context bridge (#1967, decided together with
18//! the store facetting of #1959). A change that adds an operation body here
19//! instead of a child module needs to say why in review.
20
21use std::collections::{HashMap, HashSet};
22#[cfg(feature = "persistence")]
23use std::path::Path;
24#[cfg(feature = "persistence")]
25use std::sync::Arc;
26
27use serde_json::{Map, Value};
28
29/// Structured metadata attached to a memory (the `ColumnStore` facet): exact-match
30/// fields like `project`, `author`, `type`, `status`, `date`. `content` and
31/// `_veles_expires_at` are reserved keys. [`crate::storage::AUTO_DATE_FIELD`]
32/// (`_veles_date`) is auto-populated by [`MemoryService::remember_with_ttl`]
33/// with today's date unless already present — see that method's docs.
34pub type Metadata = Map<String, Value>;
35
36use crate::clock;
37use crate::embedder::Embedder;
38use crate::error::MemoryError;
39use crate::extract::{ExtractedAttribute, ExtractedRelation, Extractor};
40use crate::id;
41use crate::model::{
42    ColumnFilter, EntityProfile, EntityRelation, Explanation, Link, MemoryEdge, MemoryNode,
43    Recollection, RememberedExtraction, UnrelateOutcome,
44};
45#[cfg(feature = "persistence")]
46use crate::mutation::MutationObserver;
47#[cfg(feature = "persistence")]
48use crate::storage::NativeStore;
49use crate::storage::{
50    is_reserved_key, strip_reserved_keys, ColumnStore, FactStore, GraphStore, RecallStore,
51    AUTO_DATE_FIELD,
52};
53
54/// [`MemoryService::recall_fused`] and its helpers — split out to keep this
55/// file under the crate's 500-NLOC-per-file budget, same pattern as
56/// `velesdb-core`'s `database/*.rs` split. A child module of `service`, so it
57/// shares full access to `MemoryService`'s private fields and methods.
58#[path = "fused_recall.rs"]
59mod fused_recall;
60
61/// The graph facet — `relate`/`forget`/hubs/`why`/`traverse` — same split,
62/// same reason; see `service_graph.rs`'s module doc.
63#[path = "service_graph.rs"]
64mod graph;
65
66#[cfg(feature = "persistence")]
67#[allow(dead_code)]
68#[path = "online_migration.rs"]
69mod online_migration;
70#[cfg(feature = "persistence")]
71pub(crate) use online_migration::recover_startup;
72#[cfg(feature = "mcp")]
73pub(crate) use online_migration::{
74    JobPhase, JobTarget, LiveGenerationSlot, MigrationStartConfig, MigrationStatus,
75    OnlineMigrationManager,
76};
77
78/// [`MemoryService::feedback`] and the recall re-ranking it drives (RL Memory).
79/// A child module of `service`, like [`fused_recall`], so it uses
80/// `MemoryService`'s private `store` directly. Gated on `persistence`: it
81/// builds on `velesdb-core`'s agent SDK (`ReinforcementStrategy`), itself
82/// behind that feature, and a durable learned confidence is meaningless on the
83/// in-memory (WASM) backend.
84#[cfg(feature = "persistence")]
85#[path = "reinforce.rs"]
86mod reinforce;
87
88/// The context compiler's memory bridge (`compile_context`,
89/// `retrieve_context_source`, `context_savings`, working contexts). A child
90/// module of `service`, like [`fused_recall`], so it reuses the private
91/// `store_fact`/`HUB_FIELD` system-fact machinery — compiler system facts
92/// (sources, events, working contexts) are hub-marked so they never surface
93/// in normal recall.
94#[cfg(feature = "context")]
95#[path = "context/memory_bridge.rs"]
96mod memory_bridge;
97
98/// Reserved metadata key marking an entity hub auto-created by
99/// [`MemoryService::remember_extracted`] (value `true`). Namespaced under the
100/// system `_veles_` prefix so it can never collide with a caller's own metadata,
101/// and rejected from caller-supplied metadata/filters (see [`is_reserved_key`]).
102/// Hubs are internal graph scaffolding — they connect facts that share a topic —
103/// so they are excluded from unfiltered recall and from `why` seeds.
104///
105/// Re-exported from [`crate::storage`] rather than spelled out again here: it
106/// is one of the five markers [`crate::storage::INTERNAL_MARKER_FIELDS`]
107/// excludes from `recall_where`, and a second literal could drift from that
108/// list without any test noticing.
109use crate::storage::HUB_FIELD;
110/// Salt mixed into a hub's stable id so the hub id space is disjoint from
111/// natural fact ids: a caller fact whose text happens to equal a hub's display
112/// content (`Entity: rust`) can never collide with, or overwrite, the hub.
113const HUB_ID_SALT: &str = "\u{0}_veles_entity_hub\u{0}";
114/// Edge label a hub uses to point back at a fact it tags (the hub → fact
115/// direction). [`fused_recall`] reads this to recognise which edges in a
116/// `why()` walk crossed a hub, so it can weight the reached fact by that
117/// hub's specificity instead of a flat constant.
118const MENTIONS_RELATION: &str = "mentions";
119/// Edge label a fact uses to point at a hub it is tagged with (the fact → hub
120/// direction) — [`MENTIONS_RELATION`]'s bipartite twin, written by
121/// [`MemoryService::remember_extracted`]'s `wire_entity`.
122const ABOUT_RELATION: &str = "about";
123
124/// Local-first agent memory backed by a single `VelesDB` instance.
125///
126/// Generic over the [`Embedder`] so production can use an on-device model while
127/// tests use a deterministic, network-free one, and over the [`FactStore`]
128/// backend `S` so the same orchestration runs over the native, file-backed
129/// engine (the default — nothing changes for existing callers) or any other
130/// backend that implements the storage facets it uses (e.g. an in-memory one
131/// for WASM). Methods needing recall, graph, or columnar capability carry
132/// that facet as an extra bound — a partial backend simply does not have
133/// those methods (#1959).
134///
135/// Two definitions, `persistence`-gated: the default type parameter itself
136/// references [`NativeStore`], which doesn't exist as a type at all without
137/// the feature, so a `persistence`-free build (e.g. `velesdb-wasm`) drops the
138/// default and every caller names its own storage backend explicitly.
139#[cfg(feature = "persistence")]
140pub struct MemoryService<E: Embedder, S: FactStore = NativeStore> {
141    store: S,
142    embedder: E,
143    autograph: Option<crate::extract::DynExtractor>,
144    autograph_queue: AutographQueue,
145    generation_gate: parking_lot::RwLock<()>,
146}
147#[cfg(not(feature = "persistence"))]
148pub struct MemoryService<E: Embedder, S: FactStore> {
149    store: S,
150    embedder: E,
151    autograph: Option<crate::extract::DynExtractor>,
152    autograph_queue: AutographQueue,
153}
154
155struct GenerationGuard<'a> {
156    #[cfg(feature = "persistence")]
157    _guard: parking_lot::RwLockReadGuard<'a, ()>,
158    #[cfg(not(feature = "persistence"))]
159    _lifetime: std::marker::PhantomData<&'a ()>,
160}
161
162/// One deferred autograph: the stored fact a background worker will read for
163/// entities, edges and attributes (#1846).
164// The fields are read only on the worker path, which `spawn_autograph_worker`
165// cfg-gates off wasm32 (no threads there) — without this the wasm check dies
166// on dead_code under -D warnings.
167#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
168struct AutographJob {
169    fact_id: u64,
170    fact: String,
171}
172
173/// The decoupling state of [`MemoryService::autograph`] (#1846).
174///
175/// Empty by default: every construction path starts with autograph running
176/// INLINE, exactly as before — the WASM binding has no threads, library
177/// consumers keep the synchronous contract, and every existing test stays
178/// meaningful. [`MemoryService::spawn_autograph_worker`] fills `tx`, after
179/// which `remember` only ENQUEUES: the caller stops paying the generation on
180/// its response path — 46 s measured for a 12-word fact on the production
181/// daemon, versus a 0.12 s embedding — and the worker wires the graph behind.
182///
183/// `dropped` counts enrichments refused by a FULL queue or skipped by a
184/// closing worker. Non-negotiably visible: a burst that outruns the
185/// extractor loses graph structure, and a loss nobody can see is the exact
186/// defect class #1820 closed for responses.
187///
188/// `closing` is the shutdown latch: the handle's drop raises it BEFORE
189/// removing the sender, so the worker finishes the job in flight and SKIPS
190/// what is still queued (counted, one aggregated warning) instead of
191/// draining a queue of generations — 64 × a 46 s model would hold the
192/// daemon's exit for tens of minutes. Re-armed by each spawn.
193#[derive(Default)]
194// `closing` is read only by the worker/drop path, absent on wasm32 — same
195// rationale as `AutographJob` above.
196#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
197struct AutographQueue {
198    tx: parking_lot::Mutex<Option<std::sync::mpsc::SyncSender<AutographJob>>>,
199    dropped: std::sync::atomic::AtomicU64,
200    closing: std::sync::atomic::AtomicBool,
201}
202
203/// Join guard for the background autograph worker.
204///
205/// Dropping it raises the closing latch, takes the sender out of the
206/// service, and JOINS the worker: the job in flight completes, the
207/// still-queued ones are SKIPPED — counted in the drop counter, one
208/// aggregated warning — and only then does the drop return. Tests get
209/// determinism; the daemon's shutdown waits for at most ONE generation,
210/// never a queue of them.
211pub struct AutographWorkerHandle {
212    close_queue: Option<Box<dyn FnOnce() + Send + Sync>>,
213    join: Option<std::thread::JoinHandle<()>>,
214}
215
216impl Drop for AutographWorkerHandle {
217    fn drop(&mut self) {
218        if let Some(close) = self.close_queue.take() {
219            close();
220        }
221        if let Some(join) = self.join.take() {
222            let _ = join.join();
223        }
224    }
225}
226
227#[cfg(feature = "persistence")]
228impl<E: Embedder> MemoryService<E, NativeStore> {
229    /// Open (or create) a native, file-backed memory store at `path`, using
230    /// `embedder` for text vectorization. The store never leaves this directory.
231    ///
232    /// # Errors
233    /// Returns [`MemoryError`] if the store cannot be opened or the agent
234    /// memory cannot be initialized for the embedder's dimension.
235    pub fn open<P: AsRef<Path>>(path: P, embedder: E) -> Result<Self, MemoryError> {
236        let store = NativeStore::open(path, embedder.dimension())?;
237        Ok(Self {
238            store,
239            embedder,
240            autograph: None,
241            autograph_queue: AutographQueue::default(),
242            generation_gate: parking_lot::RwLock::new(()),
243        })
244    }
245
246    pub(crate) fn install_mutation_observer(
247        &self,
248        observer: Option<Arc<dyn MutationObserver>>,
249    ) -> Result<(), MemoryError> {
250        let _generation = self.generation_gate.write();
251        self.store.set_mutation_observer(observer)
252    }
253
254    pub(crate) fn migration_capture_active(&self) -> bool {
255        self.store.mutation_capture_active()
256    }
257}
258
259impl<E: Embedder, S: FactStore> MemoryService<E, S> {
260    /// Build a service directly over a `store` backend, bypassing
261    /// [`Self::open`]'s filesystem-specific setup — the constructor a
262    /// non-native backend (e.g. `velesdb-wasm`'s in-memory store) uses.
263    pub fn with_store(store: S, embedder: E) -> Self {
264        Self {
265            store,
266            embedder,
267            autograph: None,
268            autograph_queue: AutographQueue::default(),
269            #[cfg(feature = "persistence")]
270            generation_gate: parking_lot::RwLock::new(()),
271        }
272    }
273
274    #[cfg(feature = "persistence")]
275    fn enter_generation(&self) -> GenerationGuard<'_> {
276        GenerationGuard {
277            _guard: self.generation_gate.read(),
278        }
279    }
280
281    #[cfg(not(feature = "persistence"))]
282    fn enter_generation(&self) -> GenerationGuard<'_> {
283        let _ = self;
284        GenerationGuard {
285            _lifetime: std::marker::PhantomData,
286        }
287    }
288
289    /// Turn on **autograph**: every [`Self::remember`] additionally reads the
290    /// stored fact for entities, entity→entity edges and entity attributes,
291    /// and wires them — so the knowledge graph builds itself from ordinary
292    /// `remember` calls, with no separate [`Self::remember_extracted`].
293    ///
294    /// Opt-in, and off unless this is called. It runs in one of two modes:
295    /// **inline** by default — the enrichment costs one generation per
296    /// `remember`, on the caller's write path, which is a real latency and
297    /// availability change: a memory write that silently depends on a local
298    /// model being up is not a default anyone should inherit — or
299    /// **decoupled** when [`Self::spawn_autograph_worker`] is active, where
300    /// `remember` returns as soon as the fact is durably stored and the
301    /// derived edges lag by one generation (an `entity`/`why` read issued
302    /// immediately after may not see them yet; the fact itself is always
303    /// immediately readable).
304    ///
305    /// The caller's fact is stored **verbatim and first**. Autograph only
306    /// *adds* structure around it; it never rewrites or replaces what the
307    /// caller asked to remember.
308    #[must_use]
309    pub fn with_autograph(mut self, extractor: crate::extract::DynExtractor) -> Self {
310        self.autograph = Some(extractor);
311        self
312    }
313
314    /// Remember a `fact`, optionally tagging it with structured `metadata`
315    /// (`ColumnStore` facet) and linking it to existing memories (graph facet).
316    /// Returns the stable id of the fact (idempotent on identical content).
317    ///
318    /// The stored metadata is auto-stamped with today's date under
319    /// [`crate::storage::AUTO_DATE_FIELD`] unless `metadata` already carries
320    /// that key — see [`Self::remember_with_ttl`] (this method's only caller)
321    /// for the full contract.
322    ///
323    /// Every link is validated — target existence AND relation label —
324    /// *before* the fact is stored, so bad link input never leaves the fact
325    /// half-written. If an edge write itself fails afterwards (e.g. a target
326    /// expiring concurrently), a freshly-created fact is rolled back; a
327    /// re-remembered fact keeps its updated payload (re-remembering updates
328    /// metadata by design, and deleting it would destroy prior state).
329    /// Concurrent `remember`s of identical content are last-writer-wins,
330    /// not transactional.
331    ///
332    /// # Errors
333    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace facts,
334    /// [`MemoryError::FactTooLarge`] if the fact exceeds
335    /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`],
336    /// [`MemoryError::SelfRelation`] if a link points the fact at itself,
337    /// [`MemoryError::ReservedKey`] if `metadata` names a reserved key
338    /// (`content` or any `_veles_`-prefixed system key, [`crate::storage::AUTO_DATE_FIELD`]
339    /// excepted),
340    /// [`MemoryError::MetadataTooLarge`] if `metadata` exceeds
341    /// [`crate::limits::MAX_METADATA_BYTES`],
342    /// [`MemoryError::UnknownMemory`] if a link points at a missing memory,
343    /// [`MemoryError::InvalidRelation`] for a bad relation label,
344    /// [`MemoryError::RollbackFailed`] if an edge write failed and the
345    /// compensating delete also failed (the fact remains stored),
346    /// or a storage error if persistence fails.
347    pub fn remember(
348        &self,
349        fact: &str,
350        links: &[Link],
351        metadata: Option<&Metadata>,
352    ) -> Result<u64, MemoryError>
353    where
354        S: GraphStore,
355    {
356        let _generation = self.enter_generation();
357        self.remember_inner(fact, links, metadata, None, true)
358    }
359
360    /// Like [`Self::remember`], but the fact **expires after `ttl_seconds`**.
361    ///
362    /// The expiry is a durable TTL — persisted with the fact (reserved
363    /// `_veles_expires_at` payload field), so it survives a process restart, and
364    /// expired facts stop being recalled. `None` stores the fact permanently,
365    /// exactly like [`Self::remember`]; an explicit `Some(0)` is **refused**
366    /// ([`MemoryError::ZeroTtl`]) rather than silently normalised to
367    /// "permanent", which is the opposite of what a caller writing `0` means.
368    /// Metadata and a TTL combine: the metadata is written and the expiry
369    /// preserved.
370    ///
371    /// The stored metadata is **auto-stamped with today's date** under
372    /// [`crate::storage::AUTO_DATE_FIELD`] (`_veles_date`, a `YYYYMMDD`
373    /// integer read from the system clock at write time — see
374    /// [`crate::clock::today_ymd`]) whenever `metadata` doesn't already carry
375    /// that key; an explicit value in `metadata` (e.g. to date a fact
376    /// retroactively) is never overwritten. No clock is available on
377    /// `wasm32-unknown-unknown`, so that target stamps nothing and `metadata`
378    /// passes through unchanged. This is the ONE place in the crate that
379    /// reads wall-clock time on the write path — the context compiler
380    /// (`compile_context` and friends) stays clock-free and deterministic,
381    /// unaffected by this stamp (it never re-derives a date from `now()`,
382    /// only ever reads whatever a fact already carries).
383    ///
384    /// Because [`Self::remember_extracted`] stores each extracted fact via
385    /// [`Self::remember`] (which delegates here), it gets the same auto-stamp
386    /// for free — entity hubs it also creates go through [`Self::store_fact`]
387    /// directly and are never stamped, since they are internal graph
388    /// scaffolding, not caller facts.
389    ///
390    /// # Errors
391    /// Same as [`Self::remember`].
392    pub fn remember_with_ttl(
393        &self,
394        fact: &str,
395        links: &[Link],
396        metadata: Option<&Metadata>,
397        ttl_seconds: Option<u64>,
398    ) -> Result<u64, MemoryError>
399    where
400        S: GraphStore,
401    {
402        let _generation = self.enter_generation();
403        self.remember_inner(fact, links, metadata, ttl_seconds, true)
404    }
405
406    /// The shared write path. `run_autograph` is false for the one caller that
407    /// has ALREADY extracted the passage — [`Self::remember_extracted`] — so a
408    /// service with autograph on does not run a second generation per stored
409    /// fact, re-deriving what it just computed.
410    fn remember_inner(
411        &self,
412        fact: &str,
413        links: &[Link],
414        metadata: Option<&Metadata>,
415        ttl_seconds: Option<u64>,
416        run_autograph: bool,
417    ) -> Result<u64, MemoryError>
418    where
419        S: GraphStore,
420    {
421        let fact = fact.trim();
422        self.validate_write(fact, links, metadata, ttl_seconds)?;
423        let fact_id = id::stable_id(fact);
424        reject_self_links(fact_id, links)?;
425        let existed_before = !links.is_empty() && self.store.get(fact_id)?.is_some();
426        self.write_fact(fact_id, fact, metadata, ttl_seconds)?;
427        self.link_or_rollback(fact_id, links, existed_before)?;
428        self.autograph_if(run_autograph, fact_id, fact);
429        Ok(fact_id)
430    }
431
432    /// Every deterministic rejection, before anything is written: a blank or
433    /// over-long fact, an explicit zero TTL, reserved or oversized metadata,
434    /// and each link's label and target. Run as one pass so a bad input never
435    /// leaves a half-written fact behind.
436    fn validate_write(
437        &self,
438        fact: &str,
439        links: &[Link],
440        metadata: Option<&Metadata>,
441        ttl_seconds: Option<u64>,
442    ) -> Result<(), MemoryError> {
443        validate_fact(fact)?;
444        reject_zero_ttl(ttl_seconds)?;
445        reject_reserved_keys(metadata)?;
446        reject_oversized_metadata(metadata)?;
447        self.validate_links(links)
448    }
449
450    /// Embed the fact and persist it with its date-stamped metadata and TTL.
451    fn write_fact(
452        &self,
453        fact_id: u64,
454        fact: &str,
455        metadata: Option<&Metadata>,
456        ttl_seconds: Option<u64>,
457    ) -> Result<(), MemoryError> {
458        let embedding = self.embedder.embed(fact)?;
459        let stamped = stamp_with_today(metadata);
460        // `ttl_seconds` is already known positive-or-absent: `validate_write`
461        // refuses an explicit `Some(0)` before any of this runs.
462        self.store_fact(fact_id, fact, &embedding, stamped.as_ref(), ttl_seconds)
463    }
464
465    /// Validate EVERY link property — relation label and target existence —
466    /// before any write, so all deterministic link failures happen while
467    /// nothing has been stored or overwritten yet.
468    fn validate_links(&self, links: &[Link]) -> Result<(), MemoryError> {
469        for link in links {
470            validate_relation(&link.relation)?;
471        }
472        self.ensure_link_targets_exist(links)
473    }
474
475    /// Write the edges, undoing a freshly-created fact if one of them fails.
476    ///
477    /// Links are fully pre-validated by [`Self::validate_links`], so an edge
478    /// write can only fail here on a race (e.g. a target's TTL lapsing since
479    /// the pre-check). Roll a FRESH fact back (delete cascades any edges
480    /// already created); a fact that existed before the call is kept —
481    /// deleting it would destroy prior state, and its updated payload stands
482    /// per re-remember's update semantics. The existence probe and the delete
483    /// are not one atomic unit: a concurrent remember of identical content
484    /// between them is last-writer-wins (documented on [`Self::remember`]).
485    fn link_or_rollback(
486        &self,
487        fact_id: u64,
488        links: &[Link],
489        existed_before: bool,
490    ) -> Result<(), MemoryError>
491    where
492        S: GraphStore,
493    {
494        let Err(cause) = self.relate_links(fact_id, links) else {
495            return Ok(());
496        };
497        if existed_before {
498            return Err(cause);
499        }
500        match self.store.delete(fact_id) {
501            Ok(()) => Err(cause),
502            Err(rollback) => Err(MemoryError::RollbackFailed {
503                cause: Box::new(cause),
504                rollback: Box::new(rollback),
505            }),
506        }
507    }
508
509    /// Run [`Self::autograph`] only when this write path asked for it — the
510    /// branch lives here rather than in the write path itself.
511    ///
512    /// With a worker spawned ([`Self::spawn_autograph_worker`]), the job is
513    /// ENQUEUED and this returns immediately: the enrichment leaves the
514    /// caller's response path (#1846). A FULL queue drops the job, counted
515    /// in [`Self::autograph_dropped`] — losing structure is recoverable by
516    /// re-remembering, stalling every write behind a slow model is not. A
517    /// disconnected queue (worker gone) falls back inline, so the graph
518    /// keeps building even if the worker died.
519    fn autograph_if(&self, run: bool, fact_id: u64, fact: &str)
520    where
521        S: GraphStore,
522    {
523        if !run {
524            return;
525        }
526        let guard = self.autograph_queue.tx.lock();
527        if let Some(tx) = guard.as_ref() {
528            use std::sync::mpsc::TrySendError;
529            match tx.try_send(AutographJob {
530                fact_id,
531                fact: fact.to_owned(),
532            }) {
533                Ok(()) => return,
534                Err(TrySendError::Full(_)) => {
535                    self.autograph_queue
536                        .dropped
537                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
538                    #[cfg(feature = "mcp")]
539                    tracing::warn!(
540                        fact_id,
541                        "autograph queue full: enrichment dropped — the fact is \
542                         stored, its graph structure is not; re-remembering \
543                         rebuilds it"
544                    );
545                    return;
546                }
547                Err(TrySendError::Disconnected(_)) => {
548                    // fall through to the inline path below
549                }
550            }
551        }
552        drop(guard);
553        self.autograph(fact_id, fact);
554    }
555
556    /// How many autograph enrichments a FULL queue refused since this
557    /// service was built (#1846). The facts themselves were stored; only
558    /// their graph wiring was skipped, and re-remembering a fact rebuilds it.
559    #[must_use]
560    pub fn autograph_dropped(&self) -> u64 {
561        self.autograph_queue
562            .dropped
563            .load(std::sync::atomic::Ordering::Relaxed)
564    }
565
566    /// Whether the background autograph queue is OPEN — a worker is spawned
567    /// and `remember` enqueues instead of running the enrichment inline.
568    /// Turns false the moment a worker handle's drop closes the queue.
569    #[must_use]
570    pub fn autograph_queue_open(&self) -> bool {
571        self.autograph_queue.tx.lock().is_some()
572    }
573
574    /// Whether an autograph extractor is configured at all.
575    #[must_use]
576    pub fn has_autograph(&self) -> bool {
577        self.autograph.is_some()
578    }
579
580    /// The total number of live tracked facts, internal entity hubs included
581    /// — the store's [`FactStore::count`], relayed for `memory_status`.
582    #[must_use]
583    pub fn fact_count(&self) -> usize {
584        let _generation = self.enter_generation();
585        self.store.count()
586    }
587
588    /// The total number of graph edges, when the backend can say —
589    /// [`GraphStore::edge_count`], relayed for `memory_status`. `None`
590    /// means "cannot say", never "zero": the two answers tell a caller
591    /// different things about `why()`.
592    #[must_use]
593    pub fn edge_count(&self) -> Option<usize>
594    where
595        S: GraphStore,
596    {
597        let _generation = self.enter_generation();
598        self.store.edge_count()
599    }
600
601    /// One page of the store's facts, for auditing — "what does my agent
602    /// know?" — which `recall` structurally cannot answer: it ranks by
603    /// resemblance to a query, and what resembles nothing you thought to
604    /// ask stays invisible.
605    ///
606    /// The store hands back raw pages ([`FactStore::list`]); the
607    /// visibility policy is applied here, once, for every backend: internal
608    /// entity hubs are skipped unless `include_internal` (they are the
609    /// graph's scaffolding, not the user's facts), reserved `_veles_*` keys
610    /// are stripped exactly as `recall` strips them (the auto-stamped date
611    /// survives — an audit legitimately asks WHEN), and `filter` keeps only
612    /// facts whose metadata equals every given key. A filtered page may
613    /// come back sparse — the cursor still advances over what was skipped,
614    /// so the WALK stays exhaustive.
615    ///
616    /// # Errors
617    /// Returns [`MemoryError`] if the backend cannot enumerate or the walk
618    /// fails.
619    pub fn list(
620        &self,
621        cursor: Option<u64>,
622        limit: usize,
623        filter: Option<&Metadata>,
624        include_internal: bool,
625    ) -> Result<(Vec<crate::model::ListedMemory>, Option<u64>), MemoryError> {
626        let _generation = self.enter_generation();
627        let limit = crate::limits::clamp_recall_limit(limit.max(1));
628        let (page, next) = self.store.list(cursor, limit)?;
629        let memories = page
630            .into_iter()
631            .filter_map(|fact| audited(fact, filter, include_internal))
632            .collect();
633        Ok((memories, next))
634    }
635}
636
637#[cfg(not(target_arch = "wasm32"))]
638impl<E, S> MemoryService<E, S>
639where
640    E: Embedder + Send + Sync + 'static,
641    S: FactStore + Send + Sync + 'static,
642{
643    /// The autograph worker's whole life, run on the spawned thread: ends
644    /// when every sender is gone — i.e. when the handle's drop takes the
645    /// sender back out of the service. Once the closing latch is up,
646    /// still-queued jobs are SKIPPED: the exit pays for the job in flight,
647    /// never for the queue.
648    fn autograph_worker_loop(&self, rx: &std::sync::mpsc::Receiver<AutographJob>)
649    where
650        S: GraphStore,
651    {
652        let mut skipped_on_close: u64 = 0;
653        for job in rx {
654            if self
655                .autograph_queue
656                .closing
657                .load(std::sync::atomic::Ordering::Acquire)
658            {
659                skipped_on_close += 1;
660                continue;
661            }
662            let _generation = self.enter_generation();
663            self.autograph(job.fact_id, &job.fact);
664        }
665        if skipped_on_close > 0 {
666            self.autograph_queue
667                .dropped
668                .fetch_add(skipped_on_close, std::sync::atomic::Ordering::Relaxed);
669            // ONE aggregated line, not one per job (#1834's rule).
670            #[cfg(feature = "mcp")]
671            tracing::warn!(
672                skipped = skipped_on_close,
673                "autograph worker closing: queued enrichments skipped — \
674                 the facts are stored, their graph structure is not; \
675                 re-remembering rebuilds it"
676            );
677        }
678    }
679
680    /// Move autograph off the response path: spawn ONE background worker
681    /// consuming a bounded queue, so `remember` returns as soon as the fact
682    /// is durably stored and the graph is wired behind (#1846).
683    ///
684    /// Measured motivation: with the production extractor, an inline
685    /// autograph held every `remember` for 46-52 s while the embedding cost
686    /// 0.12 s — and the MCP client timed out mid-generation, making a stored
687    /// fact indistinguishable from a lost one (#1839).
688    ///
689    /// The read-after-write contract changes, deliberately and visibly: an
690    /// `entity()` issued right after `remember` may not see the new edges
691    /// yet. The fact itself is always readable immediately — only the
692    /// DERIVED structure lags by one generation.
693    ///
694    /// One worker on purpose: the store is single-writer, and a second
695    /// in-flight generation would only add contention, not throughput.
696    /// `capacity` bounds the queue ([`crate::limits::MAX_AUTOGRAPH_QUEUE`]
697    /// is the daemon's choice); a full queue DROPS new enrichments, counted
698    /// by [`Self::autograph_dropped`] and logged — never silent, never
699    /// blocking the write path.
700    ///
701    /// # Errors
702    /// Returns [`MemoryError::Extract`] when a worker is already spawned for
703    /// this service — two workers would race the single-writer store for no
704    /// gain — or when the OS refuses the thread.
705    pub fn spawn_autograph_worker(
706        self: &std::sync::Arc<Self>,
707        capacity: usize,
708    ) -> Result<AutographWorkerHandle, MemoryError>
709    where
710        S: GraphStore,
711    {
712        let (tx, rx) = std::sync::mpsc::sync_channel::<AutographJob>(capacity);
713        {
714            let mut guard = self.autograph_queue.tx.lock();
715            if guard.is_some() {
716                return Err(MemoryError::Extract(crate::extract::ExtractError::Backend(
717                    "autograph worker already spawned for this service".to_owned(),
718                )));
719            }
720            *guard = Some(tx);
721            // Re-arm the shutdown latch under the same lock that installs
722            // the sender: a previous worker's close must not poison this one
723            // into skipping every job it will ever receive.
724            self.autograph_queue
725                .closing
726                .store(false, std::sync::atomic::Ordering::Release);
727        }
728        let worker_service = std::sync::Arc::clone(self);
729        let join = std::thread::Builder::new()
730            .name("velesdb-autograph".to_owned())
731            .spawn(move || worker_service.autograph_worker_loop(&rx))
732            .map_err(|err| {
733                MemoryError::Extract(crate::extract::ExtractError::Backend(format!(
734                    "spawn autograph worker: {err}"
735                )))
736            })?;
737        let closer_service = std::sync::Arc::clone(self);
738        Ok(AutographWorkerHandle {
739            close_queue: Some(Box::new(move || {
740                // Latch FIRST, sender out second: the worker observes the
741                // latch no later than the queue's end, so it cannot start
742                // draining jobs the shutdown meant to skip.
743                closer_service
744                    .autograph_queue
745                    .closing
746                    .store(true, std::sync::atomic::Ordering::Release);
747                closer_service.autograph_queue.tx.lock().take();
748            })),
749            join: Some(join),
750        })
751    }
752}
753
754impl<E: Embedder, S: FactStore> MemoryService<E, S> {
755    /// Autograph one just-stored fact: read the entities, entity→entity edges
756    /// and attributes it states, and wire them around it.
757    ///
758    /// **Deliberately infallible.** The caller's fact is already durably
759    /// stored by the time this runs, and the caller asked to remember a fact —
760    /// not to run a model. Propagating an extraction failure would turn a
761    /// successful write into a reported error, and an agent that sees
762    /// `remember` fail will sensibly retry it, re-running the generation and
763    /// failing again. So a model that is down, slow, or talking nonsense costs
764    /// the *graph enrichment* and nothing else: the memory is kept, the id is
765    /// returned, and the next `remember` tries again.
766    ///
767    /// The trade-off is that a persistently broken extractor degrades silently
768    /// to plain `remember`. That is the right way round — losing structure is
769    /// recoverable by re-remembering, losing the fact is not.
770    fn autograph(&self, fact_id: u64, fact: &str)
771    where
772        S: GraphStore,
773    {
774        let Some(extractor) = self.autograph.as_ref() else {
775            return;
776        };
777        let Ok(mut extraction) = extractor.extract_graph(fact) else {
778            return;
779        };
780        // Privacy invariant: autograph derives structure FROM a stored fact, so
781        // if the fact no longer exists, none of its derived structure may be
782        // created. With a background worker a job can sit queued for
783        // minutes-to-hours and its generation runs for tens of seconds, so a
784        // `forget` issued in between must win — a permanent deletion cannot be
785        // undone by a stale enrichment resurrecting the entity hubs. Re-check
786        // once the generation has returned, before any wiring: this closes the
787        // whole queue-plus-generation window, the common case, completely.
788        if !self.fact_exists(fact_id) {
789            return;
790        }
791        crate::extract::orient_kinship(fact, &mut extraction.relations);
792        let mut entity_ids: HashMap<String, u64> = HashMap::new();
793        let mut edges: HashSet<(u64, u64, String)> = HashSet::new();
794        // The caller's fact is the node the topics attach to — the extracted
795        // facts are NOT stored as separate memories here, which is what
796        // separates autograph from `remember_extracted`: one `remember` call
797        // must still produce exactly one caller-visible memory.
798        for extracted in &extraction.facts {
799            let _ = self.wire_entities(fact_id, &extracted.entities, &mut entity_ids, &mut edges);
800        }
801        // A concurrent `forget` can still race the wiring writes above between
802        // the first check and here. Re-check once more before the hub↔hub and
803        // attribute writes — neither of which references the source fact, so
804        // `ensure_exists` cannot catch a retired fact for them — leaving a
805        // residual window of a single already-committed generation, not the
806        // whole job.
807        if !self.fact_exists(fact_id) {
808            return;
809        }
810        let _ = self.wire_relations(&extraction.relations, &mut entity_ids, &mut edges);
811        let _ = self.wire_attributes(&extraction.attributes, &mut entity_ids);
812    }
813
814    /// Cheap "is this fact still stored?" probe gating [`Self::autograph`]'s
815    /// wiring: the same `store.get` existence check [`Self::forget`] and
816    /// [`Self::ensure_exists`] use. A store read error answers `false` —
817    /// autograph must never fabricate structure for a fact it cannot prove is
818    /// still there, and a missing (or unprovable) fact is a clean skip, never
819    /// an error on this deliberately-infallible path.
820    fn fact_exists(&self, fact_id: u64) -> bool {
821        matches!(self.store.get(fact_id), Ok(Some(_)))
822    }
823
824    /// Create each outgoing link from `fact_id`.
825    ///
826    /// Precondition: every label was already validated by
827    /// [`Self::remember_with_ttl`]'s pre-write pass (its only caller) —
828    /// no re-check here, so the validation rule lives in exactly one
829    /// place on this path.
830    fn relate_links(&self, fact_id: u64, links: &[Link]) -> Result<(), MemoryError>
831    where
832        S: GraphStore,
833    {
834        for link in links {
835            self.store.relate(fact_id, link.target, &link.relation)?;
836        }
837        Ok(())
838    }
839
840    /// Remember a passage of raw `text` by running it through an [`Extractor`]
841    /// and storing every fact it yields, **auto-wiring the fact↔entity graph**.
842    ///
843    /// This is the commodity on top of [`Self::remember`]'s bring-your-own-links
844    /// core: each extracted fact is stored (tagged with `metadata`), each salient
845    /// topic becomes a deduplicated hub memory, and every fact is linked to its
846    /// topics with a bidirectional `about`/`mentions` edge. Two facts sharing a
847    /// topic therefore become reachable from one another, so [`Self::why`] has a
848    /// real graph to traverse with no manual `relate()`.
849    ///
850    /// Entity hubs are content-addressed, so the same topic seen across many
851    /// calls collapses onto one hub. Returns the ids of the stored facts (entity
852    /// hubs excluded), in extraction order, plus how many facts were skipped
853    /// for exceeding the embeddable cap — one unusable fact must not cost the
854    /// others, the policy every other stage of this pipeline already follows
855    /// (a malformed triple is skipped, a blank entity is skipped).
856    ///
857    /// # Errors
858    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace `text`,
859    /// [`MemoryError::Extract`] if extraction fails, [`MemoryError::ReservedKey`]
860    /// if `metadata` names a reserved key, [`MemoryError::MetadataTooLarge`] if
861    /// `metadata` exceeds [`crate::limits::MAX_METADATA_BYTES`], or a storage
862    /// error if persistence fails. A fact past
863    /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`] is NOT an error: it is
864    /// counted in [`RememberedExtraction::skipped_over_cap`] and the call
865    /// carries on.
866    pub fn remember_extracted<X: Extractor>(
867        &self,
868        text: &str,
869        extractor: &X,
870        metadata: Option<&Metadata>,
871    ) -> Result<RememberedExtraction, MemoryError>
872    where
873        S: GraphStore,
874    {
875        let _generation = self.enter_generation();
876        let extraction = Self::extract_passage(text, extractor)?;
877        self.store_extraction_inner(&extraction, metadata)
878    }
879
880    /// Extract and orient one passage without writing any memory state.
881    ///
882    /// Kept separate from [`Self::store_extraction`] so the MCP durable-job
883    /// worker can persist the model output before the first graph write. A
884    /// restart after that boundary replays stable data instead of generating a
885    /// second, potentially different extraction.
886    pub(crate) fn extract_passage<X: Extractor>(
887        text: &str,
888        extractor: &X,
889    ) -> Result<crate::extract::Extraction, MemoryError> {
890        let text = text.trim();
891        if text.is_empty() {
892            return Err(MemoryError::EmptyFact);
893        }
894        let mut extraction = extractor.extract_graph(text)?;
895        crate::extract::orient_kinship(text, &mut extraction.relations);
896        Ok(extraction)
897    }
898
899    /// Store a previously generated extraction through the same idempotent
900    /// fact, hub, edge, and attribute primitives as [`Self::remember_extracted`].
901    #[cfg(feature = "mcp")]
902    pub(crate) fn store_extraction(
903        &self,
904        extraction: &crate::extract::Extraction,
905        metadata: Option<&Metadata>,
906    ) -> Result<RememberedExtraction, MemoryError>
907    where
908        S: GraphStore,
909    {
910        let _generation = self.enter_generation();
911        self.store_extraction_inner(extraction, metadata)
912    }
913
914    fn store_extraction_inner(
915        &self,
916        extraction: &crate::extract::Extraction,
917        metadata: Option<&Metadata>,
918    ) -> Result<RememberedExtraction, MemoryError>
919    where
920        S: GraphStore,
921    {
922        let mut entity_ids: HashMap<String, u64> = HashMap::new();
923        let mut edges: HashSet<(u64, u64, String)> = HashSet::new();
924        let outcome =
925            self.store_extracted_facts(&extraction.facts, metadata, &mut entity_ids, &mut edges)?;
926        self.wire_relations(&extraction.relations, &mut entity_ids, &mut edges)?;
927        self.wire_attributes(&extraction.attributes, &mut entity_ids)?;
928        Ok(outcome)
929    }
930
931    /// Look up everything known about a named entity: the attributes merged
932    /// onto its hub, and the typed edges leaving it.
933    ///
934    /// This is the *read* side of the auto-built graph, and it exists because
935    /// entity hubs are deliberately invisible to [`Self::recall`] and
936    /// [`Self::recall_where`] — a hub ranking for its own topic would evict a
937    /// real fact from the caller's results. Without this accessor an attribute
938    /// merged onto a hub would be stored correctly and yet be unreachable
939    /// through every public read path: the worst kind of feature, one that
940    /// looks done and silently returns nothing.
941    ///
942    /// `name` is canonicalized exactly like an extracted entity (trimmed,
943    /// lowercased), so the caller may pass `"Theo Durand"` and reach the node
944    /// built from `"theo durand"`. Returns `None` when no hub exists for the
945    /// name — nothing has ever mentioned that entity.
946    ///
947    /// # Errors
948    /// Returns [`MemoryError`] if the store lookup fails.
949    pub fn entity_profile(&self, name: &str) -> Result<Option<EntityProfile>, MemoryError>
950    where
951        S: GraphStore,
952    {
953        let _generation = self.enter_generation();
954        let key = canonical_entity_name(name);
955        if key.is_empty() {
956            return Ok(None);
957        }
958        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
959        if self.store.get(id)?.is_none() {
960            return Ok(None);
961        }
962        // Reserved system keys (the hub flag itself) are scaffolding, not
963        // attributes the caller ever wrote — strip them exactly as every other
964        // caller-facing read path does.
965        let (relations, relations_truncated) = self.outgoing_entity_relations(id)?;
966        let (relations_in, relations_in_truncated) = self.incoming_entity_relations(id)?;
967        Ok(Some(EntityProfile {
968            id,
969            name: key,
970            attributes: strip_reserved_keys(self.store.get_metadata(id)?).unwrap_or_default(),
971            relations,
972            relations_in,
973            relations_truncated,
974            relations_in_truncated,
975        }))
976    }
977
978    /// The typed edges leaving `id`, resolved to their target's content, and
979    /// whether that list is a partial view.
980    ///
981    /// Scaffolding edges (`mentions`, and `about` for symmetry — a hub never
982    /// has an outgoing `about`) are dropped: they point at the facts that
983    /// tagged this entity, not at a statement *about* it.
984    fn outgoing_entity_relations(&self, id: u64) -> Result<(Vec<EntityRelation>, bool), MemoryError>
985    where
986        S: GraphStore,
987    {
988        let scanned = self
989            .store
990            .relations_bounded(id, crate::limits::MAX_ENTITY_SCAN_EDGES)?;
991        self.resolve_entity_relations(scanned, |edge| edge.to)
992    }
993
994    /// The typed edges pointing at `id`, resolved to their SOURCE's content —
995    /// for an incoming edge the far end is where it comes *from* — and
996    /// whether that list is a partial view.
997    ///
998    /// Without these, a question is only answerable from one side: the graph
999    /// holds `camille --soeur de--> theo`, so reading Theo's outgoing edges
1000    /// never finds Camille. The edge exists, it simply leaves the other node.
1001    ///
1002    /// The scaffolding filter is the incoming mirror of
1003    /// [`Self::outgoing_entity_relations`]'s: `about` edges are dropped (they
1004    /// are the fact → hub half of the `about`/`mentions` pair), and `mentions`
1005    /// with them for symmetry.
1006    fn incoming_entity_relations(&self, id: u64) -> Result<(Vec<EntityRelation>, bool), MemoryError>
1007    where
1008        S: GraphStore,
1009    {
1010        let scanned = self
1011            .store
1012            .incoming_relations_bounded(id, crate::limits::MAX_ENTITY_SCAN_EDGES)?;
1013        self.resolve_entity_relations(scanned, |edge| edge.from)
1014    }
1015
1016    /// Shared resolver for both edge directions: skip the bipartite
1017    /// scaffolding labels, resolve each edge's far end (`far_end` picks which
1018    /// endpoint that is) to its stored content — at most
1019    /// [`crate::limits::MAX_ENTITY_RELATIONS`] of them — and say whether the
1020    /// result is a partial view (#1820).
1021    ///
1022    /// Truncated when either budget bit: the store's raw scan window
1023    /// ([`crate::limits::MAX_ENTITY_SCAN_EDGES`]) left edges unread, or a
1024    /// typed edge past the resolution cap was seen and dropped. Both cuts
1025    /// are the same honest signal — "there is more than this view shows".
1026    fn resolve_entity_relations(
1027        &self,
1028        scanned: crate::model::BoundedMemoryEdges,
1029        far_end: impl Fn(&MemoryEdge) -> u64,
1030    ) -> Result<(Vec<EntityRelation>, bool), MemoryError> {
1031        let mut relations = Vec::new();
1032        let mut truncated = scanned.truncated;
1033        for edge in scanned.edges {
1034            if edge.relation == MENTIONS_RELATION || edge.relation == ABOUT_RELATION {
1035                continue;
1036            }
1037            if relations.len() >= crate::limits::MAX_ENTITY_RELATIONS {
1038                truncated = true;
1039                break;
1040            }
1041            let far = far_end(&edge);
1042            let content = self.store.get(far)?.map(|(content, _)| content);
1043            relations.push(EntityRelation {
1044                predicate: edge.relation,
1045                target_id: far,
1046                target: content.unwrap_or_default(),
1047            });
1048        }
1049        Ok((relations, truncated))
1050    }
1051
1052    /// Wire each extracted `subject -[predicate]-> object` triple as a typed
1053    /// edge between the two entity hubs.
1054    ///
1055    /// This is the step that turns the bipartite fact↔topic graph into a real
1056    /// knowledge graph. The hubs are resolved through [`Self::entity_hub`], so
1057    /// an endpoint naming an entity some earlier passage already introduced
1058    /// reuses that entity's existing node rather than forking a parallel one —
1059    /// hub ids are content-addressed, so this holds across calls and sessions.
1060    ///
1061    /// Only the stated direction is written. Inferring the converse
1062    /// (`father of` ⇒ `child of`) would mean inventing a label the passage
1063    /// never used, and an inverted vocabulary nobody can predict is worse than
1064    /// an absent edge: `why()` walks outgoing edges, so a wrong direction
1065    /// silently misroutes every later traversal.
1066    ///
1067    /// A malformed triple is skipped, not fatal — one unusable predicate must
1068    /// not cost the caller the facts stored alongside it.
1069    fn wire_relations(
1070        &self,
1071        relations: &[ExtractedRelation],
1072        entity_ids: &mut HashMap<String, u64>,
1073        edges: &mut HashSet<(u64, u64, String)>,
1074    ) -> Result<(), MemoryError>
1075    where
1076        S: GraphStore,
1077    {
1078        for relation in relations {
1079            if validate_relation(&relation.predicate).is_err() {
1080                continue;
1081            }
1082            let subject_id = self.entity_hub(&relation.subject, entity_ids)?;
1083            let object_id = self.entity_hub(&relation.object, entity_ids)?;
1084            if subject_id == object_id {
1085                continue;
1086            }
1087            self.add_edge(subject_id, object_id, &relation.predicate, edges)?;
1088        }
1089        Ok(())
1090    }
1091
1092    /// Merge each extracted attribute into its entity hub's `ColumnStore`
1093    /// metadata, so `recall_where` can filter on it (`age >= 15`).
1094    ///
1095    /// The write goes through `update_metadata`, which **merges** rather than
1096    /// replaces. That is the whole point: learning "Theo has a sister" after
1097    /// "Theo is 15" must not erase the age. Re-storing the hub payload wholesale
1098    /// would silently drop every attribute learned in an earlier session.
1099    ///
1100    /// Values keep the JSON type the extractor produced. `recall_where`
1101    /// compares type-strictly with no coercion, so an age stored as `"15"`
1102    /// would never match a numeric filter — no error, just a permanent silent
1103    /// miss.
1104    ///
1105    /// Reserved keys are skipped: a model emitting `content` or a `_veles_`
1106    /// key must never be able to overwrite the hub's own content or its
1107    /// system flags.
1108    fn wire_attributes(
1109        &self,
1110        attributes: &[ExtractedAttribute],
1111        entity_ids: &mut HashMap<String, u64>,
1112    ) -> Result<(), MemoryError> {
1113        let mut per_entity: HashMap<String, Metadata> = HashMap::new();
1114        for attribute in attributes {
1115            if is_reserved_key(&attribute.key) {
1116                continue;
1117            }
1118            per_entity
1119                .entry(attribute.entity.clone())
1120                .or_default()
1121                .insert(attribute.key.clone(), attribute.value.clone());
1122        }
1123        for (entity, meta) in per_entity {
1124            if meta.is_empty() {
1125                continue;
1126            }
1127            reject_oversized_metadata(Some(&meta))?;
1128            let hub_id = self.entity_hub(&entity, entity_ids)?;
1129            self.store.update_metadata(hub_id, &meta)?;
1130        }
1131        Ok(())
1132    }
1133
1134    /// Store each extracted fact and wire it to its topics, returning their ids.
1135    ///
1136    /// Goes through the no-autograph path: the passage was ALREADY extracted by
1137    /// the caller, so re-running a generation per stored fact would re-derive
1138    /// what was just computed.
1139    fn store_extracted_facts(
1140        &self,
1141        facts: &[crate::extract::ExtractedFact],
1142        metadata: Option<&Metadata>,
1143        entity_ids: &mut HashMap<String, u64>,
1144        edges: &mut HashSet<(u64, u64, String)>,
1145    ) -> Result<RememberedExtraction, MemoryError>
1146    where
1147        S: GraphStore,
1148    {
1149        let mut ids = Vec::with_capacity(facts.len());
1150        let mut skipped_over_cap = 0;
1151        for fact in facts {
1152            let content = fact.text.trim();
1153            if content.is_empty() {
1154                continue;
1155            }
1156            // An over-cap fact is skipped, not fatal: aborting here used to
1157            // leave the previous iterations persisted with no rollback, no
1158            // graph wiring, and no ids returned — the worst of every world.
1159            // Every OTHER error still aborts: they signal bad caller input
1160            // (reserved keys, oversized metadata) or a failing store, where
1161            // carrying on would compound the damage.
1162            let fact_id = match self.remember_inner(content, &[], metadata, None, false) {
1163                Ok(id) => id,
1164                Err(MemoryError::FactTooLarge { .. }) => {
1165                    skipped_over_cap += 1;
1166                    continue;
1167                }
1168                Err(error) => return Err(error),
1169            };
1170            ids.push(fact_id);
1171            self.wire_entities(fact_id, &fact.entities, entity_ids, edges)?;
1172        }
1173        Ok(RememberedExtraction {
1174            ids,
1175            skipped_over_cap,
1176        })
1177    }
1178
1179    /// Link `fact_id` to each of its topics with a deduplicated edge in *both*
1180    /// directions. `why()` only follows outgoing edges, so the fact→topic edge
1181    /// alone leaves hubs as dead ends; the topic→fact edge is what lets a walk
1182    /// hop from one fact, through a shared topic, to its sibling facts.
1183    fn wire_entities(
1184        &self,
1185        fact_id: u64,
1186        entities: &[String],
1187        entity_ids: &mut HashMap<String, u64>,
1188        edges: &mut HashSet<(u64, u64, String)>,
1189    ) -> Result<(), MemoryError>
1190    where
1191        S: GraphStore,
1192    {
1193        for entity in entities {
1194            // Skip blank or punctuation-only topics: they would persist as junk
1195            // hubs (`Entity: -`) yet can never carry a meaningful multi-hop link.
1196            if entity.chars().any(char::is_alphanumeric) {
1197                self.wire_entity(fact_id, entity, entity_ids, edges)?;
1198            }
1199        }
1200        Ok(())
1201    }
1202
1203    /// Wire one topic to `fact_id`: resolve its hub, then add the deduplicated
1204    /// `about`/`mentions` pair (skipping a hub that is the fact itself).
1205    fn wire_entity(
1206        &self,
1207        fact_id: u64,
1208        entity: &str,
1209        entity_ids: &mut HashMap<String, u64>,
1210        edges: &mut HashSet<(u64, u64, String)>,
1211    ) -> Result<(), MemoryError>
1212    where
1213        S: GraphStore,
1214    {
1215        let entity_id = self.entity_hub(entity, entity_ids)?;
1216        if entity_id == fact_id {
1217            return Ok(());
1218        }
1219        self.add_edge(fact_id, entity_id, ABOUT_RELATION, edges)?;
1220        self.add_edge(entity_id, fact_id, MENTIONS_RELATION, edges)?;
1221        Ok(())
1222    }
1223
1224    /// Create the edge `from -> to` labelled `label`, unless `edges` already
1225    /// records that triple for this call (in-call dedup only). `relate`
1226    /// derives the edge id from `(from, relation, to)`
1227    /// ([`crate::wire::hash_edge_id`] upstream in core) and is itself an O(1)
1228    /// idempotent no-op against an already-persisted edge, so there is
1229    /// nothing left to preload from the store — a prior preload here made
1230    /// every write to a hub with `k` existing edges cost O(k), turning `n`
1231    /// writes to the same hub into O(n²).
1232    fn add_edge(
1233        &self,
1234        from: u64,
1235        to: u64,
1236        label: &str,
1237        edges: &mut HashSet<(u64, u64, String)>,
1238    ) -> Result<(), MemoryError>
1239    where
1240        S: GraphStore,
1241    {
1242        if edges.insert((from, to, label.to_string())) {
1243            self.relate_inner(from, to, label)?;
1244        }
1245        Ok(())
1246    }
1247
1248    /// Get or create the hub memory for a topic, caching its id per call. The
1249    /// hub id is a deterministic function of the (normalized) topic, so the same
1250    /// topic resolves to the same hub across calls — never a duplicate.
1251    fn entity_hub(
1252        &self,
1253        entity: &str,
1254        entity_ids: &mut HashMap<String, u64>,
1255    ) -> Result<u64, MemoryError> {
1256        let key = entity.trim().to_lowercase();
1257        if let Some(&id) = entity_ids.get(&key) {
1258            return Ok(id);
1259        }
1260        let id = self.remember_hub(&key)?;
1261        entity_ids.insert(key, id);
1262        Ok(id)
1263    }
1264
1265    /// Idempotently store the hub memory for topic `key`. The id is salted so the
1266    /// hub id space is disjoint from natural fact ids (no caller fact can collide
1267    /// with or overwrite a hub), while the stored content stays human-readable.
1268    /// Marked with the reserved [`HUB_FIELD`] so recall and `why` seeds exclude
1269    /// it; goes straight to [`Self::store_fact`] to bypass the caller-facing
1270    /// reserved-key rejection in [`Self::remember`].
1271    fn remember_hub(&self, key: &str) -> Result<u64, MemoryError> {
1272        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
1273        // An existing hub is left exactly as it is. Re-storing it would rewrite
1274        // the payload to the bare hub marker and destroy every attribute merged
1275        // onto it by an earlier call — learning "Theo has a sister" would erase
1276        // "Theo is 15", because a later sentence re-resolves the same hub. The
1277        // content is a pure function of `key`, so there is nothing to refresh;
1278        // skipping also avoids re-embedding a hub on every single mention.
1279        if self.store.get(id)?.is_some() {
1280            return Ok(id);
1281        }
1282        let content = format!("Entity: {key}");
1283        let embedding = self.embedder.embed(&content)?;
1284        let mut meta = Map::new();
1285        meta.insert(HUB_FIELD.to_string(), Value::Bool(true));
1286        // Topic hubs are graph anchors — they never expire.
1287        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
1288        Ok(id)
1289    }
1290
1291    /// Fail with [`MemoryError::UnknownMemory`] unless memory `id` exists.
1292    fn ensure_exists(&self, id: u64) -> Result<(), MemoryError> {
1293        if self.store.get(id)?.is_none() {
1294            return Err(MemoryError::UnknownMemory(id));
1295        }
1296        Ok(())
1297    }
1298
1299    /// Fail unless every link target already exists (keeps `remember` atomic).
1300    fn ensure_link_targets_exist(&self, links: &[Link]) -> Result<(), MemoryError> {
1301        for link in links {
1302            self.ensure_exists(link.target)?;
1303        }
1304        Ok(())
1305    }
1306
1307    /// Store a fact with any combination of metadata and a durable TTL.
1308    fn store_fact(
1309        &self,
1310        id: u64,
1311        fact: &str,
1312        embedding: &[f32],
1313        metadata: Option<&Metadata>,
1314        ttl_seconds: Option<u64>,
1315    ) -> Result<(), MemoryError> {
1316        match (metadata, ttl_seconds) {
1317            (Some(meta), Some(ttl)) => {
1318                // ONE write, not two. The previous `store_with_ttl` then
1319                // `update_metadata` pair left the fact live and expiring
1320                // between the calls: a short TTL could lapse in the gap and
1321                // the metadata write then failed with `NotFound(... is
1322                // expired ...)` — the caller got an error on a fact that was
1323                // valid when they asked for it. Observed with a 1 s TTL on a
1324                // loaded machine. Every TTL'd write takes this arm, since the
1325                // auto date stamp means `metadata` is always `Some`.
1326                self.store
1327                    .store_with_metadata_and_ttl(id, fact, embedding, meta, ttl)?;
1328            }
1329            (Some(meta), None) => self.store.store_with_metadata(id, fact, embedding, meta)?,
1330            (None, Some(ttl)) => self.store.store_with_ttl(id, fact, embedding, ttl)?,
1331            (None, None) => self.store.store(id, fact, embedding)?,
1332        }
1333        Ok(())
1334    }
1335
1336    /// Recall up to `k` memories semantically similar to `query` (vector facet),
1337    /// optionally narrowed to an exact-match metadata `filter` (`ColumnStore`
1338    /// facet) — e.g. `{ "project": "veles", "status": "resolved" }`.
1339    ///
1340    /// A highly selective filter may return fewer than `k` hits even when more
1341    /// matches exist — raise `k` for fuller coverage with a narrow filter.
1342    ///
1343    /// Entity hubs created by [`Self::remember_extracted`] are never returned:
1344    /// they are internal graph scaffolding, not facts the caller stored.
1345    ///
1346    /// Each hit carries its caller metadata (`Recollection::metadata`, `None`
1347    /// when the fact carries none) — store a date field (e.g. `occurred_at`)
1348    /// and it round-trips here, so a caller can sort the result into a
1349    /// chronological, date-stamped context without `recall_where`'s explicit
1350    /// filters. One extra, single batched lookup covers every returned hit.
1351    ///
1352    /// # Errors
1353    /// Returns [`MemoryError`] if the semantic query or the metadata lookup fails.
1354    pub fn recall(
1355        &self,
1356        query: &str,
1357        k: usize,
1358        filter: Option<&Metadata>,
1359    ) -> Result<Vec<Recollection>, MemoryError>
1360    where
1361        S: RecallStore,
1362    {
1363        let _generation = self.enter_generation();
1364        self.recall_inner(query, k, filter)
1365    }
1366
1367    fn recall_inner(
1368        &self,
1369        query: &str,
1370        k: usize,
1371        filter: Option<&Metadata>,
1372    ) -> Result<Vec<Recollection>, MemoryError>
1373    where
1374        S: RecallStore,
1375    {
1376        let query = query.trim();
1377        if query.is_empty() {
1378            return Ok(Vec::new());
1379        }
1380        reject_reserved_keys(filter)?;
1381        let embedding = self.embedder.embed(query)?;
1382        let hits = self.search(&embedding, k, filter)?;
1383        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
1384        // One raw batched payload lookup (reserved keys included), reused for
1385        // BOTH the RL re-rank and the caller-facing metadata below — a single
1386        // round trip, not one per concern.
1387        let payloads = self.store.get_metadata_batch(&ids)?;
1388        // RL Memory: re-order the recalled set by learned confidence. Facts
1389        // that never received `feedback` keep their similarity order exactly.
1390        #[cfg(feature = "persistence")]
1391        let (hits, payloads) = Self::rl_rerank(hits, payloads);
1392        Ok(hits
1393            .into_iter()
1394            .zip(payloads)
1395            .map(|((id, score, content), payload)| Recollection {
1396                id,
1397                score,
1398                content,
1399                metadata: strip_reserved_keys(payload),
1400            })
1401            .collect())
1402    }
1403
1404    /// Vector search for up to `k` ids, optionally narrowed by a metadata
1405    /// `filter`. Shared by [`Self::recall`] and [`Self::why`].
1406    fn search(
1407        &self,
1408        embedding: &[f32],
1409        k: usize,
1410        filter: Option<&Metadata>,
1411    ) -> Result<Vec<(u64, f32, String)>, MemoryError>
1412    where
1413        S: RecallStore,
1414    {
1415        match filter {
1416            // An include filter already excludes hubs: a hub's payload
1417            // carries only reserved keys (`content`, `_veles_hub`), and
1418            // reserved keys are rejected from caller filters, so a non-empty
1419            // filter can never match a hub. An EMPTY-but-present filter (`Some({})`, the
1420            // natural `{}` idiom at the JS boundary) matches every payload —
1421            // hubs included — so it must take the hub-excluding path below,
1422            // exactly like an absent filter (same `Some({})` ≡ `None`
1423            // convention as `recall_fused`'s graph-side `matches_filter`).
1424            Some(meta) if !meta.is_empty() => self.store.query_filtered(embedding, k, meta, 0),
1425            // Unfiltered recall must still drop entity hubs explicitly, or a hub
1426            // like `Entity: rust` would rank for the topic and evict a real fact.
1427            _ => self
1428                .store
1429                .query_excluding(embedding, k, &hub_exclude_filter()),
1430        }
1431    }
1432
1433    /// Fused recall: semantic `NEAR` search combined with structured
1434    /// `ColumnStore` predicates over metadata columns — ranges and comparisons,
1435    /// not just the equality of [`Self::recall`]. One query spanning the vector
1436    /// and column facets (e.g. "most similar facts **with `timestamp` in this
1437    /// window**"), which a vector-only or equality-only recall cannot express.
1438    ///
1439    /// Filter *values* are bound as query parameters (never interpolated), so
1440    /// they cannot inject; filter *field names* are validated to be plain
1441    /// identifiers. Results come back in similarity order.
1442    ///
1443    /// **Caller memories only.** The store also holds internal scaffolding —
1444    /// the entity hubs of [`Self::remember_extracted`] and the context
1445    /// compiler's four artefact classes (stored sources, compilation events,
1446    /// working contexts, and the per-project working-context index). They sit
1447    /// in the same collection as caller facts and are excluded from every
1448    /// result here, whatever the predicate.
1449    ///
1450    /// That exclusion is applied by the backend against
1451    /// [`crate::storage::INTERNAL_MARKER_FIELDS`]; it is NOT a consequence of
1452    /// those facts being unfilterable. A caller cannot write a filter naming a
1453    /// reserved key, but `field ne value` MATCHES a fact that has no such
1454    /// field at all — and scaffolding has none of the caller's columns, so
1455    /// before #1737 every `ne` predicate returned all of it.
1456    ///
1457    /// # Errors
1458    /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a plain
1459    /// identifier, [`MemoryError::Embed`] if the query cannot be embedded, or a
1460    /// storage error if the query fails. An empty query or `k == 0` yields `[]`.
1461    pub fn recall_where(
1462        &self,
1463        query: &str,
1464        k: usize,
1465        filters: &[ColumnFilter],
1466    ) -> Result<Vec<Recollection>, MemoryError>
1467    where
1468        S: ColumnStore + RecallStore,
1469    {
1470        let _generation = self.enter_generation();
1471        let query = query.trim();
1472        if query.is_empty() || k == 0 {
1473            return Ok(Vec::new());
1474        }
1475        // No column predicates = a plain recall: route through [`Self::recall`]
1476        // so entity hubs stay excluded — `query_columnar` with an empty filter
1477        // set is a bare vector search that would rank internal `Entity:` hub
1478        // scaffolding as results (same `[]` ≡ unfiltered convention as
1479        // `search`'s empty-map handling).
1480        if filters.is_empty() {
1481            return self.recall_inner(query, k, None);
1482        }
1483        let embedding = self.embedder.embed(query)?;
1484        self.store.query_columnar(&embedding, k, filters)
1485    }
1486}
1487
1488/// The metadata filter that excludes entity hubs from unfiltered recall and
1489/// `why` seeds — the negative counterpart [`MemoryService::search`] applies so
1490/// internal `_veles_hub` scaffolding never surfaces as a result.
1491fn hub_exclude_filter() -> Metadata {
1492    let mut exclude = Map::new();
1493    exclude.insert(HUB_FIELD.to_string(), Value::Bool(true));
1494    exclude
1495}
1496
1497/// Reject caller-supplied metadata/filters that name a reserved key.
1498fn reject_reserved_keys(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
1499    let Some(meta) = metadata else {
1500        return Ok(());
1501    };
1502    for key in meta.keys() {
1503        if is_reserved_key(key) {
1504            return Err(MemoryError::ReservedKey(key.clone()));
1505        }
1506    }
1507    Ok(())
1508}
1509
1510/// Reject caller-supplied metadata over [`crate::limits::MAX_METADATA_BYTES`]
1511/// — the `DoS` guard every `remember` path shares (see
1512/// [`MemoryError::MetadataTooLarge`]).
1513fn reject_oversized_metadata(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
1514    let Some(meta) = metadata else {
1515        return Ok(());
1516    };
1517    let bytes = crate::limits::metadata_bytes(meta);
1518    if bytes > crate::limits::MAX_METADATA_BYTES {
1519        return Err(MemoryError::MetadataTooLarge {
1520            bytes,
1521            max: crate::limits::MAX_METADATA_BYTES,
1522        });
1523    }
1524    Ok(())
1525}
1526
1527/// Normalise a TTL supplied as *configuration*: `Some(0)` (and `None`) mean
1528/// "no TTL policy" — the fact is stored permanently. Any positive value is
1529/// kept as-is.
1530///
1531/// Deliberately NOT applied to `remember`'s own `ttl_seconds` any more: an
1532/// explicit per-call `0` is an intent about one fact ("expire it"), and
1533/// silently turning that into "permanent" is the opposite (see
1534/// [`reject_zero_ttl`]). A compile policy's `source_ttl_seconds`, on the
1535/// other hand, is a knob about a whole server, where `0` reading as "no
1536/// policy" is the ordinary, unsurprising meaning.
1537///
1538/// Gated on `context`: since `remember` stopped calling it, the compile
1539/// policy in `context::memory_bridge` is its only caller, and a build
1540/// without that feature saw dead code — which `-D warnings` turns into a
1541/// failed build, not a warning.
1542#[cfg(feature = "context")]
1543pub(crate) fn positive_ttl(ttl_seconds: Option<u64>) -> Option<u64> {
1544    ttl_seconds.filter(|&seconds| seconds > 0)
1545}
1546
1547/// The canonical form of an entity name: trimmed and lowercased, exactly as
1548/// an extracted entity is keyed.
1549///
1550/// Public because a lookup MISS has to echo it too: an adapter that answered
1551/// `name: ""` when nothing matched left a caller running several lookups
1552/// unable to pair a response with its question (issue #1654). Hit and miss go
1553/// through this one function, so the two can never drift.
1554/// The audit's per-fact visibility policy, in one place: `None` skips the
1555/// fact (internal scaffolding under the default view, or a metadata filter
1556/// miss), `Some` carries what the caller may see — reserved keys stripped
1557/// exactly as recall strips them, or the raw payload under
1558/// `include_internal`.
1559fn audited(
1560    fact: crate::storage::RawListedFact,
1561    filter: Option<&Metadata>,
1562    include_internal: bool,
1563) -> Option<crate::model::ListedMemory> {
1564    if !include_internal && crate::storage::is_internal_scaffolding(&fact.payload) {
1565        return None;
1566    }
1567    let matches = filter.is_none_or(|wanted| {
1568        wanted
1569            .iter()
1570            .all(|(key, value)| fact.payload.get(key) == Some(value))
1571    });
1572    if !matches {
1573        return None;
1574    }
1575    let metadata = if include_internal {
1576        (!fact.payload.is_empty()).then_some(fact.payload)
1577    } else {
1578        strip_reserved_keys(Some(fact.payload))
1579    };
1580    Some(crate::model::ListedMemory {
1581        id: fact.id,
1582        content: fact.content,
1583        metadata,
1584    })
1585}
1586
1587#[must_use]
1588pub fn canonical_entity_name(name: &str) -> String {
1589    name.trim().to_lowercase()
1590}
1591
1592/// Refuse a fact that cannot be stored as written: blank, or past the size an
1593/// embedding model still accepts.
1594///
1595/// The size check runs BEFORE [`MemoryService::write_fact`] calls the
1596/// embedder, so an over-long fact is reported with its own size and the cap
1597/// instead of whatever the backend says — issue #1654 saw `ollama embeddings
1598/// call failed`, which names neither.
1599fn validate_fact(fact: &str) -> Result<(), MemoryError> {
1600    if fact.is_empty() {
1601        return Err(MemoryError::EmptyFact);
1602    }
1603    validate_embeddable(fact)
1604}
1605
1606/// Refuse a text past the size an embedding model still accepts.
1607///
1608/// Extracted from [`validate_fact`] so every path that embeds CALLER content
1609/// answers to the same cap: `remember` refuses (this function), while the
1610/// context bridge truncates via [`embeddable_prefix`] — but neither may hand
1611/// the backend an oversized text and relay its raw failure, which is how
1612/// issue #1654's `ollama embeddings call failed` (naming neither size nor
1613/// cap) reached users.
1614pub(crate) fn validate_embeddable(text: &str) -> Result<(), MemoryError> {
1615    if text.len() > crate::limits::MAX_EMBEDDABLE_TEXT_BYTES {
1616        return Err(MemoryError::FactTooLarge {
1617            bytes: text.len(),
1618            max: crate::limits::MAX_EMBEDDABLE_TEXT_BYTES,
1619        });
1620    }
1621    Ok(())
1622}
1623
1624/// The longest prefix of `text` that fits the embeddable cap without
1625/// splitting a UTF-8 character.
1626///
1627/// For content that must be STORED whole but whose vector only serves
1628/// similarity search (context sources: retrieval is hash-addressed, the
1629/// vector is a ranking aid), truncating the *embedded* text is the correct
1630/// trade — refusing would fail a legitimate compile, and a placeholder
1631/// vector would remove the source from semantic recall entirely.
1632///
1633/// Gated on `context`: the compiler's source writer is its only caller, so a
1634/// build without that feature sees dead code, which CI's `-D warnings`
1635/// turns into a failed build. Same shape as `positive_ttl` — and only the
1636/// per-feature ISOLATION loop catches it, never a feature combination.
1637#[cfg(feature = "context")]
1638pub(crate) fn embeddable_prefix(text: &str) -> &str {
1639    let cap = crate::limits::MAX_EMBEDDABLE_TEXT_BYTES;
1640    if text.len() <= cap {
1641        return text;
1642    }
1643    let mut end = cap;
1644    while end > 0 && !text.is_char_boundary(end) {
1645        end -= 1;
1646    }
1647    &text[..end]
1648}
1649
1650/// Refuse an explicit per-call TTL of `0`.
1651///
1652/// `0` used to be normalised to "no expiry", so a caller who meant "expire
1653/// immediately" silently got a **permanent** fact — the opposite intent, with
1654/// no signal (issue #1654). A TTL supplied as *configuration*
1655/// (`McpServer::with_default_ttl`, a compile policy's `source_ttl_seconds`)
1656/// still reads `0` as "no TTL policy": that is a default about a whole
1657/// server, not an intent about one fact, and it is deliberately untouched.
1658fn reject_zero_ttl(ttl_seconds: Option<u64>) -> Result<(), MemoryError> {
1659    if ttl_seconds == Some(0) {
1660        return Err(MemoryError::ZeroTtl);
1661    }
1662    Ok(())
1663}
1664
1665/// Refuse a `remember` link that points the fact at itself.
1666///
1667/// The same rule [`MemoryService::relate`] enforces, applied to the other way
1668/// a self-loop can be created: re-remembering existing content yields its
1669/// existing id, so a caller CAN name that id as a link target. Without this
1670/// the `relate` guard would only close half the door.
1671fn reject_self_links(fact_id: u64, links: &[Link]) -> Result<(), MemoryError> {
1672    if links.iter().any(|link| link.target == fact_id) {
1673        return Err(MemoryError::SelfRelation(fact_id));
1674    }
1675    Ok(())
1676}
1677
1678/// [`MemoryService::remember_with_ttl`]'s auto-date stamp: `metadata` with
1679/// today's date added under [`AUTO_DATE_FIELD`], unless `metadata` already
1680/// names that key (an explicit, possibly retroactive, caller value is never
1681/// overwritten) or no clock is available ([`clock::today_ymd`] returns `None`
1682/// on `wasm32-unknown-unknown`). Returns an owned map either way, `None` only
1683/// when there is nothing to store at all (no caller metadata AND no clock).
1684fn stamp_with_today(metadata: Option<&Metadata>) -> Option<Metadata> {
1685    if metadata.is_some_and(|meta| meta.contains_key(AUTO_DATE_FIELD)) {
1686        return metadata.cloned();
1687    }
1688    let Some(today) = clock::today_ymd() else {
1689        return metadata.cloned();
1690    };
1691    let mut stamped = metadata.cloned().unwrap_or_default();
1692    stamped.insert(AUTO_DATE_FIELD.to_owned(), Value::from(today));
1693    Some(stamped)
1694}
1695
1696/// Maximum byte length for a relation label (prevents oversized graph edge labels
1697/// from reaching the storage layer).
1698const MAX_RELATION_BYTES: usize = 512;
1699
1700/// Validate a caller-supplied relation label: non-empty, within the size cap, and
1701/// containing only printable, non-control ASCII characters (32–126) or non-ASCII
1702/// Unicode. This prevents null bytes and control characters from reaching the
1703/// storage layer while permitting natural-language labels like `"decided_in"` or
1704/// `"is a friend of"`.
1705fn validate_relation(label: &str) -> Result<(), MemoryError> {
1706    if label.is_empty() {
1707        return Err(MemoryError::InvalidRelation(
1708            "relation label must not be empty".to_owned(),
1709        ));
1710    }
1711    if label.len() > MAX_RELATION_BYTES {
1712        return Err(MemoryError::InvalidRelation(format!(
1713            "relation label exceeds maximum of {MAX_RELATION_BYTES} bytes ({} given)",
1714            label.len()
1715        )));
1716    }
1717    if label.chars().any(|c| c.is_ascii_control()) {
1718        return Err(MemoryError::InvalidRelation(
1719            "relation label must not contain ASCII control characters".to_owned(),
1720        ));
1721    }
1722    Ok(())
1723}