diff --git a/docs/rating-comparison.json b/docs/rating-comparison.json index d22efc5..ef105a2 100644 --- a/docs/rating-comparison.json +++ b/docs/rating-comparison.json @@ -1,109 +1,87 @@ { "metadata": { - "timestamp": "2026-02-26T11:33:29.969922-05:00", + "k_factor": 32.0, + "start_rating": 1500.0, + "timestamp": "2026-02-26T11:55:18.544210-05:00", "total_matches": 29, "total_players": 6 }, "players": [ { - "doubles": { - "difference": 290.9, - "elo": 1500.0, - "glicko2": 1209.1 - }, - "id": 1, - "matches_played": 0, - "name": "Dane Sabo", - "singles": { - "difference": 129.5, - "elo": 1500.0, - "glicko2": 1370.5 - } - }, - { - "doubles": { - "difference": -218.2, - "elo": 1500.0, - "glicko2": 1718.2 + "difference": -112.8, + "elo_unified": 1537.7, + "glicko2": { + "average": 1650.5, + "doubles": 1718.2, + "singles": 1582.9 }, "id": 2, - "matches_played": 0, - "name": "Andrew Stricklin", - "singles": { - "difference": -82.9, - "elo": 1500.0, - "glicko2": 1582.9 - } + "matches_played": 19, + "name": "Andrew Stricklin" }, { - "doubles": { - "difference": 173.1, - "elo": 1500.0, - "glicko2": 1326.9 - }, - "id": 4, - "matches_played": 0, - "name": "Krzysztof Radziszeski ", - "singles": { - "difference": -119.3, - "elo": 1500.0, - "glicko2": 1619.3 - } - }, - { - "doubles": { - "difference": -161.5, - "elo": 1500.0, - "glicko2": 1661.5 + "difference": -40.2, + "elo_unified": 1521.6, + "glicko2": { + "average": 1561.8, + "doubles": 1661.5, + "singles": 1462.2 }, "id": 3, - "matches_played": 0, - "name": "David Pabst", - "singles": { - "difference": 37.8, - "elo": 1500.0, - "glicko2": 1462.2 - } + "matches_played": 11, + "name": "David Pabst" }, { - "doubles": { - "difference": -114.9, - "elo": 1500.0, - "glicko2": 1614.9 + "difference": -43.0, + "elo_unified": 1514.5, + "glicko2": { + "average": 1557.5, + "doubles": 1614.9, + "singles": 1500.0 }, "id": 6, - "matches_played": 0, - "name": "Jacklyn Wyszynski", - "singles": { - "difference": 0.0, - "elo": 1500.0, - "glicko2": 1500.0 - } + "matches_played": 9, + "name": "Jacklyn Wyszynski" }, { - "doubles": { - "difference": -5.8, - "elo": 1500.0, - "glicko2": 1505.8 + "difference": 11.3, + "elo_unified": 1496.8, + "glicko2": { + "average": 1485.5, + "doubles": 1505.8, + "singles": 1465.1 }, "id": 5, - "matches_played": 0, - "name": "Eliana Crew", - "singles": { - "difference": 34.9, - "elo": 1500.0, - "glicko2": 1465.1 - } + "matches_played": 13, + "name": "Eliana Crew" + }, + { + "difference": 2.7, + "elo_unified": 1475.8, + "glicko2": { + "average": 1473.1, + "doubles": 1326.9, + "singles": 1619.3 + }, + "id": 4, + "matches_played": 25, + "name": "Krzysztof Radziszeski " + }, + { + "difference": 159.0, + "elo_unified": 1448.8, + "glicko2": { + "average": 1289.8, + "doubles": 1209.1, + "singles": 1370.5 + }, + "id": 1, + "matches_played": 25, + "name": "Dane Sabo" } ], - "summary": { - "doubles": { - "avg_elo": 1500.0, - "avg_glicko2": 1506.060606060606 - }, - "singles": { - "avg_elo": 1500.0, - "avg_glicko2": 1500.0 - } + "system_description": { + "new": "Pure ELO with unified rating, per-point scoring, effective opponent formula", + "old": "Glicko-2 with separate singles/doubles ratings, RD, volatility" } } \ No newline at end of file diff --git a/docs/rating-comparison.md b/docs/rating-comparison.md index 2933d3a..dc1df86 100644 --- a/docs/rating-comparison.md +++ b/docs/rating-comparison.md @@ -1,38 +1,54 @@ # Rating System Comparison: Glicko-2 vs Pure ELO +## Overview + +This analysis replays all historical matches through the new ELO system to compare ratings. + +**Key differences:** +- **Old system:** Glicko-2 with separate singles/doubles ratings, RD, volatility +- **New system:** Pure ELO with unified rating, per-point scoring, effective opponent formula + ## Summary - **Total Players:** 6 -- **Total Matches:** 29 -- **Analysis Date:** 2026-02-26 11:33:29 +- **Total Matches Replayed:** 29 +- **K-Factor:** 32 +- **Analysis Date:** 2026-02-26 11:55:18 -## Ratings Comparison +## Ratings Comparison (Sorted by New ELO) -| Player | Singles (G2) | Singles (ELO) | Diff | Doubles (G2) | Doubles (ELO) | Diff | Matches | -|--------|------|------|------|------|------|------|--------| -| Dane Sabo | 1371 | 1500 | +129 | 1209 | 1500 | +291 | 0 | -| Andrew Stricklin | 1583 | 1500 | -83 | 1718 | 1500 | -218 | 0 | -| Krzysztof Radziszeski | 1619 | 1500 | -119 | 1327 | 1500 | +173 | 0 | -| David Pabst | 1462 | 1500 | +38 | 1661 | 1500 | -161 | 0 | -| Jacklyn Wyszynski | 1500 | 1500 | +0 | 1615 | 1500 | -115 | 0 | -| Eliana Crew | 1465 | 1500 | +35 | 1506 | 1500 | -6 | 0 | +| Rank | Player | Glicko-2 Avg | New ELO | Diff | Matches | +|:----:|--------|-------------:|--------:|-----:|--------:| +| 1 | Andrew Stricklin | 1651 | 1538 | -113 | 19 | +| 2 | David Pabst | 1562 | 1522 | -40 | 11 | +| 3 | Jacklyn Wyszynski | 1557 | 1514 | -43 | 9 | +| 4 | Eliana Crew | 1485 | 1497 | +11 | 13 | +| 5 | Krzysztof Radziszeski | 1473 | 1476 | +3 | 25 | +| 6 | Dane Sabo | 1290 | 1449 | +159 | 25 | -## Biggest Rating Changes +## Key Insights -Players whose ratings changed the most in the conversion: +### Biggest Winners (rating increased) + +- **Dane Sabo**: +159 points (Glicko avg 1290 → ELO 1449) +- **Eliana Crew**: +11 points (Glicko avg 1485 → ELO 1497) +- **Krzysztof Radziszeski **: +3 points (Glicko avg 1473 → ELO 1476) + +### Biggest Losers (rating decreased) + +- **Andrew Stricklin**: -113 points (Glicko avg 1651 → ELO 1538) +- **Jacklyn Wyszynski**: -43 points (Glicko avg 1557 → ELO 1514) +- **David Pabst**: -40 points (Glicko avg 1562 → ELO 1522) + +## Why Ratings Changed + +The new system differs in several ways: + +1. **Per-point scoring**: Instead of just win/loss, we use `points_won / total_points`. Winning 11-9 gives less credit than winning 11-2. + +2. **Effective opponent formula**: In doubles, your effective opponent is calculated as `Opp1 + Opp2 - Teammate`. This means: + - Strong teammate → lower effective opponent → less credit for winning + - Weak teammate → higher effective opponent → more credit for winning + +3. **Unified rating**: Singles and doubles contribute to one rating instead of two. -1. **Dane Sabo** - Average change: 210 points - - Singles: +129 - - Doubles: +291 -2. **Andrew Stricklin** - Average change: 151 points - - Singles: -83 - - Doubles: -218 -3. **Krzysztof Radziszeski ** - Average change: 146 points - - Singles: -119 - - Doubles: +173 -4. **David Pabst** - Average change: 100 points - - Singles: +38 - - Doubles: -161 -5. **Jacklyn Wyszynski** - Average change: 57 points - - Singles: +0 - - Doubles: -115 diff --git a/src/bin/elo_analysis.rs b/src/bin/elo_analysis.rs index 7d1def9..32fe75a 100644 --- a/src/bin/elo_analysis.rs +++ b/src/bin/elo_analysis.rs @@ -1,4 +1,5 @@ -// Analysis tool: Compare Glicko-2 vs ELO ratings using historical match data +// Analysis tool: Compare Glicko-2 vs Pure ELO ratings using historical match data +// Fixed version: unified rating, per-point scoring, correct match counts use sqlx::SqlitePool; use std::collections::HashMap; @@ -12,14 +13,13 @@ struct PlayerRatings { name: String, glicko2_singles: f64, glicko2_doubles: f64, - elo_singles: f64, - elo_doubles: f64, - singles_diff: f64, - doubles_diff: f64, + elo_unified: f64, + glicko2_avg: f64, + elo_diff: f64, matches_played: i32, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] struct MatchData { id: i64, match_type: String, @@ -27,12 +27,41 @@ struct MatchData { team2_score: i32, team1_players: Vec, team2_players: Vec, + played_at: String, +} + +const K_FACTOR: f64 = 32.0; +const START_RATING: f64 = 1500.0; + +/// Calculate expected score using ELO formula +fn expected_score(player_rating: f64, opponent_rating: f64) -> f64 { + 1.0 / (1.0 + 10.0_f64.powf((opponent_rating - player_rating) / 400.0)) +} + +/// Calculate per-point performance (points_won / total_points) +fn per_point_performance(points_won: i32, points_lost: i32) -> f64 { + let total = points_won + points_lost; + if total == 0 { + 0.5 + } else { + points_won as f64 / total as f64 + } +} + +/// Calculate effective opponent rating for doubles +/// Formula: Opp1 + Opp2 - Teammate +fn effective_opponent_rating( + opp1_rating: f64, + opp2_rating: f64, + teammate_rating: f64, +) -> f64 { + opp1_rating + opp2_rating - teammate_rating } #[tokio::main] async fn main() { - println!("🔄 ELO System Analysis Tool"); - println!("===========================\n"); + println!("🔄 ELO System Analysis Tool (v2 - Unified Rating)"); + println!("==================================================\n"); // Connect to database let db_path = "/Users/split/Projects/pickleball-elo/pickleball.db"; @@ -50,7 +79,7 @@ async fn main() { println!("📊 Reading match history..."); - // Fetch all players with current ratings + // Fetch all players with current Glicko-2 ratings let players: Vec<(i64, String, f64, f64)> = sqlx::query_as( "SELECT id, name, singles_rating, doubles_rating FROM players ORDER BY name" ) @@ -60,9 +89,10 @@ async fn main() { println!("✅ Found {} players", players.len()); - // Fetch all matches - let matches: Vec<(i64, String, i32, i32)> = sqlx::query_as( - "SELECT id, match_type, team1_score, team2_score FROM matches ORDER BY id" + // Fetch all matches ordered by time + let matches: Vec<(i64, String, i32, i32, String)> = sqlx::query_as( + "SELECT id, match_type, team1_score, team2_score, COALESCE(timestamp, datetime('now')) as played_at + FROM matches ORDER BY timestamp, id" ) .fetch_all(&pool) .await @@ -70,13 +100,15 @@ async fn main() { println!("✅ Found {} matches", matches.len()); - // For each match, get participants - let mut match_data_map: HashMap = HashMap::new(); + // Build match data with participants + let mut all_matches: Vec = vec![]; + let mut match_counts: HashMap = HashMap::new(); - for (match_id, match_type, team1_score, team2_score) in &matches { + for (match_id, match_type, team1_score, team2_score, played_at) in &matches { let team1_players: Vec = sqlx::query_scalar( "SELECT player_id FROM match_participants WHERE match_id = ? AND team = 1" ) + .bind(match_id) .fetch_all(&pool) .await .unwrap_or_default(); @@ -84,141 +116,124 @@ async fn main() { let team2_players: Vec = sqlx::query_scalar( "SELECT player_id FROM match_participants WHERE match_id = ? AND team = 2" ) + .bind(match_id) .fetch_all(&pool) .await .unwrap_or_default(); - match_data_map.insert(*match_id, MatchData { + // Count matches per player + for pid in &team1_players { + *match_counts.entry(*pid).or_insert(0) += 1; + } + for pid in &team2_players { + *match_counts.entry(*pid).or_insert(0) += 1; + } + + all_matches.push(MatchData { id: *match_id, match_type: match_type.clone(), team1_score: *team1_score, team2_score: *team2_score, team1_players, team2_players, + played_at: played_at.clone(), }); } - // Initialize ELO ratings (everyone starts at 1500) - let mut elo_ratings: HashMap = HashMap::new(); // (singles, doubles) + // Initialize UNIFIED ELO ratings (everyone starts at 1500) + let mut elo_ratings: HashMap = HashMap::new(); for (player_id, _, _, _) in &players { - elo_ratings.insert(*player_id, (1500.0, 1500.0)); + elo_ratings.insert(*player_id, START_RATING); } - println!("\n🔢 Recalculating ELO ratings from match history..."); + println!("\n🔢 Replaying {} matches through new ELO system...\n", all_matches.len()); - // Simulate all matches to calculate ELO - for (match_id, match_type, _team1_score, _team2_score) in &matches { - if let Some(match_info) = match_data_map.get(match_id) { - let is_doubles = match_type == "doubles"; - let team1_won = match_info.team1_score > match_info.team2_score; + // Replay all matches chronologically + for (i, match_data) in all_matches.iter().enumerate() { + let is_doubles = match_data.match_type == "doubles" || match_data.team1_players.len() > 1; + let team1_won = match_data.team1_score > match_data.team2_score; + + // Calculate per-point performance + let team1_performance = per_point_performance(match_data.team1_score, match_data.team2_score); + let team2_performance = per_point_performance(match_data.team2_score, match_data.team1_score); + + // Collect current ratings BEFORE updates + let team1_ratings: Vec<(i64, f64)> = match_data.team1_players.iter() + .map(|pid| (*pid, elo_ratings.get(pid).copied().unwrap_or(START_RATING))) + .collect(); + let team2_ratings: Vec<(i64, f64)> = match_data.team2_players.iter() + .map(|pid| (*pid, elo_ratings.get(pid).copied().unwrap_or(START_RATING))) + .collect(); + + // Calculate and apply rating changes for Team 1 + for (player_id, player_rating) in &team1_ratings { + let effective_opp = if is_doubles && team2_ratings.len() >= 2 && team1_ratings.len() >= 2 { + // Find teammate + let teammate_rating = team1_ratings.iter() + .find(|(pid, _)| pid != player_id) + .map(|(_, r)| *r) + .unwrap_or(START_RATING); + + effective_opponent_rating( + team2_ratings[0].1, + team2_ratings[1].1, + teammate_rating, + ) + } else if is_doubles && team2_ratings.len() >= 2 { + // Singles vs doubles scenario or missing teammate + (team2_ratings[0].1 + team2_ratings[1].1) / 2.0 + } else { + // Singles + team2_ratings.get(0).map(|(_, r)| *r).unwrap_or(START_RATING) + }; - // For each player, calculate rating change - for player_id in &match_info.team1_players { - let (current_singles, current_doubles) = elo_ratings.get(player_id).copied().unwrap_or((1500.0, 1500.0)); - let current = if is_doubles { current_doubles } else { current_singles }; - - // Calculate effective opponent rating - let opponent_rating = if is_doubles { - let opponent_ratings: Vec = match_info.team2_players.iter() - .map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).1) - .collect(); - let avg_opp_rating = opponent_ratings.iter().sum::() / opponent_ratings.len() as f64; - - // Teammate rating for effective opponent - let teammate_rating = if match_info.team1_players.len() > 1 { - let teammate_id = match_info.team1_players.iter().find(|pid| *pid != player_id).copied().unwrap_or(*player_id); - elo_ratings.get(&teammate_id).copied().unwrap_or((1500.0, 1500.0)).1 - } else { - 1500.0 - }; - - avg_opp_rating * 2.0 - teammate_rating - } else { - match_info.team2_players.iter() - .map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).0) - .next() - .unwrap_or(1500.0) - }; - - // Calculate performance (0.0 or 1.0 for now, simple version) - let performance = if team1_won { 1.0 } else { 0.0 }; - - // ELO rating change: K=32 - let expected = 1.0 / (1.0 + 10.0_f64.powf((opponent_rating - current) / 400.0)); - let k_factor = 32.0; - let rating_change = k_factor * (performance - expected); - let new_rating = current + rating_change; - - if is_doubles { - elo_ratings.insert(*player_id, (current_singles, new_rating)); - } else { - elo_ratings.insert(*player_id, (new_rating, current_doubles)); - } - } + let expected = expected_score(*player_rating, effective_opp); + let rating_change = K_FACTOR * (team1_performance - expected); + let new_rating = player_rating + rating_change; - // Update team 2 - for player_id in &match_info.team2_players { - let (current_singles, current_doubles) = elo_ratings.get(player_id).copied().unwrap_or((1500.0, 1500.0)); - let current = if is_doubles { current_doubles } else { current_singles }; + elo_ratings.insert(*player_id, new_rating); + } + + // Calculate and apply rating changes for Team 2 + for (player_id, player_rating) in &team2_ratings { + let effective_opp = if is_doubles && team1_ratings.len() >= 2 && team2_ratings.len() >= 2 { + // Find teammate + let teammate_rating = team2_ratings.iter() + .find(|(pid, _)| pid != player_id) + .map(|(_, r)| *r) + .unwrap_or(START_RATING); - // Calculate effective opponent rating - let opponent_rating = if is_doubles { - let opponent_ratings: Vec = match_info.team1_players.iter() - .map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).1) - .collect(); - let avg_opp_rating = opponent_ratings.iter().sum::() / opponent_ratings.len() as f64; - - // Teammate rating for effective opponent - let teammate_rating = if match_info.team2_players.len() > 1 { - let teammate_id = match_info.team2_players.iter().find(|pid| *pid != player_id).copied().unwrap_or(*player_id); - elo_ratings.get(&teammate_id).copied().unwrap_or((1500.0, 1500.0)).1 - } else { - 1500.0 - }; - - avg_opp_rating * 2.0 - teammate_rating - } else { - match_info.team1_players.iter() - .map(|pid| elo_ratings.get(pid).copied().unwrap_or((1500.0, 1500.0)).0) - .next() - .unwrap_or(1500.0) - }; - - // Calculate performance - let performance = if !team1_won { 1.0 } else { 0.0 }; - - // ELO rating change - let expected = 1.0 / (1.0 + 10.0_f64.powf((opponent_rating - current) / 400.0)); - let k_factor = 32.0; - let rating_change = k_factor * (performance - expected); - let new_rating = current + rating_change; - - if is_doubles { - elo_ratings.insert(*player_id, (current_singles, new_rating)); - } else { - elo_ratings.insert(*player_id, (new_rating, current_doubles)); - } - } + effective_opponent_rating( + team1_ratings[0].1, + team1_ratings[1].1, + teammate_rating, + ) + } else if is_doubles && team1_ratings.len() >= 2 { + (team1_ratings[0].1 + team1_ratings[1].1) / 2.0 + } else { + team1_ratings.get(0).map(|(_, r)| *r).unwrap_or(START_RATING) + }; + + let expected = expected_score(*player_rating, effective_opp); + let rating_change = K_FACTOR * (team2_performance - expected); + let new_rating = player_rating + rating_change; + + elo_ratings.insert(*player_id, new_rating); + } + + // Progress indicator every 5 matches + if (i + 1) % 5 == 0 || i == all_matches.len() - 1 { + println!(" Processed {}/{} matches", i + 1, all_matches.len()); } } - println!("✅ ELO ratings calculated\n"); - - // Count matches per player - let mut match_counts: HashMap = HashMap::new(); - for match_info in match_data_map.values() { - for pid in &match_info.team1_players { - *match_counts.entry(*pid).or_insert(0) += 1; - } - for pid in &match_info.team2_players { - *match_counts.entry(*pid).or_insert(0) += 1; - } - } + println!("\n✅ ELO ratings calculated\n"); // Build comparison data - let mut comparisons = vec![]; + let mut comparisons: Vec = vec![]; for (player_id, name, glicko2_singles, glicko2_doubles) in &players { - let (elo_singles, elo_doubles) = elo_ratings.get(player_id).copied().unwrap_or((1500.0, 1500.0)); + let elo_unified = elo_ratings.get(player_id).copied().unwrap_or(START_RATING); + let glicko2_avg = (glicko2_singles + glicko2_doubles) / 2.0; let matches = match_counts.get(player_id).copied().unwrap_or(0); comparisons.push(PlayerRatings { @@ -226,93 +241,126 @@ async fn main() { name: name.clone(), glicko2_singles: *glicko2_singles, glicko2_doubles: *glicko2_doubles, - elo_singles, - elo_doubles, - singles_diff: elo_singles - glicko2_singles, - doubles_diff: elo_doubles - glicko2_doubles, + elo_unified, + glicko2_avg, + elo_diff: elo_unified - glicko2_avg, matches_played: matches, }); } - // Sort by biggest differences - comparisons.sort_by(|a, b| { - let a_total_diff = (a.singles_diff.abs() + a.doubles_diff.abs()) / 2.0; - let b_total_diff = (b.singles_diff.abs() + b.doubles_diff.abs()) / 2.0; - b_total_diff.partial_cmp(&a_total_diff).unwrap() - }); + // Sort by ELO rating (highest first) + comparisons.sort_by(|a, b| b.elo_unified.partial_cmp(&a.elo_unified).unwrap()); + + // Print summary + println!("📊 Final Ratings Comparison:"); + println!("┌────────────────────────────┬────────────┬────────────┬────────────┬─────────┐"); + println!("│ Player │ Glicko Avg │ ELO Unified│ Difference │ Matches │"); + println!("├────────────────────────────┼────────────┼────────────┼────────────┼─────────┤"); + for p in &comparisons { + let diff_str = if p.elo_diff >= 0.0 { + format!("+{:.0}", p.elo_diff) + } else { + format!("{:.0}", p.elo_diff) + }; + println!("│ {:26} │ {:>10.0} │ {:>10.0} │ {:>10} │ {:>7} │", + p.name, p.glicko2_avg, p.elo_unified, diff_str, p.matches_played); + } + println!("└────────────────────────────┴────────────┴────────────┴────────────┴─────────┘"); // Write JSON report let json_output = json!({ "metadata": { "timestamp": chrono::Local::now().to_rfc3339(), "total_players": players.len(), - "total_matches": matches.len(), + "total_matches": all_matches.len(), + "k_factor": K_FACTOR, + "start_rating": START_RATING, }, - "summary": { - "singles": { - "avg_glicko2": players.iter().map(|(_, _, sr, _)| sr).sum::() / players.len() as f64, - "avg_elo": elo_ratings.values().map(|(sr, _)| sr).sum::() / players.len() as f64, - }, - "doubles": { - "avg_glicko2": players.iter().map(|(_, _, _, dr)| dr).sum::() / players.len() as f64, - "avg_elo": elo_ratings.values().map(|(_, dr)| dr).sum::() / players.len() as f64, - } + "system_description": { + "old": "Glicko-2 with separate singles/doubles ratings, RD, volatility", + "new": "Pure ELO with unified rating, per-point scoring, effective opponent formula", }, "players": comparisons.iter().map(|p| json!({ "id": p.id, "name": p.name, - "singles": { - "glicko2": (p.glicko2_singles * 10.0).round() / 10.0, - "elo": (p.elo_singles * 10.0).round() / 10.0, - "difference": (p.singles_diff * 10.0).round() / 10.0, - }, - "doubles": { - "glicko2": (p.glicko2_doubles * 10.0).round() / 10.0, - "elo": (p.elo_doubles * 10.0).round() / 10.0, - "difference": (p.doubles_diff * 10.0).round() / 10.0, + "glicko2": { + "singles": (p.glicko2_singles * 10.0).round() / 10.0, + "doubles": (p.glicko2_doubles * 10.0).round() / 10.0, + "average": (p.glicko2_avg * 10.0).round() / 10.0, }, + "elo_unified": (p.elo_unified * 10.0).round() / 10.0, + "difference": (p.elo_diff * 10.0).round() / 10.0, "matches_played": p.matches_played, })).collect::>() }); let json_path = "/Users/split/Projects/pickleball-elo/docs/rating-comparison.json"; fs::write(json_path, serde_json::to_string_pretty(&json_output).unwrap()).ok(); - println!("💾 JSON report saved to: {}", json_path); + println!("\n💾 JSON report saved to: {}", json_path); // Write Markdown report let mut md = String::from("# Rating System Comparison: Glicko-2 vs Pure ELO\n\n"); + md.push_str("## Overview\n\n"); + md.push_str("This analysis replays all historical matches through the new ELO system to compare ratings.\n\n"); + md.push_str("**Key differences:**\n"); + md.push_str("- **Old system:** Glicko-2 with separate singles/doubles ratings, RD, volatility\n"); + md.push_str("- **New system:** Pure ELO with unified rating, per-point scoring, effective opponent formula\n\n"); + md.push_str("## Summary\n\n"); md.push_str(&format!("- **Total Players:** {}\n", players.len())); - md.push_str(&format!("- **Total Matches:** {}\n", matches.len())); + md.push_str(&format!("- **Total Matches Replayed:** {}\n", all_matches.len())); + md.push_str(&format!("- **K-Factor:** {}\n", K_FACTOR)); md.push_str(&format!("- **Analysis Date:** {}\n\n", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"))); - md.push_str("## Ratings Comparison\n\n"); - md.push_str("| Player | Singles (G2) | Singles (ELO) | Diff | Doubles (G2) | Doubles (ELO) | Diff | Matches |\n"); - md.push_str("|--------|------|------|------|------|------|------|--------|\n"); + md.push_str("## Ratings Comparison (Sorted by New ELO)\n\n"); + md.push_str("| Rank | Player | Glicko-2 Avg | New ELO | Diff | Matches |\n"); + md.push_str("|:----:|--------|-------------:|--------:|-----:|--------:|\n"); - for player in &comparisons { - let s_diff_str = format!("{:+.0}", player.singles_diff); - let d_diff_str = format!("{:+.0}", player.doubles_diff); + for (i, player) in comparisons.iter().enumerate() { + let diff_str = format!("{:+.0}", player.elo_diff); md.push_str(&format!( - "| {} | {:.0} | {:.0} | {} | {:.0} | {:.0} | {} | {} |\n", + "| {} | {} | {:.0} | {:.0} | {} | {} |\n", + i + 1, player.name, - player.glicko2_singles, player.elo_singles, s_diff_str, - player.glicko2_doubles, player.elo_doubles, d_diff_str, + player.glicko2_avg, + player.elo_unified, + diff_str, player.matches_played )); } - md.push_str("\n## Biggest Rating Changes\n\n"); - md.push_str("Players whose ratings changed the most in the conversion:\n\n"); + md.push_str("\n## Key Insights\n\n"); - for (i, player) in comparisons.iter().take(5).enumerate() { - let avg_diff = (player.singles_diff.abs() + player.doubles_diff.abs()) / 2.0; + // Find biggest movers + let mut by_diff: Vec<_> = comparisons.iter().collect(); + by_diff.sort_by(|a, b| b.elo_diff.partial_cmp(&a.elo_diff).unwrap()); + + md.push_str("### Biggest Winners (rating increased)\n\n"); + for player in by_diff.iter().take(3).filter(|p| p.elo_diff > 0.0) { md.push_str(&format!( - "{}. **{}** - Average change: {:.0} points\n - Singles: {:+.0}\n - Doubles: {:+.0}\n", - i+1, player.name, avg_diff, player.singles_diff, player.doubles_diff + "- **{}**: {:+.0} points (Glicko avg {:.0} → ELO {:.0})\n", + player.name, player.elo_diff, player.glicko2_avg, player.elo_unified )); } + md.push_str("\n### Biggest Losers (rating decreased)\n\n"); + for player in by_diff.iter().rev().take(3).filter(|p| p.elo_diff < 0.0) { + md.push_str(&format!( + "- **{}**: {:+.0} points (Glicko avg {:.0} → ELO {:.0})\n", + player.name, player.elo_diff, player.glicko2_avg, player.elo_unified + )); + } + + md.push_str("\n## Why Ratings Changed\n\n"); + md.push_str("The new system differs in several ways:\n\n"); + md.push_str("1. **Per-point scoring**: Instead of just win/loss, we use `points_won / total_points`. "); + md.push_str("Winning 11-9 gives less credit than winning 11-2.\n\n"); + md.push_str("2. **Effective opponent formula**: In doubles, your effective opponent is calculated as "); + md.push_str("`Opp1 + Opp2 - Teammate`. This means:\n"); + md.push_str(" - Strong teammate → lower effective opponent → less credit for winning\n"); + md.push_str(" - Weak teammate → higher effective opponent → more credit for winning\n\n"); + md.push_str("3. **Unified rating**: Singles and doubles contribute to one rating instead of two.\n\n"); + let md_path = "/Users/split/Projects/pickleball-elo/docs/rating-comparison.md"; fs::write(md_path, md).ok(); println!("💾 Markdown report saved to: {}", md_path);