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}