Skip to main content

velesdb_memory/
service.rs

1//! The memory service: five operations over the in-core Agent Memory SDK.
2//!
3//! # Role: the crate's assembler — declared, with a budget
4//!
5//! This module is the ONE place allowed to know every seam at once — store,
6//! embedder, extractor, fusion, autograph, online migration, the working
7//! context bridge — and wire them into the operations the adapters call.
8//! That role is why it stays the crate's densest module (~1 000 lines after
9//! the #2021 cut, against the ~500-NLOC file budget the child-module splits
10//! aim at), and why its size is a *declared* cost rather than an accident:
11//! an assembler that was split by size alone would scatter the wiring it
12//! exists to make readable.
13//!
14//! The budget still binds. Growth goes into child modules that keep private
15//! access (`fused_recall.rs` and `online_migration.rs` are the pattern —
16//! `#[path]` children of `service`, not siblings), never into this file. The
17//! working-context bridge was the last named cut and it is done: it left along
18//! the store-facet line of #1959, as #1967 asked. A change that adds an
19//! operation body here instead of a child module needs to say why in review.
20
21use std::collections::{HashMap, HashSet};
22#[cfg(feature = "persistence")]
23use std::path::Path;
24#[cfg(feature = "persistence")]
25use std::sync::Arc;
26
27use serde_json::{Map, Value};
28
29/// Structured metadata attached to a memory (the `ColumnStore` facet): exact-match
30/// fields like `project`, `author`, `type`, `status`, `date`. `content` and
31/// `_veles_expires_at` are reserved keys. [`crate::storage::AUTO_DATE_FIELD`]
32/// (`_veles_date`) is auto-populated by [`MemoryService::remember_with_ttl`]
33/// with today's date unless already present — see that method's docs.
34pub type Metadata = Map<String, Value>;
35
36use crate::clock;
37use crate::embedder::Embedder;
38use crate::error::MemoryError;
39use crate::extract::{ExtractedAttribute, ExtractedRelation, Extractor};
40use crate::id;
41use crate::model::{
42    ColumnFilter, EntityProfile, EntityRelation, Explanation, Link, MemoryEdge, MemoryNode,
43    Recollection, RememberedExtraction, UnrelateOutcome,
44};
45#[cfg(feature = "persistence")]
46use crate::mutation::MutationObserver;
47#[cfg(feature = "persistence")]
48use crate::storage::NativeStore;
49use crate::storage::{
50    is_reserved_key, strip_reserved_keys, ColumnStore, FactStore, GraphStore, RecallStore,
51    AUTO_DATE_FIELD,
52};
53
54/// [`MemoryService::recall_fused`] and its helpers — split out to keep this
55/// file under the crate's 500-NLOC-per-file budget, same pattern as
56/// `velesdb-core`'s `database/*.rs` split. A child module of `service`, so it
57/// shares full access to `MemoryService`'s private fields and methods.
58#[path = "fused_recall.rs"]
59mod fused_recall;
60
61/// The graph facet — `relate`/`forget`/hubs/`why`/`traverse` — same split,
62/// same reason; see `service_graph.rs`'s module doc.
63#[path = "service_graph.rs"]
64mod graph;
65
66/// The graph facet's *construction* half — `remember*`/`autograph*` and the
67/// `wire_*` plumbing — same split, same reason; see its module doc (#2021).
68#[path = "service_graph_wiring.rs"]
69mod graph_wiring;
70
71#[cfg(feature = "persistence")]
72#[allow(dead_code)]
73#[path = "online_migration.rs"]
74mod online_migration;
75#[cfg(feature = "persistence")]
76pub(crate) use online_migration::recover_startup;
77#[cfg(feature = "mcp")]
78pub(crate) use online_migration::{
79    JobPhase, JobTarget, LiveGenerationSlot, MigrationStartConfig, MigrationStatus,
80    OnlineMigrationManager,
81};
82
83/// [`MemoryService::feedback`] and the recall re-ranking it drives (RL Memory).
84/// A child module of `service`, like [`fused_recall`], so it uses
85/// `MemoryService`'s private `store` directly. Gated on `persistence`: it
86/// builds on `velesdb-core`'s agent SDK (`ReinforcementStrategy`), itself
87/// behind that feature, and a durable learned confidence is meaningless on the
88/// in-memory (WASM) backend.
89#[cfg(feature = "persistence")]
90#[path = "reinforce.rs"]
91mod reinforce;
92
93/// The context compiler's memory bridge (`compile_context`,
94/// `retrieve_context_source`, `context_savings`, working contexts). A child
95/// module of `service`, like [`fused_recall`], so it reuses the private
96/// `store_fact`/`HUB_FIELD` system-fact machinery — compiler system facts
97/// (sources, events, working contexts) are hub-marked so they never surface
98/// in normal recall.
99#[cfg(feature = "context")]
100#[path = "context/memory_bridge.rs"]
101mod memory_bridge;
102
103/// Reserved metadata key marking an entity hub auto-created by
104/// [`MemoryService::remember_extracted`] (value `true`). Namespaced under the
105/// system `_veles_` prefix so it can never collide with a caller's own metadata,
106/// and rejected from caller-supplied metadata/filters (see [`is_reserved_key`]).
107/// Hubs are internal graph scaffolding — they connect facts that share a topic —
108/// so they are excluded from unfiltered recall and from `why` seeds.
109///
110/// Re-exported from [`crate::storage`] rather than spelled out again here: it
111/// is one of the five markers [`crate::storage::INTERNAL_MARKER_FIELDS`]
112/// excludes from `recall_where`, and a second literal could drift from that
113/// list without any test noticing.
114use crate::storage::HUB_FIELD;
115/// Salt mixed into a hub's stable id so the hub id space is disjoint from
116/// natural fact ids: a caller fact whose text happens to equal a hub's display
117/// content (`Entity: rust`) can never collide with, or overwrite, the hub.
118const HUB_ID_SALT: &str = "\u{0}_veles_entity_hub\u{0}";
119/// Edge label a hub uses to point back at a fact it tags (the hub → fact
120/// direction). [`fused_recall`] reads this to recognise which edges in a
121/// `why()` walk crossed a hub, so it can weight the reached fact by that
122/// hub's specificity instead of a flat constant.
123const MENTIONS_RELATION: &str = "mentions";
124/// Edge label a fact uses to point at a hub it is tagged with (the fact → hub
125/// direction) — [`MENTIONS_RELATION`]'s bipartite twin, written by
126/// [`MemoryService::remember_extracted`]'s `wire_entity`.
127const ABOUT_RELATION: &str = "about";
128
129/// Local-first agent memory backed by a single `VelesDB` instance.
130///
131/// Generic over the [`Embedder`] so production can use an on-device model while
132/// tests use a deterministic, network-free one, and over the [`FactStore`]
133/// backend `S` so the same orchestration runs over the native, file-backed
134/// engine (the default — nothing changes for existing callers) or any other
135/// backend that implements the storage facets it uses (e.g. an in-memory one
136/// for WASM). Methods needing recall, graph, or columnar capability carry
137/// that facet as an extra bound — a partial backend simply does not have
138/// those methods (#1959).
139///
140/// Two definitions, `persistence`-gated: the default type parameter itself
141/// references [`NativeStore`], which doesn't exist as a type at all without
142/// the feature, so a `persistence`-free build (e.g. `velesdb-wasm`) drops the
143/// default and every caller names its own storage backend explicitly. The
144/// duplication stops at the type-parameter list: the field lists are
145/// identical by contract — what varies per feature is the *type* of
146/// [`GenerationGate`], never the shape of the service — and
147/// `tests/service_field_drift.rs` fails the build of any change that lets
148/// them diverge again (#2017).
149#[cfg(feature = "persistence")]
150pub struct MemoryService<E: Embedder, S: FactStore = NativeStore> {
151    store: S,
152    embedder: E,
153    autograph: Option<crate::extract::DynExtractor>,
154    autograph_queue: AutographQueue,
155    generation_gate: GenerationGate,
156}
157#[cfg(not(feature = "persistence"))]
158pub struct MemoryService<E: Embedder, S: FactStore> {
159    store: S,
160    embedder: E,
161    autograph: Option<crate::extract::DynExtractor>,
162    autograph_queue: AutographQueue,
163    generation_gate: GenerationGate,
164}
165
166#[path = "service_generation.rs"]
167mod service_generation;
168use service_generation::{GenerationGate, GenerationGuard};
169
170/// One deferred autograph: the stored fact a background worker will read for
171/// entities, edges and attributes (#1846).
172// The fields are read only on the worker path, which `spawn_autograph_worker`
173// cfg-gates off wasm32 (no threads there) — without this the wasm check dies
174// on dead_code under -D warnings.
175#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
176struct AutographJob {
177    fact_id: u64,
178    fact: String,
179}
180
181/// The decoupling state of [`MemoryService::autograph`] (#1846).
182///
183/// Empty by default: every construction path starts with autograph running
184/// INLINE, exactly as before — the WASM binding has no threads, library
185/// consumers keep the synchronous contract, and every existing test stays
186/// meaningful. [`MemoryService::spawn_autograph_worker`] fills `tx`, after
187/// which `remember` only ENQUEUES: the caller stops paying the generation on
188/// its response path — 46 s measured for a 12-word fact on the production
189/// daemon, versus a 0.12 s embedding — and the worker wires the graph behind.
190///
191/// `dropped` counts enrichments refused by a FULL queue or skipped by a
192/// closing worker. Non-negotiably visible: a burst that outruns the
193/// extractor loses graph structure, and a loss nobody can see is the exact
194/// defect class #1820 closed for responses.
195///
196/// `closing` is the shutdown latch: the handle's drop raises it BEFORE
197/// removing the sender, so the worker finishes the job in flight and SKIPS
198/// what is still queued (counted, one aggregated warning) instead of
199/// draining a queue of generations — 64 × a 46 s model would hold the
200/// daemon's exit for tens of minutes. Re-armed by each spawn.
201#[derive(Default)]
202// `closing` is read only by the worker/drop path, absent on wasm32 — same
203// rationale as `AutographJob` above.
204#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
205struct AutographQueue {
206    tx: parking_lot::Mutex<Option<std::sync::mpsc::SyncSender<AutographJob>>>,
207    dropped: std::sync::atomic::AtomicU64,
208    /// Enrichments that RAN and failed part-way through wiring — distinct
209    /// from `dropped` (never ran). See `MemoryService::autograph_failed`.
210    failed: std::sync::atomic::AtomicU64,
211    closing: std::sync::atomic::AtomicBool,
212}
213
214/// Join guard for the background autograph worker.
215///
216/// Dropping it raises the closing latch, takes the sender out of the
217/// service, and JOINS the worker: the job in flight completes, the
218/// still-queued ones are SKIPPED — counted in the drop counter, one
219/// aggregated warning — and only then does the drop return. Tests get
220/// determinism; the daemon's shutdown waits for at most ONE generation,
221/// never a queue of them.
222pub struct AutographWorkerHandle {
223    close_queue: Option<Box<dyn FnOnce() + Send + Sync>>,
224    join: Option<std::thread::JoinHandle<()>>,
225}
226
227impl Drop for AutographWorkerHandle {
228    fn drop(&mut self) {
229        if let Some(close) = self.close_queue.take() {
230            close();
231        }
232        if let Some(join) = self.join.take() {
233            let _ = join.join();
234        }
235    }
236}
237
238#[cfg(feature = "persistence")]
239impl<E: Embedder> MemoryService<E, NativeStore> {
240    /// Open (or create) a native, file-backed memory store at `path`, using
241    /// `embedder` for text vectorization. The store never leaves this directory.
242    ///
243    /// # Errors
244    /// Returns [`MemoryError`] if the store cannot be opened or the agent
245    /// memory cannot be initialized for the embedder's dimension.
246    pub fn open<P: AsRef<Path>>(path: P, embedder: E) -> Result<Self, MemoryError> {
247        let store = NativeStore::open(path, embedder.dimension())?;
248        Ok(Self {
249            store,
250            embedder,
251            autograph: None,
252            autograph_queue: AutographQueue::default(),
253            generation_gate: GenerationGate::new(),
254        })
255    }
256
257    pub(crate) fn install_mutation_observer(
258        &self,
259        observer: Option<Arc<dyn MutationObserver>>,
260    ) -> Result<(), MemoryError> {
261        let _generation = self.generation_gate.write();
262        self.store.set_mutation_observer(observer)
263    }
264
265    pub(crate) fn migration_capture_active(&self) -> bool {
266        self.store.mutation_capture_active()
267    }
268}
269
270impl<E: Embedder, S: FactStore> MemoryService<E, S> {
271    /// Build a service directly over a `store` backend, bypassing
272    /// [`Self::open`]'s filesystem-specific setup — the constructor a
273    /// non-native backend (e.g. `velesdb-wasm`'s in-memory store) uses.
274    pub fn with_store(store: S, embedder: E) -> Self {
275        Self {
276            store,
277            embedder,
278            autograph: None,
279            autograph_queue: AutographQueue::default(),
280            generation_gate: GenerationGate::new(),
281        }
282    }
283
284    fn enter_generation(&self) -> GenerationGuard<'_> {
285        GenerationGuard {
286            _guard: self.generation_gate.read(),
287        }
288    }
289
290    /// Turn on **autograph**: every [`Self::remember`] additionally reads the
291    /// stored fact for entities, entity→entity edges and entity attributes,
292    /// and wires them — so the knowledge graph builds itself from ordinary
293    /// `remember` calls, with no separate [`Self::remember_extracted`].
294    ///
295    /// Opt-in, and off unless this is called. It runs in one of two modes:
296    /// **inline** by default — the enrichment costs one generation per
297    /// `remember`, on the caller's write path, which is a real latency and
298    /// availability change: a memory write that silently depends on a local
299    /// model being up is not a default anyone should inherit — or
300    /// **decoupled** when [`Self::spawn_autograph_worker`] is active, where
301    /// `remember` returns as soon as the fact is durably stored and the
302    /// derived edges lag by one generation (an `entity`/`why` read issued
303    /// immediately after may not see them yet; the fact itself is always
304    /// immediately readable).
305    ///
306    /// The caller's fact is stored **verbatim and first**. Autograph only
307    /// *adds* structure around it; it never rewrites or replaces what the
308    /// caller asked to remember.
309    #[must_use]
310    pub fn with_autograph(mut self, extractor: crate::extract::DynExtractor) -> Self {
311        self.autograph = Some(extractor);
312        self
313    }
314
315    /// Every deterministic rejection, before anything is written: a blank or
316    /// over-long fact, an explicit zero TTL, reserved or oversized metadata,
317    /// and each link's label and target. Run as one pass so a bad input never
318    /// leaves a half-written fact behind.
319    fn validate_write(
320        &self,
321        fact: &str,
322        links: &[Link],
323        metadata: Option<&Metadata>,
324        ttl_seconds: Option<u64>,
325    ) -> Result<(), MemoryError> {
326        validate_fact(fact)?;
327        reject_zero_ttl(ttl_seconds)?;
328        reject_reserved_keys(metadata)?;
329        reject_oversized_metadata(metadata)?;
330        self.validate_links(links)
331    }
332
333    /// Embed the fact and persist it with its date-stamped metadata and TTL.
334    fn write_fact(
335        &self,
336        fact_id: u64,
337        fact: &str,
338        metadata: Option<&Metadata>,
339        ttl_seconds: Option<u64>,
340    ) -> Result<(), MemoryError> {
341        let embedding = self.embedder.embed(fact)?;
342        let stamped = stamp_with_today(metadata);
343        // `ttl_seconds` is already known positive-or-absent: `validate_write`
344        // refuses an explicit `Some(0)` before any of this runs.
345        self.store_fact(fact_id, fact, &embedding, stamped.as_ref(), ttl_seconds)
346    }
347
348    /// Validate EVERY link property — relation label and target existence —
349    /// before any write, so all deterministic link failures happen while
350    /// nothing has been stored or overwritten yet.
351    fn validate_links(&self, links: &[Link]) -> Result<(), MemoryError> {
352        for link in links {
353            validate_relation(&link.relation)?;
354        }
355        self.ensure_link_targets_exist(links)
356    }
357
358    /// Whether an autograph extractor is configured at all.
359    #[must_use]
360    pub fn has_autograph(&self) -> bool {
361        self.autograph.is_some()
362    }
363
364    /// The total number of live tracked facts, internal entity hubs included
365    /// — the store's [`FactStore::count`], relayed for `memory_status`.
366    #[must_use]
367    pub fn fact_count(&self) -> usize {
368        let _generation = self.enter_generation();
369        self.store.count()
370    }
371
372    /// One page of the store's facts, for auditing — "what does my agent
373    /// know?" — which `recall` structurally cannot answer: it ranks by
374    /// resemblance to a query, and what resembles nothing you thought to
375    /// ask stays invisible.
376    ///
377    /// The store hands back raw pages ([`FactStore::list`]); the
378    /// visibility policy is applied here, once, for every backend: internal
379    /// entity hubs are skipped unless `include_internal` (they are the
380    /// graph's scaffolding, not the user's facts), reserved `_veles_*` keys
381    /// are stripped exactly as `recall` strips them (the auto-stamped date
382    /// survives — an audit legitimately asks WHEN), and `filter` keeps only
383    /// facts whose metadata equals every given key. A filtered page may
384    /// come back sparse — the cursor still advances over what was skipped,
385    /// so the WALK stays exhaustive.
386    ///
387    /// # Errors
388    /// Returns [`MemoryError`] if the backend cannot enumerate or the walk
389    /// fails.
390    pub fn list(
391        &self,
392        cursor: Option<u64>,
393        limit: usize,
394        filter: Option<&Metadata>,
395        include_internal: bool,
396    ) -> Result<(Vec<crate::model::ListedMemory>, Option<u64>), MemoryError> {
397        let _generation = self.enter_generation();
398        let limit = crate::limits::clamp_recall_limit(limit.max(1));
399        let (page, next) = self.store.list(cursor, limit)?;
400        let memories = page
401            .into_iter()
402            .filter_map(|fact| audited(fact, filter, include_internal))
403            .collect();
404        Ok((memories, next))
405    }
406}
407
408impl<E: Embedder, S: FactStore> MemoryService<E, S> {
409    /// Cheap "is this fact still stored?" probe gating [`Self::autograph`]'s
410    /// wiring: the same `store.get` existence check [`Self::forget`] and
411    /// [`Self::ensure_exists`] use. A store read error answers `false` —
412    /// autograph must never fabricate structure for a fact it cannot prove is
413    /// still there, and a missing (or unprovable) fact is a clean skip, never
414    /// an error on this deliberately-infallible path.
415    fn fact_exists(&self, fact_id: u64) -> bool {
416        matches!(self.store.get(fact_id), Ok(Some(_)))
417    }
418
419    /// Extract and orient one passage without writing any memory state.
420    ///
421    /// Kept separate from [`Self::store_extraction`] so the MCP durable-job
422    /// worker can persist the model output before the first graph write. A
423    /// restart after that boundary replays stable data instead of generating a
424    /// second, potentially different extraction.
425    pub(crate) fn extract_passage<X: Extractor>(
426        text: &str,
427        extractor: &X,
428    ) -> Result<crate::extract::Extraction, MemoryError> {
429        let text = text.trim();
430        if text.is_empty() {
431            return Err(MemoryError::EmptyFact);
432        }
433        let mut extraction = extractor.extract_graph(text)?;
434        crate::extract::orient_kinship(text, &mut extraction.relations);
435        Ok(extraction)
436    }
437
438    /// Shared resolver for both edge directions: skip the bipartite
439    /// scaffolding labels, resolve each edge's far end (`far_end` picks which
440    /// endpoint that is) to its stored content — at most
441    /// [`crate::limits::MAX_ENTITY_RELATIONS`] of them — and say whether the
442    /// result is a partial view (#1820).
443    ///
444    /// Truncated when either budget bit: the store's raw scan window
445    /// ([`crate::limits::MAX_ENTITY_SCAN_EDGES`]) left edges unread, or a
446    /// typed edge past the resolution cap was seen and dropped. Both cuts
447    /// are the same honest signal — "there is more than this view shows".
448    fn resolve_entity_relations(
449        &self,
450        scanned: crate::model::BoundedMemoryEdges,
451        far_end: impl Fn(&MemoryEdge) -> u64,
452    ) -> Result<(Vec<EntityRelation>, bool), MemoryError> {
453        let mut relations = Vec::new();
454        let mut truncated = scanned.truncated;
455        for edge in scanned.edges {
456            if edge.relation == MENTIONS_RELATION || edge.relation == ABOUT_RELATION {
457                continue;
458            }
459            if relations.len() >= crate::limits::MAX_ENTITY_RELATIONS {
460                truncated = true;
461                break;
462            }
463            let far = far_end(&edge);
464            let content = self.store.get(far)?.map(|(content, _)| content);
465            relations.push(EntityRelation {
466                predicate: edge.relation,
467                target_id: far,
468                target: content.unwrap_or_default(),
469            });
470        }
471        Ok((relations, truncated))
472    }
473
474    /// Merge each extracted attribute into its entity hub's `ColumnStore`
475    /// metadata, so `recall_where` can filter on it (`age >= 15`).
476    ///
477    /// The write goes through `update_metadata`, which **merges** rather than
478    /// replaces. That is the whole point: learning "Theo has a sister" after
479    /// "Theo is 15" must not erase the age. Re-storing the hub payload wholesale
480    /// would silently drop every attribute learned in an earlier session.
481    ///
482    /// Values keep the JSON type the extractor produced. `recall_where`
483    /// compares type-strictly with no coercion, so an age stored as `"15"`
484    /// would never match a numeric filter — no error, just a permanent silent
485    /// miss.
486    ///
487    /// Reserved keys are skipped: a model emitting `content` or a `_veles_`
488    /// key must never be able to overwrite the hub's own content or its
489    /// system flags.
490    fn wire_attributes(
491        &self,
492        attributes: &[ExtractedAttribute],
493        entity_ids: &mut HashMap<String, u64>,
494    ) -> Result<(), MemoryError> {
495        let mut per_entity: HashMap<String, Metadata> = HashMap::new();
496        for attribute in attributes {
497            if is_reserved_key(&attribute.key) {
498                continue;
499            }
500            per_entity
501                .entry(attribute.entity.clone())
502                .or_default()
503                .insert(attribute.key.clone(), attribute.value.clone());
504        }
505        for (entity, meta) in per_entity {
506            if meta.is_empty() {
507                continue;
508            }
509            reject_oversized_metadata(Some(&meta))?;
510            let hub_id = self.entity_hub(&entity, entity_ids)?;
511            self.store.update_metadata(hub_id, &meta)?;
512        }
513        Ok(())
514    }
515
516    /// Get or create the hub memory for a topic, caching its id per call. The
517    /// hub id is a deterministic function of the (normalized) topic, so the same
518    /// topic resolves to the same hub across calls — never a duplicate.
519    fn entity_hub(
520        &self,
521        entity: &str,
522        entity_ids: &mut HashMap<String, u64>,
523    ) -> Result<u64, MemoryError> {
524        let key = entity.trim().to_lowercase();
525        if let Some(&id) = entity_ids.get(&key) {
526            return Ok(id);
527        }
528        let id = self.remember_hub(&key)?;
529        entity_ids.insert(key, id);
530        Ok(id)
531    }
532
533    /// Idempotently store the hub memory for topic `key`. The id is salted so the
534    /// hub id space is disjoint from natural fact ids (no caller fact can collide
535    /// with or overwrite a hub), while the stored content stays human-readable.
536    /// Marked with the reserved [`HUB_FIELD`] so recall and `why` seeds exclude
537    /// it; goes straight to [`Self::store_fact`] to bypass the caller-facing
538    /// reserved-key rejection in [`Self::remember`].
539    fn remember_hub(&self, key: &str) -> Result<u64, MemoryError> {
540        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
541        // An existing hub is left exactly as it is. Re-storing it would rewrite
542        // the payload to the bare hub marker and destroy every attribute merged
543        // onto it by an earlier call — learning "Theo has a sister" would erase
544        // "Theo is 15", because a later sentence re-resolves the same hub. The
545        // content is a pure function of `key`, so there is nothing to refresh;
546        // skipping also avoids re-embedding a hub on every single mention.
547        if self.store.get(id)?.is_some() {
548            return Ok(id);
549        }
550        let content = format!("Entity: {key}");
551        let embedding = self.embedder.embed(&content)?;
552        let mut meta = Map::new();
553        meta.insert(HUB_FIELD.to_string(), Value::Bool(true));
554        // Topic hubs are graph anchors — they never expire.
555        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
556        Ok(id)
557    }
558
559    /// Fail with [`MemoryError::UnknownMemory`] unless memory `id` exists.
560    fn ensure_exists(&self, id: u64) -> Result<(), MemoryError> {
561        if self.store.get(id)?.is_none() {
562            return Err(MemoryError::UnknownMemory(id));
563        }
564        Ok(())
565    }
566
567    /// Fail unless every link target already exists (keeps `remember` atomic).
568    fn ensure_link_targets_exist(&self, links: &[Link]) -> Result<(), MemoryError> {
569        for link in links {
570            self.ensure_exists(link.target)?;
571        }
572        Ok(())
573    }
574
575    /// Store a fact with any combination of metadata and a durable TTL.
576    fn store_fact(
577        &self,
578        id: u64,
579        fact: &str,
580        embedding: &[f32],
581        metadata: Option<&Metadata>,
582        ttl_seconds: Option<u64>,
583    ) -> Result<(), MemoryError> {
584        match (metadata, ttl_seconds) {
585            (Some(meta), Some(ttl)) => {
586                // ONE write, not two. The previous `store_with_ttl` then
587                // `update_metadata` pair left the fact live and expiring
588                // between the calls: a short TTL could lapse in the gap and
589                // the metadata write then failed with `NotFound(... is
590                // expired ...)` — the caller got an error on a fact that was
591                // valid when they asked for it. Observed with a 1 s TTL on a
592                // loaded machine. Every TTL'd write takes this arm, since the
593                // auto date stamp means `metadata` is always `Some`.
594                self.store
595                    .store_with_metadata_and_ttl(id, fact, embedding, meta, ttl)?;
596            }
597            (Some(meta), None) => self.store.store_with_metadata(id, fact, embedding, meta)?,
598            (None, Some(ttl)) => self.store.store_with_ttl(id, fact, embedding, ttl)?,
599            (None, None) => self.store.store(id, fact, embedding)?,
600        }
601        Ok(())
602    }
603
604    /// Recall up to `k` memories semantically similar to `query` (vector facet),
605    /// optionally narrowed to an exact-match metadata `filter` (`ColumnStore`
606    /// facet) — e.g. `{ "project": "veles", "status": "resolved" }`.
607    ///
608    /// A highly selective filter may return fewer than `k` hits even when more
609    /// matches exist — raise `k` for fuller coverage with a narrow filter.
610    ///
611    /// Entity hubs created by [`Self::remember_extracted`] are never returned:
612    /// they are internal graph scaffolding, not facts the caller stored.
613    ///
614    /// Each hit carries its caller metadata (`Recollection::metadata`, `None`
615    /// when the fact carries none) — store a date field (e.g. `occurred_at`)
616    /// and it round-trips here, so a caller can sort the result into a
617    /// chronological, date-stamped context without `recall_where`'s explicit
618    /// filters. One extra, single batched lookup covers every returned hit.
619    ///
620    /// # Errors
621    /// Returns [`MemoryError`] if the semantic query or the metadata lookup fails.
622    pub fn recall(
623        &self,
624        query: &str,
625        k: usize,
626        filter: Option<&Metadata>,
627    ) -> Result<Vec<Recollection>, MemoryError>
628    where
629        S: RecallStore,
630    {
631        let _generation = self.enter_generation();
632        self.recall_inner(query, k, filter)
633    }
634
635    fn recall_inner(
636        &self,
637        query: &str,
638        k: usize,
639        filter: Option<&Metadata>,
640    ) -> Result<Vec<Recollection>, MemoryError>
641    where
642        S: RecallStore,
643    {
644        let query = query.trim();
645        if query.is_empty() {
646            return Ok(Vec::new());
647        }
648        reject_reserved_keys(filter)?;
649        let embedding = self.embedder.embed(query)?;
650        let hits = self.search(&embedding, k, filter)?;
651        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
652        // One raw batched payload lookup (reserved keys included), reused for
653        // BOTH the RL re-rank and the caller-facing metadata below — a single
654        // round trip, not one per concern.
655        let payloads = self.store.get_metadata_batch(&ids)?;
656        // RL Memory: re-order the recalled set by learned confidence. Facts
657        // that never received `feedback` keep their similarity order exactly.
658        #[cfg(feature = "persistence")]
659        let (hits, payloads) = Self::rl_rerank(hits, payloads);
660        Ok(hits
661            .into_iter()
662            .zip(payloads)
663            .map(|((id, score, content), payload)| Recollection {
664                id,
665                score,
666                content,
667                metadata: strip_reserved_keys(payload),
668            })
669            .collect())
670    }
671
672    /// Vector search for up to `k` ids, optionally narrowed by a metadata
673    /// `filter`. Shared by [`Self::recall`] and [`Self::why`].
674    fn search(
675        &self,
676        embedding: &[f32],
677        k: usize,
678        filter: Option<&Metadata>,
679    ) -> Result<Vec<(u64, f32, String)>, MemoryError>
680    where
681        S: RecallStore,
682    {
683        match filter {
684            // An include filter already excludes hubs: a hub's payload
685            // carries only reserved keys (`content`, `_veles_hub`), and
686            // reserved keys are rejected from caller filters, so a non-empty
687            // filter can never match a hub. An EMPTY-but-present filter (`Some({})`, the
688            // natural `{}` idiom at the JS boundary) matches every payload —
689            // hubs included — so it must take the hub-excluding path below,
690            // exactly like an absent filter (same `Some({})` ≡ `None`
691            // convention as `recall_fused`'s graph-side `matches_filter`).
692            Some(meta) if !meta.is_empty() => self.store.query_filtered(embedding, k, meta, 0),
693            // Unfiltered recall must still drop entity hubs explicitly, or a hub
694            // like `Entity: rust` would rank for the topic and evict a real fact.
695            _ => self
696                .store
697                .query_excluding(embedding, k, &hub_exclude_filter()),
698        }
699    }
700
701    /// Fused recall: semantic `NEAR` search combined with structured
702    /// `ColumnStore` predicates over metadata columns — ranges and comparisons,
703    /// not just the equality of [`Self::recall`]. One query spanning the vector
704    /// and column facets (e.g. "most similar facts **with `timestamp` in this
705    /// window**"), which a vector-only or equality-only recall cannot express.
706    ///
707    /// Filter *values* are bound as query parameters (never interpolated), so
708    /// they cannot inject; filter *field names* are validated to be plain
709    /// identifiers. Results come back in similarity order.
710    ///
711    /// **Caller memories only.** The store also holds internal scaffolding —
712    /// the entity hubs of [`Self::remember_extracted`] and the context
713    /// compiler's four artefact classes (stored sources, compilation events,
714    /// working contexts, and the per-project working-context index). They sit
715    /// in the same collection as caller facts and are excluded from every
716    /// result here, whatever the predicate.
717    ///
718    /// That exclusion is applied by the backend against
719    /// [`crate::storage::INTERNAL_MARKER_FIELDS`]; it is NOT a consequence of
720    /// those facts being unfilterable. A caller cannot write a filter naming a
721    /// reserved key, but `field ne value` MATCHES a fact that has no such
722    /// field at all — and scaffolding has none of the caller's columns, so
723    /// before #1737 every `ne` predicate returned all of it.
724    ///
725    /// # Errors
726    /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a plain
727    /// identifier, [`MemoryError::Embed`] if the query cannot be embedded, or a
728    /// storage error if the query fails. An empty query or `k == 0` yields `[]`.
729    pub fn recall_where(
730        &self,
731        query: &str,
732        k: usize,
733        filters: &[ColumnFilter],
734    ) -> Result<Vec<Recollection>, MemoryError>
735    where
736        S: ColumnStore + RecallStore,
737    {
738        let _generation = self.enter_generation();
739        let query = query.trim();
740        if query.is_empty() || k == 0 {
741            return Ok(Vec::new());
742        }
743        // No column predicates = a plain recall: route through [`Self::recall`]
744        // so entity hubs stay excluded — `query_columnar` with an empty filter
745        // set is a bare vector search that would rank internal `Entity:` hub
746        // scaffolding as results (same `[]` ≡ unfiltered convention as
747        // `search`'s empty-map handling).
748        if filters.is_empty() {
749            return self.recall_inner(query, k, None);
750        }
751        let embedding = self.embedder.embed(query)?;
752        self.store.query_columnar(&embedding, k, filters)
753    }
754}
755
756/// The metadata filter that excludes entity hubs from unfiltered recall and
757/// `why` seeds — the negative counterpart [`MemoryService::search`] applies so
758/// internal `_veles_hub` scaffolding never surfaces as a result.
759fn hub_exclude_filter() -> Metadata {
760    let mut exclude = Map::new();
761    exclude.insert(HUB_FIELD.to_string(), Value::Bool(true));
762    exclude
763}
764
765/// Reject caller-supplied metadata/filters that name a reserved key.
766fn reject_reserved_keys(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
767    let Some(meta) = metadata else {
768        return Ok(());
769    };
770    for key in meta.keys() {
771        if is_reserved_key(key) {
772            return Err(MemoryError::ReservedKey(key.clone()));
773        }
774    }
775    Ok(())
776}
777
778/// Reject caller-supplied metadata over [`crate::limits::MAX_METADATA_BYTES`]
779/// — the `DoS` guard every `remember` path shares (see
780/// [`MemoryError::MetadataTooLarge`]).
781fn reject_oversized_metadata(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
782    let Some(meta) = metadata else {
783        return Ok(());
784    };
785    let bytes = crate::limits::metadata_bytes(meta);
786    if bytes > crate::limits::MAX_METADATA_BYTES {
787        return Err(MemoryError::MetadataTooLarge {
788            bytes,
789            max: crate::limits::MAX_METADATA_BYTES,
790        });
791    }
792    Ok(())
793}
794
795/// Normalise a TTL supplied as *configuration*: `Some(0)` (and `None`) mean
796/// "no TTL policy" — the fact is stored permanently. Any positive value is
797/// kept as-is.
798///
799/// Deliberately NOT applied to `remember`'s own `ttl_seconds` any more: an
800/// explicit per-call `0` is an intent about one fact ("expire it"), and
801/// silently turning that into "permanent" is the opposite (see
802/// [`reject_zero_ttl`]). A compile policy's `source_ttl_seconds`, on the
803/// other hand, is a knob about a whole server, where `0` reading as "no
804/// policy" is the ordinary, unsurprising meaning.
805///
806/// Gated on `context`: since `remember` stopped calling it, the compile
807/// policy in `context::memory_bridge` is its only caller, and a build
808/// without that feature saw dead code — which `-D warnings` turns into a
809/// failed build, not a warning.
810#[cfg(feature = "context")]
811pub(crate) fn positive_ttl(ttl_seconds: Option<u64>) -> Option<u64> {
812    ttl_seconds.filter(|&seconds| seconds > 0)
813}
814
815/// The canonical form of an entity name: trimmed and lowercased, exactly as
816/// an extracted entity is keyed.
817///
818/// Public because a lookup MISS has to echo it too: an adapter that answered
819/// `name: ""` when nothing matched left a caller running several lookups
820/// unable to pair a response with its question (issue #1654). Hit and miss go
821/// through this one function, so the two can never drift.
822/// The audit's per-fact visibility policy, in one place: `None` skips the
823/// fact (internal scaffolding under the default view, or a metadata filter
824/// miss), `Some` carries what the caller may see — reserved keys stripped
825/// exactly as recall strips them, or the raw payload under
826/// `include_internal`.
827fn audited(
828    fact: crate::storage::RawListedFact,
829    filter: Option<&Metadata>,
830    include_internal: bool,
831) -> Option<crate::model::ListedMemory> {
832    if !include_internal && crate::storage::is_internal_scaffolding(&fact.payload) {
833        return None;
834    }
835    let matches = filter.is_none_or(|wanted| {
836        wanted
837            .iter()
838            .all(|(key, value)| fact.payload.get(key) == Some(value))
839    });
840    if !matches {
841        return None;
842    }
843    let metadata = if include_internal {
844        (!fact.payload.is_empty()).then_some(fact.payload)
845    } else {
846        strip_reserved_keys(Some(fact.payload))
847    };
848    Some(crate::model::ListedMemory {
849        id: fact.id,
850        content: fact.content,
851        metadata,
852    })
853}
854
855#[must_use]
856pub fn canonical_entity_name(name: &str) -> String {
857    name.trim().to_lowercase()
858}
859
860/// Refuse a fact that cannot be stored as written: blank, or past the size an
861/// embedding model still accepts.
862///
863/// The size check runs BEFORE [`MemoryService::write_fact`] calls the
864/// embedder, so an over-long fact is reported with its own size and the cap
865/// instead of whatever the backend says — issue #1654 saw `ollama embeddings
866/// call failed`, which names neither.
867fn validate_fact(fact: &str) -> Result<(), MemoryError> {
868    if fact.is_empty() {
869        return Err(MemoryError::EmptyFact);
870    }
871    validate_embeddable(fact)
872}
873
874/// Refuse a text past the size an embedding model still accepts.
875///
876/// Extracted from [`validate_fact`] so every path that embeds CALLER content
877/// answers to the same cap: `remember` refuses (this function), while the
878/// context bridge truncates via [`embeddable_prefix`] — but neither may hand
879/// the backend an oversized text and relay its raw failure, which is how
880/// issue #1654's `ollama embeddings call failed` (naming neither size nor
881/// cap) reached users.
882pub(crate) fn validate_embeddable(text: &str) -> Result<(), MemoryError> {
883    if text.len() > crate::limits::MAX_EMBEDDABLE_TEXT_BYTES {
884        return Err(MemoryError::FactTooLarge {
885            bytes: text.len(),
886            max: crate::limits::MAX_EMBEDDABLE_TEXT_BYTES,
887        });
888    }
889    Ok(())
890}
891
892/// The longest prefix of `text` that fits the embeddable cap without
893/// splitting a UTF-8 character.
894///
895/// For content that must be STORED whole but whose vector only serves
896/// similarity search (context sources: retrieval is hash-addressed, the
897/// vector is a ranking aid), truncating the *embedded* text is the correct
898/// trade — refusing would fail a legitimate compile, and a placeholder
899/// vector would remove the source from semantic recall entirely.
900///
901/// Gated on `context`: the compiler's source writer is its only caller, so a
902/// build without that feature sees dead code, which CI's `-D warnings`
903/// turns into a failed build. Same shape as `positive_ttl` — and only the
904/// per-feature ISOLATION loop catches it, never a feature combination.
905#[cfg(feature = "context")]
906pub(crate) fn embeddable_prefix(text: &str) -> &str {
907    let cap = crate::limits::MAX_EMBEDDABLE_TEXT_BYTES;
908    if text.len() <= cap {
909        return text;
910    }
911    let mut end = cap;
912    while end > 0 && !text.is_char_boundary(end) {
913        end -= 1;
914    }
915    &text[..end]
916}
917
918/// Refuse an explicit per-call TTL of `0`.
919///
920/// `0` used to be normalised to "no expiry", so a caller who meant "expire
921/// immediately" silently got a **permanent** fact — the opposite intent, with
922/// no signal (issue #1654). A TTL supplied as *configuration*
923/// (`McpServer::with_default_ttl`, a compile policy's `source_ttl_seconds`)
924/// still reads `0` as "no TTL policy": that is a default about a whole
925/// server, not an intent about one fact, and it is deliberately untouched.
926fn reject_zero_ttl(ttl_seconds: Option<u64>) -> Result<(), MemoryError> {
927    if ttl_seconds == Some(0) {
928        return Err(MemoryError::ZeroTtl);
929    }
930    Ok(())
931}
932
933/// Refuse a `remember` link that points the fact at itself.
934///
935/// The same rule [`MemoryService::relate`] enforces, applied to the other way
936/// a self-loop can be created: re-remembering existing content yields its
937/// existing id, so a caller CAN name that id as a link target. Without this
938/// the `relate` guard would only close half the door.
939fn reject_self_links(fact_id: u64, links: &[Link]) -> Result<(), MemoryError> {
940    if links.iter().any(|link| link.target == fact_id) {
941        return Err(MemoryError::SelfRelation(fact_id));
942    }
943    Ok(())
944}
945
946/// [`MemoryService::remember_with_ttl`]'s auto-date stamp: `metadata` with
947/// today's date added under [`AUTO_DATE_FIELD`], unless `metadata` already
948/// names that key (an explicit, possibly retroactive, caller value is never
949/// overwritten) or no clock is available ([`clock::today_ymd`] returns `None`
950/// on `wasm32-unknown-unknown`). Returns an owned map either way, `None` only
951/// when there is nothing to store at all (no caller metadata AND no clock).
952fn stamp_with_today(metadata: Option<&Metadata>) -> Option<Metadata> {
953    if metadata.is_some_and(|meta| meta.contains_key(AUTO_DATE_FIELD)) {
954        return metadata.cloned();
955    }
956    let Some(today) = clock::today_ymd() else {
957        return metadata.cloned();
958    };
959    let mut stamped = metadata.cloned().unwrap_or_default();
960    stamped.insert(AUTO_DATE_FIELD.to_owned(), Value::from(today));
961    Some(stamped)
962}
963
964/// Maximum byte length for a relation label (prevents oversized graph edge labels
965/// from reaching the storage layer).
966const MAX_RELATION_BYTES: usize = 512;
967
968/// Validate a caller-supplied relation label: non-empty, within the size cap, and
969/// containing only printable, non-control ASCII characters (32–126) or non-ASCII
970/// Unicode. This prevents null bytes and control characters from reaching the
971/// storage layer while permitting natural-language labels like `"decided_in"` or
972/// `"is a friend of"`.
973fn validate_relation(label: &str) -> Result<(), MemoryError> {
974    if label.is_empty() {
975        return Err(MemoryError::InvalidRelation(
976            "relation label must not be empty".to_owned(),
977        ));
978    }
979    if label.len() > MAX_RELATION_BYTES {
980        return Err(MemoryError::InvalidRelation(format!(
981            "relation label exceeds maximum of {MAX_RELATION_BYTES} bytes ({} given)",
982            label.len()
983        )));
984    }
985    if label.chars().any(|c| c.is_ascii_control()) {
986        return Err(MemoryError::InvalidRelation(
987            "relation label must not contain ASCII control characters".to_owned(),
988        ));
989    }
990    Ok(())
991}