tidaldb/docs/research/multi-vector-preference.md
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

29 KiB
Raw Permalink Blame History

Research: Multi-Vector (PinnerSage-Style) User Preference

Question

Should tidalDB replace its single per-user EMA preference vector with a multi-vector (PinnerSage-style) representation, and if so, which clustering regime — offline-batch medoid (PinnerSage proper), online-maintained centroids (the ARCHITECTURE.md:252-254 variant), or a hybrid — fits an embeddable, single-node-first, in-process database? This doc settles one design for @tidal-engineer to build.

TidalDB Context

What exists today (verified against code, 2026-06)

  • Single preference vector per user. tidal/src/entities/preference.rs: DashMap<u64, Vec<f32>>, L2-normalized, blended via an adaptive-LR EMA alpha = base / (1 + ln_1p(count)) (update, lines 109-126). Checkpoint/restore under Tag::Preference (= 0x16, storage/keys.rs:71); value layout [count:8 LE][dim:4 LE][f32*dim LE], one row per user keyed by EntityId(0) sentinel + [user:8 BE] suffix (lines 263-362).
  • Update trigger. On a positive engagement signal, signals.rs:547 calls try_update_preference_vector (lines 765-816): reads the engaged item's stored embedding and blends it via preference_vectors.update. There is no decay, velocity, or recency weighting on this blend — the forward-decay signal model does not touch the preference vector at all. The only "recency" is the EMA's implicit exponential forgetting via alpha.
  • Consumption — for_you. query_ops.rs:99-116: when the profile's CandidateStrategy::Ann is set and query.for_user is present, the db resolves exactly one query vector (preference_vectors.get(user)) and threads it as ann_query_vector. candidate_gen.rs::ann_candidates (lines 104-130) then issues one index.search(query_vector, k, ef=64) (ANN_DEFAULT_EF_SEARCH). Stage 3 re-scores this pool by signals; compute_preference_boosts (personalization.rs:91-123) additionally applies a per-candidate cosine boost against the same single preference vector.
  • The filtered_search at pipeline.rs:431 is the SEARCH executor (explicit query vector + scope bitmap), not the for_you preference path. A multi-vector preference query would issue search/filtered_search K times from the ann_candidates/query_ops path, not from pipeline.rs.

Why this matters for tidalDB specifically

  • Averaging problem is live. A user who engages with hiking, cooking, and cars collapses to one EMA centroid that, per PinnerSage (KDD 2020), can represent none of them. tidalDB's core promise is "given a user and a context, what content should they see" — a multi-modal user is exactly where a single vector fails hardest.
  • Latency budget. <50ms p99 end-to-end. The single-query ANN path is measured at p99 1.22ms (100k/1536-D, ef=64; candidate_gen.rs:81-89). K queries multiply the ANN leg, but the ANN leg is a small fraction of the budget — headroom exists (see §4).
  • Embeddable, single-node, in-process. PinnerSage runs Ward hierarchical clustering on a Spark cluster, daily, offline. tidalDB has no Spark, no offline batch tier, and must cluster in the same process that serves queries. This is the single most important constraint and it disqualifies PinnerSage's literal implementation. The clustering primitive must be O(interactions) incremental work on the write path or a bounded periodic in-process pass — never an O(n²) hierarchical pass per user.

Approaches Surveyed

Approach 1: Offline-batch medoid clustering (PinnerSage proper)

How it works: Periodically (daily at Pinterest), collect each user's last ~N interaction embeddings, run Ward hierarchical agglomerative clustering to produce 3-100 clusters, represent each cluster by its medoid (the actual interaction embedding closest to the cluster's other members — not a synthetic centroid), and assign each cluster an importance score (time-decayed engagement mass). At serve time, sample 3 clusters by importance and issue one ANN query per sampled medoid.

Used by: Pinterest PinnerSage (KDD 2020, "PinnerSage: Multi-Modal User Embedding Framework for Recommendations at Pinterest", Pal et al.), 400M+ MAU. The medoid choice is load-bearing: Pinterest reports synthetic centroids degrade because the centroid drifts into empty embedding-space regions ("energy boosting breakfast" example).

Evidence: KDD 2020 paper; docs/research/ann_for_tidaldb.md:102-116 already endorses "PinnerSage-style multi-query with result merging — no special index modifications required."

Strengths for tidalDB: Highest-quality clusters (Ward is the gold standard for this exact problem). Medoid = an actual stored embedding, so it is always a valid, in-distribution query vector. Importance sampling composes cleanly with engagement mass.

Weaknesses for tidalDB: Ward is O(n²) memory / O(n² log n) time per user. It requires holding the user's full interaction history (or a window) in memory and re-clustering from scratch. There is no offline tier in an embeddable DB — running this inline on the write path or on a checkpoint thread for every active user is a non-starter at content-platform scale. Medoid maintenance requires storing the raw interaction embeddings, not just K centroids — a large storage multiplier (N interactions × dim vs K × dim).

Approach 2: Online-maintained centroids (the ARCHITECTURE.md variant)

How it works: Maintain K running centroids per user directly on the write path. On each positive engagement, assign the item embedding to the nearest existing centroid (by cosine) and blend it in via an EMA; if no centroid is within a similarity threshold τ and K is below the cap, spawn a new centroid seeded by the embedding. This is streaming/online k-means (specifically sequential k-means / MacQueen's online update) with a threshold-based split rule (a lightweight DP-means, Kulis & Jordan, ICML 2012). ARCHITECTURE.md:252-254 describes this: "3-10 interest cluster centroids (PinnerSage-style), maintained by the database as signals arrive."

Used by: The pattern (streaming k-means / DP-means) is textbook and ships in many online-learning systems; the specific "K running user-interest centroids updated on engagement" is what large-scale rec systems approximate when they cannot afford offline Ward. No single citable production system matches it exactly under this name — this is a tidalDB-original synthesis, which has patent-conception relevance (§7).

Strengths for tidalDB: O(K·dim) work per engagement — trivially cheap, fits the write hot path, no batch tier, no Spark. Stores only K × dim floats per user, a near zero storage delta over today's single vector. Reuses the existing EMA + adaptive-LR machinery per centroid. Naturally incremental — no rebuild.

Weaknesses for tidalDB: Re-introduces the averaging problem within a cluster. A centroid is by construction a synthetic mean — exactly what PinnerSage rejected. If τ is too loose, two genuinely distinct interests merge into one drifting centroid and you are back to the single-vector failure mode at finer granularity. Order sensitivity: online assignment is greedy and non-revisable — an early misassignment is permanent (no reclustering pass corrects it). No medoid guarantee — a centroid can drift to an empty embedding-space region between its members (the precise PinnerSage failure). τ is a per-corpus magic number that governs cluster count and must be tuned/benchmarked.

Approach 3: Hybrid — online assignment + bounded periodic in-process recluster

How it works: Run Approach 2 online for cheap, always-fresh, write-path clustering. Additionally, on the existing periodic checkpoint thread (which already walks every user for Tag::Preference checkpoint, preference.rs:263), run a bounded in-process micro-recluster for users whose interaction count crossed a threshold since their last recluster: pull that user's recent interaction-embedding window (capped, e.g. last 256), run a cheap clustering pass (mini-batch k-means with k-means++ seed, or single-linkage with a distance cutoff — both O(N·K·dim), not O(N²)), and snap each cluster's representative to the nearest actual interaction embedding (a medoid), recovering PinnerSage's medoid property. Online centroids serve queries between reclusters; the periodic pass corrects greedy misassignments and prevents centroid drift.

Used by: This is the architectural pattern Qdrant/Tantivy/Lucene use for segment management (cheap incremental writes + periodic compaction merge) applied to user clustering. ann_for_tidaldb.md:96 already cites this segment pattern as proven. No rec system publishes this exact hybrid for user clustering, again giving it conception novelty (§7).

Strengths for tidalDB: Gets online's write-path cheapness AND offline's quality correction, with no new background subsystem — it rides the checkpoint thread that already exists. Recovers the medoid property periodically. The recluster is bounded (capped window, K small, mini-batch) so it is O(active_users × window × K × dim), not O(n²) global.

Weaknesses for tidalDB: Most implementation surface of the three. Requires storing a bounded recent interaction-embedding window per user (the recluster input) — a storage cost between Approach 1 (full history) and Approach 2 (K centroids only). Two code paths (online + periodic) that must agree on the centroid format. The medoid snap requires the window to still hold the chosen embedding (it does, by construction).


Comparison

Criterion A: Offline medoid (Ward) B: Online centroids C: Hybrid (online + periodic medoid recluster)
Clustering cost O(n² log n) per user, batch O(K·dim) per engagement O(K·dim)/engagement + bounded periodic O(W·K·dim)
Needs offline/Spark tier Yes (disqualifying) No No (rides checkpoint thread)
Avoids averaging across interests Yes Yes (if τ tuned) Yes
Avoids averaging within a cluster Yes (medoid) No (synthetic centroid) Yes (periodic medoid snap)
Order-sensitivity / corrects misassignment Corrects (full recluster) No (greedy, permanent) Corrects periodically
Storage per user N×dim (full window) K×dim (≈ today) bounded W×dim window + K×dim
Reuses existing EMA/adaptive-LR Partially Fully Fully (online leg)
Write-path latency impact n/a (offline) Negligible (K cosine + 1 EMA) Negligible (online leg)
New magic constants linkage cutoff τ (split threshold), K cap τ, K cap, recluster window W, recluster cadence
Implementation surface High (Ward, but offline) Low Medium
Patent-conception novelty None (prior art) Medium High (no published equivalent)

Settling the Sub-Questions

1. Central fork — recommendation

Build Approach B (online-maintained centroids) now, with the data layout designed so Approach C (the periodic medoid recluster) drops in later without a format migration. Concretely: ship B; reserve the persistence fields and the interaction-window storage hooks that C needs (§5), but gate the periodic recluster behind a follow-up milestone.

Rationale:

  • A is disqualified by the in-process constraint. Ward hierarchical clustering has no home in an embeddable single-node DB with no batch tier. The prompt's framing is correct: PinnerSage's offline-medoid approach assumes a Spark cluster tidalDB does not have.
  • B is the cheapest correct online primitive and is already 80% built — it is N copies of the existing preference.rs EMA, one per centroid, plus a nearest-centroid assignment and a threshold split. It eliminates the across-interest averaging problem (the dominant failure) immediately.
  • Does online clustering reintroduce the averaging problem within a cluster? Yes — honestly, it does. A centroid is a mean. But this is a second-order failure (averaging within one coherent interest is far less harmful than averaging across three unrelated ones), and it is exactly what the deferred Approach-C medoid snap fixes. Shipping B first captures the large win; C captures the residual.
  • Cheapest correct online clustering primitive: threshold-based sequential k-means with a DP-means-style split (assign to nearest centroid if cosine ≥ τ, else open a new centroid up to K_max). This is strictly cheaper than online agglomerative (which needs a merge step) and needs no fixed-K commitment up front (unlike classic streaming k-means). Use cosine on unit vectors (consistent with the existing L2-normalized invariant).

2. Cluster count K — recommendation

  • Adaptive per user, capped. K grows by the DP-means split rule up to K_max = 10 (matches ARCHITECTURE.md's "3-10"). Most users will have 1-3 active clusters; only multi-modal users reach the cap. A fixed K wastes vectors for single-interest users and starves multi-modal ones.
  • Cold-start rule: below N = 5 positive interactions, fall back to the existing single adaptive-LR vector (the current code path, unchanged). This is exactly ann_for_tidaldb.md:112 ("for users with <5 interactions: simple weighted average is acceptable"). The single vector is the K=1 case; cold-start is therefore not a separate code path but the natural K=1 floor — the multi-vector store seeds its first centroid from the existing single vector when it crosses N.
  • When over the cap, the split rule must not open an 11th cluster; instead assign to the nearest existing centroid (the standard DP-means cap behavior). Do not evict — eviction loses an interest; merging is the periodic recluster's job (Approach C).

3. Cluster importance / time-decay — recommendation

  • Per-cluster importance = decayed engagement mass. Each centroid carries a scalar importance that is incremented on assignment and forward-decayed using tidalDB's existing signal-decay model rather than wall-clock recomputation. This is the composition point with the forward-decay signal system: store (importance_at_anchor, anchor_timestamp) per cluster and compute current_importance = importance_at_anchor × decay(now anchor) on read — the same O(1) forward-decay primitive the signal ledger already uses (docs/research/tidaldb_signal_ledger.md). This is the correct reconciliation of the ARCHITECTURE.md "maintained by the database as signals arrive with decay" claim, which today is unimplemented (the EMA has only implicit forgetting, no decay term).
  • Query-time cluster selection = importance sampling, deterministic top-M. Rather than PinnerSage's stochastic sample, query the top-M clusters by current importance where M = min(K_active, 3). Deterministic top-M is reproducible (tidalDB values deterministic queries elsewhere, e.g. the exploration shuffle in candidate_gen.rs:174) and 3 is PinnerSage's serve-time count. Make M a profile parameter so a profile can widen exploration.
  • This means a stale interest naturally falls out of the queried set as its importance decays below the 3rd-ranked cluster — without ever deleting it (a re-engagement re-boosts it).

4. Query-time mechanics — recommendation and latency budget

  • Issue M search calls (one per selected cluster centroid), where M ≤ 3. As shipped these run sequentially on the query thread (the loop is embarrassingly parallel and reserved for parallelization). For the for_you path these are unfiltered search (today's call); when a scope bitmap is present they become filtered_search — identical to the existing pipeline.rs:431 shape, just issued M times.
  • Merge + dedup: union the M result lists, dedup by entity id keeping the best (min) distance across clusters, then take top-k by that distance. This is a k-way merge of sorted lists, O(M·k). Critically, dedup-by-best-distance, not dedup-by-score — the ANN distance is the cluster-relevance signal; the engagement score is applied afterward by Stage 3, which must remain the single authority on final ordering.
  • Interaction with Stage 3: unchanged. Stage 3 re-ranks the merged pool by signals exactly as today. The only change compute_preference_boosts (personalization.rs:91) needs: compute the per-candidate cosine boost against the nearest of the user's clusters (max cosine over all the user's centroids, not only the M queried — a union candidate is then scored against its true best interest), not a single vector — otherwise a candidate retrieved via the "cars" cluster gets a near-zero boost measured against a "cooking"-dominated single vector.
  • Latency cost (quantitative): the ANN leg at 100k/1536-D, ef=64 is p99 1.22ms per query (candidate_gen.rs:81-89). M=3 issued sequentially (as shipped) is ~3.7ms p99 — well inside the 50ms budget; parallelizing would adds only parallel-merge overhead, not 3× latency. The merge of 3×k lists (k≈200) is sub-microsecond. Net: the multi-vector path costs <3ms additional p99 in the as-shipped serial case (<1.5ms if parallelized). The dominant budget consumer remains Stage 3 preference-boost recompute (per MEMORY: for_you ANN p99 ~24ms is Stage-3, not the ANN leg) — multi-vector does not move that needle except for the nearest-centroid cosine change, which is M cosines instead of 1 per candidate (negligible).
  • Throughput: at 1K queries/sec × M=3 = 3K ANN searches/sec; USearch sustains this comfortably at this corpus (per m12p3 grid-search). No new throughput risk.

5. Persistence — recommendation

Extend the Tag::Preference format, do not add a new tag (keeps the single prefix-scan restore). Today's value is [count:8 LE][dim:4 LE][f32*dim LE]. Make it self-describing and backward-compatible:

[version:1][n_clusters:1][dim:4 LE]
  repeated n_clusters times:
    [update_count:8 LE]            # per-cluster adaptive-LR count
    [importance_at_anchor:4 LE f32]
    [anchor_ts:8 LE]               # for forward-decay importance
    [f32 * dim LE]                 # the centroid (unit-normalized)
  • version byte distinguishes the new layout from the legacy one. Restore must read legacy rows (no version byte, starts with an 8-byte count then a 4-byte dim ≤ some sane max) and load them as a single K=1 cluster — a zero-migration upgrade. A legacy detector: if bytes[0] is a plausible version (e.g. 2) AND the implied length matches, parse new; else parse legacy. As built: the 1-byte version sentinel CAN collide with a legacy little-endian count's low byte (e.g. update_count == 2), so the discriminator is structural, not byte-value-based — a row is parsed as multi-cluster only if bytes[0] == FORMAT_VERSION AND it decodes into ≥1 dimension-correct cluster; otherwise it falls through to the legacy decoder. This rescues a colliding legacy row instead of dropping it. (See FORMAT_VERSION in entities/multi_preference.rs.)
  • For Approach C (deferred): a second tag Tag::PreferenceWindow (discriminant 0x1A0x170x19 are already taken) stores the bounded recent interaction-embedding window per user, written ring-buffer style. This tag is now reserved in keys.rs (not yet populated); adding the window when C ships is an additive row, never a rewrite of existing Tag::Preference rows.
  • The atomic-batch swap (checkpoint, lines 270-296) and the load-boundary re-normalization + NaN-neutralization (restore, lines 333-353) carry over per-cluster unchanged — re-normalize every centroid, drop torn clusters individually rather than the whole user.

6. Adaptive learning rate — recommendation

Preserve it per-cluster. Each centroid keeps its own update_count and computes alpha = base / (1 + ln_1p(count)) exactly as today — a cluster stabilizes as it accumulates engagements, which is the correct behavior (a well-established interest should resist drift). Do not replace the per-cluster EMA with re-clustering in Approach B — there is no reclustering in B. When Approach C's periodic recluster runs, it resets the per-cluster counts for any cluster whose membership it recomputes (a reclustered centroid is effectively new), which is the only point where "re-clustering replaces the LR." So: B preserves per-cluster adaptive LR; C's periodic pass resets the LR of reclustered clusters. This is internally consistent — the LR tracks "how settled is this centroid," and a recluster un-settles it.

7. Reduced-to-practice vs. conception boundary

Patent filing distinguishes what the shipped design actually implements (reduced to practice) from what is described but not built (conception only). Be precise — claiming reduction to practice for unbuilt aspirational features is a filing defect.

Element Status in the recommended design RTP or Conception
Single adaptive-LR EMA preference vector Shipped today (preference.rs) Reduced to practice (prior, in-tree)
Cold-start fallback to K=1 single vector below N interactions Built in Approach B Reduced to practice (on ship)
Online sequential-k-means + DP-means threshold split, K_max cap Built in Approach B Reduced to practice (on ship)
Per-cluster adaptive learning rate Built in Approach B Reduced to practice (on ship)
Forward-decayed per-cluster importance composed with the signal-decay model Built in Approach B Reduced to practice (on ship)
Deterministic top-M importance-ranked cluster selection at query time Built in Approach B Reduced to practice (on ship)
M sequential ANN queries + dedup-by-best-distance merge feeding Stage 3 (parallelization reserved) Built in Approach B Reduced to practice (on ship)
Max-cosine-over-all-clusters preference boost in Stage 3 Built in Approach B Reduced to practice (on ship)
Version-tagged backward-compatible Tag::Preference multi-cluster layout Built in Approach B Reduced to practice (on ship)
Periodic in-process medoid recluster on the checkpoint thread (Approach C) Designed, not built Conception only
Medoid snap recovering an actual-interaction representative Designed, not built Conception only
Bounded per-user interaction-embedding window (Tag::PreferenceWindow) Reserved, not populated Conception only
LR reset on recluster Designed for C, not built Conception only
Offline Ward hierarchical clustering (Approach A) Surveyed, rejected Neither (not pursued)

The novel-conception kernel for a filing is the combination: online DP-means user clustering + forward-decay-composed cluster importance + deterministic top-M multi-ANN merge, with a deferred in-process medoid-recluster correction — no surveyed production system (PinnerSage included, which is offline+medoid) combines online maintenance with a periodic in-process medoid correction on an embedded DB's checkpoint thread.


Recommendation (one design, chosen)

Ship Approach B. Implement multi-vector user preference as online sequential-k-means with a DP-means threshold split, capped at K_max=10, with:

  1. Cold start: below N=5 positive interactions, use the existing single adaptive-LR vector (the K=1 floor; seed the first centroid from it on crossover).
  2. Assignment: on each positive engagement, find the nearest centroid by cosine; if cosine ≥ τ blend via the existing per-cluster adaptive-LR EMA, else open a new centroid (up to K_max; over the cap, assign to nearest — never evict).
  3. Importance: per-cluster (importance_at_anchor, anchor_ts), forward-decayed with tidalDB's existing signal-decay primitive on read.
  4. Query (for_you): select top-M (M=min(K_active,3)) clusters by current importance; issue M sequential search/filtered_search calls (parallelizable, not yet parallelized); merge with dedup-by-best-distance; feed the unified pool to Stage 3 unchanged.
  5. Stage 3 boost: compute_preference_boosts uses max cosine over all the user's cluster centroids (a union candidate is scored against its true best interest).
  6. Persistence: version-tagged multi-cluster Tag::Preference value, backward- compatible with legacy K=1 rows (structural discriminator, no count-collision drop); Tag::PreferenceWindow (0x1A) reserved in keys.rs for Approach C.
  7. Design the centroid + window storage so Approach C (periodic in-process medoid recluster) drops in with no format migration — but do not build C in this milestone.

τ, K_max, N, M, and the decay half-life are benchmark-derived constants, not guesses (see Open Questions). Default τ around 0.5-0.6 cosine for OpenAI-1536-D embeddings as a starting point for the grid search, not a shipped value.


Doc / Roadmap Corrections Required

These are factual reconciliations the survey surfaced; @tidal-engineer should land them alongside (or before) the implementation:

  1. ARCHITECTURE.md:252-254 currently asserts, as if shipped, that each user's preference "is represented as 3-10 interest cluster centroids (PinnerSage-style), maintained by the database as signals arrive." This is not implemented — the code maintains exactly one EMA vector with no decay term. Correct the doc to either (a) describe the single-vector reality with a forward-pointer to this research, or (b) keep the multi-vector description but explicitly mark it as the planned design (this doc) rather than current behavior. Also fix the implicit "with decay" claim: today's blend has no decay; the decay composition is the new work in §3 here.

  2. ROADMAP.md:587 claims Phase 1 delivered "the adaptive filtered search planner." The delivered artifact is the selectivity-based query planner (AC at line 594: <2% → pre-filter+brute-force; 2-100% → filtered_search). That is real and shipped. But "adaptive" here risks being read as the adaptive preference/ multi-vector planner, which does not exist. Reword to "selectivity-based filtered-search planner" to remove the conflation, and do not let this line stand as evidence that multi-vector preference is delivered.

  3. ann_for_tidaldb.md:106-116 is consistent with this doc (PinnerSage multi-query merge) but says "pre-compute user interest clusters offline" — which contradicts the embeddable, no-batch-tier constraint. Add a cross-reference from ann_for_tidaldb.md to this doc noting that the offline framing is superseded by the online Approach B for tidalDB's in-process model.


Open Questions (benchmarks tidalDB must run)

  • τ (split threshold): the single most important constant. Grid-search τ ∈ {0.4..0.7} on a real multi-modal interaction trace; measure cluster count distribution and feed-relevance vs. ground truth. Too loose → averaging within cluster returns; too tight → K_max saturates and fragments one interest.
  • Does online-only (B) measurably beat the single vector on a multi-modal cohort, and by how much does the deferred medoid recluster (C) add on top? If C's lift over B is marginal, C may never need to ship — that decision must be data-driven, not assumed.
  • Order-sensitivity magnitude: construct adversarial interaction orderings; measure how often greedy online assignment produces a materially worse clustering than a batch recluster of the same set. This quantifies the value of Approach C.
  • M (clusters queried) sweep: M ∈ {2,3,5} — relevance lift vs. the linear ANN-leg latency cost. Confirm the <3ms p99 estimate holds at 1M/1536-D (currently k3s-pending, per the m12 memory entries).
  • Dedup-by-best-distance vs. round-robin interleave for the merge: which yields better post-Stage-3 diversity? Interleaving may better surface minority interests.
  • Importance decay half-life: how fast should a stale interest fall out of the top-M? Tie to the signal-decay half-life or make it independent? Needs a retention/ freshness A/B.
  • Storage delta for Approach C's interaction window at scale (W=256 × 1536 × 4B ≈ 1.5MB/user) — validate this is acceptable for the active-user set before committing to C's window.

Sources

  • Pal et al., "PinnerSage: Multi-Modal User Embedding Framework for Recommendations at Pinterest," KDD 2020. (Ward hierarchical clustering, medoid representation, importance sampling, averaging-failure example.)
  • Kulis & Jordan, "Revisiting k-means: New Algorithms via Bayesian Nonparametrics," ICML 2012. (DP-means — threshold-based adaptive cluster count, the online split rule.)
  • MacQueen, "Some methods for classification and analysis of multivariate observations," 1967. (Sequential/online k-means update.)
  • docs/research/ann_for_tidaldb.md §"Multi-vector retrieval needs no special indexing" (lines 102-135). (USearch multi-query merge endorsement; segment pattern.)
  • docs/research/tidaldb_signal_ledger.md. (Forward-decay O(1) primitive composed with cluster importance.)
  • In-tree code verified 2026-06: tidal/src/entities/preference.rs, tidal/src/db/signals.rs:547,765-816, tidal/src/db/query_ops.rs:99-116, tidal/src/query/executor/candidate_gen.rs:64-130, tidal/src/query/executor/personalization.rs:91-123, tidal/src/query/search/executor/pipeline.rs:429-440, tidal/src/storage/keys.rs:71, ARCHITECTURE.md:252-254, ROADMAP.md:587-598.