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