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}