tidaldb/k8s/discover/schema.yaml
jordan 6385425a92
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ranking: make Hot and New age-aware; fix the same gap in three more places
`score_hot` hardcoded `age_hours = 24.0`, so the divisor in
`log10(max(views,1)) / (age_hours + 2)^gravity` was constant across the candidate
set and `Sort::Hot` reduced EXACTLY to `log10(max(views, 1))` -- a view-count
ranking wearing a recency sort's name. Four built-in profiles use it (`hot`,
`for_you`, `following`, `brief`); anyone tuning `gravity` was tuning a no-op.

The in-code comment justified this by saying a per-entity `created_at` lookup
needs an `EntityId -> created_at_ns` reverse map that "is not built". That was
stale, and it was the load-bearing claim: `created_at` has been materialized INTO
item metadata on every write since `Items::metadata_with_created_at`, the executor
has held an `EntityId -> metadata` map since M6p3, and the replication record
carries the materialized map so replicas cannot diverge. No index, storage change,
schema change or migration -- the scorer reads the map it already had, exactly the
way `read_duration` does three lines away.

`Sort::New` used `entity_id as f64`. Wrong twice: it assumed IDs are assigned in
creation order, and it used the ID's MAGNITUDE as the base score, so on a catalog
of N items the sort contributed ~N against a boost sum in single digits. Recency
did not participate in the ranking, it annihilated every boost. Now negated age in
hours -- same ordering, boost-comparable scale.

Three more instances of the same defect class, found by auditing rather than
assuming the report was complete:

1. Both age sorts were missing from `needs_metadata_for_sort`, so a profile with
   no session and no diversity never loaded the map the fix depends on.
2. Every metadata sort was DEAD on the SEARCH path. Its metadata pre-load was
   gated on `session_context.is_some()` and never consulted `profile.sort`, AND
   the `ProfileExecutor` it built never had `with_item_metadata` called at all --
   the map it did compute went only to the keyword-hint argument, which the sort
   scorers do not read. `shortest`/`longest` scored NEG_INFINITY and the
   alphabetical sorts the missing-title sentinel, for every candidate, silently.
3. Under `ReducedCandidates` load the candidate cap kept the highest entity IDs,
   correct only while `Sort::New` meant "highest ID". Left alone it would discard
   the genuinely newest items BEFORE scoring -- wrong only when degraded, the
   hardest case to notice. Now keyed off the `created_at` index via the new
   `RangeIndex::top_n_descending`.

The decision "which sorts read item metadata" now lives on `Sort` itself as an
exhaustive match. It was a `matches!` in one executor while a second executor had
its own different copy, which is precisely how a metadata-reading sort came to be
omitted from both.

MEASURED, not inferred:
- Real server, 10 items, equal views, ages 2-20 days: before every score was 0.5
  (all-equal set folded to the normalizer's midpoint) and the feed returned
  oldest-first forever; after, 1.0 -> 0.0 strictly descending, newest first.
- `new` with zero signals returns the exact REVERSE of candidate-scan order.
- `alphabetical_asc`, `shortest`, `longest` verified end to end with title and
  duration order both opposing entity id.
- Metadata point-read cost at 2,000 candidates (the ceiling: `scan_candidates`
  caps at `max(limit*10, 200)` and `limit > 500` is rejected): 7.25ms, 3.6us per
  candidate. Guarded at 250ms.

THE BUG REPORT'S CENTRAL PROMISE IS FALSE and the changelog says so. §7 claimed
this fix lets a zero-signal corpus rank newest-first so a consumer could delete
its workaround. It arithmetically cannot: the numerator `log10(max(views,1))` is
exactly 0.0 for 0 OR 1 views, so the age divisor has nothing to scale and every
candidate still ties -- confirmed on the live server, all ten scores 0.5.
Age-awareness begins at the second view. Fixing cold-start needs recency to be
ADDITIVE rather than a pure divisor, which reorders every existing Hot consumer,
so it is a separate decision. `sort_hot_zero_view_corpus_still_ties_regardless_of_
age` pins the limit so it cannot be rediscovered by accident.

Three existing tests asserted the old entity-ID behaviour. Inverted to assert real
recency, not loosened -- and each fixture now makes id order and creation order
DISAGREE, because an ordering assertion where the two candidate orderings agree is
satisfied by the defect too. Three of my own new tests were vacuous for exactly
that reason and were caught by mutation-testing; one was also flaky (it passed in
a 12-test run and failed run alone, because retrieval order for exactly-tied
vectors is not deterministic). Every new assertion is mutation-proven against the
implementation it replaces.

Full lib suite 2130 passed. Clippy 66 warnings vs 66 at baseline, zero added.
2026-08-31 19:58:01 -06:00

156 lines
7.8 KiB
YAML

# The redgifs POSTS corpus schema, for the STANDALONE `tidaldb-discover`
# instance. Distinct from k8s/cluster/schema-configmap.yaml, which is the
# companions corpus and MUST NOT be changed by this feature.
#
# Read ONCE at boot: a ConfigMap edit needs a StatefulSet rollout. Only
# backward-compatible changes are safe against a populated data dir
# (docs/ops/recovery.md §4):
# - adding a signal SAFE
# - changing an existing signal's half_life FORBIDDEN (define a new signal)
# - changing an embedding slot's dimensions requires deleting /data
#
# The byte-identical dev copy is thepeach's tidaldb_config/discover-schema.yaml.
# `diff` them whenever either changes; nothing else guards the fork.
signals:
# Impression. Emitted on FOCUS, never on serve -- serving-time emission would
# mark every item in the page `seen` and burn the corpus in one read. Every
# context signal (one carrying a user_id) also marks the item seen for that
# user, which is what advances the feed page to page.
- name: view
entity: item
decay:
exponential:
half_life_seconds: 604800 # 7 days -- matches the companions schema
windows: [one_hour, twenty_four_hours, seven_days]
velocity: true
positive_engagement: true
# The heart of the ranking. Server-side tee off the existing post-reaction
# path, so it cannot be forged by a client and cannot drift from the reaction
# the user actually sees.
- name: like
entity: item
decay:
exponential:
half_life_seconds: 1209600 # 14 days -- matches the companions schema
windows: [twenty_four_hours, seven_days, thirty_days, all_time]
velocity: false
positive_engagement: true
# Graded engagement depth in [0,1]. The signal WEIGHT *is* the ratio -- that
# is the engine's documented contract for this exact signal name
# (tidal/src/db/signals.rs:727-735), not a convention invented here.
# Video: currentTime/duration. Image: dwell/3s, capped at 1.0.
#
# NOT present in any other shipped schema in either repo. Declared here for
# the first time; a POST /signals with this name 400s against a schema that
# omits it, inside a fire-and-forget path where nobody would see the 400.
- name: completion
entity: item
decay:
exponential:
half_life_seconds: 1209600 # 14 days, matching `like`
windows: [twenty_four_hours, seven_days, all_time]
velocity: false
positive_engagement: true
text_fields:
# `title` carries the post caption, `category` the post kind. Declared so the
# metadata the worker writes is indexed rather than inert. Nothing queries
# /search on this instance yet.
- name: title
kind: text
- name: category
kind: keyword
embedding_slots:
# DECLARED, NEVER WRITTEN in v1: 1,819 of 1,821 posts have an empty caption,
# so an embedding would give one companion's 127 posts an identical vector.
#
# Both the NAME and the WIDTH are permanent once data lands, and a future ANN
# profile must name this exact slot -- so both are fixed now to match
# thepeach's embedder (text-embedding-3-small, 1536-D) and the name every
# other peach schema already uses. The tidalDB standalone image bakes a 128-D
# default; this file overrides it.
- name: content_vector
entity: item
dimensions: 1536
profiles:
# The v1 discover ranking. Deliberately NOT built-in `for_you`: that profile
# hard-codes CandidateStrategy::Ann{slot:"content"}
# (tidal/src/ranking/builtins.rs:305-311), which (a) names a slot this schema
# does not declare -- every peach schema calls it `content_vector` -- and
# (b) with no vectors written would fall through to the scan fallback and push
# a warning on EVERY read, which thepeach's CLAUDE.md:57 forbids.
#
# `scan` reaches the same code path with no warning.
- name: rg_discover
version: 1
# Takes max(limit * M, 200) ids in ASCENDING ENTITY-ID ORDER, BEFORE
# seen-exclusion, where M is 10 with a user_id and 4 without
# (tidal/src/query/executor/candidate_gen.rs:22-44). That ordering is why the
# caller requests limit=200 rather than a page-size limit: a 2,000-wide pool
# over a ~1,821-post corpus. See the endpoint's DISCOVER_TIDAL_FETCH const.
candidate_strategy: scan
# NO `sort:` — VERIFIED DELIBERATE. Neither built-in sort is usable here, and
# the score is `sort_base + boost_sum` then min-max normalized
# (tidal/src/ranking/executor/{mod,helpers}.rs), so a sort's magnitude decides
# whether signals matter at all:
#
# hot: NOT age-aware. `score_hot` treats EVERY candidate as exactly 24
# hours old and ranks by `view` COUNT
# (tidal/src/ranking/executor/scoring.rs:263-271, which says so in a
# comment). It reads no created_at, so it adds a term the `view` boost
# below already covers, and contributes no recency whatsoever.
# new: base score is `entity_id as f64`
# (tidal/src/ranking/executor/scoring.rs:122-133), i.e. ~1_821 on this
# corpus, against a boost_sum of single digits. Recency would dominate
# by three orders of magnitude and the signals would be decorative.
#
# With no sort the ranking is boost_sum alone — measured: 5 likes outranks 3
# outranks 1, with the remainder in scan order. That IS the intent ("ranks on
# signals alone"); do not add a sort back without re-measuring both effects.
#
# Consequence, accepted and handled UPSTREAM: on a zero-signal corpus every
# score ties at the normalizer's neutral 0.5 and the order is scan order,
# i.e. OLDEST-FIRST (the sweep allocates ids oldest-first). That is why the
# endpoint routes a viewer below the impression threshold to its `ranked`
# arm instead of reading this profile unpersonalized — an arbitrary order is
# strictly worse than the recency ordering that already exists.
# NOTE: `window` is INERT for `agg: decay_score`. The executor calls
# read_decay_score_at(entity_id, signal, 0, now) -- window index hard-coded
# to 0 (tidal/src/query/executor/helpers.rs:112-114). So `all_time` below is
# not a claim that each signal declares an all_time window; built-in
# `for_you` pairs view+decay_score+all_time against a schema whose `view`
# has no all_time window, for the same reason. Do NOT "fix" this by adding
# all_time to `view.windows` (a dead index) or by switching agg.
boosts:
- signal: view
agg: decay_score
window: all_time
weight: 1.0
- signal: like
agg: decay_score
window: all_time
weight: 2.0
- signal: completion
agg: decay_score
window: all_time
weight: 2.5
diversity:
# 6, not built-in for_you's 2. One companion (`Nettie`) is 127 of 1,821
# posts and the top 10 companions are 45% of the corpus, so 2 would
# systematically starve the 1-post long tail; 6 still keeps any one
# companion off two-thirds of a 24-item page.
max_per_creator: 6
# `format_mix_max_fraction` is DELIBERATELY ABSENT. It caps how much of a
# page one `format` metadata value may occupy, but (a) this corpus is
# 1,727 images to 94 videos (measured), so capping the dominant format
# starves pages rather than diversifying them, and (b) the worker writes
# the post kind to `category`, not `format`, so the key would govern a
# field nothing populates. Two independent reasons; do not add it back
# without changing both.
# Exploration items are APPENDED at score 0.0
# (tidal/src/query/executor/candidate_gen.rs:215-273) — appended, so they
# rank last, not first. This does not solve cold start (see the no-sort note
# above); it keeps a zero-signal post reachable once the head of the ranking
# is signal-dominated, which is otherwise a closed loop: only signalled posts
# rank, and only ranked posts get signalled.
exploration: 0.1