|
| 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, >); |
| 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, >); |
| 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, >); |
| 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