use std::collections::HashSet;
use rusqlite::Connection;
use kimetsu_core::KimetsuResult;
use crate::context::{Candidate, ContextCapsule, ProvenanceRef, QueryEmbedding};
pub(crate) trait RetrievalBackend {
fn memory_candidates(
&self,
conn: &Connection,
query: &str,
query_embedding: Option<&QueryEmbedding>,
half_life_days: f32,
) -> KimetsuResult<Vec<Candidate>>;
}
pub(crate) struct FlatBackend;
impl RetrievalBackend for FlatBackend {
fn memory_candidates(
&self,
conn: &Connection,
query: &str,
query_embedding: Option<&QueryEmbedding>,
half_life_days: f32,
) -> KimetsuResult<Vec<Candidate>> {
crate::context::memory_candidates_flat(conn, query, query_embedding, half_life_days)
}
}
pub(crate) struct GraphLiteBackend;
const MAX_HOPS: usize = 2;
const MAX_FAN_OUT: usize = 12;
const HOP_DECAY: f32 = 0.6;
impl RetrievalBackend for GraphLiteBackend {
fn memory_candidates(
&self,
conn: &Connection,
query: &str,
query_embedding: Option<&QueryEmbedding>,
half_life_days: f32,
) -> KimetsuResult<Vec<Candidate>> {
let flat =
crate::context::memory_candidates_flat(conn, query, query_embedding, half_life_days)?;
let mut seen_ids: HashSet<String> = flat
.iter()
.filter_map(|c| {
c.capsule
.expansion_handle
.strip_prefix("memory:")
.map(|id| id.to_string())
})
.collect();
if seen_ids.is_empty() {
return Ok(flat);
}
let max_flat_relevance = flat.iter().map(|c| c.raw_relevance).fold(0.0_f32, f32::max);
let new_ids = graph_expand(conn, &seen_ids, MAX_HOPS, MAX_FAN_OUT)?;
if new_ids.is_empty() {
return Ok(flat);
}
let graph_candidates =
fetch_graph_candidates(conn, &new_ids, &mut seen_ids, max_flat_relevance)?;
let mut combined = flat;
combined.extend(graph_candidates);
Ok(combined)
}
}
fn graph_expand(
conn: &Connection,
seed_ids: &HashSet<String>,
max_hops: usize,
max_fan_out: usize,
) -> KimetsuResult<Vec<(String, usize)>> {
if seed_ids.is_empty() || max_hops == 0 {
return Ok(Vec::new());
}
let mut visited: HashSet<String> = seed_ids.clone();
let mut frontier: Vec<String> = seed_ids.iter().cloned().collect();
let mut new_ids: Vec<(String, usize)> = Vec::new();
for hop_idx in 0..max_hops {
if frontier.is_empty() {
break;
}
if new_ids.len() >= max_fan_out {
break;
}
let n = frontier.len();
let src_placeholders: String = (1..=n)
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let dst_placeholders: String = (n + 1..=2 * n)
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"
SELECT DISTINCT neighbour FROM (
SELECT dst_id AS neighbour FROM memory_edges
WHERE src_id IN ({src_placeholders})
UNION
SELECT src_id AS neighbour FROM memory_edges
WHERE dst_id IN ({dst_placeholders})
)
"
);
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::ToSql> = frontier
.iter()
.chain(frontier.iter())
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?;
let mut next_frontier: Vec<String> = Vec::new();
for row in rows {
let neighbour = row?;
if !visited.contains(&neighbour) {
visited.insert(neighbour.clone());
next_frontier.push(neighbour.clone());
new_ids.push((neighbour, hop_idx + 1));
if new_ids.len() >= max_fan_out {
break;
}
}
}
frontier = next_frontier;
}
Ok(new_ids)
}
fn fetch_graph_candidates(
conn: &Connection,
new_ids: &[(String, usize)],
seen_ids: &mut HashSet<String>,
seed_relevance: f32,
) -> KimetsuResult<Vec<Candidate>> {
if new_ids.is_empty() {
return Ok(Vec::new());
}
let placeholders: String = (1..=new_ids.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT memory_id, scope, kind, text, confidence, created_at
FROM memories
WHERE invalidated_at IS NULL
AND superseded_by IS NULL
AND (valid_to IS NULL OR valid_to > datetime('now'))
AND memory_id IN ({placeholders})"
);
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::ToSql> = new_ids
.iter()
.map(|(s, _)| s as &dyn rusqlite::ToSql)
.collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, f32>(4)?,
row.get::<_, String>(5)?,
))
})?;
let mut candidates = Vec::new();
for row in rows {
let (memory_id, scope, kind, text, confidence, created_at) = row?;
if !seen_ids.insert(memory_id.clone()) {
continue;
}
let hop = new_ids
.iter()
.find(|(id, _)| id == &memory_id)
.map(|(_, h)| *h)
.unwrap_or(1);
let raw_relevance = seed_relevance * HOP_DECAY.powi(hop as i32);
let freshness = crate::context::freshness_pub(&created_at);
let scope_weight = crate::context::scope_weight_pub(&scope);
let token_estimate = crate::context::estimate_tokens(&text) + 8;
candidates.push(Candidate {
raw_relevance,
embedding: None,
cosine: None,
capsule: ContextCapsule {
id: kimetsu_core::ids::new_id().to_string(),
kind: "memory".to_string(),
summary: format!("{scope}:{kind} - {text}"),
token_estimate,
expansion_handle: format!("memory:{memory_id}"),
provenance: vec![ProvenanceRef {
source: "graph".to_string(),
id: memory_id,
excerpt: Some(crate::context::excerpt_pub(&text)),
}],
confidence,
freshness,
relevance: 0.0,
scope_weight,
score: 0.0,
},
});
}
Ok(candidates)
}
#[cfg(feature = "graph")]
pub(crate) struct PetgraphBackend {
graph: petgraph::Graph<String, String>,
node_map: std::collections::HashMap<String, petgraph::graph::NodeIndex>,
}
#[cfg(feature = "graph")]
impl PetgraphBackend {
pub(crate) fn from_conn(conn: &Connection) -> KimetsuResult<Self> {
use petgraph::Graph;
use std::collections::HashMap;
let mut graph: Graph<String, String> = Graph::new();
let mut node_map: HashMap<String, petgraph::graph::NodeIndex> = HashMap::new();
let mut stmt =
conn.prepare("SELECT src_id, dst_id, edge_type FROM memory_edges ORDER BY created_at")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for row in rows {
let (src_id, dst_id, edge_type) = row?;
let src_idx = *node_map
.entry(src_id.clone())
.or_insert_with(|| graph.add_node(src_id.clone()));
let dst_idx = *node_map
.entry(dst_id.clone())
.or_insert_with(|| graph.add_node(dst_id.clone()));
graph.add_edge(src_idx, dst_idx, edge_type);
}
Ok(Self { graph, node_map })
}
#[allow(dead_code)] pub(crate) fn node_centrality(&self) -> Vec<(String, usize, usize)> {
self.node_map
.iter()
.map(|(id, &idx)| {
let in_deg = self
.graph
.edges_directed(idx, petgraph::Direction::Incoming)
.count();
let out_deg = self
.graph
.edges_directed(idx, petgraph::Direction::Outgoing)
.count();
(id.clone(), in_deg, out_deg)
})
.collect()
}
#[allow(dead_code)] pub(crate) fn shortest_path(&self, from_id: &str, to_id: &str) -> Option<Vec<String>> {
use petgraph::algo::astar;
let src_idx = *self.node_map.get(from_id)?;
let dst_idx = *self.node_map.get(to_id)?;
let (_, path_nodes) = astar(
&self.graph,
src_idx,
|finish| finish == dst_idx,
|_| 1usize,
|_| 0usize,
)?;
Some(
path_nodes
.into_iter()
.map(|idx| self.graph[idx].clone())
.collect(),
)
}
#[allow(dead_code)] pub(crate) fn community_hints(&self) -> Vec<Vec<String>> {
use petgraph::algo::kosaraju_scc;
let sccs = kosaraju_scc(&self.graph);
sccs.into_iter()
.filter(|component| !component.is_empty())
.map(|component| {
component
.into_iter()
.map(|idx| self.graph[idx].clone())
.collect()
})
.collect()
}
fn petgraph_expand(
&self,
seed_ids: &HashSet<String>,
max_hops: usize,
max_fan_out: usize,
) -> Vec<(String, usize)> {
use petgraph::visit::EdgeRef;
if seed_ids.is_empty() || max_hops == 0 {
return Vec::new();
}
let mut visited: HashSet<String> = seed_ids.clone();
let mut frontier: Vec<petgraph::graph::NodeIndex> = seed_ids
.iter()
.filter_map(|id| self.node_map.get(id).copied())
.collect();
let mut new_ids: Vec<(String, usize)> = Vec::new();
for hop_idx in 0..max_hops {
if frontier.is_empty() || new_ids.len() >= max_fan_out {
break;
}
let mut next_frontier = Vec::new();
for node_idx in &frontier {
let neighbors: Vec<petgraph::graph::NodeIndex> = self
.graph
.edges_directed(*node_idx, petgraph::Direction::Outgoing)
.map(|e| e.target())
.chain(
self.graph
.edges_directed(*node_idx, petgraph::Direction::Incoming)
.map(|e| e.source()),
)
.collect();
for neighbour_idx in neighbors {
let neighbour_id = &self.graph[neighbour_idx];
if !visited.contains(neighbour_id) {
visited.insert(neighbour_id.clone());
next_frontier.push(neighbour_idx);
new_ids.push((neighbour_id.clone(), hop_idx + 1));
if new_ids.len() >= max_fan_out {
break;
}
}
}
if new_ids.len() >= max_fan_out {
break;
}
}
frontier = next_frontier;
}
new_ids
}
}
#[cfg(feature = "graph")]
impl RetrievalBackend for PetgraphBackend {
fn memory_candidates(
&self,
conn: &Connection,
query: &str,
query_embedding: Option<&QueryEmbedding>,
half_life_days: f32,
) -> KimetsuResult<Vec<Candidate>> {
let flat =
crate::context::memory_candidates_flat(conn, query, query_embedding, half_life_days)?;
let mut seen_ids: HashSet<String> = flat
.iter()
.filter_map(|c| {
c.capsule
.expansion_handle
.strip_prefix("memory:")
.map(|id| id.to_string())
})
.collect();
if seen_ids.is_empty() {
return Ok(flat);
}
let max_flat_relevance = flat.iter().map(|c| c.raw_relevance).fold(0.0_f32, f32::max);
let new_ids = self.petgraph_expand(&seen_ids, MAX_HOPS, MAX_FAN_OUT);
if new_ids.is_empty() {
return Ok(flat);
}
let graph_candidates =
fetch_graph_candidates(conn, &new_ids, &mut seen_ids, max_flat_relevance)?;
let mut combined = flat;
combined.extend(graph_candidates);
Ok(combined)
}
}
pub(crate) fn backend_for(backend: &str) -> Box<dyn RetrievalBackend + Send + Sync> {
match backend {
"flat" => Box::new(FlatBackend),
"graph-lite" => Box::new(GraphLiteBackend),
"graph" => {
#[cfg(feature = "graph")]
{
Box::new(DeferredPetgraphBackend::new())
}
#[cfg(not(feature = "graph"))]
{
Box::new(GraphLiteBackend)
}
}
other => {
eprintln!(
"kimetsu-brain: unknown storage.backend {:?}; falling back to \"flat\"",
other
);
Box::new(FlatBackend)
}
}
}
#[cfg(feature = "graph")]
struct DeferredPetgraphBackend {
inner: std::sync::Mutex<Option<PetgraphBackend>>,
}
#[cfg(feature = "graph")]
impl DeferredPetgraphBackend {
fn new() -> Self {
Self {
inner: std::sync::Mutex::new(None),
}
}
}
#[cfg(feature = "graph")]
impl RetrievalBackend for DeferredPetgraphBackend {
fn memory_candidates(
&self,
conn: &Connection,
query: &str,
query_embedding: Option<&QueryEmbedding>,
half_life_days: f32,
) -> KimetsuResult<Vec<Candidate>> {
let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if guard.is_none() {
*guard = Some(PetgraphBackend::from_conn(conn)?);
}
guard.as_ref().expect("just initialised").memory_candidates(
conn,
query,
query_embedding,
half_life_days,
)
}
}
#[cfg(test)]
mod tests {
use rusqlite::Connection;
use serde_json::json;
use super::*;
use crate::projector;
use crate::schema;
fn make_conn() -> Connection {
let conn = Connection::open_in_memory().expect("open_in_memory");
schema::initialize(&conn).expect("schema::initialize");
conn
}
fn insert_memory(conn: &Connection, id: &str, kind: &str, text: &str) {
conn.execute(
"INSERT INTO memories
(memory_id, scope, kind, text, normalized_text, confidence,
provenance_snapshot_json, created_at, use_count, usefulness_score)
VALUES (?1, 'project', ?2, ?3, ?3, 0.9, '{}', '2025-01-01T00:00:00Z', 0, 0.0)",
rusqlite::params![id, kind, text],
)
.expect("insert memory");
conn.execute(
"INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, 'project')",
rusqlite::params![id, text, kind],
)
.expect("insert memories_fts");
}
#[test]
fn backend_for_flat_resolves() {
let _b = backend_for("flat");
}
#[test]
fn backend_for_all_known_variants_no_panic() {
for variant in &["flat", "graph-lite", "graph", "unknown-typo"] {
let _b = backend_for(variant);
}
}
#[test]
fn graph_lite_no_edges_returns_flat_set() {
let conn = make_conn();
insert_memory(&conn, "mem-a", "fact", "cargo build compiles rust code");
insert_memory(&conn, "mem-b", "preference", "use ripgrep for searching");
let flat_backend = FlatBackend;
let graph_backend = GraphLiteBackend;
let flat_candidates = flat_backend
.memory_candidates(&conn, "cargo rust", None, 90.0)
.expect("flat candidates");
let graph_candidates = graph_backend
.memory_candidates(&conn, "cargo rust", None, 90.0)
.expect("graph candidates");
assert!(
graph_candidates.len() >= flat_candidates.len(),
"graph-lite must return at least as many candidates as flat; \
flat={} graph={}",
flat_candidates.len(),
graph_candidates.len()
);
let graph_ids: HashSet<String> = graph_candidates
.iter()
.filter_map(|c| {
c.capsule
.expansion_handle
.strip_prefix("memory:")
.map(|s| s.to_string())
})
.collect();
for flat_c in &flat_candidates {
if let Some(id) = flat_c.capsule.expansion_handle.strip_prefix("memory:") {
assert!(
graph_ids.contains(id),
"flat candidate {id:?} must be present in graph-lite result set"
);
}
}
}
#[test]
fn superseded_event_inserts_edge_and_edge_survives_rebuild() {
use kimetsu_core::ids::RunId;
let conn = make_conn();
let run_id = RunId::new();
let events = vec![
kimetsu_core::event::Event::new(
run_id,
"memory.accepted",
json!({
"memory_id": "survivor-1",
"scope": "project",
"kind": "fact",
"text": "use cargo fmt to format code",
"confidence": 0.9
}),
),
kimetsu_core::event::Event::new(
run_id,
"memory.accepted",
json!({
"memory_id": "member-1",
"scope": "project",
"kind": "fact",
"text": "run cargo fmt before commit",
"confidence": 0.8
}),
),
kimetsu_core::event::Event::new(
run_id,
"memory.superseded",
json!({
"memory_id": "member-1",
"survivor_id": "survivor-1",
"use_count_delta": 2,
"score_delta": 1.5
}),
),
];
projector::apply_events(&conn, &events).expect("apply_events");
let edge_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memory_edges
WHERE src_id='survivor-1' AND dst_id='member-1' AND edge_type='supersedes'",
[],
|r| r.get(0),
)
.expect("query edge count");
assert_eq!(
edge_count, 1,
"memory.superseded event must insert a supersedes edge"
);
projector::rebuild_in_place(&conn).expect("rebuild_in_place");
let edge_count_after: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memory_edges
WHERE src_id='survivor-1' AND dst_id='member-1' AND edge_type='supersedes'",
[],
|r| r.get(0),
)
.expect("query edge count after rebuild");
assert_eq!(
edge_count_after, 1,
"supersedes edge must survive rebuild_in_place (rebuild-safe)"
);
}
#[test]
fn graph_lite_1_hop_surfaces_edge_connected_memory() {
let conn = make_conn();
insert_memory(
&conn,
"mem-survivor",
"fact",
"cargo fmt formats your Rust code automatically",
);
insert_memory(
&conn,
"mem-connected",
"preference",
"always run formatter before submitting a pull request",
);
conn.execute(
"INSERT INTO memory_edges (src_id, dst_id, edge_type, created_at)
VALUES ('mem-survivor', 'mem-connected', 'supersedes', '2025-01-01T00:00:00Z')",
[],
)
.expect("insert edge");
let flat = FlatBackend
.memory_candidates(&conn, "cargo fmt", None, 90.0)
.expect("flat");
let flat_ids: HashSet<String> = flat
.iter()
.filter_map(|c| {
c.capsule
.expansion_handle
.strip_prefix("memory:")
.map(|s| s.to_string())
})
.collect();
assert!(
flat_ids.contains("mem-survivor"),
"flat must contain mem-survivor"
);
assert!(
!flat_ids.contains("mem-connected"),
"flat must NOT contain mem-connected (no lexical match)"
);
let graph = GraphLiteBackend
.memory_candidates(&conn, "cargo fmt", None, 90.0)
.expect("graph");
let graph_ids: HashSet<String> = graph
.iter()
.filter_map(|c| {
c.capsule
.expansion_handle
.strip_prefix("memory:")
.map(|s| s.to_string())
})
.collect();
assert!(
graph_ids.contains("mem-survivor"),
"graph-lite must contain mem-survivor (from flat)"
);
assert!(
graph_ids.contains("mem-connected"),
"graph-lite must contain mem-connected (via 1-hop edge)"
);
let graph_candidate = graph
.iter()
.find(|c| c.capsule.expansion_handle.strip_prefix("memory:") == Some("mem-connected"))
.expect("mem-connected must be in graph candidates");
let is_graph_sourced = graph_candidate
.capsule
.provenance
.iter()
.any(|p| p.source == "graph");
assert!(
is_graph_sourced,
"graph-reached candidate must carry provenance source 'graph'"
);
for id in &flat_ids {
assert!(
graph_ids.contains(id),
"flat candidate {id:?} must be preserved in graph-lite"
);
}
}
#[test]
fn backend_for_graph_lite_resolves_to_graph_lite_backend() {
let conn = make_conn();
let backend = backend_for("graph-lite");
let result = backend.memory_candidates(&conn, "some query", None, 90.0);
assert!(
result.is_ok(),
"graph-lite backend must not error on empty brain"
);
}
#[cfg(feature = "graph")]
#[test]
fn petgraph_backend_from_conn_empty_db() {
let conn = make_conn();
let backend = PetgraphBackend::from_conn(&conn).expect("from_conn");
let centrality = backend.node_centrality();
assert!(centrality.is_empty(), "empty graph → empty centrality");
assert!(backend.shortest_path("a", "b").is_none());
let communities = backend.community_hints();
assert!(communities.is_empty(), "empty graph → no communities");
}
#[cfg(feature = "graph")]
#[test]
fn petgraph_backend_graph_algorithms_on_seeded_topology() {
let conn = make_conn();
insert_memory(&conn, "node-a", "fact", "node-a content");
insert_memory(&conn, "node-b", "fact", "node-b content");
insert_memory(&conn, "node-c", "fact", "node-c content");
conn.execute(
"INSERT INTO memory_edges (src_id, dst_id, edge_type, created_at)
VALUES ('node-a', 'node-b', 'supersedes', '2025-01-01T00:00:00Z'),
('node-b', 'node-c', 'supersedes', '2025-01-01T00:00:00Z')",
[],
)
.expect("insert edges");
let backend = PetgraphBackend::from_conn(&conn).expect("from_conn");
let centrality = backend.node_centrality();
assert_eq!(centrality.len(), 3, "three nodes in the graph");
let find = |id: &str| {
centrality
.iter()
.find(|(node_id, _, _)| node_id == id)
.cloned()
};
let (_, a_in, a_out) = find("node-a").expect("node-a centrality");
let (_, b_in, b_out) = find("node-b").expect("node-b centrality");
let (_, c_in, c_out) = find("node-c").expect("node-c centrality");
assert_eq!((a_in, a_out), (0, 1), "node-a: in=0 out=1");
assert_eq!((b_in, b_out), (1, 1), "node-b: in=1 out=1");
assert_eq!((c_in, c_out), (1, 0), "node-c: in=1 out=0");
let path = backend
.shortest_path("node-a", "node-c")
.expect("path A→C must exist");
assert_eq!(path, vec!["node-a", "node-b", "node-c"]);
assert!(
backend.shortest_path("node-c", "node-a").is_none(),
"no reverse path in directed chain"
);
let communities = backend.community_hints();
assert_eq!(
communities.len(),
3,
"three singleton SCCs in a directed chain"
);
}
#[cfg(feature = "graph")]
#[test]
fn petgraph_backend_memory_candidates_superset_of_flat() {
let conn = make_conn();
insert_memory(
&conn,
"pg-survivor",
"fact",
"cargo fmt formats your Rust code automatically",
);
insert_memory(
&conn,
"pg-connected",
"preference",
"always run formatter before submitting a pull request",
);
conn.execute(
"INSERT INTO memory_edges (src_id, dst_id, edge_type, created_at)
VALUES ('pg-survivor', 'pg-connected', 'supersedes', '2025-01-01T00:00:00Z')",
[],
)
.expect("insert edge");
let backend = PetgraphBackend::from_conn(&conn).expect("from_conn");
let candidates = backend
.memory_candidates(&conn, "cargo fmt", None, 90.0)
.expect("memory_candidates");
let ids: std::collections::HashSet<String> = candidates
.iter()
.filter_map(|c| {
c.capsule
.expansion_handle
.strip_prefix("memory:")
.map(|s| s.to_string())
})
.collect();
assert!(
ids.contains("pg-survivor"),
"PetgraphBackend must return the flat FTS hit"
);
assert!(
ids.contains("pg-connected"),
"PetgraphBackend must return the graph-connected memory"
);
}
#[cfg(feature = "graph")]
#[test]
fn backend_for_graph_resolves_to_petgraph_backend() {
let conn = make_conn();
let backend = backend_for("graph");
let result = backend.memory_candidates(&conn, "some query", None, 90.0);
assert!(
result.is_ok(),
"petgraph backend must not error on empty brain"
);
}
#[cfg(not(feature = "graph"))]
#[test]
fn backend_for_graph_falls_back_to_graph_lite_without_feature() {
let conn = make_conn();
let backend = backend_for("graph");
let result = backend.memory_candidates(&conn, "some query", None, 90.0);
assert!(
result.is_ok(),
"graph fallback must not error on empty brain"
);
}
}