Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ members = [
"crates/ruvllm_retrieval_diffusion",
# RAIRS IVF: Redundant Assignment + Amplified Inverse Residual (ADR-193)
"crates/ruvector-rairs",
# Temporal-Decay ANN: recency-aware nearest-neighbor search for agent memory (nightly research)
"crates/ruvector-td-hnsw",
]
resolver = "2"

Expand Down
24 changes: 24 additions & 0 deletions crates/ruvector-td-hnsw/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "ruvector-td-hnsw"
version = "0.1.0"
edition = "2021"
description = "Temporal-Decay ANN: recency-aware nearest-neighbor search for agent memory — ruvector nightly research"
authors = ["ruvnet", "claude-flow"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/ruvector"
keywords = ["ann", "temporal", "vector-search", "agent-memory", "ruvector"]
categories = ["algorithms", "data-structures"]

[[bin]]
name = "td-benchmark"
path = "src/main.rs"

[dependencies]
rand = { version = "0.8", features = ["std"] }

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "td_bench"
harness = false
52 changes: 52 additions & 0 deletions crates/ruvector-td-hnsw/benches/td_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use rand::{Rng, SeedableRng};
use ruvector_td_hnsw::{DecayConfig, IndexVariant, TdIndex};

fn make_data(n: usize, dims: usize, now: u64) -> Vec<(u64, Vec<f32>, u64)> {
let mut rng = rand::rngs::StdRng::seed_from_u64(777);
(0..n)
.map(|i| {
let v: Vec<f32> = (0..dims).map(|_| rng.gen::<f32>()).collect();
let ts = if i % 5 == 0 { now - 500 } else { now - 86400 };
(i as u64, v, ts)
})
.collect()
}

fn bench_search(c: &mut Criterion) {
let now = 100_000u64;
let dims = 128;
let data = make_data(5_000, dims, now);
let mut rng = rand::rngs::StdRng::seed_from_u64(888);
let q: Vec<f32> = (0..dims).map(|_| rng.gen::<f32>()).collect();

let mut group = c.benchmark_group("td_search");

let variants: Vec<(IndexVariant, DecayConfig, &str)> = vec![
(IndexVariant::Baseline, DecayConfig::no_decay(), "baseline"),
(
IndexVariant::TemporalDecay,
DecayConfig::standard(3.0, 3600.0),
"temporal-decay",
),
(
IndexVariant::CoherenceGated,
DecayConfig::with_gate(3.0, 3600.0, 43200.0, 1.5),
"coherence-gated",
),
];

for (var, cfg, name) in variants {
let mut idx = TdIndex::new(var, cfg);
for (id, v, ts) in &data {
idx.insert(*id, v.clone(), *ts);
}
group.bench_with_input(BenchmarkId::new("search", name), &idx, |b, index| {
b.iter(|| index.search(&q, 10, now));
});
}
group.finish();
}

criterion_group!(benches, bench_search);
criterion_main!(benches);
111 changes: 111 additions & 0 deletions crates/ruvector-td-hnsw/src/decay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! Temporal decay weighting for age-aware ANN search.
//!
//! Older vectors receive a higher effective distance so recent memories
//! are preferred in top-k retrieval without changing stored embeddings.

/// Configuration for temporal decay.
#[derive(Debug, Clone)]
pub struct DecayConfig {
/// Decay strength in [0.0, 10.0]. 0.0 = no decay.
pub decay_strength: f32,
/// Half-life in seconds. After this many seconds, decay weight ≈ 0.632 * decay_strength.
pub half_life_secs: f64,
/// Coherence gate: prune candidates older than this (seconds) when raw distance > cutoff.
pub max_age_gate_secs: f64,
/// Coherence gate distance cutoff. Only used in CoherenceGated variant.
pub coherence_cutoff: f32,
}

impl DecayConfig {
/// No decay — equivalent to standard flat search.
pub fn no_decay() -> Self {
Self {
decay_strength: 0.0,
half_life_secs: f64::MAX,
max_age_gate_secs: f64::MAX,
coherence_cutoff: f32::MAX,
}
}

/// Standard temporal decay with given strength and half-life.
pub fn standard(decay_strength: f32, half_life_secs: f64) -> Self {
Self {
decay_strength,
half_life_secs,
max_age_gate_secs: f64::MAX,
coherence_cutoff: f32::MAX,
}
}

/// Temporal decay with coherence gate: prune old+distant candidates.
pub fn with_gate(
decay_strength: f32,
half_life_secs: f64,
max_age_gate_secs: f64,
coherence_cutoff: f32,
) -> Self {
Self {
decay_strength,
half_life_secs,
max_age_gate_secs,
coherence_cutoff,
}
}

/// Returns the temporal weight multiplier for a given age in seconds.
///
/// Weight >= 1.0; older = higher weight = larger effective distance.
/// Formula: `1.0 + decay_strength * (1.0 - exp(-age_secs / half_life_secs))`
pub fn weight(&self, age_secs: f64) -> f32 {
if self.decay_strength == 0.0 {
return 1.0;
}
let ratio = (age_secs / self.half_life_secs) as f32;
1.0 + self.decay_strength * (1.0 - (-ratio).exp())
}

/// Returns true if a candidate should be pruned by the coherence gate.
///
/// A candidate is pruned when it is both old (age > max_age_gate_secs)
/// and distant (raw_dist > coherence_cutoff).
pub fn should_gate(&self, age_secs: f64, raw_dist: f32) -> bool {
age_secs > self.max_age_gate_secs && raw_dist > self.coherence_cutoff
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn no_decay_returns_one() {
let cfg = DecayConfig::no_decay();
assert_eq!(cfg.weight(0.0), 1.0);
assert_eq!(cfg.weight(1_000_000.0), 1.0);
}

#[test]
fn decay_increases_with_age() {
let cfg = DecayConfig::standard(2.0, 3600.0);
let w0 = cfg.weight(0.0);
let w1h = cfg.weight(3600.0);
let w24h = cfg.weight(86400.0);
assert!(w0 < w1h, "weight should increase with age");
assert!(w1h < w24h);
// At age=0: weight should be ~1.0
assert!((w0 - 1.0).abs() < 1e-5);
// At half_life: weight = 1 + 2*(1 - exp(-1)) ≈ 1 + 2*0.632 ≈ 2.264
assert!((w1h - 2.264).abs() < 0.01, "got {w1h}");
}

#[test]
fn gate_prunes_old_distant_vectors() {
let cfg = DecayConfig::with_gate(1.0, 3600.0, 7200.0, 0.5);
// old and distant: should be gated
assert!(cfg.should_gate(10000.0, 0.8));
// old but close: should not be gated
assert!(!cfg.should_gate(10000.0, 0.3));
// recent and distant: should not be gated
assert!(!cfg.should_gate(100.0, 0.8));
}
}
Loading
Loading