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/:

FileContents
<id>_full_rankings.csvRank, School, Name, Adjusted Rating (μ − 2φ), Deviation (φ), Matches, Rating (μ), Hash, and aff/neg prelim + elim win rates
<id>_field_statistics.csvOne row of field-wide aff/neg win rates
<id>_rankings.csvA 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

SourceGivesDivisionsFetched from
TOC bid listrank, TOC score, bid count, students, state, per-tournament breakdownCX, PF, LDtocbidlist.com/api/enriched-leaderboard
DebateDrillsDebate Elo rating and Elo rankPF, LDranking 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:

  1. Both names normalized — lowercased, punctuation stripped, whitespace collapsed — and matched exactly.
  2. LD only: DebateDrills folds the debater's initials into the team name where TOC keeps them in a separate students field, so the initials are appended before matching (Harker + Ada Lovelace → harker al).
  3. 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 MS as Strake Jesuit SM, or a 3-initial Strake Jesuit MJS as 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 listDebateDrillsServed
✅✅merged rows
✅❌bid-list rows, no Elo column
❌✅ (non-empty)Elo-only rows
❌❌ / empty502 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/catch used to answer 500 the 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 a 500. 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 as null, 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, the cancelOngoingIfNew opt-out, and that a .error result or a missing rows array 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 LeaderboardEntry simply comes back without debateElo.
  • A partnership listed with three initials silently loses its Elo column: swapTrailingInitials only ever tried the one reversal of a two-letter suffix. Fixed: 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_YEAR is hardcoded. Each new season needs it bumped, or /rank serves 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 the cpd (college policy) dataset from debate-rankings.
  • debate-rankings has 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/leaderboard and its scrapers are still deployed but no longer used by /rank.