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)
111 lines
3.9 KiB
Rust
111 lines
3.9 KiB
Rust
#![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]
|
||
);
|
||
}
|