Rating System v2 — Specification
Status: Active Date: 2026-04-03 Supersedes: elo-rating.md
Design Principles
- Evidence-based — Every formula element traces to published research or industry standard
- Amateur-first — Designed for 동호인 reality: mismatched pairings, draws, incomplete sets, RPS
- Gender-neutral rating, gender-aware rankings — No gender pools in the rating (one pool per format); leaderboards are gender-aware VIEWS over that rating. See §0.
- Global, not per-club — One rating earned across all clubs. Rank is contextual.
- Non-punitive — Inactivity grows uncertainty, doesn't decay rating. No "play or lose rank" pressure.
0. Rating vs Tier vs Ranking — the three layers (2026-07, owner-decided)
Canonical. Resolves the earlier gender-aware/gender-neutral split: the old rating-system-summary §11 gender-adjusted tier display is superseded. The three layers are distinct concerns:
- Rating (the engine) — gender-neutral. One Glicko-style rating per format (singles, doubles), earned from all matches regardless of opponent gender. No gender pools, no second ELO. Uncertainty (RD) models infrequent play, so new players read as placement (배치 중) until calibrated. Doubles credits individuals from the team result — Korean club play is overwhelmingly 복식, so this is first-class, not an afterthought.
- Tier (the public badge) — pure-skill, gender-neutral. The named tiers (실버~골드) map directly from the gender-neutral rating. The badge means one honest thing: ability. No gender-adjusted tier display — that path (summary §11, elo-tier-mapping Appendix D) is deprecated in favor of this pure-skill decision.
- Ranking (the leaderboards) — gender-aware, club-first, seasonal. Leaderboards are filtered VIEWS over the single rating: a consolidated (all-gender) board and per-gender boards, each per format. Club-scoped is primary; quarterly seasons reset standings (plus an all-time board). Rank may carry a light participation + manner nudge (never enough to leapfrog a real skill gap) so it rewards a good clubmate — but the tier stays pure-skill. Mixed-doubles boards are consolidated-only (inherently cross-gender).
Implementation note (not yet built): keeps the gender-neutral Glicko rating + named-tier mapping already specced here; deprecates getGenderAdjustedTier() (the gender-percentile display path); adds a leaderboard scope dimension (overall | men | women × format × season). Owner-decided 2026-07: named tiers, pure-skill tier, quarterly seasons.
1. Rating Pools
2 pools, both global and gender-neutral:
| Pool | What counts | Separate because |
|---|---|---|
| Singles ELO | Any 1v1 match | Individual skill, no partner variable |
| Doubles ELO | Any 2v2 match (same-gender, mixed, open) | Partner coordination is a separate skill |
No mixed doubles sub-pool. No gender-based sub-pools. This matches UTR and WTN.
Each player has:
singlesElo: number (default: from onboarding seed)
singlesRD: number (default: 350)
doublesElo: number (default: from onboarding seed)
doublesRD: number (default: 350)2. Core Formula
Singles
delta = K × (actual - expected) × scoreFactor × outcomeWeightDoubles
effective_team_A = avg(r1, r2) - α × |r1 - r2|
effective_team_B = avg(r3, r4) - α × |r3 - r4|
expected = 1 / (1 + 10^(-(effective_team_A - effective_team_B) / 400))
delta = K × (actual - expected) × scoreFactor × outcomeWeightBoth teammates receive the FULL delta (doublesWeight = 1.0). Source: UTR gives identical delta to both partners.
α (team variance penalty) = 0.08
| Team Composition | Avg | Penalty | Effective |
|---|---|---|---|
| 1400 + 1400 | 1400 | 0 | 1400 |
| 1600 + 1200 | 1400 | -32 | 1368 |
| 1800 + 1000 | 1400 | -64 | 1336 |
3. Match Outcomes
| Outcome | actual | outcomeWeight | When | Rating update? |
|---|---|---|---|---|
| decisive (win) | 1.0 | 1.0 | Normal win with final score | Yes |
| decisive (loss) | 0.0 | 1.0 | Normal loss | Yes |
| draw | 0.5 | 1.0 | Set ended tied, no tiebreak (4-4, 5-5, etc.) | Yes — draws ARE informative (chess standard) |
| incomplete (leader) | 0.6 | 0.5 | Stopped early, one side ahead (3-4, etc.) | Yes — partial signal |
| incomplete (tied) | 0.5 | 0.3 | Stopped early, score tied (3-3, etc.) | Yes — minimal signal |
| abandoned | — | 0 | Weather, injury, external factor | No |
| non_competitive | — | 0 | Rock-paper-scissors, coin flip | No |
| walkover | — | 0 | Opponent no-show | No |
Why draws get full weight (1.0, not 0.7): In chess ELO (the origin system), draws use actual = 0.5 with full weight. A draw against a stronger opponent IS informative — it tells us the weaker player performed better than expected. Reducing weight would discard real information. Source: FIDE Elo handbook, Lichess implementation.
4. Score Factor (Margin of Victory)
Based on Kovalchik (2020) — margin-of-victory ELO extensions outperformed standard ELO on ATP/WTA data.
gamePercentage = gamesWon / totalGames
scoreFactor:
gamePercentage >= 0.75 → 1.5 (dominant)
gamePercentage >= 0.60 → 1.2 (clear)
otherwise → 1.0 (close)Format-specific caps:
- Standard match (1+ sets): full range 1.0–1.5
- Tiebreak-only format (super-tiebreak as entire match): cap at 1.2
Special cases:
- Retirement/walkover: scoreFactor = 1.0 (no margin data)
- Draw: scoreFactor = 1.0 (no winner to reward)
- Incomplete: scoreFactor = 1.0 (insufficient data for margin)
5. Adaptive K-Factor
| Condition | K | Source |
|---|---|---|
| First 20 career matches (provisional) | 40 | USCF K-factor system |
| Established player (20+ matches) | 32 | Standard ELO |
| High-rated player (Diamond+, 2100+) | 24 | FIDE K-factor for 2400+ |
Upset bonus: When underdog wins, K × 1.5 (mild boost). Source: conceptually aligned with UTR's full-weight upset exception, but moderated to prevent rating explosion.
No gap-based K scaling. The ELO expected score formula already handles mismatches mathematically — (actual - expected) approaches 0 for extreme favorites. Adding K scaling on top double-counts the correction. Source: FiveThirtyEight Elo, Tennis Abstract Elo — neither uses gap-based K.
6. Inactivity Handling (Glicko-2 Inspired)
No rating decay. Rating number stays constant during inactivity. Instead, uncertainty (RD) grows:
Monthly RD growth: new_RD = min(350, sqrt(old_RD² + 15²))| Inactive Duration | Starting RD 50 → | Starting RD 100 → |
|---|---|---|
| 1 month | 52 | 101 |
| 3 months | 58 | 106 |
| 6 months | 68 | 118 |
| 12 months | 95 | 150 |
After returning: RD shrinks with each match played:
new_RD = max(30, old_RD × 0.92) // ~8% reduction per matchDisplay: Show rating ± RD for users who want precision: "1,450 ± 65" Default display: just the number "1,450" — RD is a background signal.
Why not rating decay: Moving ratings toward average punishes seasonal players (Korean winter/summer breaks). The Toss UX benchmark: never punish users for living their life. Glicko-2's RD growth is the industry standard (Lichess, Chess.com, Dota 2). Source: Glickman (2001).
7. Global Pool, Club-Contextual Display
One global rating per format (singles + doubles). Not per-club.
Display in club context:
My Doubles Rating: 1,450 (Gold II)
Club A rank: #3 of 20 members
Club B rank: #12 of 35 membersWhy global:
- Per-club ratings diverge and become meaningless for inter-club play
- Discovery/matchmaking requires comparable numbers
- UTR, WTN, NTRP are all global (not venue-specific)
- Cross-club calibration happens naturally as members play in multiple clubs
8. Safety Mechanisms
Rating Floor
A player's rating cannot drop below 80% of their personal all-time peak:
floor = peak_rating × 0.80
new_rating = max(floor, computed_new_rating)Prevents catastrophic rating loss from a bad streak or system exploitation.
Inflation Monitor
Check pool mean quarterly. If it drifts > 50 points from the seed default (1000), apply a global correction:
correction = pool_mean - 1000
new_rating = old_rating - correction (for all players)This is standard ELO pool maintenance (USCF, FIDE).
New Player Seeding
New players seed from self-assessment using Korean tennis division terminology. Seeds ensure every tier gets populated — from Bronze III up.
| Self-Assessment | Korean Div | Seed | Starting Tier | NTRP |
|---|---|---|---|---|
| 테린이 (Complete Beginner) | — | 500 | Bronze III | 1.5-2.0 |
| 입문 (Beginner) | 테린이/입문 | 700 | Bronze I | 2.0-2.5 |
| 신인부 (Newcomer) | 신인부 | 950 | Silver II | 2.5-3.0 |
| 오픈부 (Open) | 오픈부 | 1200 | Gold III | 3.0-3.5 |
| 챌린저부 (Challenger) | 챌린저부 | 1550 | Gold I | 3.5-4.0 |
| 마스터즈부 · 선수출신 | 마스터즈부 | 2100 | Diamond III | 4.5+ |
Distribution design: Korean 동호인 bulk self-selects 초급/중급 (seeds 950/1200). The 테린이 option ensures Bronze III is populated from day one. Provisional K=40 for first 20 matches ensures fast convergence to true level regardless of seed accuracy.
9. Complete Calculation Example
Scenario: Doubles match. Team A (1600 + 1200) beats Team B (1400 + 1400), score 6-3.
Step 1: Effective team ratings
Team A: avg(1600, 1200) - 0.08 × |1600-1200| = 1400 - 32 = 1368
Team B: avg(1400, 1400) - 0.08 × |0| = 1400
Step 2: Expected score for Team A
expected_A = 1 / (1 + 10^(-(1368-1400)/400)) = 1 / (1 + 10^0.08) = 0.481
Step 3: Score factor
gamePercentage = 6 / (6+3) = 0.667 → scoreFactor = 1.2
Step 4: Delta (for all 4 players)
K = 32 (all established)
delta = 32 × (1.0 - 0.481) × 1.2 × 1.0 = 32 × 0.519 × 1.2 = 19.9
Step 5: Apply
Team A player 1 (1600): 1600 + 20 = 1620
Team A player 2 (1200): 1200 + 20 = 1220
Team B player 1 (1400): 1400 - 20 = 1380
Team B player 2 (1400): 1400 - 20 = 1380
Step 6: Check floors
No player dropped below 80% of peak. OK.Scenario: Same teams, but draw (4-4, no tiebreak).
actual = 0.5, outcomeWeight = 1.0, scoreFactor = 1.0
delta = 32 × (0.5 - 0.481) × 1.0 × 1.0 = 32 × 0.019 = 0.6
// Tiny delta — draw between near-equal teams is expected. Correct.Scenario: Same teams, but decided by RPS.
outcome = 'non_competitive' → delta = 0. No rating change.11. Closed Pool Calibration
The Problem
TwoMore is club-centric. Most players start in a single club and may never play cross-club matches. Self-assessed seed ratings are an honor system — dishonest or miscalibrated seeds in an isolated club produce internally consistent but globally wrong ratings.
Solution: Layered Calibration
11.1 Rating Confidence Score
Each player carries a confidence score (0.0–1.0) that reflects how well-calibrated their rating is against the global pool.
confidence: number (default: 0.0)| Action | Confidence Change |
|---|---|
| Self-assessment seed | 0.0 (uncalibrated) |
| Each intra-club match | +0.02 (max contribution: 0.3) |
| Each cross-club match | +0.05 |
| Match vs player with confidence ≥ 0.7 | +0.03 bonus |
| 6+ months inactive | decay toward 0.3 floor |
Cap: 1.0. A player with 14+ cross-club matches against calibrated opponents reaches full confidence.
Display: Confidence is NOT shown to users. It's a backend signal used for:
- Weighting match results in pool statistics
- Triggering calibration alerts for admins
- Informing the strike system (§11.2)
11.2 Dishonesty Detection (Strike System)
Inspired by USTA's self-rate verification. Detects players whose actual performance consistently diverges from their seed.
strike_check runs after every match where:
- player.confidence < 0.5 (still in calibration phase)
- player has 5+ completed matchesDynamic ELO vs Seed ELO comparison:
seed_delta = current_elo - seed_elo
matches_played = total matches since seed
if matches_played >= 5:
if seed_delta < -150:
// Player seeded too high — flag as "over-seeded"
// Action: increase K to 48 for next 10 matches (faster correction)
if seed_delta > +200:
// Player seeded too low (sandbagging)
// Action: increase K to 48 for next 10 matches
// If seed_delta > +350 after 15 matches: admin alertNo punishment. Strikes accelerate convergence, they don't penalize. A genuinely miscalibrated player benefits from faster K. Only extreme sandbagging (>350 after 15 matches) triggers an admin review.
11.3 Bridge Player Pool Offset Correction
When a player joins a second club (becoming a "bridge player"), they create a calibration link between the two club pools.
Bridge detection:
player plays in Club A (10+ matches) AND Club B (5+ matches)
player.confidence >= 0.5
Pool offset estimation:
For each bridge player:
offset_estimate = bridge_player.elo - avg(opponents_in_new_club).elo
weighted by bridge_player.confidence
pool_offset = weighted_median(all bridge estimates for this club pair)Correction is passive, not retroactive:
- Bridge data informs the expected score calculation for future cross-club matches
- No mass rating adjustment — that would confuse users
- When enough bridge data accumulates (3+ bridge players between two clubs), the system gains confidence in the offset
Why weighted median, not mean: Resistant to a single outlier bridge player who may be miscalibrated themselves.
11.4 Cross-Pool Match Flag
Every match record tracks whether it's cross-pool:
ALTER TABLE matches ADD COLUMN is_cross_pool BOOLEAN DEFAULT false;A match is cross-pool when participants come from different "home clubs" (the club where they have the most matches). Cross-pool matches:
- Grant higher confidence increments (§11.1)
- Feed the bridge player offset model (§11.3)
- Are weighted more heavily in global pool statistics
11.5 Practical Timeline
| Phase | When | What |
|---|---|---|
| Launch | Day 1 | All players seed from self-assessment. confidence = 0.0 |
| Early | Month 1-3 | Intra-club matches build internal consistency. confidence reaches ~0.3 |
| Growth | Month 3-6 | Bridge players emerge as members join multiple clubs. First pool offsets calculated |
| Mature | Month 6+ | Cross-club events and discovery matches accelerate calibration. Most active players reach confidence > 0.7 |
Key insight: We don't need perfect calibration at launch. The system is designed to converge over time. Intra-club ratings are immediately useful for within-club matchmaking. Global accuracy improves as the social graph expands.
12. Data Model Changes Required (Updated)
profiles table
ALTER TABLE profiles ADD COLUMN singles_elo INTEGER DEFAULT 1000;
ALTER TABLE profiles ADD COLUMN singles_rd REAL DEFAULT 350;
ALTER TABLE profiles ADD COLUMN doubles_elo INTEGER DEFAULT 1000;
ALTER TABLE profiles ADD COLUMN doubles_rd REAL DEFAULT 350;
ALTER TABLE profiles ADD COLUMN singles_peak INTEGER DEFAULT 1000;
ALTER TABLE profiles ADD COLUMN doubles_peak INTEGER DEFAULT 1000;
ALTER TABLE profiles ADD COLUMN rating_confidence REAL DEFAULT 0.0;
ALTER TABLE profiles ADD COLUMN seed_singles_elo INTEGER;
ALTER TABLE profiles ADD COLUMN seed_doubles_elo INTEGER;
-- Existing elo_rating → migrate to doubles_elo (most Korean 동호인 play doubles)matches table
ALTER TABLE matches ADD COLUMN outcome TEXT DEFAULT 'decisive'
CHECK (outcome IN ('decisive','draw','incomplete','abandoned','non_competitive','walkover'));
ALTER TABLE matches ADD COLUMN is_cross_pool BOOLEAN DEFAULT false;
ALTER TABLE matches ALTER COLUMN format TYPE TEXT;
-- Expand format to include: singles, doubles (no gender prefix needed)elo_history table
ALTER TABLE elo_history ADD COLUMN rating_pool TEXT DEFAULT 'doubles'
CHECK (rating_pool IN ('singles', 'doubles'));13. References
- Kovalchik (2020) — Extension of ELO to margin of victory. International Journal of Forecasting.
- Glickman (2001) — Glicko-2 rating system. Boston University.
- UTR Algorithm FAQ — universaltennis.com/support
- UTR Doubles Algorithm — universaltennis.com/support
- FIDE Handbook — fide.com (K-factor rules)
- USCF Rating System — uschess.org
- FiveThirtyEight NBA ELO — fivethirtyeight.com
- Tennis Abstract ELO (Jeff Sackmann) — tennisabstract.com
- SliceWin ELO — slicewin.com
- Elo Sports Challenge — elosportschallenge.wordpress.com
- Schmidt Computer Ratings — computerratings.blogspot.com
- USTA Self-Rate Verification — usta.com (strike system for dishonest self-ratings)
- WTN Confidence Model — worldtennisnumber.com (confidence scoring for rating reliability)
- IRT Test Equating — Lord & Novick (1968), applied to bridge player calibration
- FIDE Pool Offset Correction (2024) — fide.com (pool-wide rating adjustment)
- LoL MMR Server Merge — Riot Games (regression toward mean for pool merges)