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}