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