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    async fn forget(&self, scope: &Scope, fact_id: &FactId) -> Result<(), MemoryError> {
320        let sk = ScopeKey::from(scope);
321        let mut guard = self.inner.lock().map_err(|_| {
322            tracing::error!(operation = "InMemoryGraph::forget", "mutex poisoned");
323            MemoryError::Store("InMemoryGraph mutex poisoned".into())
324        })?;
325        let Inner {
326            graph,
327            factref_idx,
328            fact_texts,
329            ..
330        } = &mut *guard;
331
332        fact_texts.remove(fact_id);
333        let Some(fact_node) = factref_idx.remove(&(fact_id.to_string(), sk)) else {
334            return Ok(());
335        };
336
337        // CO_OCCURS edges live entity↔entity and survive — co-occurrence
338        // is a historical fact about the original index call.
339        let fact_edges: Vec<_> = graph
340            .edges(fact_node)
341            .map(|e| petgraph::visit::EdgeRef::id(&e))
342            .collect();
343        for edge in fact_edges {
344            graph.remove_edge(edge);
345        }
346        graph.remove_node(fact_node);
347        Ok(())
348    }
349}
350
351/// Convert a graph node into the browsable [`GraphNode`] DTO.
352fn node_dto(graph: &StableGraph<Node, Edge, Undirected>, idx: NodeIndex) -> GraphNode {
353    match &graph[idx] {
354        Node::Entity(entity) => GraphNode::Entity(entity.clone()),
355        Node::FactRef(fid) => GraphNode::Fact(fid.clone()),
356    }
357}
358
359/// Equivalent to the Neo4j Cypher `UNION` in `klieo-memory-graph-neo4j`,
360/// so both backends return the same fact-id set for any given entry entity.
361fn collect_reachable_fact_ids(
362    graph: &StableGraph<Node, Edge, Undirected>,
363    entry_entity: NodeIndex,
364    seen: &mut HashSet<FactId>,
365) {
366    for neighbor in graph.neighbors(entry_entity) {
367        match &graph[neighbor] {
368            Node::FactRef(fid) => {
369                seen.insert(fid.clone());
370            }
371            Node::Entity(_) => {
372                for deeper in graph.neighbors(neighbor) {
373                    if let Node::FactRef(fid) = &graph[deeper] {
374                        seen.insert(fid.clone());
375                    }
376                }
377            }
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::types::EntityType;
386
387    #[tokio::test]
388    async fn subgraph_returns_indexed_nodes_and_edges() {
389        let g = InMemoryGraph::default();
390        let scope = Scope::Workspace("w".into());
391        let fact = FactId::new("f1");
392        let alice = EntityRef::new(EntityType::Member, "alice");
393        let acme = EntityRef::new(EntityType::Member, "acme");
394        g.index(
395            scope.clone(),
396            &fact,
397            &[alice.clone(), acme.clone()],
398            "alice at acme",
399            None,
400        )
401        .await
402        .unwrap();
403
404        let view = g.subgraph(&scope, 100).await.unwrap();
405
406        assert!(!view.truncated);
407        assert!(view.nodes.contains(&GraphNode::Fact(fact.clone())));
408        assert!(view.nodes.contains(&GraphNode::Entity(alice.clone())));
409        assert!(view.edges.iter().any(|e| e.kind == EdgeKind::MentionedIn
410            && e.from == GraphNode::Entity(alice.clone())
411            && e.to == GraphNode::Fact(fact.clone())));
412        assert!(view.edges.iter().any(|e| e.kind == EdgeKind::CoOccurs));
413    }
414
415    #[tokio::test]
416    async fn subgraph_surfaces_fact_text_and_forget_drops_it() {
417        let g = InMemoryGraph::default();
418        let scope = Scope::Workspace("w".into());
419        let fact = FactId::new("f1");
420        let alice = EntityRef::new(EntityType::Member, "alice");
421        g.index(scope.clone(), &fact, &[alice], "alice filed a claim", None)
422            .await
423            .unwrap();
424
425        let view = g.subgraph(&scope, 100).await.unwrap();
426        assert_eq!(
427            view.fact_texts.get(&fact).map(String::as_str),
428            Some("alice filed a claim"),
429        );
430
431        g.forget(&scope, &fact).await.unwrap();
432        let after = g.subgraph(&scope, 100).await.unwrap();
433        assert!(
434            after.fact_texts.is_empty(),
435            "forget must drop the fact's stored text"
436        );
437    }
438
439    #[tokio::test]
440    async fn subgraph_empty_scope_is_empty_view() {
441        let g = InMemoryGraph::default();
442        let view = g.subgraph(&Scope::Global, 100).await.unwrap();
443        assert!(view.nodes.is_empty() && view.edges.is_empty() && !view.truncated);
444    }
445
446    #[tokio::test]
447    async fn subgraph_truncates_at_limit() {
448        let g = InMemoryGraph::default();
449        let scope = Scope::Workspace("w".into());
450        for i in 0..5 {
451            let f = FactId::new(format!("f{i}"));
452            g.index(
453                scope.clone(),
454                &f,
455                &[EntityRef::new(EntityType::Member, format!("p{i}"))],
456                "x",
457                None,
458            )
459            .await
460            .unwrap();
461        }
462        let view = g.subgraph(&scope, 3).await.unwrap();
463        assert!(view.truncated);
464        assert_eq!(view.nodes.len(), 3);
465    }
466}