/** * What `League.globalRank` is comparable **within**, and the check that says * whether the collection honours it. * * ## The field is not global, and never was * * Two writers produced it and they used different numbering schemes. Opta's * league feed carries a rank that is global within one of its two files * (333 men's leagues, 115 women's), and `api-gateway/scripts/ * reclassify-world-tournaments.ts` carried a curated 1..N per confederation * and cohort for the 175 World tournaments Opta does not rank at all. Both * start at 1, so 22 leagues sat at `globalRank: 1` and 28 groups of rows * collided inside the grouping the API actually sorts in. * * The field is read by `LeaguesMongoDao.listLeagues`, which sorts on it * whenever the caller names a `confederation` or an international `scope` — * and every caller that does names all four of `scope`, `confederation`, * `gender` and `squad` — the homepage's club, regional and national-team * sections, five queries between them; its domestic section names a country * and sorts by `rank`. So that quadruple is the namespace: **`globalRank` is a * position within one (scope, confederation, gender, squad) section, and * comparing two rows from different sections is meaningless.** 51 sections * hold a ranked row. * * ## The section was `category` until C3, and the split is not cosmetic * * `category` was `squad === "youth" ? "youth" : gender`, so it could not say * "women's under-17" and one youth section held both genders. The curated * World table numbers a (confederation, cohort) group, so the women's youth * competitions took the numbers they held in their confederation's **women's** * sequence and landed behind the men's youth rows on them: the GLOBAL youth * section read U20 (1), U17 (2), Arab U20 (3), U20 Women (4), U17 Women (5), * and the AFC one interleaved them — men's U17 at 3, women's U20 at 4, the * men's U23 qualifier at 5. Those positions were an artefact of the old * grouping rather than a judgement about the competitions, and splitting the * section on `gender` makes them disappear instead of having to be decided. * Five youth sections split in two this way, which is where 46 sections become * 51. * * **A rank's section must be at least as coarse as the coarsest grouping any * caller sorts inside**, which is why this had to wait for the UI to ask on * the axes (C2): renumbering densely per gender while a caller could still ask * for one lumped youth list would have put `World Cup - U20` and `World Cup - * U20 - Women` at rank 1 of it, in five confederations — the LEA-8 defect, * reintroduced. * * The name therefore lies, and renaming it is recorded as owed rather than * done here: the same field name carries the same per-section meaning on * `Team`, it is in the `GET /leagues` and `GET /teams` payloads, and the * rename is a migration across two collections. See the LEA-8 status entry for * the call-site list. * * ## One derivation, two callers * * `deriveGlobalRanks` below is the rule, and it is the reason this module * exists rather than a script owning it: **the reviewed * `api-gateway/scripts/propose-league-global-ranks.ts` and * `POST /ingest/leagues/rerank` must compute the same answer.** Two writers * with two rules is exactly what LEA-8 removed — Opta's league feed numbered * each of its two files from 1 while `reclassify-world-tournaments.ts` * numbered the World tournaments from 1 per confederation, so 22 leagues sat * at rank 1 and 28 (section, rank) pairs collided. Two writers sharing ONE * derivation is a different thing: both compute the whole collection's ranks * from the same evidence, so whichever ran last, the answer is the same. * * `POST /ingest/leagues/classify` still does not accept the field, and * `reclassify-world-tournaments.ts` still does not write it. What changed at * Q-4 is that the derivation is no longer only reachable by hand: `rating` is * refreshed weekly and the ranks derived from it were as old as the last time * someone ran a script — measured on production 2026-08-09, a weekly run * refreshed 254 league ratings and moved 0 league ranks. */ import { Gender, Squad } from "@weekendgoals/weekendgoals-types/dist/classification"; /** The fields the namespace is built from. */ export interface RankableLeague { scope?: string; confederation?: string; gender?: Gender | string; squad?: Squad | string; globalRank?: number | null; } /** * The section a league's `globalRank` counts inside, or `undefined` when the * row cannot name one. An unclassified row has no section, so it can hold no * meaningful rank — which is why LEA-8 closes the classification gaps in the * same pass. */ export declare function globalRankNamespaceOf(league: RankableLeague): string | undefined; export interface GlobalRankCollision { namespace: string; globalRank: number; count: number; } /** * Every (namespace, rank) pair held by more than one league, plus the ranked * rows that name no namespace at all — both are states in which the sort the * API performs is decided by the tie-breaker rather than by the rank. * * Returned rather than thrown so a script can report the whole list; the * invariant is asserted in `api-gateway/test/unit/global-rank.spec.ts` and * re-checked against the collection by the apply script after every run. */ export declare function globalRankCollisions(leagues: RankableLeague[]): GlobalRankCollision[]; /** Ranked leagues that name no namespace, so their rank compares to nothing. */ export declare function rankedWithoutNamespace(leagues: T[]): T[]; /** A row with enough of itself to be ordered as well as namespaced. */ export interface DerivableLeague extends RankableLeague { name?: string; externalId?: string; rating?: number | null; } export interface GlobalRankDerivation { /** id → the rank the row should hold. A row absent from it holds none. */ ranks: Map; /** How many (scope, confederation, gender, squad) sections got a rank. */ sections: number; /** * Rows holding a rank that nothing orders — no `rating` and no entry in the * curated World table. The derivation CLEARS these; a rank nothing can * reproduce is worse than no rank, and the service's `?? 999` already * expects a missing one. */ noEvidence: T[]; /** * Rows that could be ordered but name no section, so their rank would * compare to nothing. Left unranked and reported. */ noNamespace: T[]; } /** * The whole collection's `globalRank`, derived from the collection. * * The rule, unchanged since LEA-8 and stated here because two callers depend * on it being one rule: * * - **Domestic sections order by `rating`, descending.** Opta's rating is the * strength measure its own league ranking is computed from, so this * reproduces Opta's order for every co-rated row while also placing a row * rated in an older run — which the raw rank could not, and which is exactly * how the collisions arose: a league that stopped resolving kept a number * from a run its neighbours had moved past. Ties break on the rank held * today, then the name, so a rerun over unchanged evidence is a no-op. * - **International sections take only the ORDER from the curated World * table** and are renumbered densely from 1. See `./world-tournament-order` * for why the numbers in it are not the numbers stored. * - **A row with neither evidence keeps no rank.** * * The two never mix — measured on the collection: 0 World rows carry an Opta * `rating`, and 0 domestic ranked rows lack one. The comparator handles a * mixed section anyway, rated rows leading. * * `idOf` is passed rather than assumed because the callers hold different * shapes of the same row (a lean Mongo document, a DAO projection). It must be * injective over `leagues` or two rows would claim one rank. */ export declare function deriveGlobalRanks(leagues: T[], idOf: (league: T) => string): GlobalRankDerivation; //# sourceMappingURL=global-rank.d.ts.map