poc-memory v0.4.0: graph-structured memory with consolidation pipeline
Rust core: - Cap'n Proto append-only storage (nodes + relations) - Graph algorithms: clustering coefficient, community detection, schema fit, small-world metrics, interference detection - BM25 text similarity with Porter stemming - Spaced repetition replay queue - Commands: search, init, health, status, graph, categorize, link-add, link-impact, decay, consolidate-session, etc. Python scripts: - Episodic digest pipeline: daily/weekly/monthly-digest.py - retroactive-digest.py for backfilling - consolidation-agents.py: 3 parallel Sonnet agents - apply-consolidation.py: structured action extraction + apply - digest-link-parser.py: extract ~400 explicit links from digests - content-promotion-agent.py: promote episodic obs to semantic files - bulk-categorize.py: categorize all nodes via single Sonnet call - consolidation-loop.py: multi-round automated consolidation Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
commit
23fac4e5fe
35 changed files with 9388 additions and 0 deletions
146
src/search.rs
Normal file
146
src/search.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// 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::capnp_store::Store;
|
||||
use crate::graph::Graph;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
pub struct SearchResult {
|
||||
pub key: String,
|
||||
pub activation: f64,
|
||||
pub is_direct: bool,
|
||||
pub snippet: Option<String>,
|
||||
}
|
||||
|
||||
/// 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: &Store,
|
||||
_circumscription: f64,
|
||||
) -> Vec<(String, f64)> {
|
||||
let params = &store.params;
|
||||
|
||||
let mut activation: HashMap<String, f64> = 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.nodes.get(neighbor.as_str())
|
||||
.map(|n| n.weight as f64)
|
||||
.unwrap_or(params.default_weight);
|
||||
|
||||
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.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
|
||||
results
|
||||
}
|
||||
|
||||
/// Full search: find direct hits, spread activation, return ranked results
|
||||
pub fn search(query: &str, store: &Store) -> Vec<SearchResult> {
|
||||
let graph = store.build_graph();
|
||||
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<String, String> = HashMap::new();
|
||||
|
||||
for (key, node) in &store.nodes {
|
||||
let content_lower = node.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 weight = node.weight as f64;
|
||||
let activation = if exact_match { weight } else { weight * 0.85 };
|
||||
seeds.push((key.clone(), activation));
|
||||
|
||||
let snippet: String = node.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::<Vec<_>>()
|
||||
.join("\n ");
|
||||
snippets.insert(key.clone(), snippet);
|
||||
}
|
||||
}
|
||||
|
||||
if seeds.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let direct_hits: HashSet<String> = 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::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue