Skip to main content

csm_memory/
graph_traversal.rs

1//! Graph traversal operations on the association graph.
2//!
3//! Provides BFS, shortest path, and neighbor queries on the concept association graph.
4//!
5//! # Shortest Path
6//!
7//! Two variants are provided:
8//! - [`Singularity::shortest_path`]: Weighted Dijkstra using `-ln(strength)` as edge cost.
9//!   Prefers paths through stronger associations. Returns the minimum-cost path.
10//! - [`Singularity::shortest_path_hops`]: Unweighted BFS. Returns the fewest-hop path
11//!   regardless of edge strength. Use when hop count matters more than association strength.
12
13use std::cmp::Reverse;
14use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
15
16use crate::singularity::Singularity;
17use csm_core_lib::error::{MemoryError, Result};
18
19/// Maximum traversal depth to prevent excessive resource usage.
20const MAX_TRAVERSAL_DEPTH: usize = 32;
21/// Maximum traversal results to prevent memory exhaustion.
22const MAX_TRAVERSAL_RESULTS: usize = 10_000;
23
24/// Configuration for graph traversal operations.
25#[derive(Debug, Clone)]
26pub struct TraversalConfig {
27    /// Maximum number of hops to traverse.
28    pub max_depth: usize,
29    /// Minimum edge strength to follow.
30    pub min_strength: f32,
31    /// Maximum number of nodes to visit.
32    pub max_results: usize,
33}
34
35impl Default for TraversalConfig {
36    fn default() -> Self {
37        Self {
38            max_depth: 3,
39            min_strength: 0.0,
40            max_results: 100,
41        }
42    }
43}
44
45impl TraversalConfig {
46    /// Validate traversal config parameters.
47    pub fn validate(&self) -> Result<()> {
48        if self.max_depth > MAX_TRAVERSAL_DEPTH {
49            return Err(MemoryError::InvalidInput {
50                field: "max_depth".to_string(),
51                reason: format!(
52                    "traversal depth exceeds {} (got {})",
53                    MAX_TRAVERSAL_DEPTH, self.max_depth
54                ),
55            });
56        }
57        if self.max_results > MAX_TRAVERSAL_RESULTS {
58            return Err(MemoryError::InvalidInput {
59                field: "max_results".to_string(),
60                reason: format!(
61                    "traversal results exceed {} (got {})",
62                    MAX_TRAVERSAL_RESULTS, self.max_results
63                ),
64            });
65        }
66        Ok(())
67    }
68}
69
70impl Singularity {
71    /// Get direct neighbors of a concept with edge strengths.
72    ///
73    /// Returns outbound associations with strength >= `min_strength`.
74    pub fn neighbors(&self, ns: &str, id: &str, min_strength: f32) -> Vec<(String, f32)> {
75        self.get_associations(ns, id)
76            .into_iter()
77            .filter(|(_, strength)| *strength >= min_strength)
78            .collect()
79    }
80
81    /// Get incoming associations for a concept.
82    ///
83    /// Returns concepts that have associations pointing to this concept.
84    /// Breadth-first traversal from a starting concept.
85    ///
86    /// Returns nodes reachable within `config.max_depth` hops, along with their depths.
87    /// Nodes are returned in BFS order.
88    pub fn bfs(
89        &self,
90        ns: &str,
91        start: &str,
92        config: &TraversalConfig,
93    ) -> Result<Vec<(String, u32)>> {
94        config.validate()?;
95        let ns_state = self
96            .get_namespace(ns)
97            .ok_or_else(|| MemoryError::NotFound {
98                entity: "Namespace".to_string(),
99                id: ns.to_string(),
100            })?;
101        if !ns_state.concepts.contains_key(start) {
102            return Err(MemoryError::NotFound {
103                entity: "Concept".to_string(),
104                id: start.to_string(),
105            });
106        }
107
108        let mut visited: HashSet<String> = HashSet::new();
109        let mut results: Vec<(String, u32)> = Vec::new();
110        let mut queue: VecDeque<(String, u32)> = VecDeque::new();
111
112        visited.insert(start.to_string());
113        queue.push_back((start.to_string(), 0));
114
115        while let Some((current, depth)) = queue.pop_front() {
116            if results.len() >= config.max_results {
117                break;
118            }
119
120            results.push((current.clone(), depth));
121
122            if depth as usize >= config.max_depth {
123                continue;
124            }
125
126            let neighbors = self.neighbors(ns, &current, config.min_strength);
127            for (neighbor, _) in neighbors {
128                if visited.insert(neighbor.clone()) {
129                    queue.push_back((neighbor, depth + 1));
130                }
131            }
132        }
133
134        Ok(results)
135    }
136
137    /// Find the minimum-cost path between two concepts using weighted Dijkstra.
138    ///
139    /// Edge cost is `-ln(strength)`, so stronger associations have lower cost.
140    /// A strength of `1.0` has cost `0.0`; a strength of `0.1` has cost `~2.3`.
141    /// Strength values ≤ 0 are treated as cost `f32::MAX` (effectively unreachable).
142    ///
143    /// Returns `None` if no path exists within `config.max_depth` hops.
144    /// Use [`Self::shortest_path_hops`] for unweighted (fewest-hop) traversal.
145    pub fn shortest_path(
146        &self,
147        ns: &str,
148        from: &str,
149        to: &str,
150        config: &TraversalConfig,
151    ) -> Result<Option<Vec<String>>> {
152        config.validate()?;
153        let ns_state = self
154            .get_namespace(ns)
155            .ok_or_else(|| MemoryError::NotFound {
156                entity: "Namespace".to_string(),
157                id: ns.to_string(),
158            })?;
159        if !ns_state.concepts.contains_key(from) {
160            return Err(MemoryError::NotFound {
161                entity: "Concept".to_string(),
162                id: from.to_string(),
163            });
164        }
165        if !ns_state.concepts.contains_key(to) {
166            return Err(MemoryError::NotFound {
167                entity: "Concept".to_string(),
168                id: to.to_string(),
169            });
170        }
171
172        if from == to {
173            return Ok(Some(vec![from.to_string()]));
174        }
175
176        // Dijkstra: min-heap of (cost_bits, depth, node_id)
177        // We store cost as ordered bits via f32::to_bits for BinaryHeap<Reverse<...>>.
178        let mut dist: HashMap<String, f32> = HashMap::new();
179        let mut parent: HashMap<String, String> = HashMap::new();
180        // BinaryHeap is a max-heap; Reverse makes it a min-heap.
181        let mut heap: BinaryHeap<Reverse<(u32, u32, String)>> = BinaryHeap::new();
182
183        dist.insert(from.to_string(), 0.0);
184        heap.push(Reverse((0u32, 0u32, from.to_string())));
185
186        while let Some(Reverse((cost_bits, depth, current))) = heap.pop() {
187            if current == to {
188                // Reconstruct path
189                let mut path = vec![to.to_string()];
190                let mut node = to;
191                while let Some(p) = parent.get(node) {
192                    path.push(p.clone());
193                    node = p;
194                    if node == from {
195                        break;
196                    }
197                }
198                path.reverse();
199                return Ok(Some(path));
200            }
201
202            let current_cost = f32::from_bits(cost_bits);
203            if let Some(&best) = dist.get(&current) {
204                if current_cost > best {
205                    continue; // Stale entry
206                }
207            }
208
209            if depth as usize >= config.max_depth {
210                continue;
211            }
212
213            let neighbors = self.neighbors(ns, &current, config.min_strength);
214            for (neighbor, strength) in neighbors {
215                // Cost: -ln(strength), guarding against strength <= 0
216                let edge_cost = if strength > 0.0 {
217                    -strength.ln()
218                } else {
219                    f32::MAX / 2.0
220                };
221                let new_cost = current_cost + edge_cost;
222                let best = dist.get(&neighbor).copied().unwrap_or(f32::MAX);
223                if new_cost < best {
224                    dist.insert(neighbor.clone(), new_cost);
225                    parent.insert(neighbor.clone(), current.clone());
226                    heap.push(Reverse((new_cost.to_bits(), depth + 1, neighbor)));
227                }
228            }
229        }
230
231        Ok(None)
232    }
233
234    /// Find the fewest-hop path between two concepts using unweighted BFS.
235    ///
236    /// Returns the path with the minimum number of hops, ignoring edge strengths.
237    /// Use [`Self::shortest_path`] for strength-weighted (Dijkstra) traversal.
238    ///
239    /// Returns `None` if no path exists within `config.max_depth` hops.
240    pub fn shortest_path_hops(
241        &self,
242        ns: &str,
243        from: &str,
244        to: &str,
245        config: &TraversalConfig,
246    ) -> Result<Option<Vec<String>>> {
247        config.validate()?;
248        let ns_state = self
249            .get_namespace(ns)
250            .ok_or_else(|| MemoryError::NotFound {
251                entity: "Namespace".to_string(),
252                id: ns.to_string(),
253            })?;
254        if !ns_state.concepts.contains_key(from) {
255            return Err(MemoryError::NotFound {
256                entity: "Concept".to_string(),
257                id: from.to_string(),
258            });
259        }
260        if !ns_state.concepts.contains_key(to) {
261            return Err(MemoryError::NotFound {
262                entity: "Concept".to_string(),
263                id: to.to_string(),
264            });
265        }
266
267        if from == to {
268            return Ok(Some(vec![from.to_string()]));
269        }
270
271        let mut visited: HashSet<String> = HashSet::new();
272        let mut parent: HashMap<String, String> = HashMap::new();
273        let mut queue: VecDeque<(String, u32)> = VecDeque::new();
274
275        visited.insert(from.to_string());
276        queue.push_back((from.to_string(), 0));
277
278        while let Some((current, depth)) = queue.pop_front() {
279            if depth as usize >= config.max_depth {
280                continue;
281            }
282
283            let neighbors = self.neighbors(ns, &current, config.min_strength);
284            for (neighbor, _) in neighbors {
285                if visited.insert(neighbor.clone()) {
286                    parent.insert(neighbor.clone(), current.clone());
287                    if neighbor == to {
288                        // Reconstruct path
289                        let mut path = vec![to.to_string()];
290                        let mut node = to;
291                        while let Some(p) = parent.get(node) {
292                            path.push(p.clone());
293                            node = p;
294                            if node == from {
295                                break;
296                            }
297                        }
298                        path.reverse();
299                        return Ok(Some(path));
300                    }
301                    queue.push_back((neighbor, depth + 1));
302                }
303            }
304        }
305
306        Ok(None)
307    }
308}
309
310#[cfg(test)]
311#[path = "graph_traversal_tests.rs"]
312mod tests;