Rating Interop Model — full interoperability with governing-body rating systems
Status: Active · Last reviewed: 2026-08-11
Owner-commissioned research (2026-08-11, three parallel threads: system mechanics · conversion prior art · data-model/API surfaces) synthesized into the concrete S7 design. Companion to rating-intl-audit.md (the adversarial findings this design resolves). Implementation is owner-gated on the ◆ decisions at the end.
The evidence base, distilled
Mechanics (primary sources: ITF/UTR/USTA/DTB/FFT regulations + APIs). The world's systems differ on every axis a naive schema assumes constant: direction (WTN 40→1 and LK 25→1 are reversed; UTR/NTRP/Playtomic ascend), kind (FFT classement is a non-numeric ordinal ladder — NC→40→…→0→séries; KR ladders are credential gates with hard 1-year expiry), discipline topology (WTN/UTR run fully independent singles/doubles processes; NTRP/LK deliberately collapse everything into ONE number; USTA mixed adds a pair-sum eligibility cap that is a property of a pairing, not a player), confidence (WTN literally exposes Glicko-2 rating deviation as a 0–100% confidence with a 70% verified threshold; UTR has projected/reliable + verified/unverified tiers; NTRP hides its true two-decimal dynamic rating and publishes only coarse bands, policed by a strike/committee layer), decay (DTB runs a literal monthly +0.1-toward-beginner cron; FFT is a no-subtraction ratchet; Glicko inflates uncertainty instead), and epochs (FFT rebuilt its recompute model in Oct 2022; WTN revises its algorithm in place — target scales are NOT stable).
Conversion prior art. Nobody publishes an authoritative universal conversion. US Chess maintains per-federation piecewise offset tables, not one formula; ECF's linear formula broke at the recreational tail; community NTRP↔UTR tables disagree with each other by a full band. The one successful unification — golf's World Handicap System — worked by mandating ONE canonical normalizer every source expresses itself through (hub-and-spoke), and its lingering backlash came from merging systems with different philosophies under one number. Platforms that got it right (UTR Sports app) display UTR, NTRP, and WTN side by side and never fuse. Psychometrics names our case precisely: internal-ELO↔federation-scale is calibration/moderation, not equating — only defensible as banded, interval-carrying, versioned mappings.
API surfaces. WTN: GraphQL with publicly readable docs but federation-mediated access (verified 2026-08-12: providers apply through national-association programs like USTA Connect; results flow INTO WTN only from sanctioned sources — an app cannot feed it directly, and KTA has not adopted WTN for 생활체육, so KR coverage ≈ zero today). UTR: partner Engage API (verified/unverified, S/D). USTA: credentialed Connect API (NTRP + WTN together; self vs computer provenance). DTB LK / FFT classement: portal-lookup only, no API — but public per-player numbers. Verification abroad is easier than KR's claim→corroborate in every target market.
Recommendation (2026-08-12, ◆ owner decision pending): WTN as the flagship EXTERNAL scale, never the engine. Rationale: KR coverage gap is fatal for our population; the ITF revises the algorithm in place (scale moves under you); and our engine rates every casual player from their first in-app match — data WTN structurally cannot see (the Duke UTR-vs-WTN studies show algorithm sophistication is not the differentiator; match-data coverage is). Internal-engine robustness items promoted into S7 scope: (1) rd inflation on inactivity, (2) set/game-level scoring input (scores already stored), (3) anti-sandbag governance once tiers gate socially valuable entry (NTRP strike-system precedent), (4) an explicit decay-policy decision (LK activity-pull vs uncertainty-only vs none).
Design principles (each traceable to evidence)
- Hub-and-spoke, never mesh. Internal three-track ELO is the only hub; every external scale maps to/from it. No pairwise external↔external conversions ever ship (WHS lesson; kills N² drift).
- Observations, never fusion. External ratings are immutable snapshot observations; they NEVER write into
player_ratings(the boundary 00458 already draws). Display shows both and shows disagreement (UTR-app pattern); conversion happens at read time only. - Conversions are banded, versioned, interval-carrying, and re-validated. Every crosswalk row is a band with an uncertainty note and a version/date; a scheduled re-validation exists because pools drift (ECF/chess.com lesson). No affine formulas; piecewise/lookup only.
- Confidence travels. Internal ratings already carry
rd(00458 kept it — the Glicko primitive was the right call); external observations carry the scale's own confidence field raw; converted displays inherit the WIDER of the two uncertainties. - Scales are described, not assumed. A scale registry (config) declares direction, kind (decimal/ordinal), domain, granularity, discipline topology (per-discipline | unified | pair-sum), confidence/verification presence. Formatters and comparators read the descriptor — no hardcoded "NTRP"/"UTR" strings anywhere (kills audit blocker #2, the dead
primaryScaleparams). - Credential ladders are a first-class kind, not degenerate ratings.
kind: 'ordinal'with an ordered level list + expiry semantics covers FFT and the KR division/group ladders in one mechanism — display degrades honestly to "which level," never fabricates distance. - Pair-sum is an eligibility declaration, not a stored rating.
disciplineAxis: 'pair-sum'on a scale tells eligibility consumers (RSVP gates, match generation) to compute over both players at evaluation time. No pair rows in player tables. - Epochs are explicit. External systems change their own rules (FFT 2022, WTN revisions); observations carry
observed_atand scales carry an epoch tag so history spanning a rule change is never silently mixed.
The model (five pieces)
1. Scale registry — scale.config.ts (code-owned, like market.config)
interface RatingScale {
id: string; // 'WTN' | 'UTR' | 'NTRP' | 'DTB_LK' | 'FFT_CLASSEMENT' | 'KR_DIVISION' | 'KASTA_GROUP' | ...
name: LocalizedText;
direction: 'asc' | 'desc'; // desc: WTN, DTB_LK
kind: 'decimal' | 'ordinal';
domain:
| { kind: 'decimal'; min: number; max: number | null; step: number }
| { kind: 'ordinal'; levels: Array<{ code: string; label: LocalizedText }> }; // ordered weakest→strongest
disciplineAxis: 'per-discipline' | 'unified' | 'pair-sum';
disciplines?: RatingDiscipline[]; // when per-discipline
confidence: 'none' | 'scalar' | 'tiered'; // WTN scalar; UTR/NTRP tiered; LK/FFT none
epoch?: string; // e.g. 'fft-2022-rolling'
}Coverage-checked against WTN, UTR (incl. UTR-P as a sibling scale id), NTRP, DTB LK, FFT, KR division/KASTA/테니스타운 ladders, Playtomic, Matchi — every one expressible; verified in the research pass.
2. player_external_ratings (DB — replaces the overhaul §4 sketch, which is superseded)
Immutable observation rows: (id, profile_id, scale_id TEXT, discipline NULL-able, value_numeric NUMERIC NULL, value_ordinal TEXT NULL [a registry level CODE], confidence_raw NUMERIC NULL, verification_tier CHECK('self_declared','document','api_verified'), source TEXT ['wtn_api','usta_self_rate','user_upload',…], evidence JSONB, observed_at, valid_until NULL, created_at) + CHECK (value_numeric IS NOT NULL OR value_ordinal IS NOT NULL).
- numeric/ordinal split: keeps the common decimal case sortable/indexable while expressing FFT '30/2' honestly as a level code — never a fabricated float.
- Snapshot history, never UPDATE: the calibration fitter needs (ELO@T, external@T) time-series pairs; a
_currentview (DISTINCT ON … ORDER BY observed_at DESC) serves profile display. scale_iddeliberately NOT CHECK-constrained (theelite_backgroundDB-freeze lesson from the audit); validity enforced in the write path.observed_at≠created_at: a screenshot of a 3-month-old WTN is an old observation recorded today.
3. Crosswalk prior — crosswalk.config.ts (code-owned, versioned)
Banded lookups keyed (fromScale, toScale) where one side is always INTERNAL_ELO or a display scale pair explicitly marked non-authoritative. Each entry: band rows + sourceUrl + version + uncertaintyNote. This is what today's TIER_RATING_TABLE informally is — lifted out of the KR tier table so JP declares WTN↔ELO without touching KR's NTRP entry. Community tables disagree by a full band; entries store the SPREAD, not one point.
4. Fitted calibration posterior — rating_calibration_fits (DB, computed)
(region, internal_track, scale_id, discipline, fit_type CHECK('piecewise','lookup_table'), params JSONB, sample_size, computed_at, superseded_by) — written by an offline fit job joining player_ratings × player_external_ratings_current for anchor members. One named resolution function: fitted params when sample_size ≥ threshold, else crosswalk prior — with a DRIFT PIN test, because "two ladders in one app" already happened once (76b75349). No linear fit_type on purpose: the affine-formula failure mode is documented (ECF).
5. Display & tier-band resolution (replaces TierRatingBand)
RatingRegionConfig drops ntrpMin/utrMin-shaped fields for: primaryScaleId, secondaryScaleId | null, and bands: Record<EloTier, ScaleBandRef> where a band is {min,max} (decimal scales) or {levelRange} (ordinal). One generic formatScaleValue(scaleId, value) and formatTierBand(tier, scaleId) driven by the registry replace formatNtrpBand/formatUtrBand/formatTierNtrp/formatUtrBand (replaced, not wrapped — single source of truth). Market plumbing (audit S7b) resolves region once (club country → rating region, profile fallback ◆) and threads it; TierInfoSheet moves onto the same path.
What changes vs stays
| Piece | Fate |
|---|---|
player_ratings (internal ELO, 3-track, rd) | UNTOUCHED — already carries the Glicko confidence primitive; the never-fed-by-external boundary is load-bearing |
TierRatingBand / TIER_RATING_TABLE | REPLACED by scale-registry bands (KR values byte-preserved as the NTRP/UTR entries) |
formatNtrpBand/formatUtrBand | REPLACED by registry-driven formatters |
overhaul §4 player_external_ratings sketch | SUPERSEDED by §2 above (sketch lacked discipline, numeric/ordinal split, confidence, observed_at) |
tierCredentials (region-keyed i18n) | KEPT — gains a mode per region ('podium' KR-style vs 'rating-range' DE/FR-style, audit S7e) with non-silent fallback derived from bands |
EliteBackground | S7d decision ◆ — recommend: keep the 7-rung universal coarse enum in DB, add per-region display mapping + an elite_background_detail free-text for market granularity (US D1/D3), avoiding another CHECK freeze |
Owner decisions (◆) — recommendations attached
- Scale-registry shape (§1) — recommend as specced; it's the union of primitives the mechanics research produced, each field evidence-traceable.
- Region resolution precedence — recommend club country wins, profile country is the fallback (a JP user in a KR club sees the KR club's vocabulary — ratings talk happens inside the club context).
- Elite background v2 — recommend the universal-coarse-enum + per-region display + detail-field option (no new DB freeze, no migration per market).
- Fit-threshold — recommend
sample_size ≥ 30per (region, track, scale) before fitted params outrank the prior, revisited after first real fit. - Sequencing — S7 is schema-shaped (registry types + two tables) and rides the pre-launch clean-break window like S1-S6; the KR user experience is byte-identical throughout (KR bands become the NTRP/UTR registry entries verbatim). Recommend executing as one S7 wave when wizard QA settles.