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