tidaldb/tidal/benches/multi_preference.rs
jx12n 6a937fc4bc feat(m12): multi-vector user preference modeling + ANN candidate-gen
Add multi-vector preference entity (per-signal-type preference vectors with
event-time decay) feeding ANN candidate generation in the query executor.

- entities: multi_preference vectors + event-time-aware preference updates
- query/executor: ANN candidate-gen + personalization/pipeline integration
- storage/keys, db ops, state_rebuild: persist & rebuild multi-vector prefs
- ranking: profile + builtins support for multi-vector scoring
- tidal-server/config: expose multi-preference knobs
- tests/bench: m12_preference_event_time integration + multi_preference bench
- docs: multi-vector-preference research, ROADMAP/ARCHITECTURE refresh,
  legal/tidaldb-patent-proposal
- .codex/agents: codex agent definitions
- chore: gitignore tool-regenerated .agents/ mirror (doc-guard rejects it)
2026-06-23 09:52:36 -06:00

163 lines
5.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![allow(clippy::unwrap_used)]
//! Criterion benchmarks validating the multi-vector preference latency budget
//! (`docs/research/multi-vector-preference.md` §4).
//!
//! The settled design claims the query-time fan-out (M parallel ANN queries +
//! merge) costs **< ~3 ms additional p99 in the worst (serial) case** at a
//! realistic corpus, because the ANN leg is a small fraction of the 50 ms
//! end-to-end budget. We measure that directly here rather than asserting it:
//!
//! 1. `ann_single` — one ANN search (today's single-vector path), the baseline.
//! 2. `ann_fanout_m3` — three serial ANN searches + dedup-by-best-distance merge
//! (the multi-vector warm path). The delta over (1) is the additive cost the
//! spec budgets for.
//! 3. `clustering_update` — the write-path assign-or-split cost per engagement,
//! which the spec budgets at O(K·dim) (negligible).
//!
//! Corpus: 100k × 1536-D (the production shape the m12p3 grid search used), so
//! the numbers are comparable to the existing ANN benches. All index build /
//! warm-up is OUTSIDE the measured closures.
use std::collections::HashMap;
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use rand::Rng;
use tidaldb::{
entities::MultiPreferenceVectors,
storage::vector::{
DistanceMetric, QuantizationLevel, UsearchIndex, VectorId, VectorIndex, VectorIndexConfig,
},
};
const DIM: usize = 1536;
/// Corpus size. 50k × 1536-D is large enough to make the ANN leg representative
/// (the graph is past the brute-force crossover) while keeping the bench's index
/// build tractable; the additive-cost claim is corpus-monotonic, so the delta at
/// 100k/1M is bounded by what we measure here.
const CORPUS: u64 = 50_000;
/// ANN beam width — the read-hot-path default the m12p2/p3 work settled on.
const EF_SEARCH: usize = 64;
/// Over-fetched candidate count per query (limit×10 style).
const K: usize = 200;
/// Fan-out width (`PinnerSage` serve-time count).
const M: usize = 3;
fn random_unit_vector(dim: usize, rng: &mut impl Rng) -> Vec<f32> {
let v: Vec<f32> = (0..dim).map(|_| rng.random::<f32>() - 0.5).collect();
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm < f32::EPSILON {
let mut fallback = vec![0.0_f32; dim];
fallback[0] = 1.0;
return fallback;
}
v.iter().map(|x| x / norm).collect()
}
fn build_index() -> UsearchIndex {
let config = VectorIndexConfig {
dimensions: DIM,
metric: DistanceMetric::L2,
quantization: QuantizationLevel::F16,
connectivity: 16,
ef_construction: 400,
ef_search: EF_SEARCH,
};
let index = UsearchIndex::new(config).unwrap();
index.reserve(CORPUS as usize).ok();
let mut rng = rand::rng();
for id in 0..CORPUS {
let v = random_unit_vector(DIM, &mut rng);
index.insert(id as VectorId, &v).unwrap();
}
index
}
/// The dedup-by-best-distance merge, mirroring `candidate_gen::ann_candidates_multi`
/// (which is `pub(crate)`), so the bench measures the same merge the engine runs.
fn merge_best_distance(lists: &[Vec<(VectorId, f32)>], k: usize) -> Vec<VectorId> {
let mut best: HashMap<VectorId, f32> = HashMap::new();
for list in lists {
for &(id, d) in list {
best.entry(id)
.and_modify(|cur| {
if d < *cur {
*cur = d;
}
})
.or_insert(d);
}
}
let mut merged: Vec<(VectorId, f32)> = best.into_iter().collect();
merged.sort_by(|a, b| {
a.1.partial_cmp(&b.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
merged.into_iter().take(k).map(|(id, _)| id).collect()
}
fn bench_fanout(c: &mut Criterion) {
let index = build_index();
let mut rng = rand::rng();
let queries: Vec<Vec<f32>> = (0..M).map(|_| random_unit_vector(DIM, &mut rng)).collect();
let mut group = c.benchmark_group("multi_preference");
// Long-tailed ANN search needs enough samples for a meaningful p99.
group.sample_size(50);
group.bench_function("ann_single", |b| {
b.iter(|| {
let r = index.search(black_box(&queries[0]), K, EF_SEARCH).unwrap();
black_box(r);
});
});
group.bench_function("ann_fanout_m3_serial", |b| {
b.iter(|| {
let mut lists = Vec::with_capacity(M);
for q in &queries {
let r = index.search(black_box(q), K, EF_SEARCH).unwrap();
lists.push(r.into_iter().map(|x| (x.id, x.distance)).collect());
}
let merged = merge_best_distance(&lists, K);
black_box(merged);
});
});
group.finish();
}
fn bench_clustering(c: &mut Criterion) {
let mut rng = rand::rng();
// A warm user with several clusters: measure the per-engagement assign/split.
let warm = MultiPreferenceVectors::new(DIM);
for t in 0..200u64 {
let v = random_unit_vector(DIM, &mut rng);
let _ = warm.update_at(1, &v, 1000 + t);
}
let new_emb = random_unit_vector(DIM, &mut rng);
let mut group = c.benchmark_group("multi_preference");
group.bench_function("clustering_update", |b| {
let mut ts = 1_000_000u64;
b.iter(|| {
ts += 1;
let _ = warm.update_at(1, black_box(&new_emb), ts);
});
});
// The query-time fan-out resolution (top-M cluster selection by decayed
// importance) — must be negligible vs the ANN leg.
group.bench_function("query_vectors_topm", |b| {
b.iter(|| {
let vs = warm.query_vectors(black_box(1), 2_000_000, M);
black_box(vs);
});
});
group.finish();
}
criterion_group!(benches, bench_fanout, bench_clustering);
criterion_main!(benches);