tidaldb/tidal-server/tests/vector_search.rs
jordan 8aa1fbb414 vector search: normalize the query, instrument the blob path, expose per-group vector counts
Three real defects, plus a retracted fourth that was a probe artifact.

P3 (fixed) - query/stored normalization asymmetry. The write path L2-normalized
every stored vector; the read path passed the caller's raw query straight to the
index, so the two sides lived in different spaces. With unit v,
d = |q|^2 - 2q.v + 1, so a non-unit query shifted and scaled every distance by
|q|^2. Measured live: 591-1174 against a documented [0,4], and an exact match
scoring |q|^2 - 1 instead of ~0. vector_search_items now normalizes with the
canonical l2_normalize; a zero-norm query (no direction, so nearest-by-cosine is
undefined) is rejected with 400. Ranking is unchanged - |q|^2 and 1 are constant
across candidates - which is why it went unnoticed; what broke was every absolute
use of the number. WIRE-VISIBLE, recorded in CHANGELOG.

P2 (fixed) - the blob path had zero instrumentation. Added per-kind
tidaldb_cluster_blobs_originated/applied/apply_failed totals. Label cardinality
is fixed at 4 by construction via a new BlobKind enum, and BlobRecord::blob_kind
is now the ONE exhaustive match over the variants (kind() derives from it), so a
new variant is a compile error in one place instead of a silent zero in three.
Only the live apply path is counted - boot replay would inflate applied past
originated on every restart.

Coverage gap (fixed) - tidaldb_usearch_vector_count rendered only the metrics
owner's shard group, so on a 3-group node two thirds of the corpus had no
vector-count series at all. Co-located groups now render shard="N"; the owner
stays unlabeled for wire compatibility, so an alert grouped by (shard) buckets
each replica set separately without double-counting.

P1 (RETRACTED) - the "replica-divergent vector index" does not exist. Every probe
wrote through the /sharded/ surface, which hash-partitions and applies to the
owning region's local store with no WAL append, and therefore does not replicate
BY DESIGN (cluster/node.rs:8828-8829). A controlled A/B settled it: on /items plus
/embeddings all 6 entities reach all 3 replicas; on the sharded surface four of six
reach exactly one node. Both are now pinned by tests. See
tmp/vector-search-correctness/diagnosis.md and the k3s-fleet cluster-state.yaml
entry RETRACTED_blob_replication_rf1_2026_08_30.

Pre-work: usearch_index.rs 872 to 503 lines by extracting its tests to a sibling
(the project's existing path-attribute convention), and the three hand-rolled
l2_normalize copies collapsed to one. The two entity copies used a zero threshold
about 2900x looser than the canonical one; normalize_centroid now names the
centroid zero-tolerance policy once, and a test pins the tightened behavior.

Tests: 2107 lib (+5), 8 vector_search e2e (+4, three of which fail without the
P3 fix), 4 cluster_sharding e2e (+2). The heavy multiproc tests in
cluster_sharding are now serialized - four concurrent 3-node clusters made the
pre-existing failover test miss its 10s budget.
2026-08-30 13:57:36 -06:00

329 lines
11 KiB
Rust
Raw 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.

// Integration-test exemption (same posture as the other tidal-server tests).
#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
//! End-to-end coverage for the m12p1 `POST /vector_search` recall probe.
//!
//! Drives the real standalone handler in-process via `tower::ServiceExt::oneshot`
//! (no TCP bind): seed items + embeddings, then POST a query vector and assert
//! the raw k-NN result — closest-first ordering, `k` honored — plus the boundary
//! 400s (empty vector, dimension mismatch). This pins the surface the
//! `tidal-stress --verify-recall` harness measures against.
use std::sync::Arc;
use axum::{
body::Body,
http::{Method, Request, StatusCode},
};
use tidal_server::{router::build_router, state::ServerState};
use tidaldb::TidalDb;
use tower::ServiceExt;
/// Dimensionality of the default schema's `content_vector` slot.
const DIM: usize = 128;
fn make_app() -> axum::Router {
let (schema, profiles) = tidal_server::config::load_schema(None).unwrap();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.with_profiles(profiles)
.open()
.unwrap();
let state = Arc::new(ServerState::new(db));
build_router(
state,
Arc::new(tidal_server::cluster::security::ClusterCreds::unauthenticated()),
)
}
/// A 128-dim one-hot-ish vector: component `axis` set to `mag`, rest 0 — except
/// we nudge a second axis a hair so no vector is exactly zero-norm.
fn axis_vector(axis: usize, mag: f32) -> Vec<f32> {
let mut v = vec![0.0_f32; DIM];
v[axis] = mag;
v[(axis + 1) % DIM] = 0.01;
v
}
/// `v` scaled by `s` — a TRUE scalar multiple, i.e. the same direction at a
/// different magnitude.
///
/// Necessary because `axis_vector(axis, mag)` pins a fixed `0.01` on the second
/// axis regardless of `mag`, so varying `mag` there changes the DIRECTION once the
/// vector is normalized, not just its length. A magnitude-independence test built
/// on `axis_vector` would be testing the wrong thing (and would fail for a correct
/// implementation).
fn scaled(v: &[f32], s: f32) -> Vec<f32> {
v.iter().map(|x| x * s).collect()
}
async fn post_json(app: &axum::Router, uri: &str, body: serde_json::Value) -> StatusCode {
app.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap()
.status()
}
async fn post_json_full(
app: &axum::Router,
uri: &str,
body: serde_json::Value,
) -> (StatusCode, serde_json::Value) {
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, json)
}
async fn seed(app: &axum::Router) {
// Items on distinct axes: item 1 ~ axis 0, item 3 ~ axis 0 (close to item 1),
// item 2 ~ axis 64 (far). A query on axis 0 must rank 1 and 3 above 2.
for (id, v) in [
(1u64, axis_vector(0, 1.0)),
(2u64, axis_vector(64, 1.0)),
(3u64, axis_vector(0, 0.8)),
] {
let s = post_json(
app,
"/items",
serde_json::json!({ "entity_id": id, "metadata": {} }),
)
.await;
assert_eq!(s, StatusCode::CREATED, "item {id}");
let s = post_json(
app,
"/embeddings",
serde_json::json!({ "entity_id": id, "values": v }),
)
.await;
assert_eq!(s, StatusCode::NO_CONTENT, "embedding {id}");
}
}
#[tokio::test]
async fn vector_search_returns_nearest_closest_first() {
let app = make_app();
seed(&app).await;
let (status, body) = post_json_full(
&app,
"/vector_search",
serde_json::json!({ "vector": axis_vector(0, 1.0), "k": 3 }),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let items = body["items"].as_array().expect("items array");
assert_eq!(items.len(), 3, "k=3 nearest");
// Closest-first: an axis-0 query ranks the two axis-0 items (1, 3) above the
// far axis-64 item (2).
let ids: Vec<u64> = items
.iter()
.map(|it| it["entity_id"].as_u64().unwrap())
.collect();
assert_eq!(ids[0], 1, "the exact-axis item is nearest");
assert!(
ids[..2].contains(&3),
"the near-axis item ranks above the far one; got {ids:?}"
);
assert_eq!(ids[2], 2, "the far axis-64 item is last");
// Distances are present and ascending.
let dists: Vec<f64> = items
.iter()
.map(|it| it["distance"].as_f64().unwrap())
.collect();
for w in dists.windows(2) {
assert!(w[0] <= w[1], "distances must ascend: {dists:?}");
}
}
#[tokio::test]
async fn vector_search_k_defaults_to_ten_and_clamps_to_corpus() {
let app = make_app();
seed(&app).await;
// k omitted → defaults to 10, but only 3 items exist, so 3 come back.
let (status, body) = post_json_full(
&app,
"/vector_search",
serde_json::json!({ "vector": axis_vector(0, 1.0) }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["items"].as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn vector_search_empty_vector_is_400() {
let app = make_app();
seed(&app).await;
let v: Vec<f32> = vec![];
let status = post_json(&app, "/vector_search", serde_json::json!({ "vector": v })).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn vector_search_dimension_mismatch_is_400() {
let app = make_app();
seed(&app).await;
// 4-dim query against a 128-dim slot → a client error, not a 500.
let status = post_json(
&app,
"/vector_search",
serde_json::json!({ "vector": vec![0.1f32, 0.2, 0.3, 0.4] }),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
/// An exact-match query must score ~0, not `|q|² 1`.
///
/// The write path L2-normalizes every stored vector; until 2026-08-30 the read path
/// passed the caller's raw query straight to the index, so the two sides lived in
/// different spaces. With unit `v`, `d = |q|² 2q·v + 1`. Measured live, a non-unit
/// query returned 5911174 against a documented `[0.0, 4.0]`.
#[tokio::test]
async fn vector_search_exact_match_scores_zero() {
let app = make_app();
seed(&app).await;
// Item 1's exact direction, scaled x3 so the query is NOT unit-length: the
// point is that the server owns the normalization. A unit query would pass
// even against the bug. Must be a true scalar multiple (see `scaled`), or it
// is a different direction and not an exact match at all.
let query = scaled(&axis_vector(0, 1.0), 3.0);
let (status, body) = post_json_full(
&app,
"/vector_search",
serde_json::json!({ "vector": query, "k": 1 }),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let d = body["items"][0]["distance"].as_f64().expect("distance");
// f16 quantization puts an exact match near 1e-3, not at 0 — assert a tolerance.
// Equality here would be a flaky test, not a stricter one.
assert!(
d < 1e-2,
"an exact-direction match must score ~0, got {d} (pre-fix this was |q|^2 - 1 ~= 8)"
);
}
/// Every distance must fall inside the range documented at
/// `tidal/src/storage/vector/mod.rs:49`.
///
/// This is the assertion whose absence let 5911174 ship unnoticed: the existing
/// ordering test proves distances ASCEND, which stayed true the whole time.
#[tokio::test]
async fn vector_search_distances_within_documented_range() {
let app = make_app();
seed(&app).await;
// A range of query magnitudes, since the bug scaled distances by |q|^2 — one
// magnitude could coincidentally land in range.
for mag in [0.5_f32, 1.0, 3.0, 10.0] {
let (status, body) = post_json_full(
&app,
"/vector_search",
serde_json::json!({ "vector": scaled(&axis_vector(0, 1.0), mag), "k": 3 }),
)
.await;
assert_eq!(status, StatusCode::OK, "mag {mag}, body: {body}");
let dists: Vec<f64> = body["items"]
.as_array()
.expect("items")
.iter()
.map(|it| it["distance"].as_f64().expect("distance"))
.collect();
assert!(
dists.iter().all(|d| (0.0..=4.0).contains(d)),
"mag {mag}: outside documented [0.0, 4.0] (storage/vector/mod.rs:49): {dists:?}"
);
}
}
/// A zero-norm query has no direction, so "nearest by cosine" is undefined for it.
/// Reject it as caller error rather than returning an arbitrary ranking.
#[tokio::test]
async fn vector_search_rejects_zero_query() {
let app = make_app();
seed(&app).await;
let (status, body) = post_json_full(
&app,
"/vector_search",
serde_json::json!({ "vector": vec![0.0_f32; DIM], "k": 3 }),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"a zero query must be rejected, not answered with an arbitrary ranking; body: {body}"
);
}
/// Normalizing the query must NOT change ranking — it is a contract fix, not a
/// behavioral one.
///
/// `|q|²` and `1` are constant across candidates, so ordering was already correct
/// cosine order. This is the regression guard: if a future change to the read path
/// alters ranking, this fails even though the range assertions still pass.
#[tokio::test]
async fn vector_search_ordering_unchanged_by_normalization() {
let app = make_app();
seed(&app).await;
// The same direction at four magnitudes must produce the IDENTICAL id order.
// Pre-fix this held too (the bug was scale-only), so this test passes before
// AND after — which is exactly what makes it a guard rather than a symptom test.
let mut orders = Vec::new();
for mag in [0.5_f32, 1.0, 3.0, 10.0] {
let (status, body) = post_json_full(
&app,
"/vector_search",
serde_json::json!({ "vector": scaled(&axis_vector(0, 1.0), mag), "k": 3 }),
)
.await;
assert_eq!(status, StatusCode::OK, "mag {mag}");
orders.push(
body["items"]
.as_array()
.expect("items")
.iter()
.map(|it| it["entity_id"].as_u64().expect("entity_id"))
.collect::<Vec<u64>>(),
);
}
assert_eq!(orders[0], vec![1, 3, 2], "golden order for an axis-0 query");
for (i, o) in orders.iter().enumerate() {
assert_eq!(
o, &orders[0],
"query magnitude must not affect ranking (index {i}): {orders:?}"
);
}
}