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::EntityRef;
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 carrying only the identifier needed to surface the
22/// neighbor's identity; the scope is enforced via the index keys, not the
23/// payload. The `FactId` payload is the actual return value of `neighbors()`,
24/// so the Entity variant intentionally carries no payload at this milestone.
25#[derive(Debug, Clone)]
26enum Node {
27    Entity,
28    FactRef(FactId),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
32struct ScopeKey {
33    kind: &'static str,
34    value: String,
35}
36
37impl From<&Scope> for ScopeKey {
38    fn from(scope: &Scope) -> Self {
39        match scope {
40            Scope::Workspace(s) => Self {
41                kind: "workspace",
42                value: s.clone(),
43            },
44            Scope::Agent(s) => Self {
45                kind: "agent",
46                value: s.clone(),
47            },
48            Scope::Global => Self {
49                kind: "global",
50                value: String::new(),
51            },
52        }
53    }
54}
55
56#[derive(Debug, Clone)]
57enum Edge {
58    MentionedIn,
59    CoOccurs { count: u32 },
60}
61
62struct Inner {
63    graph: StableGraph<Node, Edge, Undirected>,
64    entity_idx: HashMap<(String, String, ScopeKey), NodeIndex>,
65    /// Scope-keyed FactRef index. Two facts with the same `FactId` in
66    /// different scopes get distinct nodes — closing the cross-scope leak
67    /// the prior un-scoped key allowed via shared-FactRef neighbors.
68    factref_idx: HashMap<(String, ScopeKey), NodeIndex>,
69}
70
71/// In-memory [`KnowledgeGraph`] backed by a petgraph `StableGraph`.
72///
73/// `StableGraph` preserves node/edge indices after removal — required for
74/// future `forget()` support without re-indexing every entry. The single
75/// `Mutex<Inner>` is acceptable for the spike scale; production traffic
76/// goes to `Neo4jKnowledgeGraph` (M2).
77pub struct InMemoryGraph {
78    inner: Mutex<Inner>,
79}
80
81impl Default for InMemoryGraph {
82    fn default() -> Self {
83        Self {
84            inner: Mutex::new(Inner {
85                graph: StableGraph::default(),
86                entity_idx: HashMap::new(),
87                factref_idx: HashMap::new(),
88            }),
89        }
90    }
91}
92
93#[async_trait]
94impl KnowledgeGraph for InMemoryGraph {
95    async fn index(
96        &self,
97        scope: Scope,
98        fact_id: &FactId,
99        entities: &[EntityRef],
100        _text: &str,
101        _valid_from: Option<DateTime<Utc>>,
102    ) -> Result<(), MemoryError> {
103        if entities.is_empty() {
104            tracing::debug!(%fact_id, "index called with empty entities; no-op");
105            return Ok(());
106        }
107        let sk = ScopeKey::from(&scope);
108        // No `.await` while the std Mutex guard is held — sound for Send across the async boundary.
109        let mut guard = self.inner.lock().map_err(|_| {
110            tracing::error!(operation = "InMemoryGraph::index", "mutex poisoned");
111            MemoryError::Store("InMemoryGraph mutex poisoned".into())
112        })?;
113        // Split-borrow: destructure once so closures don't reborrow the guard.
114        let Inner {
115            graph,
116            entity_idx,
117            factref_idx,
118        } = &mut *guard;
119
120        let fact_node = *factref_idx
121            .entry((fact_id.to_string(), sk.clone()))
122            .or_insert_with(|| graph.add_node(Node::FactRef(fact_id.clone())));
123
124        let mut entity_nodes: Vec<NodeIndex> = Vec::with_capacity(entities.len());
125        for entity in entities {
126            let key = (
127                entity.entity_type.as_str().to_owned(),
128                entity.name.clone(),
129                sk.clone(),
130            );
131            let ent_node = *entity_idx
132                .entry(key)
133                .or_insert_with(|| graph.add_node(Node::Entity));
134            entity_nodes.push(ent_node);
135            if !graph.contains_edge(ent_node, fact_node) {
136                graph.add_edge(ent_node, fact_node, Edge::MentionedIn);
137            }
138        }
139
140        for i in 0..entity_nodes.len() {
141            for j in (i + 1)..entity_nodes.len() {
142                let (a, b) = (entity_nodes[i], entity_nodes[j]);
143                if let Some(edge) = graph.find_edge(a, b) {
144                    if let Some(Edge::CoOccurs { count }) = graph.edge_weight_mut(edge) {
145                        *count += 1;
146                    }
147                } else {
148                    graph.add_edge(a, b, Edge::CoOccurs { count: 1 });
149                }
150            }
151        }
152        Ok(())
153    }
154
155    async fn neighbors(
156        &self,
157        scope: &Scope,
158        entities: &[EntityRef],
159    ) -> Result<Vec<FactId>, MemoryError> {
160        if entities.is_empty() {
161            tracing::debug!("neighbors called with empty entities; no-op");
162            return Ok(Vec::new());
163        }
164        let sk = ScopeKey::from(scope);
165        let guard = self.inner.lock().map_err(|_| {
166            tracing::error!(operation = "InMemoryGraph::neighbors", "mutex poisoned");
167            MemoryError::Store("InMemoryGraph mutex poisoned".into())
168        })?;
169        let Inner {
170            graph, entity_idx, ..
171        } = &*guard;
172        let mut seen: HashSet<FactId> = HashSet::new();
173
174        for entity in entities {
175            let key = (
176                entity.entity_type.as_str().to_owned(),
177                entity.name.clone(),
178                sk.clone(),
179            );
180            let Some(&ent_node) = entity_idx.get(&key) else {
181                continue;
182            };
183            collect_reachable_fact_ids(graph, ent_node, &mut seen);
184        }
185
186        Ok(seen.into_iter().collect())
187    }
188
189    async fn recall_paths(
190        &self,
191        scope: &Scope,
192        entities: &[EntityRef],
193    ) -> Result<Vec<RetrievalPath>, MemoryError> {
194        if entities.is_empty() {
195            return Ok(Vec::new());
196        }
197        let sk = ScopeKey::from(scope);
198        let guard = self.inner.lock().map_err(|_| {
199            tracing::error!(operation = "InMemoryGraph::recall_paths", "mutex poisoned");
200            MemoryError::Store("InMemoryGraph mutex poisoned".into())
201        })?;
202        let Inner {
203            graph, entity_idx, ..
204        } = &*guard;
205
206        let mut paths: Vec<RetrievalPath> = Vec::new();
207        let mut seen: HashSet<(String, String, FactId)> = HashSet::new();
208
209        for entity in entities {
210            let key = (
211                entity.entity_type.as_str().to_owned(),
212                entity.name.clone(),
213                sk.clone(),
214            );
215            let Some(&ent_node) = entity_idx.get(&key) else {
216                continue;
217            };
218            let mut fact_ids: HashSet<FactId> = HashSet::new();
219            collect_reachable_fact_ids(graph, ent_node, &mut fact_ids);
220            for fid in fact_ids {
221                let dedupe_key = (
222                    entity.entity_type.as_str().to_owned(),
223                    entity.name.clone(),
224                    fid.clone(),
225                );
226                if !seen.insert(dedupe_key) {
227                    continue;
228                }
229                paths.push(RetrievalPath {
230                    hops: vec![PathHop::new(entity.clone(), fid)],
231                });
232            }
233        }
234        Ok(paths)
235    }
236
237    async fn forget(&self, scope: &Scope, fact_id: &FactId) -> Result<(), MemoryError> {
238        let sk = ScopeKey::from(scope);
239        let mut guard = self.inner.lock().map_err(|_| {
240            tracing::error!(operation = "InMemoryGraph::forget", "mutex poisoned");
241            MemoryError::Store("InMemoryGraph mutex poisoned".into())
242        })?;
243        let Inner {
244            graph, factref_idx, ..
245        } = &mut *guard;
246
247        let Some(fact_node) = factref_idx.remove(&(fact_id.to_string(), sk)) else {
248            return Ok(());
249        };
250
251        // CO_OCCURS edges live entity↔entity and survive — co-occurrence
252        // is a historical fact about the original index call.
253        let fact_edges: Vec<_> = graph
254            .edges(fact_node)
255            .map(|e| petgraph::visit::EdgeRef::id(&e))
256            .collect();
257        for edge in fact_edges {
258            graph.remove_edge(edge);
259        }
260        graph.remove_node(fact_node);
261        Ok(())
262    }
263}
264
265/// Equivalent to the Neo4j Cypher `UNION` in `klieo-memory-graph-neo4j`,
266/// so both backends return the same fact-id set for any given entry entity.
267fn collect_reachable_fact_ids(
268    graph: &StableGraph<Node, Edge, Undirected>,
269    entry_entity: NodeIndex,
270    seen: &mut HashSet<FactId>,
271) {
272    for neighbor in graph.neighbors(entry_entity) {
273        match &graph[neighbor] {
274            Node::FactRef(fid) => {
275                seen.insert(fid.clone());
276            }
277            Node::Entity => {
278                for deeper in graph.neighbors(neighbor) {
279                    if let Node::FactRef(fid) = &graph[deeper] {
280                        seen.insert(fid.clone());
281                    }
282                }
283            }
284        }
285    }
286}