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.
12pub type Metadata = Map<String, Value>;
13
14use crate::embedder::Embedder;
15use crate::error::MemoryError;
16use crate::extract::Extractor;
17use crate::id;
18use crate::model::{ColumnFilter, Explanation, Link, MemoryNode, Recollection};
19#[cfg(feature = "persistence")]
20use crate::storage::NativeStore;
21use crate::storage::{is_reserved_key, strip_reserved_keys, MemoryStore};
22
23/// [`MemoryService::recall_fused`] and its helpers — split out to keep this
24/// file under the crate's 500-NLOC-per-file budget, same pattern as
25/// `velesdb-core`'s `database/*.rs` split. A child module of `service`, so it
26/// shares full access to `MemoryService`'s private fields and methods.
27#[path = "fused_recall.rs"]
28mod fused_recall;
29
30/// Reserved metadata key marking an entity hub auto-created by
31/// [`MemoryService::remember_extracted`] (value `true`). Namespaced under the
32/// system `_veles_` prefix so it can never collide with a caller's own metadata,
33/// and rejected from caller-supplied metadata/filters (see [`is_reserved_key`]).
34/// Hubs are internal graph scaffolding — they connect facts that share a topic —
35/// so they are excluded from unfiltered recall and from `why` seeds.
36const HUB_FIELD: &str = "_veles_hub";
37/// Salt mixed into a hub's stable id so the hub id space is disjoint from
38/// natural fact ids: a caller fact whose text happens to equal a hub's display
39/// content (`Entity: rust`) can never collide with, or overwrite, the hub.
40const HUB_ID_SALT: &str = "\u{0}_veles_entity_hub\u{0}";
41/// Edge label a hub uses to point back at a fact it tags (the hub → fact
42/// direction). [`fused_recall`] reads this to recognise which edges in a
43/// `why()` walk crossed a hub, so it can weight the reached fact by that
44/// hub's specificity instead of a flat constant.
45const MENTIONS_RELATION: &str = "mentions";
46
47/// Local-first agent memory backed by a single `VelesDB` instance.
48///
49/// Generic over the [`Embedder`] so production can use an on-device model while
50/// tests use a deterministic, network-free one, and over the [`MemoryStore`]
51/// backend `S` so the same orchestration runs over the native, file-backed
52/// engine (the default — nothing changes for existing callers) or any other
53/// backend that implements the trait (e.g. an in-memory one for WASM).
54///
55/// Two definitions, `persistence`-gated: the default type parameter itself
56/// references [`NativeStore`], which doesn't exist as a type at all without
57/// the feature, so a `persistence`-free build (e.g. `velesdb-wasm`) drops the
58/// default and every caller names its own [`MemoryStore`] backend explicitly.
59#[cfg(feature = "persistence")]
60pub struct MemoryService<E: Embedder, S: MemoryStore = NativeStore> {
61    store: S,
62    embedder: E,
63}
64#[cfg(not(feature = "persistence"))]
65pub struct MemoryService<E: Embedder, S: MemoryStore> {
66    store: S,
67    embedder: E,
68}
69
70#[cfg(feature = "persistence")]
71impl<E: Embedder> MemoryService<E, NativeStore> {
72    /// Open (or create) a native, file-backed memory store at `path`, using
73    /// `embedder` for text vectorization. The store never leaves this directory.
74    ///
75    /// # Errors
76    /// Returns [`MemoryError`] if the store cannot be opened or the agent
77    /// memory cannot be initialized for the embedder's dimension.
78    pub fn open<P: AsRef<Path>>(path: P, embedder: E) -> Result<Self, MemoryError> {
79        let store = NativeStore::open(path, embedder.dimension())?;
80        Ok(Self { store, embedder })
81    }
82}
83
84impl<E: Embedder, S: MemoryStore> MemoryService<E, S> {
85    /// Build a service directly over a `store` backend, bypassing
86    /// [`Self::open`]'s filesystem-specific setup — the constructor a
87    /// non-native backend (e.g. `velesdb-wasm`'s in-memory store) uses.
88    pub fn with_store(store: S, embedder: E) -> Self {
89        Self { store, embedder }
90    }
91
92    /// Remember a `fact`, optionally tagging it with structured `metadata`
93    /// (`ColumnStore` facet) and linking it to existing memories (graph facet).
94    /// Returns the stable id of the fact (idempotent on identical content).
95    ///
96    /// Every link is validated — target existence AND relation label —
97    /// *before* the fact is stored, so bad link input never leaves the fact
98    /// half-written. If an edge write itself fails afterwards (e.g. a target
99    /// expiring concurrently), a freshly-created fact is rolled back; a
100    /// re-remembered fact keeps its updated payload (re-remembering updates
101    /// metadata by design, and deleting it would destroy prior state).
102    /// Concurrent `remember`s of identical content are last-writer-wins,
103    /// not transactional.
104    ///
105    /// # Errors
106    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace facts,
107    /// [`MemoryError::ReservedKey`] if `metadata` names a reserved key
108    /// (`content` or any `_veles_`-prefixed system key),
109    /// [`MemoryError::UnknownMemory`] if a link points at a missing memory,
110    /// [`MemoryError::InvalidRelation`] for a bad relation label,
111    /// [`MemoryError::RollbackFailed`] if an edge write failed and the
112    /// compensating delete also failed (the fact remains stored),
113    /// or a storage error if persistence fails.
114    pub fn remember(
115        &self,
116        fact: &str,
117        links: &[Link],
118        metadata: Option<&Metadata>,
119    ) -> Result<u64, MemoryError> {
120        self.remember_with_ttl(fact, links, metadata, None)
121    }
122
123    /// Like [`Self::remember`], but the fact **expires after `ttl_seconds`**.
124    ///
125    /// The expiry is a durable TTL — persisted with the fact (reserved
126    /// `_veles_expires_at` payload field), so it survives a process restart, and
127    /// expired facts stop being recalled. `None` (or `Some(0)`) stores the fact
128    /// permanently, exactly like [`Self::remember`]. Metadata and a TTL combine:
129    /// the metadata is written and the expiry preserved.
130    ///
131    /// # Errors
132    /// Same as [`Self::remember`].
133    pub fn remember_with_ttl(
134        &self,
135        fact: &str,
136        links: &[Link],
137        metadata: Option<&Metadata>,
138        ttl_seconds: Option<u64>,
139    ) -> Result<u64, MemoryError> {
140        let fact = fact.trim();
141        if fact.is_empty() {
142            return Err(MemoryError::EmptyFact);
143        }
144        reject_reserved_keys(metadata)?;
145        // EVERY link property — relation label and target existence — is
146        // validated before any write, so all deterministic link failures
147        // happen while nothing has been stored or overwritten yet.
148        for link in links {
149            validate_relation(&link.relation)?;
150        }
151        self.ensure_link_targets_exist(links)?;
152        let fact_id = id::stable_id(fact);
153        let embedding = self.embedder.embed(fact)?;
154        let existed_before = !links.is_empty() && self.store.get(fact_id)?.is_some();
155        self.store_fact(
156            fact_id,
157            fact,
158            &embedding,
159            metadata,
160            positive_ttl(ttl_seconds),
161        )?;
162        // Links are fully pre-validated above, so an edge write can only
163        // fail here on a race (e.g. a target's TTL lapsing since the
164        // pre-check). Roll a FRESH fact back (delete cascades any edges
165        // already created); a fact that existed before this call is kept —
166        // deleting it would destroy prior state, and its updated payload
167        // stands per re-remember's update semantics. The existence probe
168        // and the delete are not one atomic unit: a concurrent remember of
169        // identical content between them is last-writer-wins (documented
170        // on [`Self::remember`]).
171        if let Err(e) = self.relate_links(fact_id, links) {
172            if !existed_before {
173                if let Err(rollback) = self.store.delete(fact_id) {
174                    return Err(MemoryError::RollbackFailed {
175                        cause: Box::new(e),
176                        rollback: Box::new(rollback),
177                    });
178                }
179            }
180            return Err(e);
181        }
182        Ok(fact_id)
183    }
184
185    /// Create each outgoing link from `fact_id`.
186    ///
187    /// Precondition: every label was already validated by
188    /// [`Self::remember_with_ttl`]'s pre-write pass (its only caller) —
189    /// no re-check here, so the validation rule lives in exactly one
190    /// place on this path.
191    fn relate_links(&self, fact_id: u64, links: &[Link]) -> Result<(), MemoryError> {
192        for link in links {
193            self.store.relate(fact_id, link.target, &link.relation)?;
194        }
195        Ok(())
196    }
197
198    /// Remember a passage of raw `text` by running it through an [`Extractor`]
199    /// and storing every fact it yields, **auto-wiring the fact↔entity graph**.
200    ///
201    /// This is the commodity on top of [`Self::remember`]'s bring-your-own-links
202    /// core: each extracted fact is stored (tagged with `metadata`), each salient
203    /// topic becomes a deduplicated hub memory, and every fact is linked to its
204    /// topics with a bidirectional `about`/`mentions` edge. Two facts sharing a
205    /// topic therefore become reachable from one another, so [`Self::why`] has a
206    /// real graph to traverse with no manual `relate()`.
207    ///
208    /// Entity hubs are content-addressed, so the same topic seen across many
209    /// calls collapses onto one hub. Returns the ids of the stored facts (entity
210    /// hubs excluded), in extraction order.
211    ///
212    /// # Errors
213    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace `text`,
214    /// [`MemoryError::Extract`] if extraction fails, [`MemoryError::ReservedKey`]
215    /// if `metadata` names a reserved key, or a storage error if persistence fails.
216    pub fn remember_extracted<X: Extractor>(
217        &self,
218        text: &str,
219        extractor: &X,
220        metadata: Option<&Metadata>,
221    ) -> Result<Vec<u64>, MemoryError> {
222        let text = text.trim();
223        if text.is_empty() {
224            return Err(MemoryError::EmptyFact);
225        }
226        let facts = extractor.extract(text)?;
227        let mut fact_ids = Vec::with_capacity(facts.len());
228        let mut entity_ids: HashMap<String, u64> = HashMap::new();
229        let mut edges: HashSet<(u64, u64)> = HashSet::new();
230        let mut seeded: HashSet<u64> = HashSet::new();
231        for fact in &facts {
232            let content = fact.text.trim();
233            if content.is_empty() {
234                continue;
235            }
236            let fact_id = self.remember(content, &[], metadata)?;
237            fact_ids.push(fact_id);
238            self.wire_entities(
239                fact_id,
240                &fact.entities,
241                &mut entity_ids,
242                &mut edges,
243                &mut seeded,
244            )?;
245        }
246        Ok(fact_ids)
247    }
248
249    /// Link `fact_id` to each of its topics with a deduplicated edge in *both*
250    /// directions. `why()` only follows outgoing edges, so the fact→topic edge
251    /// alone leaves hubs as dead ends; the topic→fact edge is what lets a walk
252    /// hop from one fact, through a shared topic, to its sibling facts.
253    fn wire_entities(
254        &self,
255        fact_id: u64,
256        entities: &[String],
257        entity_ids: &mut HashMap<String, u64>,
258        edges: &mut HashSet<(u64, u64)>,
259        seeded: &mut HashSet<u64>,
260    ) -> Result<(), MemoryError> {
261        for entity in entities {
262            // Skip blank or punctuation-only topics: they would persist as junk
263            // hubs (`Entity: -`) yet can never carry a meaningful multi-hop link.
264            if entity.chars().any(char::is_alphanumeric) {
265                self.wire_entity(fact_id, entity, entity_ids, edges, seeded)?;
266            }
267        }
268        Ok(())
269    }
270
271    /// Wire one topic to `fact_id`: resolve its hub, then add the deduplicated
272    /// `about`/`mentions` pair (skipping a hub that is the fact itself).
273    fn wire_entity(
274        &self,
275        fact_id: u64,
276        entity: &str,
277        entity_ids: &mut HashMap<String, u64>,
278        edges: &mut HashSet<(u64, u64)>,
279        seeded: &mut HashSet<u64>,
280    ) -> Result<(), MemoryError> {
281        let entity_id = self.entity_hub(entity, entity_ids)?;
282        if entity_id == fact_id {
283            return Ok(());
284        }
285        // Fold already-persisted edges into the dedup set so re-ingesting the
286        // same text never creates duplicate parallel edges (core `relate` does
287        // not dedup by endpoint+label, only by edge id).
288        self.seed_existing_edges(fact_id, edges, seeded)?;
289        self.seed_existing_edges(entity_id, edges, seeded)?;
290        self.add_edge(fact_id, entity_id, "about", edges)?;
291        self.add_edge(entity_id, fact_id, MENTIONS_RELATION, edges)?;
292        Ok(())
293    }
294
295    /// Create the edge `from -> to` labelled `label`, unless `edges` already
296    /// records that endpoint pair (in-call and persisted dedup).
297    fn add_edge(
298        &self,
299        from: u64,
300        to: u64,
301        label: &str,
302        edges: &mut HashSet<(u64, u64)>,
303    ) -> Result<(), MemoryError> {
304        if edges.insert((from, to)) {
305            self.relate(from, to, label)?;
306        }
307        Ok(())
308    }
309
310    /// Load `node`'s already-persisted outgoing edges into `edges` once per call
311    /// (tracked by `seeded`), so the dedup set reflects the stored graph and a
312    /// repeated ingest is idempotent rather than edge-duplicating.
313    fn seed_existing_edges(
314        &self,
315        node: u64,
316        edges: &mut HashSet<(u64, u64)>,
317        seeded: &mut HashSet<u64>,
318    ) -> Result<(), MemoryError> {
319        if !seeded.insert(node) {
320            return Ok(());
321        }
322        for edge in self.store.relations(node)? {
323            edges.insert((node, edge.to));
324        }
325        Ok(())
326    }
327
328    /// Get or create the hub memory for a topic, caching its id per call. The
329    /// hub id is a deterministic function of the (normalized) topic, so the same
330    /// topic resolves to the same hub across calls — never a duplicate.
331    fn entity_hub(
332        &self,
333        entity: &str,
334        entity_ids: &mut HashMap<String, u64>,
335    ) -> Result<u64, MemoryError> {
336        let key = entity.trim().to_lowercase();
337        if let Some(&id) = entity_ids.get(&key) {
338            return Ok(id);
339        }
340        let id = self.remember_hub(&key)?;
341        entity_ids.insert(key, id);
342        Ok(id)
343    }
344
345    /// Idempotently store the hub memory for topic `key`. The id is salted so the
346    /// hub id space is disjoint from natural fact ids (no caller fact can collide
347    /// with or overwrite a hub), while the stored content stays human-readable.
348    /// Marked with the reserved [`HUB_FIELD`] so recall and `why` seeds exclude
349    /// it; goes straight to [`Self::store_fact`] to bypass the caller-facing
350    /// reserved-key rejection in [`Self::remember`].
351    fn remember_hub(&self, key: &str) -> Result<u64, MemoryError> {
352        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
353        let content = format!("Entity: {key}");
354        let embedding = self.embedder.embed(&content)?;
355        let mut meta = Map::new();
356        meta.insert(HUB_FIELD.to_string(), Value::Bool(true));
357        // Topic hubs are graph anchors — they never expire.
358        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
359        Ok(id)
360    }
361
362    /// Fail with [`MemoryError::UnknownMemory`] unless memory `id` exists.
363    fn ensure_exists(&self, id: u64) -> Result<(), MemoryError> {
364        if self.store.get(id)?.is_none() {
365            return Err(MemoryError::UnknownMemory(id));
366        }
367        Ok(())
368    }
369
370    /// Fail unless every link target already exists (keeps `remember` atomic).
371    fn ensure_link_targets_exist(&self, links: &[Link]) -> Result<(), MemoryError> {
372        for link in links {
373            self.ensure_exists(link.target)?;
374        }
375        Ok(())
376    }
377
378    /// Store a fact with any combination of metadata and a durable TTL.
379    fn store_fact(
380        &self,
381        id: u64,
382        fact: &str,
383        embedding: &[f32],
384        metadata: Option<&Metadata>,
385        ttl_seconds: Option<u64>,
386    ) -> Result<(), MemoryError> {
387        match (metadata, ttl_seconds) {
388            (Some(meta), Some(ttl)) => {
389                // store_with_ttl writes the fact + the durable expiry; update_metadata
390                // then merges the metadata while preserving `_veles_expires_at`.
391                self.store.store_with_ttl(id, fact, embedding, ttl)?;
392                self.store.update_metadata(id, meta)?;
393            }
394            (Some(meta), None) => self.store.store_with_metadata(id, fact, embedding, meta)?,
395            (None, Some(ttl)) => self.store.store_with_ttl(id, fact, embedding, ttl)?,
396            (None, None) => self.store.store(id, fact, embedding)?,
397        }
398        Ok(())
399    }
400
401    /// Recall up to `k` memories semantically similar to `query` (vector facet),
402    /// optionally narrowed to an exact-match metadata `filter` (`ColumnStore`
403    /// facet) — e.g. `{ "project": "veles", "status": "resolved" }`.
404    ///
405    /// A highly selective filter may return fewer than `k` hits even when more
406    /// matches exist — raise `k` for fuller coverage with a narrow filter.
407    ///
408    /// Entity hubs created by [`Self::remember_extracted`] are never returned:
409    /// they are internal graph scaffolding, not facts the caller stored.
410    ///
411    /// Each hit carries its caller metadata (`Recollection::metadata`, `None`
412    /// when the fact carries none) — store a date field (e.g. `occurred_at`)
413    /// and it round-trips here, so a caller can sort the result into a
414    /// chronological, date-stamped context without `recall_where`'s explicit
415    /// filters. One extra, single batched lookup covers every returned hit.
416    ///
417    /// # Errors
418    /// Returns [`MemoryError`] if the semantic query or the metadata lookup fails.
419    pub fn recall(
420        &self,
421        query: &str,
422        k: usize,
423        filter: Option<&Metadata>,
424    ) -> Result<Vec<Recollection>, MemoryError> {
425        let query = query.trim();
426        if query.is_empty() {
427            return Ok(Vec::new());
428        }
429        reject_reserved_keys(filter)?;
430        let embedding = self.embedder.embed(query)?;
431        let hits = self.search(&embedding, k, filter)?;
432        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
433        let metadata = self.recall_metadata_batch(&ids)?;
434        Ok(hits
435            .into_iter()
436            .zip(metadata)
437            .map(|((id, score, content), metadata)| Recollection {
438                id,
439                score,
440                content,
441                metadata,
442            })
443            .collect())
444    }
445
446    /// Vector search for up to `k` ids, optionally narrowed by a metadata
447    /// `filter`. Shared by [`Self::recall`] and [`Self::why`].
448    fn search(
449        &self,
450        embedding: &[f32],
451        k: usize,
452        filter: Option<&Metadata>,
453    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
454        match filter {
455            // An include filter already excludes hubs: a hub's payload
456            // carries only reserved keys (`content`, `_veles_hub`), and
457            // reserved keys are rejected from caller filters, so a non-empty
458            // filter can never match a hub. An EMPTY-but-present filter (`Some({})`, the
459            // natural `{}` idiom at the JS boundary) matches every payload —
460            // hubs included — so it must take the hub-excluding path below,
461            // exactly like an absent filter (same `Some({})` ≡ `None`
462            // convention as `recall_fused`'s graph-side `matches_filter`).
463            Some(meta) if !meta.is_empty() => self.store.query_filtered(embedding, k, meta, 0),
464            // Unfiltered recall must still drop entity hubs explicitly, or a hub
465            // like `Entity: rust` would rank for the topic and evict a real fact.
466            _ => self
467                .store
468                .query_excluding(embedding, k, &hub_exclude_filter()),
469        }
470    }
471
472    /// Fused recall: semantic `NEAR` search combined with structured
473    /// `ColumnStore` predicates over metadata columns — ranges and comparisons,
474    /// not just the equality of [`Self::recall`]. One query spanning the vector
475    /// and column facets (e.g. "most similar facts **with `timestamp` in this
476    /// window**"), which a vector-only or equality-only recall cannot express.
477    ///
478    /// Filter *values* are bound as query parameters (never interpolated), so
479    /// they cannot inject; filter *field names* are validated to be plain
480    /// identifiers. Results come back in similarity order.
481    ///
482    /// # Errors
483    /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a plain
484    /// identifier, [`MemoryError::Embed`] if the query cannot be embedded, or a
485    /// storage error if the query fails. An empty query or `k == 0` yields `[]`.
486    pub fn recall_where(
487        &self,
488        query: &str,
489        k: usize,
490        filters: &[ColumnFilter],
491    ) -> Result<Vec<Recollection>, MemoryError> {
492        let query = query.trim();
493        if query.is_empty() || k == 0 {
494            return Ok(Vec::new());
495        }
496        // No column predicates = a plain recall: route through [`Self::recall`]
497        // so entity hubs stay excluded — `query_columnar` with an empty filter
498        // set is a bare vector search that would rank internal `Entity:` hub
499        // scaffolding as results (same `[]` ≡ unfiltered convention as
500        // `search`'s empty-map handling).
501        if filters.is_empty() {
502            return self.recall(query, k, None);
503        }
504        let embedding = self.embedder.embed(query)?;
505        self.store.query_columnar(&embedding, k, filters)
506    }
507
508    /// Create a typed edge `from -> to`. Returns the edge id.
509    ///
510    /// Both endpoints are validated to exist first, so the tool reports an
511    /// unknown id as client input (`UnknownMemory`) rather than a generic
512    /// storage fault — and the graph never gains an edge dangling off a memory
513    /// that was never stored.
514    ///
515    /// # Errors
516    /// Returns [`MemoryError::UnknownMemory`] if either endpoint is missing, or
517    /// a storage error if the edge cannot be created.
518    pub fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError> {
519        validate_relation(relation)?;
520        self.ensure_exists(from)?;
521        self.ensure_exists(to)?;
522        self.store.relate(from, to, relation)
523    }
524
525    /// Forget (delete) the memory with `fact_id`.
526    ///
527    /// # Errors
528    /// Returns [`MemoryError`] if the deletion fails.
529    pub fn forget(&self, fact_id: u64) -> Result<(), MemoryError> {
530        self.store.delete(fact_id)
531    }
532
533    /// Explain a `decision`: find the best-matching memory (optionally scoped to
534    /// a metadata `filter`, e.g. the current project), then walk its typed links
535    /// up to `max_hops` away — fusing the vector, `ColumnStore`, and graph facets.
536    ///
537    /// Returns an empty [`Explanation`] when nothing matches the decision.
538    ///
539    /// # Errors
540    /// Returns [`MemoryError`] if recall or graph traversal fails.
541    pub fn why(
542        &self,
543        decision: &str,
544        max_hops: usize,
545        filter: Option<&Metadata>,
546    ) -> Result<Explanation, MemoryError> {
547        let decision = decision.trim();
548        if decision.is_empty() {
549            return Ok(Explanation::default());
550        }
551        reject_reserved_keys(filter)?;
552        let embedding = self.embedder.embed(decision)?;
553        let seeds = self.search(&embedding, 1, filter)?;
554        let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
555            return Ok(Explanation::default());
556        };
557        self.traverse(seed_id, seed_content, max_hops)
558    }
559
560    /// Breadth-first walk over outgoing links from `seed_id`, collecting nodes
561    /// and edges up to `max_hops` away.
562    fn traverse(
563        &self,
564        seed_id: u64,
565        seed_content: String,
566        max_hops: usize,
567    ) -> Result<Explanation, MemoryError> {
568        let mut explanation = Explanation {
569            nodes: vec![MemoryNode {
570                id: seed_id,
571                content: seed_content,
572                hop: 0,
573            }],
574            edges: Vec::new(),
575        };
576        let mut visited: HashSet<u64> = HashSet::from([seed_id]);
577        let mut frontier = vec![seed_id];
578        let mut next: Vec<u64> = Vec::new();
579        for hop in 1..=max_hops {
580            next.clear();
581            for node_id in frontier.drain(..) {
582                self.expand(node_id, hop, &mut explanation, &mut visited, &mut next)?;
583            }
584            if next.is_empty() {
585                break;
586            }
587            std::mem::swap(&mut frontier, &mut next);
588        }
589        Ok(explanation)
590    }
591
592    /// Expand a single node: enqueue unseen targets and record edges. An edge is
593    /// only recorded once its target is a resolved node, so the subgraph never
594    /// contains an edge pointing at a node absent from `nodes` (e.g. a forgotten
595    /// target whose edge outlived it).
596    fn expand(
597        &self,
598        node_id: u64,
599        hop: usize,
600        explanation: &mut Explanation,
601        visited: &mut HashSet<u64>,
602        next: &mut Vec<u64>,
603    ) -> Result<(), MemoryError> {
604        for edge in self.store.relations(node_id)? {
605            let target = edge.to;
606            if !visited.contains(&target) {
607                let Some((content, _embedding)) = self.store.get(target)? else {
608                    continue; // target no longer exists → drop the dangling edge too
609                };
610                visited.insert(target);
611                explanation.nodes.push(MemoryNode {
612                    id: target,
613                    content,
614                    hop,
615                });
616                next.push(target);
617            }
618            explanation.edges.push(edge);
619        }
620        Ok(())
621    }
622}
623
624/// The metadata filter that excludes entity hubs from unfiltered recall and
625/// `why` seeds — the negative counterpart [`MemoryService::search`] applies so
626/// internal `_veles_hub` scaffolding never surfaces as a result.
627fn hub_exclude_filter() -> Metadata {
628    let mut exclude = Map::new();
629    exclude.insert(HUB_FIELD.to_string(), Value::Bool(true));
630    exclude
631}
632
633/// Reject caller-supplied metadata/filters that name a reserved key.
634fn reject_reserved_keys(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
635    let Some(meta) = metadata else {
636        return Ok(());
637    };
638    for key in meta.keys() {
639        if is_reserved_key(key) {
640            return Err(MemoryError::ReservedKey(key.clone()));
641        }
642    }
643    Ok(())
644}
645
646/// Normalise a requested TTL: `Some(0)` (and `None`) mean "no expiry" — the fact
647/// is stored permanently. Any positive value is kept as-is.
648fn positive_ttl(ttl_seconds: Option<u64>) -> Option<u64> {
649    ttl_seconds.filter(|&seconds| seconds > 0)
650}
651
652/// Maximum byte length for a relation label (prevents oversized graph edge labels
653/// from reaching the storage layer).
654const MAX_RELATION_BYTES: usize = 512;
655
656/// Validate a caller-supplied relation label: non-empty, within the size cap, and
657/// containing only printable, non-control ASCII characters (32–126) or non-ASCII
658/// Unicode. This prevents null bytes and control characters from reaching the
659/// storage layer while permitting natural-language labels like `"decided_in"` or
660/// `"is a friend of"`.
661fn validate_relation(label: &str) -> Result<(), MemoryError> {
662    if label.is_empty() {
663        return Err(MemoryError::InvalidRelation(
664            "relation label must not be empty".to_owned(),
665        ));
666    }
667    if label.len() > MAX_RELATION_BYTES {
668        return Err(MemoryError::InvalidRelation(format!(
669            "relation label exceeds maximum of {MAX_RELATION_BYTES} bytes ({} given)",
670            label.len()
671        )));
672    }
673    if label.chars().any(|c| c.is_ascii_control()) {
674        return Err(MemoryError::InvalidRelation(
675            "relation label must not contain ASCII control characters".to_owned(),
676        ));
677    }
678    Ok(())
679}