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}