Skip to content

Commit 6267cb1

Browse files
ruvnetclaude
andauthored
research(nightly): temporal-coherence-agent-memory (#564)
* feat: add temporal coherence decay crate for agent memory retrieval Implements ruvector-temporal-coherence with three VectorSearch variants: - FlatSearch: pure cosine similarity baseline - TemporalSearch: cosine × exponential time decay - CoherenceSearch: cosine × (decay + graph-coherence gate) All 21 unit tests pass. Acceptance benchmark: N=5000 D=128 K=10 200q - FlatSearch: cosine_recall=1.000 PASS - TemporalSearch: recency=0.962 PASS - CoherenceSearch: coh_gate=0.971 PASS - Latency: ~1036µs mean / 965 q/s (x86-64, linear scan, Rust 1.94.1) https://claude.ai/code/session_01AZSYgw84vT12vXZDsRGDvK * docs: add nightly research and ADR for temporal coherence agent memory - docs/adr/ADR-211-temporal-coherence-agent-memory.md - docs/research/nightly/2026-06-13-temporal-coherence-agent-memory/README.md - docs/research/nightly/2026-06-13-temporal-coherence-agent-memory/gist.md ADR-211 documents design decisions, benchmark evidence, failure modes, alternatives considered (gMMR, QuIVer, MinCut compaction), and migration path. https://claude.ai/code/session_01AZSYgw84vT12vXZDsRGDvK * chore: update Cargo.lock for ruvector-temporal-coherence dependencies Adds rand small_rng feature lock entries for the new crate. https://claude.ai/code/session_01AZSYgw84vT12vXZDsRGDvK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e188a61 commit 6267cb1

13 files changed

Lines changed: 2501 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ exclude = ["external/ruqu", "external/rvdna", "examples/OSpipe", "examples/rvf",
1818
# land in iters 92-97.
1919
"crates/ruos-thermal"]
2020
members = [
21+
"crates/ruvector-temporal-coherence",
2122
"crates/ruvector-acorn",
2223
"crates/ruvector-acorn-wasm",
2324
"crates/ruvector-rabitq",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "ruvector-temporal-coherence"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "Temporal coherence decay for agent memory retrieval — three scored variants with graph-coherence gating"
6+
authors = ["ruvnet", "claude-flow"]
7+
license = "MIT OR Apache-2.0"
8+
repository = "https://github.com/ruvnet/ruvector"
9+
keywords = ["agent-memory", "vector-search", "temporal", "coherence", "ruvector"]
10+
categories = ["algorithms", "data-structures"]
11+
12+
[[bin]]
13+
name = "tcd-demo"
14+
path = "src/main.rs"
15+
16+
[[bin]]
17+
name = "tcd-benchmark"
18+
path = "src/benchmark.rs"
19+
20+
[dependencies]
21+
rand = { version = "0.8", features = ["small_rng"] }
22+
23+
[dev-dependencies]
24+
rand = { version = "0.8", features = ["small_rng"] }
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
//! Benchmark binary: temporal coherence decay — three variants.
2+
//!
3+
//! Reports mean/p50/p95 latency, throughput, memory estimate, and variant-
4+
//! specific quality metrics:
5+
//! FlatSearch → cosine recall@K vs cosine ground truth
6+
//! TemporalSearch → mean recency score of retrieved memories (want high)
7+
//! CoherenceSearch → mean coherence gate of retrieved memories (want high)
8+
//!
9+
//! Lower cosine recall for temporal/coherence variants is *expected and correct*:
10+
//! they intentionally trade some cosine similarity for recency or coherence.
11+
//!
12+
//! Usage:
13+
//! cargo run --release -p ruvector-temporal-coherence --bin tcd-benchmark
14+
//! cargo run --release -p ruvector-temporal-coherence --bin tcd-benchmark -- --n 5000 --dims 128
15+
16+
use rand::SeedableRng;
17+
use ruvector_temporal_coherence::{
18+
estimate_memory_bytes, generate_memory_corpus, ground_truth_topk, recall_at_k, CoherenceGraph,
19+
CoherenceSearch, DecayConfig, FlatSearch, MemoryStore, TemporalSearch, VectorSearch,
20+
};
21+
use std::time::{Duration, Instant};
22+
23+
const DEFAULT_N: usize = 5_000;
24+
const DEFAULT_DIMS: usize = 128;
25+
const DEFAULT_QUERIES: usize = 200;
26+
const DEFAULT_K: usize = 10;
27+
const COHERENCE_THRESHOLD: f32 = 0.55;
28+
const COHERENCE_WEIGHT: f32 = 0.30;
29+
const HALF_LIFE_FRAC: f64 = 0.30; // 30 % of time_span
30+
const TIME_SPAN: u64 = 1_000_000;
31+
const NUM_CLUSTERS: usize = 20;
32+
// Acceptance thresholds
33+
const MIN_FLAT_RECALL: f32 = 0.95;
34+
// Temporal/coherence are scored by their OWN fitness metric (recency/coherence),
35+
// not by cosine recall. Thresholds are in [0,1].
36+
const MIN_TEMPORAL_RECENCY: f32 = 0.55; // retrieved memories must be in top 55% by time
37+
const MIN_COHERENCE_GATE: f32 = 0.50; // retrieved memories must have coherence gate >= 0.50 mean
38+
const MAX_MEAN_LATENCY_US: u128 = 500_000; // 500 ms per query (conservative for n=5k O(n²) build)
39+
40+
fn percentile(mut data: Vec<Duration>, p: f64) -> Duration {
41+
data.sort();
42+
let idx = ((p / 100.0) * data.len() as f64).floor() as usize;
43+
data[idx.min(data.len().saturating_sub(1))]
44+
}
45+
46+
/// Mean normalised timestamp [0,1] of retrieved memories — measures recency.
47+
fn mean_recency(ids: &[u64], store: &MemoryStore) -> f32 {
48+
if ids.is_empty() {
49+
return 0.0;
50+
}
51+
let sum: f64 = ids
52+
.iter()
53+
.filter_map(|&id| store.get(id))
54+
.map(|r| r.metadata.timestamp as f64 / TIME_SPAN as f64)
55+
.sum();
56+
(sum / ids.len() as f64) as f32
57+
}
58+
59+
/// Mean coherence gate of retrieved memories — measures community relevance.
60+
fn mean_coherence_gate(ids: &[u64], graph: &CoherenceGraph) -> f32 {
61+
if ids.is_empty() {
62+
return 0.0;
63+
}
64+
let sum: f32 = ids.iter().map(|&id| graph.gate(id)).sum();
65+
sum / ids.len() as f32
66+
}
67+
68+
fn print_hw_info() {
69+
println!("--- Hardware / Runtime ---");
70+
println!(" OS : {}", std::env::consts::OS);
71+
println!(" Arch : {}", std::env::consts::ARCH);
72+
println!(
73+
" rustc : {}",
74+
option_env!("CARGO_BUILD_RUSTC_VERSION").unwrap_or("(see rustc --version)")
75+
);
76+
println!();
77+
}
78+
79+
fn parse_args() -> (usize, usize, usize) {
80+
let args: Vec<String> = std::env::args().collect();
81+
let mut n = DEFAULT_N;
82+
let mut dims = DEFAULT_DIMS;
83+
let mut queries = DEFAULT_QUERIES;
84+
let mut i = 1;
85+
while i < args.len() {
86+
match args[i].as_str() {
87+
"--n" => {
88+
n = args[i + 1].parse().unwrap_or(n);
89+
i += 2;
90+
}
91+
"--dims" => {
92+
dims = args[i + 1].parse().unwrap_or(dims);
93+
i += 2;
94+
}
95+
"--queries" => {
96+
queries = args[i + 1].parse().unwrap_or(queries);
97+
i += 2;
98+
}
99+
_ => {
100+
i += 1;
101+
}
102+
}
103+
}
104+
(n, dims, queries)
105+
}
106+
107+
struct VariantStats {
108+
name: &'static str,
109+
latencies: Vec<Duration>,
110+
/// cosine recall vs flat gt
111+
cosine_recalls: Vec<f32>,
112+
/// variant-specific quality (recency or coherence gate)
113+
quality: Vec<f32>,
114+
quality_label: &'static str,
115+
memory_bytes: usize,
116+
}
117+
118+
impl VariantStats {
119+
fn new(name: &'static str, quality_label: &'static str, memory_bytes: usize) -> Self {
120+
Self {
121+
name,
122+
latencies: Vec::new(),
123+
cosine_recalls: Vec::new(),
124+
quality: Vec::new(),
125+
quality_label,
126+
memory_bytes,
127+
}
128+
}
129+
130+
fn add(&mut self, lat: Duration, recall: f32, quality: f32) {
131+
self.latencies.push(lat);
132+
self.cosine_recalls.push(recall);
133+
self.quality.push(quality);
134+
}
135+
136+
fn print(&self) {
137+
let mean_lat =
138+
self.latencies.iter().sum::<Duration>() / self.latencies.len().max(1) as u32;
139+
let p50 = percentile(self.latencies.clone(), 50.0);
140+
let p95 = percentile(self.latencies.clone(), 95.0);
141+
let total_secs = self.latencies.iter().sum::<Duration>().as_secs_f64();
142+
let throughput = self.latencies.len() as f64 / total_secs.max(1e-9);
143+
let mean_recall: f32 =
144+
self.cosine_recalls.iter().sum::<f32>() / self.cosine_recalls.len().max(1) as f32;
145+
let mean_quality: f32 =
146+
self.quality.iter().sum::<f32>() / self.quality.len().max(1) as f32;
147+
let mem_kb = self.memory_bytes / 1024;
148+
149+
println!(
150+
" {:<20} mean={:>7}µs p50={:>7}µs p95={:>7}µs tput={:>7.1}q/s mem={:>5}KB recall@K={:.3} {}={:.3}",
151+
self.name,
152+
mean_lat.as_micros(),
153+
p50.as_micros(),
154+
p95.as_micros(),
155+
throughput,
156+
mem_kb,
157+
mean_recall,
158+
self.quality_label,
159+
mean_quality,
160+
);
161+
}
162+
163+
fn mean_latency_us(&self) -> u128 {
164+
(self.latencies.iter().sum::<Duration>() / self.latencies.len().max(1) as u32).as_micros()
165+
}
166+
167+
fn mean_cosine_recall(&self) -> f32 {
168+
self.cosine_recalls.iter().sum::<f32>() / self.cosine_recalls.len().max(1) as f32
169+
}
170+
171+
fn mean_quality(&self) -> f32 {
172+
self.quality.iter().sum::<f32>() / self.quality.len().max(1) as f32
173+
}
174+
}
175+
176+
fn main() {
177+
print_hw_info();
178+
179+
let (n, dims, num_queries) = parse_args();
180+
let half_life = (TIME_SPAN as f64 * HALF_LIFE_FRAC) as u64;
181+
182+
println!("--- Dataset ---");
183+
println!(" N={n} dims={dims} queries={num_queries} K={DEFAULT_K}");
184+
println!(" clusters={NUM_CLUSTERS} time_span={TIME_SPAN} half_life={half_life}");
185+
println!(" coherence_threshold={COHERENCE_THRESHOLD} coherence_weight={COHERENCE_WEIGHT}");
186+
println!();
187+
188+
let mut rng = rand::rngs::SmallRng::seed_from_u64(0xDEAD_BEEF);
189+
190+
println!("Building corpus ({n} × {dims}D)…");
191+
let t0 = Instant::now();
192+
let store = generate_memory_corpus(n, dims, TIME_SPAN, NUM_CLUSTERS, &mut rng);
193+
println!(
194+
" corpus built in {:.1}ms",
195+
t0.elapsed().as_secs_f64() * 1000.0
196+
);
197+
198+
println!("Building coherence graph (threshold={COHERENCE_THRESHOLD})…");
199+
let tg = Instant::now();
200+
let graph = CoherenceGraph::build(&store, COHERENCE_THRESHOLD);
201+
println!(
202+
" graph built in {:.1}ms nodes={} edges={} mean_gate={:.3}",
203+
tg.elapsed().as_secs_f64() * 1000.0,
204+
graph.node_count(),
205+
graph.edge_count(),
206+
graph.mean_gate(),
207+
);
208+
println!();
209+
210+
let now = TIME_SPAN;
211+
let decay = DecayConfig::exponential(now, half_life);
212+
let flat = FlatSearch;
213+
let temporal = TemporalSearch {
214+
decay: decay.clone(),
215+
};
216+
let coherence_search = CoherenceSearch::new(
217+
decay.clone(),
218+
CoherenceGraph::build(&store, COHERENCE_THRESHOLD),
219+
COHERENCE_WEIGHT,
220+
);
221+
222+
let mem_vec = estimate_memory_bytes(n, dims);
223+
224+
let mut stat_flat = VariantStats::new("FlatSearch", "cosine_recall", mem_vec);
225+
let mut stat_temp = VariantStats::new("TemporalSearch", "recency", mem_vec);
226+
let mut stat_coh = VariantStats::new(
227+
"CoherenceSearch",
228+
"coh_gate",
229+
mem_vec + n * 4,
230+
);
231+
232+
use rand::distributions::{Distribution, Uniform};
233+
let uni = Uniform::new(-1.0f32, 1.0);
234+
235+
println!("Running {num_queries} queries…");
236+
for _ in 0..num_queries {
237+
let query: Vec<f32> = (0..dims).map(|_| uni.sample(&mut rng)).collect();
238+
let gt = ground_truth_topk(&query, &store, DEFAULT_K);
239+
240+
// FlatSearch — quality = cosine recall (should be ~1.0)
241+
let t = Instant::now();
242+
let r_flat = flat.search(&query, DEFAULT_K, &store);
243+
let lat = t.elapsed();
244+
let ids_flat: Vec<u64> = r_flat.iter().map(|x| x.id).collect();
245+
let rc = recall_at_k(&ids_flat, &gt);
246+
stat_flat.add(lat, rc, rc);
247+
248+
// TemporalSearch — quality = mean recency of retrieved memories
249+
let t = Instant::now();
250+
let r_temp = temporal.search(&query, DEFAULT_K, &store);
251+
let lat = t.elapsed();
252+
let ids_temp: Vec<u64> = r_temp.iter().map(|x| x.id).collect();
253+
let rc_t = recall_at_k(&ids_temp, &gt);
254+
let recency = mean_recency(&ids_temp, &store);
255+
stat_temp.add(lat, rc_t, recency);
256+
257+
// CoherenceSearch — quality = mean coherence gate of retrieved memories
258+
let t = Instant::now();
259+
let r_coh = coherence_search.search(&query, DEFAULT_K, &store);
260+
let lat = t.elapsed();
261+
let ids_coh: Vec<u64> = r_coh.iter().map(|x| x.id).collect();
262+
let rc_c = recall_at_k(&ids_coh, &gt);
263+
let coh_gate = mean_coherence_gate(&ids_coh, &graph);
264+
stat_coh.add(lat, rc_c, coh_gate);
265+
}
266+
267+
println!();
268+
println!("--- Results ---");
269+
println!(
270+
" {:<20} {:>10} {:>10} {:>10} {:>12} {:>8} {:>12} quality",
271+
"Variant", "mean_lat", "p50_lat", "p95_lat", "throughput", "mem", "recall@K"
272+
);
273+
stat_flat.print();
274+
stat_temp.print();
275+
stat_coh.print();
276+
277+
println!();
278+
println!("--- Quality metric explanation ---");
279+
println!(" FlatSearch.cosine_recall = overlap with cosine-only ground truth (expect ~1.0)");
280+
println!(" TemporalSearch.recency = mean normalised timestamp of retrieved results [0,1]");
281+
println!(" (1.0 = always retrieves newest memories)");
282+
println!(" CoherenceSearch.coh_gate = mean graph-coherence gate of retrieved results [0,1]");
283+
println!(" (1.0 = always retrieves most graph-connected memories)");
284+
println!();
285+
println!(" Temporal/coherence cosine_recall vs flat is expected to be < 1.0 —");
286+
println!(" the variants deliberately trade cosine similarity for recency/coherence.");
287+
println!();
288+
289+
// Acceptance tests — each variant is tested on its PRIMARY fitness metric
290+
println!("--- Acceptance ---");
291+
let flat_ok = stat_flat.mean_cosine_recall() >= MIN_FLAT_RECALL;
292+
let temp_ok = stat_temp.mean_quality() >= MIN_TEMPORAL_RECENCY;
293+
let coh_ok = stat_coh.mean_quality() >= MIN_COHERENCE_GATE;
294+
let lat_ok = stat_flat.mean_latency_us() <= MAX_MEAN_LATENCY_US;
295+
296+
println!(
297+
" FlatSearch cosine_recall >= {MIN_FLAT_RECALL} : {} ({:.3})",
298+
if flat_ok { "PASS" } else { "FAIL" },
299+
stat_flat.mean_cosine_recall()
300+
);
301+
println!(
302+
" TemporalSearch recency >= {MIN_TEMPORAL_RECENCY} : {} ({:.3})",
303+
if temp_ok { "PASS" } else { "FAIL" },
304+
stat_temp.mean_quality()
305+
);
306+
println!(
307+
" CoherenceSearch coh_gate >= {MIN_COHERENCE_GATE} : {} ({:.3})",
308+
if coh_ok { "PASS" } else { "FAIL" },
309+
stat_coh.mean_quality()
310+
);
311+
println!(
312+
" FlatSearch mean_lat <= {MAX_MEAN_LATENCY_US}µs : {} ({}µs)",
313+
if lat_ok { "PASS" } else { "FAIL" },
314+
stat_flat.mean_latency_us()
315+
);
316+
317+
let all_ok = flat_ok && temp_ok && coh_ok && lat_ok;
318+
println!();
319+
if all_ok {
320+
println!("✓ All acceptance tests PASSED.");
321+
std::process::exit(0);
322+
} else {
323+
println!("✗ One or more acceptance tests FAILED.");
324+
std::process::exit(1);
325+
}
326+
}

0 commit comments

Comments
 (0)