Skip to main content

velesdb_memory/
service.rs

1//! The memory service: five operations over the in-core Agent Memory SDK.
2
3use std::collections::{HashMap, HashSet};
4#[cfg(feature = "persistence")]
5use std::path::Path;
6
7use serde_json::{Map, Value};
8
9/// Structured metadata attached to a memory (the `ColumnStore` facet): exact-match
10/// fields like `project`, `author`, `type`, `status`, `date`. `content` and
11/// `_veles_expires_at` are reserved keys. [`crate::storage::AUTO_DATE_FIELD`]
12/// (`_veles_date`) is auto-populated by [`MemoryService::remember_with_ttl`]
13/// with today's date unless already present — see that method's docs.
14pub type Metadata = Map<String, Value>;
15
16use crate::clock;
17use crate::embedder::Embedder;
18use crate::error::MemoryError;
19use crate::extract::{ExtractedAttribute, ExtractedRelation, Extractor};
20use crate::id;
21use crate::model::{
22    ColumnFilter, EntityProfile, EntityRelation, Explanation, Link, MemoryNode, Recollection,
23};
24#[cfg(feature = "persistence")]
25use crate::storage::NativeStore;
26use crate::storage::{is_reserved_key, strip_reserved_keys, MemoryStore, AUTO_DATE_FIELD};
27
28/// [`MemoryService::recall_fused`] and its helpers — split out to keep this
29/// file under the crate's 500-NLOC-per-file budget, same pattern as
30/// `velesdb-core`'s `database/*.rs` split. A child module of `service`, so it
31/// shares full access to `MemoryService`'s private fields and methods.
32#[path = "fused_recall.rs"]
33mod fused_recall;
34
35/// [`MemoryService::feedback`] and the recall re-ranking it drives (RL Memory).
36/// A child module of `service`, like [`fused_recall`], so it uses
37/// `MemoryService`'s private `store` directly. Gated on `persistence`: it
38/// builds on `velesdb-core`'s agent SDK (`ReinforcementStrategy`), itself
39/// behind that feature, and a durable learned confidence is meaningless on the
40/// in-memory (WASM) backend.
41#[cfg(feature = "persistence")]
42#[path = "reinforce.rs"]
43mod reinforce;
44
45/// The context compiler's memory bridge (`compile_context`,
46/// `retrieve_context_source`, `context_savings`, working contexts). A child
47/// module of `service`, like [`fused_recall`], so it reuses the private
48/// `store_fact`/`HUB_FIELD` system-fact machinery — compiler system facts
49/// (sources, events, working contexts) are hub-marked so they never surface
50/// in normal recall.
51#[cfg(feature = "context")]
52#[path = "context/memory_bridge.rs"]
53mod memory_bridge;
54
55/// Reserved metadata key marking an entity hub auto-created by
56/// [`MemoryService::remember_extracted`] (value `true`). Namespaced under the
57/// system `_veles_` prefix so it can never collide with a caller's own metadata,
58/// and rejected from caller-supplied metadata/filters (see [`is_reserved_key`]).
59/// Hubs are internal graph scaffolding — they connect facts that share a topic —
60/// so they are excluded from unfiltered recall and from `why` seeds.
61const HUB_FIELD: &str = "_veles_hub";
62/// Salt mixed into a hub's stable id so the hub id space is disjoint from
63/// natural fact ids: a caller fact whose text happens to equal a hub's display
64/// content (`Entity: rust`) can never collide with, or overwrite, the hub.
65const HUB_ID_SALT: &str = "\u{0}_veles_entity_hub\u{0}";
66/// Edge label a hub uses to point back at a fact it tags (the hub → fact
67/// direction). [`fused_recall`] reads this to recognise which edges in a
68/// `why()` walk crossed a hub, so it can weight the reached fact by that
69/// hub's specificity instead of a flat constant.
70const MENTIONS_RELATION: &str = "mentions";
71
72/// Local-first agent memory backed by a single `VelesDB` instance.
73///
74/// Generic over the [`Embedder`] so production can use an on-device model while
75/// tests use a deterministic, network-free one, and over the [`MemoryStore`]
76/// backend `S` so the same orchestration runs over the native, file-backed
77/// engine (the default — nothing changes for existing callers) or any other
78/// backend that implements the trait (e.g. an in-memory one for WASM).
79///
80/// Two definitions, `persistence`-gated: the default type parameter itself
81/// references [`NativeStore`], which doesn't exist as a type at all without
82/// the feature, so a `persistence`-free build (e.g. `velesdb-wasm`) drops the
83/// default and every caller names its own [`MemoryStore`] backend explicitly.
84#[cfg(feature = "persistence")]
85pub struct MemoryService<E: Embedder, S: MemoryStore = NativeStore> {
86    store: S,
87    embedder: E,
88    autograph: Option<crate::extract::DynExtractor>,
89}
90#[cfg(not(feature = "persistence"))]
91pub struct MemoryService<E: Embedder, S: MemoryStore> {
92    store: S,
93    embedder: E,
94    autograph: Option<crate::extract::DynExtractor>,
95}
96
97#[cfg(feature = "persistence")]
98impl<E: Embedder> MemoryService<E, NativeStore> {
99    /// Open (or create) a native, file-backed memory store at `path`, using
100    /// `embedder` for text vectorization. The store never leaves this directory.
101    ///
102    /// # Errors
103    /// Returns [`MemoryError`] if the store cannot be opened or the agent
104    /// memory cannot be initialized for the embedder's dimension.
105    pub fn open<P: AsRef<Path>>(path: P, embedder: E) -> Result<Self, MemoryError> {
106        let store = NativeStore::open(path, embedder.dimension())?;
107        Ok(Self {
108            store,
109            embedder,
110            autograph: None,
111        })
112    }
113}
114
115impl<E: Embedder, S: MemoryStore> MemoryService<E, S> {
116    /// Build a service directly over a `store` backend, bypassing
117    /// [`Self::open`]'s filesystem-specific setup — the constructor a
118    /// non-native backend (e.g. `velesdb-wasm`'s in-memory store) uses.
119    pub fn with_store(store: S, embedder: E) -> Self {
120        Self {
121            store,
122            embedder,
123            autograph: None,
124        }
125    }
126
127    /// Turn on **autograph**: every [`Self::remember`] additionally reads the
128    /// stored fact for entities, entity→entity edges and entity attributes,
129    /// and wires them — so the knowledge graph builds itself from ordinary
130    /// `remember` calls, with no separate [`Self::remember_extracted`].
131    ///
132    /// Opt-in, and off unless this is called. It costs one generation per
133    /// `remember`, which is a real latency and availability change: a memory
134    /// write that silently depends on a local model being up is not a default
135    /// anyone should inherit.
136    ///
137    /// The caller's fact is stored **verbatim and first**. Autograph only
138    /// *adds* structure around it; it never rewrites or replaces what the
139    /// caller asked to remember.
140    #[must_use]
141    pub fn with_autograph(mut self, extractor: crate::extract::DynExtractor) -> Self {
142        self.autograph = Some(extractor);
143        self
144    }
145
146    /// Remember a `fact`, optionally tagging it with structured `metadata`
147    /// (`ColumnStore` facet) and linking it to existing memories (graph facet).
148    /// Returns the stable id of the fact (idempotent on identical content).
149    ///
150    /// The stored metadata is auto-stamped with today's date under
151    /// [`crate::storage::AUTO_DATE_FIELD`] unless `metadata` already carries
152    /// that key — see [`Self::remember_with_ttl`] (this method's only caller)
153    /// for the full contract.
154    ///
155    /// Every link is validated — target existence AND relation label —
156    /// *before* the fact is stored, so bad link input never leaves the fact
157    /// half-written. If an edge write itself fails afterwards (e.g. a target
158    /// expiring concurrently), a freshly-created fact is rolled back; a
159    /// re-remembered fact keeps its updated payload (re-remembering updates
160    /// metadata by design, and deleting it would destroy prior state).
161    /// Concurrent `remember`s of identical content are last-writer-wins,
162    /// not transactional.
163    ///
164    /// # Errors
165    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace facts,
166    /// [`MemoryError::ReservedKey`] if `metadata` names a reserved key
167    /// (`content` or any `_veles_`-prefixed system key, [`crate::storage::AUTO_DATE_FIELD`]
168    /// excepted),
169    /// [`MemoryError::MetadataTooLarge`] if `metadata` exceeds
170    /// [`crate::limits::MAX_METADATA_BYTES`],
171    /// [`MemoryError::UnknownMemory`] if a link points at a missing memory,
172    /// [`MemoryError::InvalidRelation`] for a bad relation label,
173    /// [`MemoryError::RollbackFailed`] if an edge write failed and the
174    /// compensating delete also failed (the fact remains stored),
175    /// or a storage error if persistence fails.
176    pub fn remember(
177        &self,
178        fact: &str,
179        links: &[Link],
180        metadata: Option<&Metadata>,
181    ) -> Result<u64, MemoryError> {
182        self.remember_with_ttl(fact, links, metadata, None)
183    }
184
185    /// Like [`Self::remember`], but the fact **expires after `ttl_seconds`**.
186    ///
187    /// The expiry is a durable TTL — persisted with the fact (reserved
188    /// `_veles_expires_at` payload field), so it survives a process restart, and
189    /// expired facts stop being recalled. `None` (or `Some(0)`) stores the fact
190    /// permanently, exactly like [`Self::remember`]. Metadata and a TTL combine:
191    /// the metadata is written and the expiry preserved.
192    ///
193    /// The stored metadata is **auto-stamped with today's date** under
194    /// [`crate::storage::AUTO_DATE_FIELD`] (`_veles_date`, a `YYYYMMDD`
195    /// integer read from the system clock at write time — see
196    /// [`crate::clock::today_ymd`]) whenever `metadata` doesn't already carry
197    /// that key; an explicit value in `metadata` (e.g. to date a fact
198    /// retroactively) is never overwritten. No clock is available on
199    /// `wasm32-unknown-unknown`, so that target stamps nothing and `metadata`
200    /// passes through unchanged. This is the ONE place in the crate that
201    /// reads wall-clock time on the write path — the context compiler
202    /// (`compile_context` and friends) stays clock-free and deterministic,
203    /// unaffected by this stamp (it never re-derives a date from `now()`,
204    /// only ever reads whatever a fact already carries).
205    ///
206    /// Because [`Self::remember_extracted`] stores each extracted fact via
207    /// [`Self::remember`] (which delegates here), it gets the same auto-stamp
208    /// for free — entity hubs it also creates go through [`Self::store_fact`]
209    /// directly and are never stamped, since they are internal graph
210    /// scaffolding, not caller facts.
211    ///
212    /// # Errors
213    /// Same as [`Self::remember`].
214    pub fn remember_with_ttl(
215        &self,
216        fact: &str,
217        links: &[Link],
218        metadata: Option<&Metadata>,
219        ttl_seconds: Option<u64>,
220    ) -> Result<u64, MemoryError> {
221        self.remember_inner(fact, links, metadata, ttl_seconds, true)
222    }
223
224    /// The shared write path. `run_autograph` is false for the one caller that
225    /// has ALREADY extracted the passage — [`Self::remember_extracted`] — so a
226    /// service with autograph on does not run a second generation per stored
227    /// fact, re-deriving what it just computed.
228    fn remember_inner(
229        &self,
230        fact: &str,
231        links: &[Link],
232        metadata: Option<&Metadata>,
233        ttl_seconds: Option<u64>,
234        run_autograph: bool,
235    ) -> Result<u64, MemoryError> {
236        let fact = fact.trim();
237        self.validate_write(fact, links, metadata)?;
238        let fact_id = id::stable_id(fact);
239        let existed_before = !links.is_empty() && self.store.get(fact_id)?.is_some();
240        self.write_fact(fact_id, fact, metadata, ttl_seconds)?;
241        self.link_or_rollback(fact_id, links, existed_before)?;
242        self.autograph_if(run_autograph, fact_id, fact);
243        Ok(fact_id)
244    }
245
246    /// Every deterministic rejection, before anything is written: a blank fact,
247    /// reserved or oversized metadata, and each link's label and target. Run as
248    /// one pass so a bad input never leaves a half-written fact behind.
249    fn validate_write(
250        &self,
251        fact: &str,
252        links: &[Link],
253        metadata: Option<&Metadata>,
254    ) -> Result<(), MemoryError> {
255        if fact.is_empty() {
256            return Err(MemoryError::EmptyFact);
257        }
258        reject_reserved_keys(metadata)?;
259        reject_oversized_metadata(metadata)?;
260        self.validate_links(links)
261    }
262
263    /// Embed the fact and persist it with its date-stamped metadata and TTL.
264    fn write_fact(
265        &self,
266        fact_id: u64,
267        fact: &str,
268        metadata: Option<&Metadata>,
269        ttl_seconds: Option<u64>,
270    ) -> Result<(), MemoryError> {
271        let embedding = self.embedder.embed(fact)?;
272        let stamped = stamp_with_today(metadata);
273        self.store_fact(
274            fact_id,
275            fact,
276            &embedding,
277            stamped.as_ref(),
278            positive_ttl(ttl_seconds),
279        )
280    }
281
282    /// Validate EVERY link property — relation label and target existence —
283    /// before any write, so all deterministic link failures happen while
284    /// nothing has been stored or overwritten yet.
285    fn validate_links(&self, links: &[Link]) -> Result<(), MemoryError> {
286        for link in links {
287            validate_relation(&link.relation)?;
288        }
289        self.ensure_link_targets_exist(links)
290    }
291
292    /// Write the edges, undoing a freshly-created fact if one of them fails.
293    ///
294    /// Links are fully pre-validated by [`Self::validate_links`], so an edge
295    /// write can only fail here on a race (e.g. a target's TTL lapsing since
296    /// the pre-check). Roll a FRESH fact back (delete cascades any edges
297    /// already created); a fact that existed before the call is kept —
298    /// deleting it would destroy prior state, and its updated payload stands
299    /// per re-remember's update semantics. The existence probe and the delete
300    /// are not one atomic unit: a concurrent remember of identical content
301    /// between them is last-writer-wins (documented on [`Self::remember`]).
302    fn link_or_rollback(
303        &self,
304        fact_id: u64,
305        links: &[Link],
306        existed_before: bool,
307    ) -> Result<(), MemoryError> {
308        let Err(cause) = self.relate_links(fact_id, links) else {
309            return Ok(());
310        };
311        if existed_before {
312            return Err(cause);
313        }
314        match self.store.delete(fact_id) {
315            Ok(()) => Err(cause),
316            Err(rollback) => Err(MemoryError::RollbackFailed {
317                cause: Box::new(cause),
318                rollback: Box::new(rollback),
319            }),
320        }
321    }
322
323    /// Run [`Self::autograph`] only when this write path asked for it — the
324    /// branch lives here rather than in the write path itself.
325    fn autograph_if(&self, run: bool, fact_id: u64, fact: &str) {
326        if run {
327            self.autograph(fact_id, fact);
328        }
329    }
330
331    /// Autograph one just-stored fact: read the entities, entity→entity edges
332    /// and attributes it states, and wire them around it.
333    ///
334    /// **Deliberately infallible.** The caller's fact is already durably
335    /// stored by the time this runs, and the caller asked to remember a fact —
336    /// not to run a model. Propagating an extraction failure would turn a
337    /// successful write into a reported error, and an agent that sees
338    /// `remember` fail will sensibly retry it, re-running the generation and
339    /// failing again. So a model that is down, slow, or talking nonsense costs
340    /// the *graph enrichment* and nothing else: the memory is kept, the id is
341    /// returned, and the next `remember` tries again.
342    ///
343    /// The trade-off is that a persistently broken extractor degrades silently
344    /// to plain `remember`. That is the right way round — losing structure is
345    /// recoverable by re-remembering, losing the fact is not.
346    fn autograph(&self, fact_id: u64, fact: &str) {
347        let Some(extractor) = self.autograph.as_ref() else {
348            return;
349        };
350        let Ok(extraction) = extractor.extract_graph(fact) else {
351            return;
352        };
353        let mut entity_ids: HashMap<String, u64> = HashMap::new();
354        let mut edges: HashSet<(u64, u64)> = HashSet::new();
355        let mut seeded: HashSet<u64> = HashSet::new();
356        // The caller's fact is the node the topics attach to — the extracted
357        // facts are NOT stored as separate memories here, which is what
358        // separates autograph from `remember_extracted`: one `remember` call
359        // must still produce exactly one caller-visible memory.
360        for extracted in &extraction.facts {
361            let _ = self.wire_entities(
362                fact_id,
363                &extracted.entities,
364                &mut entity_ids,
365                &mut edges,
366                &mut seeded,
367            );
368        }
369        let _ = self.wire_relations(
370            &extraction.relations,
371            &mut entity_ids,
372            &mut edges,
373            &mut seeded,
374        );
375        let _ = self.wire_attributes(&extraction.attributes, &mut entity_ids);
376    }
377
378    /// Create each outgoing link from `fact_id`.
379    ///
380    /// Precondition: every label was already validated by
381    /// [`Self::remember_with_ttl`]'s pre-write pass (its only caller) —
382    /// no re-check here, so the validation rule lives in exactly one
383    /// place on this path.
384    fn relate_links(&self, fact_id: u64, links: &[Link]) -> Result<(), MemoryError> {
385        for link in links {
386            self.store.relate(fact_id, link.target, &link.relation)?;
387        }
388        Ok(())
389    }
390
391    /// Remember a passage of raw `text` by running it through an [`Extractor`]
392    /// and storing every fact it yields, **auto-wiring the fact↔entity graph**.
393    ///
394    /// This is the commodity on top of [`Self::remember`]'s bring-your-own-links
395    /// core: each extracted fact is stored (tagged with `metadata`), each salient
396    /// topic becomes a deduplicated hub memory, and every fact is linked to its
397    /// topics with a bidirectional `about`/`mentions` edge. Two facts sharing a
398    /// topic therefore become reachable from one another, so [`Self::why`] has a
399    /// real graph to traverse with no manual `relate()`.
400    ///
401    /// Entity hubs are content-addressed, so the same topic seen across many
402    /// calls collapses onto one hub. Returns the ids of the stored facts (entity
403    /// hubs excluded), in extraction order.
404    ///
405    /// # Errors
406    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace `text`,
407    /// [`MemoryError::Extract`] if extraction fails, [`MemoryError::ReservedKey`]
408    /// if `metadata` names a reserved key, [`MemoryError::MetadataTooLarge`] if
409    /// `metadata` exceeds [`crate::limits::MAX_METADATA_BYTES`], or a storage
410    /// error if persistence fails.
411    pub fn remember_extracted<X: Extractor>(
412        &self,
413        text: &str,
414        extractor: &X,
415        metadata: Option<&Metadata>,
416    ) -> Result<Vec<u64>, MemoryError> {
417        let text = text.trim();
418        if text.is_empty() {
419            return Err(MemoryError::EmptyFact);
420        }
421        let extraction = extractor.extract_graph(text)?;
422        let mut entity_ids: HashMap<String, u64> = HashMap::new();
423        let mut edges: HashSet<(u64, u64)> = HashSet::new();
424        let mut seeded: HashSet<u64> = HashSet::new();
425        let fact_ids = self.store_extracted_facts(
426            &extraction.facts,
427            metadata,
428            &mut entity_ids,
429            &mut edges,
430            &mut seeded,
431        )?;
432        self.wire_relations(
433            &extraction.relations,
434            &mut entity_ids,
435            &mut edges,
436            &mut seeded,
437        )?;
438        self.wire_attributes(&extraction.attributes, &mut entity_ids)?;
439        Ok(fact_ids)
440    }
441
442    /// Look up everything known about a named entity: the attributes merged
443    /// onto its hub, and the typed edges leaving it.
444    ///
445    /// This is the *read* side of the auto-built graph, and it exists because
446    /// entity hubs are deliberately invisible to [`Self::recall`] and
447    /// [`Self::recall_where`] — a hub ranking for its own topic would evict a
448    /// real fact from the caller's results. Without this accessor an attribute
449    /// merged onto a hub would be stored correctly and yet be unreachable
450    /// through every public read path: the worst kind of feature, one that
451    /// looks done and silently returns nothing.
452    ///
453    /// `name` is canonicalized exactly like an extracted entity (trimmed,
454    /// lowercased), so the caller may pass `"Axel Lange"` and reach the node
455    /// built from `"axel lange"`. Returns `None` when no hub exists for the
456    /// name — nothing has ever mentioned that entity.
457    ///
458    /// # Errors
459    /// Returns [`MemoryError`] if the store lookup fails.
460    pub fn entity_profile(&self, name: &str) -> Result<Option<EntityProfile>, MemoryError> {
461        let key = name.trim().to_lowercase();
462        if key.is_empty() {
463            return Ok(None);
464        }
465        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
466        if self.store.get(id)?.is_none() {
467            return Ok(None);
468        }
469        // Reserved system keys (the hub flag itself) are scaffolding, not
470        // attributes the caller ever wrote — strip them exactly as every other
471        // caller-facing read path does.
472        Ok(Some(EntityProfile {
473            id,
474            name: key,
475            attributes: strip_reserved_keys(self.store.get_metadata(id)?).unwrap_or_default(),
476            relations: self.outgoing_entity_relations(id)?,
477        }))
478    }
479
480    /// The typed edges leaving `id`, resolved to their target's content.
481    ///
482    /// `mentions` edges are dropped: they point at the facts that tagged this
483    /// entity, which is the bipartite scaffolding, not a statement *about* it.
484    fn outgoing_entity_relations(&self, id: u64) -> Result<Vec<EntityRelation>, MemoryError> {
485        let mut relations = Vec::new();
486        for edge in self.store.relations(id)? {
487            if edge.relation == MENTIONS_RELATION {
488                continue;
489            }
490            let target = self.store.get(edge.to)?.map(|(content, _)| content);
491            relations.push(EntityRelation {
492                predicate: edge.relation,
493                target_id: edge.to,
494                target: target.unwrap_or_default(),
495            });
496        }
497        Ok(relations)
498    }
499
500    /// Wire each extracted `subject -[predicate]-> object` triple as a typed
501    /// edge between the two entity hubs.
502    ///
503    /// This is the step that turns the bipartite fact↔topic graph into a real
504    /// knowledge graph. The hubs are resolved through [`Self::entity_hub`], so
505    /// an endpoint naming an entity some earlier passage already introduced
506    /// reuses that entity's existing node rather than forking a parallel one —
507    /// hub ids are content-addressed, so this holds across calls and sessions.
508    ///
509    /// Only the stated direction is written. Inferring the converse
510    /// (`father of` ⇒ `child of`) would mean inventing a label the passage
511    /// never used, and an inverted vocabulary nobody can predict is worse than
512    /// an absent edge: `why()` walks outgoing edges, so a wrong direction
513    /// silently misroutes every later traversal.
514    ///
515    /// A malformed triple is skipped, not fatal — one unusable predicate must
516    /// not cost the caller the facts stored alongside it.
517    fn wire_relations(
518        &self,
519        relations: &[ExtractedRelation],
520        entity_ids: &mut HashMap<String, u64>,
521        edges: &mut HashSet<(u64, u64)>,
522        seeded: &mut HashSet<u64>,
523    ) -> Result<(), MemoryError> {
524        for relation in relations {
525            if validate_relation(&relation.predicate).is_err() {
526                continue;
527            }
528            let subject_id = self.entity_hub(&relation.subject, entity_ids)?;
529            let object_id = self.entity_hub(&relation.object, entity_ids)?;
530            if subject_id == object_id {
531                continue;
532            }
533            self.seed_existing_edges(subject_id, edges, seeded)?;
534            self.add_edge(subject_id, object_id, &relation.predicate, edges)?;
535        }
536        Ok(())
537    }
538
539    /// Merge each extracted attribute into its entity hub's `ColumnStore`
540    /// metadata, so `recall_where` can filter on it (`age >= 15`).
541    ///
542    /// The write goes through `update_metadata`, which **merges** rather than
543    /// replaces. That is the whole point: learning "Axel has a sister" after
544    /// "Axel is 15" must not erase the age. Re-storing the hub payload wholesale
545    /// would silently drop every attribute learned in an earlier session.
546    ///
547    /// Values keep the JSON type the extractor produced. `recall_where`
548    /// compares type-strictly with no coercion, so an age stored as `"15"`
549    /// would never match a numeric filter — no error, just a permanent silent
550    /// miss.
551    ///
552    /// Reserved keys are skipped: a model emitting `content` or a `_veles_`
553    /// key must never be able to overwrite the hub's own content or its
554    /// system flags.
555    fn wire_attributes(
556        &self,
557        attributes: &[ExtractedAttribute],
558        entity_ids: &mut HashMap<String, u64>,
559    ) -> Result<(), MemoryError> {
560        let mut per_entity: HashMap<String, Metadata> = HashMap::new();
561        for attribute in attributes {
562            if is_reserved_key(&attribute.key) {
563                continue;
564            }
565            per_entity
566                .entry(attribute.entity.clone())
567                .or_default()
568                .insert(attribute.key.clone(), attribute.value.clone());
569        }
570        for (entity, meta) in per_entity {
571            if meta.is_empty() {
572                continue;
573            }
574            reject_oversized_metadata(Some(&meta))?;
575            let hub_id = self.entity_hub(&entity, entity_ids)?;
576            self.store.update_metadata(hub_id, &meta)?;
577        }
578        Ok(())
579    }
580
581    /// Store each extracted fact and wire it to its topics, returning their ids.
582    ///
583    /// Goes through the no-autograph path: the passage was ALREADY extracted by
584    /// the caller, so re-running a generation per stored fact would re-derive
585    /// what was just computed.
586    fn store_extracted_facts(
587        &self,
588        facts: &[crate::extract::ExtractedFact],
589        metadata: Option<&Metadata>,
590        entity_ids: &mut HashMap<String, u64>,
591        edges: &mut HashSet<(u64, u64)>,
592        seeded: &mut HashSet<u64>,
593    ) -> Result<Vec<u64>, MemoryError> {
594        let mut fact_ids = Vec::with_capacity(facts.len());
595        for fact in facts {
596            let content = fact.text.trim();
597            if content.is_empty() {
598                continue;
599            }
600            let fact_id = self.remember_inner(content, &[], metadata, None, false)?;
601            fact_ids.push(fact_id);
602            self.wire_entities(fact_id, &fact.entities, entity_ids, edges, seeded)?;
603        }
604        Ok(fact_ids)
605    }
606
607    /// Link `fact_id` to each of its topics with a deduplicated edge in *both*
608    /// directions. `why()` only follows outgoing edges, so the fact→topic edge
609    /// alone leaves hubs as dead ends; the topic→fact edge is what lets a walk
610    /// hop from one fact, through a shared topic, to its sibling facts.
611    fn wire_entities(
612        &self,
613        fact_id: u64,
614        entities: &[String],
615        entity_ids: &mut HashMap<String, u64>,
616        edges: &mut HashSet<(u64, u64)>,
617        seeded: &mut HashSet<u64>,
618    ) -> Result<(), MemoryError> {
619        for entity in entities {
620            // Skip blank or punctuation-only topics: they would persist as junk
621            // hubs (`Entity: -`) yet can never carry a meaningful multi-hop link.
622            if entity.chars().any(char::is_alphanumeric) {
623                self.wire_entity(fact_id, entity, entity_ids, edges, seeded)?;
624            }
625        }
626        Ok(())
627    }
628
629    /// Wire one topic to `fact_id`: resolve its hub, then add the deduplicated
630    /// `about`/`mentions` pair (skipping a hub that is the fact itself).
631    fn wire_entity(
632        &self,
633        fact_id: u64,
634        entity: &str,
635        entity_ids: &mut HashMap<String, u64>,
636        edges: &mut HashSet<(u64, u64)>,
637        seeded: &mut HashSet<u64>,
638    ) -> Result<(), MemoryError> {
639        let entity_id = self.entity_hub(entity, entity_ids)?;
640        if entity_id == fact_id {
641            return Ok(());
642        }
643        // Fold already-persisted edges into the dedup set so re-ingesting the
644        // same text never creates duplicate parallel edges (core `relate` does
645        // not dedup by endpoint+label, only by edge id).
646        self.seed_existing_edges(fact_id, edges, seeded)?;
647        self.seed_existing_edges(entity_id, edges, seeded)?;
648        self.add_edge(fact_id, entity_id, "about", edges)?;
649        self.add_edge(entity_id, fact_id, MENTIONS_RELATION, edges)?;
650        Ok(())
651    }
652
653    /// Create the edge `from -> to` labelled `label`, unless `edges` already
654    /// records that endpoint pair (in-call and persisted dedup).
655    fn add_edge(
656        &self,
657        from: u64,
658        to: u64,
659        label: &str,
660        edges: &mut HashSet<(u64, u64)>,
661    ) -> Result<(), MemoryError> {
662        if edges.insert((from, to)) {
663            self.relate(from, to, label)?;
664        }
665        Ok(())
666    }
667
668    /// Load `node`'s already-persisted outgoing edges into `edges` once per call
669    /// (tracked by `seeded`), so the dedup set reflects the stored graph and a
670    /// repeated ingest is idempotent rather than edge-duplicating.
671    fn seed_existing_edges(
672        &self,
673        node: u64,
674        edges: &mut HashSet<(u64, u64)>,
675        seeded: &mut HashSet<u64>,
676    ) -> Result<(), MemoryError> {
677        if !seeded.insert(node) {
678            return Ok(());
679        }
680        for edge in self.store.relations(node)? {
681            edges.insert((node, edge.to));
682        }
683        Ok(())
684    }
685
686    /// Get or create the hub memory for a topic, caching its id per call. The
687    /// hub id is a deterministic function of the (normalized) topic, so the same
688    /// topic resolves to the same hub across calls — never a duplicate.
689    fn entity_hub(
690        &self,
691        entity: &str,
692        entity_ids: &mut HashMap<String, u64>,
693    ) -> Result<u64, MemoryError> {
694        let key = entity.trim().to_lowercase();
695        if let Some(&id) = entity_ids.get(&key) {
696            return Ok(id);
697        }
698        let id = self.remember_hub(&key)?;
699        entity_ids.insert(key, id);
700        Ok(id)
701    }
702
703    /// Idempotently store the hub memory for topic `key`. The id is salted so the
704    /// hub id space is disjoint from natural fact ids (no caller fact can collide
705    /// with or overwrite a hub), while the stored content stays human-readable.
706    /// Marked with the reserved [`HUB_FIELD`] so recall and `why` seeds exclude
707    /// it; goes straight to [`Self::store_fact`] to bypass the caller-facing
708    /// reserved-key rejection in [`Self::remember`].
709    fn remember_hub(&self, key: &str) -> Result<u64, MemoryError> {
710        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
711        // An existing hub is left exactly as it is. Re-storing it would rewrite
712        // the payload to the bare hub marker and destroy every attribute merged
713        // onto it by an earlier call — learning "Axel has a sister" would erase
714        // "Axel is 15", because a later sentence re-resolves the same hub. The
715        // content is a pure function of `key`, so there is nothing to refresh;
716        // skipping also avoids re-embedding a hub on every single mention.
717        if self.store.get(id)?.is_some() {
718            return Ok(id);
719        }
720        let content = format!("Entity: {key}");
721        let embedding = self.embedder.embed(&content)?;
722        let mut meta = Map::new();
723        meta.insert(HUB_FIELD.to_string(), Value::Bool(true));
724        // Topic hubs are graph anchors — they never expire.
725        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
726        Ok(id)
727    }
728
729    /// Fail with [`MemoryError::UnknownMemory`] unless memory `id` exists.
730    fn ensure_exists(&self, id: u64) -> Result<(), MemoryError> {
731        if self.store.get(id)?.is_none() {
732            return Err(MemoryError::UnknownMemory(id));
733        }
734        Ok(())
735    }
736
737    /// Fail unless every link target already exists (keeps `remember` atomic).
738    fn ensure_link_targets_exist(&self, links: &[Link]) -> Result<(), MemoryError> {
739        for link in links {
740            self.ensure_exists(link.target)?;
741        }
742        Ok(())
743    }
744
745    /// Store a fact with any combination of metadata and a durable TTL.
746    fn store_fact(
747        &self,
748        id: u64,
749        fact: &str,
750        embedding: &[f32],
751        metadata: Option<&Metadata>,
752        ttl_seconds: Option<u64>,
753    ) -> Result<(), MemoryError> {
754        match (metadata, ttl_seconds) {
755            (Some(meta), Some(ttl)) => {
756                // ONE write, not two. The previous `store_with_ttl` then
757                // `update_metadata` pair left the fact live and expiring
758                // between the calls: a short TTL could lapse in the gap and
759                // the metadata write then failed with `NotFound(... is
760                // expired ...)` — the caller got an error on a fact that was
761                // valid when they asked for it. Observed with a 1 s TTL on a
762                // loaded machine. Every TTL'd write takes this arm, since the
763                // auto date stamp means `metadata` is always `Some`.
764                self.store
765                    .store_with_metadata_and_ttl(id, fact, embedding, meta, ttl)?;
766            }
767            (Some(meta), None) => self.store.store_with_metadata(id, fact, embedding, meta)?,
768            (None, Some(ttl)) => self.store.store_with_ttl(id, fact, embedding, ttl)?,
769            (None, None) => self.store.store(id, fact, embedding)?,
770        }
771        Ok(())
772    }
773
774    /// Recall up to `k` memories semantically similar to `query` (vector facet),
775    /// optionally narrowed to an exact-match metadata `filter` (`ColumnStore`
776    /// facet) — e.g. `{ "project": "veles", "status": "resolved" }`.
777    ///
778    /// A highly selective filter may return fewer than `k` hits even when more
779    /// matches exist — raise `k` for fuller coverage with a narrow filter.
780    ///
781    /// Entity hubs created by [`Self::remember_extracted`] are never returned:
782    /// they are internal graph scaffolding, not facts the caller stored.
783    ///
784    /// Each hit carries its caller metadata (`Recollection::metadata`, `None`
785    /// when the fact carries none) — store a date field (e.g. `occurred_at`)
786    /// and it round-trips here, so a caller can sort the result into a
787    /// chronological, date-stamped context without `recall_where`'s explicit
788    /// filters. One extra, single batched lookup covers every returned hit.
789    ///
790    /// # Errors
791    /// Returns [`MemoryError`] if the semantic query or the metadata lookup fails.
792    pub fn recall(
793        &self,
794        query: &str,
795        k: usize,
796        filter: Option<&Metadata>,
797    ) -> Result<Vec<Recollection>, MemoryError> {
798        let query = query.trim();
799        if query.is_empty() {
800            return Ok(Vec::new());
801        }
802        reject_reserved_keys(filter)?;
803        let embedding = self.embedder.embed(query)?;
804        let hits = self.search(&embedding, k, filter)?;
805        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
806        // One raw batched payload lookup (reserved keys included), reused for
807        // BOTH the RL re-rank and the caller-facing metadata below — a single
808        // round trip, not one per concern.
809        let payloads = self.store.get_metadata_batch(&ids)?;
810        // RL Memory: re-order the recalled set by learned confidence. Facts
811        // that never received `feedback` keep their similarity order exactly.
812        #[cfg(feature = "persistence")]
813        let (hits, payloads) = Self::rl_rerank(hits, payloads);
814        Ok(hits
815            .into_iter()
816            .zip(payloads)
817            .map(|((id, score, content), payload)| Recollection {
818                id,
819                score,
820                content,
821                metadata: strip_reserved_keys(payload),
822            })
823            .collect())
824    }
825
826    /// Vector search for up to `k` ids, optionally narrowed by a metadata
827    /// `filter`. Shared by [`Self::recall`] and [`Self::why`].
828    fn search(
829        &self,
830        embedding: &[f32],
831        k: usize,
832        filter: Option<&Metadata>,
833    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
834        match filter {
835            // An include filter already excludes hubs: a hub's payload
836            // carries only reserved keys (`content`, `_veles_hub`), and
837            // reserved keys are rejected from caller filters, so a non-empty
838            // filter can never match a hub. An EMPTY-but-present filter (`Some({})`, the
839            // natural `{}` idiom at the JS boundary) matches every payload —
840            // hubs included — so it must take the hub-excluding path below,
841            // exactly like an absent filter (same `Some({})` ≡ `None`
842            // convention as `recall_fused`'s graph-side `matches_filter`).
843            Some(meta) if !meta.is_empty() => self.store.query_filtered(embedding, k, meta, 0),
844            // Unfiltered recall must still drop entity hubs explicitly, or a hub
845            // like `Entity: rust` would rank for the topic and evict a real fact.
846            _ => self
847                .store
848                .query_excluding(embedding, k, &hub_exclude_filter()),
849        }
850    }
851
852    /// Fused recall: semantic `NEAR` search combined with structured
853    /// `ColumnStore` predicates over metadata columns — ranges and comparisons,
854    /// not just the equality of [`Self::recall`]. One query spanning the vector
855    /// and column facets (e.g. "most similar facts **with `timestamp` in this
856    /// window**"), which a vector-only or equality-only recall cannot express.
857    ///
858    /// Filter *values* are bound as query parameters (never interpolated), so
859    /// they cannot inject; filter *field names* are validated to be plain
860    /// identifiers. Results come back in similarity order.
861    ///
862    /// # Errors
863    /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a plain
864    /// identifier, [`MemoryError::Embed`] if the query cannot be embedded, or a
865    /// storage error if the query fails. An empty query or `k == 0` yields `[]`.
866    pub fn recall_where(
867        &self,
868        query: &str,
869        k: usize,
870        filters: &[ColumnFilter],
871    ) -> Result<Vec<Recollection>, MemoryError> {
872        let query = query.trim();
873        if query.is_empty() || k == 0 {
874            return Ok(Vec::new());
875        }
876        // No column predicates = a plain recall: route through [`Self::recall`]
877        // so entity hubs stay excluded — `query_columnar` with an empty filter
878        // set is a bare vector search that would rank internal `Entity:` hub
879        // scaffolding as results (same `[]` ≡ unfiltered convention as
880        // `search`'s empty-map handling).
881        if filters.is_empty() {
882            return self.recall(query, k, None);
883        }
884        let embedding = self.embedder.embed(query)?;
885        self.store.query_columnar(&embedding, k, filters)
886    }
887
888    /// Create a typed edge `from -> to`. Returns the edge id.
889    ///
890    /// Both endpoints are validated to exist first, so the tool reports an
891    /// unknown id as client input (`UnknownMemory`) rather than a generic
892    /// storage fault — and the graph never gains an edge dangling off a memory
893    /// that was never stored.
894    ///
895    /// # Errors
896    /// Returns [`MemoryError::UnknownMemory`] if either endpoint is missing, or
897    /// a storage error if the edge cannot be created.
898    pub fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError> {
899        validate_relation(relation)?;
900        self.ensure_exists(from)?;
901        self.ensure_exists(to)?;
902        self.store.relate(from, to, relation)
903    }
904
905    /// Forget (delete) the memory with `fact_id`. Returns whether a memory
906    /// actually existed under that id — the underlying store's `delete` is a
907    /// silent no-op on an unknown id (matching most backends' idempotent
908    /// delete semantics), which is indistinguishable from a real deletion
909    /// unless existence is checked first. Every surface that exposes
910    /// `forget` (MCP, Node, WASM, Python) forwards this so a caller can tell
911    /// "I removed something" from "that id was a typo".
912    ///
913    /// The delete always runs, even when `get` reports the id absent: `get`
914    /// filters TTL-expired facts, and an expired-but-unpurged row must still
915    /// be reclaimed (the caller is told `false` — the memory was already
916    /// gone from its perspective). Existence check and delete are two store
917    /// calls, not one atomic operation: two concurrent forgets of one id may
918    /// both report `true`.
919    ///
920    /// # Errors
921    /// Returns [`MemoryError`] if the existence check or the deletion fails.
922    pub fn forget(&self, fact_id: u64) -> Result<bool, MemoryError> {
923        let found = self.store.get(fact_id)?.is_some();
924        // Read the fact's hubs BEFORE the delete: afterwards its edges are gone
925        // and there is no way back to the entities it created.
926        let hubs = self.hubs_linked_from(fact_id)?;
927        self.store.delete(fact_id)?;
928        self.collect_orphan_hubs(&hubs)?;
929        Ok(found)
930    }
931
932    /// The entity hubs `fact_id` points at.
933    ///
934    /// Hubs are recognised by the reserved [`HUB_FIELD`] marker rather than by
935    /// the edge label, so a caller's own `relate` to a hub is seen too.
936    fn hubs_linked_from(&self, fact_id: u64) -> Result<Vec<u64>, MemoryError> {
937        let mut hubs = Vec::new();
938        for edge in self.store.relations(fact_id)? {
939            if self.is_hub(edge.to)? {
940                hubs.push(edge.to);
941            }
942        }
943        Ok(hubs)
944    }
945
946    /// Delete every hub in `hubs` that no surviving fact mentions any more.
947    ///
948    /// An entity outlives the fact that introduced it as long as another fact
949    /// still refers to it — forgetting "Axel is 15" must not erase Axel while
950    /// "Axel has a sister" is still stored. Only a hub whose every `mentions`
951    /// target is gone is itself removed, so entities do not accumulate as
952    /// unreachable scaffolding once the facts behind them are retracted.
953    fn collect_orphan_hubs(&self, hubs: &[u64]) -> Result<(), MemoryError> {
954        for &hub in hubs {
955            if !self.hub_still_mentioned(hub)? {
956                self.store.delete(hub)?;
957            }
958        }
959        Ok(())
960    }
961
962    /// Whether `hub` still points at a fact that exists.
963    fn hub_still_mentioned(&self, hub: u64) -> Result<bool, MemoryError> {
964        for edge in self.store.relations(hub)? {
965            if edge.relation == MENTIONS_RELATION && self.store.get(edge.to)?.is_some() {
966                return Ok(true);
967            }
968        }
969        Ok(false)
970    }
971
972    /// Whether `id` is an entity hub (carries the reserved [`HUB_FIELD`]).
973    fn is_hub(&self, id: u64) -> Result<bool, MemoryError> {
974        Ok(self
975            .store
976            .get_metadata(id)?
977            .is_some_and(|meta| meta.contains_key(HUB_FIELD)))
978    }
979
980    /// Explain a `decision`: find the best-matching memory (optionally scoped to
981    /// a metadata `filter`, e.g. the current project), then walk its typed links
982    /// up to `max_hops` away — fusing the vector, `ColumnStore`, and graph facets.
983    ///
984    /// Returns an empty [`Explanation`] when nothing matches the decision.
985    ///
986    /// # Errors
987    /// Returns [`MemoryError`] if recall or graph traversal fails.
988    pub fn why(
989        &self,
990        decision: &str,
991        max_hops: usize,
992        filter: Option<&Metadata>,
993    ) -> Result<Explanation, MemoryError> {
994        let decision = decision.trim();
995        if decision.is_empty() {
996            return Ok(Explanation::default());
997        }
998        reject_reserved_keys(filter)?;
999        let embedding = self.embedder.embed(decision)?;
1000        let seeds = self.search(&embedding, 1, filter)?;
1001        let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
1002            return Ok(Explanation::default());
1003        };
1004        self.traverse(seed_id, seed_content, max_hops)
1005    }
1006
1007    /// Breadth-first walk over outgoing links from `seed_id`, collecting nodes
1008    /// and edges up to `max_hops` away.
1009    fn traverse(
1010        &self,
1011        seed_id: u64,
1012        seed_content: String,
1013        max_hops: usize,
1014    ) -> Result<Explanation, MemoryError> {
1015        let mut explanation = Explanation {
1016            nodes: vec![MemoryNode {
1017                id: seed_id,
1018                content: seed_content,
1019                hop: 0,
1020            }],
1021            edges: Vec::new(),
1022        };
1023        let mut visited: HashSet<u64> = HashSet::from([seed_id]);
1024        let mut frontier = vec![seed_id];
1025        let mut next: Vec<u64> = Vec::new();
1026        for hop in 1..=max_hops {
1027            next.clear();
1028            for node_id in frontier.drain(..) {
1029                self.expand(node_id, hop, &mut explanation, &mut visited, &mut next)?;
1030            }
1031            if next.is_empty() {
1032                break;
1033            }
1034            std::mem::swap(&mut frontier, &mut next);
1035        }
1036        Ok(explanation)
1037    }
1038
1039    /// Expand a single node: enqueue unseen targets and record edges. An edge is
1040    /// only recorded once its target is a resolved node, so the subgraph never
1041    /// contains an edge pointing at a node absent from `nodes` (e.g. a forgotten
1042    /// target whose edge outlived it).
1043    fn expand(
1044        &self,
1045        node_id: u64,
1046        hop: usize,
1047        explanation: &mut Explanation,
1048        visited: &mut HashSet<u64>,
1049        next: &mut Vec<u64>,
1050    ) -> Result<(), MemoryError> {
1051        for edge in self.store.relations(node_id)? {
1052            let target = edge.to;
1053            if !visited.contains(&target) {
1054                let Some((content, _embedding)) = self.store.get(target)? else {
1055                    continue; // target no longer exists → drop the dangling edge too
1056                };
1057                visited.insert(target);
1058                explanation.nodes.push(MemoryNode {
1059                    id: target,
1060                    content,
1061                    hop,
1062                });
1063                next.push(target);
1064            }
1065            explanation.edges.push(edge);
1066        }
1067        Ok(())
1068    }
1069}
1070
1071/// The metadata filter that excludes entity hubs from unfiltered recall and
1072/// `why` seeds — the negative counterpart [`MemoryService::search`] applies so
1073/// internal `_veles_hub` scaffolding never surfaces as a result.
1074fn hub_exclude_filter() -> Metadata {
1075    let mut exclude = Map::new();
1076    exclude.insert(HUB_FIELD.to_string(), Value::Bool(true));
1077    exclude
1078}
1079
1080/// Reject caller-supplied metadata/filters that name a reserved key.
1081fn reject_reserved_keys(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
1082    let Some(meta) = metadata else {
1083        return Ok(());
1084    };
1085    for key in meta.keys() {
1086        if is_reserved_key(key) {
1087            return Err(MemoryError::ReservedKey(key.clone()));
1088        }
1089    }
1090    Ok(())
1091}
1092
1093/// Reject caller-supplied metadata over [`crate::limits::MAX_METADATA_BYTES`]
1094/// — the `DoS` guard every `remember` path shares (see
1095/// [`MemoryError::MetadataTooLarge`]).
1096fn reject_oversized_metadata(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
1097    let Some(meta) = metadata else {
1098        return Ok(());
1099    };
1100    let bytes = crate::limits::metadata_bytes(meta);
1101    if bytes > crate::limits::MAX_METADATA_BYTES {
1102        return Err(MemoryError::MetadataTooLarge {
1103            bytes,
1104            max: crate::limits::MAX_METADATA_BYTES,
1105        });
1106    }
1107    Ok(())
1108}
1109
1110/// Normalise a requested TTL: `Some(0)` (and `None`) mean "no expiry" — the fact
1111/// is stored permanently. Any positive value is kept as-is.
1112fn positive_ttl(ttl_seconds: Option<u64>) -> Option<u64> {
1113    ttl_seconds.filter(|&seconds| seconds > 0)
1114}
1115
1116/// [`MemoryService::remember_with_ttl`]'s auto-date stamp: `metadata` with
1117/// today's date added under [`AUTO_DATE_FIELD`], unless `metadata` already
1118/// names that key (an explicit, possibly retroactive, caller value is never
1119/// overwritten) or no clock is available ([`clock::today_ymd`] returns `None`
1120/// on `wasm32-unknown-unknown`). Returns an owned map either way, `None` only
1121/// when there is nothing to store at all (no caller metadata AND no clock).
1122fn stamp_with_today(metadata: Option<&Metadata>) -> Option<Metadata> {
1123    if metadata.is_some_and(|meta| meta.contains_key(AUTO_DATE_FIELD)) {
1124        return metadata.cloned();
1125    }
1126    let Some(today) = clock::today_ymd() else {
1127        return metadata.cloned();
1128    };
1129    let mut stamped = metadata.cloned().unwrap_or_default();
1130    stamped.insert(AUTO_DATE_FIELD.to_owned(), Value::from(today));
1131    Some(stamped)
1132}
1133
1134/// Maximum byte length for a relation label (prevents oversized graph edge labels
1135/// from reaching the storage layer).
1136const MAX_RELATION_BYTES: usize = 512;
1137
1138/// Validate a caller-supplied relation label: non-empty, within the size cap, and
1139/// containing only printable, non-control ASCII characters (32–126) or non-ASCII
1140/// Unicode. This prevents null bytes and control characters from reaching the
1141/// storage layer while permitting natural-language labels like `"decided_in"` or
1142/// `"is a friend of"`.
1143fn validate_relation(label: &str) -> Result<(), MemoryError> {
1144    if label.is_empty() {
1145        return Err(MemoryError::InvalidRelation(
1146            "relation label must not be empty".to_owned(),
1147        ));
1148    }
1149    if label.len() > MAX_RELATION_BYTES {
1150        return Err(MemoryError::InvalidRelation(format!(
1151            "relation label exceeds maximum of {MAX_RELATION_BYTES} bytes ({} given)",
1152            label.len()
1153        )));
1154    }
1155    if label.chars().any(|c| c.is_ascii_control()) {
1156        return Err(MemoryError::InvalidRelation(
1157            "relation label must not contain ASCII control characters".to_owned(),
1158        ));
1159    }
1160    Ok(())
1161}