Team Rankings
How /rank gets its rows. The table reads the Glicko-2 rankings computed
by the debate-rankings package; the older /api/leaderboard endpoint (TOC
bid list + DebateDrills Elo) is documented below but no longer feeds the
panel.
- Route:
/rank. - Package:
debate-rankings(the ratings and the TypeScript loader),debate-videos(the panel and the load hook).
The user-facing Team Rankings feature page covers what the table shows.
debate-rankings
A Python pipeline (src/main.py, a clone of
github.com/debate/debate-rankings)
replays each tournament in tournaments/<format>/ through a Glicko-2 model —
rounds at a config's majors twice — and writes three CSVs per dataset to
output/:
| File | Contents |
|---|---|
<id>_full_rankings.csv | Rank, School, Name, Adjusted Rating (μ − 2φ), Deviation (φ), Matches, Rating (μ), Hash, and aff/neg prelim + elim win rates |
<id>_field_statistics.csv | One row of field-wide aff/neg win rates |
<id>_rankings.csv | A trimmed copy (no Deviation/Matches/Hash); unused by the site |
Datasets are hspf, hsld, hsld_sepoct, hscx and cpd. A win rate
with no rounds on that side is written as NaN — an empty cell — and parsed
to null.
The package's TypeScript entry (js/index.ts) exports RANKING_DATASETS
(labels plus each config's tournaments / majors) and
loadRankingDataset(id). The CSVs are pulled in with Vite ?raw dynamic
imports, so each dataset is its own chunk, nothing is copied, and re-running
src/main.py is the whole refresh. There is no server round trip: the data
ships with the client bundle.
The panel
DIVISION_CONFIG (debate-videos/src/panels/leaderboard/leaderboardUtils.ts)
maps each division to its datasets — VPF → hspf, VLD → hsld +
hsld_sepoct, VCX → hscx, NDT → cpd. A division with two datasets gets a
scope switcher. useLeaderboardData loads the selected dataset (and still
fetches /history for the champion banner); RankingsTable renders one
sortable column per CSV field except the raw Rating and Deviation (only the
Adjusted Rating is shown, rounded to a whole number), and RankingsFieldSummary the field
statistics and tournament list. The table is shown for the current season
year only, since the datasets carry no season.
The legacy /api/leaderboard endpoint
Data from /api/leaderboard?division=<VPF|VLD|VCX>&year=<YYYY>, served by
debate-data-sync
(the scrapers and the merge) and apps/debate-ai.com
(app/api/leaderboard/route.ts, lib/leaderboard/resolve.ts).
The two sources
| Source | Gives | Divisions | Fetched from |
|---|---|---|---|
| TOC bid list | rank, TOC score, bid count, students, state, per-tournament breakdown | CX, PF, LD | tocbidlist.com/api/enriched-leaderboard |
| DebateDrills | Debate Elo rating and Elo rank | PF, LD | ranking CSVs on raw.githubusercontent.com |
Neither is ours and neither is under contract. They go down separately, and the current season's table is a join of the two: the bid list is the spine, Elo is merged onto it.
Prior seasons (2021–2025) are DebateDrills-only — the bid list API serves the
current season — so /rank for an older year returns Elo rows for PF and LD
and an empty list for anything else.
Joining them by name
The two sources name the same team differently, so mergeElo
(debate-data-sync/src/rankings/merge-elo.ts) tries progressively more
forgiving keys:
- Both names normalized — lowercased, punctuation stripped, whitespace collapsed — and matched exactly.
- LD only: DebateDrills folds the debater's initials into the team name
where TOC keeps them in a separate
studentsfield, so the initials are appended before matching (Harker+Ada Lovelace→harker al). - PF only: partners' initials are not always listed in the same order,
so a trailing 2-or-3-letter initials block is also tried under every other
ordering —
Strake Jesuit MSasStrake Jesuit SM, or a 3-initialStrake Jesuit MJSas each of its other five orderings.
A row that matches nothing is returned unchanged rather than dropped: a partial or empty Elo dataset costs you the Elo column, not the leaderboard.
Degrading when a source is down
resolveLeaderboard (apps/debate-ai.com/lib/leaderboard/resolve.ts) owns
this; the route is the HTTP shell around it. Both upstreams are requested
concurrently and what to serve is decided afterwards:
| TOC bid list | DebateDrills | Served |
|---|---|---|
| ✅ | ✅ | merged rows |
| ✅ | ❌ | bid-list rows, no Elo column |
| ❌ | ✅ (non-empty) | Elo-only rows |
| ❌ | ❌ / empty | 502 with { error, details } |
CX has no Elo source, so a bid-list outage is a 502 for that division.
Two rules this encodes, both of which the endpoint previously got wrong:
- One dead upstream must not take out the other. A single wrapping
try/catchused to answer500the moment either scrape threw, so a tocbidlist outage blanked PF, LD and CX at once even though DebateDrills was answering. - An upstream failure is a
502, not a500. The app is fine; something it depends on is not. Saying so keeps third-party downtime out of our own error rate.
Every failure is console.error-logged before it is absorbed, so it lands in
Workers Logs. The DebateDrills branch used to swallow its error in a bare
catch {}, which made an outage indistinguishable from a season with no Elo
published yet.
grab, not axios
Both scrapers go through grab-url, the repo's standard request client.
This matters more than house style: these scrapes run inside the Cloudflare
Worker, where axios picks its transport at import time and reaches for
XMLHttpRequest or node:http rather than the runtime's real fetch.
grab's cancelOngoingIfNew is explicitly off for the bid-list fetch. It
defaults on and is keyed by path, which is right in a browser (one user, one
in-flight request per endpoint) and wrong on a server, where two visitors
asking for the same division at the same moment would cancel each other.
grab also resolves with an .error field instead of rejecting, so both
scrapers check it — an unchecked result looks exactly like a successful empty
response.
Tests
packages/debate-rankings/test/parse-rankings.test.ts— CSV quoting, the column mapping, empty win rates asnull, and that every generated dataset loads with rank 1 first and ratings in descending order.packages/debate-videos/test/leaderboard-utils.test.ts— sorting (nulls last in either direction), the name/school filter, and that every division maps to a generated dataset.packages/debate-data-sync/test/merge-elo.test.ts— the three key strategies, that unmatched rows survive, and that the input entries are not mutated.packages/debate-data-sync/test/sync-rankings-tocbidlist.test.ts— row mapping and sorting, thecancelOngoingIfNewopt-out, and that a.errorresult or a missingrowsarray throws instead of reading as empty.apps/debate-ai.com/lib/leaderboard/__tests__/resolve.test.ts— the degradation matrix above, that the two upstreams are queried concurrently, and that failures are logged.
Known gaps
- Both sources are scraped live on every request with no server-side cache, so each page load costs two third-party round trips and the endpoint is only as fast and as available as they are.
- The name join is exact-match-after-normalization plus two hand-written
special cases. A school that renames itself mid-season still silently loses
its Elo column — there is no Fuse.js-style fuzzy fallback for that, and the
LeaderboardEntrysimply comes back withoutdebateElo. A partnership listed with three initials silently loses its Elo column:Fixed:swapTrailingInitialsonly ever tried the one reversal of a two-letter suffix.trailingInitialsVariants(same file) generalizes this to every other ordering of a 2-or-3-letter trailing initials block — one variant for two letters (unchanged), the other five orderings for three — so a PF partnership like "Strake Jesuit MJS" still matches "Strake Jesuit SMJ" on the other source. A block outside that 2-3 length range still doesn't match, same as before.CURRENT_YEARis hardcoded. Each new season needs it bumped, or/rankserves the new year from the historical Elo-only path.NDT has no bid-list or Elo data source, so the panel showed only the champion banner.Fixed: NDT now reads thecpd(college policy) dataset fromdebate-rankings.debate-rankingshas no season dimension. The panel shows it for the current season year only, and the datasets must be recomputed by hand (python src/main.py) as tournaments are added./api/leaderboardand its scrapers are still deployed but no longer used by/rank.
