tidaldb/tidal/tests/m12p1_vector_recall.rs
jx12n bb21e69ae6 feat(m12): vector retrieval G1/G2 — recall harness, ANN in RETRIEVE, index tuning
m12p1 (measurement truth): TidalDb::vector_search_items pure k-NN probe +
POST /vector_search (standalone + region node, merge-by-distance) +
tidal-stress --verify-recall (deterministic id-keyed corpus, in-RAM brute-force
cosine oracle, open-loop ramp → recall@k + true p99 + read-knee + JSON/gate exit).
Repaired fabricated p99 columns (mean-as-p99) in social-scale.md / scale.rs.
Verified real: recall@10=0.9997 at 20k/1536-D vs brute-force.

m12p2 (G1 unblock): ANN candidate-gen wired into RETRIEVE — for_you=preference
vector, related=seed embedding (similar_to), graceful scan-fallback. Cached
per-signal-type top-K (signals/ledger/hot_top_k.rs, decay-order-invariant) so
trending serves O(K). related over HTTP (FeedQuery.similar_to). Harness gains
--feed-profile / --seed-preferences. Verified: trending retrieve p99 3.5-7.7ms.

m12p3 (G2): per-query ef_search now honored (RwLock epoch-guard with_expansion,
shared guard for same-ef concurrency) + dimension-aware brute→HNSW crossover
usearch_min_vectors(dim) + memory_usage() + examples/ann_grid_search.rs.
Measured 1536-D/100k clustered: default M=16/ef_c=400/F16/ef_s=200 clears
G1+G2 (recall 0.997, p99 1.4ms); F16 -0.25% vs F32; Int8 rejected (-28%).
Recall corpus is now clustered (Gaussian mixture) in grid + harness.
2026-06-14 11:07:09 -06:00

175 lines
6.5 KiB
Rust

#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]
//! m12p1 — pure k-NN recall probe (`TidalDb::vector_search_items`).
//!
//! The recall harness (`tidal-stress --verify-recall`) measures ANN recall@k by
//! comparing the engine's nearest-neighbor result against a brute-force cosine
//! ground truth. This test exercises the engine-side surface that harness hits
//! — the raw k-NN probe — and proves it returns the true nearest neighbors
//! (recall == 1.0) at a corpus size where the slot index is exact brute-force,
//! so any later HNSW recall shortfall is attributable to the index, not the
//! plumbing.
//!
//! # UAT Scenario
//!
//! ```
//! Given: A db with an Item "content" embedding slot and N indexed vectors
//! When: db.vector_search_items(q, 10, None)
//! Then: Returns the 10 items nearest q by cosine, closest-first
//! And: The set equals a brute-force cosine top-10 ground truth (recall 1.0)
//! ```
use std::{collections::HashMap, time::Duration};
use tidaldb::{
TidalDb,
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Window},
};
const DIM: usize = 16;
// Below `USEARCH_MIN_VECTORS` (10k) the slot is an exact BruteForceIndex, so the
// probe's recall against a brute-force ground truth must be exactly 1.0 — this
// isolates the plumbing from HNSW approximation, which the live harness measures.
const N: u64 = 500;
/// `SplitMix64` — a tiny deterministic generator so the corpus is reproducible
/// without a dev-dependency on a seeded RNG. Same scheme the live harness uses.
const fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A deterministic, non-zero vector keyed by entity id (each component in
/// `[-0.5, 0.5)`). The engine L2-normalizes on write, so cosine order over these
/// raw vectors matches the engine's L2-on-normalized order.
fn vector_for(id: u64) -> Vec<f32> {
let mut state = id.wrapping_mul(0x2545_F491_4F6C_DD1D).wrapping_add(1);
(0..DIM)
.map(|_| {
let bits = (splitmix64(&mut state) >> 40) as u32; // 24 random bits
(bits as f32 / f32::from(1u16 << 12) / 4096.0) - 0.5
})
.collect()
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na == 0.0 || nb == 0.0 {
0.0
} else {
dot / (na * nb)
}
}
/// Brute-force cosine top-`k` ground truth over the deterministic corpus.
fn ground_truth(query: &[f32], k: usize) -> Vec<u64> {
let mut scored: Vec<(u64, f32)> = (1..=N)
.map(|id| (id, cosine(query, &vector_for(id))))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
scored.into_iter().take(k).map(|(id, _)| id).collect()
}
fn build_db() -> TidalDb {
let mut builder = SchemaBuilder::new();
// A schema must declare at least one signal; the probe ignores signals
// entirely, but the engine requires one to open.
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::TwentyFourHours])
.velocity(false)
.add();
builder.embedding_slot("content", EntityKind::Item, DIM);
let schema = builder.build().unwrap();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
for id in 1..=N {
// An item must exist for its embedding; metadata is irrelevant to k-NN.
db.write_item_with_metadata(EntityId::new(id), &HashMap::new())
.unwrap();
db.write_item_embedding(EntityId::new(id), &vector_for(id))
.unwrap();
}
db
}
#[test]
fn vector_search_returns_exact_nearest_neighbors() {
let db = build_db();
// Query with item 42's own vector: it must come back first, ~zero distance.
let q = vector_for(42);
let results = db.vector_search_items(&q, 10, None).unwrap();
assert_eq!(results.len(), 10, "k=10 nearest requested");
assert_eq!(
results[0].id, 42,
"an item's own vector is its nearest neighbor"
);
// ~0 modulo F16 quantization (the slot's default), which perturbs a stored
// unit vector by ~1e-3 — far below any other item's distance.
assert!(
results[0].distance <= 0.01,
"self-distance must be ~0, got {}",
results[0].distance
);
// Results are ordered closest-first (ascending L2 distance).
for w in results.windows(2) {
assert!(
w[0].distance <= w[1].distance,
"results must be sorted by ascending distance"
);
}
// Recall@10 vs an INDEPENDENT brute-force cosine ground truth. The exhaustive
// index is exact, but it scores in F16 (and computes L2-on-normalized where the
// oracle computes cosine), so a single item at the k=10 boundary may swap —
// hence `>= 9`, not `== 10`. A real recall miss (HNSW approximation) shows up
// as a much larger shortfall, which the live harness measures at scale.
let truth: std::collections::HashSet<u64> = ground_truth(&q, 10).into_iter().collect();
let got: std::collections::HashSet<u64> = results.iter().map(|r| r.id).collect();
let hits = got.intersection(&truth).count();
assert!(
hits >= 9,
"exact index must achieve recall@10 ~ 1.0, got {hits}/10"
);
}
#[test]
fn vector_search_accepts_ef_search_override() {
let db = build_db();
let q = vector_for(7);
// Both the slot default (None) and an explicit override resolve and return
// the same exact nearest set on a brute-force slot (ef is ignored there).
let a = db.vector_search_items(&q, 5, None).unwrap();
let b = db.vector_search_items(&q, 5, Some(64)).unwrap();
let ids_a: Vec<u64> = a.iter().map(|r| r.id).collect();
let ids_b: Vec<u64> = b.iter().map(|r| r.id).collect();
assert_eq!(ids_a, ids_b);
assert_eq!(ids_a[0], 7);
}
#[test]
fn vector_search_rejects_dimension_mismatch() {
let db = build_db();
let wrong = vec![0.1_f32; DIM + 1];
let err = db.vector_search_items(&wrong, 10, None);
assert!(
err.is_err(),
"a query vector of the wrong dimension must error, not silently return"
);
}