// Spreading activation search across the memory graph // // Same model as the old system but richer: uses graph edge strengths, // supports circumscription parameter for blending associative vs // causal walks, and benefits from community-aware result grouping. use crate::store::StoreView; use crate::graph::Graph; use std::collections::{HashMap, HashSet, VecDeque}; pub struct SearchResult { pub key: String, pub activation: f64, pub is_direct: bool, pub snippet: Option, } /// Spreading activation with circumscription parameter. /// /// circ = 0.0: field mode — all edges (default, broad resonance) /// circ = 1.0: causal mode — prefer causal edges fn spreading_activation( seeds: &[(String, f64)], graph: &Graph, store: &impl StoreView, _circumscription: f64, ) -> Vec<(String, f64)> { let params = store.params(); let mut activation: HashMap = HashMap::new(); let mut queue: VecDeque<(String, f64, u32)> = VecDeque::new(); for (key, act) in seeds { let current = activation.entry(key.clone()).or_insert(0.0); if *act > *current { *current = *act; queue.push_back((key.clone(), *act, 0)); } } while let Some((key, act, depth)) = queue.pop_front() { if depth >= params.max_hops { continue; } for (neighbor, strength) in graph.neighbors(&key) { let neighbor_weight = store.node_weight(neighbor.as_str()); let propagated = act * params.edge_decay * neighbor_weight * strength as f64; if propagated < params.min_activation { continue; } let current = activation.entry(neighbor.clone()).or_insert(0.0); if propagated > *current { *current = propagated; queue.push_back((neighbor.clone(), propagated, depth + 1)); } } } let mut results: Vec<_> = activation.into_iter().collect(); results.sort_by(|a, b| b.1.total_cmp(&a.1)); results } /// Full search: find direct hits, spread activation, return ranked results pub fn search(query: &str, store: &impl StoreView) -> Vec { let graph = crate::graph::build_graph_fast(store); let query_lower = query.to_lowercase(); let query_tokens: Vec<&str> = query_lower.split_whitespace().collect(); let mut seeds: Vec<(String, f64)> = Vec::new(); let mut snippets: HashMap = HashMap::new(); store.for_each_node(|key, content, weight| { let content_lower = content.to_lowercase(); let exact_match = content_lower.contains(&query_lower); let token_match = query_tokens.len() > 1 && query_tokens.iter().all(|t| content_lower.contains(t)); if exact_match || token_match { let activation = if exact_match { weight as f64 } else { weight as f64 * 0.85 }; seeds.push((key.to_owned(), activation)); let snippet: String = content.lines() .filter(|l| { let ll = l.to_lowercase(); if exact_match && ll.contains(&query_lower) { return true; } query_tokens.iter().any(|t| ll.contains(t)) }) .take(3) .map(|l| { let t = l.trim(); if t.len() > 100 { let end = t.floor_char_boundary(97); format!("{}...", &t[..end]) } else { t.to_string() } }) .collect::>() .join("\n "); snippets.insert(key.to_owned(), snippet); } }); if seeds.is_empty() { return Vec::new(); } let direct_hits: HashSet = seeds.iter().map(|(k, _)| k.clone()).collect(); let raw_results = spreading_activation(&seeds, &graph, store, 0.0); raw_results.into_iter().map(|(key, activation)| { let is_direct = direct_hits.contains(&key); let snippet = snippets.get(&key).cloned(); SearchResult { key, activation, is_direct, snippet } }).collect() } /// Extract meaningful search terms from natural language. /// Strips common English stop words, returns up to max_terms words. pub fn extract_query_terms(text: &str, max_terms: usize) -> String { const STOP_WORDS: &[&str] = &[ "the", "a", "an", "is", "are", "was", "were", "do", "does", "did", "have", "has", "had", "will", "would", "could", "should", "can", "may", "might", "shall", "been", "being", "to", "of", "in", "for", "on", "with", "at", "by", "from", "as", "but", "or", "and", "not", "no", "if", "then", "than", "that", "this", "it", "its", "my", "your", "our", "we", "you", "i", "me", "he", "she", "they", "them", "what", "how", "why", "when", "where", "about", "just", "let", "want", "tell", "show", "think", "know", "see", "look", "make", "get", "go", "some", "any", "all", "very", "really", "also", "too", "so", "up", "out", "here", "there", ]; text.to_lowercase() .split(|c: char| !c.is_alphanumeric()) .filter(|w| !w.is_empty() && w.len() > 2 && !STOP_WORDS.contains(w)) .take(max_terms) .collect::>() .join(" ") } /// Format search results as text lines (for hook consumption). pub fn format_results(results: &[SearchResult]) -> String { let mut out = String::new(); for (i, r) in results.iter().enumerate().take(5) { let marker = if r.is_direct { "→" } else { " " }; out.push_str(&format!("{}{:2}. [{:.2}/{:.2}] {}", marker, i + 1, r.activation, r.activation, r.key)); out.push('\n'); if let Some(ref snippet) = r.snippet { out.push_str(&format!(" {}\n", snippet)); } } out }