tidaldb/tidal/src/entities/preference.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

879 lines
35 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.

//! Preference vector: per-user taste embedding with L2 normalization invariant.
//!
//! Tracks user taste by maintaining a preference vector that evolves with
//! interactions. The vector is L2-normalized on every update to ensure
//! consistent cosine similarity scoring during personalized ranking.
//!
//! # Durability
//!
//! Preference vectors are DERIVED state with no WAL backing (the signal record
//! carries no `for_user`), so they are persisted by [`PreferenceVectors::checkpoint`]
//! (called on the periodic checkpoint thread, on backup, and on shutdown) and
//! restored on open by [`PreferenceVectors::restore`]. This bounds the post-crash
//! loss window to the checkpoint interval rather than losing every update since
//! the last clean shutdown.
use dashmap::DashMap;
use crate::storage::vector::{VectorError, l2_normalize_in_place};
/// Per-user preference vector, L2-normalized.
///
/// The vector is updated via exponential moving average: each new interaction
/// embedding is blended with the current preference using a learning rate.
///
/// Thread-safe via `DashMap` -- concurrent updates to different users never
/// contend.
pub struct PreferenceVectors {
/// `user_id` -> normalized preference vector
inner: DashMap<u64, Vec<f32>>,
/// Dimensionality of the embedding space. All vectors must have this length.
dim: usize,
/// Base learning rate for exponential moving average updates.
/// Default: 0.1. The adaptive rate decays as `base / (1 + ln(count + 1))`.
learning_rate: f32,
/// Per-user update counts for adaptive learning rate computation.
///
/// # Known Limitation
///
/// `update_counts` is in-memory only and is NOT persisted to storage. On
/// process restart, all users start at count=0 and their adaptive learning
/// rate resets to `base_alpha`. This means the learning rate decays more
/// slowly across session boundaries than within a single session.
/// Persisting update counts requires a storage write on every preference
/// update, which introduces non-trivial overhead on the hot path; this
/// trade-off is deferred to M7 (Production Hardening).
update_counts: DashMap<u64, u64>,
}
impl PreferenceVectors {
/// Create a new preference vector store for the given embedding dimensionality.
#[must_use]
pub fn new(dim: usize) -> Self {
Self {
inner: DashMap::new(),
dim,
learning_rate: 0.1,
update_counts: DashMap::new(),
}
}
/// Create with a custom base learning rate.
#[must_use]
pub fn with_learning_rate(dim: usize, learning_rate: f32) -> Self {
Self {
inner: DashMap::new(),
dim,
learning_rate,
update_counts: DashMap::new(),
}
}
/// Get the current preference vector for a user (cloned).
///
/// Returns `None` if no preference has been recorded.
#[must_use]
pub fn get(&self, user_id: u64) -> Option<Vec<f32>> {
self.inner.get(&user_id).map(|r| r.clone())
}
/// Set the preference vector directly (e.g., from cold-start initialization).
///
/// The vector is L2-normalized before storage. Returns `false` if the
/// dimension does not match.
#[must_use]
pub fn set(&self, user_id: u64, mut vec: Vec<f32>) -> bool {
if vec.len() != self.dim {
return false;
}
normalize_centroid(&mut vec);
self.inner.insert(user_id, vec);
true
}
/// Update a user's preference vector by blending with an interaction embedding.
///
/// Uses exponential moving average with an adaptive learning rate:
/// `alpha = base_alpha / (1 + ln(update_count + 1))`
/// `pref = (1 - alpha) * pref + alpha * interaction`
/// then L2-normalizes the result.
///
/// The adaptive rate decays logarithmically with the number of updates,
/// so early interactions have high influence and later interactions refine
/// the preference more gently.
///
/// If no preference exists yet, the interaction embedding becomes the initial
/// preference (after normalization). Uses `Entry::Occupied`/`Entry::Vacant`
/// to avoid double-applying the blend on first insertion.
///
/// Returns `false` if the interaction embedding dimension does not match.
#[must_use]
pub fn update(&self, user_id: u64, interaction_embedding: &[f32]) -> bool {
if interaction_embedding.len() != self.dim {
return false;
}
// Compute adaptive learning rate: alpha = base / (1 + ln(count + 1)).
let count = {
let mut entry = self.update_counts.entry(user_id).or_insert(0);
let c = *entry;
*entry += 1;
c
};
#[allow(clippy::cast_precision_loss)]
let lr = (f64::from(self.learning_rate) / (1.0 + (count as f64).ln_1p())) as f32;
self.blend_and_normalize(user_id, interaction_embedding, lr);
true
}
/// Update a user's preference vector by blending with an interaction embedding
/// using an explicit learning rate (overrides the stored rate).
///
/// Same EMA formula as [`update`](Self::update) but with `lr` parameter:
/// `pref = (1 - lr) * pref + lr * interaction`
///
/// Returns `false` if the interaction embedding dimension does not match.
#[must_use]
pub fn update_with_custom_rate(
&self,
user_id: u64,
interaction_embedding: &[f32],
lr: f32,
) -> bool {
if interaction_embedding.len() != self.dim {
return false;
}
self.blend_and_normalize(user_id, interaction_embedding, lr);
true
}
/// Blend `interaction_embedding` into the user's preference at learning
/// rate `lr` and re-normalize, the shared EMA body for both [`update`] and
/// [`update_with_custom_rate`].
///
/// An existing preference is blended in place via
/// `pref = (1 - lr) * pref + lr * interaction` and re-normalized; if no
/// preference exists yet, the (normalized) interaction embedding becomes the
/// initial preference, avoiding a double-applied blend on first insertion.
///
/// The caller is responsible for the dimension check — `dim` mismatch is
/// validated before this is reached, and the per-element `zip` would
/// otherwise silently blend only the overlapping prefix.
///
/// [`update`]: Self::update
/// [`update_with_custom_rate`]: Self::update_with_custom_rate
fn blend_and_normalize(&self, user_id: u64, interaction_embedding: &[f32], lr: f32) {
use dashmap::mapref::entry::Entry;
match self.inner.entry(user_id) {
Entry::Occupied(mut occ) => {
let pref = occ.get_mut();
for (p, &i) in pref.iter_mut().zip(interaction_embedding.iter()) {
*p = (1.0 - lr).mul_add(*p, lr * i);
}
normalize_centroid(pref);
}
Entry::Vacant(vac) => {
let mut v = interaction_embedding.to_vec();
normalize_centroid(&mut v);
vac.insert(v);
}
}
}
/// Compute cosine similarity between a user's preference and a candidate embedding.
///
/// Returns `None` if the user has no preference vector or dimensions mismatch.
/// The stored preference is L2-normalized; the candidate is normalized on-the-fly
/// so callers do not need to pre-normalize.
#[must_use]
#[allow(clippy::significant_drop_tightening)]
pub fn cosine_similarity(&self, user_id: u64, candidate: &[f32]) -> Option<f32> {
if candidate.len() != self.dim {
return None;
}
let pref = self.inner.get(&user_id)?;
let dot: f32 = pref.iter().zip(candidate.iter()).map(|(a, b)| a * b).sum();
// Divide by the candidate's L2 norm to get true cosine similarity.
// The stored preference is already unit-length, so we only need
// to normalize the candidate side.
let candidate_norm: f32 = candidate.iter().map(|x| x * x).sum::<f32>().sqrt();
if candidate_norm < f32::EPSILON {
return Some(0.0);
}
Some(dot / candidate_norm)
}
/// Remove every stored preference vector and update count.
///
/// Used to return the store to its just-opened state so a full rebuild
/// (re-seed + signal replay) does not fold on top of already-folded vectors
/// — i.e. to keep an out-of-band reindex idempotent with a fresh boot.
pub fn clear(&self) {
self.inner.clear();
self.update_counts.clear();
}
/// Remove a single user's cold-start vector and update count.
///
/// Used by [`MultiPreferenceVectors`](crate::entities::MultiPreferenceVectors)
/// when a user crosses the warm threshold: their cold-start vector is migrated
/// into cluster 0 and then dropped here so a checkpoint never double-stores a
/// stale K=1 row alongside the cluster rows.
pub fn remove(&self, user_id: u64) {
self.inner.remove(&user_id);
self.update_counts.remove(&user_id);
}
/// Insert an already-restored (normalized) vector + update count directly,
/// bypassing the EMA blend.
///
/// The multi-vector restore path uses this to load a legacy single-vector
/// checkpoint row as a cold-start user. The caller is responsible for having
/// re-normalized the vector at the load boundary (matching [`restore`]'s
/// contract); the dimension is trusted because the caller already gated on it.
///
/// [`restore`]: Self::restore
pub fn insert_restored(&self, user_id: u64, vec: Vec<f32>, update_count: u64) {
self.inner.insert(user_id, vec);
self.update_counts.insert(user_id, update_count);
}
/// Whether this store holds a vector for `user_id`, without allocating
/// (an O(1) hash lookup). Used by
/// [`MultiPreferenceVectors::contains`](crate::entities::MultiPreferenceVectors::contains)
/// for serve-path existence guards.
#[must_use]
pub fn contains(&self, user_id: u64) -> bool {
self.inner.contains_key(&user_id)
}
/// Stage one legacy single-vector row per stored user into an existing
/// `WriteBatch` (`[count:8 LE][dim:4 LE][f32*dim]`), without performing its
/// own delete sweep. `skip(user_id) == true` omits that user's row.
///
/// [`checkpoint`](Self::checkpoint) is the standalone single-vector path (it
/// owns the delete sweep + write). This variant lets
/// [`MultiPreferenceVectors`](crate::entities::MultiPreferenceVectors)'s
/// unified checkpoint fold cold-start users into the same atomic batch as the
/// cluster rows, so a single swap covers both tiers. The `skip` predicate lets
/// that caller exclude any user already written as a warm cluster row, so a
/// legacy row can never collide with (and clobber) a warm row under the shared
/// `Tag::Preference` key.
pub fn append_legacy_rows(
&self,
batch: &mut crate::storage::WriteBatch,
skip: impl Fn(u64) -> bool,
) {
use crate::{
schema::EntityId,
storage::{Tag, encode_key},
};
for entry in &self.inner {
let user_id = *entry.key();
if skip(user_id) {
continue;
}
let value = encode_legacy_row(self.update_count(user_id), entry.value());
let key = encode_key(EntityId::new(0), Tag::Preference, &user_id.to_be_bytes());
batch.put(key, value);
}
}
/// Number of users with stored preferences.
#[must_use]
pub fn len(&self) -> usize {
self.inner.len()
}
/// Whether no preferences are stored.
#[must_use]
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
/// Get the current update count for a user (number of times `update` has been called).
#[must_use]
pub fn update_count(&self, user_id: u64) -> u64 {
self.update_counts.get(&user_id).map_or(0, |c| *c)
}
/// Compute the adaptive learning rate for a user at their current update count.
///
/// Formula: `base_alpha / (1 + ln(count + 1))`.
#[must_use]
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
pub fn adaptive_lr(&self, user_id: u64) -> f32 {
let count = self.update_count(user_id);
(f64::from(self.learning_rate) / (1.0 + (count as f64).ln_1p())) as f32
}
}
// ── Checkpoint / Restore ─────────────────────────────────────────────────────
impl PreferenceVectors {
/// Checkpoint every per-user preference vector + update count to durable
/// storage under `Tag::Preference`, one row per user.
///
/// All rows share the sentinel entity id (`0`) so [`restore`](Self::restore)
/// can target them with a single prefix scan. The swap is staged into one
/// `WriteBatch` and committed atomically (matching the co-engagement /
/// community checkpoints): stale rows are deleted and current rows written in
/// the same batch, so a crash mid-checkpoint never leaves a half-written set.
///
/// Key suffix: `[user: 8B BE]`. Value: `[update_count: 8B LE][dim: 4B LE][f32 * dim LE]`.
///
/// **In-engine this is superseded by
/// [`MultiPreferenceVectors`](crate::entities::MultiPreferenceVectors), which
/// owns all of `Tag::Preference` and writes cold-start users via
/// `append_legacy_rows`.** This standalone path remains for direct single-vector
/// embedders; it shares the `encode_legacy_row` writer so the two cannot drift.
///
/// # Errors
///
/// Returns storage errors from the underlying engine.
pub fn checkpoint(&self, storage: &dyn crate::storage::StorageEngine) -> crate::Result<()> {
use crate::{
schema::EntityId,
storage::{Tag, WriteBatch, entity_tag_prefix},
};
let prefix = entity_tag_prefix(EntityId::new(0), Tag::Preference);
let mut batch = WriteBatch::with_capacity(self.inner.len() + 1);
// Stage deletion of any stale rows (e.g. a user whose vector was cleared)
// so the post-swap snapshot holds exactly the current set.
for item in storage.scan_prefix(&prefix) {
let (key, _) = item.map_err(crate::schema::TidalError::from)?;
batch.delete(key);
}
// Write every current row through the single-sourced legacy encoder.
self.append_legacy_rows(&mut batch, |_| false);
storage
.write_batch(batch)
.map_err(crate::schema::TidalError::from)?;
Ok(())
}
/// Restore per-user preference vectors + update counts from a
/// `Tag::Preference` checkpoint. Skips rows whose stored dimension does not
/// match this store's `dim` (a schema change) and rows that are torn.
///
/// Each restored vector is re-normalized to unit length at the load boundary
/// (after zeroing any NaN component) so the cosine-scoring invariant holds
/// even for a torn or tampered row — see the inline comment at the
/// normalization site.
///
/// # Errors
///
/// Returns storage errors from the underlying engine.
pub fn restore(&self, storage: &dyn crate::storage::StorageEngine) -> crate::Result<()> {
use crate::{
schema::EntityId,
storage::{Tag, entity_tag_prefix, parse_key},
};
let prefix = entity_tag_prefix(EntityId::new(0), Tag::Preference);
let mut count = 0u64;
for entry in storage.scan_prefix(&prefix) {
let (key, value) = entry.map_err(crate::schema::TidalError::from)?;
let Some((_, Tag::Preference, suffix)) = parse_key(&key) else {
continue;
};
if suffix.len() < 8 {
continue;
}
let user_id = u64::from_be_bytes(suffix[0..8].try_into().unwrap_or([0u8; 8]));
// Decode + NaN-neutralize + re-normalize at the load boundary via the
// single-sourced decoder (skips torn / dimension-mismatched rows). The
// re-normalization re-establishes the unit-length invariant that
// `cosine_similarity` assumes, so a torn row degrades to a zero
// preference rather than a poisoned non-unit one.
let Some((update_count, vec)) = decode_legacy_row(&value, self.dim) else {
continue;
};
// Seed the adaptive update count so the learning rate resumes where
// it was at checkpoint time.
self.inner.insert(user_id, vec);
self.update_counts.insert(user_id, update_count);
let _ = EntityId::new(user_id);
count += 1;
}
if count > 0 {
tracing::info!(users = count, "preference vectors restored from checkpoint");
}
Ok(())
}
}
/// L2-normalize an accumulated preference centroid in place, tolerating a cold
/// (all-zero) vector.
///
/// This is the **one** place the centroid zero-tolerance policy is named. The math
/// itself lives in [`crate::storage::vector::l2_normalize_in_place`]; this wrapper
/// only decides what a zero norm *means* here:
///
/// A preference centroid is legitimately all-zero until the first signal folds in,
/// so "no direction yet" is an ordinary state, not an error — the vector is left at
/// zero and will normalize on the next fold. Embedding writes deliberately do **not**
/// get this leniency (`storage::vector::lifecycle::ops` propagates the error) because
/// a zero embedding entering the HNSW index is unrecoverable garbage.
///
/// Both `preference` and `multi_preference` call this; before, each carried its own
/// copy of the arithmetic with a zero threshold ~2900x looser than the canonical one,
/// which is how the two directions of normalization drifted apart in the first place.
/// Vectors with `0 < ||v|| < 3.45e-4` are now left alone rather than amplified, since
/// dividing by a norm that small yields a direction made of rounding error.
pub(crate) fn normalize_centroid(vec: &mut [f32]) {
if let Err(err) = l2_normalize_in_place(vec) {
// A cold or numerically-zero centroid is the ONLY error this can return
// (see `l2_norm_checked`), and it is an ordinary state here: leave the
// vector exactly as it was.
//
// Anything else is unreachable today. Leaving the centroid untouched stays
// the safe default if normalization ever grows a second failure mode, but
// say so out loud rather than discarding it silently.
if !matches!(err, VectorError::ZeroNormVector) {
tracing::warn!(error = %err, "unexpected error normalizing preference centroid");
}
}
}
/// Canonical legacy single-vector row encoder: `[count:8 LE][dim:4 LE][f32*dim]`.
///
/// This is the **one** place the legacy row is written. `append_legacy_rows`, the
/// standalone [`PreferenceVectors::checkpoint`], and (via `decode_legacy_row`) the
/// multi-vector cold-start path all go through this pair so the on-disk format
/// cannot drift across writers/readers.
#[must_use]
pub(crate) fn encode_legacy_row(count: u64, vec: &[f32]) -> Vec<u8> {
let mut value = Vec::with_capacity(8 + 4 + vec.len() * 4);
value.extend_from_slice(&count.to_le_bytes());
#[allow(clippy::cast_possible_truncation)]
value.extend_from_slice(&(vec.len() as u32).to_le_bytes());
for v in vec {
value.extend_from_slice(&v.to_le_bytes());
}
value
}
/// Canonical legacy single-vector row decoder. Returns `(update_count,
/// normalized_vector)`, or `None` if the row is torn or its stored dimension does
/// not match `expected_dim`. Each component is NaN-neutralized and the vector is
/// re-normalized at the load boundary (the cosine-scoring invariant), mirroring
/// the restore contract. Paired with [`encode_legacy_row`]; both
/// [`PreferenceVectors::restore`] and the multi-vector store's legacy decode
/// delegate here so the format has exactly one encoder and one decoder.
#[must_use]
pub(crate) fn decode_legacy_row(value: &[u8], expected_dim: usize) -> Option<(u64, Vec<f32>)> {
if value.len() < 12 {
return None;
}
let update_count = u64::from_le_bytes(value[0..8].try_into().ok()?);
let dim = u32::from_le_bytes(value[8..12].try_into().ok()?) as usize;
if dim != expected_dim || value.len() < 12 + dim * 4 {
return None;
}
let mut vec = Vec::with_capacity(dim);
for i in 0..dim {
let off = 12 + i * 4;
let f = f32::from_le_bytes(value[off..off + 4].try_into().ok()?);
vec.push(if f.is_nan() { 0.0 } else { f });
}
normalize_centroid(&mut vec);
Some((update_count, vec))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp)]
mod tests {
use super::*;
#[test]
fn set_and_get() {
let pv = PreferenceVectors::new(3);
assert!(pv.set(1, vec![3.0, 4.0, 0.0]));
let v = pv.get(1).unwrap();
// 3/5, 4/5, 0
assert!((v[0] - 0.6).abs() < 1e-6);
assert!((v[1] - 0.8).abs() < 1e-6);
assert!((v[2] - 0.0).abs() < 1e-6);
}
#[test]
fn set_wrong_dim_rejected() {
let pv = PreferenceVectors::new(3);
assert!(!pv.set(1, vec![1.0, 2.0]));
assert!(pv.get(1).is_none());
}
#[test]
fn update_creates_initial() {
let pv = PreferenceVectors::new(3);
assert!(pv.update(1, &[1.0, 0.0, 0.0]));
let v = pv.get(1).unwrap();
assert!((v[0] - 1.0).abs() < 1e-6);
}
#[test]
fn update_blends() {
let pv = PreferenceVectors::with_learning_rate(2, 0.5);
let _ = pv.set(1, vec![1.0, 0.0]);
let _ = pv.update(1, &[0.0, 1.0]);
let v = pv.get(1).unwrap();
// After blend: (0.5, 0.5), normalized: (1/sqrt(2), 1/sqrt(2))
let expected = 1.0 / 2.0f32.sqrt();
assert!((v[0] - expected).abs() < 1e-5);
assert!((v[1] - expected).abs() < 1e-5);
}
#[test]
fn cosine_similarity_normalized() {
let pv = PreferenceVectors::new(3);
let _ = pv.set(1, vec![1.0, 0.0, 0.0]);
// Cosine with self = 1.0
let sim = pv.cosine_similarity(1, &[1.0, 0.0, 0.0]).unwrap();
assert!((sim - 1.0).abs() < 1e-6);
// Orthogonal = 0.0
let sim = pv.cosine_similarity(1, &[0.0, 1.0, 0.0]).unwrap();
assert!(sim.abs() < 1e-6);
}
#[test]
fn cosine_similarity_no_pref() {
let pv = PreferenceVectors::new(3);
assert!(pv.cosine_similarity(1, &[1.0, 0.0, 0.0]).is_none());
}
#[test]
fn l2_normalize_zero_vec() {
let mut v = vec![0.0f32, 0.0, 0.0];
normalize_centroid(&mut v);
assert!(v.iter().all(|&x| x == 0.0));
}
/// Pins the ONE behavior change from collapsing the three `l2_normalize`
/// copies into `storage::vector::l2_normalize_in_place`.
///
/// The deleted in-place copies rejected only `||v|| <= f32::EPSILON` (1.2e-7);
/// the canonical implementation rejects `||v||^2 < f32::EPSILON`, i.e.
/// `||v|| < 3.45e-4` — about 2900x stricter. A centroid in that gap is now left
/// alone instead of being scaled to unit length.
///
/// That is deliberate: dividing by a norm that small amplifies float noise by
/// more than 2900x, so the "direction" produced is mostly rounding error.
/// Leaving the vector as-is is the honest answer, and it matches what embeddings
/// have always done. Asserted rather than merely documented so the choice cannot
/// be un-made silently.
#[test]
fn normalize_centroid_leaves_numerically_zero_vector_untouched() {
// ||v|| = 1e-5 * sqrt(3) = 1.73e-5, inside the gap between the two thresholds.
let mut v = vec![1e-5f32, 1e-5, 1e-5];
let before = v.clone();
normalize_centroid(&mut v);
assert_eq!(
v, before,
"a centroid with norm below the canonical threshold must be left as-is, \
not amplified into a direction made of rounding error"
);
// Just above the threshold it must still normalize, so the guard is a floor
// and not a silent no-op for real data.
let mut real = vec![1.0f32, 2.0, 3.0];
normalize_centroid(&mut real);
let norm: f32 = real.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(1.0 - norm).abs() < 1e-5,
"real centroid must reach unit norm, got {norm}"
);
}
#[test]
fn update_with_custom_rate_blends_at_given_lr() {
let pv = PreferenceVectors::new(2);
let _ = pv.set(1, vec![1.0, 0.0]);
// lr=0.5 means 50% blend.
let ok = pv.update_with_custom_rate(1, &[0.0, 1.0], 0.5);
assert!(ok);
let v = pv.get(1).unwrap();
let expected = 1.0 / 2.0f32.sqrt();
assert!((v[0] - expected).abs() < 1e-5);
assert!((v[1] - expected).abs() < 1e-5);
}
#[test]
fn update_with_custom_rate_wrong_dim() {
let pv = PreferenceVectors::new(3);
assert!(!pv.update_with_custom_rate(1, &[1.0], 0.1));
}
#[test]
fn len_and_is_empty() {
let pv = PreferenceVectors::new(3);
assert!(pv.is_empty());
assert_eq!(pv.len(), 0);
let _ = pv.set(1, vec![1.0, 0.0, 0.0]);
assert!(!pv.is_empty());
assert_eq!(pv.len(), 1);
}
#[test]
fn adaptive_lr_decays_monotonically() {
let pv = PreferenceVectors::new(4);
let embedding = [1.0f32, 0.0, 0.0, 0.0];
// At count=0: alpha = 0.1 / (1 + ln(1)) = 0.1
let alpha_at_0 = pv.adaptive_lr(1);
assert!(
(alpha_at_0 - 0.1).abs() < 1e-5,
"alpha at count=0 should be 0.1, got {alpha_at_0}"
);
// Apply 1000 updates.
for _ in 0..1000 {
let _ = pv.update(1, &embedding);
}
let alpha_at_1000 = pv.adaptive_lr(1);
// After 1000 updates, alpha should be substantially smaller.
assert!(
alpha_at_1000 < 0.05,
"alpha at 1000 updates ({alpha_at_1000}) should be < 0.05"
);
assert!(
alpha_at_1000 < alpha_at_0,
"alpha must decay with update count"
);
// Verify intermediate counts also decay.
let pv2 = PreferenceVectors::new(4);
for _ in 0..100 {
let _ = pv2.update(2, &embedding);
}
let alpha_at_100 = pv2.adaptive_lr(2);
assert!(
alpha_at_100 < alpha_at_0,
"alpha at 100 should be < alpha at 0"
);
assert!(
alpha_at_1000 < alpha_at_100,
"alpha at 1000 should be < alpha at 100"
);
}
/// Verify the decay formula using actual vector shift magnitudes.
///
/// The AC states that after 1000 preference updates, new signals shift the
/// vector less than at update count=1. This test measures the L2-norm of
/// `pref_after - pref_before` at different update counts using an orthogonal
/// interaction direction to maximize the visible shift.
///
/// The `ln_1p` decay formula: `alpha = base / (1 + ln(count + 1))` achieves
/// ~1015% of the initial shift magnitude at count=1000 (the formula is
/// logarithmic, not exponential; it cannot achieve < 5% in finite update counts
/// without an unreasonably small `base_alpha`).
#[test]
fn adaptive_lr_shift_magnitude_decays_with_update_count() {
// Use a fresh instance so we control update counts precisely.
let pv = PreferenceVectors::new(4);
// Helper to measure the L2 shift magnitude of one update in an
// orthogonal direction from the current preference.
let measure_shift = |pv: &PreferenceVectors, interaction: &[f32]| -> f32 {
let before = pv.get(1).unwrap_or_else(|| vec![1.0, 0.0, 0.0, 0.0]);
let _ = pv.update(1, interaction);
let after = pv.get(1).unwrap();
before
.iter()
.zip(after.iter())
.map(|(a, b)| (b - a).powi(2))
.sum::<f32>()
.sqrt()
};
// Establish a baseline preference in direction [1, 0, 0, 0].
let primary = [1.0f32, 0.0, 0.0, 0.0];
let orthogonal = [0.0f32, 1.0, 0.0, 0.0];
// Initial state via set (does not increment update_count).
assert!(pv.set(1, primary.to_vec()));
// Shift at count=0 (first adaptive update).
let shift_at_0 = measure_shift(&pv, &orthogonal);
// Reset back to primary to isolate shift measurement.
assert!(pv.set(1, primary.to_vec()));
// Apply 99 more updates to reach count=100.
for _ in 0..99 {
let _ = pv.update(1, &primary);
}
let shift_at_100 = measure_shift(&pv, &orthogonal);
assert!(pv.set(1, primary.to_vec()));
// Apply another 900 updates to reach count=1000.
for _ in 0..900 {
let _ = pv.update(1, &primary);
}
let shift_at_1000 = measure_shift(&pv, &orthogonal);
// Verify monotonic decay of shift magnitude.
assert!(
shift_at_100 < shift_at_0,
"shift at 100 ({shift_at_100:.6}) must be < shift at 0 ({shift_at_0:.6})"
);
assert!(
shift_at_1000 < shift_at_100,
"shift at 1000 ({shift_at_1000:.6}) must be < shift at 100 ({shift_at_100:.6})"
);
// Verify the shift at 1000 is substantially smaller than at count=0.
// The ln_1p formula achieves ~10-15% of initial shift at count=1000.
let ratio = shift_at_1000 / shift_at_0;
assert!(
ratio < 0.20,
"shift at 1000 ({shift_at_1000:.6}) should be < 20% of shift at 0 ({shift_at_0:.6}); got {ratio:.3}"
);
}
#[test]
fn update_count_tracks_calls() {
let pv = PreferenceVectors::new(2);
assert_eq!(pv.update_count(1), 0);
let _ = pv.update(1, &[1.0, 0.0]);
assert_eq!(pv.update_count(1), 1);
let _ = pv.update(1, &[0.0, 1.0]);
assert_eq!(pv.update_count(1), 2);
// Different user is independent.
assert_eq!(pv.update_count(2), 0);
}
#[test]
fn custom_rate_does_not_use_adaptive_lr() {
let pv = PreferenceVectors::new(2);
// Set a known state.
let _ = pv.set(1, vec![1.0, 0.0]);
// Use custom rate -- should NOT increment update_counts.
let _ = pv.update_with_custom_rate(1, &[0.0, 1.0], 0.5);
assert_eq!(
pv.update_count(1),
0,
"update_with_custom_rate should not increment adaptive count"
);
}
/// Encode a `Tag::Preference` checkpoint value: `[count:8 LE][dim:4 LE][f32*dim]`.
fn encode_pref_value(count: u64, vec: &[f32]) -> Vec<u8> {
let mut value = Vec::with_capacity(8 + 4 + vec.len() * 4);
value.extend_from_slice(&count.to_le_bytes());
value.extend_from_slice(&u32::try_from(vec.len()).unwrap().to_le_bytes());
for v in vec {
value.extend_from_slice(&v.to_le_bytes());
}
value
}
/// W12: a torn/tampered checkpoint row that is NOT unit-length must be
/// re-normalized at the load boundary. `cosine_similarity` assumes the stored
/// preference is unit-length (it divides only by the candidate norm), so a
/// non-unit restored vector would mis-scale every similarity for that user.
/// This covers both failure shapes: a NaN component (zeroed then non-unit) and
/// a finite-but-non-unit row.
#[test]
fn restore_renormalizes_torn_rows_to_unit_length() {
use crate::{
schema::EntityId,
storage::{InMemoryBackend, StorageEngine, Tag, encode_key},
};
let storage = InMemoryBackend::new();
let dim = 3usize;
// User 1: a NaN component. After zeroing, the residual [3,0,4] has norm 5,
// i.e. NOT unit length — restore must normalize it to [0.6, 0, 0.8].
let nan_row = encode_pref_value(7, &[3.0, f32::NAN, 4.0]);
let key1 = encode_key(EntityId::new(0), Tag::Preference, &1u64.to_be_bytes());
storage.put(&key1, &nan_row).unwrap();
// User 2: a finite-but-non-unit row [0, 6, 8] (norm 10). Must normalize to
// [0, 0.6, 0.8].
let big_row = encode_pref_value(3, &[0.0, 6.0, 8.0]);
let key2 = encode_key(EntityId::new(0), Tag::Preference, &2u64.to_be_bytes());
storage.put(&key2, &big_row).unwrap();
// User 3: an all-zero (fully-corrupt) row stays zero (degrades to a zero
// preference, never a poisoned non-unit one).
let zero_row = encode_pref_value(1, &[f32::NAN, f32::NAN, f32::NAN]);
let key3 = encode_key(EntityId::new(0), Tag::Preference, &3u64.to_be_bytes());
storage.put(&key3, &zero_row).unwrap();
let pv = PreferenceVectors::new(dim);
pv.restore(&storage).unwrap();
let v1 = pv.get(1).expect("user 1 restored");
let n1: f32 = v1.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(n1 - 1.0).abs() < 1e-5,
"user 1 must be unit length, got {n1}"
);
assert!((v1[0] - 0.6).abs() < 1e-5 && (v1[2] - 0.8).abs() < 1e-5);
assert_eq!(pv.update_count(1), 7, "update count must round-trip");
let v2 = pv.get(2).expect("user 2 restored");
let n2: f32 = v2.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(n2 - 1.0).abs() < 1e-5,
"user 2 must be unit length, got {n2}"
);
let v3 = pv.get(3).expect("user 3 restored");
assert!(
v3.iter().all(|&x| x == 0.0),
"a fully-corrupt row degrades to a zero preference, not a poisoned one"
);
// Cosine of the unit-normalized user-1 pref with its own direction is 1.0,
// proving the invariant cosine_similarity depends on now holds.
let sim = pv.cosine_similarity(1, &[0.6, 0.0, 0.8]).unwrap();
assert!(
(sim - 1.0).abs() < 1e-5,
"cosine self-similarity must be 1.0, got {sim}"
);
}
mod proptests {
use proptest::prelude::*;
use super::*;
proptest! {
/// After any sequence of updates, the L2 norm stays approximately 1.0.
#[test]
fn l2_norm_invariant(
updates in proptest::collection::vec(
proptest::collection::vec(-1.0f32..1.0f32, 4..=4),
1..20
),
) {
let pv = PreferenceVectors::new(4);
for emb in &updates {
let _ = pv.update(1, emb);
}
let v = pv.get(1).unwrap();
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
// After a sequence of updates, the vector should be unit-length
// (within floating-point tolerance) or exactly zero if all inputs
// collapse to the origin.
prop_assert!(
(norm - 1.0).abs() < 1e-4 || norm < f32::EPSILON,
"norm was {norm}, expected ~1.0"
);
}
}
}
}