1use 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#[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 factref_idx: HashMap<(String, ScopeKey), NodeIndex>,
68 fact_texts: HashMap<FactId, String>,
72}
73
74pub 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 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 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 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 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 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
351fn 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
359fn 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}