feat(bet1): productionize reuse-under-drift + validate on a real learned-GNN trajectory (ADR-202 WIN) (#537)

* docs(bet1): pre-register reuse-under-drift gate on real GNN trajectory

Productionize BET 1 (ADR-200 WIN under synthetic drift) by wiring
re-weight + periodic-rebuild into the ruvector-diskann loop behind a
feature flag, validated on a REAL contrastive-link-prediction embedding
trajectory on ogbn-arxiv (ADR-200 next-step #4).

Gate frozen before any contender run (prove-not-hype): WIN = ReweightOnly
within 2% recall@10 of AlwaysRebuild + Periodic{k} within 1% at <=50%
cumulative rebuild cost; KILL = no transfer from synthetic to real drift.
Minimum-drift precondition (>=15% top-10 churn) guards against a vacuous
pass. Self-contained off main; independent of PR #535. Outcome -> ADR-202.

Linked: ruvnet/RuVector#534

* feat(diskann): M0 — reuse-under-drift policy module behind feature flag

DriftingIndex wraps a VamanaGraph and owns only the rebuild decision
(RebuildPolicy: AlwaysRebuild / ReweightOnly / Periodic{k}); the consumer
owns the drifting vectors and passes snapshots to on_metric_update + search.
Native reuse hook: greedy_search takes vectors externally, so adapt-to-drift
recomputes only distances. Feature-gated (reuse-under-drift, default off) —
default build byte-identical. 5 unit tests green (cadence + search).

Refs ruvnet/RuVector#534

* feat(bet1): M1-M3 real-trajectory validation harness

examples/diskann_real_trajectory.rs: generates a REAL learned-GNN metric
trajectory via contrastive link-prediction (InfoNCE over ogbn-arxiv
citations, ruvector-gnn Optimizer + info_nce_loss, embeddings on the unit
sphere so cosine==dot and L2 ranking agrees), then drives the diskann
reuse policy (DriftingIndex) through all four contenders step-by-step.

Result (n=20k, gradual trajectory to 67% churn):
- WIN. Reuse holds within 2% recall@10 of full rebuild up to 40% top-10
  churn (>= ADR-200's synthetic ~36% regime) -- transfer confirmed on real
  learned drift. Stale control collapses 92%->33% (teeth).
- Periodic recovers the high-churn tail: P k=4 = 98.7% (gap -0.01%) at 24%
  of rebuild cost, evals 1.00x B. ADR-200 hybrid reproduced on real drift.
- Honest caveat: pure reuse past the ceiling decays (-4.73% over the whole
  overdriven trajectory, 1.05x evals); the shippable periodic policy does not.

Refs ruvnet/RuVector#534

* style(bet1): rustfmt the reuse module + trajectory harness

* docs(adr): ADR-202 — reuse-under-drift WIN on a real learned-GNN trajectory

Outcome ADR for BET 1 productionization (closes ADR-200 next-step #4).
Fixed-topology reuse + periodic rebuild, validated on a real contrastive-
link-prediction trajectory over ogbn-arxiv (not synthetic A(t)).

WIN at n=20k AND n=50k: pure reuse holds within 2% recall@10 of full
rebuild up to a 40% top-10 churn ceiling (identical at both scales, >=
ADR-200's synthetic ~36%); Periodic{k:4} recovers the high-churn tail to
within 0.01% (20k) / above rebuild (50k) at 20-24% of rebuild cost, equal
per-query work. Stale control collapses (teeth). Honest caveat: pure reuse
past the ceiling decays -- the shippable policy is periodic, not never.

Refs ruvnet/RuVector#534

* docs(bet1): record WIN outcome pointer to ADR-202 in pre-registration

* docs(bet1): pre-register sampled-recall trigger gate + force_rebuild plumbing

Pre-register (frozen before any run) the ADR-200 next-step #2 bet: does a
sampled-recall rebuild trigger beat fixed Periodic{k} under VARIABLE-RATE
drift, and beat the Frobenius monitor ADR-200 found wanting? Honest test =
the (rebuilds, recall) Pareto frontier; WIN = trigger >=25% fewer rebuilds
at matched recall with probe cost counted; KILL = no frontier dominance.

Plumbing (allowed pre-freeze): DriftingIndex::force_rebuild + harness.

Refs ruvnet/RuVector#534

* fix(bet1): trigger harness — Adam + enforced churn precondition (first run was VOID)

The first variable-rate run was VOID (0% churn): plain SGD at lr 0.002-0.03
on unit-normalized embeddings doesn't move them. Switched to Adam (real
motion in bursts), n=20k for edge density, and ENFORCED the >=15% churn
precondition (abort before rendering a verdict) so a no-drift trajectory
can't masquerade as a result. Gate criteria unchanged.

Result (n=20k, bursty trajectory, per-step Δchurn ~45 burst / ~2 calm,
89% end churn): WIN. Recall{floor=0.95} = 97.2% @ 7 rebuilds beats
Periodic{k=2} (96.8% @ 12) on BOTH axes; probe cost ~1s vs ~73s rebuild
time saved (trap passed); beats best Frobenius (97.3% @ 9) on rebuilds.

Refs ruvnet/RuVector#534

* feat(bet1): productionize RecallTrigger (WIN) + ADR-202 addendum

The sampled-recall trigger WON (ADR-200 next-step #2): under bursty drift it
uses ~42% fewer rebuilds than fixed Periodic{k} at matched recall, beats the
Frobenius monitor ADR-200 found wanting, and passes the probe-cost trap
(~1s probe vs ~73s rebuild saved). Productionized as RecallTrigger in
ruvector_diskann::reuse (DriftingIndex in ReweightOnly mode + a probe-driven
force_rebuild); its knob 'floor' IS the recall SLA, unlike k/tau. 8 reuse
tests (incl. holds-under-no-drift + fires-then-recovers). ADR-202 addendum
records the result; pre-registration carries the WIN outcome pointer.

Refs ruvnet/RuVector#534

* docs(bet1): pre-register objective-dependence check + nodeclass trajectory

Frozen-before-run generality check of ADR-202's 40% holding ceiling: does
it generalize beyond contrastive link-prediction to a DIFFERENT learned
objective? Adds a node-classification trajectory (real arxiv 40-class
labels, CE on a linear head, embeddings as params) selectable via an
'objective=nodeclass' arg to the existing harness — same contenders + 2%
gate, only the objective changes. CONFIRM = holding ceiling >=30% churn +
periodic recovers; CAVEAT = <20% or materially different (reportable).

Refs ruvnet/RuVector#534

* docs(bet1): objective-dependence CONFIRMED + class-collapse degeneracy caveat

Node-classification trajectory (2nd objective) holds reuse within 2% of
rebuild up to a 54% churn ceiling (>= link-pred's 40%) -> the ADR-202
holding-ceiling result GENERALIZES across two learned objectives; the
objective-dependence caveat is resolved.

Honest finding (reported, not buried): past ~60% churn node-class CE
collapses embeddings into ~40 class blobs where recall@10 is ill-posed
(intra-blob near-ties) and the FULL-REBUILD baseline itself destabilizes
(B swings 55-96%). The trajectory-wide 'reuse > rebuild +4.3%' is a
benchmark-degeneracy artifact (ADR-200's t=0.25 dip amplified), NOT a
genuine superiority claim. Operational conclusion unaffected (reuse+periodic
never worse). ADR-202 addendum + next-step #5 (collapse-aware metric).

Refs ruvnet/RuVector#534
This commit is contained in:
Ofer Shaal 2026-06-17 20:18:50 -04:00 committed by GitHub
parent 8417dc283b
commit dfe22d62a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2183 additions and 0 deletions

1
Cargo.lock generated
View file

@ -9221,6 +9221,7 @@ dependencies = [
"rand_distr 0.4.3",
"rayon",
"ruvector-core 2.2.3",
"ruvector-diskann",
"serde",
"serde_json",
"tempfile",

View file

@ -11,6 +11,8 @@ description = "DiskANN/Vamana — SSD-friendly approximate nearest neighbor sear
default = []
gpu = [] # Feature flag for GPU acceleration (CUDA/Metal stubs)
simd = ["simsimd"]
# BET 1 (ADR-200): fixed-topology reuse + periodic rebuild under metric drift.
reuse-under-drift = []
[dependencies]
memmap2 = { workspace = true }

View file

@ -15,7 +15,12 @@ pub mod error;
pub mod graph;
pub mod index;
pub mod pq;
/// Fixed-topology reuse + periodic rebuild under metric drift (BET 1, ADR-200).
#[cfg(feature = "reuse-under-drift")]
pub mod reuse;
pub use error::{DiskAnnError, Result};
pub use index::{DiskAnnConfig, DiskAnnIndex};
pub use pq::ProductQuantizer;
#[cfg(feature = "reuse-under-drift")]
pub use reuse::{DriftingIndex, RebuildPolicy, RecallTrigger};

View file

@ -0,0 +1,448 @@
//! Fixed-topology reuse under metric drift + periodic rebuild (BET 1, ADR-200).
//!
//! A self-learning system (e.g. `ruvector-gnn`) continuously re-estimates node
//! embeddings, so the effective L2 metric over those embeddings **drifts**. The
//! textbook remedy is a full [`VamanaGraph`] rebuild on every update — superlinear,
//! minutes-to-hours at corpus scale. ADR-200 showed (under synthetic drift, on this
//! exact production index) that the navigation topology can be **reused**: build the
//! graph once on `E₀`, then search the *drifted* vectors against it, recomputing only
//! distances. Recall stays within 2% of a full rebuild at ~10³10⁴× lower update cost,
//! with a periodic rebuild recovering the residual gap under heavy drift.
//!
//! This module wires that policy into the production loop. The reuse hook is native:
//! [`VamanaGraph`] stores only topology (`neighbors` + `medoid`) and
//! [`VamanaGraph::greedy_search`] takes the vectors externally — so the consumer (the
//! GNN) owns and mutates the embeddings, and the index only decides *when* to rebuild.
//!
//! Feature-gated behind `reuse-under-drift` (default off) — the shipping build is
//! unaffected. See `docs/plans/bet1-productionize/PRE-REGISTRATION.md`.
use crate::distance::FlatVectors;
use crate::error::Result;
use crate::graph::VamanaGraph;
/// When to spend a full [`VamanaGraph`] rebuild as the metric drifts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RebuildPolicy {
/// Rebuild on every metric update — the incumbent remedy. Highest recall, full
/// rebuild cost every step. The baseline `B` of ADR-200.
AlwaysRebuild,
/// Never rebuild — reuse the `E₀` topology, recompute distances under the drifted
/// vectors. Zero rebuild cost. The bet `A` of ADR-200; decays under heavy
/// accumulated drift (why [`Periodic`](RebuildPolicy::Periodic) exists).
ReweightOnly,
/// Reuse every step, full rebuild every `k` updates — the shippable hybrid. ADR-200
/// found `Periodic{k:4}` recovered to within 0.3% of `AlwaysRebuild` at 25% of its
/// cost. `k == 0` is treated as [`ReweightOnly`](RebuildPolicy::ReweightOnly).
Periodic {
/// Rebuild cadence: rebuild when `step % k == 0`.
k: usize,
},
}
impl RebuildPolicy {
/// Whether the policy rebuilds at update number `step` (1-based: the first
/// `on_metric_update` is step 1).
fn rebuilds_at(self, step: usize) -> bool {
match self {
RebuildPolicy::AlwaysRebuild => true,
RebuildPolicy::ReweightOnly => false,
RebuildPolicy::Periodic { k } => k > 0 && step % k == 0,
}
}
}
/// A Vamana index that adapts to a drifting metric by reusing its navigation topology,
/// rebuilding only as dictated by its [`RebuildPolicy`].
///
/// The index does **not** own the vectors — the consumer owns the embedding store and
/// passes the current snapshot to [`on_metric_update`](DriftingIndex::on_metric_update)
/// and [`search`](DriftingIndex::search). This keeps the dependency direction clean: the
/// index knows nothing about *what* drives the drift.
pub struct DriftingIndex {
graph: VamanaGraph,
policy: RebuildPolicy,
// Build parameters, retained to reconstruct the graph on rebuild.
n: usize,
max_degree: usize,
build_beam: usize,
alpha: f32,
// Telemetry.
step: usize,
rebuilds: usize,
}
impl DriftingIndex {
/// Build the initial topology on `vectors` (the `E₀` snapshot) under `policy`.
///
/// `max_degree`, `build_beam`, `alpha` are the Vamana build parameters (production
/// defaults: 32 / 64 / 1.2), reused on every subsequent rebuild.
pub fn build(
vectors: &FlatVectors,
policy: RebuildPolicy,
max_degree: usize,
build_beam: usize,
alpha: f32,
) -> Result<Self> {
let n = vectors.len();
let graph = build_graph(vectors, n, max_degree, build_beam, alpha)?;
Ok(Self {
graph,
policy,
n,
max_degree,
build_beam,
alpha,
step: 0,
rebuilds: 0,
})
}
/// Signal that the metric drifted (the consumer wrote a new embedding snapshot).
///
/// Rebuilds the topology on `vectors` iff the policy dictates it at this step;
/// otherwise the existing topology is retained (pure re-weight). Returns whether a
/// rebuild happened, so the caller can account for cost.
///
/// `vectors` must contain the same number of points as the original build (drift
/// changes vector *values*, not membership; insert/delete is out of scope for the
/// reuse model). Returns [`DiskAnnError::DimensionMismatch`](crate::DiskAnnError) if
/// the count changed.
pub fn on_metric_update(&mut self, vectors: &FlatVectors) -> Result<bool> {
self.step += 1;
if !self.policy.rebuilds_at(self.step) {
return Ok(false);
}
debug_assert_eq!(
vectors.len(),
self.n,
"reuse model assumes fixed membership; point count changed"
);
self.graph = build_graph(
vectors,
self.n,
self.max_degree,
self.build_beam,
self.alpha,
)?;
self.rebuilds += 1;
Ok(true)
}
/// Search the current topology against `vectors` (the live, possibly-drifted
/// snapshot), returning candidate ids and the visited count (distance-evals proxy).
///
/// Callers typically re-rank the candidates by exact distance to the query under the
/// current metric and take the top-k.
pub fn search(
&self,
vectors: &FlatVectors,
query: &[f32],
beam_width: usize,
) -> (Vec<u32>, usize) {
self.graph.greedy_search(vectors, query, beam_width)
}
/// Force a topology rebuild on `vectors`, bypassing the policy. The primitive an
/// externally-driven trigger (e.g. a sampled-recall monitor) is built on: the caller
/// owns the rebuild *signal*, the index owns the rebuild *mechanism*. Counts toward
/// `rebuilds()` but does not advance the update `step`.
pub fn force_rebuild(&mut self, vectors: &FlatVectors) -> Result<()> {
debug_assert_eq!(vectors.len(), self.n, "force_rebuild: point count changed");
self.graph = build_graph(
vectors,
self.n,
self.max_degree,
self.build_beam,
self.alpha,
)?;
self.rebuilds += 1;
Ok(())
}
/// The configured rebuild policy.
pub fn policy(&self) -> RebuildPolicy {
self.policy
}
/// Number of metric updates seen so far.
pub fn step(&self) -> usize {
self.step
}
/// Number of full rebuilds performed (the cost the reuse policy is trying to avoid).
pub fn rebuilds(&self) -> usize {
self.rebuilds
}
/// Borrow the underlying topology (e.g. for inspection or persistence).
pub fn graph(&self) -> &VamanaGraph {
&self.graph
}
}
fn build_graph(
vectors: &FlatVectors,
n: usize,
max_degree: usize,
build_beam: usize,
alpha: f32,
) -> Result<VamanaGraph> {
let mut graph = VamanaGraph::new(n, max_degree, build_beam, alpha);
graph.build(vectors)?;
Ok(graph)
}
/// Exact top-`k` neighbours of point `q` under L2 on `vectors` (brute force, excludes `q`).
fn brute_force_topk(vectors: &FlatVectors, q: usize, k: usize) -> Vec<u32> {
let qv = vectors.get(q);
let mut scored: Vec<(f32, u32)> = (0..vectors.len())
.filter(|&i| i != q)
.map(|i| (crate::distance::l2_squared(vectors.get(i), qv), i as u32))
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
scored.into_iter().take(k).map(|(_, i)| i).collect()
}
/// A drift-adaptive index whose rebuilds are driven by a **sampled-recall probe** instead of
/// a fixed cadence: on each metric update it estimates live recall@k on a small held-out
/// probe set and rebuilds only when that estimate falls below `floor`.
///
/// Under *bursty* drift this beats fixed [`Periodic`](RebuildPolicy::Periodic) — it spends
/// rebuilds where the drift actually is, skipping calm stretches (ADR-202 addendum:
/// validated WIN, ~42% fewer rebuilds than periodic at matched recall, and beats the
/// Frobenius-norm monitor ADR-200 found wanting). The knob `floor` *is* the recall SLA
/// (e.g. 0.95 = "keep recall ≥ 95%"), unlike `k`/`τ` which are indirect proxies.
///
/// **Cost:** the probe costs `probe_queries.len() × n` distance-evals per update — ~12
/// orders of magnitude below a rebuild — the price of measuring recall directly. Wraps a
/// [`DriftingIndex`] in `ReweightOnly` mode and drives [`force_rebuild`](DriftingIndex::force_rebuild).
pub struct RecallTrigger {
index: DriftingIndex,
probe_queries: Vec<u32>,
k: usize,
floor: f32,
search_beam: usize,
}
impl RecallTrigger {
/// Build the trigger on `vectors` (the `E₀` snapshot). `probe_queries` is a small, fixed
/// held-out set of point indices used to estimate recall; `floor` is the recall target.
#[allow(clippy::too_many_arguments)]
pub fn build(
vectors: &FlatVectors,
probe_queries: Vec<u32>,
k: usize,
floor: f32,
search_beam: usize,
max_degree: usize,
build_beam: usize,
alpha: f32,
) -> Result<Self> {
let index = DriftingIndex::build(
vectors,
RebuildPolicy::ReweightOnly,
max_degree,
build_beam,
alpha,
)?;
Ok(Self {
index,
probe_queries,
k,
floor,
search_beam,
})
}
/// Probe-estimated recall@k of the current topology against exact neighbours under
/// `vectors` (mean over the probe set). 1.0 if the probe set is empty.
pub fn probe_recall(&self, vectors: &FlatVectors) -> f32 {
if self.probe_queries.is_empty() {
return 1.0;
}
let mut sum = 0.0f32;
for &q in &self.probe_queries {
let qi = q as usize;
let truth = brute_force_topk(vectors, qi, self.k);
let qv = vectors.get(qi);
let (cands, _) = self.index.search(vectors, qv, self.search_beam);
let mut scored: Vec<(f32, u32)> = cands
.iter()
.map(|&c| (crate::distance::l2_squared(vectors.get(c as usize), qv), c))
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
let hits = scored
.into_iter()
.filter(|&(_, c)| c as usize != qi)
.take(self.k)
.filter(|(_, c)| truth.contains(c))
.count();
sum += hits as f32 / self.k.max(1) as f32;
}
sum / self.probe_queries.len() as f32
}
/// React to a metric update: rebuild on `vectors` iff the probe recall is below `floor`.
/// Returns whether a rebuild happened.
pub fn on_metric_update(&mut self, vectors: &FlatVectors) -> Result<bool> {
if self.probe_recall(vectors) < self.floor {
self.index.force_rebuild(vectors)?;
Ok(true)
} else {
Ok(false)
}
}
/// Search the current topology against `vectors`.
pub fn search(
&self,
vectors: &FlatVectors,
query: &[f32],
beam_width: usize,
) -> (Vec<u32>, usize) {
self.index.search(vectors, query, beam_width)
}
/// Number of rebuilds the trigger has fired.
pub fn rebuilds(&self) -> usize {
self.index.rebuilds()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Deterministic clustered points so the graph is non-trivial.
fn fixture(n: usize, dim: usize) -> FlatVectors {
let mut f = FlatVectors::with_capacity(dim, n);
for i in 0..n {
let v: Vec<f32> = (0..dim)
.map(|d| ((i * 31 + d * 7) % 97) as f32 / 97.0)
.collect();
f.push(&v);
}
f
}
#[test]
fn reweight_only_never_rebuilds() {
let v = fixture(64, 8);
let mut idx = DriftingIndex::build(&v, RebuildPolicy::ReweightOnly, 16, 32, 1.2).unwrap();
for _ in 0..10 {
assert!(!idx.on_metric_update(&v).unwrap());
}
assert_eq!(idx.rebuilds(), 0);
assert_eq!(idx.step(), 10);
}
#[test]
fn always_rebuild_rebuilds_every_step() {
let v = fixture(64, 8);
let mut idx = DriftingIndex::build(&v, RebuildPolicy::AlwaysRebuild, 16, 32, 1.2).unwrap();
for _ in 0..10 {
assert!(idx.on_metric_update(&v).unwrap());
}
assert_eq!(idx.rebuilds(), 10);
}
#[test]
fn periodic_rebuilds_on_cadence() {
let v = fixture(64, 8);
let mut idx =
DriftingIndex::build(&v, RebuildPolicy::Periodic { k: 4 }, 16, 32, 1.2).unwrap();
let did: Vec<bool> = (0..12).map(|_| idx.on_metric_update(&v).unwrap()).collect();
// steps 1..=12, rebuild at 4, 8, 12
assert_eq!(
did,
vec![false, false, false, true, false, false, false, true, false, false, false, true]
);
assert_eq!(idx.rebuilds(), 3);
}
#[test]
fn periodic_k0_is_reweight_only() {
let v = fixture(32, 8);
let mut idx =
DriftingIndex::build(&v, RebuildPolicy::Periodic { k: 0 }, 16, 32, 1.2).unwrap();
for _ in 0..5 {
assert!(!idx.on_metric_update(&v).unwrap());
}
assert_eq!(idx.rebuilds(), 0);
}
#[test]
fn force_rebuild_counts_but_does_not_advance_step() {
let v = fixture(64, 8);
let mut idx = DriftingIndex::build(&v, RebuildPolicy::ReweightOnly, 16, 32, 1.2).unwrap();
idx.on_metric_update(&v).unwrap(); // step 1, no rebuild
idx.force_rebuild(&v).unwrap(); // external trigger fires
idx.force_rebuild(&v).unwrap();
assert_eq!(
idx.step(),
1,
"force_rebuild must not advance the update step"
);
assert_eq!(
idx.rebuilds(),
2,
"force_rebuild must count toward rebuilds"
);
}
/// A geometrically distinct fixture so swapping it in collapses the E0 graph's recall.
fn fixture_b(n: usize, dim: usize) -> FlatVectors {
let mut f = FlatVectors::with_capacity(dim, n);
for i in 0..n {
let v: Vec<f32> = (0..dim)
.map(|d| (((n - i) * 53 + d * 17) % 89) as f32 / 89.0)
.collect();
f.push(&v);
}
f
}
#[test]
fn recall_trigger_holds_under_no_drift() {
let v = fixture(128, 8);
let probes: Vec<u32> = (0..16).collect();
let mut t = RecallTrigger::build(&v, probes, 5, 0.9, 32, 16, 32, 1.2).unwrap();
// same vectors → the index searches what it was built on → recall ~1.0 → no rebuild
assert!(t.probe_recall(&v) >= 0.9);
assert!(!t.on_metric_update(&v).unwrap());
assert_eq!(t.rebuilds(), 0);
}
#[test]
fn recall_trigger_fires_then_recovers_under_drift() {
let v = fixture(128, 8);
let probes: Vec<u32> = (0..16).collect();
let mut t = RecallTrigger::build(&v, probes, 5, 0.9, 32, 16, 32, 1.2).unwrap();
// swap in a geometrically different vector set: recall collapses → trigger fires
let vb = fixture_b(128, 8);
assert!(
t.probe_recall(&vb) < 0.9,
"drift should drop probe recall below floor"
);
assert!(
t.on_metric_update(&vb).unwrap(),
"trigger must fire on the drift"
);
assert_eq!(t.rebuilds(), 1);
// after rebuilding on vb, recall is restored → a second update does not re-fire
assert!(!t.on_metric_update(&vb).unwrap());
assert_eq!(t.rebuilds(), 1);
}
#[test]
fn search_returns_self_as_nearest() {
let v = fixture(128, 8);
let idx = DriftingIndex::build(&v, RebuildPolicy::ReweightOnly, 16, 32, 1.2).unwrap();
// Query with point 5's own vector; it should be among the nearest candidates.
let q = v.get(5).to_vec();
let (cands, visited) = idx.search(&v, &q, 16);
assert!(visited > 0);
assert!(cands.contains(&5), "self should be retrieved: {cands:?}");
}
}

View file

@ -55,6 +55,13 @@ cold-tier = ["mmap"] # Hyperbatch training for graphs exceeding RAM
criterion = { workspace = true }
proptest = { workspace = true }
tempfile = "3.10"
# BET 1 productionize (ADR-200): the real-trajectory validation harness drives the
# diskann reuse policy. See docs/plans/bet1-productionize/PRE-REGISTRATION.md.
ruvector-diskann = { path = "../ruvector-diskann", features = ["reuse-under-drift"] }
[[example]]
name = "diskann_real_trajectory"
path = "examples/diskann_real_trajectory.rs"
[lib]
crate-type = ["rlib"]

View file

@ -0,0 +1,626 @@
//! BET 1 productionize (ADR-200 next-step #4): validate fixed-topology reuse +
//! periodic rebuild on a **real learned-GNN embedding trajectory** — not a synthetic
//! `A(t)` transform. The trajectory is produced by contrastive link-prediction
//! (InfoNCE over the ogbn-arxiv citation graph) using `ruvector-gnn`'s own optimizer
//! and loss; the index is the shipping `ruvector-diskann` Vamana, driven through its
//! `reuse-under-drift` policy (`DriftingIndex`).
//!
//! Gate (frozen, pre-registered): docs/plans/bet1-productionize/PRE-REGISTRATION.md.
//! WIN = ReweightOnly within 2% recall@10 of AlwaysRebuild, and some Periodic{k}
//! within 1% at <= 50% cumulative rebuild cost.
//! KILL = ReweightOnly collapses early AND no Periodic{k} recovers within gate.
//! Precondition (teeth): the trajectory must induce >= 15% top-10 churn E0->ET,
//! and the Stale control must degrade materially.
//!
//! Run: cargo run --release -p ruvector-gnn --example diskann_real_trajectory -- [N] [EPOCHS]
use ndarray::Array2;
use rand::{rngs::StdRng, Rng, SeedableRng};
use ruvector_diskann::distance::{l2_squared, FlatVectors};
use ruvector_diskann::{DriftingIndex, RebuildPolicy};
use ruvector_gnn::training::{info_nce_loss, Optimizer, OptimizerType};
use std::time::Instant;
const DIM: usize = 128;
const R: usize = 32; // Vamana max out-degree (production default)
const BUILD_BEAM: usize = 64;
const SEARCH_BEAM: usize = 64;
const ALPHA: f32 = 1.2;
const K: usize = 10; // recall@K
// ---------- data loading ----------
fn read_features(path: &str, n: usize) -> Vec<Vec<f32>> {
let txt = std::fs::read_to_string(path).expect("read features csv");
txt.lines()
.take(n)
.map(|line| {
line.split(',')
.map(|s| s.trim().parse::<f32>().unwrap())
.collect()
})
.collect()
}
/// Citation edges with both endpoints inside the n-node slice (self-loops dropped).
fn read_edges(path: &str, n: usize) -> Vec<(usize, usize)> {
let txt = std::fs::read_to_string(path).expect("read edge csv");
let mut edges = Vec::new();
for line in txt.lines() {
let mut it = line.split(',');
if let (Some(a), Some(b)) = (it.next(), it.next()) {
if let (Ok(a), Ok(b)) = (a.trim().parse::<usize>(), b.trim().parse::<usize>()) {
if a < n && b < n && a != b {
edges.push((a, b));
}
}
}
}
edges
}
// ---------- embedding helpers ----------
fn normalize_row(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-12);
for x in v.iter_mut() {
*x /= norm;
}
}
fn matrix_from_features(feats: &[Vec<f32>]) -> Array2<f32> {
let n = feats.len();
let mut m = Array2::<f32>::zeros((n, DIM));
for (i, f) in feats.iter().enumerate() {
let mut row = f.clone();
normalize_row(&mut row);
for d in 0..DIM {
m[[i, d]] = row[d];
}
}
m
}
fn to_flat(emb: &Array2<f32>) -> FlatVectors {
let n = emb.nrows();
let mut f = FlatVectors::with_capacity(DIM, n);
let mut buf = vec![0.0f32; DIM];
for i in 0..n {
for d in 0..DIM {
buf[d] = emb[[i, d]];
}
f.push(&buf);
}
f
}
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
/// Exact top-k under the L2 metric on `emb` (the index's metric), excluding `q` itself.
fn brute_topk(emb: &Array2<f32>, q: usize, k: usize) -> Vec<u32> {
let n = emb.nrows();
let qv = emb.row(q);
let qs = qv.as_slice().unwrap();
let mut scored: Vec<(f32, u32)> = (0..n)
.filter(|&i| i != q)
.map(|i| (l2_squared(emb.row(i).as_slice().unwrap(), qs), i as u32))
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
scored.into_iter().take(k).map(|(_, i)| i).collect()
}
fn recall(got: &[u32], truth: &[u32]) -> f64 {
if truth.is_empty() {
return 1.0;
}
let hits = got.iter().filter(|g| truth.contains(g)).count();
hits as f64 / truth.len() as f64
}
/// Graph search over `flat`/`emb` then exact re-rank by L2 to the query; returns
/// (top-k ids, distance-evals proxy = nodes visited during the greedy walk).
fn search_topk(
idx: &DriftingIndex,
emb: &Array2<f32>,
flat: &FlatVectors,
q: usize,
) -> (Vec<u32>, usize) {
let qs = emb.row(q).as_slice().unwrap().to_vec();
let (cands, visited) = idx.search(flat, &qs, SEARCH_BEAM);
let mut scored: Vec<(f32, u32)> = cands
.iter()
.map(|&c| (l2_squared(emb.row(c as usize).as_slice().unwrap(), &qs), c))
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
let ids = scored
.into_iter()
.filter(|&(_, c)| c as usize != q)
.take(K)
.map(|(_, c)| c)
.collect();
(ids, visited)
}
// ---------- trajectory generation: contrastive link-prediction (InfoNCE) ----------
struct Trajectory {
snapshots: Vec<Array2<f32>>, // E0 .. ET (E0 = normalized raw features)
loss_curve: Vec<f32>,
}
#[allow(clippy::too_many_arguments)]
fn train_trajectory(
e0: Array2<f32>,
edges: &[(usize, usize)],
n: usize,
epochs: usize,
snap_every: usize,
batch: usize,
n_neg: usize,
tau: f32,
lr: f32,
seed: u64,
) -> Trajectory {
let mut emb = e0.clone();
let mut opt = Optimizer::new(OptimizerType::Adam {
learning_rate: lr,
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
});
let mut rng = StdRng::seed_from_u64(seed);
let mut snapshots = vec![emb.clone()];
let mut loss_curve = Vec::with_capacity(epochs);
for _epoch in 0..epochs {
let mut grad = Array2::<f32>::zeros((n, DIM));
let mut loss_acc = 0.0f32;
let mut count = 0usize;
for _ in 0..batch {
let (a, p) = edges[rng.gen_range(0..edges.len())];
let negs: Vec<usize> = (0..n_neg)
.map(|_| {
let mut j = rng.gen_range(0..n);
while j == a {
j = rng.gen_range(0..n);
}
j
})
.collect();
let av: Vec<f32> = emb.row(a).to_vec();
let pv: Vec<f32> = emb.row(p).to_vec();
// scores / tau over {p} u negs (cosine == dot on the unit sphere)
let s_p = dot(&av, &pv) / tau;
let mut s_neg = Vec::with_capacity(n_neg);
for &j in &negs {
s_neg.push(dot(&av, emb.row(j).as_slice().unwrap()) / tau);
}
// softmax over [s_p, s_neg...]
let m = s_neg.iter().cloned().fold(s_p, f32::max);
let mut z = (s_p - m).exp();
for &s in &s_neg {
z += (s - m).exp();
}
let sm_p = (s_p - m).exp() / z;
// reported loss via the repo primitive (faithful to the pre-registration):
// on normalized vectors info_nce_loss's cosine == our dot scores.
let neg_vecs: Vec<Vec<f32>> = negs.iter().map(|&j| emb.row(j).to_vec()).collect();
let neg_refs: Vec<&[f32]> = neg_vecs.iter().map(|v| v.as_slice()).collect();
loss_acc += info_nce_loss(&av, &[&pv], &neg_refs, tau);
count += 1;
// grads: dL/da = (1/tau)[ (sm_p-1) p + sum_j sm_j neg_j ]
// dL/dp = (1/tau)(sm_p-1) a ; dL/dneg_j = (1/tau) sm_j a
let inv_tau = 1.0 / tau;
for d in 0..DIM {
grad[[a, d]] += inv_tau * (sm_p - 1.0) * pv[d];
grad[[p, d]] += inv_tau * (sm_p - 1.0) * av[d];
}
for (jdx, &j) in negs.iter().enumerate() {
let sm_j = (s_neg[jdx] - m).exp() / z;
for d in 0..DIM {
grad[[a, d]] += inv_tau * sm_j * emb[[j, d]];
grad[[j, d]] += inv_tau * sm_j * av[d];
}
}
}
// average over the mini-batch for a stable step scale
grad.mapv_inplace(|g| g / batch as f32);
opt.step(&mut emb, &grad).expect("optimizer step");
// retraction back onto the unit sphere (keeps cosine == dot)
for i in 0..n {
let mut row = emb.row(i).to_vec();
normalize_row(&mut row);
for d in 0..DIM {
emb[[i, d]] = row[d];
}
}
loss_curve.push(loss_acc / count.max(1) as f32);
if (_epoch + 1) % snap_every == 0 {
snapshots.push(emb.clone());
}
}
if (epochs % snap_every) != 0 {
snapshots.push(emb.clone()); // ensure ET is captured
}
Trajectory {
snapshots,
loss_curve,
}
}
// ---------- node-classification trajectory (the ADR-202 generality check) ----------
fn read_labels(path: &str, n: usize) -> Vec<usize> {
let txt = std::fs::read_to_string(path).expect("read labels csv");
txt.lines()
.take(n)
.map(|l| l.trim().parse::<usize>().unwrap())
.collect()
}
/// Drift the embeddings by supervised node classification: a linear head `W` (d×C) maps each
/// embedding to class logits; cross-entropy trains both `W` and the embeddings, pulling each
/// node toward its class region. A genuinely different drift geometry from link-prediction.
#[allow(clippy::too_many_arguments)]
fn train_nodeclass_trajectory(
e0: Array2<f32>,
labels: &[usize],
n_cls: usize,
n: usize,
epochs: usize,
snap_every: usize,
lr: f32,
seed: u64,
) -> Trajectory {
let mut emb = e0.clone();
let mut w = Array2::<f32>::zeros((DIM, n_cls)); // classifier head
{
// small random init so logits aren't degenerate
let mut rng = StdRng::seed_from_u64(seed);
for v in w.iter_mut() {
*v = (rng.gen_range(0..2000) as f32 / 1000.0 - 1.0) * 0.01;
}
}
let mut opt_e = Optimizer::new(OptimizerType::Adam {
learning_rate: lr,
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
});
let mut opt_w = Optimizer::new(OptimizerType::Adam {
learning_rate: lr,
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
});
let mut snapshots = vec![emb.clone()];
let mut loss_curve = Vec::with_capacity(epochs);
for _epoch in 0..epochs {
let mut grad_e = Array2::<f32>::zeros((n, DIM));
let mut grad_w = Array2::<f32>::zeros((DIM, n_cls));
let mut loss_acc = 0.0f32;
for i in 0..n {
// logits = emb_i · W
let mut logits = vec![0.0f32; n_cls];
for c in 0..n_cls {
let mut s = 0.0f32;
for d in 0..DIM {
s += emb[[i, d]] * w[[d, c]];
}
logits[c] = s;
}
let m = logits.iter().cloned().fold(f32::MIN, f32::max);
let mut z = 0.0f32;
for c in 0..n_cls {
logits[c] = (logits[c] - m).exp();
z += logits[c];
}
let y = labels[i];
loss_acc += -(logits[y] / z).max(1e-12).ln();
// dL/dlogit_c = softmax_c - [c==y]
for c in 0..n_cls {
let g = logits[c] / z - if c == y { 1.0 } else { 0.0 };
for d in 0..DIM {
grad_e[[i, d]] += g * w[[d, c]];
grad_w[[d, c]] += g * emb[[i, d]];
}
}
}
grad_e.mapv_inplace(|g| g / n as f32);
grad_w.mapv_inplace(|g| g / n as f32);
opt_e.step(&mut emb, &grad_e).expect("step e");
opt_w.step(&mut w, &grad_w).expect("step w");
for i in 0..n {
let mut row = emb.row(i).to_vec();
normalize_row(&mut row);
for d in 0..DIM {
emb[[i, d]] = row[d];
}
}
loss_curve.push(loss_acc / n as f32);
if (_epoch + 1) % snap_every == 0 {
snapshots.push(emb.clone());
}
}
if epochs % snap_every != 0 {
snapshots.push(emb.clone());
}
Trajectory {
snapshots,
loss_curve,
}
}
// ---------- contenders ----------
fn build_index(emb: &Array2<f32>, policy: RebuildPolicy) -> DriftingIndex {
let flat = to_flat(emb);
DriftingIndex::build(&flat, policy, R, BUILD_BEAM, ALPHA).expect("build")
}
fn main() {
// Args: N EPOCHS LR SNAP_EVERY. The trajectory must be *gradual* (the premise is
// a GNN that *continuously* re-estimates relevance), so lr/snap are chosen for a
// smooth churn ramp, not a single violent jump — set before reading the verdict.
let args: Vec<String> = std::env::args().collect();
let n: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20_000);
let epochs: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(60);
let lr: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(0.01);
let snap_every: usize = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(3);
// objective: "linkpred" (default, contrastive citation link-prediction) or "nodeclass"
// (supervised CE on the 40 real arxiv subject labels) — the generality check of ADR-202.
let objective = args
.get(5)
.map(|s| s.as_str())
.unwrap_or("linkpred")
.to_string();
let feat_path = "target/m1-data/node-feat-100k.csv";
let edge_path = "target/m1-data/arxiv/raw/edge.csv";
eprintln!("[traj] loading arxiv slice n={n} ...");
let feats = read_features(feat_path, n);
let n = feats.len();
let edges = read_edges(edge_path, n);
eprintln!(
"[traj] {} intra-slice citation edges; dim={DIM}",
edges.len()
);
assert!(!edges.is_empty(), "no edges in slice; increase N");
let e0 = matrix_from_features(&feats);
// ---- M1: generate the real learned trajectory (objective selectable) ----
let t0 = Instant::now();
let traj = if objective == "nodeclass" {
let labels = read_labels("target/m1-data/node-label.csv", n);
let n_cls = labels.iter().copied().max().unwrap_or(0) + 1;
eprintln!("[traj] objective=nodeclass; {n_cls} classes");
train_nodeclass_trajectory(e0, &labels, n_cls, n, epochs, snap_every, lr, 1234)
} else {
eprintln!("[traj] objective=linkpred");
train_trajectory(
e0, &edges, n, epochs, snap_every, /*batch*/ 2048, /*n_neg*/ 64,
/*tau*/ 0.1, lr, /*seed*/ 1234,
)
};
let n_snap = traj.snapshots.len();
eprintln!(
"[traj] trained {epochs} epochs in {:.1}s; {n_snap} snapshots; loss {:.3} -> {:.3}",
t0.elapsed().as_secs_f64(),
traj.loss_curve.first().copied().unwrap_or(0.0),
traj.loss_curve.last().copied().unwrap_or(0.0),
);
// query set + per-snapshot ground truth (brute force under E_t)
let mut qrng = StdRng::seed_from_u64(999);
let n_queries = 200.min(n);
let queries: Vec<usize> = (0..n_queries).map(|_| qrng.gen_range(0..n)).collect();
let truth_per_step: Vec<Vec<Vec<u32>>> = traj
.snapshots
.iter()
.map(|e| queries.iter().map(|&q| brute_topk(e, q, K)).collect())
.collect();
// ---- precondition (teeth): top-10 churn E0 -> ET ----
let churn_total: f64 = queries
.iter()
.enumerate()
.map(|(qi, _)| 1.0 - recall(&truth_per_step[n_snap - 1][qi], &truth_per_step[0][qi]))
.sum::<f64>()
/ n_queries as f64;
println!(
"\n=== PRECONDITION: top-{K} churn E0->ET = {:.1}% (gate: >= 15%) ===",
churn_total * 100.0
);
if churn_total < 0.15 {
println!("!! trajectory too gentle (churn < 15%) — escalate epochs/lr before treating any result as valid.");
}
// ---- M2/M3: contenders over the trajectory ----
let policies: Vec<(&str, RebuildPolicy)> = vec![
("B always", RebuildPolicy::AlwaysRebuild),
("A reuse", RebuildPolicy::ReweightOnly),
("P k=2", RebuildPolicy::Periodic { k: 2 }),
("P k=4", RebuildPolicy::Periodic { k: 4 }),
("P k=8", RebuildPolicy::Periodic { k: 8 }),
];
// one DriftingIndex per policy, all built on E0
let mut indices: Vec<DriftingIndex> = policies
.iter()
.map(|&(_, p)| build_index(&traj.snapshots[0], p))
.collect();
// Stale control: graph AND vectors frozen at E0.
let stale_idx = build_index(&traj.snapshots[0], RebuildPolicy::ReweightOnly);
let stale_flat = to_flat(&traj.snapshots[0]);
let mut rebuild_cost = vec![0.0f64; policies.len()];
let mut recall_sum = vec![0.0f64; policies.len()];
let mut evals_sum = vec![0.0f64; policies.len()];
let mut steps_counted = 0usize;
// per-step series for regime-resolved gate analysis (the gate's "early trajectory" clause)
let mut step_churn: Vec<f64> = Vec::new();
let mut step_recall: Vec<Vec<f64>> = vec![Vec::new(); policies.len()];
// header
println!("\n=== CONTENDERS: recall@{K} per step (mean over {n_queries} queries) ===");
print!("{:>4} {:>7}", "step", "churn");
for (name, _) in &policies {
print!(" {:>9}", name);
}
println!(" {:>9}", "C stale");
println!("{}", "-".repeat(8 + 10 * (policies.len() + 1)));
for step in 1..n_snap {
let emb = &traj.snapshots[step];
let flat = to_flat(emb);
let truth = &truth_per_step[step];
let churn: f64 = (0..n_queries)
.map(|qi| 1.0 - recall(&truth[qi], &truth_per_step[0][qi]))
.sum::<f64>()
/ n_queries as f64;
print!("{:>4} {:>6.0}%", step, churn * 100.0);
for (pi, idx) in indices.iter_mut().enumerate() {
let tb = Instant::now();
let did_rebuild = idx.on_metric_update(&flat).expect("update");
if did_rebuild {
rebuild_cost[pi] += tb.elapsed().as_secs_f64();
}
let mut rsum = 0.0f64;
let mut esum = 0.0f64;
for (qi, &q) in queries.iter().enumerate() {
let (got, ev) = search_topk(idx, emb, &flat, q);
rsum += recall(&got, &truth[qi]);
esum += ev as f64;
}
let r = rsum / n_queries as f64;
recall_sum[pi] += r;
evals_sum[pi] += esum / n_queries as f64;
step_recall[pi].push(r);
print!(" {:>8.1}%", r * 100.0);
}
step_churn.push(churn);
// Stale control: search the E0 graph against E0 vectors, grade vs current truth.
let mut cs = 0.0f64;
for (qi, &q) in queries.iter().enumerate() {
let (got, _) = search_topk(&stale_idx, &traj.snapshots[0], &stale_flat, q);
cs += recall(&got, &truth[qi]);
}
print!(" {:>8.1}%", cs / n_queries as f64 * 100.0);
println!();
steps_counted += 1;
}
// ---- summary + gate verdict ----
let steps = steps_counted.max(1) as f64;
println!("\n=== SUMMARY (mean over {steps_counted} drift steps) ===");
println!(
"{:>9} {:>9} {:>14} {:>12}",
"policy", "recall", "rebuild cost s", "evals/query"
);
let mut mean_recall = vec![0.0f64; policies.len()];
for (pi, (name, _)) in policies.iter().enumerate() {
mean_recall[pi] = recall_sum[pi] / steps;
println!(
"{:>9} {:>8.1}% {:>14.2} {:>12.0}",
name,
mean_recall[pi] * 100.0,
rebuild_cost[pi],
evals_sum[pi] / steps,
);
}
// indices: 0=B always, 1=A reuse, 2..=Periodic
let b_recall = mean_recall[0];
let b_cost = rebuild_cost[0].max(1e-9);
let a_gap_avg = (b_recall - mean_recall[1]) * 100.0; // trajectory-wide (pessimistic, mixes regimes)
let eval_ratio_a = (evals_sum[1] / steps) / (evals_sum[0] / steps).max(1e-9);
// The frozen gate's "within 2% over the EARLY trajectory" clause, operationalized as
// the holding ceiling: the highest cumulative churn reached while A (reuse) stayed
// within 2% of B at every step up to there. This is the regime-resolved statistic the
// gate named — not the trajectory-wide mean, which deliberately overdrives past it.
let mut holding_ceiling = 0.0f64;
for s in 0..step_churn.len() {
if (step_recall[0][s] - step_recall[1][s]) * 100.0 <= 2.0 {
holding_ceiling = holding_ceiling.max(step_churn[s]);
} else {
break;
}
}
println!("\n=== GATE (pre-registered) ===");
println!(
"churn E0->ET ............. {:.1}% (precondition >= 15%: {})",
churn_total * 100.0,
pass(churn_total >= 0.15)
);
println!(
"A reuse holding ceiling .. {:.0}% churn (transfer vs ADR-200 ~36%: {})",
holding_ceiling * 100.0,
pass(holding_ceiling >= 0.30)
);
println!(
"A reuse gap (whole traj) . {:+.2}% vs B (decays past ceiling, by design)",
-a_gap_avg
);
println!("A reuse evals (whole traj) {:.2}x B", eval_ratio_a);
// best Periodic within 1% of B at <= 50% cost (the shippable hybrid)
let mut periodic_win = false;
let mut best_desc = String::from("none within gate");
for pi in 2..policies.len() {
let gap = (b_recall - mean_recall[pi]) * 100.0;
let cost_frac = rebuild_cost[pi] / b_cost;
let p_eval_ratio = (evals_sum[pi] / steps) / (evals_sum[0] / steps).max(1e-9);
if gap <= 1.0 && cost_frac <= 0.5 {
periodic_win = true;
best_desc = format!(
"{} (gap {:+.2}%, cost {:.0}% of B, evals {:.2}x B)",
policies[pi].0,
-gap,
cost_frac * 100.0,
p_eval_ratio
);
break;
}
}
println!(
"Periodic within 1% @ <=50% cost: {} [{}]",
pass(periodic_win),
best_desc
);
let verdict = if churn_total < 0.15 {
"VOID (trajectory too gentle — escalate epochs/lr)"
} else if holding_ceiling >= 0.30 && periodic_win {
"WIN — reuse transfers in-regime (holds to ADR-200-class churn) AND periodic recovers the high-churn tail"
} else if holding_ceiling >= 0.30 {
"PARTIAL — reuse transfers in-regime but no periodic{k} recovered the tail within gate"
} else if periodic_win {
"PARTIAL — pure reuse does not transfer (low holding ceiling) but periodic recovers"
} else {
"KILL — BET 1 does not transfer to real GNN drift"
};
println!("\n>>> VERDICT: {verdict}");
}
fn pass(b: bool) -> &'static str {
if b {
"PASS"
} else {
"FAIL"
}
}

View file

@ -0,0 +1,518 @@
//! BET 1 follow-up (ADR-200 next-step #2, ADR-202 next-step): does a **sampled-recall
//! rebuild trigger** beat fixed `Periodic{k}` under *variable-rate* drift — and beat the
//! Frobenius-norm monitor ADR-200 found wanting?
//!
//! Periodic{k} is near-optimal under STEADY drift (ADR-202). A trigger can only earn its
//! keep when drift is BURSTY: calm stretches where a fixed cadence over-rebuilds, bursts
//! where it under-rebuilds. So the trajectory here alternates high-lr bursts and low-lr
//! calm. If the trigger can't beat periodic *there*, it's a clean KILL.
//!
//! Gate (frozen): docs/plans/bet1-productionize/PRE-REGISTRATION-trigger.md.
//! Honest comparison = the (rebuilds, recall) PARETO FRONTIER of Triggered{floor},
//! Periodic{k}, Frobenius{tau} (no cherry-picked single config). WIN = Triggered's
//! frontier dominates (fewer rebuilds at equal recall) AND the probe's own cost
//! (counted) is less than the rebuilds it saves AND it beats Frobenius.
//!
//! Runs at n=10k: ADR-202 already established scale-robustness; this bet isolates the
//! cadence question, where rebuild *count* (not scale) is the signal.
//!
//! Run: cargo run --release -p ruvector-gnn --example triggered_rebuild -- [N] [EPOCHS]
use ndarray::Array2;
use rand::{rngs::StdRng, Rng, SeedableRng};
use ruvector_diskann::distance::{l2_squared, FlatVectors};
use ruvector_diskann::{DriftingIndex, RebuildPolicy};
use ruvector_gnn::training::{Optimizer, OptimizerType};
use std::time::Instant;
const DIM: usize = 128;
const R: usize = 32;
const BUILD_BEAM: usize = 64;
const SEARCH_BEAM: usize = 64;
const ALPHA: f32 = 1.2;
const K: usize = 10;
// ---------- data + embedding helpers (self-contained; cf. diskann_real_trajectory.rs) ----------
fn read_features(path: &str, n: usize) -> Vec<Vec<f32>> {
let txt = std::fs::read_to_string(path).expect("read features csv");
txt.lines()
.take(n)
.map(|line| {
line.split(',')
.map(|s| s.trim().parse::<f32>().unwrap())
.collect()
})
.collect()
}
fn read_edges(path: &str, n: usize) -> Vec<(usize, usize)> {
let txt = std::fs::read_to_string(path).expect("read edge csv");
let mut edges = Vec::new();
for line in txt.lines() {
let mut it = line.split(',');
if let (Some(a), Some(b)) = (it.next(), it.next()) {
if let (Ok(a), Ok(b)) = (a.trim().parse::<usize>(), b.trim().parse::<usize>()) {
if a < n && b < n && a != b {
edges.push((a, b));
}
}
}
}
edges
}
fn normalize_row(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-12);
for x in v.iter_mut() {
*x /= norm;
}
}
fn matrix_from_features(feats: &[Vec<f32>]) -> Array2<f32> {
let n = feats.len();
let mut m = Array2::<f32>::zeros((n, DIM));
for (i, f) in feats.iter().enumerate() {
let mut row = f.clone();
normalize_row(&mut row);
for d in 0..DIM {
m[[i, d]] = row[d];
}
}
m
}
fn to_flat(emb: &Array2<f32>) -> FlatVectors {
let mut f = FlatVectors::with_capacity(DIM, emb.nrows());
let mut buf = vec![0.0f32; DIM];
for i in 0..emb.nrows() {
for d in 0..DIM {
buf[d] = emb[[i, d]];
}
f.push(&buf);
}
f
}
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
fn brute_topk(emb: &Array2<f32>, q: usize, k: usize) -> Vec<u32> {
let qrow = emb.row(q);
let qs = qrow.as_slice().unwrap();
let mut scored: Vec<(f32, u32)> = (0..emb.nrows())
.filter(|&i| i != q)
.map(|i| (l2_squared(emb.row(i).as_slice().unwrap(), qs), i as u32))
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
scored.into_iter().take(k).map(|(_, i)| i).collect()
}
fn recall(got: &[u32], truth: &[u32]) -> f64 {
if truth.is_empty() {
return 1.0;
}
got.iter().filter(|g| truth.contains(g)).count() as f64 / truth.len() as f64
}
fn search_topk(idx: &DriftingIndex, emb: &Array2<f32>, flat: &FlatVectors, q: usize) -> Vec<u32> {
let qs = emb.row(q).as_slice().unwrap().to_vec();
let (cands, _) = idx.search(flat, &qs, SEARCH_BEAM);
let mut scored: Vec<(f32, u32)> = cands
.iter()
.map(|&c| (l2_squared(emb.row(c as usize).as_slice().unwrap(), &qs), c))
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
scored
.into_iter()
.filter(|&(_, c)| c as usize != q)
.take(K)
.map(|(_, c)| c)
.collect()
}
/// Mean recall of the reuse index over `qs` against truth recomputed under `emb`.
fn probe_recall(idx: &DriftingIndex, emb: &Array2<f32>, flat: &FlatVectors, qs: &[usize]) -> f64 {
qs.iter()
.map(|&q| recall(&search_topk(idx, emb, flat, q), &brute_topk(emb, q, K)))
.sum::<f64>()
/ qs.len().max(1) as f64
}
// ---------- variable-rate contrastive trajectory ----------
/// `lr_at(epoch)` lets the caller impose a burst/calm schedule.
fn train_variable_rate(
e0: Array2<f32>,
edges: &[(usize, usize)],
n: usize,
epochs: usize,
batch: usize,
n_neg: usize,
tau: f32,
lr_at: impl Fn(usize) -> f32,
seed: u64,
) -> Vec<Array2<f32>> {
let mut emb = e0.clone();
let mut rng = StdRng::seed_from_u64(seed);
let mut snapshots = vec![emb.clone()];
for epoch in 0..epochs {
let lr = lr_at(epoch);
// Adam (fresh per epoch so the burst/calm lr schedule takes effect): its
// per-parameter scaling produces real embedding motion at these lrs where plain
// SGD does not (a VOID 0%-churn trajectory).
let mut opt = Optimizer::new(OptimizerType::Adam {
learning_rate: lr,
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
});
let mut grad = Array2::<f32>::zeros((n, DIM));
for _ in 0..batch {
let (a, p) = edges[rng.gen_range(0..edges.len())];
let negs: Vec<usize> = (0..n_neg)
.map(|_| {
let mut j = rng.gen_range(0..n);
while j == a {
j = rng.gen_range(0..n);
}
j
})
.collect();
let av: Vec<f32> = emb.row(a).to_vec();
let pv: Vec<f32> = emb.row(p).to_vec();
let s_p = dot(&av, &pv) / tau;
let s_neg: Vec<f32> = negs
.iter()
.map(|&j| dot(&av, emb.row(j).as_slice().unwrap()) / tau)
.collect();
let m = s_neg.iter().cloned().fold(s_p, f32::max);
let mut z = (s_p - m).exp();
for &s in &s_neg {
z += (s - m).exp();
}
let sm_p = (s_p - m).exp() / z;
let inv_tau = 1.0 / tau;
for d in 0..DIM {
grad[[a, d]] += inv_tau * (sm_p - 1.0) * pv[d];
grad[[p, d]] += inv_tau * (sm_p - 1.0) * av[d];
}
for (jdx, &j) in negs.iter().enumerate() {
let sm_j = (s_neg[jdx] - m).exp() / z;
for d in 0..DIM {
grad[[a, d]] += inv_tau * sm_j * emb[[j, d]];
grad[[j, d]] += inv_tau * sm_j * av[d];
}
}
}
grad.mapv_inplace(|g| g / batch as f32);
opt.step(&mut emb, &grad).expect("step");
for i in 0..n {
let mut row = emb.row(i).to_vec();
normalize_row(&mut row);
for d in 0..DIM {
emb[[i, d]] = row[d];
}
}
let _ = epoch;
snapshots.push(emb.clone());
}
snapshots
}
// ---------- policy runner ----------
#[derive(Clone, Copy)]
enum Trigger {
Periodic(usize),
Frobenius(f32), // rebuild when mean per-node displacement since last rebuild > tau
Recall(f64), // rebuild when sampled-recall probe < floor
}
struct Outcome {
label: String,
recall: f64,
rebuilds: usize,
rebuild_cost_s: f64,
probe_evals: f64, // distance-evals spent on the recall probe (counted against the trigger)
}
#[allow(clippy::too_many_arguments)]
fn run_policy(
label: String,
trig: Trigger,
snapshots: &[Array2<f32>],
flats: &[FlatVectors],
queries: &[usize],
truth: &[Vec<Vec<u32>>],
probe_qs: &[usize],
n: usize,
) -> Outcome {
// ReweightOnly => on_metric_update never auto-rebuilds; we drive force_rebuild.
let mut idx =
DriftingIndex::build(&flats[0], RebuildPolicy::ReweightOnly, R, BUILD_BEAM, ALPHA)
.expect("build");
let mut rebuilds = 0usize;
let mut rebuild_cost = 0.0f64;
let mut probe_evals = 0.0f64;
let mut last_rebuild = 0usize; // snapshot index of last (re)build
let mut recall_sum = 0.0f64;
let steps = snapshots.len() - 1;
for step in 1..snapshots.len() {
let emb = &snapshots[step];
let flat = &flats[step];
idx.on_metric_update(flat).expect("update"); // reweight (no auto-rebuild)
let do_rebuild = match trig {
Trigger::Periodic(k) => k > 0 && step % k == 0,
Trigger::Frobenius(t) => {
// mean per-node L2 displacement since last rebuild snapshot
let prev = &snapshots[last_rebuild];
let mut acc = 0.0f64;
for i in 0..n {
acc += l2_squared(
emb.row(i).as_slice().unwrap(),
prev.row(i).as_slice().unwrap(),
)
.sqrt() as f64;
}
(acc / n as f64) > t as f64
}
Trigger::Recall(floor) => {
probe_evals += (probe_qs.len() * n) as f64; // brute-force probe truth cost
probe_recall(&idx, emb, flat, probe_qs) < floor
}
};
if do_rebuild {
let tb = Instant::now();
idx.force_rebuild(flat).expect("rebuild");
rebuild_cost += tb.elapsed().as_secs_f64();
rebuilds += 1;
last_rebuild = step;
}
let r: f64 = queries
.iter()
.enumerate()
.map(|(qi, &q)| recall(&search_topk(&idx, emb, flat, q), &truth[step][qi]))
.sum::<f64>()
/ queries.len() as f64;
recall_sum += r;
}
Outcome {
label,
recall: recall_sum / steps as f64,
rebuilds,
rebuild_cost_s: rebuild_cost,
probe_evals,
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let n: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20_000);
let epochs: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(24);
let feats = read_features("target/m1-data/node-feat-100k.csv", n);
let n = feats.len();
let edges = read_edges("target/m1-data/arxiv/raw/edge.csv", n);
eprintln!("[trig] n={n} edges={} dim={DIM}", edges.len());
assert!(!edges.is_empty());
// Variable-rate schedule: 3-epoch bursts (lr 0.02) separated by 5-epoch calm (lr 0.0005).
// Adam at these lrs produces real motion in bursts, near-stasis in calm → the bursty
// churn profile where a fixed cadence is provably suboptimal.
let lr_at = |e: usize| -> f32 {
if e % 8 < 3 {
0.02
} else {
0.0005
}
};
let e0 = matrix_from_features(&feats);
let t0 = Instant::now();
let snaps = train_variable_rate(e0, &edges, n, epochs, 2048, 64, 0.1, lr_at, 1234);
eprintln!(
"[trig] {} snapshots (burst/calm) in {:.1}s",
snaps.len(),
t0.elapsed().as_secs_f64()
);
let flats: Vec<FlatVectors> = snaps.iter().map(to_flat).collect();
let mut qrng = StdRng::seed_from_u64(999);
let queries: Vec<usize> = (0..200.min(n)).map(|_| qrng.gen_range(0..n)).collect();
// disjoint probe set (no leakage into the scored query set)
let probe_qs: Vec<usize> = (0..30.min(n)).map(|_| qrng.gen_range(0..n)).collect();
let truth: Vec<Vec<Vec<u32>>> = snaps
.iter()
.map(|e| queries.iter().map(|&q| brute_topk(e, q, K)).collect())
.collect();
// per-step churn ramp (for visibility) + variable-rate sanity
let last = snaps.len() - 1;
let churn: f64 = queries
.iter()
.enumerate()
.map(|(qi, _)| 1.0 - recall(&truth[last][qi], &truth[0][qi]))
.sum::<f64>()
/ queries.len() as f64;
println!(
"\n=== variable-rate trajectory: E0->ET churn {:.0}% over {} steps ===",
churn * 100.0,
last
);
// per-step churn delta (vs previous snapshot) — bursts spike, calm flattens
print!("per-step Δchurn: ");
for step in 1..snaps.len() {
let d: f64 = queries
.iter()
.enumerate()
.map(|(qi, _)| 1.0 - recall(&truth[step][qi], &truth[step - 1][qi]))
.sum::<f64>()
/ queries.len() as f64;
print!("{:.0} ", d * 100.0);
}
println!();
if churn < 0.15 {
println!(
"\n!! VOID — trajectory churn < 15% (no real drift). Not a result; escalate lr/epochs."
);
return;
}
let configs: Vec<Trigger> = vec![
Trigger::Periodic(2),
Trigger::Periodic(3),
Trigger::Periodic(4),
Trigger::Periodic(6),
Trigger::Frobenius(0.15),
Trigger::Frobenius(0.25),
Trigger::Frobenius(0.40),
Trigger::Recall(0.97),
Trigger::Recall(0.95),
Trigger::Recall(0.93),
];
let label = |t: &Trigger| match t {
Trigger::Periodic(k) => format!("Periodic k={k}"),
Trigger::Frobenius(x) => format!("Frobenius t={x}"),
Trigger::Recall(f) => format!("Recall floor={f}"),
};
let mut outcomes: Vec<Outcome> = configs
.iter()
.map(|t| run_policy(label(t), *t, &snaps, &flats, &queries, &truth, &probe_qs, n))
.collect();
// reference: always-rebuild ceiling cost (one full build per step) for cost framing
let always = run_policy(
"ALWAYS".into(),
Trigger::Periodic(1),
&snaps,
&flats,
&queries,
&truth,
&probe_qs,
n,
);
println!(
"\n=== policy outcomes (mean recall@{K}, {} steps) ===",
last
);
println!(
"{:>18} {:>8} {:>9} {:>13} {:>13}",
"policy", "recall", "rebuilds", "rebuild s", "probe evals"
);
println!("{}", "-".repeat(64));
println!(
"{:>18} {:>7.1}% {:>9} {:>13.1} {:>13}",
always.label,
always.recall * 100.0,
always.rebuilds,
always.rebuild_cost_s,
"-"
);
for o in &outcomes {
println!(
"{:>18} {:>7.1}% {:>9} {:>13.1} {:>13.0}",
o.label,
o.recall * 100.0,
o.rebuilds,
o.rebuild_cost_s,
o.probe_evals
);
}
// ---- Pareto frontier analysis: fewer rebuilds at equal-or-better recall wins ----
// For each Recall-trigger config, find the cheapest Periodic/Frobenius config that
// matches its recall (within 0.5%); the trigger wins if it used fewer rebuilds.
outcomes.sort_by(|a, b| a.rebuilds.cmp(&b.rebuilds));
println!("\n=== GATE: does the recall trigger dominate the frontier? ===");
let recalls: Vec<&Outcome> = outcomes
.iter()
.filter(|o| o.label.starts_with("Recall"))
.collect();
let periodics: Vec<&Outcome> = outcomes
.iter()
.filter(|o| o.label.starts_with("Periodic"))
.collect();
let frobs: Vec<&Outcome> = outcomes
.iter()
.filter(|o| o.label.starts_with("Frobenius"))
.collect();
let mut trigger_wins = false;
let mut beats_frob = false;
for rt in &recalls {
// cheapest periodic with recall >= rt.recall - 0.5%
let matched = periodics
.iter()
.filter(|p| p.recall >= rt.recall - 0.005)
.min_by_key(|p| p.rebuilds);
if let Some(p) = matched {
let fewer = rt.rebuilds as f64 <= p.rebuilds as f64 * 0.75; // >=25% fewer
// best frobenius at matched recall
let fb = frobs
.iter()
.filter(|f| f.recall >= rt.recall - 0.005)
.min_by_key(|f| f.rebuilds);
let beat_this_frob = fb.map(|f| rt.rebuilds < f.rebuilds).unwrap_or(true);
println!(
" {} ({:.1}%, {} rebuilds) vs periodic {} ({} rebuilds): {}{}",
rt.label,
rt.recall * 100.0,
rt.rebuilds,
p.label,
p.rebuilds,
if fewer {
">=25% fewer ✓"
} else {
"not enough fewer"
},
fb.map(|f| format!("; vs {} ({} rebuilds)", f.label, f.rebuilds))
.unwrap_or_default()
);
if fewer {
trigger_wins = true;
}
if beat_this_frob {
beats_frob = true;
}
}
}
println!(
"\n>>> VERDICT: {}",
if trigger_wins && beats_frob {
"WIN — recall trigger uses >=25% fewer rebuilds at matched recall AND beats Frobenius"
} else if trigger_wins {
"PARTIAL — trigger beats periodic but not clearly the Frobenius monitor"
} else {
"KILL — recall trigger does not dominate periodic-K (ADR-200's periodic-is-the-knob stands)"
}
);
}

View file

@ -0,0 +1,274 @@
---
adr: 202
title: "Fixed-Topology Reuse + Periodic Rebuild on a Real Learned-GNN Trajectory"
status: proposed
date: 2026-06-04
authors: [ofershaal, claude-flow]
related: [ADR-196, ADR-198, ADR-199, ADR-200]
tags: [ruvector, retrieval, ann, vamana, diskann, gnn, self-learning, metric-drift, productionization]
---
# ADR-202 — Fixed-Topology Reuse + Periodic Rebuild on a Real Learned-GNN Trajectory
## Status
**Proposed — WIN on a real learned trajectory (2026-06-04).** This closes ADR-200's named
open frontier (next-step #4): productionize the BET 1 reuse-under-drift result by wiring
"re-weight every step + periodic rebuild" into the production `ruvector-diskann` loop behind a
feature flag, and validate it on a **genuine learned-GNN embedding trajectory** — contrastive
link-prediction over the ogbn-arxiv citation graph — instead of the synthetic `A(t)` transforms
of ADR-200.
The result **transfers, at both n=20k and n=50k**: on a real trajectory, pure topology reuse
(`ReweightOnly`) holds recall@10 **within 2% of a full rebuild up to a 40% top-10 churn ceiling
(identical at both scales)** — at or beyond ADR-200's synthetic ~36% holding regime — and the
**periodic-rebuild hybrid recovers the high-churn tail completely** (`Periodic{k:4}`: gap
**0.01%** at n=20k and **+0.1% (above rebuild)** at n=50k, at **2024%** of the cumulative
rebuild cost, equal per-query work). The stale control collapses (92% → 33%), proving the benchmark is
drift-sensitive. **Honest boundary:** pure reuse, run past its holding ceiling on a deliberately
overdriven trajectory, decays (4.73% averaged to 67% churn, 1.05× per-query distance-evals) —
which is precisely what the periodic policy is for, and the shippable periodic policy carries
neither penalty.
The gate was **pre-registered and frozen before any contender run**
(`docs/plans/bet1-productionize/PRE-REGISTRATION.md`).
## Context
RuVector is a self-learning memory: a GNN continuously re-estimates node embeddings, so the
effective L2 metric over those embeddings drifts. ADR-200 showed — under *synthetic* drift, on
the production `ruvector-diskann` Vamana — that the navigation topology can be **reused** (build
once on `E₀`, recompute only distances under `E_t`) within a 2% recall gate up to ~36% churn,
at ~10³10⁴× lower update cost, with a periodic rebuild recovering the residual gap under heavy
drift. ADR-200's explicitly-named caveat was that the drift was parametric, not a real learned
trajectory, and its next-step #4 was to wire the policy into the live loop and prove it there.
Two facts established the substrate (both verified, not assumed):
1. **The reuse hook is native.** `VamanaGraph` (`crates/ruvector-diskann/src/graph.rs`) stores
only topology (`neighbors` + `medoid`); `greedy_search(vectors, query, beam)` takes the
vectors externally. So "adapt to drift" = pass the drifted snapshot to a graph built on the
original — zero structural change.
2. **`GraphMAE::train_step` does not learn.** It takes `&self` and only returns a loss — no
backprop, no weight update — so it cannot produce drift. The repo's genuine learnable path is
direct embedding optimization via `Optimizer` (Adam/SGD) + a real objective. The trajectory is
built from those primitives, documented up front so its provenance is auditable.
## Decision / Finding
**Ship `ReweightOnly` + `Periodic{k}` as a feature-gated rebuild policy on the production
index; reuse the topology every step and rebuild on a fixed cadence.** Validated head-to-head
(pre-registered gate) against a full rebuild on a real learned trajectory, with a stale-index
negative control.
### Production wiring — `ruvector-diskann::reuse` (feature `reuse-under-drift`, default off)
`RebuildPolicy { AlwaysRebuild, ReweightOnly, Periodic { k } }` + `DriftingIndex`, which owns a
`VamanaGraph` + build params and exposes `on_metric_update(&mut self, vectors)` (bumps a step
counter; rebuilds iff the policy dictates) and `search(vectors, q, beam)`. The index owns only
the *rebuild decision*; the consumer (the GNN) owns the drifting embeddings and passes snapshots
in. The default build is byte-identical (the module is `#[cfg]`-gated out). 5 unit tests cover
cadence + search.
### Trajectory — contrastive link-prediction on ogbn-arxiv (real, public)
Node embeddings are the trainable parameters, initialised from the raw 128-d features (`E₀`,
L2-normalised). Each epoch optimises **InfoNCE** (`ruvector_gnn::training::info_nce_loss`) over
citation edges (positives) + sampled non-edges (negatives) with `ruvector_gnn`'s `Optimizer`
(Adam); embeddings are renormalised onto the unit sphere after each step (so cosine = dot and the
diskann L2 ranking agrees with the contrastive metric), and snapshotted to form `E₀ … E_T`. A
genuinely learned trajectory driven by real arxiv structure. Harness:
`crates/ruvector-gnn/examples/diskann_real_trajectory.rs`. Build params: production Vamana
R=32, L=64, α=1.2; recall@10; 200 queries.
### Evidence (n = 20,000; gradual trajectory, 30 epochs, cumulative churn → 67%)
Strategies (recall@10 vs brute-force truth recomputed under `E_t`):
| cum. churn | B always | **A reuse** | P k=2 | P k=4 | P k=8 | C stale |
|---|---|---|---|---|---|---|
| 7% | 98.7% | 98.1% | 98.6% | 98.4% | 98.2% | 91.9% |
| 20% | 98.5% | 98.2% | 98.7% | 98.5% | 97.9% | 78.7% |
| 29% | 98.4% | 97.7% | 98.6% | 98.3% | 98.6% | 70.4% |
| 37% | 98.5% | 97.1% | 98.9% | 98.3% | 98.8% | 62.7% |
| **40%** | 98.2% | **96.8%** | 98.6% | 98.8% | 98.8% | 59.7% |
| 42% | 98.9% | 95.9% | 98.8% | 98.8% | 98.6% | 57.5% |
| 54% | 99.2% | 92.4% | 98.9% | 98.6% | 99.0% | 45.8% |
| 67% | 98.8% | 87.4% | 99.2% | 99.0% | 98.8% | 33.2% |
| policy | mean recall | cumulative rebuild cost | evals/query |
|---|---|---|---|
| B always (rebuild every step) | 98.7% | 246.3s (30 builds) | 982 |
| **A reuse** (never rebuild) | 94.0% | **0s** | 1034 |
| **P k=2** | 98.8% | 124.2s | 982 |
| **P k=4** | **98.7%** | **58.7s (24% of B)** | 983 |
| P k=8 | 98.6% | 25.2s (10% of B) | 988 |
**Gate (pre-registered): WIN.**
- **Precondition (teeth) PASS** — trajectory churn 67% (≥ 15% floor); the `C` stale control
collapses 92% → 33%, so the benchmark is genuinely drift-sensitive (not insensitive).
- **Reuse transfers in-regime**`A` holds within 2% of `B` up to a **40% churn holding
ceiling**, at/beyond ADR-200's synthetic ~36%. Through 40% churn the gap is ≤1.6% and at low
churn `A` is occasionally *above* `B` (a fresh build on partially-drifted geometry can
underperform reuse — the t=0.25 effect ADR-200 first saw and reproduced).
- **Periodic recovers the tail**`Periodic{k:4}` within **0.01%** of `B` at **24%** of its
cumulative rebuild cost, with **equal** per-query work (1.00× evals). `k=8` within ~0.1% at
10% cost. ADR-200's hybrid finding (periodic-4 ≈ always at 25% cost) reproduced on real drift.
### Scale confirmation (n = 50,000; 20 epochs, cumulative churn → 50%)
The result holds at 2.5× scale — the **holding ceiling is identical (40% churn)**, and at low
churn reuse is again *above* full rebuild:
| cum. churn | B always | **A reuse** | P k=2 | P k=4 | P k=8 | C stale |
|---|---|---|---|---|---|---|
| 12% | 97.0% | **97.5%** | 96.9% | 97.3% | 97.2% | 85.8% |
| 28% | 96.7% | 97.1% | 96.9% | 96.9% | 97.1% | 70.5% |
| 36% | 97.1% | 96.1% | 96.9% | 97.2% | 96.2% | 62.0% |
| **40%** | 96.8% | **95.4%** | 97.1% | 97.1% | 95.5% | 58.2% |
| 50% | 97.5% | 93.1% | 97.3% | 97.3% | 96.7% | 48.9% |
| policy | mean recall | cumulative rebuild cost | evals/query |
|---|---|---|---|
| B always | 97.0% | 271.2s (10 builds) | 1129 |
| A reuse | 95.8% | 0s | 1138 |
| P k=2 | 97.0% | 132.0s (49% of B) | 1127 |
| **P k=4** | **97.1%** (above B) | **53.7s (20% of B)** | 1126 |
| P k=8 | 96.7% | 26.8s (10% of B) | 1130 |
Same verdict: **WIN.** Holding ceiling 40% churn (matches 20k, ≥ ADR-200's 36%); stale control
collapses 86% → 49% (teeth); `Periodic{k:4}` matches/exceeds full rebuild (97.1% vs 97.0%) at
**20% of the cost**, equal per-query work. The whole-trajectory reuse gap is only 1.18% here
(this trajectory tops out at 50% churn vs 20k's 67%) — even pure reuse nearly clears 2% across
the entire run at this drift level.
## Consequences
**Positive.**
- The reuse-under-drift result **transfers from synthetic to real learned drift** — the ADR-200
WIN is not an artifact of parametric `A(t)` transforms. A self-learning system can defer index
rebuilds under genuine GNN embedding drift.
- **The shippable policy is `Periodic{k}`, not pure reuse.** It tracks full-rebuild recall within
~0.010.1% at 1024% of the cost *and* equal per-query work — capturing nearly all of the cost
asymmetry with none of pure reuse's high-churn decay or eval penalty. `k` is a single, legible
knob (rebuild cadence).
- The policy lives behind a default-off feature flag, so it ships with zero impact on the
existing index.
**Boundaries / honest caveats.**
- **Pure `ReweightOnly` decays past its holding ceiling.** On the deliberately overdriven
trajectory (to 67% churn) it falls to 4.73% mean and pays 1.05× per-query distance-evals. This
is the predicted failure mode, addressed operationally by `Periodic{k}` — *use the hybrid, not
never-rebuild.*
- **The trajectory is one objective (contrastive link-prediction) on one corpus (arxiv).** Other
learned objectives (node classification, GraphMAE with real backprop) may drift differently;
the holding ceiling is objective-dependent.
- **The "metric update" is snapshot-granular**, not per-gradient-step; a production loop would
call `on_metric_update` on its own embedding-flush cadence.
- **Membership is fixed** (drift changes vector *values*, not the point set); streaming
insert/delete under reuse is unaddressed.
- **A smarter rebuild trigger** (sampled-recall probe, ADR-200 next-step #2) — **now tested and
WON; see the addendum below.** `Periodic{k}` remains the zero-dependency default; the trigger
is the better knob when a probe set is available.
*(Resolved from ADR-200: "synthetic drift only" — a real learned-GNN trajectory now confirms the
transfer, with the holding ceiling at 40% churn ≥ the synthetic 36%.)*
## Addendum (2026-06-04): Sampled-recall trigger — WIN
ADR-200 next-step #2 asked whether a smarter rebuild trigger beats fixed `Periodic{k}`; ADR-200's
own Frobenius-norm monitor had *lost* to periodic. Re-tested under **variable-rate** drift (the
only regime where a trigger can earn its keep — periodic is near-optimal under steady drift), with
the gate **pre-registered and frozen** (`docs/plans/bet1-productionize/PRE-REGISTRATION-trigger.md`).
**Stage:** a bursty trajectory — 3-epoch high-lr bursts (per-step churn ~45%) separated by
5-epoch low-lr calm (~2%), 89% end churn, n=20k. **Contenders:** `Recall{floor}` (the bet) vs
`Periodic{k}` (the ADR-202 winner) vs `Frobenius{τ}` (ADR-200's failed monitor), compared on the
(rebuilds, recall) Pareto frontier.
| policy | recall@10 | rebuilds | rebuild cost | probe evals |
|---|---|---|---|---|
| Always | 97.4% | 24 | 333s | — |
| Periodic k=2 | 96.8% | 12 | 168s | — |
| Periodic k=3 | 96.5% | 8 | 113s | — |
| Frobenius τ=0.15 | 97.3% | 9 | 118s | — |
| **Recall floor=0.95** | **97.2%** | **7** | **95s** | 14.4M (~1s) |
| Recall floor=0.93 | 96.6% | 6 | 85s | 14.4M |
**Verdict: WIN.** `Recall{floor=0.95}` reaches 97.2% recall at **7 rebuilds** — beating
`Periodic{k=2}` (96.8% @ 12) on *both* axes (higher recall, **42% fewer rebuilds**) and beating
the best `Frobenius{τ}` (97.3% @ 9) on rebuilds at equal recall. **Probe-cost trap passed:** the
probe's 14.4M distance-evals (~1s total) are <2% of the ~73s of rebuild time saved.
**Mechanism (visible, not asserted):** the per-step churn line `45 44 45 | 2 2 2 | 45 44 …` shows
the trigger rebuilds right after each burst and skips calm stretches, while periodic wastes
rebuilds during calm and under-protects during bursts. Frobenius measures *how much the metric
moved*; the recall probe measures *whether the move broke navigability* — and ADR-202 showed those
decouple, which is why the probe is the better signal.
**Productionized:** `ruvector_diskann::reuse::RecallTrigger` (a `DriftingIndex` in `ReweightOnly`
mode driven by a probe + `force_rebuild`). Its knob `floor` **is the recall SLA** (`0.95` = "keep
recall ≥ 95%"), unlike `k`/`τ` which are indirect proxies. Honest caveat: the probe needs an exact
small-set kNN each update (counted, negligible) and a representative probe set; with no probe
available, `Periodic{k}` remains the zero-dependency fallback. Harness:
`crates/ruvector-gnn/examples/triggered_rebuild.rs`.
## Addendum (2026-06-04): Objective-dependence — generality CONFIRMED, with a degeneracy caveat
This ADR's headline was established on **one** learned objective (contrastive link-prediction);
the named caveat was that the 40% holding ceiling might be objective-dependent. Re-tested with a
**second, different objective** — supervised **node classification** (real ogbn-arxiv 40-class
labels, cross-entropy on a linear head, embeddings as the trainable params) — via the same
harness, contenders, and 2% gate (`objective=nodeclass`; gate pre-registered in
`PRE-REGISTRATION-objective.md`). n=20k, recall@10.
**CONFIRM (the pre-registered question):** in the well-behaved early regime, reuse holds within
2% of full rebuild up to a **54% churn holding ceiling***higher* than link-prediction's 40%:
| cum. churn | B always | A reuse | gap |
|---|---|---|---|
| 13% | 98.4% | 98.5% | +0.1 (A above) |
| 37% | 98.3% | 97.7% | 0.6 |
| 47% | 98.4% | 97.4% | 1.0 |
| **54%** | 97.9% | 96.8% | **1.1** |
| 59% | 98.4% | 94.8% | 3.6 (crosses) |
So the reuse-vs-rebuild parity **generalizes across two distinct learned objectives** (40% and
54% ceilings); the objective-dependence caveat is resolved in the direction of "it generalizes,
and node-class drift is, early, *more* reuse-friendly." `Periodic{k:4}` again recovers at ~22% of
rebuild cost with ~equal per-query work.
**Honest caveat (a real finding, not buried):** past ~60% churn the node-class trajectory
**collapses the embeddings into ~40 class blobs**, and there recall@10 becomes **ill-posed** — with
~500 nodes/class on the unit sphere, a query's top-10 are near-tied intra-blob points whose order
reshuffles under tiny perturbations (churn *saturates* at 67%, never reaching 100%, because
cross-class order is stable but intra-class order is noise). In that degenerate tail the
**full-rebuild baseline itself destabilizes** (B swings 5596%, its evals/query drop to 721 — a
fresh Vamana build needs distance spread that collapsed geometry denies), so the trajectory-wide
summary shows reuse (92.1%) numerically *above* rebuild (87.8%). **That is a benchmark-degeneracy
artifact (ADR-200's t=0.25 reuse-beats-rebuild dip, amplified), not a genuine "reuse > rebuild"
claim** — recall@10 is not a meaningful target once the metric collapses. The *operational*
conclusion is unaffected: reuse + periodic is never worse than rebuild here. Reporting the artifact
rather than the flattering headline is the point.
## Next steps
1. Wire `on_metric_update` / `RecallTrigger` into the actual `ruvector-gnn` embedding-flush path
(the policies are validated via the harness; the live serving hook is the remaining glue).
2. ~~Smarter rebuild trigger — sampled-recall probe vs fixed periodic~~ **DONE (addendum: WIN).**
3. ~~Confirm the holding ceiling under a second learned objective (node-classification)~~ **DONE
(addendum: CONFIRMED, ceiling 54% ≥ link-pred 40%; surfaced a class-collapse degeneracy caveat).**
4. Incremental-rebuild baseline for a fair cost comparison (ADR-200 #3 still open).
5. **(New, from the degeneracy finding)** recall@10 is ill-posed under extreme class collapse — a
collapse-aware quality metric (or capped-churn operating regime) for self-learning indices whose
objective tightens clusters over time.
## Alternatives considered
- **Rebuild on every metric update** (`AlwaysRebuild`) — the incumbent; the cost this removes
(kept as baseline B). Highest recall, full cost every step.
- **Never rebuild** (`ReweightOnly` alone) — rejected as the *default*: transfers in-regime but
decays past ~40% churn on real drift. Retained as a policy for low-drift / cost-critical
deployments, with the ceiling documented.
- **CCH customization** (ADR-198 via ADR-196) — rejected earlier (ADR-199: contraction blows up
on embedding graphs). Fixed-topology ANN reuse is the surviving vehicle.

View file

@ -0,0 +1,51 @@
# BET 1 generality check — is the 40% holding ceiling objective-dependent?
**Status:** Pre-registered (frozen before the run) · **Date:** 2026-06-04 ·
**Research line:** SepRAG (ruvnet/RuVector issue #534) · **Tests an ADR-202 caveat** ·
**Self-contained:** `ruvector-diskann` + `ruvector-gnn` · **Outcome:** ADR-202 addendum.
> ADR-202 established its 40% top-10 churn holding ceiling on **one** learned objective
> (contrastive link-prediction). Its named caveat: "the holding ceiling is objective-dependent."
> This check tests that directly with a *different* objective — **node classification** (real
> ogbn-arxiv 40-class subject labels, cross-entropy on a linear head, embeddings as the
> trainable params). CE-toward-class-separability reorganizes the embedding geometry differently
> from citation-neighbour contrastive learning, so it is a genuine second objective, not a
> reparametrization.
## Thesis (one claim, one number)
> The ADR-202 holding ceiling (reuse within 2% recall@10 of full rebuild) is a property of
> **reuse-under-drift**, not of the link-prediction objective: under a node-classification
> trajectory of comparable churn, reuse holds to a **≥ 30% churn ceiling** and `Periodic{k}`
> recovers the high-churn tail.
## Method
Identical harness, contenders, and 2% gate as ADR-202 (`diskann_real_trajectory.rs`, selected via
an `objective=nodeclass` arg) — **only the trajectory objective changes**. n=20k; recall@10; 200
queries; production Vamana R=32/L=64/α=1.2. Embeddings on the unit sphere (L2 ranking ≡ the metric
the GNN shapes). Precondition (teeth): churn ≥ 15% and the stale control degrades materially —
else VOID.
## Pre-registered outcome criteria (frozen)
- **CONFIRM (generality):** reuse holding ceiling **≥ 30% churn** (within ~10 pts of the 40%
link-prediction ceiling) **and** `Periodic{k}` recovers the tail within ADR-202's bar (within
1% of full rebuild at ≤ 50% cost). → ADR-202's objective-dependence caveat is **resolved**; the
result generalizes across two learned objectives.
- **CAVEAT (objective-dependent — the honest negative):** holding ceiling **< 20% churn**, or
reuse behaves materially differently (e.g. does not decay, or decays from step 1). → the ceiling
is objective-specific; reported as a sharpened caveat on ADR-202, not a silent omission.
- **Reported regardless:** the node-class holding ceiling vs the link-prediction 40%, and the
per-step recall/churn curves.
A CAVEAT outcome is acceptable and reportable (the prove-not-hype stance): it would mean "reuse
transfers for citation-structure drift but the safe-reuse window depends on what the GNN learns."
> **OUTCOME: CONFIRM (with a degeneracy caveat)** (2026-06-04) — see
> [ADR-202 addendum](../../adr/ADR-202-reuse-under-drift-real-gnn-trajectory.md#addendum-2026-06-04-objective-dependence--generality-confirmed-with-a-degeneracy-caveat).
> Node-class holding ceiling = **54% churn** (≥ 30%, *above* link-prediction's 40%) → generality
> confirmed across two objectives. Surfaced a real finding: past ~60% churn node-classification
> collapses embeddings into ~40 class blobs where recall@10 is ill-posed and the *rebuild baseline
> itself* destabilizes — so the trajectory-wide "reuse > rebuild" is a degeneracy artifact, not a
> claim. Reported as such, not as a flattering headline.

View file

@ -0,0 +1,88 @@
# BET 1 follow-up — Sampled-recall rebuild trigger vs fixed periodic-K
**Status:** Pre-registered (gate frozen before any contender run) · **Date:** 2026-06-04 ·
**Research line:** SepRAG (ruvnet/RuVector issue #534) · **Extends:** ADR-202 (BET 1
productionized WIN), ADR-200 next-step #2 · **Self-contained:** `ruvector-diskann` +
`ruvector-gnn` only · **Outcome:** ADR-202 addendum (WIN *or* KILL).
> Pre-registration, committed before the harness runs. A loss is acceptable and reportable
> (ADR-200's own Frobenius trigger lost — that is the precedent). Editing the gate after seeing
> results voids the bet. Plumbing (`DriftingIndex::force_rebuild` + harness) may precede freeze;
> the contender run may not.
> **OUTCOME: WIN** (2026-06-04) — see [ADR-202 addendum](../../adr/ADR-202-reuse-under-drift-real-gnn-trajectory.md#addendum-2026-06-04-sampled-recall-trigger--win).
> On bursty drift (n=20k, 89% end churn), `Recall{floor=0.95}` = 97.2% recall @ 7 rebuilds beat
> `Periodic{k=2}` (96.8% @ 12) on both axes and the best `Frobenius` (97.3% @ 9) on rebuilds;
> probe cost (~1s) was <2% of the ~73s rebuild time saved. Productionized as
> `ruvector_diskann::reuse::RecallTrigger`. **Note:** the first run was VOID (plain-SGD trajectory
> drifted 0%); switched the generator to Adam and enforced the ≥15% churn precondition — the
> WIN/KILL gate itself was unchanged.
## Prove-not-hype protocol (all five)
1. One claim, one number. 2. Beat the strongest in-repo incumbent (here: `Periodic{k}`, the
ADR-202 winner) tuned. 3. Public data + ground truth (ogbn-arxiv). 4. Pre-register WIN + KILL.
5. Adversarial check (here: the **probe-cost honesty trap** — the trigger's own measurement cost
is counted, so it can't win by ignoring it).
## Thesis (one claim, one number)
> Under **variable-rate** drift, a sampled-recall-triggered rebuild matches `Periodic{k}`'s
> recall floor (within 1%) at **≥ 25% fewer rebuilds**, with the probe's own distance-eval cost
> counted — and uses fewer rebuilds at matched recall than the **Frobenius-norm monitor** ADR-200
> found wanting.
## Why variable-rate drift is the honest stage (central insight)
`Periodic{k}` is near-optimal under **steady** drift (ADR-202). A trigger can only earn its keep
when drift is **bursty**: calm stretches where a fixed cadence over-rebuilds, bursts where it
under-rebuilds. The trajectory therefore alternates high-lr bursts (3 epochs, lr 0.03) and
low-lr calm (5 epochs, lr 0.002) on the same arxiv contrastive objective. If the trigger cannot
beat periodic *there*, it cannot beat it anywhere — clean KILL.
**Mechanism (falsifiable):** Frobenius measures *how much the metric moved*; recall measures
*whether the move broke navigability*. ADR-202 showed those decouple (40% churn cost ~0 recall),
so a recall probe should track the thing we care about and the norm monitor should not.
## Contenders
| Trigger | Role |
|---|---|
| `Recall{floor}` (sweep {0.97, 0.95, 0.93}) | **the bet** — rebuild when a probe-set recall estimate drops below `floor` |
| `Periodic{k}` (sweep {2, 3, 4, 6}) | incumbent (ADR-202 winner) |
| `Frobenius{τ}` (sweep {0.15, 0.25, 0.40}) | the monitor ADR-200 found wanting — must be beaten |
| `Always` (k=1) | cost ceiling reference |
Index built once on `E₀` (`ReweightOnly` so `on_metric_update` never auto-rebuilds);
`force_rebuild` driven by each trigger. Production Vamana R=32/L=64/α=1.2; recall@10; 200 scored
queries; **30 disjoint probe queries** (no leakage into the scored set). n=10k (ADR-202 already
established scale-robustness; this bet isolates *cadence*, where rebuild count is the signal).
## Pre-registered gate
- **Honest comparison = the (rebuilds, recall) Pareto frontier**, not a cherry-picked single
config. For each `Recall{floor}`, find the cheapest `Periodic{k}` matching its recall (within
0.5%); the trigger wins that cell iff it used **≥ 25% fewer rebuilds**.
- **Probe-cost honesty trap (counted):** the recall probe costs `probe_size × n` distance-evals
per step. Reported in the trigger's ledger; a rebuild-count win whose probe cost exceeds the
saved rebuild cost is **not** a WIN.
- **WIN:** some `Recall{floor}` is within 1% recall of the best `Periodic{k}` at ≥ 25% fewer
rebuilds, net cost (rebuilds + probes) below that periodic, **and** strictly fewer rebuilds
than the best `Frobenius{τ}` at matched recall.
- **KILL (reportable, like ADR-200's Frobenius result):** no `Recall{floor}` cell beats periodic
by ≥ 25% fewer rebuilds at matched recall, **or** the probe cost eats the savings, **or** it
merely ties Frobenius. Then ADR-200's "periodic-K is the recommended knob" stands, reinforced.
## Where it lives
- Primitive: `DriftingIndex::force_rebuild(vectors)` (shipped in `ruvector-diskann::reuse`, the
clean mechanism an external trigger drives). The `Recall` trigger stays in the harness until it
earns productionization — `RebuildPolicy` keeps only self-contained policies for now.
- Harness: `crates/ruvector-gnn/examples/triggered_rebuild.rs`.
- Same branch / PR #537; outcome as an ADR-202 addendum.
## Out of scope
- Steady-drift regime (periodic already owns it — ADR-202).
- Productionizing the trigger as a `RebuildPolicy` variant (only if it WINS).
- Larger n (scale is ADR-202's domain; this is the cadence question).

View file

@ -0,0 +1,163 @@
# BET 1 productionize — Fixed-topology reuse + periodic rebuild on a REAL learned-GNN trajectory
**Status:** Pre-registered (gate frozen before any contender run) · **Date:** 2026-06-04 ·
**Research line:** SepRAG (ruvnet/RuVector issue #534) · **Self-contained:** depends only on
crates already on `main` (`ruvector-diskann`, `ruvector-gnn`) — **independent of PR #535
(`ruvector-seprag`).** ·
**Builds on (by reference):** ADR-200 (BET 1 WIN under *synthetic* drift), ADR-199 (CCH
NO-GO → why fixed-topology, not separators) ·
**Outcome ADR:** ADR-202 (written from the result — WIN *or* NO-GO).
> This document is the **pre-registration**, committed before the validation harness runs on a
> real trajectory. A loss is an acceptable, reportable outcome (cf. ADR-199). Editing the gate
> after seeing results voids the bet. Plumbing (M0M1) may be built before freeze; contender
> runs (M3+) may not.
> **OUTCOME: WIN** (2026-06-04) — see [ADR-202](../../adr/ADR-202-reuse-under-drift-real-gnn-trajectory.md).
> Reuse holds within 2% recall@10 of full rebuild up to a **40% churn ceiling** (identical at
> n=20k and n=50k, ≥ ADR-200's synthetic ~36%); `Periodic{k:4}` recovers the high-churn tail to
> within 0.01% at 2024% of rebuild cost. The "early-trajectory" WIN clause was operationalized
> post-hoc as the *holding ceiling* (max contiguous churn where reuse stays within 2%) — the
> regime-resolved statistic this gate named, not the trajectory-wide mean.
## Prove-not-hype protocol (mandatory — all five)
1. **One claim, one number.** 2. **Beat the strongest in-repo incumbent, tuned** (here the
incumbent *is* the production remedy: full `VamanaGraph` rebuild on the shipping index).
3. **Public data + ground truth** (ogbn-arxiv, in hand). 4. **Pre-register WIN *and* KILL.**
5. **Adversarial check** (here: the *minimum-drift precondition* — the test must not pass
vacuously on a trajectory that barely moves).
## What this bet proves that ADR-200 did not
ADR-200 established the WIN under *synthetic* drift (`v_t = A(t)·v_0`: diagonal, rotational,
non-linear tanh, compounding random-walk) on the production `ruvector-diskann` Vamana. Its
explicitly-named open frontier (next-step #4): **a real learned-GNN metric trajectory.** This
bet closes exactly that gap and wires the validated policy into the production loop behind a
flag.
**The metric here is L2 over node embeddings** (`ruvector_diskann::distance::l2_squared`). The
GNN re-estimates embeddings over training, so the metric trajectory *is* the embedding
trajectory `E₀ → E₁ → … → E_T`. The reuse hook is native: `VamanaGraph` stores only topology
(`neighbors` + `medoid`); `greedy_search(vectors, query, beam)` (`graph.rs:208`) takes vectors
externally — so "adapt to drift" = build on `E₀`, search with `E_t`, **zero rebuild**.
## Thesis (one claim, one number)
> On a **real learned-GNN embedding trajectory** on ogbn-arxiv, **`ReweightOnly`** (fixed `E₀`
> topology, distances recomputed under `E_t`) holds **recall@10 within 2%** of **`AlwaysRebuild`**
> (full `VamanaGraph` rebuild every step), and where it decays under accumulated drift,
> **`Periodic{k}`** recovers to **within 1%** of `AlwaysRebuild` at **≤ 50% of its cumulative
> rebuild cost**.
Primary metric = **recall@10** vs brute-force ground truth recomputed under `E_t` (as ADR-200).
Secondary, reported as honesty guards: **cumulative rebuild cost (s)** and **per-query
distance-evals** (a recall win that costs more per query is not a clean win).
## Why this scope is the honest one (central insight)
The risk **inverts** relative to a contender benchmark. There the danger is the benchmark being
too easy on the contender; here the danger is the **test being too easy on reuse** — if the
real GNN embeddings drift only slightly, `ReweightOnly` passes *vacuously* and proves nothing.
So the gate carries a **minimum-drift precondition** and a **stale control**, the mirror of
ADR-200's stale-index control ("the C control degrades up to 29 points, proving the graph
matters").
**A second honesty point:** `GraphMAE::train_step` (`graphmae.rs:405`) takes `&self` and only
returns a loss — it has **no backprop and never updates weights**, so it cannot produce drift.
The trajectory is therefore assembled from the repo's *real* learnable primitives
(`Optimizer::step`, `info_nce_loss`, SGD on node embeddings), not from GraphMAE, and not from a
synthetic transform. This is stated up front so the trajectory's provenance is auditable.
## Data & trajectory (real, public — ogbn-arxiv)
n ≈ 169,343 nodes, 128-d features, ~1.17M citation edges (`target/m1-data/arxiv/raw/`:
`node-feat.csv.gz`, `edge.csv`, `node-label.csv.gz`, `node_year.csv.gz` — all in hand).
Validation runs at a tractable slice (n ∈ {20k, 50k}; full-n is a stretch goal).
**Trajectory generation (contrastive link-prediction — chosen path):** node embeddings are the
trainable parameters, initialised from the raw 128-d features (`E₀`). Each epoch optimises
**InfoNCE** (`ruvector_gnn::training::info_nce_loss`) over the citation graph — positives =
sampled edges, negatives = sampled non-edges — with the existing `Optimizer` (Adam/SGD, the
harness computes the InfoNCE gradient w.r.t. embeddings). Embeddings are snapshotted each epoch
to form `E₀ … E_T`. This is a *genuinely learned* trajectory driven by real arxiv structure —
not a parametric `A(t)`.
## Contenders (all scored vs brute-force truth recomputed under `E_t`)
| ID | Strategy | Role |
|---|---|---|
| **A** | `ReweightOnly` — graph built once on `E₀`, searched under `E_t` | **the bet**; rebuild cost 0 |
| **B** | `AlwaysRebuild``VamanaGraph` rebuilt under `E_t` every step | incumbent / production remedy |
| **P** | `Periodic{k}` — reuse every step, full rebuild every `k` steps | the shippable hybrid (ADR-200's recommended knob) |
| **C** | `Stale` — built on `E₀`, searched on `E₀`, graded vs `E_t` truth (ignores drift) | floor / teeth control |
`k` sweep: {2, 4, 8}. Build params: production Vamana R=32, L=64, α=1.2 (as `diskann_drift.rs`).
## Pre-registered gate
- **Minimum-drift precondition (teeth — adversarial check):** the trajectory must induce
**≥ 15% top-10 relevant-set churn** from `E₀` to `E_T` (else the trajectory is too gentle →
escalate the objective: more epochs / higher LR; a pass on a near-static trajectory is
**void**). Independently, the **`Stale` control (C)** must degrade **materially** below
`AlwaysRebuild` (proving the benchmark is drift-sensitive, not insensitive).
- **WIN**`ReweightOnly (A)` within **2% recall@10** of `AlwaysRebuild (B)` over the early
trajectory **and**, where A decays under accumulated drift, **some `Periodic{k} (P)`**
recovers to **within 1%** of B at **≤ 50% of B's cumulative rebuild cost**.
- **Per-query-cost honesty guard** — A's mean distance-evals/query must stay **within ~5%** of
B's (reuse must not buy build savings with slower queries; ADR-200 found parity within ~1%).
- **Wall-clock honesty guard** — rebuild cost reported in wall-clock seconds; the cost win is
the *cumulative rebuild* asymmetry (B rebuilds T times, A zero, P `T/k` times).
- **KILL (reportable NO-GO, written like ADR-199)**`ReweightOnly` **collapses** (>2% below
B) **early** in the trajectory **and no** `Periodic{k}` recovers within the 1%/≤50%-cost bar:
i.e. **BET 1 does not transfer from synthetic to real GNN drift.** A clean, publishable
negative result.
- **Reported regardless:** the recall-vs-step curves for A/B/P/C, the churn-vs-step curve, and
the cost/recall Pareto point of the best `Periodic{k}`.
**Named live risk (not a formality):** a real link-prediction trajectory may drift the
embeddings *non-uniformly* (some clusters re-learn hard, others barely) — closer to ADR-200's
region-local case than its global case. If `ReweightOnly` holds globally but a re-learned
cluster's in-region recall collapses, that is a **partial result** (report in/out-region
separately, as `region_drift.rs` did), not a silent global-average pass.
## Where it lives (self-contained off `main`)
- **Production wiring — `crates/ruvector-diskann/src/reuse.rs`**, behind cargo feature
**`reuse-under-drift`** (`default = []`, so the shipping build is byte-identical):
`RebuildPolicy { AlwaysRebuild, ReweightOnly, Periodic { k } }` + `DriftingIndex` that owns a
`VamanaGraph` + build params, with `on_metric_update(&mut self, vectors: &FlatVectors)` (bumps
a step counter; rebuilds iff `Periodic && step % k == 0`) and `search(vectors, q, k)`. The GNN
side is a pure *consumer* — it writes a new snapshot, then calls `on_metric_update`. Clean
dependency direction: diskann knows nothing about the GNN.
- **Validation harness — `crates/ruvector-gnn/examples/diskann_real_trajectory.rs`** (dev-deps
on `ruvector-diskann`): generates the contrastive trajectory, drives all four contenders,
emits the WIN/KILL table.
No dependency on `ruvector-seprag` (PR #535) — this PR stands alone.
## Milestones
- **M0 — substrate + flag.** Add `reuse-under-drift` feature; scaffold `reuse.rs`
(`RebuildPolicy`, `DriftingIndex`) + unit tests (policy step-counting, rebuild cadence).
*Gate: `cargo test -p ruvector-diskann --features reuse-under-drift` green; default build
unchanged.*
- **M1 — trajectory generator.** arxiv loader (feat + edges); InfoNCE link-prediction loop
(embeddings as params, `Optimizer::step`, snapshots). *Gate: loss decreases monotonically;
trajectory induces ≥ 15% top-10 churn (the precondition) — else escalate before freeze.*
- **M2 — contender plumbing.** `AlwaysRebuild` / `ReweightOnly` / `Periodic{k}` / `Stale` over
the trajectory; recall@10, distance-eval, and rebuild-cost counters; in/out-region split.
*Gate: `Stale` control degrades materially (teeth).*
- **M3 — full run + gate eval. [FROZEN — post-registration]** Sweep `k ∈ {2,4,8}` over the
trajectory at n ∈ {20k, 50k}; emit WIN/KILL table; apply both honesty guards.
- **M4 — ADR-202.** Write the outcome (WIN or NO-GO) with ADR-199/200 honesty; update issue
#534 and `FUTURE-DIRECTIONS.md` (close open item #2).
## Out of scope (named, not silently assumed)
- The smarter sampled-recall rebuild trigger (ADR-200 next-step #2) — `Periodic{k}` is the knob
under test; the trigger remains future work.
- Incremental-rebuild baseline (vs *full* rebuild) — ADR-200 open item, not this bet.
- Disk-resident / billion-scale; the live multi-tenant serving path. In-memory arxiv at
n ≤ 50k is the stage.
- Filtered / multi-predicate retrieval (that is BET 2 / ADR-201).