Skip to main content

velesdb_memory/
service_graph.rs

1//! Part of the graph facet of [`MemoryService`]: `relate`/`unrelate`/
2//! `forget`, the *destruction* half of the entity-hub lifecycle, and the
3//! `why`/`traverse`/`expand` walks — split out to keep `service.rs` inside
4//! the crate's file budget, same pattern as `fused_recall.rs`. A child
5//! module of `service`, so it shares full access to `MemoryService`'s
6//! private fields and methods. Every method here needs at least
7//! `S: GraphStore` (#1959) — but the converse does not hold yet: the
8//! *wiring* half of the graph surface (`wire_entities`, `entity_profile`,
9//! `add_edge`, the `remember*`/`autograph*` family) still lives in
10//! `service.rs` with the same bound. Finishing that cut is the natural next
11//! slice when `service.rs` needs to shrink again.
12
13use super::{
14    reject_reserved_keys, validate_relation, Embedder, Explanation, FactStore, GraphStore, HashSet,
15    MemoryError, MemoryNode, MemoryService, Metadata, RecallStore, UnrelateOutcome, HUB_FIELD,
16    MENTIONS_RELATION,
17};
18
19impl<E: Embedder, S: FactStore> MemoryService<E, S> {
20    /// Create a typed edge `from -> to`. Returns the edge id.
21    ///
22    /// Both endpoints are validated to exist first, so the tool reports an
23    /// unknown id as client input (`UnknownMemory`) rather than a generic
24    /// storage fault — and the graph never gains an edge dangling off a memory
25    /// that was never stored.
26    ///
27    /// A self-loop (`from == to`) is refused: it states nothing, and `why`
28    /// traverses it like any other edge, so it only adds noise to the
29    /// evidence trail. The same rule covers [`Self::remember`]'s `links`.
30    ///
31    /// # Errors
32    /// Returns [`MemoryError::InvalidRelation`] for a bad label,
33    /// [`MemoryError::SelfRelation`] if both endpoints are the same memory,
34    /// [`MemoryError::UnknownMemory`] if either endpoint is missing, or
35    /// a storage error if the edge cannot be created.
36    pub fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError>
37    where
38        S: GraphStore,
39    {
40        let _generation = self.enter_generation();
41        self.relate_inner(from, to, relation)
42    }
43
44    pub(super) fn relate_inner(
45        &self,
46        from: u64,
47        to: u64,
48        relation: &str,
49    ) -> Result<u64, MemoryError>
50    where
51        S: GraphStore,
52    {
53        validate_relation(relation)?;
54        if from == to {
55            return Err(MemoryError::SelfRelation(from));
56        }
57        self.ensure_exists(from)?;
58        self.ensure_exists(to)?;
59        self.store.relate(from, to, relation)
60    }
61
62    /// Remove the edge(s) `from -relation-> to`: [`Self::relate`]'s exact
63    /// undo (issue #1661), so a mistaken edge no longer costs the facts at
64    /// its endpoints. Neither the facts nor any entity hub are touched —
65    /// collecting an orphaned hub stays [`Self::forget`]'s job.
66    ///
67    /// Idempotent: an absent edge is `found: false`, not an error, so a
68    /// cleanup is replayable. It refuses exactly what `relate` refuses
69    /// (empty label, self-loop), and deliberately does NOT require the
70    /// endpoints to exist — the edge of a forgotten fact is already gone,
71    /// and reporting that as an error would break replay.
72    ///
73    /// Scope: the store does not distinguish an explicit edge from one the
74    /// autograph derived from a passage, so `unrelate` removes both alike.
75    /// To correct an autograph edge, prefer `forget` + `remember` of the
76    /// source fact — otherwise a later `remember` of the same passage can
77    /// rebuild the edge removed here.
78    ///
79    /// # Errors
80    /// Returns [`MemoryError::InvalidRelation`] for a bad label,
81    /// [`MemoryError::SelfRelation`] if both endpoints are the same memory,
82    /// or a storage error if lookup or removal fails.
83    pub fn unrelate(
84        &self,
85        from: u64,
86        to: u64,
87        relation: &str,
88    ) -> Result<UnrelateOutcome, MemoryError>
89    where
90        S: GraphStore,
91    {
92        let _generation = self.enter_generation();
93        validate_relation(relation)?;
94        if from == to {
95            return Err(MemoryError::SelfRelation(from));
96        }
97        let removed = self.remove_matching_edges(from, to, relation)?;
98        Ok(UnrelateOutcome {
99            found: removed > 0,
100            removed,
101        })
102    }
103
104    /// [`Self::unrelate`]'s removal pass: resolve `from`'s outgoing edges and
105    /// delete every one matching `(to, relation)` by its id, counting them.
106    fn remove_matching_edges(
107        &self,
108        from: u64,
109        to: u64,
110        relation: &str,
111    ) -> Result<usize, MemoryError>
112    where
113        S: GraphStore,
114    {
115        let mut removed = 0usize;
116        for edge in self.store.relations(from)? {
117            if edge.to == to
118                && edge.relation == relation
119                && self.store.unrelate_from(from, edge.id)?
120            {
121                removed += 1;
122            }
123        }
124        Ok(removed)
125    }
126
127    /// Forget (delete) the memory with `fact_id`. Returns whether a memory
128    /// actually existed under that id — the underlying store's `delete` is a
129    /// silent no-op on an unknown id (matching most backends' idempotent
130    /// delete semantics), which is indistinguishable from a real deletion
131    /// unless existence is checked first. Every surface that exposes
132    /// `forget` (MCP, Node, WASM, Python) forwards this so a caller can tell
133    /// "I removed something" from "that id was a typo".
134    ///
135    /// The delete always runs, even when `get` reports the id absent: `get`
136    /// filters TTL-expired facts, and an expired-but-unpurged row must still
137    /// be reclaimed (the caller is told `false` — the memory was already
138    /// gone from its perspective). Existence check and delete are two store
139    /// calls, not one atomic operation: two concurrent forgets of one id may
140    /// both report `true`.
141    ///
142    /// # Errors
143    /// Returns [`MemoryError`] if the existence check or the deletion fails.
144    pub fn forget(&self, fact_id: u64) -> Result<bool, MemoryError>
145    where
146        S: GraphStore,
147    {
148        let _generation = self.enter_generation();
149        let found = self.store.get(fact_id)?.is_some();
150        // Read the fact's hubs BEFORE the delete: afterwards its edges are gone
151        // and there is no way back to the entities it created.
152        let hubs = self.hubs_linked_from(fact_id)?;
153        self.store.delete(fact_id)?;
154        self.collect_orphan_hubs(&hubs)?;
155        Ok(found)
156    }
157
158    /// The entity hubs `fact_id` points at.
159    ///
160    /// Hubs are recognised by the reserved [`HUB_FIELD`] marker rather than by
161    /// the edge label, so a caller's own `relate` to a hub is seen too.
162    fn hubs_linked_from(&self, fact_id: u64) -> Result<Vec<u64>, MemoryError>
163    where
164        S: GraphStore,
165    {
166        let mut hubs = Vec::new();
167        for edge in self.store.relations(fact_id)? {
168            if self.is_hub(edge.to)? {
169                hubs.push(edge.to);
170            }
171        }
172        Ok(hubs)
173    }
174
175    /// Delete every hub in `hubs` that no surviving fact mentions any more.
176    ///
177    /// An entity outlives the fact that introduced it as long as another fact
178    /// still refers to it — forgetting "Theo is 15" must not erase Theo while
179    /// "Theo has a sister" is still stored. Only a hub whose every `mentions`
180    /// target is gone is itself removed, so entities do not accumulate as
181    /// unreachable scaffolding once the facts behind them are retracted.
182    fn collect_orphan_hubs(&self, hubs: &[u64]) -> Result<(), MemoryError>
183    where
184        S: GraphStore,
185    {
186        for &hub in hubs {
187            if !self.hub_still_mentioned(hub)? {
188                self.store.delete(hub)?;
189            }
190        }
191        Ok(())
192    }
193
194    /// Whether anything alive still needs `hub`.
195    ///
196    /// Two references count, and the second is why this reads BOTH
197    /// directions (issue #1662):
198    ///
199    /// - an outgoing `mentions` edge to a live fact — the pair
200    ///   [`Self::wire_entities`] writes, the ordinary case;
201    /// - an incoming edge from a live NON-HUB fact — what a caller's own
202    ///   `relate` writes, and it writes one direction only. Relating a fact
203    ///   to a hub is reachable (`entity()` hands out the hub id), so reading
204    ///   outgoing edges alone swept hubs from under live callers' edges,
205    ///   losing them in silence.
206    ///
207    /// Incoming edges from another HUB are deliberately ignored: hub↔hub
208    /// edges exist (`wire_relations` writes them), and counting them would
209    /// let two hubs keep each other alive forever — a leak whose outcome
210    /// depends on collection order, which is worse than the bug being fixed.
211    fn hub_still_mentioned(&self, hub: u64) -> Result<bool, MemoryError>
212    where
213        S: GraphStore,
214    {
215        for edge in self.store.relations(hub)? {
216            if edge.relation == MENTIONS_RELATION && self.store.get(edge.to)?.is_some() {
217                return Ok(true);
218            }
219        }
220        self.hub_has_live_referent(hub)
221    }
222
223    /// Whether a live non-hub fact points AT `hub` — see
224    /// [`Self::hub_still_mentioned`] for why hub→hub edges do not count.
225    fn hub_has_live_referent(&self, hub: u64) -> Result<bool, MemoryError>
226    where
227        S: GraphStore,
228    {
229        for edge in self.store.incoming_relations(hub)? {
230            if self.store.get(edge.from)?.is_some() && !self.is_hub(edge.from)? {
231                return Ok(true);
232            }
233        }
234        Ok(false)
235    }
236
237    /// Whether `id` is an entity hub (carries the reserved [`HUB_FIELD`]).
238    fn is_hub(&self, id: u64) -> Result<bool, MemoryError> {
239        Ok(self
240            .store
241            .get_metadata(id)?
242            .is_some_and(|meta| meta.contains_key(HUB_FIELD)))
243    }
244
245    /// Explain a `decision`: find the best-matching memory (optionally scoped to
246    /// a metadata `filter`, e.g. the current project), then walk its typed links
247    /// up to `max_hops` away — fusing the [`RecallStore`] and [`GraphStore`]
248    /// facets (the `filter` goes through `query_filtered`, not the columnar
249    /// path).
250    ///
251    /// Returns an empty [`Explanation`] when nothing matches the decision.
252    ///
253    /// # Errors
254    /// Returns [`MemoryError`] if recall or graph traversal fails.
255    pub fn why(
256        &self,
257        decision: &str,
258        max_hops: usize,
259        filter: Option<&Metadata>,
260    ) -> Result<Explanation, MemoryError>
261    where
262        S: GraphStore + RecallStore,
263    {
264        let _generation = self.enter_generation();
265        let decision = decision.trim();
266        if decision.is_empty() {
267            return Ok(Explanation::default());
268        }
269        reject_reserved_keys(filter)?;
270        let embedding = self.embedder.embed(decision)?;
271        let seeds = self.search(&embedding, 1, filter)?;
272        let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
273            return Ok(Explanation::default());
274        };
275        self.traverse(seed_id, seed_content, max_hops)
276    }
277
278    /// Breadth-first walk over outgoing links from `seed_id`, collecting nodes
279    /// and edges up to `max_hops` away.
280    pub(super) fn traverse(
281        &self,
282        seed_id: u64,
283        seed_content: String,
284        max_hops: usize,
285    ) -> Result<Explanation, MemoryError>
286    where
287        S: GraphStore,
288    {
289        let mut explanation = Explanation {
290            nodes: vec![MemoryNode {
291                id: seed_id,
292                content: seed_content,
293                hop: 0,
294            }],
295            edges: Vec::new(),
296            truncated: false,
297        };
298        let mut visited: HashSet<u64> = HashSet::from([seed_id]);
299        let mut frontier = vec![seed_id];
300        let mut next: Vec<u64> = Vec::new();
301        'hops: for hop in 1..=max_hops {
302            next.clear();
303            for node_id in frontier.drain(..) {
304                // Both width budgets, checked here AND inside `expand`: this
305                // check alone would let the expansion that crosses the line
306                // finish its node — up to MAX_WHY_NODE_DEGREE nodes past the
307                // "ceiling", which a review measured at 522 of a promised 500.
308                if why_budget_spent(&explanation) {
309                    // Unexpanded frontier work remained — the response is a
310                    // partial view and must SAY so (#1820); whether the rest
311                    // held anything unseen is exactly what the budget forbids
312                    // finding out, so the cautious true is the honest one.
313                    explanation.truncated = true;
314                    break 'hops; // width budget spent — depth left in max_hops is moot
315                }
316                self.expand(node_id, hop, &mut explanation, &mut visited, &mut next)?;
317            }
318            if next.is_empty() {
319                break;
320            }
321            std::mem::swap(&mut frontier, &mut next);
322        }
323        Ok(explanation)
324    }
325
326    /// Expand a single node: enqueue unseen targets and record edges, following
327    /// at most [`crate::limits::MAX_WHY_NODE_DEGREE`] outgoing edges — an entity
328    /// hub's degree scales with the whole store, so an unbounded walk here would
329    /// dump its entire neighborhood into one response (issue #1743). An edge is
330    /// only recorded once its target is a resolved node, so the subgraph never
331    /// contains an edge pointing at a node absent from `nodes` (e.g. a forgotten
332    /// target whose edge outlived it).
333    fn expand(
334        &self,
335        node_id: u64,
336        hop: usize,
337        explanation: &mut Explanation,
338        visited: &mut HashSet<u64>,
339        next: &mut Vec<u64>,
340    ) -> Result<(), MemoryError>
341    where
342        S: GraphStore,
343    {
344        // The bounded read pushes the per-node budget into the store's own
345        // index scan: the old full fetch materialized a super-node's whole
346        // degree before `.take()` could apply — O(store size) transient
347        // allocation at a single hop, the cost half of #1743 that #1820
348        // closes. The store also reports whether the degree exceeded the
349        // budget, which is what makes the cut OBSERVABLE.
350        let bounded = self
351            .store
352            .relations_bounded(node_id, crate::limits::MAX_WHY_NODE_DEGREE)?;
353        if bounded.truncated {
354            explanation.truncated = true;
355        }
356        for edge in bounded.edges {
357            // The budgets are ceilings, not suggestions: once either is spent,
358            // this node's expansion stops MID-NODE rather than finishing. The
359            // caller's check between nodes cannot provide that — an expansion
360            // that crosses the line would otherwise add its whole degree.
361            if why_budget_spent(explanation) {
362                // An edge was in hand and not followed — an exact cut, not
363                // a conservative one.
364                explanation.truncated = true;
365                break;
366            }
367            if self.resolve_target(edge.to, hop, explanation, visited, next)? {
368                explanation.edges.push(edge);
369            }
370        }
371        Ok(())
372    }
373
374    /// Resolve one edge target: record it as a node and enqueue it if unseen.
375    /// Returns whether the edge may be recorded — `false` means the target no
376    /// longer exists, and the dangling edge must be dropped with it so the
377    /// subgraph never contains an edge pointing at a node absent from `nodes`.
378    fn resolve_target(
379        &self,
380        target: u64,
381        hop: usize,
382        explanation: &mut Explanation,
383        visited: &mut HashSet<u64>,
384        next: &mut Vec<u64>,
385    ) -> Result<bool, MemoryError> {
386        if visited.contains(&target) {
387            return Ok(true);
388        }
389        let Some((content, _embedding)) = self.store.get(target)? else {
390            return Ok(false); // target no longer exists → drop the dangling edge too
391        };
392        visited.insert(target);
393        explanation.nodes.push(MemoryNode {
394            id: target,
395            content,
396            hop,
397        });
398        next.push(target);
399        Ok(true)
400    }
401}
402
403/// Whether either width budget of a why-subgraph is spent. Both ceilings are
404/// checked at both call sites — between nodes AND per edge inside a node's
405/// expansion — because either alone lets work cross the line (see the call
406/// sites for what each miss costs).
407fn why_budget_spent(explanation: &Explanation) -> bool {
408    explanation.nodes.len() >= crate::limits::MAX_WHY_NODES
409        || explanation.edges.len() >= crate::limits::MAX_WHY_EDGES
410}