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::{ExtractedAttribute, ExtractedRelation, Extractor};
20use crate::id;
21use crate::model::{
22    ColumnFilter, EntityProfile, EntityRelation, Explanation, Link, MemoryEdge, MemoryNode,
23    Recollection, RememberedExtraction, UnrelateOutcome,
24};
25#[cfg(feature = "persistence")]
26use crate::storage::NativeStore;
27use crate::storage::{is_reserved_key, strip_reserved_keys, MemoryStore, AUTO_DATE_FIELD};
28
29/// [`MemoryService::recall_fused`] and its helpers — split out to keep this
30/// file under the crate's 500-NLOC-per-file budget, same pattern as
31/// `velesdb-core`'s `database/*.rs` split. A child module of `service`, so it
32/// shares full access to `MemoryService`'s private fields and methods.
33#[path = "fused_recall.rs"]
34mod fused_recall;
35
36/// [`MemoryService::feedback`] and the recall re-ranking it drives (RL Memory).
37/// A child module of `service`, like [`fused_recall`], so it uses
38/// `MemoryService`'s private `store` directly. Gated on `persistence`: it
39/// builds on `velesdb-core`'s agent SDK (`ReinforcementStrategy`), itself
40/// behind that feature, and a durable learned confidence is meaningless on the
41/// in-memory (WASM) backend.
42#[cfg(feature = "persistence")]
43#[path = "reinforce.rs"]
44mod reinforce;
45
46/// The context compiler's memory bridge (`compile_context`,
47/// `retrieve_context_source`, `context_savings`, working contexts). A child
48/// module of `service`, like [`fused_recall`], so it reuses the private
49/// `store_fact`/`HUB_FIELD` system-fact machinery — compiler system facts
50/// (sources, events, working contexts) are hub-marked so they never surface
51/// in normal recall.
52#[cfg(feature = "context")]
53#[path = "context/memory_bridge.rs"]
54mod memory_bridge;
55
56/// Reserved metadata key marking an entity hub auto-created by
57/// [`MemoryService::remember_extracted`] (value `true`). Namespaced under the
58/// system `_veles_` prefix so it can never collide with a caller's own metadata,
59/// and rejected from caller-supplied metadata/filters (see [`is_reserved_key`]).
60/// Hubs are internal graph scaffolding — they connect facts that share a topic —
61/// so they are excluded from unfiltered recall and from `why` seeds.
62const HUB_FIELD: &str = "_veles_hub";
63/// Salt mixed into a hub's stable id so the hub id space is disjoint from
64/// natural fact ids: a caller fact whose text happens to equal a hub's display
65/// content (`Entity: rust`) can never collide with, or overwrite, the hub.
66const HUB_ID_SALT: &str = "\u{0}_veles_entity_hub\u{0}";
67/// Edge label a hub uses to point back at a fact it tags (the hub → fact
68/// direction). [`fused_recall`] reads this to recognise which edges in a
69/// `why()` walk crossed a hub, so it can weight the reached fact by that
70/// hub's specificity instead of a flat constant.
71const MENTIONS_RELATION: &str = "mentions";
72/// Edge label a fact uses to point at a hub it is tagged with (the fact → hub
73/// direction) — [`MENTIONS_RELATION`]'s bipartite twin, written by
74/// [`MemoryService::remember_extracted`]'s `wire_entity`.
75const ABOUT_RELATION: &str = "about";
76
77/// Local-first agent memory backed by a single `VelesDB` instance.
78///
79/// Generic over the [`Embedder`] so production can use an on-device model while
80/// tests use a deterministic, network-free one, and over the [`MemoryStore`]
81/// backend `S` so the same orchestration runs over the native, file-backed
82/// engine (the default — nothing changes for existing callers) or any other
83/// backend that implements the trait (e.g. an in-memory one for WASM).
84///
85/// Two definitions, `persistence`-gated: the default type parameter itself
86/// references [`NativeStore`], which doesn't exist as a type at all without
87/// the feature, so a `persistence`-free build (e.g. `velesdb-wasm`) drops the
88/// default and every caller names its own [`MemoryStore`] backend explicitly.
89#[cfg(feature = "persistence")]
90pub struct MemoryService<E: Embedder, S: MemoryStore = NativeStore> {
91    store: S,
92    embedder: E,
93    autograph: Option<crate::extract::DynExtractor>,
94}
95#[cfg(not(feature = "persistence"))]
96pub struct MemoryService<E: Embedder, S: MemoryStore> {
97    store: S,
98    embedder: E,
99    autograph: Option<crate::extract::DynExtractor>,
100}
101
102#[cfg(feature = "persistence")]
103impl<E: Embedder> MemoryService<E, NativeStore> {
104    /// Open (or create) a native, file-backed memory store at `path`, using
105    /// `embedder` for text vectorization. The store never leaves this directory.
106    ///
107    /// # Errors
108    /// Returns [`MemoryError`] if the store cannot be opened or the agent
109    /// memory cannot be initialized for the embedder's dimension.
110    pub fn open<P: AsRef<Path>>(path: P, embedder: E) -> Result<Self, MemoryError> {
111        let store = NativeStore::open(path, embedder.dimension())?;
112        Ok(Self {
113            store,
114            embedder,
115            autograph: None,
116        })
117    }
118}
119
120impl<E: Embedder, S: MemoryStore> MemoryService<E, S> {
121    /// Build a service directly over a `store` backend, bypassing
122    /// [`Self::open`]'s filesystem-specific setup — the constructor a
123    /// non-native backend (e.g. `velesdb-wasm`'s in-memory store) uses.
124    pub fn with_store(store: S, embedder: E) -> Self {
125        Self {
126            store,
127            embedder,
128            autograph: None,
129        }
130    }
131
132    /// Turn on **autograph**: every [`Self::remember`] additionally reads the
133    /// stored fact for entities, entity→entity edges and entity attributes,
134    /// and wires them — so the knowledge graph builds itself from ordinary
135    /// `remember` calls, with no separate [`Self::remember_extracted`].
136    ///
137    /// Opt-in, and off unless this is called. It costs one generation per
138    /// `remember`, which is a real latency and availability change: a memory
139    /// write that silently depends on a local model being up is not a default
140    /// anyone should inherit.
141    ///
142    /// The caller's fact is stored **verbatim and first**. Autograph only
143    /// *adds* structure around it; it never rewrites or replaces what the
144    /// caller asked to remember.
145    #[must_use]
146    pub fn with_autograph(mut self, extractor: crate::extract::DynExtractor) -> Self {
147        self.autograph = Some(extractor);
148        self
149    }
150
151    /// Remember a `fact`, optionally tagging it with structured `metadata`
152    /// (`ColumnStore` facet) and linking it to existing memories (graph facet).
153    /// Returns the stable id of the fact (idempotent on identical content).
154    ///
155    /// The stored metadata is auto-stamped with today's date under
156    /// [`crate::storage::AUTO_DATE_FIELD`] unless `metadata` already carries
157    /// that key — see [`Self::remember_with_ttl`] (this method's only caller)
158    /// for the full contract.
159    ///
160    /// Every link is validated — target existence AND relation label —
161    /// *before* the fact is stored, so bad link input never leaves the fact
162    /// half-written. If an edge write itself fails afterwards (e.g. a target
163    /// expiring concurrently), a freshly-created fact is rolled back; a
164    /// re-remembered fact keeps its updated payload (re-remembering updates
165    /// metadata by design, and deleting it would destroy prior state).
166    /// Concurrent `remember`s of identical content are last-writer-wins,
167    /// not transactional.
168    ///
169    /// # Errors
170    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace facts,
171    /// [`MemoryError::FactTooLarge`] if the fact exceeds
172    /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`],
173    /// [`MemoryError::SelfRelation`] if a link points the fact at itself,
174    /// [`MemoryError::ReservedKey`] if `metadata` names a reserved key
175    /// (`content` or any `_veles_`-prefixed system key, [`crate::storage::AUTO_DATE_FIELD`]
176    /// excepted),
177    /// [`MemoryError::MetadataTooLarge`] if `metadata` exceeds
178    /// [`crate::limits::MAX_METADATA_BYTES`],
179    /// [`MemoryError::UnknownMemory`] if a link points at a missing memory,
180    /// [`MemoryError::InvalidRelation`] for a bad relation label,
181    /// [`MemoryError::RollbackFailed`] if an edge write failed and the
182    /// compensating delete also failed (the fact remains stored),
183    /// or a storage error if persistence fails.
184    pub fn remember(
185        &self,
186        fact: &str,
187        links: &[Link],
188        metadata: Option<&Metadata>,
189    ) -> Result<u64, MemoryError> {
190        self.remember_with_ttl(fact, links, metadata, None)
191    }
192
193    /// Like [`Self::remember`], but the fact **expires after `ttl_seconds`**.
194    ///
195    /// The expiry is a durable TTL — persisted with the fact (reserved
196    /// `_veles_expires_at` payload field), so it survives a process restart, and
197    /// expired facts stop being recalled. `None` stores the fact permanently,
198    /// exactly like [`Self::remember`]; an explicit `Some(0)` is **refused**
199    /// ([`MemoryError::ZeroTtl`]) rather than silently normalised to
200    /// "permanent", which is the opposite of what a caller writing `0` means.
201    /// Metadata and a TTL combine: the metadata is written and the expiry
202    /// preserved.
203    ///
204    /// The stored metadata is **auto-stamped with today's date** under
205    /// [`crate::storage::AUTO_DATE_FIELD`] (`_veles_date`, a `YYYYMMDD`
206    /// integer read from the system clock at write time — see
207    /// [`crate::clock::today_ymd`]) whenever `metadata` doesn't already carry
208    /// that key; an explicit value in `metadata` (e.g. to date a fact
209    /// retroactively) is never overwritten. No clock is available on
210    /// `wasm32-unknown-unknown`, so that target stamps nothing and `metadata`
211    /// passes through unchanged. This is the ONE place in the crate that
212    /// reads wall-clock time on the write path — the context compiler
213    /// (`compile_context` and friends) stays clock-free and deterministic,
214    /// unaffected by this stamp (it never re-derives a date from `now()`,
215    /// only ever reads whatever a fact already carries).
216    ///
217    /// Because [`Self::remember_extracted`] stores each extracted fact via
218    /// [`Self::remember`] (which delegates here), it gets the same auto-stamp
219    /// for free — entity hubs it also creates go through [`Self::store_fact`]
220    /// directly and are never stamped, since they are internal graph
221    /// scaffolding, not caller facts.
222    ///
223    /// # Errors
224    /// Same as [`Self::remember`].
225    pub fn remember_with_ttl(
226        &self,
227        fact: &str,
228        links: &[Link],
229        metadata: Option<&Metadata>,
230        ttl_seconds: Option<u64>,
231    ) -> Result<u64, MemoryError> {
232        self.remember_inner(fact, links, metadata, ttl_seconds, true)
233    }
234
235    /// The shared write path. `run_autograph` is false for the one caller that
236    /// has ALREADY extracted the passage — [`Self::remember_extracted`] — so a
237    /// service with autograph on does not run a second generation per stored
238    /// fact, re-deriving what it just computed.
239    fn remember_inner(
240        &self,
241        fact: &str,
242        links: &[Link],
243        metadata: Option<&Metadata>,
244        ttl_seconds: Option<u64>,
245        run_autograph: bool,
246    ) -> Result<u64, MemoryError> {
247        let fact = fact.trim();
248        self.validate_write(fact, links, metadata, ttl_seconds)?;
249        let fact_id = id::stable_id(fact);
250        reject_self_links(fact_id, links)?;
251        let existed_before = !links.is_empty() && self.store.get(fact_id)?.is_some();
252        self.write_fact(fact_id, fact, metadata, ttl_seconds)?;
253        self.link_or_rollback(fact_id, links, existed_before)?;
254        self.autograph_if(run_autograph, fact_id, fact);
255        Ok(fact_id)
256    }
257
258    /// Every deterministic rejection, before anything is written: a blank or
259    /// over-long fact, an explicit zero TTL, reserved or oversized metadata,
260    /// and each link's label and target. Run as one pass so a bad input never
261    /// leaves a half-written fact behind.
262    fn validate_write(
263        &self,
264        fact: &str,
265        links: &[Link],
266        metadata: Option<&Metadata>,
267        ttl_seconds: Option<u64>,
268    ) -> Result<(), MemoryError> {
269        validate_fact(fact)?;
270        reject_zero_ttl(ttl_seconds)?;
271        reject_reserved_keys(metadata)?;
272        reject_oversized_metadata(metadata)?;
273        self.validate_links(links)
274    }
275
276    /// Embed the fact and persist it with its date-stamped metadata and TTL.
277    fn write_fact(
278        &self,
279        fact_id: u64,
280        fact: &str,
281        metadata: Option<&Metadata>,
282        ttl_seconds: Option<u64>,
283    ) -> Result<(), MemoryError> {
284        let embedding = self.embedder.embed(fact)?;
285        let stamped = stamp_with_today(metadata);
286        // `ttl_seconds` is already known positive-or-absent: `validate_write`
287        // refuses an explicit `Some(0)` before any of this runs.
288        self.store_fact(fact_id, fact, &embedding, stamped.as_ref(), ttl_seconds)
289    }
290
291    /// Validate EVERY link property — relation label and target existence —
292    /// before any write, so all deterministic link failures happen while
293    /// nothing has been stored or overwritten yet.
294    fn validate_links(&self, links: &[Link]) -> Result<(), MemoryError> {
295        for link in links {
296            validate_relation(&link.relation)?;
297        }
298        self.ensure_link_targets_exist(links)
299    }
300
301    /// Write the edges, undoing a freshly-created fact if one of them fails.
302    ///
303    /// Links are fully pre-validated by [`Self::validate_links`], so an edge
304    /// write can only fail here on a race (e.g. a target's TTL lapsing since
305    /// the pre-check). Roll a FRESH fact back (delete cascades any edges
306    /// already created); a fact that existed before the call is kept —
307    /// deleting it would destroy prior state, and its updated payload stands
308    /// per re-remember's update semantics. The existence probe and the delete
309    /// are not one atomic unit: a concurrent remember of identical content
310    /// between them is last-writer-wins (documented on [`Self::remember`]).
311    fn link_or_rollback(
312        &self,
313        fact_id: u64,
314        links: &[Link],
315        existed_before: bool,
316    ) -> Result<(), MemoryError> {
317        let Err(cause) = self.relate_links(fact_id, links) else {
318            return Ok(());
319        };
320        if existed_before {
321            return Err(cause);
322        }
323        match self.store.delete(fact_id) {
324            Ok(()) => Err(cause),
325            Err(rollback) => Err(MemoryError::RollbackFailed {
326                cause: Box::new(cause),
327                rollback: Box::new(rollback),
328            }),
329        }
330    }
331
332    /// Run [`Self::autograph`] only when this write path asked for it — the
333    /// branch lives here rather than in the write path itself.
334    fn autograph_if(&self, run: bool, fact_id: u64, fact: &str) {
335        if run {
336            self.autograph(fact_id, fact);
337        }
338    }
339
340    /// Autograph one just-stored fact: read the entities, entity→entity edges
341    /// and attributes it states, and wire them around it.
342    ///
343    /// **Deliberately infallible.** The caller's fact is already durably
344    /// stored by the time this runs, and the caller asked to remember a fact —
345    /// not to run a model. Propagating an extraction failure would turn a
346    /// successful write into a reported error, and an agent that sees
347    /// `remember` fail will sensibly retry it, re-running the generation and
348    /// failing again. So a model that is down, slow, or talking nonsense costs
349    /// the *graph enrichment* and nothing else: the memory is kept, the id is
350    /// returned, and the next `remember` tries again.
351    ///
352    /// The trade-off is that a persistently broken extractor degrades silently
353    /// to plain `remember`. That is the right way round — losing structure is
354    /// recoverable by re-remembering, losing the fact is not.
355    fn autograph(&self, fact_id: u64, fact: &str) {
356        let Some(extractor) = self.autograph.as_ref() else {
357            return;
358        };
359        let Ok(mut extraction) = extractor.extract_graph(fact) else {
360            return;
361        };
362        crate::extract::orient_kinship(fact, &mut extraction.relations);
363        let mut entity_ids: HashMap<String, u64> = HashMap::new();
364        let mut edges: HashSet<(u64, u64)> = HashSet::new();
365        let mut seeded: HashSet<u64> = HashSet::new();
366        // The caller's fact is the node the topics attach to — the extracted
367        // facts are NOT stored as separate memories here, which is what
368        // separates autograph from `remember_extracted`: one `remember` call
369        // must still produce exactly one caller-visible memory.
370        for extracted in &extraction.facts {
371            let _ = self.wire_entities(
372                fact_id,
373                &extracted.entities,
374                &mut entity_ids,
375                &mut edges,
376                &mut seeded,
377            );
378        }
379        let _ = self.wire_relations(
380            &extraction.relations,
381            &mut entity_ids,
382            &mut edges,
383            &mut seeded,
384        );
385        let _ = self.wire_attributes(&extraction.attributes, &mut entity_ids);
386    }
387
388    /// Create each outgoing link from `fact_id`.
389    ///
390    /// Precondition: every label was already validated by
391    /// [`Self::remember_with_ttl`]'s pre-write pass (its only caller) —
392    /// no re-check here, so the validation rule lives in exactly one
393    /// place on this path.
394    fn relate_links(&self, fact_id: u64, links: &[Link]) -> Result<(), MemoryError> {
395        for link in links {
396            self.store.relate(fact_id, link.target, &link.relation)?;
397        }
398        Ok(())
399    }
400
401    /// Remember a passage of raw `text` by running it through an [`Extractor`]
402    /// and storing every fact it yields, **auto-wiring the fact↔entity graph**.
403    ///
404    /// This is the commodity on top of [`Self::remember`]'s bring-your-own-links
405    /// core: each extracted fact is stored (tagged with `metadata`), each salient
406    /// topic becomes a deduplicated hub memory, and every fact is linked to its
407    /// topics with a bidirectional `about`/`mentions` edge. Two facts sharing a
408    /// topic therefore become reachable from one another, so [`Self::why`] has a
409    /// real graph to traverse with no manual `relate()`.
410    ///
411    /// Entity hubs are content-addressed, so the same topic seen across many
412    /// calls collapses onto one hub. Returns the ids of the stored facts (entity
413    /// hubs excluded), in extraction order, plus how many facts were skipped
414    /// for exceeding the embeddable cap — one unusable fact must not cost the
415    /// others, the policy every other stage of this pipeline already follows
416    /// (a malformed triple is skipped, a blank entity is skipped).
417    ///
418    /// # Errors
419    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace `text`,
420    /// [`MemoryError::Extract`] if extraction fails, [`MemoryError::ReservedKey`]
421    /// if `metadata` names a reserved key, [`MemoryError::MetadataTooLarge`] if
422    /// `metadata` exceeds [`crate::limits::MAX_METADATA_BYTES`], or a storage
423    /// error if persistence fails. A fact past
424    /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`] is NOT an error: it is
425    /// counted in [`RememberedExtraction::skipped_over_cap`] and the call
426    /// carries on.
427    pub fn remember_extracted<X: Extractor>(
428        &self,
429        text: &str,
430        extractor: &X,
431        metadata: Option<&Metadata>,
432    ) -> Result<RememberedExtraction, MemoryError> {
433        let text = text.trim();
434        if text.is_empty() {
435            return Err(MemoryError::EmptyFact);
436        }
437        let mut extraction = extractor.extract_graph(text)?;
438        crate::extract::orient_kinship(text, &mut extraction.relations);
439        let mut entity_ids: HashMap<String, u64> = HashMap::new();
440        let mut edges: HashSet<(u64, u64)> = HashSet::new();
441        let mut seeded: HashSet<u64> = HashSet::new();
442        let outcome = self.store_extracted_facts(
443            &extraction.facts,
444            metadata,
445            &mut entity_ids,
446            &mut edges,
447            &mut seeded,
448        )?;
449        self.wire_relations(
450            &extraction.relations,
451            &mut entity_ids,
452            &mut edges,
453            &mut seeded,
454        )?;
455        self.wire_attributes(&extraction.attributes, &mut entity_ids)?;
456        Ok(outcome)
457    }
458
459    /// Look up everything known about a named entity: the attributes merged
460    /// onto its hub, and the typed edges leaving it.
461    ///
462    /// This is the *read* side of the auto-built graph, and it exists because
463    /// entity hubs are deliberately invisible to [`Self::recall`] and
464    /// [`Self::recall_where`] — a hub ranking for its own topic would evict a
465    /// real fact from the caller's results. Without this accessor an attribute
466    /// merged onto a hub would be stored correctly and yet be unreachable
467    /// through every public read path: the worst kind of feature, one that
468    /// looks done and silently returns nothing.
469    ///
470    /// `name` is canonicalized exactly like an extracted entity (trimmed,
471    /// lowercased), so the caller may pass `"Theo Durand"` and reach the node
472    /// built from `"theo durand"`. Returns `None` when no hub exists for the
473    /// name — nothing has ever mentioned that entity.
474    ///
475    /// # Errors
476    /// Returns [`MemoryError`] if the store lookup fails.
477    pub fn entity_profile(&self, name: &str) -> Result<Option<EntityProfile>, MemoryError> {
478        let key = canonical_entity_name(name);
479        if key.is_empty() {
480            return Ok(None);
481        }
482        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
483        if self.store.get(id)?.is_none() {
484            return Ok(None);
485        }
486        // Reserved system keys (the hub flag itself) are scaffolding, not
487        // attributes the caller ever wrote — strip them exactly as every other
488        // caller-facing read path does.
489        Ok(Some(EntityProfile {
490            id,
491            name: key,
492            attributes: strip_reserved_keys(self.store.get_metadata(id)?).unwrap_or_default(),
493            relations: self.outgoing_entity_relations(id)?,
494            relations_in: self.incoming_entity_relations(id)?,
495        }))
496    }
497
498    /// The typed edges leaving `id`, resolved to their target's content.
499    ///
500    /// Scaffolding edges (`mentions`, and `about` for symmetry — a hub never
501    /// has an outgoing `about`) are dropped: they point at the facts that
502    /// tagged this entity, not at a statement *about* it.
503    fn outgoing_entity_relations(&self, id: u64) -> Result<Vec<EntityRelation>, MemoryError> {
504        self.resolve_entity_relations(self.store.relations(id)?, |edge| edge.to)
505    }
506
507    /// The typed edges pointing at `id`, resolved to their SOURCE's content —
508    /// for an incoming edge the far end is where it comes *from*.
509    ///
510    /// Without these, a question is only answerable from one side: the graph
511    /// holds `camille --soeur de--> theo`, so reading Theo's outgoing edges
512    /// never finds Camille. The edge exists, it simply leaves the other node.
513    ///
514    /// The scaffolding filter is the incoming mirror of
515    /// [`Self::outgoing_entity_relations`]'s: `about` edges are dropped (they
516    /// are the fact → hub half of the `about`/`mentions` pair), and `mentions`
517    /// with them for symmetry.
518    fn incoming_entity_relations(&self, id: u64) -> Result<Vec<EntityRelation>, MemoryError> {
519        self.resolve_entity_relations(self.store.incoming_relations(id)?, |edge| edge.from)
520    }
521
522    /// Shared resolver for both edge directions: skip the bipartite
523    /// scaffolding labels, resolve each edge's far end (`far_end` picks which
524    /// endpoint that is) to its stored content.
525    fn resolve_entity_relations(
526        &self,
527        edges: Vec<MemoryEdge>,
528        far_end: impl Fn(&MemoryEdge) -> u64,
529    ) -> Result<Vec<EntityRelation>, MemoryError> {
530        let mut relations = Vec::new();
531        for edge in edges {
532            if edge.relation == MENTIONS_RELATION || edge.relation == ABOUT_RELATION {
533                continue;
534            }
535            let far = far_end(&edge);
536            let content = self.store.get(far)?.map(|(content, _)| content);
537            relations.push(EntityRelation {
538                predicate: edge.relation,
539                target_id: far,
540                target: content.unwrap_or_default(),
541            });
542        }
543        Ok(relations)
544    }
545
546    /// Wire each extracted `subject -[predicate]-> object` triple as a typed
547    /// edge between the two entity hubs.
548    ///
549    /// This is the step that turns the bipartite fact↔topic graph into a real
550    /// knowledge graph. The hubs are resolved through [`Self::entity_hub`], so
551    /// an endpoint naming an entity some earlier passage already introduced
552    /// reuses that entity's existing node rather than forking a parallel one —
553    /// hub ids are content-addressed, so this holds across calls and sessions.
554    ///
555    /// Only the stated direction is written. Inferring the converse
556    /// (`father of` ⇒ `child of`) would mean inventing a label the passage
557    /// never used, and an inverted vocabulary nobody can predict is worse than
558    /// an absent edge: `why()` walks outgoing edges, so a wrong direction
559    /// silently misroutes every later traversal.
560    ///
561    /// A malformed triple is skipped, not fatal — one unusable predicate must
562    /// not cost the caller the facts stored alongside it.
563    fn wire_relations(
564        &self,
565        relations: &[ExtractedRelation],
566        entity_ids: &mut HashMap<String, u64>,
567        edges: &mut HashSet<(u64, u64)>,
568        seeded: &mut HashSet<u64>,
569    ) -> Result<(), MemoryError> {
570        for relation in relations {
571            if validate_relation(&relation.predicate).is_err() {
572                continue;
573            }
574            let subject_id = self.entity_hub(&relation.subject, entity_ids)?;
575            let object_id = self.entity_hub(&relation.object, entity_ids)?;
576            if subject_id == object_id {
577                continue;
578            }
579            self.seed_existing_edges(subject_id, edges, seeded)?;
580            self.add_edge(subject_id, object_id, &relation.predicate, edges)?;
581        }
582        Ok(())
583    }
584
585    /// Merge each extracted attribute into its entity hub's `ColumnStore`
586    /// metadata, so `recall_where` can filter on it (`age >= 15`).
587    ///
588    /// The write goes through `update_metadata`, which **merges** rather than
589    /// replaces. That is the whole point: learning "Theo has a sister" after
590    /// "Theo is 15" must not erase the age. Re-storing the hub payload wholesale
591    /// would silently drop every attribute learned in an earlier session.
592    ///
593    /// Values keep the JSON type the extractor produced. `recall_where`
594    /// compares type-strictly with no coercion, so an age stored as `"15"`
595    /// would never match a numeric filter — no error, just a permanent silent
596    /// miss.
597    ///
598    /// Reserved keys are skipped: a model emitting `content` or a `_veles_`
599    /// key must never be able to overwrite the hub's own content or its
600    /// system flags.
601    fn wire_attributes(
602        &self,
603        attributes: &[ExtractedAttribute],
604        entity_ids: &mut HashMap<String, u64>,
605    ) -> Result<(), MemoryError> {
606        let mut per_entity: HashMap<String, Metadata> = HashMap::new();
607        for attribute in attributes {
608            if is_reserved_key(&attribute.key) {
609                continue;
610            }
611            per_entity
612                .entry(attribute.entity.clone())
613                .or_default()
614                .insert(attribute.key.clone(), attribute.value.clone());
615        }
616        for (entity, meta) in per_entity {
617            if meta.is_empty() {
618                continue;
619            }
620            reject_oversized_metadata(Some(&meta))?;
621            let hub_id = self.entity_hub(&entity, entity_ids)?;
622            self.store.update_metadata(hub_id, &meta)?;
623        }
624        Ok(())
625    }
626
627    /// Store each extracted fact and wire it to its topics, returning their ids.
628    ///
629    /// Goes through the no-autograph path: the passage was ALREADY extracted by
630    /// the caller, so re-running a generation per stored fact would re-derive
631    /// what was just computed.
632    fn store_extracted_facts(
633        &self,
634        facts: &[crate::extract::ExtractedFact],
635        metadata: Option<&Metadata>,
636        entity_ids: &mut HashMap<String, u64>,
637        edges: &mut HashSet<(u64, u64)>,
638        seeded: &mut HashSet<u64>,
639    ) -> Result<RememberedExtraction, MemoryError> {
640        let mut ids = Vec::with_capacity(facts.len());
641        let mut skipped_over_cap = 0;
642        for fact in facts {
643            let content = fact.text.trim();
644            if content.is_empty() {
645                continue;
646            }
647            // An over-cap fact is skipped, not fatal: aborting here used to
648            // leave the previous iterations persisted with no rollback, no
649            // graph wiring, and no ids returned — the worst of every world.
650            // Every OTHER error still aborts: they signal bad caller input
651            // (reserved keys, oversized metadata) or a failing store, where
652            // carrying on would compound the damage.
653            let fact_id = match self.remember_inner(content, &[], metadata, None, false) {
654                Ok(id) => id,
655                Err(MemoryError::FactTooLarge { .. }) => {
656                    skipped_over_cap += 1;
657                    continue;
658                }
659                Err(error) => return Err(error),
660            };
661            ids.push(fact_id);
662            self.wire_entities(fact_id, &fact.entities, entity_ids, edges, seeded)?;
663        }
664        Ok(RememberedExtraction {
665            ids,
666            skipped_over_cap,
667        })
668    }
669
670    /// Link `fact_id` to each of its topics with a deduplicated edge in *both*
671    /// directions. `why()` only follows outgoing edges, so the fact→topic edge
672    /// alone leaves hubs as dead ends; the topic→fact edge is what lets a walk
673    /// hop from one fact, through a shared topic, to its sibling facts.
674    fn wire_entities(
675        &self,
676        fact_id: u64,
677        entities: &[String],
678        entity_ids: &mut HashMap<String, u64>,
679        edges: &mut HashSet<(u64, u64)>,
680        seeded: &mut HashSet<u64>,
681    ) -> Result<(), MemoryError> {
682        for entity in entities {
683            // Skip blank or punctuation-only topics: they would persist as junk
684            // hubs (`Entity: -`) yet can never carry a meaningful multi-hop link.
685            if entity.chars().any(char::is_alphanumeric) {
686                self.wire_entity(fact_id, entity, entity_ids, edges, seeded)?;
687            }
688        }
689        Ok(())
690    }
691
692    /// Wire one topic to `fact_id`: resolve its hub, then add the deduplicated
693    /// `about`/`mentions` pair (skipping a hub that is the fact itself).
694    fn wire_entity(
695        &self,
696        fact_id: u64,
697        entity: &str,
698        entity_ids: &mut HashMap<String, u64>,
699        edges: &mut HashSet<(u64, u64)>,
700        seeded: &mut HashSet<u64>,
701    ) -> Result<(), MemoryError> {
702        let entity_id = self.entity_hub(entity, entity_ids)?;
703        if entity_id == fact_id {
704            return Ok(());
705        }
706        // Fold already-persisted edges into the dedup set so re-ingesting the
707        // same text never creates duplicate parallel edges (core `relate` does
708        // not dedup by endpoint+label, only by edge id).
709        self.seed_existing_edges(fact_id, edges, seeded)?;
710        self.seed_existing_edges(entity_id, edges, seeded)?;
711        self.add_edge(fact_id, entity_id, ABOUT_RELATION, edges)?;
712        self.add_edge(entity_id, fact_id, MENTIONS_RELATION, edges)?;
713        Ok(())
714    }
715
716    /// Create the edge `from -> to` labelled `label`, unless `edges` already
717    /// records that endpoint pair (in-call and persisted dedup).
718    fn add_edge(
719        &self,
720        from: u64,
721        to: u64,
722        label: &str,
723        edges: &mut HashSet<(u64, u64)>,
724    ) -> Result<(), MemoryError> {
725        if edges.insert((from, to)) {
726            self.relate(from, to, label)?;
727        }
728        Ok(())
729    }
730
731    /// Load `node`'s already-persisted outgoing edges into `edges` once per call
732    /// (tracked by `seeded`), so the dedup set reflects the stored graph and a
733    /// repeated ingest is idempotent rather than edge-duplicating.
734    fn seed_existing_edges(
735        &self,
736        node: u64,
737        edges: &mut HashSet<(u64, u64)>,
738        seeded: &mut HashSet<u64>,
739    ) -> Result<(), MemoryError> {
740        if !seeded.insert(node) {
741            return Ok(());
742        }
743        for edge in self.store.relations(node)? {
744            edges.insert((node, edge.to));
745        }
746        Ok(())
747    }
748
749    /// Get or create the hub memory for a topic, caching its id per call. The
750    /// hub id is a deterministic function of the (normalized) topic, so the same
751    /// topic resolves to the same hub across calls — never a duplicate.
752    fn entity_hub(
753        &self,
754        entity: &str,
755        entity_ids: &mut HashMap<String, u64>,
756    ) -> Result<u64, MemoryError> {
757        let key = entity.trim().to_lowercase();
758        if let Some(&id) = entity_ids.get(&key) {
759            return Ok(id);
760        }
761        let id = self.remember_hub(&key)?;
762        entity_ids.insert(key, id);
763        Ok(id)
764    }
765
766    /// Idempotently store the hub memory for topic `key`. The id is salted so the
767    /// hub id space is disjoint from natural fact ids (no caller fact can collide
768    /// with or overwrite a hub), while the stored content stays human-readable.
769    /// Marked with the reserved [`HUB_FIELD`] so recall and `why` seeds exclude
770    /// it; goes straight to [`Self::store_fact`] to bypass the caller-facing
771    /// reserved-key rejection in [`Self::remember`].
772    fn remember_hub(&self, key: &str) -> Result<u64, MemoryError> {
773        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
774        // An existing hub is left exactly as it is. Re-storing it would rewrite
775        // the payload to the bare hub marker and destroy every attribute merged
776        // onto it by an earlier call — learning "Theo has a sister" would erase
777        // "Theo is 15", because a later sentence re-resolves the same hub. The
778        // content is a pure function of `key`, so there is nothing to refresh;
779        // skipping also avoids re-embedding a hub on every single mention.
780        if self.store.get(id)?.is_some() {
781            return Ok(id);
782        }
783        let content = format!("Entity: {key}");
784        let embedding = self.embedder.embed(&content)?;
785        let mut meta = Map::new();
786        meta.insert(HUB_FIELD.to_string(), Value::Bool(true));
787        // Topic hubs are graph anchors — they never expire.
788        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
789        Ok(id)
790    }
791
792    /// Fail with [`MemoryError::UnknownMemory`] unless memory `id` exists.
793    fn ensure_exists(&self, id: u64) -> Result<(), MemoryError> {
794        if self.store.get(id)?.is_none() {
795            return Err(MemoryError::UnknownMemory(id));
796        }
797        Ok(())
798    }
799
800    /// Fail unless every link target already exists (keeps `remember` atomic).
801    fn ensure_link_targets_exist(&self, links: &[Link]) -> Result<(), MemoryError> {
802        for link in links {
803            self.ensure_exists(link.target)?;
804        }
805        Ok(())
806    }
807
808    /// Store a fact with any combination of metadata and a durable TTL.
809    fn store_fact(
810        &self,
811        id: u64,
812        fact: &str,
813        embedding: &[f32],
814        metadata: Option<&Metadata>,
815        ttl_seconds: Option<u64>,
816    ) -> Result<(), MemoryError> {
817        match (metadata, ttl_seconds) {
818            (Some(meta), Some(ttl)) => {
819                // ONE write, not two. The previous `store_with_ttl` then
820                // `update_metadata` pair left the fact live and expiring
821                // between the calls: a short TTL could lapse in the gap and
822                // the metadata write then failed with `NotFound(... is
823                // expired ...)` — the caller got an error on a fact that was
824                // valid when they asked for it. Observed with a 1 s TTL on a
825                // loaded machine. Every TTL'd write takes this arm, since the
826                // auto date stamp means `metadata` is always `Some`.
827                self.store
828                    .store_with_metadata_and_ttl(id, fact, embedding, meta, ttl)?;
829            }
830            (Some(meta), None) => self.store.store_with_metadata(id, fact, embedding, meta)?,
831            (None, Some(ttl)) => self.store.store_with_ttl(id, fact, embedding, ttl)?,
832            (None, None) => self.store.store(id, fact, embedding)?,
833        }
834        Ok(())
835    }
836
837    /// Recall up to `k` memories semantically similar to `query` (vector facet),
838    /// optionally narrowed to an exact-match metadata `filter` (`ColumnStore`
839    /// facet) — e.g. `{ "project": "veles", "status": "resolved" }`.
840    ///
841    /// A highly selective filter may return fewer than `k` hits even when more
842    /// matches exist — raise `k` for fuller coverage with a narrow filter.
843    ///
844    /// Entity hubs created by [`Self::remember_extracted`] are never returned:
845    /// they are internal graph scaffolding, not facts the caller stored.
846    ///
847    /// Each hit carries its caller metadata (`Recollection::metadata`, `None`
848    /// when the fact carries none) — store a date field (e.g. `occurred_at`)
849    /// and it round-trips here, so a caller can sort the result into a
850    /// chronological, date-stamped context without `recall_where`'s explicit
851    /// filters. One extra, single batched lookup covers every returned hit.
852    ///
853    /// # Errors
854    /// Returns [`MemoryError`] if the semantic query or the metadata lookup fails.
855    pub fn recall(
856        &self,
857        query: &str,
858        k: usize,
859        filter: Option<&Metadata>,
860    ) -> Result<Vec<Recollection>, MemoryError> {
861        let query = query.trim();
862        if query.is_empty() {
863            return Ok(Vec::new());
864        }
865        reject_reserved_keys(filter)?;
866        let embedding = self.embedder.embed(query)?;
867        let hits = self.search(&embedding, k, filter)?;
868        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
869        // One raw batched payload lookup (reserved keys included), reused for
870        // BOTH the RL re-rank and the caller-facing metadata below — a single
871        // round trip, not one per concern.
872        let payloads = self.store.get_metadata_batch(&ids)?;
873        // RL Memory: re-order the recalled set by learned confidence. Facts
874        // that never received `feedback` keep their similarity order exactly.
875        #[cfg(feature = "persistence")]
876        let (hits, payloads) = Self::rl_rerank(hits, payloads);
877        Ok(hits
878            .into_iter()
879            .zip(payloads)
880            .map(|((id, score, content), payload)| Recollection {
881                id,
882                score,
883                content,
884                metadata: strip_reserved_keys(payload),
885            })
886            .collect())
887    }
888
889    /// Vector search for up to `k` ids, optionally narrowed by a metadata
890    /// `filter`. Shared by [`Self::recall`] and [`Self::why`].
891    fn search(
892        &self,
893        embedding: &[f32],
894        k: usize,
895        filter: Option<&Metadata>,
896    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
897        match filter {
898            // An include filter already excludes hubs: a hub's payload
899            // carries only reserved keys (`content`, `_veles_hub`), and
900            // reserved keys are rejected from caller filters, so a non-empty
901            // filter can never match a hub. An EMPTY-but-present filter (`Some({})`, the
902            // natural `{}` idiom at the JS boundary) matches every payload —
903            // hubs included — so it must take the hub-excluding path below,
904            // exactly like an absent filter (same `Some({})` ≡ `None`
905            // convention as `recall_fused`'s graph-side `matches_filter`).
906            Some(meta) if !meta.is_empty() => self.store.query_filtered(embedding, k, meta, 0),
907            // Unfiltered recall must still drop entity hubs explicitly, or a hub
908            // like `Entity: rust` would rank for the topic and evict a real fact.
909            _ => self
910                .store
911                .query_excluding(embedding, k, &hub_exclude_filter()),
912        }
913    }
914
915    /// Fused recall: semantic `NEAR` search combined with structured
916    /// `ColumnStore` predicates over metadata columns — ranges and comparisons,
917    /// not just the equality of [`Self::recall`]. One query spanning the vector
918    /// and column facets (e.g. "most similar facts **with `timestamp` in this
919    /// window**"), which a vector-only or equality-only recall cannot express.
920    ///
921    /// Filter *values* are bound as query parameters (never interpolated), so
922    /// they cannot inject; filter *field names* are validated to be plain
923    /// identifiers. Results come back in similarity order.
924    ///
925    /// # Errors
926    /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a plain
927    /// identifier, [`MemoryError::Embed`] if the query cannot be embedded, or a
928    /// storage error if the query fails. An empty query or `k == 0` yields `[]`.
929    pub fn recall_where(
930        &self,
931        query: &str,
932        k: usize,
933        filters: &[ColumnFilter],
934    ) -> Result<Vec<Recollection>, MemoryError> {
935        let query = query.trim();
936        if query.is_empty() || k == 0 {
937            return Ok(Vec::new());
938        }
939        // No column predicates = a plain recall: route through [`Self::recall`]
940        // so entity hubs stay excluded — `query_columnar` with an empty filter
941        // set is a bare vector search that would rank internal `Entity:` hub
942        // scaffolding as results (same `[]` ≡ unfiltered convention as
943        // `search`'s empty-map handling).
944        if filters.is_empty() {
945            return self.recall(query, k, None);
946        }
947        let embedding = self.embedder.embed(query)?;
948        self.store.query_columnar(&embedding, k, filters)
949    }
950
951    /// Create a typed edge `from -> to`. Returns the edge id.
952    ///
953    /// Both endpoints are validated to exist first, so the tool reports an
954    /// unknown id as client input (`UnknownMemory`) rather than a generic
955    /// storage fault — and the graph never gains an edge dangling off a memory
956    /// that was never stored.
957    ///
958    /// A self-loop (`from == to`) is refused: it states nothing, and `why`
959    /// traverses it like any other edge, so it only adds noise to the
960    /// evidence trail. The same rule covers [`Self::remember`]'s `links`.
961    ///
962    /// # Errors
963    /// Returns [`MemoryError::InvalidRelation`] for a bad label,
964    /// [`MemoryError::SelfRelation`] if both endpoints are the same memory,
965    /// [`MemoryError::UnknownMemory`] if either endpoint is missing, or
966    /// a storage error if the edge cannot be created.
967    pub fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError> {
968        validate_relation(relation)?;
969        if from == to {
970            return Err(MemoryError::SelfRelation(from));
971        }
972        self.ensure_exists(from)?;
973        self.ensure_exists(to)?;
974        self.store.relate(from, to, relation)
975    }
976
977    /// Remove the edge(s) `from -relation-> to`: [`Self::relate`]'s exact
978    /// undo (issue #1661), so a mistaken edge no longer costs the facts at
979    /// its endpoints. Neither the facts nor any entity hub are touched —
980    /// collecting an orphaned hub stays [`Self::forget`]'s job.
981    ///
982    /// Idempotent: an absent edge is `found: false`, not an error, so a
983    /// cleanup is replayable. It refuses exactly what `relate` refuses
984    /// (empty label, self-loop), and deliberately does NOT require the
985    /// endpoints to exist — the edge of a forgotten fact is already gone,
986    /// and reporting that as an error would break replay.
987    ///
988    /// Scope: the store does not distinguish an explicit edge from one the
989    /// autograph derived from a passage, so `unrelate` removes both alike.
990    /// To correct an autograph edge, prefer `forget` + `remember` of the
991    /// source fact — otherwise a later `remember` of the same passage can
992    /// rebuild the edge removed here.
993    ///
994    /// # Errors
995    /// Returns [`MemoryError::InvalidRelation`] for a bad label,
996    /// [`MemoryError::SelfRelation`] if both endpoints are the same memory,
997    /// or a storage error if lookup or removal fails.
998    pub fn unrelate(
999        &self,
1000        from: u64,
1001        to: u64,
1002        relation: &str,
1003    ) -> Result<UnrelateOutcome, MemoryError> {
1004        validate_relation(relation)?;
1005        if from == to {
1006            return Err(MemoryError::SelfRelation(from));
1007        }
1008        let removed = self.remove_matching_edges(from, to, relation)?;
1009        Ok(UnrelateOutcome {
1010            found: removed > 0,
1011            removed,
1012        })
1013    }
1014
1015    /// [`Self::unrelate`]'s removal pass: resolve `from`'s outgoing edges and
1016    /// delete every one matching `(to, relation)` by its id, counting them.
1017    fn remove_matching_edges(
1018        &self,
1019        from: u64,
1020        to: u64,
1021        relation: &str,
1022    ) -> Result<usize, MemoryError> {
1023        let mut removed = 0usize;
1024        for edge in self.store.relations(from)? {
1025            if edge.to == to && edge.relation == relation && self.store.unrelate(edge.id)? {
1026                removed += 1;
1027            }
1028        }
1029        Ok(removed)
1030    }
1031
1032    /// Forget (delete) the memory with `fact_id`. Returns whether a memory
1033    /// actually existed under that id — the underlying store's `delete` is a
1034    /// silent no-op on an unknown id (matching most backends' idempotent
1035    /// delete semantics), which is indistinguishable from a real deletion
1036    /// unless existence is checked first. Every surface that exposes
1037    /// `forget` (MCP, Node, WASM, Python) forwards this so a caller can tell
1038    /// "I removed something" from "that id was a typo".
1039    ///
1040    /// The delete always runs, even when `get` reports the id absent: `get`
1041    /// filters TTL-expired facts, and an expired-but-unpurged row must still
1042    /// be reclaimed (the caller is told `false` — the memory was already
1043    /// gone from its perspective). Existence check and delete are two store
1044    /// calls, not one atomic operation: two concurrent forgets of one id may
1045    /// both report `true`.
1046    ///
1047    /// # Errors
1048    /// Returns [`MemoryError`] if the existence check or the deletion fails.
1049    pub fn forget(&self, fact_id: u64) -> Result<bool, MemoryError> {
1050        let found = self.store.get(fact_id)?.is_some();
1051        // Read the fact's hubs BEFORE the delete: afterwards its edges are gone
1052        // and there is no way back to the entities it created.
1053        let hubs = self.hubs_linked_from(fact_id)?;
1054        self.store.delete(fact_id)?;
1055        self.collect_orphan_hubs(&hubs)?;
1056        Ok(found)
1057    }
1058
1059    /// The entity hubs `fact_id` points at.
1060    ///
1061    /// Hubs are recognised by the reserved [`HUB_FIELD`] marker rather than by
1062    /// the edge label, so a caller's own `relate` to a hub is seen too.
1063    fn hubs_linked_from(&self, fact_id: u64) -> Result<Vec<u64>, MemoryError> {
1064        let mut hubs = Vec::new();
1065        for edge in self.store.relations(fact_id)? {
1066            if self.is_hub(edge.to)? {
1067                hubs.push(edge.to);
1068            }
1069        }
1070        Ok(hubs)
1071    }
1072
1073    /// Delete every hub in `hubs` that no surviving fact mentions any more.
1074    ///
1075    /// An entity outlives the fact that introduced it as long as another fact
1076    /// still refers to it — forgetting "Theo is 15" must not erase Theo while
1077    /// "Theo has a sister" is still stored. Only a hub whose every `mentions`
1078    /// target is gone is itself removed, so entities do not accumulate as
1079    /// unreachable scaffolding once the facts behind them are retracted.
1080    fn collect_orphan_hubs(&self, hubs: &[u64]) -> Result<(), MemoryError> {
1081        for &hub in hubs {
1082            if !self.hub_still_mentioned(hub)? {
1083                self.store.delete(hub)?;
1084            }
1085        }
1086        Ok(())
1087    }
1088
1089    /// Whether anything alive still needs `hub`.
1090    ///
1091    /// Two references count, and the second is why this reads BOTH
1092    /// directions (issue #1662):
1093    ///
1094    /// - an outgoing `mentions` edge to a live fact — the pair
1095    ///   [`Self::wire_entities`] writes, the ordinary case;
1096    /// - an incoming edge from a live NON-HUB fact — what a caller's own
1097    ///   `relate` writes, and it writes one direction only. Relating a fact
1098    ///   to a hub is reachable (`entity()` hands out the hub id), so reading
1099    ///   outgoing edges alone swept hubs from under live callers' edges,
1100    ///   losing them in silence.
1101    ///
1102    /// Incoming edges from another HUB are deliberately ignored: hub↔hub
1103    /// edges exist (`wire_relations` writes them), and counting them would
1104    /// let two hubs keep each other alive forever — a leak whose outcome
1105    /// depends on collection order, which is worse than the bug being fixed.
1106    fn hub_still_mentioned(&self, hub: u64) -> Result<bool, MemoryError> {
1107        for edge in self.store.relations(hub)? {
1108            if edge.relation == MENTIONS_RELATION && self.store.get(edge.to)?.is_some() {
1109                return Ok(true);
1110            }
1111        }
1112        self.hub_has_live_referent(hub)
1113    }
1114
1115    /// Whether a live non-hub fact points AT `hub` — see
1116    /// [`Self::hub_still_mentioned`] for why hub→hub edges do not count.
1117    fn hub_has_live_referent(&self, hub: u64) -> Result<bool, MemoryError> {
1118        for edge in self.store.incoming_relations(hub)? {
1119            if self.store.get(edge.from)?.is_some() && !self.is_hub(edge.from)? {
1120                return Ok(true);
1121            }
1122        }
1123        Ok(false)
1124    }
1125
1126    /// Whether `id` is an entity hub (carries the reserved [`HUB_FIELD`]).
1127    fn is_hub(&self, id: u64) -> Result<bool, MemoryError> {
1128        Ok(self
1129            .store
1130            .get_metadata(id)?
1131            .is_some_and(|meta| meta.contains_key(HUB_FIELD)))
1132    }
1133
1134    /// Explain a `decision`: find the best-matching memory (optionally scoped to
1135    /// a metadata `filter`, e.g. the current project), then walk its typed links
1136    /// up to `max_hops` away — fusing the vector, `ColumnStore`, and graph facets.
1137    ///
1138    /// Returns an empty [`Explanation`] when nothing matches the decision.
1139    ///
1140    /// # Errors
1141    /// Returns [`MemoryError`] if recall or graph traversal fails.
1142    pub fn why(
1143        &self,
1144        decision: &str,
1145        max_hops: usize,
1146        filter: Option<&Metadata>,
1147    ) -> Result<Explanation, MemoryError> {
1148        let decision = decision.trim();
1149        if decision.is_empty() {
1150            return Ok(Explanation::default());
1151        }
1152        reject_reserved_keys(filter)?;
1153        let embedding = self.embedder.embed(decision)?;
1154        let seeds = self.search(&embedding, 1, filter)?;
1155        let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
1156            return Ok(Explanation::default());
1157        };
1158        self.traverse(seed_id, seed_content, max_hops)
1159    }
1160
1161    /// Breadth-first walk over outgoing links from `seed_id`, collecting nodes
1162    /// and edges up to `max_hops` away.
1163    fn traverse(
1164        &self,
1165        seed_id: u64,
1166        seed_content: String,
1167        max_hops: usize,
1168    ) -> Result<Explanation, MemoryError> {
1169        let mut explanation = Explanation {
1170            nodes: vec![MemoryNode {
1171                id: seed_id,
1172                content: seed_content,
1173                hop: 0,
1174            }],
1175            edges: Vec::new(),
1176        };
1177        let mut visited: HashSet<u64> = HashSet::from([seed_id]);
1178        let mut frontier = vec![seed_id];
1179        let mut next: Vec<u64> = Vec::new();
1180        for hop in 1..=max_hops {
1181            next.clear();
1182            for node_id in frontier.drain(..) {
1183                self.expand(node_id, hop, &mut explanation, &mut visited, &mut next)?;
1184            }
1185            if next.is_empty() {
1186                break;
1187            }
1188            std::mem::swap(&mut frontier, &mut next);
1189        }
1190        Ok(explanation)
1191    }
1192
1193    /// Expand a single node: enqueue unseen targets and record edges. An edge is
1194    /// only recorded once its target is a resolved node, so the subgraph never
1195    /// contains an edge pointing at a node absent from `nodes` (e.g. a forgotten
1196    /// target whose edge outlived it).
1197    fn expand(
1198        &self,
1199        node_id: u64,
1200        hop: usize,
1201        explanation: &mut Explanation,
1202        visited: &mut HashSet<u64>,
1203        next: &mut Vec<u64>,
1204    ) -> Result<(), MemoryError> {
1205        for edge in self.store.relations(node_id)? {
1206            let target = edge.to;
1207            if !visited.contains(&target) {
1208                let Some((content, _embedding)) = self.store.get(target)? else {
1209                    continue; // target no longer exists → drop the dangling edge too
1210                };
1211                visited.insert(target);
1212                explanation.nodes.push(MemoryNode {
1213                    id: target,
1214                    content,
1215                    hop,
1216                });
1217                next.push(target);
1218            }
1219            explanation.edges.push(edge);
1220        }
1221        Ok(())
1222    }
1223}
1224
1225/// The metadata filter that excludes entity hubs from unfiltered recall and
1226/// `why` seeds — the negative counterpart [`MemoryService::search`] applies so
1227/// internal `_veles_hub` scaffolding never surfaces as a result.
1228fn hub_exclude_filter() -> Metadata {
1229    let mut exclude = Map::new();
1230    exclude.insert(HUB_FIELD.to_string(), Value::Bool(true));
1231    exclude
1232}
1233
1234/// Reject caller-supplied metadata/filters that name a reserved key.
1235fn reject_reserved_keys(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
1236    let Some(meta) = metadata else {
1237        return Ok(());
1238    };
1239    for key in meta.keys() {
1240        if is_reserved_key(key) {
1241            return Err(MemoryError::ReservedKey(key.clone()));
1242        }
1243    }
1244    Ok(())
1245}
1246
1247/// Reject caller-supplied metadata over [`crate::limits::MAX_METADATA_BYTES`]
1248/// — the `DoS` guard every `remember` path shares (see
1249/// [`MemoryError::MetadataTooLarge`]).
1250fn reject_oversized_metadata(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
1251    let Some(meta) = metadata else {
1252        return Ok(());
1253    };
1254    let bytes = crate::limits::metadata_bytes(meta);
1255    if bytes > crate::limits::MAX_METADATA_BYTES {
1256        return Err(MemoryError::MetadataTooLarge {
1257            bytes,
1258            max: crate::limits::MAX_METADATA_BYTES,
1259        });
1260    }
1261    Ok(())
1262}
1263
1264/// Normalise a TTL supplied as *configuration*: `Some(0)` (and `None`) mean
1265/// "no TTL policy" — the fact is stored permanently. Any positive value is
1266/// kept as-is.
1267///
1268/// Deliberately NOT applied to `remember`'s own `ttl_seconds` any more: an
1269/// explicit per-call `0` is an intent about one fact ("expire it"), and
1270/// silently turning that into "permanent" is the opposite (see
1271/// [`reject_zero_ttl`]). A compile policy's `source_ttl_seconds`, on the
1272/// other hand, is a knob about a whole server, where `0` reading as "no
1273/// policy" is the ordinary, unsurprising meaning.
1274///
1275/// Gated on `context`: since `remember` stopped calling it, the compile
1276/// policy in `context::memory_bridge` is its only caller, and a build
1277/// without that feature saw dead code — which `-D warnings` turns into a
1278/// failed build, not a warning.
1279#[cfg(feature = "context")]
1280pub(crate) fn positive_ttl(ttl_seconds: Option<u64>) -> Option<u64> {
1281    ttl_seconds.filter(|&seconds| seconds > 0)
1282}
1283
1284/// The canonical form of an entity name: trimmed and lowercased, exactly as
1285/// an extracted entity is keyed.
1286///
1287/// Public because a lookup MISS has to echo it too: an adapter that answered
1288/// `name: ""` when nothing matched left a caller running several lookups
1289/// unable to pair a response with its question (issue #1654). Hit and miss go
1290/// through this one function, so the two can never drift.
1291#[must_use]
1292pub fn canonical_entity_name(name: &str) -> String {
1293    name.trim().to_lowercase()
1294}
1295
1296/// Refuse a fact that cannot be stored as written: blank, or past the size an
1297/// embedding model still accepts.
1298///
1299/// The size check runs BEFORE [`MemoryService::write_fact`] calls the
1300/// embedder, so an over-long fact is reported with its own size and the cap
1301/// instead of whatever the backend says — issue #1654 saw `ollama embeddings
1302/// call failed`, which names neither.
1303fn validate_fact(fact: &str) -> Result<(), MemoryError> {
1304    if fact.is_empty() {
1305        return Err(MemoryError::EmptyFact);
1306    }
1307    validate_embeddable(fact)
1308}
1309
1310/// Refuse a text past the size an embedding model still accepts.
1311///
1312/// Extracted from [`validate_fact`] so every path that embeds CALLER content
1313/// answers to the same cap: `remember` refuses (this function), while the
1314/// context bridge truncates via [`embeddable_prefix`] — but neither may hand
1315/// the backend an oversized text and relay its raw failure, which is how
1316/// issue #1654's `ollama embeddings call failed` (naming neither size nor
1317/// cap) reached users.
1318pub(crate) fn validate_embeddable(text: &str) -> Result<(), MemoryError> {
1319    if text.len() > crate::limits::MAX_EMBEDDABLE_TEXT_BYTES {
1320        return Err(MemoryError::FactTooLarge {
1321            bytes: text.len(),
1322            max: crate::limits::MAX_EMBEDDABLE_TEXT_BYTES,
1323        });
1324    }
1325    Ok(())
1326}
1327
1328/// The longest prefix of `text` that fits the embeddable cap without
1329/// splitting a UTF-8 character.
1330///
1331/// For content that must be STORED whole but whose vector only serves
1332/// similarity search (context sources: retrieval is hash-addressed, the
1333/// vector is a ranking aid), truncating the *embedded* text is the correct
1334/// trade — refusing would fail a legitimate compile, and a placeholder
1335/// vector would remove the source from semantic recall entirely.
1336///
1337/// Gated on `context`: the compiler's source writer is its only caller, so a
1338/// build without that feature sees dead code, which CI's `-D warnings`
1339/// turns into a failed build. Same shape as `positive_ttl` — and only the
1340/// per-feature ISOLATION loop catches it, never a feature combination.
1341#[cfg(feature = "context")]
1342pub(crate) fn embeddable_prefix(text: &str) -> &str {
1343    let cap = crate::limits::MAX_EMBEDDABLE_TEXT_BYTES;
1344    if text.len() <= cap {
1345        return text;
1346    }
1347    let mut end = cap;
1348    while end > 0 && !text.is_char_boundary(end) {
1349        end -= 1;
1350    }
1351    &text[..end]
1352}
1353
1354/// Refuse an explicit per-call TTL of `0`.
1355///
1356/// `0` used to be normalised to "no expiry", so a caller who meant "expire
1357/// immediately" silently got a **permanent** fact — the opposite intent, with
1358/// no signal (issue #1654). A TTL supplied as *configuration*
1359/// (`McpServer::with_default_ttl`, a compile policy's `source_ttl_seconds`)
1360/// still reads `0` as "no TTL policy": that is a default about a whole
1361/// server, not an intent about one fact, and it is deliberately untouched.
1362fn reject_zero_ttl(ttl_seconds: Option<u64>) -> Result<(), MemoryError> {
1363    if ttl_seconds == Some(0) {
1364        return Err(MemoryError::ZeroTtl);
1365    }
1366    Ok(())
1367}
1368
1369/// Refuse a `remember` link that points the fact at itself.
1370///
1371/// The same rule [`MemoryService::relate`] enforces, applied to the other way
1372/// a self-loop can be created: re-remembering existing content yields its
1373/// existing id, so a caller CAN name that id as a link target. Without this
1374/// the `relate` guard would only close half the door.
1375fn reject_self_links(fact_id: u64, links: &[Link]) -> Result<(), MemoryError> {
1376    if links.iter().any(|link| link.target == fact_id) {
1377        return Err(MemoryError::SelfRelation(fact_id));
1378    }
1379    Ok(())
1380}
1381
1382/// [`MemoryService::remember_with_ttl`]'s auto-date stamp: `metadata` with
1383/// today's date added under [`AUTO_DATE_FIELD`], unless `metadata` already
1384/// names that key (an explicit, possibly retroactive, caller value is never
1385/// overwritten) or no clock is available ([`clock::today_ymd`] returns `None`
1386/// on `wasm32-unknown-unknown`). Returns an owned map either way, `None` only
1387/// when there is nothing to store at all (no caller metadata AND no clock).
1388fn stamp_with_today(metadata: Option<&Metadata>) -> Option<Metadata> {
1389    if metadata.is_some_and(|meta| meta.contains_key(AUTO_DATE_FIELD)) {
1390        return metadata.cloned();
1391    }
1392    let Some(today) = clock::today_ymd() else {
1393        return metadata.cloned();
1394    };
1395    let mut stamped = metadata.cloned().unwrap_or_default();
1396    stamped.insert(AUTO_DATE_FIELD.to_owned(), Value::from(today));
1397    Some(stamped)
1398}
1399
1400/// Maximum byte length for a relation label (prevents oversized graph edge labels
1401/// from reaching the storage layer).
1402const MAX_RELATION_BYTES: usize = 512;
1403
1404/// Validate a caller-supplied relation label: non-empty, within the size cap, and
1405/// containing only printable, non-control ASCII characters (32–126) or non-ASCII
1406/// Unicode. This prevents null bytes and control characters from reaching the
1407/// storage layer while permitting natural-language labels like `"decided_in"` or
1408/// `"is a friend of"`.
1409fn validate_relation(label: &str) -> Result<(), MemoryError> {
1410    if label.is_empty() {
1411        return Err(MemoryError::InvalidRelation(
1412            "relation label must not be empty".to_owned(),
1413        ));
1414    }
1415    if label.len() > MAX_RELATION_BYTES {
1416        return Err(MemoryError::InvalidRelation(format!(
1417            "relation label exceeds maximum of {MAX_RELATION_BYTES} bytes ({} given)",
1418            label.len()
1419        )));
1420    }
1421    if label.chars().any(|c| c.is_ascii_control()) {
1422        return Err(MemoryError::InvalidRelation(
1423            "relation label must not contain ASCII control characters".to_owned(),
1424        ));
1425    }
1426    Ok(())
1427}