use std::collections::HashSet;
use rusqlite::Connection;
use kimetsu_core::KimetsuResult;
use crate::context::{Candidate, QueryEmbedding};
pub(crate) trait RetrievalBackend {
fn memory_candidates(
&self,
conn: &Connection,
query: &str,
query_embedding: Option<&QueryEmbedding>,
half_life_days: f32,
include_facts: bool,
) -> KimetsuResult<Vec<Candidate>>;
}
pub(crate) struct FlatBackend {
pub(crate) fusion: crate::fusion::Fusion,
}
impl RetrievalBackend for FlatBackend {
fn memory_candidates(
&self,
conn: &Connection,
query: &str,
query_embedding: Option<&QueryEmbedding>,
half_life_days: f32,
include_facts: bool,
) -> KimetsuResult<Vec<Candidate>> {
crate::context::memory_candidates_flat(
conn,
query,
query_embedding,
half_life_days,
self.fusion,
include_facts,
)
}
}
pub(crate) struct GraphLiteBackend {
pub(crate) fusion: crate::fusion::Fusion,
}
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,
include_facts: bool,
) -> KimetsuResult<Vec<Candidate>> {
let flat = crate::context::memory_candidates_flat(
conn,
query,
query_embedding,
half_life_days,
self.fusion,
include_facts,
)?;
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,
half_life_days,
include_facts,
)?;
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,
half_life_days: f32,
include_facts: bool,
) -> 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,
use_count, usefulness_score, last_useful_at, provenance_snapshot_json
FROM memories
WHERE invalidated_at IS NULL
AND superseded_by IS NULL
AND (valid_from IS NULL OR julianday(valid_from) <= julianday('now'))
AND (valid_to IS NULL OR julianday(valid_to) > julianday('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)?,
row.get::<_, i64>(6)?,
row.get::<_, f64>(7)?,
row.get::<_, Option<String>>(8)?,
row.get::<_, Option<String>>(9)?,
))
})?;
let mut candidates = Vec::new();
for row in rows {
let (
memory_id,
scope,
kind,
text,
confidence,
created_at,
use_count,
usefulness_score,
last_useful_at,
provenance,
) = 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 revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
let claim_revision = Some(revision);
if let Some(mut candidate) = crate::context::memory_row_to_candidate(
&[],
memory_id,
scope,
kind,
text,
confidence,
created_at,
use_count,
usefulness_score,
last_useful_at,
provenance,
half_life_days,
Some(raw_relevance),
None,
None,
) {
candidate.capsule.claim_revision = claim_revision;
crate::context::hydrate_fact_evidence(conn, &mut candidate, include_facts)?;
for source in &mut candidate.capsule.provenance {
source.source = "graph".into();
}
candidates.push(candidate);
}
}
Ok(candidates)
}
#[cfg(feature = "graph")]
pub(crate) struct PetgraphBackend {
graph: petgraph::Graph<String, String>,
node_map: std::collections::HashMap<String, petgraph::graph::NodeIndex>,
fusion: crate::fusion::Fusion,
}
#[cfg(feature = "graph")]
impl PetgraphBackend {
pub(crate) fn from_conn(
conn: &Connection,
fusion: crate::fusion::Fusion,
) -> 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,
fusion,
})
}
#[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,
include_facts: bool,
) -> KimetsuResult<Vec<Candidate>> {
let flat = crate::context::memory_candidates_flat(
conn,
query,
query_embedding,
half_life_days,
self.fusion,
include_facts,
)?;
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,
half_life_days,
include_facts,
)?;
let mut combined = flat;
combined.extend(graph_candidates);
Ok(combined)
}
}
pub(crate) fn backend_for(
backend: &str,
fusion: crate::fusion::Fusion,
) -> Box<dyn RetrievalBackend + Send + Sync> {
match backend {
"flat" => Box::new(FlatBackend { fusion }),
"graph-lite" => Box::new(GraphLiteBackend { fusion }),
"graph" => {
#[cfg(feature = "graph")]
{
Box::new(DeferredPetgraphBackend::new(fusion))
}
#[cfg(not(feature = "graph"))]
{
Box::new(GraphLiteBackend { fusion })
}
}
other => {
eprintln!(
"kimetsu-brain: unknown storage.backend {:?}; falling back to \"flat\"",
other
);
Box::new(FlatBackend { fusion })
}
}
}
#[cfg(feature = "graph")]
struct DeferredPetgraphBackend {
inner: std::sync::Mutex<Option<PetgraphBackend>>,
fusion: crate::fusion::Fusion,
}
#[cfg(feature = "graph")]
impl DeferredPetgraphBackend {
fn new(fusion: crate::fusion::Fusion) -> Self {
Self {
inner: std::sync::Mutex::new(None),
fusion,
}
}
}
#[cfg(feature = "graph")]
impl RetrievalBackend for DeferredPetgraphBackend {
fn memory_candidates(
&self,
conn: &Connection,
query: &str,
query_embedding: Option<&QueryEmbedding>,
half_life_days: f32,
include_facts: bool,
) -> 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, self.fusion)?);
}
guard.as_ref().expect("just initialised").memory_candidates(
conn,
query,
query_embedding,
half_life_days,
include_facts,
)
}
}
#[cfg(test)]
mod tests {
use rusqlite::Connection;
use serde_json::json;
use super::*;
use crate::projector;
use crate::schema;
#[test]
fn hardening_graph_hydration_checks_future_and_offset_expiry() {
let conn = make_conn();
for id in ["live", "future", "expired"] {
insert_memory(&conn, id, "fact", "graph fact");
}
conn.execute(
"UPDATE memories SET valid_from='2099-01-01T00:00:00Z' WHERE memory_id='future'",
[],
)
.unwrap();
let expired = (time::OffsetDateTime::now_utc() - time::Duration::seconds(2))
.to_offset(time::UtcOffset::from_hms(12, 0, 0).unwrap())
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
conn.execute(
"UPDATE memories SET valid_to=?1 WHERE memory_id='expired'",
rusqlite::params![expired],
)
.unwrap();
let ids = vec![
("live".into(), 1),
("future".into(), 1),
("expired".into(), 1),
];
let out =
fetch_graph_candidates(&conn, &ids, &mut HashSet::new(), 1.0, 30.0, false).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].capsule.expansion_handle, "memory:live");
assert_eq!(
out[0].capsule.claim_revision.as_deref(),
Some("baseline:live")
);
}
#[test]
fn graph_rerank_retains_trust_and_decayed_usefulness() {
struct Scores;
impl crate::embeddings::Reranker for Scores {
fn rerank(
&self,
_query: &str,
docs: &[&str],
) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
Ok(docs
.iter()
.map(|d| if d.contains("imported") { 0.9 } else { 0.8 })
.collect())
}
fn model_id(&self) -> &str {
"graph-policy-test"
}
}
let conn = make_conn();
insert_memory(&conn, "pack", "fact", "imported claim");
insert_memory(&conn, "local", "fact", "local claim");
conn.execute("UPDATE memories SET provenance_snapshot_json='{\"source\":\"pack\"}' WHERE memory_id='pack'",[]).unwrap();
let ids = vec![("pack".into(), 1), ("local".into(), 1)];
let out =
fetch_graph_candidates(&conn, &ids, &mut HashSet::new(), 1.0, 30.0, false).unwrap();
let ranked = crate::context::rerank_capsules(
"q",
out.into_iter().map(|c| c.capsule).collect(),
&Scores,
0.0,
0,
);
assert_eq!(
ranked[0].expansion_handle, "memory:local",
"graph imports must preserve provenance discount"
);
let past = (time::OffsetDateTime::now_utc() - time::Duration::days(30))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
conn.execute("UPDATE memories SET use_count=5,usefulness_score=-5,last_useful_at=?1 WHERE memory_id='local'",[past]).unwrap();
let out =
fetch_graph_candidates(&conn, &ids, &mut HashSet::new(), 1.0, 30.0, false).unwrap();
let local = out
.iter()
.find(|c| c.capsule.expansion_handle == "memory:local")
.unwrap();
assert!((local.capsule.rerank_usefulness.unwrap_or(1.0) - 0.75).abs() < 0.0001);
}
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", crate::fusion::Fusion::Linear);
}
#[test]
fn backend_for_all_known_variants_no_panic() {
for variant in &["flat", "graph-lite", "graph", "unknown-typo"] {
let _b = backend_for(variant, crate::fusion::Fusion::Linear);
}
}
#[test]
fn graph_lite_is_a_superset_of_flat_with_edges_present() {
let conn = make_conn();
let corpus = [
(
"mem-a",
"convention",
"[tags: sqlite wal] checkpoint the WAL before copying brain.db",
),
(
"mem-b",
"failure_pattern",
"[tags: sqlite wal] opening a WAL database read-only skips recovery",
),
(
"mem-c",
"command",
"[tags: rust cargo] regenerate the schema with cargo xtask gen",
),
(
"mem-d",
"fact",
"[tags: rust cargo] the workspace pins edition 2024",
),
(
"mem-e",
"preference",
"[tags: search] prefer ripgrep over grep for large trees",
),
];
for (id, kind, text) in corpus {
insert_memory(&conn, id, kind, text);
crate::graph::project_entities(&conn, id, text).expect("project entities");
}
for (id, _, _) in corpus {
let edges = crate::graph::incremental_edges_for_memory(&conn, id, 0).expect("edges");
let tuples: Vec<(String, String, String)> = edges
.into_iter()
.map(|e| (e.src_id, e.dst_id, e.edge_type))
.collect();
projector::add_memory_edges(&conn, &tuples).expect("persist edges");
}
let edge_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memory_edges WHERE edge_type='relates_to'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
edge_count > 0,
"fixture must actually have edges, or this proves nothing"
);
for query in [
"wal checkpoint",
"cargo schema",
"ripgrep",
"sqlite recovery read-only",
"something entirely unrelated to this corpus",
] {
let flat = FlatBackend {
fusion: crate::fusion::Fusion::Linear,
}
.memory_candidates(&conn, query, None, 90.0, false)
.expect("flat");
let graph = GraphLiteBackend {
fusion: crate::fusion::Fusion::Linear,
}
.memory_candidates(&conn, query, None, 90.0, false)
.expect("graph-lite");
for candidate in &flat {
let same = graph
.iter()
.find(|g| g.capsule.expansion_handle == candidate.capsule.expansion_handle)
.unwrap_or_else(|| {
panic!(
"graph-lite dropped a flat candidate for {query:?}: {}",
candidate.capsule.expansion_handle
)
});
assert!(
(same.raw_relevance - candidate.raw_relevance).abs() < f32::EPSILON,
"graph-lite changed a flat candidate's relevance for {query:?}: \
{} vs {}",
same.raw_relevance,
candidate.raw_relevance
);
}
assert!(
graph.len() >= flat.len(),
"graph-lite must never shrink the pool for {query:?}: {} < {}",
graph.len(),
flat.len()
);
}
}
#[test]
fn graph_reached_candidates_are_hop_decayed_below_their_seed() {
let conn = make_conn();
for (id, kind, text) in [
(
"mem-a",
"convention",
"[tags: sqlite wal] checkpoint the WAL before copying",
),
(
"mem-b",
"fact",
"[tags: sqlite wal] recovery is skipped on read-only opens",
),
] {
insert_memory(&conn, id, kind, text);
crate::graph::project_entities(&conn, id, text).expect("entities");
}
projector::add_memory_edges(
&conn,
&[(
"mem-a".to_string(),
"mem-b".to_string(),
"relates_to".to_string(),
)],
)
.expect("edge");
let graph = GraphLiteBackend {
fusion: crate::fusion::Fusion::Linear,
}
.memory_candidates(&conn, "checkpoint", None, 90.0, false)
.expect("graph-lite");
let reached = graph
.iter()
.find(|c| c.capsule.expansion_handle.contains("mem-b"))
.expect("mem-b must be reachable through the edge");
let seed = graph
.iter()
.find(|c| c.capsule.expansion_handle.contains("mem-a"))
.expect("mem-a is the direct hit");
assert!(
reached.raw_relevance < seed.raw_relevance,
"a graph-reached candidate must rank below the hit that pulled it in: \
{} vs {}",
reached.raw_relevance,
seed.raw_relevance
);
let expected = seed.raw_relevance * HOP_DECAY;
assert!(
(reached.raw_relevance - expected).abs() < 1e-4,
"one hop must decay by HOP_DECAY: got {}, expected {expected}",
reached.raw_relevance
);
}
#[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 {
fusion: crate::fusion::Fusion::Linear,
};
let graph_backend = GraphLiteBackend {
fusion: crate::fusion::Fusion::Linear,
};
let flat_candidates = flat_backend
.memory_candidates(&conn, "cargo rust", None, 90.0, false)
.expect("flat candidates");
let graph_candidates = graph_backend
.memory_candidates(&conn, "cargo rust", None, 90.0, false)
.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 {
fusion: crate::fusion::Fusion::Linear,
}
.memory_candidates(&conn, "cargo fmt", None, 90.0, false)
.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 {
fusion: crate::fusion::Fusion::Linear,
}
.memory_candidates(&conn, "cargo fmt", None, 90.0, false)
.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", crate::fusion::Fusion::Linear);
let result = backend.memory_candidates(&conn, "some query", None, 90.0, false);
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, crate::fusion::Fusion::Linear).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, crate::fusion::Fusion::Linear).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, crate::fusion::Fusion::Linear).expect("from_conn");
let candidates = backend
.memory_candidates(&conn, "cargo fmt", None, 90.0, false)
.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", crate::fusion::Fusion::Linear);
let result = backend.memory_candidates(&conn, "some query", None, 90.0, false);
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", crate::fusion::Fusion::Linear);
let result = backend.memory_candidates(&conn, "some query", None, 90.0, false);
assert!(
result.is_ok(),
"graph fallback must not error on empty brain"
);
}
}