Skip to main content

velesdb_memory/
service.rs

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