Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions docs/RANKING_SCORE_PIPELINE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# From Phoenix probabilities to a final score: the ranking pipeline in detail

The [README's Scoring and Ranking section](../README.md#scoring-and-ranking) describes the four
stages a candidate goes through after Phoenix predicts its action probabilities: the base
weighted score, author cold-start, author diversity, and the out-of-network discount. This doc
fills in the formulas, default values, and code references behind each stage, with worked
examples, so you don't have to reconstruct them from
[`home-mixer/scorers/ranking_scorer.rs`](../home-mixer/scorers/ranking_scorer.rs) and
[`home-mixer/scorers/author_cold_start.rs`](../home-mixer/scorers/author_cold_start.rs) yourself.

All four stages run in that order, each one operating on the output of the last:

```
base weighted score → author cold-start boost → author diversity decay → OON discount
```

## Stage 1: the base weighted score

Phoenix outputs a probability (or, for a few heads like dwell time, a continuous value) per
action per candidate. `RankingScorer` turns those into one number per candidate in three steps.

**`apply`** ([`ranking_scorer.rs:447`](../home-mixer/scorers/ranking_scorer.rs#L447)) is the
atomic operation — it multiplies one action's probability by its configured weight:

```rust
fn apply(score: Option<f64>, weight: f64) -> f64 {
score.unwrap_or(0.0) * weight
}
```

**`compute_weighted_parts`** ([`ranking_scorer.rs:460`](../home-mixer/scorers/ranking_scorer.rs#L460))
calls `apply` for each of the ~26 action terms (favorite, reply, retweet, report, block, mute,
dwell, etc.) using the weights in
[`home-mixer/params/param.rs`](../home-mixer/params/param.rs), then splits the results into two
running totals: `pos`, the sum of every non-negative term, and `neg`, the summed magnitude of
every negative term (not-interested, block, mute, report, not-dwelled all carry negative
weights).

**`offset_score`** ([`ranking_scorer.rs:554`](../home-mixer/scorers/ranking_scorer.rs#L554)) takes
`combined_score = pos - neg` and remaps it:

```rust
pub(crate) fn offset_score(combined_score: f64, w: &ScoringWeights) -> f64 {
if w.total_sum == 0.0 {
combined_score.max(0.0)
} else if combined_score < 0.0 {
(combined_score + w.negative_sum) / w.total_sum * NEGATIVE_SCORES_OFFSET
} else {
combined_score + NEGATIVE_SCORES_OFFSET
}
}
```

`NEGATIVE_SCORES_OFFSET` is `0.001`
([`home-mixer/params/config.rs:40`](../home-mixer/params/config.rs#L40)). Net-negative candidates
get squeezed into `[0, 0.001)`; net-non-negative candidates get shifted up by a flat `0.001`. The
effect: every candidate whose predicted actions are net-good outranks every candidate whose
predicted actions are net-bad, while relative order is preserved within each group.

**Worked example.** Using the default weights (`FavoriteWeight = 0.5`,
`ReplyWeight = 5.0`, `ReportWeight = -234.0`) and a candidate with
`P(favorite) = 0.10`, `P(reply) = 0.02`, `P(report) = 0.001`:

```
favorite term = 0.10 × 0.5 = 0.050
reply term = 0.02 × 5.0 = 0.100
report term = 0.001 × -234 = -0.234

pos = 0.150, neg = 0.234
combined_score = 0.150 - 0.234 = -0.084 (net negative)
```

That -234 weight looks disproportionate until you remember it's multiplying a *probability*, not
a raw count — a 0.1% predicted report chance still outweighs a 10% predicted favorite chance,
which is the intended behavior: a small chance of a severe negative action should dominate a
larger chance of a mild positive one.

## Stage 2: author cold-start

Runs in [`author_cold_start.rs`](../home-mixer/scorers/author_cold_start.rs), gated by
`EnableViewerColdStart`. New/small accounts get almost no engagement signal, so Phoenix's
probability estimates for their posts are naturally weak — which keeps them from ever being
shown, which keeps them from ever collecting signal. This stage breaks that loop by forcing one