Skip to main content

klieo_memory_graph/
in_memory.rs

1//! `InMemoryGraph` — petgraph `StableGraph`-backed [`KnowledgeGraph`] impl.
2//!
3//! For tests and the hello-agent M1 spike only — not for production use.
4//! Production deployments wire `klieo-memory-graph-neo4j::Neo4jKnowledgeGraph`
5//! (M2). `StableGraph` is used over `Graph` because handle stability survives
6//! removal — needed once `forget()` lands.
7
8use crate::path::{PathHop, RetrievalPath};
9use crate::traits::KnowledgeGraph;
10use crate::types::{EdgeKind, EntityRef, GraphEdge, GraphNode, GraphView};
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use klieo_core::error::MemoryError;
14use klieo_core::ids::FactId;
15use klieo_core::memory::Scope;
16use petgraph::stable_graph::{NodeIndex, StableGraph};
17use petgraph::Undirected;
18use std::collections::{HashMap, HashSet};
19use std::sync::Mutex;
20
21/// `Node` discriminator. The `Entity` variant carries its `EntityRef` so
22/// `subgraph()` can surface entity identity when browsing; `neighbors()`
23/// still returns `FactId`s only, via the `FactRef` variant.
24#[derive(Debug, Clone)]
25enum Node {
26    Entity(EntityRef),
27    FactRef(FactId),
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31struct ScopeKey {
32    kind: &'static str,
33    value: String,
34}
35
36impl From<&Scope> for ScopeKey {
37    fn from(scope: &Scope) -> Self {
38        match scope {
39            Scope::Workspace(s) => Self {
40                kind: "workspace",
41                value: s.clone(),
42            },
43            Scope::Agent(s) => Self {
44                kind: "agent",
45                value: s.clone(),
46            },
47            Scope::Global => Self {
48                kind: "global",
49                value: String::new(),
50            },
51        }
52    }
53}
54
55#[derive(Debug, Clone)]
56enum Edge {
57    MentionedIn,
58    CoOccurs { count: u32 },
59}
60
61struct Inner {
62    graph: StableGraph<Node, Edge, Undirected>,
63    entity_idx: HashMap<(String, String, ScopeKey), NodeIndex>,
64    /// Scope-keyed FactRef index. Two facts with the same `FactId` in
65    /// different scopes get distinct nodes — closing the cross-scope leak
66    /// the prior un-scoped key allowed via shared-FactRef neighbors.
67    factref_idx: HashMap<(String, ScopeKey), NodeIndex>,
68    /// Fact sentence text keyed by fact id, captured at `index` time so
69    /// `subgraph` can surface it in [`GraphView::fact_texts`]. Keyed by id
70    /// alone (text is a property of the fact, not the scope).
71    fact_texts: HashMap<FactId, String>,
72}
73
74/// In-memory [`KnowledgeGraph`] backed by a petgraph `StableGraph`.
75///
76/// `StableGraph` preserves node/edge indices after removal — required for
77/// future `forget()` support without re-indexing every entry. The single
78/// `Mutex<Inner>` is acceptable for the spike scale; production traffic
79/// goes to `Neo4jKnowledgeGraph` (M2).
80pub struct InMemoryGraph {
81    inner: Mutex<Inner>,
82}
83
84impl Default for InMemoryGraph {
85    fn default() -> Self {
86        Self {
87            inner: Mutex::new(Inner {
88                graph: StableGraph::default(),
89                entity_idx: HashMap::new(),
90                factref_idx: HashMap::new(),
91                fact_texts: HashMap::new(),
92            }),
93        }
94    }
95}
96
97#[async_trait]
98impl KnowledgeGraph for InMemoryGraph {
99    async fn index(
100        &self,
101        scope: Scope,
102        fact_id: &FactId,
103        entities: &[EntityRef],
104        text: &str,
105        _valid_from: Option<DateTime<Utc>>,
106    ) -> Result<(), MemoryError> {
107        if entities.is_empty() {
108            tracing::debug!(%fact_id, "index called with empty entities; no-op");
109            return Ok(());
110        }
111        let sk = ScopeKey::from(&scope);
112        // No `.await` while the std Mutex guard is held — sound for Send across the async boundary.
113        let mut guard = self.inner.lock().map_err(|_| {
114            tracing::error!(operation = "InMemoryGraph::index", "mutex poisoned");
115            MemoryError::Store("InMemoryGraph mutex poisoned".into())
116        })?;
117        // Split-borrow: destructure once so closures don't reborrow the guard.
118        let Inner {
119            graph,
120            entity_idx,
121            factref_idx,
122            fact_texts,
123        } = &mut *guard;
124
125        if !text.is_empty() {
126            fact_texts.insert(fact_id.clone(), text.to_string());
127        }
128        let fact_node = *factref_idx
129            .entry((fact_id.to_string(), sk.clone()))
130            .or_insert_with(|| graph.add_node(Node::FactRef(fact_id.clone())));
131
132        let mut entity_nodes: Vec<NodeIndex> = Vec::with_capacity(entities.len());
133        for entity in entities {
134            let key = (
135                entity.entity_type.as_str().to_owned(),
136                entity.name.clone(),
137                sk.clone(),
138            );
139            let ent_node = *entity_idx
140                .entry(key)
141                .or_insert_with(|| graph.add_node(Node::Entity(entity.clone())));
142            entity_nodes.push(ent_node);
143            if !graph.contains_edge(ent_node, fact_node) {
144                graph.add_edge(ent_node, fact_node, Edge::MentionedIn);
145            }
146        }
147
148        for i in 0..entity_nodes.len() {
149            for j in (i + 1)..entity_nodes.len() {
150                let (a, b) = (entity_nodes[i], entity_nodes[j]);
151                if let Some(edge) = graph.find_edge(a, b) {
152                    if let Some(Edge::CoOccurs { count }) = graph.edge_weight_mut(edge) {
153                        *count += 1;
154                    }
155                } else {
156                    graph.add_edge(a, b, Edge::CoOccurs { count: 1 });
157                }
158            }
159        }
160        Ok(())
161    }
162
163    async fn neighbors(
164        &self,
165        scope: &Scope,
166        entities: &[EntityRef],
167    ) -> Result<Vec<FactId>, MemoryError> {
168        if entities.is_empty() {
169            tracing::debug!("neighbors called with empty entities; no-op");
170            return Ok(Vec::new());
171        }
172        let sk = ScopeKey::from(scope);
173        let guard = self.inner.lock().map_err(|_| {
174            tracing::error!(operation = "InMemoryGraph::neighbors", "mutex poisoned");
175            MemoryError::Store("InMemoryGraph mutex poisoned".into())
176        })?;
177        let Inner {
178            graph, entity_idx, ..
179        } = &*guard;
180        let mut seen: HashSet<FactId> = HashSet::new();
181
182        for entity in entities {
183            let key = (
184                entity.entity_type.as_str().to_owned(),
185                entity.name.clone(),
186                sk.clone(),
187            );
188            let Some(&ent_node) = entity_idx.get(&key) else {
189                continue;
190            };
191            collect_reachable_fact_ids(graph, ent_node, &mut seen);
192        }
193
194        Ok(seen.into_iter().collect())
195    }
196
197    async fn recall_paths(
198        &self,
199        scope: &Scope,
200        entities: &[EntityRef],
201    ) -> Result<Vec<RetrievalPath>, MemoryError> {
202        if entities.is_empty() {
203            return Ok(Vec::new());
204        }
205        let sk = ScopeKey::from(scope);
206        let guard = self.inner.lock().map_err(|_| {
207            tracing::error!(operation = "InMemoryGraph::recall_paths", "mutex poisoned");
208            MemoryError::Store("InMemoryGraph mutex poisoned".into())
209        })?;
210        let Inner {
211            graph, entity_idx, ..
212        } = &*guard;
213
214        let mut paths: Vec<RetrievalPath> = Vec::new();
215        let mut seen: HashSet<(String, String, FactId)> = HashSet::new();
216
217        for entity in entities {
218            let key = (
219                entity.entity_type.as_str().to_owned(),
220                entity.name.clone(),
221                sk.clone(),
222            );
223            let Some(&ent_node) = entity_idx.get(&key) else {
224                continue;
225            };
226            let mut fact_ids: HashSet<FactId> = HashSet::new();
227            collect_reachable_fact_ids(graph, ent_node, &mut fact_ids);
228            for fid in fact_ids {
229                let dedupe_key = (
230                    entity.entity_type.as_str().to_owned(),
231                    entity.name.clone(),
232                    fid.clone(),
233                );
234                if !seen.insert(dedupe_key) {
235                    continue;
236                }
237                paths.push(RetrievalPath {
238                    hops: vec![PathHop::new(entity.clone(), fid)],
239                });
240            }
241        }
242        Ok(paths)
243    }
244
245    async fn subgraph(&self, scope: &Scope, limit: usize) -> Result<GraphView, MemoryError> {
246        let sk = ScopeKey::from(scope);
247        let guard = self.inner.lock().map_err(|_| {
248            tracing::error!(operation = "InMemoryGraph::subgraph", "mutex poisoned");
249            MemoryError::Store("InMemoryGraph mutex poisoned".into())
250        })?;
251        let Inner {
252            graph,
253            entity_idx,
254            factref_idx,
255            fact_texts,
256        } = &*guard;
257
258        let mut scope_nodes: Vec<NodeIndex> = entity_idx
259            .iter()
260            .filter(|(key, _)| key.2 == sk)
261            .map(|(_, &idx)| idx)
262            .chain(
263                factref_idx
264                    .iter()
265                    .filter(|(key, _)| key.1 == sk)
266                    .map(|(_, &idx)| idx),
267            )
268            .collect();
269        // Sort before the cap so truncation is deterministic across process
270        // restarts — HashMap iteration order is randomized per process.
271        scope_nodes.sort_unstable();
272
273        let mut nodes = Vec::new();
274        let mut keep: HashSet<NodeIndex> = HashSet::new();
275        let mut truncated = false;
276        for &idx in &scope_nodes {
277            if nodes.len() >= limit {
278                truncated = true;
279                break;
280            }
281            nodes.push(node_dto(graph, idx));
282            keep.insert(idx);
283        }
284
285        // ponytail: scans every edge in the shared store (O(total edges), not
286        // O(scope)) — fine for this test/dev backend per the module header; a
287        // real backend would scope the scan to the kept nodes' incident edges.
288        let edges = graph
289            .edge_indices()
290            .filter_map(|e| {
291                let (a, b) = graph.edge_endpoints(e)?;
292                if !keep.contains(&a) || !keep.contains(&b) {
293                    return None;
294                }
295                let kind = match graph[e] {
296                    Edge::MentionedIn => EdgeKind::MentionedIn,
297                    Edge::CoOccurs { .. } => EdgeKind::CoOccurs,
298                };
299                Some(GraphEdge::new(node_dto(graph, a), node_dto(graph, b), kind))
300            })
301            .collect();
302
303        let view_fact_texts = nodes
304            .iter()
305            .filter_map(|node| match node {
306                GraphNode::Fact(fid) => fact_texts.get(fid).map(|t| (fid.clone(), t.clone())),
307                _ => None,
308            })
309            .collect();
310
311        let view = if truncated {
312            GraphView::truncated(nodes, edges)
313        } else {
314            GraphView::complete(nodes, edges)
315        };
316        Ok(view.with_fact_texts(view_fact_texts))
317    }
318
319    /// Counts `MENTIONED_IN` edges per entity node in `scope` — no view is
320    /// materialised and no `limit` is involved, so the tally cannot be
321    /// truncated the way a `subgraph`-walking client-side version can.
322    async fn all_entity_frequencies(
323        &self,
324        scope: &Scope,
325    ) -> Result<HashMap<String, usize>, MemoryError> {
326        let sk = ScopeKey::from(scope);
327        let guard = self.inner.lock().map_err(|_| {
328            tracing::error!(
329                operation = "InMemoryGraph::all_entity_frequencies",
330                "mutex poisoned"
331            );
332            MemoryError::Store("InMemoryGraph mutex poisoned".into())
333        })?;
334        let Inner {
335            graph, entity_idx, ..
336        } = &*guard;
337
338        let mut counts: HashMap<String, usize> = HashMap::new();
339        for ((_, name, key), &idx) in entity_idx.iter() {
340            if *key != sk {
341                continue;
342            }
343            let mentions = graph
344                .edges(idx)
345                .filter(|edge| matches!(petgraph::visit::EdgeRef::weight(edge), Edge::MentionedIn))
346                .count();
347            *counts.entry(name.clone()).or_insert(0) += mentions;
348        }
349        Ok(counts)
350    }
351
352    async fn forget(&self, scope: &Scope, fact_id: &FactId) -> Result<(), MemoryError> {
353        let sk = ScopeKey::from(scope);
354        let mut guard = self.inner.lock().map_err(|_| {
355            tracing::error!(operation = "InMemoryGraph::forget", "mutex poisoned");
356            MemoryError::Store("InMemoryGraph mutex poisoned".into())
357        })?;
358        let Inner {
359            graph,
360            factref_idx,
361            fact_texts,
362            ..
363        } = &mut *guard;
364
365        fact_texts.remove(fact_id);
366        let Some(fact_node) = factref_idx.remove(&(fact_id.to_string(), sk)) else {
367            return Ok(());
368        };
369
370        // CO_OCCURS edges live entity↔entity and survive — co-occurrence
371        // is a historical fact about the original index call.
372        let fact_edges: Vec<_> = graph
373            .edges(fact_node)
374            .map(|e| petgraph::visit::EdgeRef::id(&e))
375            .collect();
376        for edge in fact_edges {
377            graph.remove_edge(edge);
378        }
379        graph.remove_node(fact_node);
380        Ok(())
381    }
382}
383
384/// Convert a graph node into the browsable [`GraphNode`] DTO.
385fn node_dto(graph: &StableGraph<Node, Edge, Undirected>, idx: NodeIndex) -> GraphNode {
386    match &graph[idx] {
387        Node::Entity(entity) => GraphNode::Entity(entity.clone()),
388        Node::FactRef(fid) => GraphNode::Fact(fid.clone()),
389    }
390}
391
392/// Equivalent to the Neo4j Cypher `UNION` in `klieo-memory-graph-neo4j`,
393/// so both backends return the same fact-id set for any given entry entity.
394fn collect_reachable_fact_ids(
395    graph: &StableGraph<Node, Edge, Undirected>,
396    entry_entity: NodeIndex,
397    seen: &mut HashSet<FactId>,
398) {
399    for neighbor in graph.neighbors(entry_entity) {
400        match &graph[neighbor] {
401            Node::FactRef(fid) => {
402                seen.insert(fid.clone());
403            }
404            Node::Entity(_) => {
405                for deeper in graph.neighbors(neighbor) {
406                    if let Node::FactRef(fid) = &graph[deeper] {
407                        seen.insert(fid.clone());
408                    }
409                }
410            }
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::types::EntityType;
419
420    #[tokio::test]
421    async fn subgraph_returns_indexed_nodes_and_edges() {
422        let g = InMemoryGraph::default();
423        let scope = Scope::Workspace("w".into());
424        let fact = FactId::new("f1");
425        let alice = EntityRef::new(EntityType::Member, "alice");
426        let acme = EntityRef::new(EntityType::Member, "acme");
427        g.index(
428            scope.clone(),
429            &fact,
430            &[alice.clone(), acme.clone()],
431            "alice at acme",
432            None,
433        )
434        .await
435        .unwrap();
436
437        let view = g.subgraph(&scope, 100).await.unwrap();
438
439        assert!(!view.truncated);
440        assert!(view.nodes.contains(&GraphNode::Fact(fact.clone())));
441        assert!(view.nodes.contains(&GraphNode::Entity(alice.clone())));
442        assert!(view.edges.iter().any(|e| e.kind == EdgeKind::MentionedIn
443            && e.from == GraphNode::Entity(alice.clone())
444            && e.to == GraphNode::Fact(fact.clone())));
445        assert!(view.edges.iter().any(|e| e.kind == EdgeKind::CoOccurs));
446    }
447
448    #[tokio::test]
449    async fn subgraph_surfaces_fact_text_and_forget_drops_it() {
450        let g = InMemoryGraph::default();
451        let scope = Scope::Workspace("w".into());
452        let fact = FactId::new("f1");
453        let alice = EntityRef::new(EntityType::Member, "alice");
454        g.index(scope.clone(), &fact, &[alice], "alice filed a claim", None)
455            .await
456            .unwrap();
457
458        let view = g.subgraph(&scope, 100).await.unwrap();
459        assert_eq!(
460            view.fact_texts.get(&fact).map(String::as_str),
461            Some("alice filed a claim"),
462        );
463
464        g.forget(&scope, &fact).await.unwrap();
465        let after = g.subgraph(&scope, 100).await.unwrap();
466        assert!(
467            after.fact_texts.is_empty(),
468            "forget must drop the fact's stored text"
469        );
470    }
471
472    #[tokio::test]
473    async fn subgraph_empty_scope_is_empty_view() {
474        let g = InMemoryGraph::default();
475        let view = g.subgraph(&Scope::Global, 100).await.unwrap();
476        assert!(view.nodes.is_empty() && view.edges.is_empty() && !view.truncated);
477    }
478
479    #[tokio::test]
480    async fn subgraph_truncates_at_limit() {
481        let g = InMemoryGraph::default();
482        let scope = Scope::Workspace("w".into());
483        for i in 0..5 {
484            let f = FactId::new(format!("f{i}"));
485            g.index(
486                scope.clone(),
487                &f,
488                &[EntityRef::new(EntityType::Member, format!("p{i}"))],
489                "x",
490                None,
491            )
492            .await
493            .unwrap();
494        }
495        let view = g.subgraph(&scope, 3).await.unwrap();
496        assert!(view.truncated);
497        assert_eq!(view.nodes.len(), 3);
498    }
499
500    /// Counts mentions per entity and — the point of having the query at all
501    /// — is not bounded by the `limit` that makes `subgraph` return a
502    /// truncation error instead of data at the same corpus size.
503    #[tokio::test]
504    async fn entity_frequencies_counts_mentions_where_subgraph_would_truncate() {
505        let g = InMemoryGraph::default();
506        let scope = Scope::Workspace("w".into());
507        let generic = EntityRef::new(EntityType::Concept, "notification");
508        for i in 0..5 {
509            let specific = EntityRef::new(EntityType::Concept, format!("term{i}"));
510            g.index(
511                scope.clone(),
512                &FactId::new(format!("f{i}")),
513                &[generic.clone(), specific],
514                "x",
515                None,
516            )
517            .await
518            .unwrap();
519        }
520        assert!(g.subgraph(&scope, 3).await.unwrap().truncated);
521
522        let counts = g.all_entity_frequencies(&scope).await.unwrap();
523        assert_eq!(counts.get("notification"), Some(&5));
524        assert_eq!(counts.get("term0"), Some(&1));
525        assert_eq!(counts.len(), 6);
526    }
527
528    /// A name the caller asked about but the corpus never mentions answers
529    /// `0`, not an absence the caller has to interpret.
530    #[tokio::test]
531    async fn entity_frequencies_answers_zero_for_an_unmentioned_name() {
532        let g = InMemoryGraph::default();
533        let scope = Scope::Workspace("w".into());
534        g.index(
535            scope.clone(),
536            &FactId::new("f0"),
537            &[EntityRef::new(EntityType::Concept, "present")],
538            "x",
539            None,
540        )
541        .await
542        .unwrap();
543
544        let counts = g
545            .entity_frequencies(&scope, &["present", "absent"])
546            .await
547            .unwrap();
548        assert_eq!(counts.get("present"), Some(&1));
549        assert_eq!(counts.get("absent"), Some(&0));
550    }
551
552    /// The default must not answer `Ok(empty)` — that reads as "nothing is
553    /// generic in this corpus" and would silently pass every consumer's
554    /// threshold on a backend that never counted anything.
555    #[tokio::test]
556    async fn the_trait_default_refuses_rather_than_reporting_an_empty_corpus() {
557        struct NoCounts;
558
559        #[async_trait]
560        impl KnowledgeGraph for NoCounts {
561            async fn index(
562                &self,
563                _scope: Scope,
564                _fact_id: &FactId,
565                _entities: &[EntityRef],
566                _text: &str,
567                _valid_from: Option<DateTime<Utc>>,
568            ) -> Result<(), MemoryError> {
569                Ok(())
570            }
571            async fn neighbors(
572                &self,
573                _scope: &Scope,
574                _entities: &[EntityRef],
575            ) -> Result<Vec<FactId>, MemoryError> {
576                Ok(Vec::new())
577            }
578        }
579
580        let scope = Scope::Workspace("w".into());
581        assert!(NoCounts.all_entity_frequencies(&scope).await.is_err());
582        assert!(NoCounts.entity_frequencies(&scope, &["x"]).await.is_err());
583    }
584}