#![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 { 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] ); }