# 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>`, 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 `0x1A` — `0x17`–`0x19` 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`.