tidaldb/k8s/discover/schema.yaml
jordan a588f01f63
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ranking: fix two BLOCKERs in the age-aware sorts, and stop trusting created_at units
Three parallel reviews of 6385425 found two BLOCKERs I introduced, one CRITICAL,
and a CHANGELOG that named profiles that do not exist. All verified before fixing.

BLOCKER 1 -- an undated row ranked #1 instead of last. `Sort::New` mapped a
missing age to 0.0, but every DATED candidate scores `-age_hours`, i.e. <= 0.0.
So 0.0 was the MAXIMUM of the scale, not a neutral value: a row with no readable
`created_at` outranked the genuinely newest item and `normalize` reported its
score as 1.0.

The codebase had already ruled on this and gone the other way. `score_shortest`,
`score_longest` and `score_date_saved` all use the NEG_INFINITY sentinel, and
`helpers.rs` carries a regression test named for the exact anomaly --
`normalize_neg_inf_sentinel_on_negated_scale_folds_to_bottom` -- because
`Shortest` hit it first: "a pre-clamped 0.0 floated above them and the
last-ranked (missing) item reported the highest score 1.0". I reintroduced it on
a new negated scale.

Three reachable data classes, none hypothetical: a legacy row written before
`created_at` was materialized (`state_rebuild.rs` documents the class), an empty
map from `deserialize_metadata` on a short or corrupt row, and an entity dropped
by the `.ok().flatten()` in the metadata map builder -- which means a TRANSIENT
STORAGE READ ERROR could promote an item to the top of the feed.

Now the two `None` causes are distinguished. Map absent entirely -> 0.0, the whole
set ties, unchanged. Map present but this entity has no usable `created_at` ->
NEG_INFINITY, sorts last. A future-dated timestamp still clamps to age 0, because
that is a real value that legitimately means "newest"; an absent one carries no
recency claim at all.

Same conflation in `score_hot`, and worse there because `Sort::Hot` now reports
`needs_item_metadata`, so the map is loaded on essentially every Hot query and the
per-entity branch is the LIVE one. `unwrap_or(DEFAULT_HOT_AGE_HOURS)` handed a
single undated row the freshest divisor in the set: against a year-old cohort at
the builtin gravity 1.8 the ratio `(8762/26)^1.8` is ~3e4, so a corrupt row with
two views outranked correctly-dated items with tens of thousands. Across all four
Hot builtins. Now scored at Hot's floor.

BLOCKER 2 -- my own load-shedding "fix" INVERTED the survivor set. `truncate_to_
newest` ranked candidates against the newest ids of the WHOLE universe, but
`scan_candidates` iterates the universe bitmap ASCENDING and breaks at
`(limit * multiplier).max(200)`, so `candidates` is the LOW-id prefix. On a
catalog whose ids are assigned in creation order -- which is exactly what
`metadata_with_created_at`'s `Timestamp::now()` default produces -- the global
newest are the HIGH ids. Above roughly `max_candidates + 4*cap` items the two sets
stop intersecting, every candidate tied at `usize::MAX`, the stable sort became a
NO-OP, and `truncate(cap)` kept the OLDEST candidates. The `select_nth_unstable_by`
I replaced kept the newest available. Strictly worse than the bug I set out to fix,
and only visible when degraded.

The key is now composite -- recency rank, then DESCENDING id for anything the
oversample did not cover -- so where index and candidate window overlap the
survivors are genuinely newest, and where they do not it degrades to the documented
pre-existing approximation instead of inverting. Back to `select_nth_unstable_by_
key`: this path runs ONLY when the load shedder has already decided the node cannot
afford the work, so it must stay a linear partition, and only membership of
`[0, cap)` matters because Stage 3 re-orders the survivors anyway.

Mutation-proven, and the numbers show why the old test was blind: rank-only key
returns `EntityId(100)` where the composite key returns `EntityId(200)`, and the
pre-existing 150-item test PASSES under that same mutation -- its fixture had the
oversample covering the whole universe AND the newest items at the low ids, the one
configuration where the defect cannot appear. New test uses a 3000-item
time-ordered catalog; limit=50 does NOT exercise it (max_candidates == cap == 200
so the guard skips), limit=25 does.

Three of my ordering fixtures pinned nothing. `finalize`'s tie-break 2 is ASCENDING
entity id, and I had made newest-first coincide with it, so any mutation that
merely TIED the set still produced the asserted vector. The gate mutation proves
it: reverting `needs_metadata_for_sort` to its old four-variant form yielded
`left: [1, 2, 3, 4, 5]` -- literally the old fixture's expectation. No test in the
suite failed if the retrieve executor stopped consulting `Sort::needs_item_metadata`,
which is the exact drift this work exists to prevent. Every ordering fixture is now
non-monotonic in id, so it disagrees with BOTH descending id (the old proxy) and
ascending id (the tie collapse).

CHANGELOG named profiles that do not exist. `recent_uploads` -- zero hits
repo-wide; I invented it. `following` is Sort::New, not Hot. `brief` is Hot, not
New. `related` (Hot{1.2}) and `chronological` (New) were omitted entirely. The real
blast radius is SEVEN profiles, not the four-plus-three I claimed, and five of them
carry a diversity block so they already loaded item metadata and change behaviour
the instant this image rolls. Replaced with a verified table. Also cited the spec
conformance this brings: docs/specs/09-ranking-scoring.md:1214 already specified
`Sort::New` as "created_at DESC".

created_at units are now trusted with a warning instead of silently. A
seconds-unit value parses as u64 and was stored verbatim; `read_age_hours` divides
by nanos-per-hour, so `1700000000` reads as 56 years old. `Sort::New` then scores
-496731 against boost sums in single digits -- re-entering through DATA the exact
"recency annihilates every boost" defect this work removed -- and Hot buries the
item by a factor of 5.1e7. Pre-change both sorts ignored the value, so it was
inert; this work made it live. The repo's OWN fixtures made that mistake in three
places, which is the proof it is the natural one.

`metadata_with_created_at` now warns when the value is too small to be nanoseconds,
logging entity id, value, age_hours and age_years so an operator can act. It does
NOT rewrite the value -- guessing the unit would corrupt what the `created_at`
range index already reads as nanoseconds -- and does NOT reject the write, which
would break an API that currently accepts it. Threshold 6e17 ns (1989): nanosecond
timestamps after 1990 exceed 6.31e17 while seconds/millis/micros for any plausible
date stay under 1e16, so the ranges cannot overlap. VERIFIED on a live server:
seconds, millis and micros each warn with age_years=56; a real nanosecond value is
silent. The nanosecond contract is now documented on the public DTO and propagates
to the OpenAPI schema, where it was invisible before.

Stale docs corrected: two published blog posts and two claim-verification ledgers
were certifying "entity recency (higher ID = newer)"; the e2e fixture contract
justified an interleaving decision with reasoning that is now false (its conclusion
still holds, for a different reason); and k8s/discover/schema.yaml's "NO sort:"
rationale cited behaviour this work removed, so it is now marked PENDING
RE-MEASUREMENT with the three specific measurements named rather than left reading
as justified.

Full lib suite 2133 passed. Clippy 66 vs 66 at baseline, zero added, zero errors.
Fast integration suites all green. Real-server e2e re-verified: `new` and `hot`
both return newest-first, `new`'s scores now evenly spaced across evenly spaced
ages.
2026-08-31 21:31:31 -06:00

188 lines
9.9 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:` — DECISION UNCHANGED, RATIONALE REWRITTEN. The score is
# `sort_base + boost_sum`, then min-max normalized
# (tidal/src/ranking/executor/{mod,helpers}.rs), so what decides whether
# signals matter is the SPREAD of sort_base across the candidate set
# relative to the spread of boost_sum (single digits here).
#
# The two bullets below previously argued from the PRE-CHANGE formulas and
# cited `tidal/src/query/executor/scoring.rs`, a path that does not exist
# (the real file is `tidal/src/ranking/executor/scoring.rs`). Both sorts are
# now age-aware; here is what they actually do:
#
# hot: IS age-aware now. `score_hot` reads this item's real `created_at`
# out of the item-metadata map and scores
# `log10(max(views,1)) / (age_hours + 2)^gravity`
# (`score_hot`, tidal/src/ranking/executor/scoring.rs). The old claim
# that it "treats EVERY candidate as exactly 24 hours old" and
# "contributes no recency whatsoever" is VOID.
# What still holds: the numerator is exactly 0.0 at 0 OR 1 views, so
# on the zero-signal rows that make up most of this corpus the age
# divisor has nothing to divide — every such candidate scores 0.0 and
# ties. `hot` would add recency only from an item's second view on.
# new: base is negated age in HOURS now, not `entity_id as f64`. The old
# "~1_821 on this corpus" figure is VOID.
# It is NOT automatically boost-comparable, though: the spread of
# `-age_hours` equals the corpus's age span expressed in hours, so a
# corpus accumulated over a year spans ~8_766 — the same order of
# magnitude as the entity-id spread it replaced, and still ~3 orders
# above a single-digit boost_sum. The domination concern SURVIVES the
# change; only its derivation moved from item COUNT to corpus AGE
# SPAN. On a corpus spanning under ~a day it would genuinely be
# boost-comparable, which is exactly why this needs measuring rather
# than asserting.
#
# 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").
#
# STATUS: PENDING RE-MEASUREMENT. Keeping `NO sort:` is the safe hold, but
# it is no longer JUSTIFIED by the reasoning above — the reasoning it rested
# on was about the old formulas. Before adding a sort back, measure on a
# production snapshot:
# 1. corpus age span in hours (max created_at - min created_at). That is
# the `new` sort_base spread.
# 2. observed boost_sum spread (p1..p99) over the same candidate pool.
# 3. fraction of the pool with >= 2 `view` signals. That is the fraction
# `hot` can differentiate at all.
# Add `new` only if (1) is within roughly one order of magnitude of (2);
# add `hot` only if (3) is a clear majority. Otherwise the sort still either
# annihilates the signals or ties the set.
#
# Consequence, accepted and handled UPSTREAM — UNCHANGED by the age-aware
# work, because with no sort no age term is read at all: 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