diff --git a/Cargo.lock b/Cargo.lock index 895e642572..077daa20ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9982,6 +9982,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ruvector-memory-self-repair" +version = "2.3.0" +dependencies = [ + "rand 0.8.6", + "ruvector-coherence", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "ruvector-metrics" version = "2.3.0" diff --git a/Cargo.toml b/Cargo.toml index b4381e6431..460d3eae04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -296,6 +296,7 @@ members = [ "crates/ruvector-cluster-rag", # S-T mincut namespace routing for multi-namespace search (ADR-299) "crates/ruvector-namespace-merge", + "crates/ruvector-memory-self-repair", # Semantic query-result reuse for repeated agent-memory queries (ADR-301) "crates/ruvector-query-cache", # Reservoir-sampled adaptive product quantization under drift (ADR-302) diff --git a/crates/ruvector-memory-self-repair/Cargo.toml b/crates/ruvector-memory-self-repair/Cargo.toml new file mode 100644 index 0000000000..af1b9682c5 --- /dev/null +++ b/crates/ruvector-memory-self-repair/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "ruvector-memory-self-repair" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Spectral drift detection and witness-gated targeted repair for agent memory graphs in RuVector" +readme = "README.md" +keywords = ["agent-memory", "coherence", "spectral", "self-healing", "witness"] +categories = ["algorithms", "data-structures"] + +[lib] +name = "ruvector_memory_self_repair" +path = "src/lib.rs" + +[[bin]] +name = "benchmark" +path = "src/bin/benchmark.rs" + +[dependencies] +ruvector-coherence = { path = "../ruvector-coherence", features = ["spectral"] } +rand = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = "0.10" + +[dev-dependencies] + +[lints.rust] +unexpected_cfgs = { level = "allow", priority = -1 } diff --git a/crates/ruvector-memory-self-repair/src/bin/benchmark.rs b/crates/ruvector-memory-self-repair/src/bin/benchmark.rs new file mode 100644 index 0000000000..c062059a06 --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/bin/benchmark.rs @@ -0,0 +1,305 @@ +//! Reproducible benchmark: baseline vs monitor-only vs spectral-repair on an +//! identical simulated agent-memory timeline. Run with: +//! +//! cargo run --release -p ruvector-memory-self-repair --bin benchmark +//! +//! Prints a human-readable table and writes raw JSON evidence next to the +//! research writeup so numbers are never hand-transcribed. + +use ruvector_memory_self_repair::drift::{ + apply_arrival, apply_compaction, apply_drift_wave, build_arrivals, build_drift_schedule, + SimConfig, +}; +use ruvector_memory_self_repair::graph::MemoryGraph; +use ruvector_memory_self_repair::repair::{RepairConfig, Runner, Variant}; +use ruvector_memory_self_repair::retrieval::mean_recall_at_k; +use serde::Serialize; +use std::time::Instant; + +const MONITOR_INTERVAL: u64 = 10; +const QUERY_STRIDE: u64 = 5; +const RECALL_K: usize = 10; +// Deliberately tight: a generous budget (originally 200 on a ~2600-node +// graph) let best-first search route around decayed bridges via redundant +// paths, saturating recall@10 at 1.0 for every variant and making the +// benchmark unable to discriminate baseline from repair. Associative +// recall in a real agent is few-hop and budget constrained, so this also +// better matches what it is standing in for. +const WALK_BUDGET: usize = 20; +const DETECTION_WINDOW: u64 = 2 * MONITOR_INTERVAL; + +#[derive(Serialize)] +struct VariantResult { + variant: String, + final_alive_nodes: usize, + final_edge_count: usize, + mean_recall_at_10: f64, + recall_queries_counted: usize, + monitor_calls: usize, + alert_steps: usize, + repairs_triggered: usize, + cumulative_edges_added: usize, + repair_edge_fraction_of_final_graph: f64, + wall_time_ms: f64, + witness_chain_valid: Option, + witness_receipt_count: usize, +} + +#[derive(Serialize)] +struct DetectionResult { + true_drift_events: usize, + detected_within_window: usize, + detection_recall: f64, + alerts_total: usize, + alerts_within_window_of_any_drift: usize, + alert_precision: f64, +} + +#[derive(Serialize)] +struct Evidence { + config: String, + seed: u64, + total_steps: u64, + baseline: VariantResult, + monitor_only: VariantResult, + spectral_repair: VariantResult, + detection: DetectionResult, + recall_uplift_pp: f64, + acceptance_threshold_pp: f64, + max_repair_edge_fraction: f64, + acceptance_result: String, +} + +fn run_variant( + cfg: &SimConfig, + variant: Variant, +) -> (MemoryGraph, VariantResult, Option, u128) { + let (_, arrivals) = build_arrivals(cfg); + let drift_schedule = build_drift_schedule(cfg); + let mut graph = MemoryGraph::new(); + let mut runner = if variant == Variant::NoRepair { + None + } else { + Some(Runner::new(RepairConfig::default())) + }; + + let start = Instant::now(); + let mut drift_idx = 0usize; + for (step, embedding) in arrivals.into_iter().enumerate() { + let step = step as u64; + apply_arrival(&mut graph, embedding, step, cfg); + + if step > 0 && step % cfg.compaction_interval == 0 { + apply_compaction(&mut graph, step, cfg); + } + while drift_idx < drift_schedule.len() && drift_schedule[drift_idx].step == step { + apply_drift_wave(&mut graph, drift_schedule[drift_idx], cfg); + drift_idx += 1; + } + if let Some(r) = runner.as_mut() { + if step % MONITOR_INTERVAL == 0 { + r.check_and_repair(&mut graph, step, variant); + } + } + } + let wall_time = start.elapsed().as_micros(); + + let queries: Vec = (0..cfg.total_steps) + .step_by(QUERY_STRIDE as usize) + .map(|s| s as usize) + .collect(); + let (recall, counted) = mean_recall_at_k(&graph, &queries, RECALL_K, WALK_BUDGET); + + let final_edges = graph.edge_count(); + let (monitor_calls, alert_steps, repairs, cum_edges, witness_valid, witness_count) = + match &runner { + Some(r) => ( + r.stats.monitor_calls, + r.stats.alert_steps.len(), + r.stats.repairs.len(), + r.stats.cumulative_edges_added, + Some(r.witness.verify().is_ok()), + r.witness.receipts.len(), + ), + None => (0, 0, 0, 0, None, 0), + }; + let repair_fraction = if final_edges == 0 { + 0.0 + } else { + cum_edges as f64 / final_edges as f64 + }; + + let result = VariantResult { + variant: format!("{variant:?}"), + final_alive_nodes: graph.alive_ids().len(), + final_edge_count: final_edges, + mean_recall_at_10: recall, + recall_queries_counted: counted, + monitor_calls, + alert_steps, + repairs_triggered: repairs, + cumulative_edges_added: cum_edges, + repair_edge_fraction_of_final_graph: repair_fraction, + wall_time_ms: wall_time as f64 / 1000.0, + witness_chain_valid: witness_valid, + witness_receipt_count: witness_count, + }; + (graph, result, runner, wall_time) +} + +fn main() { + let seed = std::env::var("SIM_SEED") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(42); + let cfg = SimConfig { + seed, + ..Default::default() + }; + println!("=== ruvector-memory-self-repair benchmark ==="); + println!( + "config: dim={} n_topics={} total_steps={} semantic_k={} compaction_interval={} evict={} drift_interval={} decay={} seed={}", + cfg.dim, cfg.n_topics, cfg.total_steps, cfg.semantic_k, cfg.compaction_interval, + cfg.compaction_evict_count, cfg.drift_interval, cfg.drift_decay_factor, cfg.seed + ); + + let (_, baseline, _, _) = run_variant(&cfg, Variant::NoRepair); + let (_, monitor_only, monitor_runner, _) = run_variant(&cfg, Variant::MonitorOnly); + let (_, spectral_repair, _, _) = run_variant(&cfg, Variant::SpectralRepair); + + // Detection quality is scored off the monitor-only run: it observes the + // exact same graph the baseline would have (no repair mutation), so its + // alerts are a clean read on "did the spectral signal notice the + // injected drift" independent of any repair feedback loop. + let drift_schedule = build_drift_schedule(&cfg); + let alert_steps: Vec = monitor_runner + .as_ref() + .map(|r| r.stats.alert_steps.clone()) + .unwrap_or_default(); + let mut detected = 0usize; + for ev in &drift_schedule { + if alert_steps + .iter() + .any(|&a| a >= ev.step && a <= ev.step + DETECTION_WINDOW) + { + detected += 1; + } + } + let mut precise = 0usize; + for &a in &alert_steps { + if drift_schedule + .iter() + .any(|ev| a >= ev.step && a <= ev.step + DETECTION_WINDOW) + { + precise += 1; + } + } + let detection = DetectionResult { + true_drift_events: drift_schedule.len(), + detected_within_window: detected, + detection_recall: if drift_schedule.is_empty() { + 0.0 + } else { + detected as f64 / drift_schedule.len() as f64 + }, + alerts_total: alert_steps.len(), + alerts_within_window_of_any_drift: precise, + alert_precision: if alert_steps.is_empty() { + 0.0 + } else { + precise as f64 / alert_steps.len() as f64 + }, + }; + + let recall_uplift_pp = (spectral_repair.mean_recall_at_10 - baseline.mean_recall_at_10) * 100.0; + const ACCEPTANCE_THRESHOLD_PP: f64 = 10.0; + const MAX_REPAIR_EDGE_FRACTION: f64 = 0.08; + + let repair_bounded = + spectral_repair.repair_edge_fraction_of_final_graph <= MAX_REPAIR_EDGE_FRACTION; + let witness_ok = spectral_repair.witness_chain_valid.unwrap_or(false); + let recall_met = recall_uplift_pp >= ACCEPTANCE_THRESHOLD_PP; + + let acceptance_result = if !witness_ok { + "REJECT (witness chain invalid)".to_string() + } else if !repair_bounded { + "INCONCLUSIVE (repair exceeded targeted-edit bound, cannot rule out rebuild-in-disguise)" + .to_string() + } else if recall_met { + "ACCEPT".to_string() + } else { + "REJECT (recall uplift below acceptance threshold)".to_string() + }; + + println!( + "\n{:<16} {:>10} {:>8} {:>12} {:>8} {:>7} {:>9} {:>10} {:>9}", + "variant", + "alive", + "edges", + "recall@10", + "monCalls", + "alerts", + "repairs", + "edgesAdd", + "wallMs" + ); + for r in [&baseline, &monitor_only, &spectral_repair] { + println!( + "{:<16} {:>10} {:>8} {:>12.4} {:>8} {:>7} {:>9} {:>10} {:>9.2}", + r.variant, + r.final_alive_nodes, + r.final_edge_count, + r.mean_recall_at_10, + r.monitor_calls, + r.alert_steps, + r.repairs_triggered, + r.cumulative_edges_added, + r.wall_time_ms + ); + } + + println!("\ndrift events injected: {}", detection.true_drift_events); + println!( + "detection recall (alert within {DETECTION_WINDOW} steps of drift): {:.4}", + detection.detection_recall + ); + println!( + "alert precision (alert within window of some drift): {:.4}", + detection.alert_precision + ); + println!("\nrecall@10 uplift (spectral_repair - baseline): {recall_uplift_pp:.2} pp (threshold: {ACCEPTANCE_THRESHOLD_PP} pp)"); + println!( + "repair edge fraction of final graph: {:.4} (max allowed: {MAX_REPAIR_EDGE_FRACTION})", + spectral_repair.repair_edge_fraction_of_final_graph + ); + println!( + "witness chain valid: {witness_ok}, receipts: {}", + spectral_repair.witness_receipt_count + ); + println!("\nACCEPTANCE RESULT: {acceptance_result}"); + + let evidence = Evidence { + config: format!("{cfg:?}"), + seed: cfg.seed, + total_steps: cfg.total_steps, + baseline, + monitor_only, + spectral_repair, + detection, + recall_uplift_pp, + acceptance_threshold_pp: ACCEPTANCE_THRESHOLD_PP, + max_repair_edge_fraction: MAX_REPAIR_EDGE_FRACTION, + acceptance_result, + }; + let json = serde_json::to_string_pretty(&evidence).unwrap(); + let out_path = format!( + "/tmp/claude-0/-home-user-ruvector/f7813f58-cd3a-5bbb-9872-9c6f3c070715/scratchpad/spectral-memory-self-repair-evidence-seed{}.json", + cfg.seed + ); + let out_path = out_path.as_str(); + if let Some(parent) = std::path::Path::new(out_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(out_path, &json).expect("write evidence json"); + println!("\nraw evidence written to {out_path}"); +} diff --git a/crates/ruvector-memory-self-repair/src/drift.rs b/crates/ruvector-memory-self-repair/src/drift.rs new file mode 100644 index 0000000000..f6fc0ad7a7 --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/drift.rs @@ -0,0 +1,209 @@ +//! Deterministic simulation timeline for an evolving agent memory graph: +//! memory arrivals, coherence-weighted compaction, and injected structural +//! drift (bridge-edge decay between topic regions). The exact same arrival +//! and drift *rules* are replayed against every variant's own graph so +//! comparisons stay fair even after a repair variant's graph structurally +//! diverges from the baseline. + +use crate::embedding::{Embedding, TopicSpace}; +use crate::graph::MemoryGraph; +use crate::scoring::{lowest_scoring, ScoringWeights}; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +#[derive(Debug, Clone)] +pub struct SimConfig { + pub dim: usize, + pub n_topics: usize, + pub total_steps: u64, + pub semantic_k: usize, + pub embedding_noise: f32, + pub compaction_interval: u64, + pub compaction_evict_count: usize, + pub drift_interval: u64, + pub drift_decay_factor: f64, + pub drift_max_edges_per_wave: usize, + pub seed: u64, +} + +impl Default for SimConfig { + fn default() -> Self { + Self { + dim: 24, + n_topics: 8, + total_steps: 3000, + semantic_k: 5, + embedding_noise: 0.12, + compaction_interval: 25, + compaction_evict_count: 3, + drift_interval: 60, + drift_decay_factor: 0.15, + drift_max_edges_per_wave: 40, + seed: 42, + } + } +} + +/// A ground-truth drift injection: which topic pair had its bridge edges +/// weakened at which step. Used only for scoring monitor detection quality, +/// never fed into the repair logic itself. +#[derive(Debug, Clone, Copy)] +pub struct DriftEvent { + pub step: u64, + pub topic_a: usize, + pub topic_b: usize, +} + +/// Pre-generates the full memory arrival stream once, up front, from a +/// single RNG pass — so it is byte-identical across every variant no matter +/// how their graphs later diverge via repair. +pub fn build_arrivals(cfg: &SimConfig) -> (TopicSpace, Vec) { + let mut rng = StdRng::seed_from_u64(cfg.seed); + let space = TopicSpace::new(cfg.dim, cfg.n_topics, &mut rng); + let arrivals = (0..cfg.total_steps) + .map(|_| { + let topic = rng.gen_range(0..cfg.n_topics); + space.sample(topic, cfg.embedding_noise, &mut rng) + }) + .collect(); + (space, arrivals) +} + +/// Deterministic drift schedule: which topic pair gets targeted at each +/// drift wave, cycling through all pairs so every topic eventually +/// experiences a weakened bridge. +pub fn build_drift_schedule(cfg: &SimConfig) -> Vec { + let mut pairs = Vec::new(); + for a in 0..cfg.n_topics { + for b in (a + 1)..cfg.n_topics { + pairs.push((a, b)); + } + } + if pairs.is_empty() { + return Vec::new(); + } + let mut events = Vec::new(); + let mut step = cfg.drift_interval; + let mut i = 0; + while step < cfg.total_steps { + let (a, b) = pairs[i % pairs.len()]; + events.push(DriftEvent { + step, + topic_a: a, + topic_b: b, + }); + i += 1; + step += cfg.drift_interval; + } + events +} + +/// Adds one new memory at `step`, wiring it to its top-k alive semantic +/// neighbors plus a temporal edge to the immediately preceding node. +pub fn apply_arrival(graph: &mut MemoryGraph, embedding: Embedding, step: u64, cfg: &SimConfig) { + let id = graph.add_node(embedding, step); + let neighbors = graph.semantic_topk(id, cfg.semantic_k); + let mut original = Vec::new(); + for (n, sim) in neighbors { + if sim > 0.0 { + graph.add_edge(id, n, sim); + original.push(n); + } + } + if id > 0 && graph.nodes[id - 1].alive { + graph.add_edge(id, id - 1, 0.6); + original.push(id - 1); + } + graph.nodes[id].original_neighbors = original; +} + +/// Coherence-weighted compaction: evicts the lowest-retention-score alive +/// memories, exactly the mechanism from the agent-memory-compaction nightly. +pub fn apply_compaction(graph: &mut MemoryGraph, now: u64, cfg: &SimConfig) { + let weights = ScoringWeights::default(); + let victims = lowest_scoring(graph, now, &weights, cfg.compaction_evict_count); + for v in victims { + graph.evict(v); + } +} + +/// Weakens (and, below a floor, removes) bridge edges between two topic +/// regions among currently alive nodes — simulates topic staleness / +/// context drift without ever touching the embeddings themselves, so a +/// repair can always legitimately recover the true similarity. +pub fn apply_drift_wave(graph: &mut MemoryGraph, event: DriftEvent, cfg: &SimConfig) -> usize { + let alive = graph.alive_ids(); + let mut touched = 0usize; + let mut to_remove = Vec::new(); + let mut to_decay = Vec::new(); + 'outer: for &u in &alive { + if graph.nodes[u].embedding.topic != event.topic_a { + continue; + } + for (&v, &w) in &graph.adjacency[u] { + if graph.nodes[v].embedding.topic == event.topic_b { + let new_w = w * cfg.drift_decay_factor; + if new_w < 0.05 { + to_remove.push((u, v)); + } else { + to_decay.push((u, v, new_w)); + } + touched += 1; + if touched >= cfg.drift_max_edges_per_wave { + break 'outer; + } + } + } + } + for (u, v) in to_remove { + graph.remove_edge(u, v); + } + for (u, v, w) in to_decay { + graph.add_edge(u, v, w); + } + touched +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arrivals_are_reproducible_for_same_seed() { + let cfg = SimConfig { + total_steps: 50, + ..Default::default() + }; + let (_, a1) = build_arrivals(&cfg); + let (_, a2) = build_arrivals(&cfg); + for (x, y) in a1.iter().zip(a2.iter()) { + assert_eq!(x.topic, y.topic); + assert_eq!(x.vec, y.vec); + } + } + + #[test] + fn drift_wave_reduces_or_removes_bridge_edges() { + let cfg = SimConfig { + drift_decay_factor: 0.1, + ..Default::default() + }; + let mut rng = StdRng::seed_from_u64(1); + let space = TopicSpace::new(cfg.dim, cfg.n_topics, &mut rng); + let mut g = MemoryGraph::new(); + let a = g.add_node(space.sample(0, 0.05, &mut rng), 0); + let b = g.add_node(space.sample(1, 0.05, &mut rng), 0); + g.add_edge(a, b, 0.9); + let touched = apply_drift_wave( + &mut g, + DriftEvent { + step: 0, + topic_a: 0, + topic_b: 1, + }, + &cfg, + ); + assert_eq!(touched, 1); + assert!(g.adjacency[a].get(&b).copied().unwrap_or(0.0) < 0.9); + } +} diff --git a/crates/ruvector-memory-self-repair/src/embedding.rs b/crates/ruvector-memory-self-repair/src/embedding.rs new file mode 100644 index 0000000000..cee4865420 --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/embedding.rs @@ -0,0 +1,84 @@ +//! Deterministic synthetic embedding generation for agent-memory PoC graphs. + +use rand::rngs::StdRng; +use rand::Rng; + +/// A synthetic memory embedding: a low-dimensional vector drawn from one of +/// `K` topic clusters plus isotropic noise, so ground-truth topic membership +/// is known exactly (needed to score associative-retrieval recall honestly). +#[derive(Debug, Clone)] +pub struct Embedding { + pub vec: Vec, + pub topic: usize, +} + +pub struct TopicSpace { + pub dim: usize, + pub centroids: Vec>, +} + +impl TopicSpace { + /// Builds `n_topics` centroids spread on the unit hypersphere via a fixed + /// deterministic RNG so the whole PoC is reproducible from one seed. + pub fn new(dim: usize, n_topics: usize, rng: &mut StdRng) -> Self { + let mut centroids = Vec::with_capacity(n_topics); + for _ in 0..n_topics { + let mut v: Vec = (0..dim).map(|_| rng.gen_range(-1.0..1.0)).collect(); + normalize(&mut v); + centroids.push(v); + } + Self { dim, centroids } + } + + pub fn sample(&self, topic: usize, noise: f32, rng: &mut StdRng) -> Embedding { + let mut v = self.centroids[topic].clone(); + for x in v.iter_mut() { + *x += rng.gen_range(-noise..noise); + } + normalize(&mut v); + Embedding { vec: v, topic } + } +} + +pub fn normalize(v: &mut [f32]) { + let n: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if n > 1e-9 { + for x in v.iter_mut() { + *x /= n; + } + } +} + +pub fn cosine(a: &[f32], b: &[f32]) -> f32 { + a.iter() + .zip(b) + .map(|(x, y)| x * y) + .sum::() + .clamp(-1.0, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + #[test] + fn centroids_are_unit_norm() { + let mut rng = StdRng::seed_from_u64(7); + let space = TopicSpace::new(16, 8, &mut rng); + for c in &space.centroids { + let n: f32 = c.iter().map(|x| x * x).sum::().sqrt(); + assert!((n - 1.0).abs() < 1e-4); + } + } + + #[test] + fn same_topic_more_similar_than_different_topic() { + let mut rng = StdRng::seed_from_u64(7); + let space = TopicSpace::new(16, 8, &mut rng); + let a = space.sample(0, 0.05, &mut rng); + let b = space.sample(0, 0.05, &mut rng); + let c = space.sample(1, 0.05, &mut rng); + assert!(cosine(&a.vec, &b.vec) > cosine(&a.vec, &c.vec)); + } +} diff --git a/crates/ruvector-memory-self-repair/src/graph.rs b/crates/ruvector-memory-self-repair/src/graph.rs new file mode 100644 index 0000000000..1a32b3a45b --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/graph.rs @@ -0,0 +1,172 @@ +//! Agent memory graph: nodes are memories, edges are semantic + temporal +//! associations. This is the structure whose spectral health we monitor. + +use crate::embedding::{cosine, Embedding}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone)] +pub struct MemoryNode { + pub id: usize, + pub embedding: Embedding, + pub created_step: u64, + pub last_access_step: u64, + pub access_count: u32, + pub alive: bool, + /// Snapshot of the neighbor ids this memory was originally wired to at + /// formation time (semantic top-k + temporal chain). Frozen at + /// creation; used as ground truth for "did the graph retain/recover + /// this memory's original associations" independent of whatever the + /// live adjacency looks like later. + pub original_neighbors: Vec, +} + +/// Undirected weighted memory graph. Node ids are stable slot indices; +/// `alive[i] == false` marks an evicted memory whose slot is retained only +/// so edge lists don't need renumbering. +#[derive(Debug, Clone, Default)] +pub struct MemoryGraph { + pub nodes: Vec, + /// adjacency[u] = { v: weight }, symmetric (adjacency[v] also has u). + pub adjacency: Vec>, +} + +impl MemoryGraph { + pub fn new() -> Self { + Self::default() + } + + pub fn add_node(&mut self, embedding: Embedding, step: u64) -> usize { + let id = self.nodes.len(); + self.nodes.push(MemoryNode { + id, + embedding, + created_step: step, + last_access_step: step, + access_count: 1, + alive: true, + original_neighbors: Vec::new(), + }); + self.adjacency.push(BTreeMap::new()); + id + } + + pub fn add_edge(&mut self, u: usize, v: usize, weight: f64) { + if u == v { + return; + } + self.adjacency[u].insert(v, weight); + self.adjacency[v].insert(u, weight); + } + + pub fn remove_edge(&mut self, u: usize, v: usize) { + self.adjacency[u].remove(&v); + self.adjacency[v].remove(&u); + } + + pub fn evict(&mut self, id: usize) { + self.nodes[id].alive = false; + let neighbors: Vec = self.adjacency[id].keys().copied().collect(); + for n in neighbors { + self.adjacency[n].remove(&id); + } + self.adjacency[id].clear(); + } + + pub fn alive_ids(&self) -> Vec { + self.nodes + .iter() + .filter(|n| n.alive) + .map(|n| n.id) + .collect() + } + + pub fn degree(&self, id: usize) -> usize { + self.adjacency[id].len() + } + + pub fn edge_count(&self) -> usize { + self.adjacency.iter().map(|m| m.len()).sum::() / 2 + } + + /// Top-k semantic neighbors among currently alive nodes (excluding self), + /// by cosine similarity of stored embeddings. + pub fn semantic_topk(&self, id: usize, k: usize) -> Vec<(usize, f64)> { + let e = &self.nodes[id].embedding; + let mut sims: Vec<(usize, f64)> = self + .nodes + .iter() + .filter(|n| n.alive && n.id != id) + .map(|n| (n.id, cosine(&e.vec, &n.embedding.vec) as f64)) + .collect(); + sims.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + sims.truncate(k); + sims + } + + /// Exports the alive-node induced subgraph as a dense edge list plus a + /// mapping from local (0..n) index back to original node ids, in the + /// format `ruvector_coherence::spectral::CsrMatrixView::build_laplacian` + /// expects. + pub fn to_laplacian_edges(&self) -> (usize, Vec<(usize, usize, f64)>, Vec) { + let alive: Vec = self.alive_ids(); + let index: BTreeMap = alive + .iter() + .enumerate() + .map(|(local, &orig)| (orig, local)) + .collect(); + let mut edges = Vec::new(); + let mut seen: BTreeSet<(usize, usize)> = BTreeSet::new(); + for &u in &alive { + for (&v, &w) in &self.adjacency[u] { + if !self.nodes[v].alive { + continue; + } + let (lu, lv) = (index[&u], index[&v]); + let key = if lu < lv { (lu, lv) } else { (lv, lu) }; + if seen.insert(key) { + edges.push((key.0, key.1, w)); + } + } + } + (alive.len(), edges, alive) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::embedding::TopicSpace; + use rand::rngs::StdRng; + use rand::SeedableRng; + + #[test] + fn evict_removes_incident_edges() { + let mut rng = StdRng::seed_from_u64(1); + let space = TopicSpace::new(4, 2, &mut rng); + let mut g = MemoryGraph::new(); + let a = g.add_node(space.sample(0, 0.1, &mut rng), 0); + let b = g.add_node(space.sample(0, 0.1, &mut rng), 0); + g.add_edge(a, b, 1.0); + assert_eq!(g.edge_count(), 1); + g.evict(a); + assert_eq!(g.edge_count(), 0); + assert!(g.adjacency[b].is_empty()); + } + + #[test] + fn laplacian_export_excludes_dead_nodes() { + let mut rng = StdRng::seed_from_u64(1); + let space = TopicSpace::new(4, 2, &mut rng); + let mut g = MemoryGraph::new(); + let a = g.add_node(space.sample(0, 0.1, &mut rng), 0); + let b = g.add_node(space.sample(0, 0.1, &mut rng), 0); + let c = g.add_node(space.sample(1, 0.1, &mut rng), 0); + g.add_edge(a, b, 1.0); + g.add_edge(b, c, 1.0); + g.evict(a); + let (n, edges, alive) = g.to_laplacian_edges(); + assert_eq!(n, 2); + assert_eq!(alive, vec![b, c]); + assert_eq!(edges.len(), 1); + } +} diff --git a/crates/ruvector-memory-self-repair/src/lib.rs b/crates/ruvector-memory-self-repair/src/lib.rs new file mode 100644 index 0000000000..20248a098d --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/lib.rs @@ -0,0 +1,29 @@ +//! Spectral drift detection and witness-gated targeted repair for agent +//! memory graphs. +//! +//! Reuses `ruvector_coherence::spectral` (Fiedler value, spectral gap, +//! effective resistance, degree regularity — the same primitives built for +//! HNSW index health monitoring) as the trigger signal for detecting +//! structural drift in an evolving agent-memory association graph, and adds +//! a bounded, hash-chain-witnessed repair action that reconnects the +//! structurally weakest nodes to their current semantic neighbors. +//! +//! See `docs/research/nightly/2026-08-16_spectral-memory-self-repair/README.md` +//! for the full hypothesis, methodology, and benchmark results. + +pub mod drift; +pub mod embedding; +pub mod graph; +pub mod repair; +pub mod retrieval; +pub mod scoring; +pub mod witness; + +pub use drift::{ + apply_arrival, apply_compaction, apply_drift_wave, build_arrivals, build_drift_schedule, + DriftEvent, SimConfig, +}; +pub use graph::MemoryGraph; +pub use repair::{RepairConfig, Runner, Variant}; +pub use retrieval::mean_recall_at_k; +pub use witness::WitnessLog; diff --git a/crates/ruvector-memory-self-repair/src/repair.rs b/crates/ruvector-memory-self-repair/src/repair.rs new file mode 100644 index 0000000000..3f9ad6b289 --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/repair.rs @@ -0,0 +1,203 @@ +//! Ties the reused `ruvector-coherence` spectral health monitor to a +//! bounded, witness-logged repair action on the memory graph. +//! +//! The spectral composite score (Fiedler value, spectral gap, effective +//! resistance, degree regularity — all computed by +//! `ruvector_coherence::spectral::HnswHealthMonitor`, unmodified) decides +//! *whether* to repair. The Fiedler eigenvector — nodes near zero sit on or +//! near a weak graph cut — decides *where*: repair targets are the alive +//! nodes with the smallest `|fiedler_vec[i]|`, i.e. the ones structurally +//! closest to falling into a disconnected fragment. Repair itself only adds +//! edges to a target's current top-k semantic neighbors (recomputed from +//! the embeddings that never decay), bounded to a fixed node/edge budget +//! per event so it can never degrade into "just rebuild everything". + +use crate::graph::MemoryGraph; +use crate::witness::WitnessLog; +use ruvector_coherence::spectral::{ + estimate_fiedler, CsrMatrixView, HnswHealthMonitor, SpectralConfig, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Variant { + NoRepair, + MonitorOnly, + SpectralRepair, +} + +#[derive(Debug, Clone)] +pub struct RepairConfig { + pub max_nodes_per_repair: usize, + pub semantic_k_reconnect: usize, + /// Repair triggers once `check_health()` returns at least this many + /// simultaneous alerts (2 is also where `HnswHealthMonitor` itself + /// emits `RebuildRecommended`, so this matches its own judgment of + /// "seriously degraded", not an arbitrarily looser bar). + pub alert_repair_min_issues: usize, +} + +impl Default for RepairConfig { + fn default() -> Self { + Self { + max_nodes_per_repair: 12, + semantic_k_reconnect: 3, + alert_repair_min_issues: 2, + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct RunStats { + pub scs_history: Vec<(u64, f64)>, + pub alert_steps: Vec, + pub repairs: Vec<(u64, usize, usize)>, // (step, nodes_touched, edges_added) + pub cumulative_edges_added: usize, + pub monitor_calls: usize, +} + +pub struct Runner { + monitor: HnswHealthMonitor, + cfg: RepairConfig, + pub witness: WitnessLog, + pub stats: RunStats, +} + +impl Runner { + pub fn new(cfg: RepairConfig) -> Self { + Self { + monitor: HnswHealthMonitor::new(SpectralConfig::default()), + cfg, + witness: WitnessLog::new(), + stats: RunStats::default(), + } + } + + /// Runs a full spectral health check against the graph's current alive + /// subgraph and, for `Variant::SpectralRepair` only, performs a bounded + /// targeted repair if the alert count crosses the configured threshold. + /// No-ops entirely for `Variant::NoRepair` (the baseline never pays + /// monitoring cost); `Variant::MonitorOnly` checks and records but never + /// mutates the graph, which is what makes it useful as a detector + /// sanity-check independent of repair effects. + pub fn check_and_repair(&mut self, graph: &mut MemoryGraph, step: u64, variant: Variant) { + if variant == Variant::NoRepair { + return; + } + let (n, edges, alive_map) = graph.to_laplacian_edges(); + if n < 3 { + return; + } + let lap = CsrMatrixView::build_laplacian(n, &edges); + self.monitor.update(&lap, None); + self.stats.monitor_calls += 1; + let alerts = self.monitor.check_health(); + let scs_before = self.monitor.score().composite; + self.stats.scs_history.push((step, scs_before)); + if !alerts.is_empty() { + self.stats.alert_steps.push(step); + } + + if variant == Variant::SpectralRepair && alerts.len() >= self.cfg.alert_repair_min_issues { + self.perform_repair(graph, &lap, &alive_map, step, scs_before); + } + } + + fn perform_repair( + &mut self, + graph: &mut MemoryGraph, + lap: &CsrMatrixView, + alive_map: &[usize], + step: u64, + scs_before: f64, + ) { + let (_eigenvalue, fiedler_vec) = estimate_fiedler(lap, 50, 1e-6); + let mut order: Vec = (0..fiedler_vec.len()).collect(); + order.sort_by(|&a, &b| fiedler_vec[a].abs().total_cmp(&fiedler_vec[b].abs())); + + let mut nodes_touched = Vec::new(); + let mut edges_added = 0usize; + for &local in order.iter().take(self.cfg.max_nodes_per_repair) { + let id = alive_map[local]; + let neighbors = graph.semantic_topk(id, self.cfg.semantic_k_reconnect); + let mut touched = false; + for (nbr, sim) in neighbors { + if sim <= 0.0 || graph.adjacency[id].contains_key(&nbr) { + continue; + } + graph.add_edge(id, nbr, sim); + edges_added += 1; + touched = true; + } + if touched { + nodes_touched.push(id); + } + } + + let (n2, edges2, _) = graph.to_laplacian_edges(); + let lap2 = CsrMatrixView::build_laplacian(n2, &edges2); + self.monitor.update(&lap2, None); + let scs_after = self.monitor.score().composite; + + self.witness.record( + step, + nodes_touched.clone(), + edges_added, + 0, + scs_before, + scs_after, + ); + self.stats + .repairs + .push((step, nodes_touched.len(), edges_added)); + self.stats.cumulative_edges_added += edges_added; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::drift::{apply_arrival, build_arrivals, SimConfig}; + + #[test] + fn repair_budget_never_exceeds_configured_cap_per_event() { + let cfg = SimConfig { + total_steps: 200, + ..Default::default() + }; + let (_, arrivals) = build_arrivals(&cfg); + let mut graph = MemoryGraph::new(); + for (step, e) in arrivals.into_iter().enumerate() { + apply_arrival(&mut graph, e, step as u64, &cfg); + } + let rcfg = RepairConfig { + max_nodes_per_repair: 5, + semantic_k_reconnect: 2, + ..Default::default() + }; + let cap = rcfg.max_nodes_per_repair * rcfg.semantic_k_reconnect; + let mut runner = Runner::new(rcfg); + runner.check_and_repair(&mut graph, 200, Variant::SpectralRepair); + for &(_, nodes, edges) in &runner.stats.repairs { + assert!(nodes <= 5); + assert!(edges <= cap); + } + } + + #[test] + fn no_repair_variant_never_mutates_graph_edge_count() { + let cfg = SimConfig { + total_steps: 100, + ..Default::default() + }; + let (_, arrivals) = build_arrivals(&cfg); + let mut graph = MemoryGraph::new(); + for (step, e) in arrivals.into_iter().enumerate() { + apply_arrival(&mut graph, e, step as u64, &cfg); + } + let before = graph.edge_count(); + let mut runner = Runner::new(RepairConfig::default()); + runner.check_and_repair(&mut graph, 100, Variant::NoRepair); + assert_eq!(before, graph.edge_count()); + assert_eq!(runner.stats.monitor_calls, 0); + } +} diff --git a/crates/ruvector-memory-self-repair/src/retrieval.rs b/crates/ruvector-memory-self-repair/src/retrieval.rs new file mode 100644 index 0000000000..0179771917 --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/retrieval.rs @@ -0,0 +1,167 @@ +//! Associative retrieval quality: graph-walk top-k retrieval whose accuracy +//! depends directly on structural connectivity, so it is the honest, +//! measurable stand-in for "does drift/repair actually matter" rather than +//! a proxy metric decoupled from the graph itself. + +use crate::graph::MemoryGraph; +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashSet}; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct ScoredNode { + score: f64, + node: usize, +} +impl Eq for ScoredNode {} +impl Ord for ScoredNode { + fn cmp(&self, other: &Self) -> Ordering { + self.score.total_cmp(&other.score) + } +} +impl PartialOrd for ScoredNode { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Best-first graph walk from `query`: score of a reached node is the +/// maximum path weight (product of edge weights) over all paths explored +/// within `budget` node expansions. This is exactly the kind of traversal a +/// real associative-memory read would perform, and it degrades gracefully +/// (not catastrophically) as bridge edges weaken, then recovers once a +/// repair reconnects them. +pub fn graph_walk_topk(graph: &MemoryGraph, query: usize, k: usize, budget: usize) -> Vec { + let mut best: Vec = vec![0.0; graph.nodes.len()]; + let mut heap = BinaryHeap::new(); + best[query] = 1.0; + heap.push(ScoredNode { + score: 1.0, + node: query, + }); + let mut expansions = 0usize; + let mut visited_order: Vec<(usize, f64)> = Vec::new(); + + while let Some(ScoredNode { score, node }) = heap.pop() { + if score < best[node] - 1e-12 { + continue; // stale heap entry + } + if node != query { + visited_order.push((node, score)); + } + expansions += 1; + if expansions >= budget { + break; + } + for (&next, &w) in &graph.adjacency[node] { + if !graph.nodes[next].alive { + continue; + } + let cand = score * w; + if cand > best[next] { + best[next] = cand; + heap.push(ScoredNode { + score: cand, + node: next, + }); + } + } + } + + visited_order.sort_by(|a, b| b.1.total_cmp(&a.1)); + visited_order.into_iter().take(k).map(|(n, _)| n).collect() +} + +/// Recall@k of graph-walk retrieval against each query's *original* +/// associative neighbors (the ones it was wired to at formation time, +/// still alive), averaged over `queries`. Queries whose ground truth is +/// empty are skipped (undefined, not scored as 0 or 1). +/// +/// This is deliberately not "any other same-topic memory": with a few +/// hundred same-topic memories typically alive, top-k against that broad a +/// truth set is satisfied by whatever is locally abundant and never +/// exercises reachability, which saturates recall at 1.0 for every variant +/// regardless of drift (see git history of this file). Restricting ground +/// truth to a memory's own originally-formed, still-alive neighbors makes +/// recall directly sensitive to whether the graph retained/recovered the +/// specific structure that existed before drift and compaction acted on it. +pub fn mean_recall_at_k( + graph: &MemoryGraph, + queries: &[usize], + k: usize, + budget: usize, +) -> (f64, usize) { + let mut total = 0.0; + let mut counted = 0usize; + for &q in queries { + if !graph.nodes[q].alive { + continue; + } + let truth: HashSet = graph.nodes[q] + .original_neighbors + .iter() + .copied() + .filter(|&n| graph.nodes[n].alive) + .collect(); + if truth.is_empty() { + continue; + } + let predicted = graph_walk_topk(graph, q, k, budget); + let hits = predicted.iter().filter(|p| truth.contains(p)).count(); + let denom = k.min(truth.len()); + total += hits as f64 / denom as f64; + counted += 1; + } + if counted == 0 { + (0.0, 0) + } else { + (total / counted as f64, counted) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::embedding::TopicSpace; + use rand::rngs::StdRng; + use rand::SeedableRng; + + #[test] + fn reachable_original_neighbors_are_recalled() { + let mut rng = StdRng::seed_from_u64(2); + let space = TopicSpace::new(8, 2, &mut rng); + let mut g = MemoryGraph::new(); + let mut ids = Vec::new(); + for _ in 0..6 { + ids.push(g.add_node(space.sample(0, 0.02, &mut rng), 0)); + } + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + g.add_edge(ids[i], ids[j], 0.9); + } + } + // Every node's "original neighbors" are declared to be all the + // others, matching the fully-connected wiring above. + for &id in &ids { + g.nodes[id].original_neighbors = ids.iter().copied().filter(|&o| o != id).collect(); + } + let (recall, counted) = mean_recall_at_k(&g, &ids, 5, 50); + assert_eq!(counted, ids.len()); + assert!(recall > 0.9, "recall = {recall}"); + } + + #[test] + fn unreachable_original_neighbor_is_not_recalled() { + let mut rng = StdRng::seed_from_u64(2); + let space = TopicSpace::new(8, 2, &mut rng); + let mut g = MemoryGraph::new(); + let a = g.add_node(space.sample(0, 0.02, &mut rng), 0); + let b = g.add_node(space.sample(0, 0.02, &mut rng), 0); + // a's original neighbor was b, but no edge exists any more (e.g. + // decayed away): graph walk from a can never reach b. + g.nodes[a].original_neighbors = vec![b]; + g.nodes[b].original_neighbors = vec![a]; + let (recall, counted) = mean_recall_at_k(&g, &[a, b], 5, 50); + assert_eq!(counted, 2); + assert_eq!(recall, 0.0); + } +} diff --git a/crates/ruvector-memory-self-repair/src/scoring.rs b/crates/ruvector-memory-self-repair/src/scoring.rs new file mode 100644 index 0000000000..bbce32ee5d --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/scoring.rs @@ -0,0 +1,104 @@ +//! Coherence-weighted compaction scoring: recency + frequency + local +//! semantic coherence, in the spirit of the 2026-06-14 agent-memory +//! compaction nightly (docs/research/nightly/2026-06-14-agent-memory-compaction). +//! Reimplemented locally (not depended on as a crate) since that PoC is a +//! standalone binary crate outside the cargo workspace; the formula shape is +//! the same three-signal blend, used here as a *reasonable baseline*, not a +//! strawman, driving which memories get evicted and thus what structural +//! drift looks like. + +use crate::graph::MemoryGraph; + +#[derive(Debug, Clone, Copy)] +pub struct ScoringWeights { + pub recency: f64, + pub frequency: f64, + pub coherence: f64, + pub half_life_steps: f64, +} + +impl Default for ScoringWeights { + fn default() -> Self { + Self { + recency: 0.4, + frequency: 0.25, + coherence: 0.35, + half_life_steps: 300.0, + } + } +} + +/// Retention score in [0, ~1]; higher survives compaction. +pub fn retention_score(graph: &MemoryGraph, id: usize, now: u64, w: &ScoringWeights) -> f64 { + let node = &graph.nodes[id]; + let age = (now.saturating_sub(node.last_access_step)) as f64; + let recency = 0.5_f64.powf(age / w.half_life_steps.max(1.0)); + let frequency = (1.0 + node.access_count as f64).ln() / (1.0 + 50.0_f64).ln(); + let frequency = frequency.min(1.0); + let coherence = local_coherence(graph, id); + (w.recency * recency + w.frequency * frequency + w.coherence * coherence).clamp(0.0, 1.0) +} + +/// Mean edge weight to currently alive neighbors; 0 for isolated nodes. +/// This is the signal that structural drift (weakened/removed bridge edges) +/// directly degrades, which is what couples compaction to graph health. +pub fn local_coherence(graph: &MemoryGraph, id: usize) -> f64 { + let neighbors = &graph.adjacency[id]; + if neighbors.is_empty() { + return 0.0; + } + let sum: f64 = neighbors.values().sum(); + (sum / neighbors.len() as f64).clamp(0.0, 1.0) +} + +/// Selects the `count` lowest-scoring alive nodes for eviction. +pub fn lowest_scoring( + graph: &MemoryGraph, + now: u64, + w: &ScoringWeights, + count: usize, +) -> Vec { + let mut scored: Vec<(usize, f64)> = graph + .alive_ids() + .into_iter() + .map(|id| (id, retention_score(graph, id, now, w))) + .collect(); + scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + scored.truncate(count); + scored.into_iter().map(|(id, _)| id).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::embedding::TopicSpace; + use rand::rngs::StdRng; + use rand::SeedableRng; + + #[test] + fn well_connected_recent_node_outscores_isolated_stale_node() { + let mut rng = StdRng::seed_from_u64(3); + let space = TopicSpace::new(8, 3, &mut rng); + let mut g = MemoryGraph::new(); + let fresh = g.add_node(space.sample(0, 0.05, &mut rng), 100); + let stale = g.add_node(space.sample(1, 0.05, &mut rng), 0); + let hub = g.add_node(space.sample(0, 0.05, &mut rng), 100); + g.add_edge(fresh, hub, 0.9); + let w = ScoringWeights::default(); + let s_fresh = retention_score(&g, fresh, 100, &w); + let s_stale = retention_score(&g, stale, 100, &w); + assert!(s_fresh > s_stale, "{s_fresh} should exceed {s_stale}"); + } + + #[test] + fn lowest_scoring_returns_requested_count() { + let mut rng = StdRng::seed_from_u64(3); + let space = TopicSpace::new(8, 3, &mut rng); + let mut g = MemoryGraph::new(); + for i in 0..10 { + g.add_node(space.sample(i % 3, 0.05, &mut rng), i as u64); + } + let picked = lowest_scoring(&g, 10, &ScoringWeights::default(), 4); + assert_eq!(picked.len(), 4); + } +} diff --git a/crates/ruvector-memory-self-repair/src/witness.rs b/crates/ruvector-memory-self-repair/src/witness.rs new file mode 100644 index 0000000000..0fe2e42c3a --- /dev/null +++ b/crates/ruvector-memory-self-repair/src/witness.rs @@ -0,0 +1,150 @@ +//! Hash-chained witness log for repair actions: every repair (or would-be +//! alert) is recorded as a receipt whose hash includes the previous +//! receipt's hash, so the sequence can be verified end-to-end and any +//! tampering or dropped entry is detectable. Same technique as the +//! 2026-08-13 retrieval-receipts nightly, implemented fresh here as a small +//! self-contained log rather than a cross-crate dependency. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepairReceipt { + pub seq: u64, + pub step: u64, + pub nodes_touched: Vec, + pub edges_added: usize, + pub edges_removed: usize, + pub scs_before: f64, + pub scs_after: f64, + pub prev_hash: String, + pub hash: String, +} + +#[derive(Debug, Default)] +pub struct WitnessLog { + pub receipts: Vec, +} + +fn hash_receipt( + seq: u64, + step: u64, + nodes_touched: &[usize], + edges_added: usize, + edges_removed: usize, + scs_before: f64, + scs_after: f64, + prev_hash: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(seq.to_le_bytes()); + hasher.update(step.to_le_bytes()); + for n in nodes_touched { + hasher.update(n.to_le_bytes()); + } + hasher.update(edges_added.to_le_bytes()); + hasher.update(edges_removed.to_le_bytes()); + hasher.update(scs_before.to_le_bytes()); + hasher.update(scs_after.to_le_bytes()); + hasher.update(prev_hash.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +impl WitnessLog { + pub fn new() -> Self { + Self::default() + } + + pub fn genesis_hash() -> String { + "0".repeat(64) + } + + /// Appends a repair receipt, chaining it to the previous entry's hash. + pub fn record( + &mut self, + step: u64, + nodes_touched: Vec, + edges_added: usize, + edges_removed: usize, + scs_before: f64, + scs_after: f64, + ) -> &RepairReceipt { + let seq = self.receipts.len() as u64; + let prev_hash = self + .receipts + .last() + .map(|r| r.hash.clone()) + .unwrap_or_else(Self::genesis_hash); + let hash = hash_receipt( + seq, + step, + &nodes_touched, + edges_added, + edges_removed, + scs_before, + scs_after, + &prev_hash, + ); + self.receipts.push(RepairReceipt { + seq, + step, + nodes_touched, + edges_added, + edges_removed, + scs_before, + scs_after, + prev_hash, + hash, + }); + self.receipts.last().unwrap() + } + + /// Recomputes every hash in the chain and checks it against the stored + /// value and against the next entry's `prev_hash`. Returns `Ok(())` iff + /// the entire chain is intact. + pub fn verify(&self) -> Result<(), String> { + let mut prev = Self::genesis_hash(); + for r in &self.receipts { + if r.prev_hash != prev { + return Err(format!("seq {}: prev_hash mismatch", r.seq)); + } + let recomputed = hash_receipt( + r.seq, + r.step, + &r.nodes_touched, + r.edges_added, + r.edges_removed, + r.scs_before, + r.scs_after, + &r.prev_hash, + ); + if recomputed != r.hash { + return Err(format!("seq {}: hash mismatch", r.seq)); + } + prev = r.hash.clone(); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_chain_verifies() { + let mut log = WitnessLog::new(); + log.record(10, vec![1, 2], 2, 0, 0.3, 0.5); + log.record(20, vec![3], 1, 1, 0.5, 0.55); + assert!(log.verify().is_ok()); + } + + #[test] + fn tampering_is_detected() { + let mut log = WitnessLog::new(); + log.record(10, vec![1, 2], 2, 0, 0.3, 0.5); + log.record(20, vec![3], 1, 1, 0.5, 0.55); + log.receipts[0].scs_after = 0.99; // tamper without recomputing hash + assert!(log.verify().is_err()); + } +} diff --git a/crates/ruvector-memory-self-repair/tests/acceptance.rs b/crates/ruvector-memory-self-repair/tests/acceptance.rs new file mode 100644 index 0000000000..1dc3262a4e --- /dev/null +++ b/crates/ruvector-memory-self-repair/tests/acceptance.rs @@ -0,0 +1,93 @@ +//! End-to-end acceptance checks over a full simulated timeline: witness +//! chain integrity and the "repair stays targeted, not a rebuild" bound. +//! Kept separate from the benchmark binary so `cargo test` always exercises +//! the full pipeline even without anyone running the benchmark by hand. + +use ruvector_memory_self_repair::drift::{ + apply_arrival, apply_compaction, apply_drift_wave, build_arrivals, build_drift_schedule, + SimConfig, +}; +use ruvector_memory_self_repair::graph::MemoryGraph; +use ruvector_memory_self_repair::repair::{RepairConfig, Runner, Variant}; + +fn run(cfg: &SimConfig, variant: Variant) -> (MemoryGraph, Runner) { + let (_, arrivals) = build_arrivals(cfg); + let drift_schedule = build_drift_schedule(cfg); + let mut graph = MemoryGraph::new(); + let mut runner = Runner::new(RepairConfig::default()); + let mut drift_idx = 0usize; + for (step, embedding) in arrivals.into_iter().enumerate() { + let step = step as u64; + apply_arrival(&mut graph, embedding, step, cfg); + if step > 0 && step % cfg.compaction_interval == 0 { + apply_compaction(&mut graph, step, cfg); + } + while drift_idx < drift_schedule.len() && drift_schedule[drift_idx].step == step { + apply_drift_wave(&mut graph, drift_schedule[drift_idx], cfg); + drift_idx += 1; + } + if step % 10 == 0 { + runner.check_and_repair(&mut graph, step, variant); + } + } + (graph, runner) +} + +#[test] +fn witness_chain_is_valid_after_a_full_run() { + let cfg = SimConfig { + total_steps: 1200, + ..Default::default() + }; + let (_, runner) = run(&cfg, Variant::SpectralRepair); + assert!( + runner.witness.verify().is_ok(), + "witness chain must verify end to end" + ); + assert!( + !runner.witness.receipts.is_empty(), + "a drifting graph should trigger at least one repair" + ); +} + +#[test] +fn repair_edges_stay_a_small_fraction_of_the_final_graph() { + let cfg = SimConfig { + total_steps: 1200, + ..Default::default() + }; + let (graph, runner) = run(&cfg, Variant::SpectralRepair); + let fraction = runner.stats.cumulative_edges_added as f64 / graph.edge_count().max(1) as f64; + assert!( + fraction <= 0.08, + "repair touched {:.4} of final graph edges, expected a targeted repair (<=0.08)", + fraction + ); +} + +#[test] +fn monitor_only_never_changes_edge_count_relative_to_no_monitoring() { + let cfg = SimConfig { + total_steps: 600, + ..Default::default() + }; + let (graph_baseline, _) = run(&cfg, Variant::NoRepair); + let (graph_monitor, _) = run(&cfg, Variant::MonitorOnly); + assert_eq!(graph_baseline.edge_count(), graph_monitor.edge_count()); + assert_eq!( + graph_baseline.alive_ids().len(), + graph_monitor.alive_ids().len() + ); +} + +#[test] +fn spectral_repair_graph_has_at_least_as_many_edges_as_baseline() { + let cfg = SimConfig { + total_steps: 1200, + ..Default::default() + }; + let (graph_baseline, _) = run(&cfg, Variant::NoRepair); + let (graph_repair, runner) = run(&cfg, Variant::SpectralRepair); + assert!(graph_repair.edge_count() >= graph_baseline.edge_count()); + assert!(runner.stats.monitor_calls > 0); +} diff --git a/docs/adr/ADR-305-spectral-memory-self-repair.md b/docs/adr/ADR-305-spectral-memory-self-repair.md new file mode 100644 index 0000000000..32919a5bd4 --- /dev/null +++ b/docs/adr/ADR-305-spectral-memory-self-repair.md @@ -0,0 +1,317 @@ +# ADR-305: Spectral Drift Detection for Agent Memory Graphs — Targeted Repair Hypothesis Rejected + +## Status + +**Rejected for the repair mechanism as designed. Accepted (validated) for the +detection mechanism.** Experimental crate (`ruvector-memory-self-repair`), +not wired into any production agent-memory path. This ADR documents a +negative result with a clear, reproduced mechanism, per the nightly research +process's rule that a falsified hypothesis with good evidence is a valid +outcome — this is not a rejected-and-forgotten idea, it is a specific +finding about where a plausible design breaks. + +## Context + +Two RuVector capabilities existed independently before this work: + +- `ruvector-coherence`'s `spectral` feature (added 2026-07-28, ADR context + in that crate) computes a composite Spectral Coherence Score — Fiedler + value, spectral gap, effective resistance, degree regularity — over a + graph Laplacian, via `HnswHealthMonitor`. It was built for HNSW *index* + health monitoring and was never applied to an agent-memory association + graph. +- The 2026-06-14 agent-memory-compaction nightly (`ruvector-agent-memory`) + established coherence-weighted eviction (recency + frequency + local + semantic coherence) as a reasonable compaction baseline, and its own + README explicitly flagged "spectral gate future" as unimplemented + follow-on work. + +Neither prior nightly closed that loop: nothing in the repository used +spectral graph-health signals to *detect* structural drift in an evolving +agent-memory graph, and nothing attempted a *repair* action gated on that +signal. The Step-2/Step-6 novelty gate for this run confirmed this via an +Explore survey of all 27 prior nightly topics (rabitq through +retrieval-receipts / entropy-adaptive-ann): mincut is used for RAG context +bounding and namespace routing, coherence scoring drives retrieval ranking +and compaction, and both write- and read-provenance receipts exist — but no +prior work combines spectral graph monitoring with agent-memory structural +repair. + +## Hypothesis + +``` +Given an agent memory graph (nodes = memories, edges = semantic top-k + +temporal-chain associations) evolving over 3000 arrivals with periodic +coherence-weighted compaction (evicting 3 lowest-retention-score memories +every 25 steps) and injected structural drift (cross-topic bridge edges +decayed to 15% of their weight, or removed below a floor, in waves every +60 steps, cycling through all topic pairs), + +when a bounded, spectrally-triggered repair reconnects the alive nodes +with the smallest-magnitude Fiedler-eigenvector components (i.e. those +sitting closest to a weak graph cut) to their current top-k semantic +neighbors, capped at 12 nodes / 3 edges each per repair event, + +then recall@10 of each memory's *originally-formed* associative neighbors +(the ones it was wired to at creation time, still alive) should improve by +at least 10 percentage points relative to a no-repair baseline, + +subject to: repair touching no more than 8% of the final graph's edges +(so it counts as targeted, not a rebuild-in-disguise), a hash-chained +witness log of every repair verifying with 100% integrity, and the +underlying `HnswHealthMonitor`/Fiedler-vector reuse from +`ruvector-coherence` remaining unmodified. +``` + +## Decision + +**Do not promote the repair mechanism.** The spectral trigger (reused, +unmodified `ruvector-coherence::spectral`) reliably *detects* injected +drift — 100% detection recall across three seeds, within a 20-step window +of every injection — but the targeted-reconnection repair action **did not +improve, and mildly hurt, recall of a memory's original associations**, +consistently across three independent seeds: + +| seed | baseline recall@10 | spectral-repair recall@10 | uplift | +|-----:|--------------------:|---------------------------:|-------:| +| 42 | 0.7603 | 0.7487 | −1.16 pp | +| 7 | 0.7447 | 0.7385 | −0.62 pp | +| 123 | 0.7538 | 0.7446 | −0.92 pp | + +The +10pp acceptance threshold was not met on any seed; the sign is +consistently negative, not merely noisy. **Acceptance result: REJECT** (see +Evidence below for full methodology and the honest mid-run correction that +was required to get a benchmark capable of measuring this at all). + +## Evidence + +Ran via `cargo run --release -p ruvector-memory-self-repair --bin benchmark` +(seed overridable with `SIM_SEED`), on the container's x86_64 Linux host, +rustc 1.94.1, release profile, single-threaded, three variants over an +identical arrival/compaction/drift script per seed: + +- `NoRepair` — baseline, no monitoring, matches wall time budget of the + underlying system with zero overhead. +- `MonitorOnly` — runs the exact same `HnswHealthMonitor` checks as the + repair variant every 10 steps, but never mutates the graph. Its graph is + byte-for-byte identical to `NoRepair`'s (edge count and alive-node count + match exactly on every seed) — this is a built-in sanity check that + monitoring itself is causally inert, isolating the repair action as the + only source of any recall difference. +- `SpectralRepair` — same monitoring, plus the bounded repair action + described above when `check_health()` returns ≥2 simultaneous alerts + (the same threshold at which `HnswHealthMonitor` itself already emits + `RebuildRecommended`). + +Config (fixed before any run, unchanged after seeing results): +`dim=24, n_topics=8, total_steps=3000, semantic_k=5, embedding_noise=0.12, +compaction_interval=25, compaction_evict_count=3, drift_interval=60, +drift_decay_factor=0.15, drift_max_edges_per_wave=40`. Full raw JSON +evidence per seed is written by the benchmark binary itself (not hand +transcribed) and referenced in the research README. + +**Detection quality** (from the `MonitorOnly` run, seed 42 shown, all three +seeds within noise of each other): 49 drift waves injected, 49/49 detected +within a 20-step window (100% recall). Alert precision (fraction of the +297 raised alerts falling within that window of *some* injected drift) was +~49% — the other alerts are plausibly real: compaction-driven eviction is +itself a structural-drift source this design deliberately did not suppress, +so an honest reading is that the monitor is also catching compaction-driven +fragmentation, not raising false alarms in the classic sense. This was not +further disambiguated and is flagged as an open question below rather than +asserted. + +**Repair stayed targeted, not a rebuild**: cumulative edges added by repair +were 3.0–3.1% of the final graph's edge count across all three seeds — well +under the 8% bound — and the witness chain (SHA-256, hash-chained receipts +per repair event, `WitnessLog::verify()`) validated with zero failures on +every run, including the four-test acceptance suite (`tests/acceptance.rs`) +that exercises this end-to-end rather than only the benchmark binary. + +**Monitor overhead is real and non-trivial**: baseline wall time ~212ms for +the full 3000-step simulation; `MonitorOnly` ~3.0–3.1s (≈14×); with repair, +~7.0–7.4s (≈34×). This is 299 full spectral recomputes (conjugate-gradient +Fiedler estimation via `estimate_fiedler`) over a graph that grows to ~2600 +alive nodes / ~15k edges. This cost was not gated in the acceptance +criteria (only recall uplift, repair-edge fraction, and witness integrity +were), but it is reported honestly because it would matter for any future +attempt at this direction: periodic full recompute at this frequency is +not free, and `SpectralTracker::update_edge`'s incremental path (unused +here — see Limitations) exists precisely to avoid this cost. + +### A methodology correction worth recording + +The first benchmark run used ground truth = "any other alive same-topic +memory" and a generous 200-node search budget. Every variant, including the +undrifted, unmonitored baseline, scored **recall@10 = 1.0000**. This was not +a positive result — it meant the metric could not discriminate anything: with +several hundred same-topic memories typically alive and a top-10 metric, a +best-first walk trivially fills its top 10 from whatever is locally +abundant, without ever needing to cross a decayed bridge. Ground truth was +redefined to each memory's own originally-formed neighbor set (frozen at +creation time, filtered to still-alive), and the search budget was tightened +to 20 expansions — both changes made *before* drawing any conclusion from a +discriminating result, not after seeing which variant they favored (per +Step 32/40 of the nightly process: do not change the hypothesis after +benchmarking begins; this was a repair of a degenerate instrument, not a +hypothesis change — the corrected version is what the Given/When/Then above +describes, and it is the only version whose numbers are reported). + +## Why the repair likely hurts, not just fails to help + +Not independently confirmed by a further ablation (flagged as next-step +work, not asserted as proven), but consistent with the measured data: the +repair action reconnects a target node to its **current** top-k semantic +neighbors, not necessarily the **specific** neighbor recall@10 is scored +against. Because new memories keep arriving throughout the 3000-step run, a +node's true top-5 semantic match at creation time can be displaced from its +*current* top-5 by later, closer arrivals — so a repair can legitimately +improve the node's general connectivity while doing nothing for, or even +crowding out attention from, the specific original edge being measured. +Compounding this, `scoring::local_coherence` (mean adjacent edge weight) +feeds directly into the compaction retention score, so a repaired node's +higher connectivity makes it comparatively *more* likely to survive future +compaction rounds — which, since compaction always evicts a fixed count +per interval, makes some other node *more* likely to be evicted instead. +That second-order effect can remove precisely the neighbors the metric +credits, in variants where repair ran and baseline did not. + +## Consequences + +- `ruvector-coherence::spectral` gains a second validated consumer + (`HnswHealthMonitor`/`estimate_fiedler`, used unmodified) beyond its + original HNSW-index-health use case, with evidence it generalizes + cleanly to an agent-memory association graph without any change to the + reused crate. +- The specific repair heuristic (Fiedler-vector-magnitude node selection → + reconnect to current top-k semantic neighbors) is falsified for the + "restore original associations" goal and should not be reused as-is by a + future attempt at this direction. +- The witness-chain pattern (hash-chained repair receipts, following the + 2026-08-13 retrieval-receipts technique) worked exactly as designed — + 100% chain integrity across every run — and is a reusable building block + independent of whether the repair itself is useful. +- No production code path is affected; this stays an experimental, + non-workspace-default crate pending a redesigned repair mechanism or a + decision to abandon the repair half of this direction while keeping the + detection half. + +## Alternatives (for a follow-on attempt, not implemented here) + +1. **Snapshot-restore repair**: instead of reconnecting to *current* + top-k, repair could re-add the *specific* original edge if the + neighbor is still alive (a much narrower, more mechanically obvious + fix — but see Rejection Criteria below on why this wasn't the first + thing tried: it doesn't test whether the spectral signal is useful for + anything beyond "put back what was removed", which is a weaker claim). +2. **Decouple repair from compaction scoring**: exclude repaired edges + from `local_coherence` (or discount them) so repair cannot change + *who* gets evicted, isolating whether the negative result is really the + second-order compaction-interaction effect described above. +3. **Reduce monitor cost** via `SpectralTracker::update_edge`'s + incremental path (built into `ruvector-coherence` already, unused by + this experiment, which always called `full_recompute` for simplicity + and measurement clarity) before any latency-sensitive production + consideration. + +## Implementation Plan + +Not applicable — rejected for promotion. If a follow-on nightly pursues +Alternative 1 or 2 above, it should be a new dated nightly topic, not a +silent edit to this one, per the "never silently change the hypothesis +after seeing results" rule; this ADR's numbers stand as the record of what +was actually tried and measured here. + +## API Shape + +`ruvector-memory-self-repair` exposes: `MemoryGraph`, `SimConfig` + +`build_arrivals`/`build_drift_schedule`/`apply_arrival`/ +`apply_compaction`/`apply_drift_wave`, `Runner`/`RepairConfig`/`Variant` +(`NoRepair` | `MonitorOnly` | `SpectralRepair`), `WitnessLog`, and +`mean_recall_at_k`. None of this is intended as a stable public API; it is +research-tier scaffolding for reproducing or extending this experiment. + +## Feature Flags + +None. The crate is entirely experimental and workspace-buildable but not +depended on by any production crate. + +## Benchmark Evidence + +See the Evidence section above and +`docs/research/nightly/2026-08-16-spectral-memory-self-repair/README.md` +for full detail, raw JSON evidence, and the reproduction command. + +## Security + +- Repair actions are bounded (≤12 nodes, ≤3 reconnects each per event) and + can only *add* edges derived from the node's own already-stored, + never-decayed embedding — it cannot fabricate associations to memories + it has no legitimate semantic basis for connecting to. +- Every repair produces a SHA-256 hash-chained witness receipt + (`WitnessLog`); `verify()` detects any single mutated field in any + receipt in the chain (unit-tested: `witness::tests::tampering_is_detected`). +- This experiment never grants the monitored/repaired system authority to + evict or delete anything beyond the existing compaction policy it did + not change; repair is strictly edge-additive. + +## Governance + +No autonomous production authority is proposed or implied. This ADR +documents a rejected design; nothing here should be wired into a live +agent-memory path without a follow-on nightly that either fixes the +mechanism (see Alternatives) or independently re-validates a different one. + +## Failure Modes + +- **Monitor cost dominates at high check frequency**: at `MONITOR_INTERVAL + = 10` steps, monitoring alone was ~14× baseline wall time on a ~2600-node + graph using full Laplacian recompute every check. Not tuned or optimized + in this experiment; see Alternatives #3. +- **Repair can compound compaction bias**: see mechanism discussion above; + not confirmed by ablation, flagged as the leading hypothesis. +- **Alert precision (~49%) was not disambiguated** between "false alarm" + and "correctly detecting compaction-driven drift the injection schedule + didn't cause" — an open question, not resolved here. + +## Migration + +None — nothing in production depends on this crate. + +## Rollback + +Trivial: the crate is additive, workspace-member-only, and not depended on +by any other crate. Removing it from `Cargo.toml`'s `members` list and +deleting `crates/ruvector-memory-self-repair` fully reverts this change. + +## Rejection Criteria + +This specific repair mechanism (Fiedler-magnitude node selection → current +top-k semantic reconnection) should remain rejected unless a follow-on +experiment demonstrates, with the same or stricter methodology (frozen +ground truth, tightened search budget, multi-seed reporting, unmodified +reuse of `ruvector-coherence::spectral`): + +- A statistically consistent **positive** recall uplift across ≥3 seeds + (not just a single favorable run), or +- A clean ablation showing the negative result was entirely the + compaction-interaction second-order effect (Alternative #2), in which + case a decoupled variant should be re-benchmarked before any promotion + decision. + +## Open Questions + +- Is the ~49% alert precision actually a false-positive problem, or is the + monitor correctly also catching compaction-driven (non-injected) + structural drift? Answering this needs an ablation with compaction + disabled, isolating drift-only alerts. +- Would Alternative 1 (snapshot-restore repair) meet the acceptance + threshold, and if so, does that mean the spectral trigger is doing + useful work, or would a much simpler "periodically re-check original + edges still exist" policy achieve the same result without any spectral + computation at all? This is the sharpest open question for whether + spectral monitoring earns its cost in this application, as opposed to + the HNSW-index-health application it was originally built for. +- Does `SpectralTracker::update_edge`'s incremental-update path (unused + here) change the overhead picture enough to make higher-frequency + monitoring viable, independent of whether repair itself is fixed? diff --git a/docs/research/nightly/2026-08-16-spectral-memory-self-repair/README.md b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/README.md new file mode 100644 index 0000000000..7486b08760 --- /dev/null +++ b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/README.md @@ -0,0 +1,488 @@ +# Spectral Drift Detection and Witness-Gated Repair for Agent Memory Graphs + +**Date**: 2026-08-16 +**Crate**: `ruvector-memory-self-repair` (`crates/ruvector-memory-self-repair`) +**Status**: PoC complete — **partial negative result**: detection validated, repair mechanism rejected +**ADR**: [ADR-305](../../../adr/ADR-305-spectral-memory-self-repair.md) + +--- + +## Summary + +An agent-memory graph accumulates structural drift as it evolves: coherence-weighted +compaction evicts memories, and topic associations weaken as an agent's context moves +on. `ruvector-coherence` already computes a Spectral Coherence Score (Fiedler value, +spectral gap, effective resistance, degree regularity) for HNSW *index* health — this +experiment reused that primitive, unmodified, as a drift detector for an agent-memory +*association* graph, and added a bounded, witness-logged repair action triggered by it. + +**Detection worked**: the reused spectral monitor caught 49/49 injected drift events +(100% recall) within a 20-step window, across three seeds, using the crate's existing +public API with no modification. + +**Repair did not work**: the targeted reconnection heuristic (find the alive nodes +closest to a weak graph cut via the Fiedler eigenvector, reconnect them to their +*current* top-k semantic neighbors) produced a small, consistently **negative** +recall change relative to a no-repair baseline — −1.16pp, −0.62pp, −0.92pp across +seeds 42/7/123 respectively, against a pre-registered +10pp acceptance threshold. +The repair stayed correctly bounded (3.0–3.1% of final graph edges, well under the +8% cap) and the witness chain verified with 100% integrity on every run — it just +didn't help, and the most likely mechanism (detailed below) is a second-order +interaction with compaction scoring that this PoC did not isolate. + +**Acceptance result: REJECT** for the repair mechanism. **ACCEPT** for the reused +detection primitive generalizing cleanly to a new domain. + +--- + +## Hypothesis + +``` +Given an agent memory graph (nodes = memories, edges = semantic top-5 + +temporal-chain associations) evolving over 3000 memory arrivals with periodic +coherence-weighted compaction (evict 3 lowest-retention-score memories every +25 steps) and injected structural drift (cross-topic bridge edges decayed to +15% of weight, or removed below a floor, cycling through all topic pairs +every 60 steps), + +when a bounded repair reconnects the alive nodes with the smallest-magnitude +Fiedler-eigenvector components to their current top-5 semantic neighbors, +capped at 12 nodes and 3 new edges each per repair event, triggered whenever +the reused ruvector-coherence health monitor raises ≥2 simultaneous alerts, + +then recall@10 of each memory's originally-formed associative neighbors +(frozen at creation time, filtered to still-alive) should improve by at +least 10 percentage points relative to a no-repair baseline, + +subject to: repair touching ≤8% of the final graph's edges, a SHA-256 +hash-chained witness log verifying with 100% integrity, and the underlying +spectral primitives from ruvector-coherence remaining unmodified. +``` + +## Why This Matters Now (2026) — and the 10/20-Year Thesis + +Agent memory is the fastest-growing consumer of vector search capacity in the +ecosystem RuVector sits in, and unlike a static document corpus, it never stops +mutating: every write, eviction, and compaction pass is a small structural edit to +a graph the agent depends on for recall quality. Today (2026) that graph is either +rebuilt wholesale on a schedule (expensive, coarse) or never monitored at all +(silent quality decay). A cheap, reused structural-health signal that can trigger a +*targeted* fix — not a rebuild — is the obvious middle path, and this experiment +tested the most natural version of it. + +Over a 10–20 year horizon, the more interesting claim is architectural, not this +specific mechanism: an agent that runs continuously for months or years needs +memory infrastructure that **maintains itself** the way a filesystem needs +`fsck` or a database needs autovacuum — not a human- or cron-scheduled rebuild, +but a signal-driven, bounded, auditable self-maintenance loop native to the memory +substrate. This PoC is a negative result on one specific repair heuristic, not on +that architectural thesis; the Alternatives section in ADR-305 and the Next +Research section below are where that thesis gets tested again. + +## RuVector Ecosystem Fit + +This experiment deliberately connects five existing pieces, reusing four of them +unmodified: + +| Capability | Crate | Role here | +|---|---|---| +| Spectral graph health | `ruvector-coherence` (`spectral` feature) | Reused unmodified as the drift-detection trigger (`HnswHealthMonitor`, `estimate_fiedler`) | +| Coherence-weighted compaction | `ruvector-agent-memory` (2026-06-14 nightly) | Reimplemented locally (that crate is outside the cargo workspace) as the *baseline* eviction policy that generates realistic drift | +| Graph/mincut research lineage | `ruvector-attn-mincut`, `ruvector-namespace-merge` | Prior art establishing graph-structural methods as a first-class RuVector technique; not directly depended on | +| Witness/provenance | 2026-08-13 retrieval-receipts nightly | Technique reused (hash-chained receipts), implemented fresh in `witness.rs` rather than as a cross-crate dependency | +| Agent memory | this PoC's `MemoryGraph` | The subject graph itself | + +### MetaHarness + +`npx metaharness --help` is available in this environment (a scaffolding tool +for generating new agentic-harness projects — templates, hosts, a scorecard +command) but there is no installed `npx ruvector harness` binary in this +repository/environment (`npm error could not determine executable to run`). +This nightly run therefore did **not** use MetaHarness for goal decomposition, +context isolation, or evidence collection — those roles were performed directly +in-session. This is recorded honestly per the process's Step 0/47 requirement to +verify capability availability rather than assume it. + +### Flywheel / Darwin + +Same finding: no `ruvector harness flywheel` or `ruvector harness darwin` CLI is +installed. No Darwin evolutionary search was run — there was exactly one baseline +and one candidate repair design, evaluated once (across 3 seeds), and rejected. Per +the nightly process's own rule ("if no Darwin candidate improves the parent, keep +the parent"), the correct outcome given no Darwin infrastructure and a rejected +single candidate is identical: nothing is promoted, and this ADR is the retained +evidence a future run should consult before re-trying a similar mechanism. + +### ruFlo + +A validated version of this mechanism (see Alternatives in ADR-305) is a natural +ruFlo workflow: "periodically compute spectral health on the live agent-memory +graph; on alert, run bounded repair; log a witness receipt; escalate to a full +compaction/rebuild only if repair fails to recover health within N cycles." This +PoC's `Runner` is structured so that loop could be lifted directly into a ruFlo +scheduled task once the repair heuristic itself is fixed. + +### MCP + +If promoted, this would expose a narrow, read-mostly tool: `memory_health_check` +(inputs: namespace; outputs: `SpectralCoherenceScore` + alert list; no mutation) and +a separate, explicitly-authorized `memory_repair` tool gated the way +`ruvector-capgated`/`ruvector-proof-gate` gate writes today. Not implemented here — +this stays out of scope until the repair mechanism itself is fixed. + +### WASM / Edge + +`ruvector-coherence::spectral` already runs pure-Rust CG/power-iteration with no +external solver dependency (checked in its own source: "Self-contained, no external +solver deps"), so it is WASM-compatible in principle. This PoC did not build or +measure a WASM target — flagged as unverified, not claimed. + +### RVF / RVM + +**RVF**: a memory graph's spectral health score plus its witness chain are exactly +the kind of small, signed, portable state an RVF cognitive-state package could +carry across devices — "this agent's memory graph was healthy as of receipt +``" is a deterministic, replayable claim. Not implemented; flagged as +materially relevant per Step 27's requirement to evaluate this explicitly. + +**RVM**: repair-as-privileged-operation is a natural RVM-enforced boundary +("mutating agent memory structure requires the `memory-repair` capability, itself +gated on a valid spectral-alert witness") — again not implemented, flagged as +relevant per Step 28. + +--- + +## Architecture + +```mermaid +flowchart TB + subgraph Timeline["Deterministic simulation (identical across all 3 variants)"] + A[Memory arrival
topic-clustered embedding] --> B[Wire semantic top-5
+ temporal chain edge] + B --> C{step % 25 == 0?} + C -->|yes| D[Coherence-weighted
compaction: evict 3] + C -->|no| E + D --> E{drift wave
scheduled?} + E -->|yes| F[Decay/remove cross-topic
bridge edges] + E -->|no| G[Next step] + F --> G + end + + G --> H{variant} + H -->|NoRepair| I[baseline: no monitoring] + H -->|MonitorOnly| J["ruvector_coherence::spectral
HnswHealthMonitor (reused, unmodified)"] + H -->|SpectralRepair| J + + J --> K{alerts >= 2?} + K -->|SpectralRepair only| L["estimate_fiedler → nodes nearest
the weak cut → reconnect to
current top-k semantic neighbors
(bounded: 12 nodes / 3 edges each)"] + K -->|no, or MonitorOnly| M[record alert, no mutation] + L --> N[WitnessLog: SHA-256
hash-chained repair receipt] + + G --> O["mean_recall_at_10 vs each
memory's frozen original-neighbor set"] + O --> P[REJECT: repair uplift
−1.16 / −0.62 / −0.92 pp
across 3 seeds] +``` + +## Implementation + +Modules (`crates/ruvector-memory-self-repair/src/`): + +- `embedding.rs` — deterministic, seeded topic-clustered synthetic embeddings. +- `graph.rs` — `MemoryGraph`: nodes, weighted adjacency, alive/dead tracking, + Laplacian export for `ruvector_coherence::spectral::CsrMatrixView`, and each + node's frozen `original_neighbors` snapshot. +- `scoring.rs` — coherence-weighted retention scoring (recency + frequency + + local coherence), the baseline compaction policy. +- `drift.rs` — the deterministic arrival/compaction/drift-wave timeline, replayed + identically (same RNG-derived arrivals) into every variant's own graph. +- `retrieval.rs` — best-first graph-walk top-k retrieval and the recall@10 metric. +- `repair.rs` — `Runner`: ties `ruvector_coherence::spectral::HnswHealthMonitor` + (trigger) to the bounded, Fiedler-vector-guided repair action (target selection), + reconnecting via `graph.semantic_topk` (repair mechanism). +- `witness.rs` — SHA-256 hash-chained repair receipt log with `verify()`. +- `src/bin/benchmark.rs` — runs all 3 variants, prints a table, writes raw JSON + evidence (never hand-transcribed). + +Three variants, one identical underlying timeline per seed: `NoRepair` (baseline), +`MonitorOnly` (detection-only, causally inert — verified to produce byte-identical +graphs to `NoRepair`), `SpectralRepair` (the candidate). + +## Benchmark Methodology + +``` +cargo build --release -p ruvector-memory-self-repair +cargo run --release -p ruvector-memory-self-repair --bin benchmark +SIM_SEED=7 cargo run --release -p ruvector-memory-self-repair --bin benchmark +SIM_SEED=123 cargo run --release -p ruvector-memory-self-repair --bin benchmark +``` + +Hardware/toolchain: x86_64 Linux container, rustc 1.94.1, cargo 1.94.1, release +profile (`opt-level` per workspace default), single-threaded, no warmup needed +(one-shot deterministic simulation, not a steady-state throughput benchmark). +Config fixed before any run: `dim=24, n_topics=8, total_steps=3000, semantic_k=5, +embedding_noise=0.12, compaction_interval=25, compaction_evict_count=3, +drift_interval=60, drift_decay_factor=0.15, drift_max_edges_per_wave=40`, seeds +`{42, 7, 123}`. Recall measured over 600 candidate query ids (every 5th memory, +skipping those whose original-neighbor ground truth was fully evicted), graph-walk +budget 20 node expansions. + +### A methodology correction, kept in the record rather than erased + +The first run used ground truth = "any other alive same-topic memory" with a +200-expansion walk budget. **Every variant scored recall@10 = 1.0000** — the metric +was saturated and could not discriminate anything, because a few hundred same-topic +memories are typically alive and a top-10 metric is trivially satisfied by whatever +is locally abundant. This was diagnosed as a degenerate instrument, not a result. +Ground truth was redefined to each memory's own *originally-formed* neighbor set +(frozen at creation, filtered to still-alive) and the walk budget was tightened to +20 — both changes made before looking at which variant they'd favor, consistent +with the nightly process's rule against changing the hypothesis after seeing +results. The corrected methodology is what the Given/When/Then above describes and +the only version whose numbers are reported as evidence. + +## Benchmark Results (raw, 3 seeds) + +| seed | variant | alive nodes | edges | recall@10 | monitor calls | alerts | repairs | edges added | wall time (ms) | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 42 | NoRepair | 2643 | 14759 | 0.7603 | 0 | 0 | 0 | 0 | 212.3 | +| 42 | MonitorOnly | 2643 | 14759 | 0.7603 | 299 | 297 | 0 | 0 | 3052.4 | +| 42 | SpectralRepair | 2643 | 15159 | 0.7487 | 299 | 297 | 279 | 457 | 7185.4 | +| 7 | NoRepair | 2643 | 14796 | 0.7447 | 0 | 0 | 0 | 0 | 217.1 | +| 7 | MonitorOnly | 2643 | 14796 | 0.7447 | 299 | 298 | 0 | 0 | 3034.1 | +| 7 | SpectralRepair | 2643 | 15181 | 0.7385 | 299 | 298 | 279 | 448 | 7225.0 | +| 123 | NoRepair | 2643 | 14671 | 0.7538 | 0 | 0 | 0 | 0 | 213.9 | +| 123 | MonitorOnly | 2643 | 14671 | 0.7538 | 299 | 297 | 0 | 0 | 3027.1 | +| 123 | SpectralRepair | 2643 | 15083 | 0.7446 | 299 | 297 | 283 | 460 | 7403.2 | + +Detection quality (from `MonitorOnly`, which is causally inert relative to +`NoRepair` on every seed — a built-in sanity check): 49/49 injected drift waves +detected within a 20-step window on all three seeds (100% recall); alert precision +(alerts within that window of some injected drift) ≈49%, plausibly explained by the +monitor also correctly catching compaction-driven fragmentation the injection +schedule didn't cause — not disambiguated, listed as an open question. + +**Recall uplift (spectral_repair − baseline)**: −1.16pp / −0.62pp / −0.92pp for +seeds 42/7/123. Threshold was +10pp. **Consistently negative, not noise around +zero.** + +**Repair boundedness**: 3.0–3.1% of final graph edges added by repair, under the +8% cap on every seed. + +**Witness integrity**: 279–283 repair receipts per seed, `WitnessLog::verify()` +returned `Ok(())` on every run, including the dedicated acceptance test suite. + +Full raw JSON per seed (not hand-transcribed into this table — generated directly +by the benchmark binary): `/tmp/.../spectral-memory-self-repair-evidence-seed{42,7,123}.json` +in the run's scratch directory; reproduce with the commands above. + +## Memory and Performance Math + +- Graph at step 3000: ~2643 alive nodes, ~14.7–14.8k edges (baseline), i.e. ~5.6 + edges/node average degree (semantic_k=5 + temporal chain, minus compaction/drift + losses). +- Monitor overhead: baseline 212ms → monitor-only ~3.0s (≈14×) → +repair ~7.2s + (≈34×) for the full 3000-step run, with monitoring every 10 steps (299 full + Laplacian recomputes + CG-based Fiedler estimation). This is `full_recompute` + every check; `SpectralTracker::update_edge`'s incremental path exists in + `ruvector-coherence` but was not used here (see Limitations). +- Repair cost: ~457 edges added over 279 repair events ≈ 1.6 edges/event, tightly + matching the configured cap (max 3 semantic reconnects × 12 nodes = 36/event + ceiling, rarely approached since most candidate edges already exist). + +## Failure Modes + +- Monitor cost scales with check frequency × full-recompute cost; at 10-step + intervals on a ~2600-node graph this is the dominant cost in the `SpectralRepair` + variant (see wall-time table). +- Repair's node-targeting (Fiedler-vector magnitude) and edge-targeting (current + top-k semantic neighbors) are decoupled from the recall metric's specific ground + truth (originally-formed neighbors), which is the leading hypothesis for the + negative result — see ADR-305's mechanism discussion. +- Alert precision (~49%) was not disambiguated between true/false positives. + +## Rejected Alternatives + +See ADR-305 "Alternatives" — snapshot-restore repair (re-add the specific decayed +original edge rather than reconnecting to current top-k), decoupling repair edges +from compaction retention scoring, and using the existing incremental spectral +update path to reduce monitor cost. None implemented in this PoC; each is a +distinct, testable next experiment. + +## Security + +Repair is strictly edge-additive, bounded per event, and can only connect a node to +targets derived from its own stored (never-decayed) embedding — it has no path to +fabricate an association without a genuine semantic basis. Every repair action is +logged as a SHA-256 hash-chained witness receipt; tampering with any field in any +receipt is detected by `verify()` (unit-tested). No autonomous production authority +is proposed; this is research-tier and not wired into any live path. + +## Governance + +None implied beyond the existing coherence-weighted compaction policy this +experiment reused but did not modify. A future promoted version would need the +narrow, explicitly-authorized MCP tool split described above (read-only health +check vs. gated repair) before any production exposure. + +## Practical Applications + +1. **Long-running coding-agent memory** — an agent maintaining project context over + months; detects when its own memory graph is fragmenting without a human + noticing degraded recall. RuVector: `ruvector-coherence` + agent memory. Risk: + this PoC's repair doesn't yet fix it. Horizon: near-term once repair is fixed. +2. **Customer-support agent memory audits** — periodic spectral health reports as a + cheap proxy for "is this agent's knowledge base decaying" without full recall + evaluation. Risk: needs the detection-only half validated at production scale. +3. **Multi-tenant agent-memory SaaS** — per-tenant health scoring to prioritize + which tenants' graphs need a full rebuild vs. are fine. Risk: requires the + `ruvector-agent-memory` production integration this PoC reimplemented locally. +4. **Compliance/audit trails for memory maintenance** — the witness chain gives an + auditor a tamper-evident log of every automated structural change to an agent's + memory. Risk: none specific to this PoC; the witness mechanism worked. +5. **Edge/offline agents** — spectral computation is pure Rust, no external solver; + plausible for constrained devices, unverified here. +6. **Index-health dashboards** — reuse `HnswHealthMonitor` (already exists) as a + general graph-structure dashboard signal across both HNSW indices and agent + memory in the same operational view. +7. **RAG pipeline debugging** — "why did recall drop" investigations could start + from spectral health history instead of re-running expensive recall evals. +8. **Research tooling** — this PoC's timeline/drift-injection harness is directly + reusable for testing *other* repair heuristics (Alternatives 1–2) without + rebuilding the simulation. + +## Long-Horizon Applications + +1. **Self-maintaining agent memory as infrastructure primitive** — the filesystem/ + database analogy in "Why This Matters" above; requires a repair mechanism that + actually works, which this PoC did not find. Falsification path: if no repair + heuristic beats the +10pp bar across several follow-on attempts, the + architectural thesis itself (not just this mechanism) should be questioned. +2. **Swarm memory coherence** — multiple agents sharing a namespace-partitioned + memory graph (`ruvector-namespace-merge`), each running local spectral health + checks, escalating cross-namespace repair only when locally bounded repair + fails. Requires: a working single-graph repair mechanism first. +3. **Proof-gated autonomous memory mutation** — RVM-enforced capability boundary + around repair actions, building on this PoC's witness chain. Requires: RVM + integration (not attempted here). +4. **Portable cognitive state (RVF)** — a signed "memory graph health" claim + travels with an agent's RVF package across devices. Requires: RVF integration + (not attempted here). +5. **Robotics/embodied agent memory** — physical-world association graphs (spatial + + temporal + semantic) facing the same drift problem under real-time + constraints tighter than this PoC's ~7s/3000-step overhead. Primary + uncertainty: whether spectral computation is cheap enough at that latency + budget; not measured here. +6. **World-model consistency checking** — spectral health as a general "is this + learned structure still coherent" signal beyond agent memory specifically. +7. **Synthetic-nervous-system-style self-repair** — the detect→bounded-repair→ + witness loop generalizes beyond memory graphs to any RuVector graph structure + (routing tables, capability graphs); this PoC is the first concrete data point + on whether the "obvious" repair heuristic for such a loop actually works (it + didn't, here). +8. **Regulatory/audit-grade autonomous infrastructure** — witness-chained, + bounded, explicitly-gated self-maintenance as a template other autonomous + RuVector subsystems could adopt regardless of this specific mechanism's fate. + +## Competitor Comparison + +No major vector database (Milvus, Qdrant, Weaviate, Pinecone, LanceDB, FAISS, +pgvector, Chroma, Vespa) documents spectral graph-health monitoring or +witness-gated structural self-repair for agent-memory graphs specifically, based on +their public documentation (`documented_external_capability`; not independently +verified against source, so treated as `unknown` rather than a claimed +differentiator). This experiment's architectural difference (`RuVector_architectural_difference`) +is reusing an existing, unmodified in-repo primitive (`ruvector-coherence::spectral`) +built for a different purpose (HNSW index health) and testing its generalization — +that reuse-and-generalize pattern, not a performance claim, is what's novel here, +and the result (detection generalizes, this repair heuristic does not) is the +honest finding. + +## Evolution Results (Darwin) + +Not run — no `ruvector harness darwin` CLI is installed in this environment (see +MetaHarness/Flywheel/Darwin section above). Exactly one candidate repair design was +implemented and evaluated across 3 seeds; it was rejected. Per the nightly process's +rule, the correct outcome with no Darwin infrastructure and a rejected candidate is +identical to "no candidate improved the parent, keep the parent": nothing is +promoted, and this write-up plus ADR-305 are the retained lineage evidence. + +## Promotion Decision + +**REJECT** the repair mechanism as designed. **ACCEPT** (informally — not a +production promotion, but a validated finding) that `ruvector-coherence::spectral` +generalizes cleanly, unmodified, to a new graph domain (agent-memory associations) +it was not originally built for. See ADR-305 "Rejection Criteria" for what a +follow-on attempt would need to show to reverse the repair-mechanism verdict. + +## Witness Evidence + +`WitnessLog` (SHA-256 hash chain, `crates/ruvector-memory-self-repair/src/witness.rs`) +recorded 279, 279, and 283 repair receipts for seeds 42, 7, and 123 respectively; +`verify()` returned `Ok(())` on every run and every `cargo test` invocation of the +acceptance suite. Unit tests (`witness::tests::tampering_is_detected`) confirm a +single mutated field anywhere in the chain is caught. + +## Production Path + +None at this time. A production path would require: (1) a repair mechanism that +clears the acceptance bar (see ADR-305 Alternatives), (2) the compaction-interaction +ablation to understand *why* this one failed, (3) moving `ruvector-agent-memory` +into the cargo workspace so a real dependency (not a local reimplementation of its +scoring formula) can be used, (4) an MCP surface per the narrow design sketched +above, and (5) RVM-gated authority before any autonomous deployment. + +## Falsification Criteria (pre-registered, met) + +The hypothesis was falsified as specified: recall uplift did not reach +10pp on any +of 3 seeds, and was consistently negative rather than noisy-around-the-threshold. + +## What Was Not Claimed + +- No claim that spectral monitoring is the *only* or *best* way to detect + agent-memory drift — only that this specific reuse of an existing primitive + detects the specific injected drift pattern tested here. +- No claim about performance relative to any competitor vector database. +- No claim about WASM/edge viability beyond "the underlying spectral code has no + external solver dependency" — not built or measured for WASM here. +- No claim that the negative repair result generalizes beyond this specific + heuristic; the Alternatives in ADR-305 are explicitly untested, not implicitly + ruled out. + +## Limitations + +- Synthetic embeddings/topics, not real agent-memory content or real query + distributions. +- `ruvector-agent-memory`'s exact compaction formula was not depended on + (workspace-membership issue), only its documented shape reimplemented — a + production integration should use the real crate directly. +- Single machine, single-threaded; no concurrent-write stress test. +- No WASM build or measurement. +- The compaction-interaction hypothesis for the negative result is not confirmed + by ablation. + +## Next Research + +1. Ablation: decouple repaired edges from `local_coherence`/compaction scoring, + re-run the identical benchmark, and see whether the sign flips (tests the + leading mechanism hypothesis directly). +2. Snapshot-restore repair heuristic (Alternative 1 in ADR-305) as a cleaner, + narrower test of whether *any* repair helps before concluding the whole + direction is dead. +3. Real integration with `ruvector-agent-memory` (move it into the workspace or + depend on it via a path outside the workspace) instead of a local + reimplementation of its scoring formula. +4. Measure `SpectralTracker::update_edge`'s incremental-update path against the + `full_recompute` used throughout this PoC, to quantify how much of the ~14–34× + monitor overhead is avoidable. + +## References + +- `ruvector-coherence` spectral module (in-repo, added 2026-07-28): + `crates/ruvector-coherence/src/spectral.rs` +- 2026-06-14 agent-memory-compaction nightly: + `docs/research/nightly/2026-06-14-agent-memory-compaction/` +- 2026-06-13 temporal-coherence-agent-memory nightly (source of the "spectral gate + future" forward reference this experiment closes): + `docs/research/nightly/2026-06-13-temporal-coherence-agent-memory/` +- 2026-08-13 retrieval-receipts nightly (witness-chain technique reused): + `docs/research/nightly/2026-08-13-retrieval-receipts/` +- 2026-08-08 namespace-merge-mincut nightly (prior graph-structural technique in + the same lineage): `docs/research/nightly/2026-08-08-namespace-merge-mincut/` diff --git a/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed123.json b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed123.json new file mode 100644 index 0000000000..da6fac413c --- /dev/null +++ b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed123.json @@ -0,0 +1,62 @@ +{ + "config": "SimConfig { dim: 24, n_topics: 8, total_steps: 3000, semantic_k: 5, embedding_noise: 0.12, compaction_interval: 25, compaction_evict_count: 3, drift_interval: 60, drift_decay_factor: 0.15, drift_max_edges_per_wave: 40, seed: 123 }", + "seed": 123, + "total_steps": 3000, + "baseline": { + "variant": "NoRepair", + "final_alive_nodes": 2643, + "final_edge_count": 14671, + "mean_recall_at_10": 0.7537820512820478, + "recall_queries_counted": 520, + "monitor_calls": 0, + "alert_steps": 0, + "repairs_triggered": 0, + "cumulative_edges_added": 0, + "repair_edge_fraction_of_final_graph": 0.0, + "wall_time_ms": 213.909, + "witness_chain_valid": null, + "witness_receipt_count": 0 + }, + "monitor_only": { + "variant": "MonitorOnly", + "final_alive_nodes": 2643, + "final_edge_count": 14671, + "mean_recall_at_10": 0.7537820512820478, + "recall_queries_counted": 520, + "monitor_calls": 299, + "alert_steps": 297, + "repairs_triggered": 0, + "cumulative_edges_added": 0, + "repair_edge_fraction_of_final_graph": 0.0, + "wall_time_ms": 3027.071, + "witness_chain_valid": true, + "witness_receipt_count": 0 + }, + "spectral_repair": { + "variant": "SpectralRepair", + "final_alive_nodes": 2643, + "final_edge_count": 15083, + "mean_recall_at_10": 0.7446144040790278, + "recall_queries_counted": 523, + "monitor_calls": 299, + "alert_steps": 297, + "repairs_triggered": 283, + "cumulative_edges_added": 460, + "repair_edge_fraction_of_final_graph": 0.030497911556056488, + "wall_time_ms": 7403.172, + "witness_chain_valid": true, + "witness_receipt_count": 283 + }, + "detection": { + "true_drift_events": 49, + "detected_within_window": 49, + "detection_recall": 1.0, + "alerts_total": 297, + "alerts_within_window_of_any_drift": 147, + "alert_precision": 0.494949494949495 + }, + "recall_uplift_pp": -0.9167647203019991, + "acceptance_threshold_pp": 10.0, + "max_repair_edge_fraction": 0.08, + "acceptance_result": "REJECT (recall uplift below acceptance threshold)" +} \ No newline at end of file diff --git a/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed42.json b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed42.json new file mode 100644 index 0000000000..b6c74e2dd0 --- /dev/null +++ b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed42.json @@ -0,0 +1,62 @@ +{ + "config": "SimConfig { dim: 24, n_topics: 8, total_steps: 3000, semantic_k: 5, embedding_noise: 0.12, compaction_interval: 25, compaction_evict_count: 3, drift_interval: 60, drift_decay_factor: 0.15, drift_max_edges_per_wave: 40, seed: 42 }", + "seed": 42, + "total_steps": 3000, + "baseline": { + "variant": "NoRepair", + "final_alive_nodes": 2643, + "final_edge_count": 14759, + "mean_recall_at_10": 0.7602950408035113, + "recall_queries_counted": 531, + "monitor_calls": 0, + "alert_steps": 0, + "repairs_triggered": 0, + "cumulative_edges_added": 0, + "repair_edge_fraction_of_final_graph": 0.0, + "wall_time_ms": 212.262, + "witness_chain_valid": null, + "witness_receipt_count": 0 + }, + "monitor_only": { + "variant": "MonitorOnly", + "final_alive_nodes": 2643, + "final_edge_count": 14759, + "mean_recall_at_10": 0.7602950408035113, + "recall_queries_counted": 531, + "monitor_calls": 299, + "alert_steps": 297, + "repairs_triggered": 0, + "cumulative_edges_added": 0, + "repair_edge_fraction_of_final_graph": 0.0, + "wall_time_ms": 3052.416, + "witness_chain_valid": true, + "witness_receipt_count": 0 + }, + "spectral_repair": { + "variant": "SpectralRepair", + "final_alive_nodes": 2643, + "final_edge_count": 15159, + "mean_recall_at_10": 0.7486842105263118, + "recall_queries_counted": 532, + "monitor_calls": 299, + "alert_steps": 297, + "repairs_triggered": 279, + "cumulative_edges_added": 457, + "repair_edge_fraction_of_final_graph": 0.030147107328979485, + "wall_time_ms": 7185.362, + "witness_chain_valid": true, + "witness_receipt_count": 279 + }, + "detection": { + "true_drift_events": 49, + "detected_within_window": 49, + "detection_recall": 1.0, + "alerts_total": 297, + "alerts_within_window_of_any_drift": 147, + "alert_precision": 0.494949494949495 + }, + "recall_uplift_pp": -1.1610830277199446, + "acceptance_threshold_pp": 10.0, + "max_repair_edge_fraction": 0.08, + "acceptance_result": "REJECT (recall uplift below acceptance threshold)" +} \ No newline at end of file diff --git a/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed7.json b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed7.json new file mode 100644 index 0000000000..100f569c4a --- /dev/null +++ b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/evidence/spectral-memory-self-repair-evidence-seed7.json @@ -0,0 +1,62 @@ +{ + "config": "SimConfig { dim: 24, n_topics: 8, total_steps: 3000, semantic_k: 5, embedding_noise: 0.12, compaction_interval: 25, compaction_evict_count: 3, drift_interval: 60, drift_decay_factor: 0.15, drift_max_edges_per_wave: 40, seed: 7 }", + "seed": 7, + "total_steps": 3000, + "baseline": { + "variant": "NoRepair", + "final_alive_nodes": 2643, + "final_edge_count": 14796, + "mean_recall_at_10": 0.7447237740533794, + "recall_queries_counted": 537, + "monitor_calls": 0, + "alert_steps": 0, + "repairs_triggered": 0, + "cumulative_edges_added": 0, + "repair_edge_fraction_of_final_graph": 0.0, + "wall_time_ms": 217.127, + "witness_chain_valid": null, + "witness_receipt_count": 0 + }, + "monitor_only": { + "variant": "MonitorOnly", + "final_alive_nodes": 2643, + "final_edge_count": 14796, + "mean_recall_at_10": 0.7447237740533794, + "recall_queries_counted": 537, + "monitor_calls": 299, + "alert_steps": 298, + "repairs_triggered": 0, + "cumulative_edges_added": 0, + "repair_edge_fraction_of_final_graph": 0.0, + "wall_time_ms": 3034.11, + "witness_chain_valid": true, + "witness_receipt_count": 0 + }, + "spectral_repair": { + "variant": "SpectralRepair", + "final_alive_nodes": 2643, + "final_edge_count": 15181, + "mean_recall_at_10": 0.7385455680399465, + "recall_queries_counted": 534, + "monitor_calls": 299, + "alert_steps": 298, + "repairs_triggered": 279, + "cumulative_edges_added": 448, + "repair_edge_fraction_of_final_graph": 0.02951057242605889, + "wall_time_ms": 7224.952, + "witness_chain_valid": true, + "witness_receipt_count": 279 + }, + "detection": { + "true_drift_events": 49, + "detected_within_window": 49, + "detection_recall": 1.0, + "alerts_total": 298, + "alerts_within_window_of_any_drift": 147, + "alert_precision": 0.49328859060402686 + }, + "recall_uplift_pp": -0.6178206013432885, + "acceptance_threshold_pp": 10.0, + "max_repair_edge_fraction": 0.08, + "acceptance_result": "REJECT (recall uplift below acceptance threshold)" +} \ No newline at end of file diff --git a/docs/research/nightly/2026-08-16-spectral-memory-self-repair/gist.md b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/gist.md new file mode 100644 index 0000000000..d68f24dd57 --- /dev/null +++ b/docs/research/nightly/2026-08-16-spectral-memory-self-repair/gist.md @@ -0,0 +1,145 @@ +# Reusing an HNSW Health Monitor to Detect (and Fail to Fix) Agent Memory Drift + +## Problem + +Agent memory graphs — nodes are memories, edges are semantic/temporal +associations — drift structurally as they run: compaction evicts memories, and +associations that once connected two topics weaken as an agent's context moves on. +Most systems either rebuild the whole structure on a schedule (coarse, expensive) +or never check at all (silent quality decay). Is there a cheap, reused signal that +can trigger a *targeted* fix instead? + +## Hypothesis + +`ruvector-coherence` already ships a Spectral Coherence Score — Fiedler value, +spectral gap, effective resistance, degree regularity — built for monitoring HNSW +*index* health. Nothing had applied it to an agent-memory *association* graph, or +paired it with a repair action. The hypothesis: reuse the monitor unmodified as a +drift trigger, add a bounded repair (reconnect the nodes nearest a weak graph cut, +per the Fiedler eigenvector, to their current semantic neighbors), and see whether +recall of each memory's original associations recovers after injected drift. + +## Technical Design + +A deterministic simulation (seeded, reproducible) runs 3000 memory arrivals against +8 topic clusters, with coherence-weighted compaction every 25 steps (evict the 3 +lowest-scoring memories: recency + frequency + local edge-weight coherence) and +injected drift every 60 steps (decay cross-topic bridge edges to 15% weight, or +remove them below a floor, cycling through all topic pairs). Three variants share +the exact same arrival/compaction/drift script: + +- **NoRepair** — no monitoring, the baseline. +- **MonitorOnly** — runs `ruvector_coherence::spectral::HnswHealthMonitor` every 10 + steps, records alerts, never mutates the graph. +- **SpectralRepair** — same monitoring; on ≥2 simultaneous alerts, reconnects the + 12 alive nodes with smallest `|fiedler_vector[i]|` (closest to a weak cut) to up + to 3 current top-k semantic neighbors each, and logs a SHA-256 hash-chained + witness receipt for every repair. + +Ground truth for scoring: each memory's neighbor set *at the moment it was +created*, frozen and filtered to still-alive nodes at measurement time. Retrieval +is a tightly-budgeted (20-expansion) best-first graph walk, not a raw +nearest-neighbor lookup — it's testing whether the *graph structure* still +supports finding what it once could. + +## Implementation + +Real Rust, `crates/ruvector-memory-self-repair`, 8 source modules plus a benchmark +binary and an integration-test acceptance suite, workspace member, depends on +`ruvector-coherence` (path dependency, `spectral` feature) — not a reimplementation +of the spectral math, an actual reuse of the existing crate. + +## Actual Benchmark Evidence + +Three seeds (42, 7, 123), release build, x86_64 Linux, rustc 1.94.1: + +| seed | baseline recall@10 | repair recall@10 | uplift | +|---:|---:|---:|---:| +| 42 | 0.7603 | 0.7487 | −1.16 pp | +| 7 | 0.7447 | 0.7385 | −0.62 pp | +| 123 | 0.7538 | 0.7446 | −0.92 pp | + +Pre-registered acceptance threshold: **+10pp**. Result: **consistently negative** +across all three seeds — not noise around zero. + +What *did* work: drift detection. 49/49 injected drift waves were caught within a +20-step window on every seed (100% recall), using the health monitor's existing, +unmodified public API. `MonitorOnly` produced a graph byte-identical to `NoRepair` +on every seed (edge count, alive-node count match exactly) — confirming monitoring +itself is causally inert, which isolates the repair action as the sole source of +the (negative) recall difference. Repair stayed correctly bounded (3.0–3.1% of +final edges, under an 8% cap) and the witness chain verified with zero failures +across 279–283 receipts per seed. + +### A methodology bug worth naming + +The first version of this benchmark scored ground truth as "any other alive +same-topic memory" with a 200-expansion search budget. Every variant — including +the undrifted baseline — hit **recall@10 = 1.0000**. That's not a result, it's a +saturated instrument: with hundreds of same-topic memories typically alive, a +top-10 metric is satisfied by whatever's locally abundant, never forcing the walk +to cross a decayed bridge. Fixed by narrowing ground truth to each memory's frozen +original neighbors and tightening the budget to 20 — before looking at which +variant that favored, not after. + +## Why Repair Likely Hurt (Hypothesis, Not Confirmed) + +Repair reconnects to a node's *current* top-k semantic neighbors, not necessarily +the *specific* neighbor the metric is scored against — by step 3000, newer, closer +arrivals can have displaced a node's original best match from its current top-5. +Worse, the repaired edges feed into `local_coherence`, which feeds into compaction's +retention score — so a repaired node becomes comparatively less likely to be +evicted, which (since compaction always evicts a fixed count) makes some other node +more likely to be evicted instead, possibly removing exactly the neighbor the +metric credits. Not confirmed by an ablation in this PoC; that ablation is the +top item in Next Research. + +## Limitations + +Synthetic data, not real agent-memory content. `ruvector-agent-memory`'s exact +compaction formula was reimplemented locally (it's outside the cargo workspace) +rather than depended on directly. Single-threaded, no concurrency stress test. No +WASM build measured, though the underlying spectral code has no external solver +dependency. The compaction-interaction mechanism is a hypothesis, not a proven +cause. + +## Production Relevance + +None yet, and that's the honest headline: this specific repair heuristic is +rejected. What's production-relevant is narrower and still useful — the spectral +health monitor built for HNSW indices generalizes, unmodified, to a completely +different graph domain, and the witness-chain pattern for auditing autonomous +structural changes worked exactly as designed. Both are reusable building blocks +for whichever repair heuristic eventually clears the bar this one didn't. + +## RuVector Ecosystem Implications + +Connects `ruvector-coherence` (spectral monitor, reused unmodified), agent-memory +compaction (2026-06-14 nightly), and the witness/provenance pattern (2026-08-13 +retrieval-receipts nightly) — closing a "spectral gate future" placeholder left +open in the 2026-06-13 temporal-coherence-agent-memory nightly. A follow-on that +fixes the repair mechanism is a natural ruFlo scheduled workflow and, longer term, +an RVM-gated capability boundary with RVF-portable health receipts — neither +implemented here, both flagged as materially relevant. + +## Future Direction + +1. Ablation isolating the compaction-interaction hypothesis (decouple repaired + edges from retention scoring, re-run, check if the sign flips). +2. A narrower "snapshot-restore" repair (re-add the specific decayed original + edge instead of reconnecting to current top-k) as a cleaner test of whether + *any* repair can clear the bar. +3. Real dependency on `ruvector-agent-memory` instead of a local reimplementation. +4. Measure the incremental (`SpectralTracker::update_edge`) monitoring path against + the `full_recompute` used throughout this PoC — overhead was 14–34× baseline + wall time here, all of it from full recomputation every check. + +## References + +- `crates/ruvector-coherence/src/spectral.rs` (reused, unmodified) +- `docs/research/nightly/2026-06-14-agent-memory-compaction/` +- `docs/research/nightly/2026-06-13-temporal-coherence-agent-memory/` +- `docs/research/nightly/2026-08-13-retrieval-receipts/` +- Full methodology, mermaid architecture diagram, and per-seed raw evidence: + `docs/research/nightly/2026-08-16-spectral-memory-self-repair/README.md` +- `docs/adr/ADR-305-spectral-memory-self-repair.md`