# Multi-vector user preference modelling (✅ COMPLETE 2026-06-23) Landed in `6a937fc`, with the docs/spec/API refresh in `4051077`. Design: [docs/research/multi-vector-preference.md](../../research/multi-vector-preference.md). Changelog: [CHANGELOG.md](../../../CHANGELOG.md). Milestone index: [README.md](README.md). Backfilled record. > **Deliberately not numbered.** The ROADMAP's M12 row lists this work as a peer > of m12p1–m12p6 with no phase number, and no commit or document ever called it > `m12p7`. It is filed under its own name rather than given an invented id. ## The premise A warm user is many interests, not one averaged vector. Averaging jazz and powerlifting into a single centroid produces a vector that retrieves neither — and ANN candidate generation (m12p2) made that failure load-bearing, because the preference vector *is* the query. ## What shipped 1. **Online preference clustering** (`tidal/src/entities/multi_preference.rs`). A warm user (≥ `COLD_START_N = 5` interactions) maintains up to `K_MAX` preference clusters, built by online sequential k-means with a DP-means threshold split: a new engagement updates its nearest cluster via a per-cluster adaptive EMA, or — past the split threshold and under the cap — opens a new cluster. At the cap the nearest cluster absorbs the engagement, so the structure is bounded rather than unboundedly growing. 2. **Interests that fade.** Per-cluster *importance* composes the canonical forward-decay kernel anchored to each engagement's timestamp, so a stale interest decays instead of persisting at full strength forever. Reusing the canonical kernel is the same discipline `SessionHotState` follows: one decay implementation, not per-tier copies. 3. **Top-M ANN fan-out.** At query time `for_you` selects the top-`M` clusters by current importance, issues `M` ANN queries (`candidate_gen::ann_candidates_multi`), and merges by best (minimum) distance; the personalization boost is the **max** cosine over all clusters — a candidate that matches any one interest strongly is not diluted by the interests it does not match. 4. **Cold-start fallback preserved.** Users below the cold-start threshold keep the single adaptive-LR vector (`tidal/src/entities/preference.rs`), so nothing about the pre-existing behaviour changes for a new user. 5. **Zero-migration persistence.** `MultiPreferenceVectors::checkpoint` / `restore` (`multi_preference.rs:642,703`) serialize per-cluster `[update_count:8 LE][importance_at_anchor:4 LE][anchor_ts:8 LE]` behind a `FORMAT_VERSION` byte, with a first-byte discrimination trick so a **legacy single-vector row whose `update_count` low byte happens to equal the format version** is still read correctly as a cold-start user rather than misparsed. That edge is covered by `restore_rescues_legacy_row_whose_count_low_byte_equals_format_version`. ## Evidence - Unit coverage in `multi_preference.rs`: `checkpoint_restore_roundtrip_multi_cluster`, `restore_reads_legacy_single_vector_rows_as_cold_start`, `restore_renormalizes_torn_cluster_to_unit_length`, `restore_drops_torn_tail_cluster_keeps_prefix`, `restore_skips_dimension_mismatch`, `restore_skips_dim_mismatched_multi_row_not_loaded_as_garbage`, `checkpoint_skips_cold_row_for_user_also_in_clusters_no_demotion`. The torn-row and dimension-mismatch cases matter: a checkpoint is read after a crash, so "garbage in the tail" is the expected input, not the exceptional one. - `tidal/tests/m12_preference_event_time.rs` (1) — event-time anchoring. - `tidal/benches/multi_preference.rs`. ## Side effect worth recording This work incidentally closed a limitation M6 had booked against M7: per-user preference `update_count` is now persisted and restored (see [milestone-6/phase-4.md](../milestone-6/phase-4.md) and [milestone-6/phase-6.md](../milestone-6/phase-6.md)). The `# Known Limitation` comment in `tidal/src/entities/preference.rs:37-45` still claims otherwise and is stale. ## Deferred The **offline medoid-recluster tier** — periodically re-deriving cluster centroids from stored engagements rather than only updating them online — is recorded in the ROADMAP as an open follow-up. Online sequential k-means is order-dependent; a recluster pass is what would remove that dependence. Not built.