diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 17e92d7..cd615fb 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -630,7 +630,7 @@ impl McpServer { }); tools::dream::execute(&self.storage, &self.cognitive, request.arguments).await } - "explore_connections" => tools::explore::execute(&self.storage, &self.cognitive, request.arguments).await, + "explore_connections" => tools::explore::execute(&self.storage, request.arguments).await, "predict" => tools::predict::execute(&self.storage, &self.cognitive, request.arguments).await, "restore" => tools::restore::execute(&self.storage, request.arguments).await, diff --git a/crates/vestige-mcp/src/tools/explore.rs b/crates/vestige-mcp/src/tools/explore.rs index 503bad5..bc56106 100644 --- a/crates/vestige-mcp/src/tools/explore.rs +++ b/crates/vestige-mcp/src/tools/explore.rs @@ -1,12 +1,30 @@ //! Explore connections tool — Graph exploration, chain building, bridge discovery. -//! v1.5.0: Wires MemoryChainBuilder + ActivationNetwork + HippocampalIndex. +//! v2.0.0: Rewritten to use SQLite storage directly (matching graph.rs pattern). +//! +//! Previously relied on in-memory CognitiveEngine data structures that were +//! never populated, causing all actions to return empty results. Now queries +//! the same SQLite connection data that `memory_graph` uses successfully. +use std::collections::{BinaryHeap, HashMap, HashSet}; +use std::cmp::Ordering; use std::sync::Arc; -use tokio::sync::Mutex; - -use crate::cognitive::CognitiveEngine; use vestige_core::Storage; +/// Maximum depth for chain building +const MAX_CHAIN_DEPTH: usize = 10; + +/// Maximum states to explore in BFS +const MAX_STATES_TO_EXPLORE: usize = 1000; + +/// Minimum connection strength to traverse +const MIN_CONNECTION_STRENGTH: f64 = 0.2; + +/// Depth to pull subgraph neighborhoods +const SUBGRAPH_DEPTH: u32 = 3; + +/// Max nodes per subgraph pull +const SUBGRAPH_MAX_NODES: usize = 200; + pub fn schema() -> serde_json::Value { serde_json::json!({ "type": "object", @@ -34,9 +52,134 @@ pub fn schema() -> serde_json::Value { }) } +/// Adjacency list edge: (target_id, strength, link_type) +type AdjEdge = (String, f64, String); + +/// Build a bidirectional adjacency list from ConnectionRecord edges. +fn build_adjacency(edges: &[vestige_core::ConnectionRecord]) -> HashMap> { + let mut adj: HashMap> = HashMap::new(); + for e in edges { + adj.entry(e.source_id.clone()) + .or_default() + .push((e.target_id.clone(), e.strength, e.link_type.clone())); + adj.entry(e.target_id.clone()) + .or_default() + .push((e.source_id.clone(), e.strength, e.link_type.clone())); + } + adj +} + +/// State for BFS priority queue (best-first by score). +#[derive(Debug, Clone)] +struct SearchState { + node_id: String, + path: Vec, + /// (from, to, strength, link_type) for each hop + hops: Vec<(String, String, f64, String)>, + score: f64, + depth: usize, +} + +impl PartialEq for SearchState { + fn eq(&self, other: &Self) -> bool { + self.score == other.score + } +} +impl Eq for SearchState {} + +impl PartialOrd for SearchState { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SearchState { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .partial_cmp(&other.score) + .unwrap_or(Ordering::Equal) + } +} + +/// Find best paths from `from_id` to `to_id` using best-first search on the adjacency list. +fn bfs_find_paths( + adj: &HashMap>, + from_id: &str, + to_id: &str, + max_paths: usize, +) -> Vec { + let mut found: Vec = Vec::new(); + let mut queue = BinaryHeap::new(); + let mut explored: usize = 0; + + queue.push(SearchState { + node_id: from_id.to_string(), + path: vec![from_id.to_string()], + hops: vec![], + score: 1.0, + depth: 0, + }); + + while let Some(state) = queue.pop() { + explored += 1; + if explored > MAX_STATES_TO_EXPLORE { + break; + } + + // Reached target + if state.node_id == to_id { + found.push(state); + if found.len() >= max_paths { + break; + } + continue; + } + + if state.depth >= MAX_CHAIN_DEPTH { + continue; + } + + if let Some(neighbors) = adj.get(&state.node_id) { + for (target, strength, link_type) in neighbors { + if *strength < MIN_CONNECTION_STRENGTH { + continue; + } + // Avoid cycles + if state.path.contains(target) { + continue; + } + + let mut new_path = state.path.clone(); + new_path.push(target.clone()); + + let mut new_hops = state.hops.clone(); + new_hops.push(( + state.node_id.clone(), + target.clone(), + *strength, + link_type.clone(), + )); + + let new_score = state.score * strength * 0.9; + + queue.push(SearchState { + node_id: target.clone(), + path: new_path, + hops: new_hops, + score: new_score, + depth: state.depth + 1, + }); + } + } + } + + // Sort by score descending + found.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal)); + found +} + pub async fn execute( - _storage: &Arc, - cognitive: &Arc>, + storage: &Arc, args: Option, ) -> Result { let args = args.ok_or("Missing arguments")?; @@ -45,84 +188,190 @@ pub async fn execute( let to = args.get("to").and_then(|v| v.as_str()); let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; - let cog = cognitive.lock().await; - match action { - "chain" => { - let to_id = to.ok_or("'to' is required for chain action")?; - match cog.chain_builder.build_chain(from, to_id) { - Some(chain) => { - Ok(serde_json::json!({ - "action": "chain", - "from": from, - "to": to_id, - "steps": chain.steps.iter().map(|s| serde_json::json!({ - "memory_id": s.memory_id, - "memory_preview": s.memory_preview, - "connection_type": format!("{:?}", s.connection_type), - "connection_strength": s.connection_strength, - "reasoning": s.reasoning, - })).collect::>(), - "confidence": chain.confidence, - "total_hops": chain.total_hops, - })) - } - None => { - Ok(serde_json::json!({ - "action": "chain", - "from": from, - "to": to_id, - "steps": [], - "message": "No chain found between these memories" - })) - } - } - } "associations" => { - let activation_assocs = cog.activation_network.get_associations(from); - let hippocampal_assocs = cog.hippocampal_index.get_associations(from, 2) - .unwrap_or_default(); + // Pull depth-1 neighborhood from SQLite + let (_nodes, edges) = storage + .get_memory_subgraph(from, 1, limit.max(50)) + .map_err(|e| format!("Failed to get subgraph: {}", e))?; - let mut all_associations: Vec = Vec::new(); + // Filter edges connected to `from` + let mut associations: Vec = Vec::new(); + for e in &edges { + let (neighbor_id, direction) = if e.source_id == from { + (&e.target_id, "outgoing") + } else if e.target_id == from { + (&e.source_id, "incoming") + } else { + continue; + }; - for assoc in activation_assocs.iter().take(limit) { - all_associations.push(serde_json::json!({ - "memory_id": assoc.memory_id, - "strength": assoc.association_strength, - "link_type": format!("{:?}", assoc.link_type), - "source": "spreading_activation", - })); - } - for m in hippocampal_assocs.iter().take(limit) { - all_associations.push(serde_json::json!({ - "memory_id": m.index.memory_id, - "semantic_score": m.semantic_score, - "text_score": m.text_score, - "source": "hippocampal_index", + associations.push(serde_json::json!({ + "memory_id": neighbor_id, + "strength": e.strength, + "link_type": &e.link_type, + "direction": direction, + "source": "sqlite", })); } - all_associations.truncate(limit); + // Sort by strength descending, then truncate + associations.sort_by(|a, b| { + let sa = a["strength"].as_f64().unwrap_or(0.0); + let sb = b["strength"].as_f64().unwrap_or(0.0); + sb.partial_cmp(&sa).unwrap_or(Ordering::Equal) + }); + associations.truncate(limit); Ok(serde_json::json!({ "action": "associations", "from": from, - "associations": all_associations, - "count": all_associations.len(), + "associations": associations, + "count": associations.len(), })) } + + "chain" => { + let to_id = to.ok_or("'to' is required for chain action")?; + + // Pull neighborhoods around both endpoints and merge + let (nodes_from, edges_from) = storage + .get_memory_subgraph(from, SUBGRAPH_DEPTH, SUBGRAPH_MAX_NODES) + .map_err(|e| format!("Failed to get subgraph for 'from': {}", e))?; + + let mut all_edges = edges_from; + let mut all_node_ids: HashSet = nodes_from.iter().map(|n| n.id.clone()).collect(); + + // If target isn't in the from-neighborhood, also pull its neighborhood + if !all_node_ids.contains(to_id) { + let (_nodes_to, edges_to) = storage + .get_memory_subgraph(to_id, SUBGRAPH_DEPTH, SUBGRAPH_MAX_NODES) + .map_err(|e| format!("Failed to get subgraph for 'to': {}", e))?; + + // Merge edges (dedup by source+target) + let existing: HashSet<(String, String)> = all_edges + .iter() + .map(|e| (e.source_id.clone(), e.target_id.clone())) + .collect(); + for e in edges_to { + if !existing.contains(&(e.source_id.clone(), e.target_id.clone())) { + all_edges.push(e); + } + } + // Update node set + for n in &_nodes_to { + all_node_ids.insert(n.id.clone()); + } + } + + let adj = build_adjacency(&all_edges); + let paths = bfs_find_paths(&adj, from, to_id, 5); + + if let Some(best) = paths.first() { + // Build steps from the best path's hops + let steps: Vec = best.hops.iter().map(|(f, t, s, lt)| { + serde_json::json!({ + "from": f, + "to": t, + "connection_strength": s, + "link_type": lt, + }) + }).collect(); + + // Confidence = geometric mean of hop strengths + let confidence = if best.hops.is_empty() { + 0.0 + } else { + let product: f64 = best.hops.iter().map(|(_, _, s, _)| s).product(); + product.powf(1.0 / best.hops.len() as f64) + }; + + Ok(serde_json::json!({ + "action": "chain", + "from": from, + "to": to_id, + "path": best.path, + "steps": steps, + "confidence": (confidence * 1000.0).round() / 1000.0, + "total_hops": best.hops.len(), + "paths_found": paths.len(), + })) + } else { + Ok(serde_json::json!({ + "action": "chain", + "from": from, + "to": to_id, + "path": [], + "steps": [], + "message": "No chain found between these memories", + "paths_found": 0, + })) + } + } + "bridges" => { let to_id = to.ok_or("'to' is required for bridges action")?; - let bridges = cog.chain_builder.find_bridge_memories(from, to_id); - let limited: Vec<_> = bridges.iter().take(limit).collect(); + + // Reuse chain logic — find multiple paths, extract intermediates + let (nodes_from, edges_from) = storage + .get_memory_subgraph(from, SUBGRAPH_DEPTH, SUBGRAPH_MAX_NODES) + .map_err(|e| format!("Failed to get subgraph for 'from': {}", e))?; + + let mut all_edges = edges_from; + let all_node_ids: HashSet = nodes_from.iter().map(|n| n.id.clone()).collect(); + + if !all_node_ids.contains(to_id) { + let (_nodes_to, edges_to) = storage + .get_memory_subgraph(to_id, SUBGRAPH_DEPTH, SUBGRAPH_MAX_NODES) + .map_err(|e| format!("Failed to get subgraph for 'to': {}", e))?; + + let existing: HashSet<(String, String)> = all_edges + .iter() + .map(|e| (e.source_id.clone(), e.target_id.clone())) + .collect(); + for e in edges_to { + if !existing.contains(&(e.source_id.clone(), e.target_id.clone())) { + all_edges.push(e); + } + } + } + + let adj = build_adjacency(&all_edges); + let paths = bfs_find_paths(&adj, from, to_id, 10); + + // Count frequency of intermediate nodes across all paths + let mut bridge_counts: HashMap = HashMap::new(); + for path in &paths { + if path.path.len() > 2 { + // Skip first (from) and last (to) — intermediates only + for node_id in &path.path[1..path.path.len() - 1] { + *bridge_counts.entry(node_id.clone()).or_insert(0) += 1; + } + } + } + + // Sort by frequency descending + let mut bridge_list: Vec<_> = bridge_counts.into_iter().collect(); + bridge_list.sort_by(|a, b| b.1.cmp(&a.1)); + bridge_list.truncate(limit); + + let bridges: Vec = bridge_list.iter().map(|(id, count)| { + serde_json::json!({ + "memory_id": id, + "frequency": count, + "paths_total": paths.len(), + }) + }).collect(); + Ok(serde_json::json!({ "action": "bridges", "from": from, "to": to_id, - "bridges": limited, - "count": limited.len(), + "bridges": bridges, + "count": bridges.len(), })) } + _ => Err(format!("Unknown action: '{}'. Expected: chain, associations, bridges", action)), } } @@ -130,19 +379,45 @@ pub async fn execute( #[cfg(test)] mod tests { use super::*; - use crate::cognitive::CognitiveEngine; use tempfile::TempDir; - fn test_cognitive() -> Arc> { - Arc::new(Mutex::new(CognitiveEngine::new())) - } - async fn test_storage() -> (Arc, TempDir) { let dir = TempDir::new().unwrap(); let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap(); (Arc::new(storage), dir) } + /// Helper: ingest a memory and return its ID. + fn ingest_memory(storage: &Storage, content: &str) -> String { + let node = storage.ingest(vestige_core::IngestInput { + content: content.to_string(), + node_type: "fact".to_string(), + source: None, + sentiment_score: 0.0, + sentiment_magnitude: 0.0, + tags: vec!["test".to_string()], + valid_from: None, + valid_until: None, + }).unwrap(); + node.id + } + + /// Helper: save a connection between two memories. + fn connect(storage: &Storage, from: &str, to: &str, strength: f64, link_type: &str) { + use chrono::Utc; + storage.save_connection(&vestige_core::ConnectionRecord { + source_id: from.to_string(), + target_id: to.to_string(), + strength, + link_type: link_type.to_string(), + created_at: Utc::now(), + last_activated: Utc::now(), + activation_count: 1, + }).unwrap(); + } + + // ---- Schema tests ---- + #[test] fn test_schema_has_required_fields() { let s = schema(); @@ -165,10 +440,12 @@ mod tests { assert!(action_enum.contains(&serde_json::json!("bridges"))); } + // ---- Validation tests ---- + #[tokio::test] async fn test_missing_args_fails() { let (storage, _dir) = test_storage().await; - let result = execute(&storage, &test_cognitive(), None).await; + let result = execute(&storage, None).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing arguments")); } @@ -177,7 +454,7 @@ mod tests { async fn test_missing_action_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "from": "some-id" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing 'action'")); } @@ -186,7 +463,7 @@ mod tests { async fn test_missing_from_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "action": "associations" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing 'from'")); } @@ -195,7 +472,7 @@ mod tests { async fn test_unknown_action_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "action": "invalid", "from": "id1" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Unknown action")); } @@ -204,7 +481,7 @@ mod tests { async fn test_chain_missing_to_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "action": "chain", "from": "id1" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("'to' is required")); } @@ -213,11 +490,13 @@ mod tests { async fn test_bridges_missing_to_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "action": "bridges", "from": "id1" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("'to' is required")); } + // ---- Empty results tests (backwards compat) ---- + #[tokio::test] async fn test_associations_succeeds_empty() { let (storage, _dir) = test_storage().await; @@ -225,7 +504,7 @@ mod tests { "action": "associations", "from": "00000000-0000-0000-0000-000000000000" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["action"], "associations"); @@ -241,7 +520,7 @@ mod tests { "from": "00000000-0000-0000-0000-000000000001", "to": "00000000-0000-0000-0000-000000000002" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["action"], "chain"); @@ -256,22 +535,223 @@ mod tests { "from": "00000000-0000-0000-0000-000000000001", "to": "00000000-0000-0000-0000-000000000002" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["action"], "bridges"); assert_eq!(value["count"], 0); } + // ---- Integration tests with actual connections ---- + #[tokio::test] - async fn test_associations_with_limit() { + async fn test_associations_returns_neighbors() { let (storage, _dir) = test_storage().await; + + let id_a = ingest_memory(&storage, "Memory A about databases"); + let id_b = ingest_memory(&storage, "Memory B about indexing"); + let id_c = ingest_memory(&storage, "Memory C about queries"); + + connect(&storage, &id_a, &id_b, 0.8, "semantic"); + connect(&storage, &id_a, &id_c, 0.6, "temporal"); + let args = serde_json::json!({ "action": "associations", - "from": "00000000-0000-0000-0000-000000000000", - "limit": 5 + "from": &id_a, }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, Some(args)).await; assert!(result.is_ok()); + let value = result.unwrap(); + assert_eq!(value["action"], "associations"); + let count = value["count"].as_u64().unwrap(); + assert!(count >= 2, "Expected at least 2 associations, got {}", count); + + let assocs = value["associations"].as_array().unwrap(); + let neighbor_ids: Vec<&str> = assocs.iter() + .map(|a| a["memory_id"].as_str().unwrap()) + .collect(); + assert!(neighbor_ids.contains(&id_b.as_str())); + assert!(neighbor_ids.contains(&id_c.as_str())); + } + + #[tokio::test] + async fn test_chain_finds_path() { + let (storage, _dir) = test_storage().await; + + let id_a = ingest_memory(&storage, "Start node"); + let id_b = ingest_memory(&storage, "Middle node"); + let id_c = ingest_memory(&storage, "End node"); + + connect(&storage, &id_a, &id_b, 0.9, "causal"); + connect(&storage, &id_b, &id_c, 0.85, "causal"); + + let args = serde_json::json!({ + "action": "chain", + "from": &id_a, + "to": &id_c, + }); + let result = execute(&storage, Some(args)).await; + assert!(result.is_ok()); + let value = result.unwrap(); + assert_eq!(value["action"], "chain"); + let hops = value["total_hops"].as_u64().unwrap(); + assert!(hops >= 2, "Expected at least 2 hops, got {}", hops); + assert!(value["confidence"].as_f64().unwrap() > 0.0); + + let path = value["path"].as_array().unwrap(); + assert_eq!(path.first().unwrap().as_str().unwrap(), id_a); + assert_eq!(path.last().unwrap().as_str().unwrap(), id_c); + } + + #[tokio::test] + async fn test_bridges_finds_intermediate_nodes() { + let (storage, _dir) = test_storage().await; + + let id_a = ingest_memory(&storage, "Node A"); + let id_bridge = ingest_memory(&storage, "Bridge node"); + let id_c = ingest_memory(&storage, "Node C"); + + connect(&storage, &id_a, &id_bridge, 0.9, "semantic"); + connect(&storage, &id_bridge, &id_c, 0.8, "semantic"); + + let args = serde_json::json!({ + "action": "bridges", + "from": &id_a, + "to": &id_c, + }); + let result = execute(&storage, Some(args)).await; + assert!(result.is_ok()); + let value = result.unwrap(); + assert_eq!(value["action"], "bridges"); + let count = value["count"].as_u64().unwrap(); + assert!(count >= 1, "Expected at least 1 bridge, got {}", count); + + let bridges = value["bridges"].as_array().unwrap(); + let bridge_ids: Vec<&str> = bridges.iter() + .map(|b| b["memory_id"].as_str().unwrap()) + .collect(); + assert!(bridge_ids.contains(&id_bridge.as_str())); + } + + #[tokio::test] + async fn test_associations_respects_limit() { + let (storage, _dir) = test_storage().await; + + let id_center = ingest_memory(&storage, "Center node"); + for i in 0..10 { + let neighbor = ingest_memory(&storage, &format!("Neighbor {}", i)); + connect(&storage, &id_center, &neighbor, 0.5 + (i as f64 * 0.03), "semantic"); + } + + let args = serde_json::json!({ + "action": "associations", + "from": &id_center, + "limit": 3, + }); + let result = execute(&storage, Some(args)).await; + assert!(result.is_ok()); + let value = result.unwrap(); + let count = value["count"].as_u64().unwrap(); + assert!(count <= 3, "Expected at most 3, got {}", count); + } + + // ---- Unit tests for internal helpers ---- + + #[test] + fn test_build_adjacency_bidirectional() { + use chrono::Utc; + let edges = vec![ + vestige_core::ConnectionRecord { + source_id: "a".to_string(), + target_id: "b".to_string(), + strength: 0.8, + link_type: "semantic".to_string(), + created_at: Utc::now(), + last_activated: Utc::now(), + activation_count: 1, + }, + ]; + let adj = build_adjacency(&edges); + assert!(adj.contains_key("a")); + assert!(adj.contains_key("b")); + assert_eq!(adj["a"].len(), 1); + assert_eq!(adj["b"].len(), 1); + assert_eq!(adj["a"][0].0, "b"); + assert_eq!(adj["b"][0].0, "a"); + } + + #[test] + fn test_bfs_finds_direct_path() { + use chrono::Utc; + let edges = vec![ + vestige_core::ConnectionRecord { + source_id: "a".to_string(), + target_id: "b".to_string(), + strength: 0.9, + link_type: "test".to_string(), + created_at: Utc::now(), + last_activated: Utc::now(), + activation_count: 1, + }, + ]; + let adj = build_adjacency(&edges); + let paths = bfs_find_paths(&adj, "a", "b", 5); + assert!(!paths.is_empty()); + assert_eq!(paths[0].path, vec!["a", "b"]); + } + + #[test] + fn test_bfs_finds_multi_hop_path() { + use chrono::Utc; + let edges = vec![ + vestige_core::ConnectionRecord { + source_id: "a".to_string(), + target_id: "b".to_string(), + strength: 0.9, + link_type: "test".to_string(), + created_at: Utc::now(), + last_activated: Utc::now(), + activation_count: 1, + }, + vestige_core::ConnectionRecord { + source_id: "b".to_string(), + target_id: "c".to_string(), + strength: 0.8, + link_type: "test".to_string(), + created_at: Utc::now(), + last_activated: Utc::now(), + activation_count: 1, + }, + ]; + let adj = build_adjacency(&edges); + let paths = bfs_find_paths(&adj, "a", "c", 5); + assert!(!paths.is_empty()); + assert_eq!(paths[0].path, vec!["a", "b", "c"]); + } + + #[test] + fn test_bfs_skips_weak_connections() { + use chrono::Utc; + let edges = vec![ + vestige_core::ConnectionRecord { + source_id: "a".to_string(), + target_id: "b".to_string(), + strength: 0.1, // Below MIN_CONNECTION_STRENGTH + link_type: "test".to_string(), + created_at: Utc::now(), + last_activated: Utc::now(), + activation_count: 1, + }, + ]; + let adj = build_adjacency(&edges); + let paths = bfs_find_paths(&adj, "a", "b", 5); + assert!(paths.is_empty(), "Should not traverse weak connections"); + } + + #[test] + fn test_bfs_no_path() { + let adj: HashMap> = HashMap::new(); + let paths = bfs_find_paths(&adj, "a", "b", 5); + assert!(paths.is_empty()); } }