Skip to main content

velesdb_memory/
fused_recall.rs

1//! [`MemoryService::recall_fused`]: vector recall combined with the graph
2//! reach `why()` already walks, re-ranked by [`crate::fusion::fuse`]. Split
3//! out of `service.rs` to keep that file under the crate's NLOC budget; a
4//! child module of `service`, so it freely uses `MemoryService`'s private
5//! fields and methods (`traverse`, `search`, `HUB_FIELD`, …).
6
7use std::collections::HashMap;
8
9use serde_json::Value;
10
11use super::{
12    reject_reserved_keys, strip_reserved_keys, MemoryService, Metadata, HUB_FIELD,
13    MENTIONS_RELATION,
14};
15use crate::embedder::Embedder;
16use crate::error::MemoryError;
17use crate::fusion::{self, Candidate};
18use crate::model::{FusionOptions, MemoryEdge, MemoryNode, Recollection};
19use crate::rerank::Reranker;
20use crate::storage::{FactStore, GraphStore, RecallStore};
21
22impl<E: Embedder, S: FactStore> MemoryService<E, S> {
23    /// Fused recall: like [`Self::recall`], but also walks the graph from the
24    /// query's top vector hit and folds any fact it reaches (hop ≥ 1) into the
25    /// ranking, scored by `opts.graph_boost · graph_weight` on top of its
26    /// normalised vector similarity. A fact the graph reaches never displaces
27    /// a strong vector hit unless the boosted score genuinely outranks it; a
28    /// fact the vector pool ranked low (or missed) can still surface if the
29    /// graph connects it. This is the tri-engine ranking measured on
30    /// HotpotQA/TimeQA/LoCoMo (`examples/multihop`, `examples/timeqa`,
31    /// `examples/locomo`) — [`Self::recall`] stays pure-vector and unchanged,
32    /// so existing callers see no behavior shift.
33    ///
34    /// The graph reach requires a wired graph to find anything: it walks
35    /// edges from [`Self::relate`] or the entity hubs
36    /// [`Self::remember_extracted`] auto-wires. Entity hubs themselves are
37    /// never returned, exactly like [`Self::recall`].
38    ///
39    /// # Errors
40    /// Returns [`MemoryError`] if embedding, vector search, or graph
41    /// traversal fails.
42    pub fn recall_fused(
43        &self,
44        query: &str,
45        k: usize,
46        filter: Option<&Metadata>,
47        opts: FusionOptions,
48    ) -> Result<Vec<Recollection>, MemoryError>
49    where
50        S: GraphStore + RecallStore,
51    {
52        let _generation = self.enter_generation();
53        self.recall_fused_inner(query, k, filter, opts)
54    }
55
56    fn recall_fused_inner(
57        &self,
58        query: &str,
59        k: usize,
60        filter: Option<&Metadata>,
61        opts: FusionOptions,
62    ) -> Result<Vec<Recollection>, MemoryError>
63    where
64        S: GraphStore + RecallStore,
65    {
66        let query = query.trim();
67        if query.is_empty() || k == 0 {
68            return Ok(Vec::new());
69        }
70        let opts = opts.sanitized();
71        reject_reserved_keys(filter)?;
72        let embedding = self.embedder.embed(query)?;
73        let pool = self.fused_pool(&embedding, pool_depth(k, opts), filter)?;
74        let reached = self.graph_reached(&embedding, filter, opts.hops)?;
75        Ok(fusion::fuse(pool, &reached, k, opts.graph_boost))
76    }
77
78    /// [`Self::recall_fused`] with the per-candidate score ventilation kept
79    /// (normalised vector term, graph weight, fused score) — consumed by the
80    /// context compiler's memory bridge, whose provenance records an
81    /// explainable `relevance ∈ [0, 1]` per pulled memory. Same pipeline,
82    /// same ordering, same numbers as [`Self::recall_fused`]; only the
83    /// breakdown is kept instead of dropped.
84    ///
85    /// # Errors
86    /// Returns [`MemoryError`] if embedding, vector search, or graph
87    /// traversal fails.
88    #[cfg(feature = "context")]
89    pub(crate) fn recall_fused_scored(
90        &self,
91        query: &str,
92        k: usize,
93        filter: Option<&Metadata>,
94        opts: FusionOptions,
95    ) -> Result<Vec<fusion::ScoredCandidate>, MemoryError>
96    where
97        S: GraphStore + RecallStore,
98    {
99        let query = query.trim();
100        if query.is_empty() || k == 0 {
101            return Ok(Vec::new());
102        }
103        let opts = opts.sanitized();
104        reject_reserved_keys(filter)?;
105        let embedding = self.embedder.embed(query)?;
106        let pool = self.fused_pool(&embedding, pool_depth(k, opts), filter)?;
107        let reached = self.graph_reached(&embedding, filter, opts.hops)?;
108        Ok(fusion::fuse_scored(pool, &reached, k, opts.graph_boost))
109    }
110
111    /// [`Self::recall_fused`] paired with the dated-context rendering of its
112    /// results: returns the recalled facts and the
113    /// [`DatedContext`](crate::DatedContext) built from their `date_field`
114    /// metadata (see [`format_dated_context`](crate::format_dated_context)).
115    /// Every binding that exposes a "dated recall" (the MCP `recall_fused`
116    /// tool's `date_field`, Node/WASM `recallFusedDated`) calls this, so the
117    /// "recall then format" pairing lives in exactly one place and can't drift
118    /// between surfaces.
119    ///
120    /// `date_field` can name any caller metadata key, but passing
121    /// [`crate::storage::AUTO_DATE_FIELD`] needs zero setup: `remember`
122    /// auto-stamps that key on every fact already, so a caller gets a correct
123    /// `dated_context` without ever having managed a date field itself.
124    ///
125    /// # Errors
126    /// Returns [`MemoryError`] if the underlying [`Self::recall_fused`] fails.
127    pub fn recall_fused_dated(
128        &self,
129        query: &str,
130        k: usize,
131        filter: Option<&Metadata>,
132        opts: FusionOptions,
133        date_field: &str,
134    ) -> Result<(Vec<Recollection>, crate::DatedContext), MemoryError>
135    where
136        S: GraphStore + RecallStore,
137    {
138        let _generation = self.enter_generation();
139        let hits = self.recall_fused_inner(query, k, filter, opts)?;
140        let ctx = crate::format_dated_context(&hits, date_field);
141        Ok((hits, ctx))
142    }
143
144    /// Like [`Self::recall_fused`], but hands the FULL fused-ranked candidate
145    /// pool (before the final `k` cutoff) to `reranker` for a second-stage
146    /// re-score, then truncates to `k`. Closes the ranking-miss gap the
147    /// `LoCoMo` ceiling diagnostic found: a relevant fact can be IN the pool
148    /// (recall@64 ≈ 89% on multi-hop) yet outranked out of a tight `k`
149    /// (recall@8 ≈ 50%) — a reranker recovers it without widening `k` itself.
150    ///
151    /// No built-in reranker ships: bring your own (cross-encoder, LLM judge,
152    /// …) via [`Reranker`]. Never call this as a default — a reranker can
153    /// also *hurt* out-of-distribution conversational queries (measured on
154    /// `LoCoMo`), so it is opt-in, one call at a time.
155    ///
156    /// # Errors
157    /// Returns [`MemoryError`] if embedding, vector search, graph traversal,
158    /// or `reranker` itself fails.
159    pub fn recall_fused_reranked<R: Reranker>(
160        &self,
161        query: &str,
162        k: usize,
163        filter: Option<&Metadata>,
164        opts: FusionOptions,
165        reranker: &R,
166    ) -> Result<Vec<Recollection>, MemoryError>
167    where
168        S: GraphStore + RecallStore,
169    {
170        let _generation = self.enter_generation();
171        self.recall_fused_reranked_inner(query, k, filter, opts, reranker)
172    }
173
174    pub(super) fn recall_fused_reranked_inner<R: Reranker>(
175        &self,
176        query: &str,
177        k: usize,
178        filter: Option<&Metadata>,
179        opts: FusionOptions,
180        reranker: &R,
181    ) -> Result<Vec<Recollection>, MemoryError>
182    where
183        S: GraphStore + RecallStore,
184    {
185        let query = query.trim();
186        if query.is_empty() || k == 0 {
187            return Ok(Vec::new());
188        }
189        let opts = opts.sanitized();
190        reject_reserved_keys(filter)?;
191        let embedding = self.embedder.embed(query)?;
192        let depth = pool_depth(k, opts);
193        let pool = self.fused_pool(&embedding, depth, filter)?;
194        let reached = self.graph_reached(&embedding, filter, opts.hops)?;
195        let fused = fusion::fuse(pool, &reached, depth, opts.graph_boost);
196        let ranked = reranker.rerank(query, fused)?;
197        Ok(ranked.into_iter().take(k).collect())
198    }
199
200    /// The oversampled vector pool [`Self::recall_fused`] re-ranks. One
201    /// batched metadata lookup covers the whole pool (up to hundreds of ids
202    /// at the deepest `pool_depth`), not one round trip per hit.
203    fn fused_pool(
204        &self,
205        embedding: &[f32],
206        depth: usize,
207        filter: Option<&Metadata>,
208    ) -> Result<Vec<Candidate>, MemoryError>
209    where
210        S: RecallStore,
211    {
212        let hits = self.search(embedding, depth, filter)?;
213        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
214        let metadata = self.recall_metadata_batch(&ids)?;
215        Ok(hits
216            .into_iter()
217            .zip(metadata)
218            .map(|((id, score, content), metadata)| Candidate {
219                recollection: Recollection {
220                    id,
221                    score,
222                    content,
223                    metadata,
224                },
225                vector_score: f64::from(score),
226                graph_weight: 0.0,
227            })
228            .collect())
229    }
230
231    /// The caller-supplied metadata for every id in `ids` (reserved system
232    /// keys excluded, `None` per-id when it carries none), in the same
233    /// order — one batched storage round trip, so a `k`- or pool-sized
234    /// result set (here, and in [`MemoryService::recall`]) costs one
235    /// metadata lookup, not `k`/`pool_size` of them.
236    pub(crate) fn recall_metadata_batch(
237        &self,
238        ids: &[u64],
239    ) -> Result<Vec<Option<Metadata>>, MemoryError> {
240        Ok(self
241            .store
242            .get_metadata_batch(ids)?
243            .into_iter()
244            .map(strip_reserved_keys)
245            .collect())
246    }
247
248    /// Facts the graph reaches (hop ≥ 1) from the query's top vector seed,
249    /// entity hubs excluded, each weighted by [`Self::reach_weight`]: a link
250    /// through a rare, specific entity hub promotes harder than one through a
251    /// generic mega-hub whose connections carry little signal — the idf lever
252    /// validated on `HotpotQA` (+5.0pp both-facts-complete) and `LoCoMo` (turns
253    /// the graph net-positive on multi-hop, no regression elsewhere).
254    ///
255    /// `filter` is re-checked against every reached fact's own metadata, not
256    /// just the seed: the graph walk is otherwise filter-blind, so a fact
257    /// outside the caller's scope (e.g. a different tenant/project) could
258    /// leak in just by being graph-connected to the seed.
259    fn graph_reached(
260        &self,
261        embedding: &[f32],
262        filter: Option<&Metadata>,
263        hops: usize,
264    ) -> Result<Vec<Candidate>, MemoryError>
265    where
266        S: GraphStore + RecallStore,
267    {
268        let seeds = self.search(embedding, 1, filter)?;
269        let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
270            return Ok(Vec::new());
271        };
272        let explanation = self.traverse(seed_id, seed_content, hops)?;
273        let nodes: Vec<&MemoryNode> = explanation.nodes.iter().filter(|n| n.hop != 0).collect();
274        let ids: Vec<u64> = nodes.iter().map(|n| n.id).collect();
275        let raw_payloads = self.store.get_metadata_batch(&ids)?;
276        let mentions_by_target = index_mentions_edges(&explanation.edges);
277
278        let mut idf_cache: HashMap<u64, f64> = HashMap::new();
279        let mut reached = Vec::new();
280        for (node, raw) in nodes.into_iter().zip(raw_payloads) {
281            if let Some(candidate) =
282                self.reached_candidate(node, raw, &mentions_by_target, filter, &mut idf_cache)?
283            {
284                reached.push(candidate);
285            }
286        }
287        Ok(reached)
288    }
289
290    /// The graph-reached candidate for `node` given its already-fetched raw
291    /// payload `raw`, or `None` when it's an entity hub (internal
292    /// scaffolding, never a caller fact) or outside `filter`'s scope — split
293    /// out of [`Self::graph_reached`] to keep that loop's complexity within
294    /// budget. `raw` is fetched once, batched across the whole traversal, by
295    /// the caller — not per node — and serves both the hub check and the
296    /// returned candidate's metadata: the hub flag lives under the reserved
297    /// `_veles_hub` key, so it must be checked before `strip_reserved_keys`
298    /// removes it for the caller-facing metadata. `idf_cache` memoizes
299    /// [`Self::entity_idf`] per hub across the whole traversal (siblings
300    /// under the same hub would otherwise recompute an identical value once
301    /// per fact).
302    fn reached_candidate(
303        &self,
304        node: &MemoryNode,
305        raw: Option<Metadata>,
306        mentions_by_target: &HashMap<u64, Vec<u64>>,
307        filter: Option<&Metadata>,
308        idf_cache: &mut HashMap<u64, f64>,
309    ) -> Result<Option<Candidate>, MemoryError>
310    where
311        S: GraphStore,
312    {
313        if raw
314            .as_ref()
315            .is_some_and(|meta| meta.get(HUB_FIELD) == Some(&Value::Bool(true)))
316        {
317            return Ok(None);
318        }
319        let metadata = strip_reserved_keys(raw);
320        if !matches_filter(metadata.as_ref(), filter) {
321            return Ok(None);
322        }
323        let weight = self.reach_weight(node.id, mentions_by_target, idf_cache)?;
324        Ok(Some(Candidate {
325            recollection: Recollection {
326                id: node.id,
327                score: 0.0,
328                content: node.content.clone(),
329                metadata,
330            },
331            vector_score: 0.0,
332            graph_weight: weight,
333        }))
334    }
335
336    /// The strength of the link(s) that reached `fact_id`: the maximum
337    /// entity-idf ([`Self::entity_idf`]) over every hub `mentions_by_target`
338    /// lists for it, or a flat `1.0` when it was reached through a direct
339    /// (non-hub) [`Self::relate`] edge instead — idf has nothing to weight
340    /// there, so the original flat signal is kept.
341    fn reach_weight(
342        &self,
343        fact_id: u64,
344        mentions_by_target: &HashMap<u64, Vec<u64>>,
345        idf_cache: &mut HashMap<u64, f64>,
346    ) -> Result<f64, MemoryError>
347    where
348        S: GraphStore,
349    {
350        let Some(hub_ids) = mentions_by_target.get(&fact_id) else {
351            return Ok(1.0);
352        };
353        let mut weight: Option<f64> = None;
354        for &hub_id in hub_ids {
355            let idf = self.cached_entity_idf(hub_id, idf_cache)?;
356            weight = Some(weight.map_or(idf, |w: f64| w.max(idf)));
357        }
358        Ok(weight.unwrap_or(1.0))
359    }
360
361    /// [`Self::entity_idf`], memoized in `cache` for the lifetime of one
362    /// [`Self::graph_reached`] call — sibling facts under the same hub would
363    /// otherwise each pay a fresh `relations`+`count` store round trip for an
364    /// identical value.
365    fn cached_entity_idf(
366        &self,
367        hub_id: u64,
368        cache: &mut HashMap<u64, f64>,
369    ) -> Result<f64, MemoryError>
370    where
371        S: GraphStore,
372    {
373        if let Some(&idf) = cache.get(&hub_id) {
374            return Ok(idf);
375        }
376        let idf = self.entity_idf(hub_id)?;
377        cache.insert(hub_id, idf);
378        Ok(idf)
379    }
380
381    /// Normalised inverse document frequency of hub `hub_id`, in `[0, 1]`:
382    /// `1` when it links a single fact (maximally specific), trending to `0`
383    /// as it links ever more (a generic mega-hub whose links carry little
384    /// answer signal). Mirrors the `LoCoMo` harness formula
385    /// (`examples/locomo/ingest.rs`), using the store's total memory count
386    /// (facts + hubs) as a corpus-size proxy.
387    fn entity_idf(&self, hub_id: u64) -> Result<f64, MemoryError>
388    where
389        S: GraphStore,
390    {
391        let degree = self.store.relations(hub_id)?.len();
392        let n = self.store.count();
393        if degree == 0 || n <= 1 {
394            return Ok(0.0);
395        }
396        #[allow(clippy::cast_precision_loss)] // corpus/degree sizes are far below f64's exact range
397        let (n, d) = (n as f64, degree as f64);
398        Ok((n / d).ln() / n.ln())
399    }
400}
401
402/// Index of `mentions` edges by target, built once per [`MemoryService::graph_reached`]
403/// call: `fact_id -> [hub_id, ...]`. [`MemoryService::reach_weight`] used to
404/// rescan every edge in the traversal for each reached node, making
405/// `graph_reached` quadratic in the number of facts a hub mentions (a single
406/// entity accumulating history is the product's nominal use case, not an edge
407/// case). This index turns that per-node scan into an O(1) lookup, so the
408/// whole pass over `edges` costs O(edges) once instead of O(edges) per node.
409fn index_mentions_edges(edges: &[MemoryEdge]) -> HashMap<u64, Vec<u64>> {
410    let mut index: HashMap<u64, Vec<u64>> = HashMap::new();
411    for edge in edges {
412        if edge.relation == MENTIONS_RELATION {
413            index.entry(edge.to).or_default().push(edge.from);
414        }
415    }
416    index
417}
418
419/// True when `filter` is absent, or every key in it matches `metadata`
420/// exactly — the same "all filter keys must match" semantics
421/// [`MemoryService::search`]'s vector-side filtering applies, now also
422/// enforced on graph-reached facts so a caller-scoped `recall_fused` can't
423/// leak a fact outside that scope just because it's graph-connected to the
424/// seed.
425fn matches_filter(metadata: Option<&Metadata>, filter: Option<&Metadata>) -> bool {
426    let Some(filter) = filter else {
427        return true;
428    };
429    // Mirrors velesdb-core's `payload_matches`: an empty (not absent) filter
430    // matches everything, including a metadata-less fact — `Some({})` from a
431    // caller (e.g. a JS `recallFused(q, k, {})`) must behave exactly like
432    // `None`, not like "reject anything without metadata".
433    if filter.is_empty() {
434        return true;
435    }
436    let Some(metadata) = metadata else {
437        return false;
438    };
439    filter.iter().all(|(k, v)| metadata.get(k) == Some(v))
440}
441
442/// The oversampled candidate pool depth for a `k`-sized fused recall:
443/// `opts.pool` if the caller set one (floored at 1), else the proven default
444/// ([`fusion::pool_size`]) — either way, capped at
445/// [`crate::limits::MAX_RECALL_LIMIT`], the same `DoS` ceiling `k`/`hops`
446/// carry. Both bounds live here, not at each binding's FFI boundary:
447/// - the floor of 1 stops an explicit `pool` of 0 (a binding now exposes the
448///   knob: `options={"pool": 0}` in Python) from oversampling *zero* candidates
449///   and returning nothing. A caller can still deliberately narrow the pool
450///   below the default (e.g. `pool: 1` to admit only the top vector hit — the
451///   documented behavior fusion's tests pin); the floor only rules out the
452///   degenerate empty-set case, it does not force a minimum recall depth.
453/// - the cap bounds the default too: `k.saturating_mul(8)` exceeds the limit
454///   well before `k` itself does, so even a caller who never touches `pool` is
455///   bounded.
456fn pool_depth(k: usize, opts: FusionOptions) -> usize {
457    let depth = opts.pool.map_or_else(|| fusion::pool_size(k), |p| p.max(1));
458    crate::limits::clamp_recall_limit(depth)
459}