tidaldb/tidal/tests/m12_preference_event_time.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

111 lines
3.9 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, clippy::cast_possible_truncation)]
//! Multi-vector preference — **event-time anchoring** (end-to-end regression for
//! the W11 fix).
//!
//! A positive-engagement signal must anchor the user's per-cluster importance at
//! the signal's EVENT timestamp, not the ingestion wall-clock. Otherwise a
//! backfilled / out-of-order engagement masquerades as fresh in the top-M fan-out.
//! The engine plumbs the event timestamp through `try_update_preference_vector ->
//! MultiPreferenceVectors::update_at(.., timestamp.as_nanos())`; this test proves
//! that wiring through the real `signal_with_context` path (the unit tests cover
//! the kernel, but not that the live signal path reaches it with the event time).
use std::collections::HashMap;
use tidaldb::{
TidalDb,
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
};
const DIM: usize = 8;
const DAY_NS: u64 = 24 * 3600 * 1_000_000_000;
const BASE_NS: u64 = 1_000_000_000;
fn one_hot(axis: usize) -> Vec<f32> {
let mut v = vec![0.0_f32; DIM];
v[axis] = 1.0;
v
}
fn schema() -> tidaldb::schema::Schema {
let mut b = SchemaBuilder::new();
let _ = b
.signal(
"like",
EntityKind::Item,
DecaySpec::Exponential {
half_life: std::time::Duration::from_secs(30 * 24 * 3600),
},
)
.windows(&[Window::TwentyFourHours])
.positive_engagement(true)
.add();
b.embedding_slot("content", EntityKind::Item, DIM);
b.build().unwrap()
}
#[test]
fn preference_importance_anchors_at_event_time_not_wall_clock() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema())
.open()
.unwrap();
// Interest A on axis 0, interest B on axis 3 (orthogonal ⇒ distinct clusters).
let item_a = EntityId::new(1);
let item_b = EntityId::new(2);
for (id, axis) in [(item_a, 0usize), (item_b, 3usize)] {
db.write_item_with_metadata(id, &HashMap::new()).unwrap();
db.write_item_embedding(id, &one_hot(axis)).unwrap();
}
let user = 7u64;
// Warm the user entirely on interest A at an OLD event time (timestamps offset
// by 1ns each to avoid WAL dedup; the spread is negligible vs the half-life).
// >= COLD_START_N "like"s crosses the user into the clustered tier on A.
for i in 0..6u64 {
db.signal_with_context(
"like",
item_a,
1.0,
Timestamp::from_nanos(BASE_NS + i),
Some(user),
Some(100),
)
.unwrap();
}
assert!(
db.preference_vectors().is_warm(user),
"user must be warm on interest A after >= COLD_START_N likes"
);
// A SINGLE engagement with interest B, but 120 days later in EVENT time
// (4 importance half-lives — the importance half-life default is 30 days).
let t_late = Timestamp::from_nanos(BASE_NS + 120 * DAY_NS);
db.signal_with_context("like", item_b, 1.0, t_late, Some(user), Some(100))
.unwrap();
let prefs = db.preference_vectors();
assert_eq!(
prefs.cluster_count(user),
2,
"A and B are orthogonal ⇒ two distinct interest clusters"
);
// Rank the clusters by current importance at t_late. With correct EVENT-time
// anchoring, interest A (anchored ~120 days ago, decayed ~16×) falls BELOW the
// single fresh interest B. Under the wall-clock bug, all six A engagements would
// anchor at ~now and A's 6× mass would dominate B — so "B ranks first" is the
// negative control that distinguishes the two implementations.
let fanout = prefs.query_vectors(user, t_late.as_nanos(), 2);
assert_eq!(fanout.len(), 2);
assert!(
fanout[0][3] > 0.9 && fanout[0][0] < 0.1,
"the FRESH interest B (axis 3) must outrank the STALE interest A (axis 0) \
when importance is anchored at event time; got fanout[0]={:?}",
fanout[0]
);
}