Skip to main content

aimem_core/
graph.rs

1//! AiMem graph — traverse rooms and wings in the memory store.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4
5use thiserror::Error;
6
7use crate::{
8    db::{AimemDb, DbError},
9    types::{RoomNode, Tunnel},
10};
11
12#[derive(Debug, Error)]
13pub enum GraphError {
14    #[error("db: {0}")]
15    Db(#[from] DbError),
16    #[error("turso: {0}")]
17    Turso(#[from] turso::Error),
18}
19
20/// AiMem graph traversal.
21#[derive(Debug, Clone)]
22pub struct AimemGraph {
23    db: AimemDb,
24}
25
26impl AimemGraph {
27    pub fn new(db: AimemDb) -> Self {
28        Self { db }
29    }
30
31    /// Build full room→wings mapping from the memory store.
32    pub async fn build(&self) -> Result<(Vec<RoomNode>, Vec<Tunnel>), GraphError> {
33        let conn = self.db.conn()?;
34        let mut room_wings: HashMap<String, (HashSet<String>, i64)> = HashMap::new();
35
36        let mut rows = conn
37            .query(
38                "SELECT room, wing, COUNT(*) as cnt \
39                 FROM drawers \
40                 WHERE room != 'general' \
41                 GROUP BY room, wing",
42                (),
43            )
44            .await?;
45
46        while let Some(row) = rows.next().await? {
47            let room = val_str(&row, 0);
48            let wing = val_str(&row, 1);
49            let cnt = row
50                .get_value(2)
51                .ok()
52                .and_then(|v| v.as_integer().copied())
53                .unwrap_or(0);
54            if room.is_empty() || wing.is_empty() {
55                continue;
56            }
57            let entry = room_wings.entry(room).or_insert((HashSet::new(), 0));
58            entry.0.insert(wing);
59            entry.1 += cnt;
60        }
61
62        let mut nodes: Vec<RoomNode> = room_wings
63            .iter()
64            .map(|(room, (wings, cnt))| {
65                let mut ws: Vec<String> = wings.iter().cloned().collect();
66                ws.sort();
67                RoomNode {
68                    room: room.clone(),
69                    wings: ws,
70                    drawer_count: *cnt,
71                }
72            })
73            .collect();
74        nodes.sort_by(|a, b| a.room.cmp(&b.room));
75
76        let tunnels: Vec<Tunnel> = nodes
77            .iter()
78            .filter(|n| n.wings.len() >= 2)
79            .map(|n| Tunnel {
80                room: n.room.clone(),
81                wings: n.wings.clone(),
82                drawer_count: n.drawer_count,
83            })
84            .collect();
85
86        Ok((nodes, tunnels))
87    }
88
89    /// BFS from a starting room, up to `max_hops`.
90    pub async fn traverse(
91        &self,
92        start_room: &str,
93        max_hops: usize,
94    ) -> Result<Vec<TraversalNode>, GraphError> {
95        let (nodes, _) = self.build().await?;
96        let node_map: HashMap<&str, &RoomNode> =
97            nodes.iter().map(|n| (n.room.as_str(), n)).collect();
98
99        let Some(start) = node_map.get(start_room) else {
100            let suggestions: Vec<String> = nodes
101                .iter()
102                .filter(|n| n.room.contains(start_room) || start_room.contains(n.room.as_str()))
103                .take(5)
104                .map(|n| n.room.clone())
105                .collect();
106            return Ok(vec![TraversalNode {
107                room: format!("Room '{start_room}' not found. Suggestions: {suggestions:?}"),
108                wings: vec![],
109                drawer_count: 0,
110                hop: 0,
111                connected_via: vec![],
112            }]);
113        };
114
115        let mut visited: HashSet<&str> = HashSet::new();
116        visited.insert(start_room);
117
118        let mut results = vec![TraversalNode {
119            room: start.room.clone(),
120            wings: start.wings.clone(),
121            drawer_count: start.drawer_count,
122            hop: 0,
123            connected_via: vec![],
124        }];
125
126        let mut frontier: VecDeque<(&str, usize)> = VecDeque::new();
127        frontier.push_back((start_room, 0));
128
129        while let Some((current_room, depth)) = frontier.pop_front() {
130            if depth >= max_hops {
131                continue;
132            }
133            let current_wings: HashSet<&str> = node_map
134                .get(current_room)
135                .map(|n| n.wings.iter().map(|w| w.as_str()).collect())
136                .unwrap_or_default();
137
138            for (room, node) in &node_map {
139                if visited.contains(room) {
140                    continue;
141                }
142                let shared: Vec<String> = node
143                    .wings
144                    .iter()
145                    .filter(|w| current_wings.contains(w.as_str()))
146                    .cloned()
147                    .collect();
148                if !shared.is_empty() {
149                    visited.insert(room);
150                    results.push(TraversalNode {
151                        room: node.room.clone(),
152                        wings: node.wings.clone(),
153                        drawer_count: node.drawer_count,
154                        hop: depth + 1,
155                        connected_via: shared,
156                    });
157                    if depth + 1 < max_hops {
158                        frontier.push_back((room, depth + 1));
159                    }
160                }
161            }
162        }
163
164        results.sort_by(|a, b| a.hop.cmp(&b.hop).then(b.drawer_count.cmp(&a.drawer_count)));
165        results.truncate(50);
166        Ok(results)
167    }
168
169    /// Find rooms that bridge two wings.
170    pub async fn find_tunnels(
171        &self,
172        wing_a: Option<&str>,
173        wing_b: Option<&str>,
174    ) -> Result<Vec<Tunnel>, GraphError> {
175        let (_, mut tunnels) = self.build().await?;
176        if let Some(wa) = wing_a {
177            tunnels.retain(|t| t.wings.contains(&wa.to_string()));
178        }
179        if let Some(wb) = wing_b {
180            tunnels.retain(|t| t.wings.contains(&wb.to_string()));
181        }
182        tunnels.sort_by(|a, b| b.drawer_count.cmp(&a.drawer_count));
183        tunnels.truncate(50);
184        Ok(tunnels)
185    }
186
187    /// Graph summary statistics.
188    pub async fn stats(&self) -> Result<serde_json::Value, GraphError> {
189        let (nodes, tunnels) = self.build().await?;
190        let mut rooms_per_wing: HashMap<String, usize> = HashMap::new();
191        for node in &nodes {
192            for wing in &node.wings {
193                *rooms_per_wing.entry(wing.clone()).or_default() += 1;
194            }
195        }
196        let mut rpw: Vec<(String, usize)> = rooms_per_wing.into_iter().collect();
197        rpw.sort_by(|a, b| b.1.cmp(&a.1));
198
199        Ok(serde_json::json!({
200            "total_rooms": nodes.len(),
201            "tunnel_rooms": tunnels.len(),
202            "rooms_per_wing": rpw.into_iter().map(|(w, c)| serde_json::json!({"wing": w, "count": c})).collect::<Vec<_>>(),
203            "top_tunnels": tunnels.iter().take(10).map(|t| serde_json::json!({
204                "room": t.room, "wings": t.wings, "count": t.drawer_count,
205            })).collect::<Vec<_>>(),
206        }))
207    }
208}
209
210#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
211pub struct TraversalNode {
212    pub room: String,
213    pub wings: Vec<String>,
214    pub drawer_count: i64,
215    pub hop: usize,
216    pub connected_via: Vec<String>,
217}
218
219fn val_str(row: &turso::Row, idx: usize) -> String {
220    match row.get_value(idx) {
221        Ok(turso::Value::Text(s)) => s,
222        Ok(turso::Value::Null) | Err(_) => String::new(),
223        Ok(v) => format!("{v:?}"),
224    }
225}