Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
`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.
156 lines
7.8 KiB
YAML
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
|