PickleBALLER/src/demo.rs
Split 9ae1bd37fd Refactor: Implement all four ELO system improvements
CHANGES:

1. Replace arbitrary margin bonus with per-point expected value
   - Replace tanh formula in score_weight.rs
   - New: performance = actual_points / total_points
   - Expected: P(point) = 1 / (1 + 10^((R_opp - R_self)/400))
   - Outcome now reflects actual performance vs expected

2. Fix RD-based distribution (backwards logic)
   - Changed weight from 1.0/rd² to rd²
   - Higher RD (uncertain) now gets more change
   - Lower RD (certain) gets less change
   - Follows correct Glicko-2 principle

3. Add new effective opponent calculation for doubles
   - New functions: calculate_effective_opponent_rating()
   - Formula: Eff_Opp = Opp1 + Opp2 - Teammate
   - Personalizes rating change by partner strength
   - Strong teammate → lower effective opponent
   - Weak teammate → higher effective opponent

4. Document unified rating consolidation (Phase 1)
   - Added REFACTORING_NOTES.md with full plan
   - Schema changes identified but deferred
   - Code is ready for single rating migration

All changes:
- Compile successfully (release build)
- Pass all 14 unit tests
- Backwards compatible with demo/example code updated
- Database backup available at pickleball.db.backup-20260226-105326
2026-02-26 10:58:10 -05:00

288 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Demo module for generating test data and running simulations
use crate::glicko::{GlickoRating, Glicko2Calculator, calculate_weighted_score};
use fake::faker::name::en::Name;
use fake::Fake;
extern crate rand;
use rand::Rng;
use rand::seq::SliceRandom;
#[derive(Debug, Clone)]
pub struct Player {
pub name: String,
pub email: String,
pub singles: GlickoRating,
pub doubles: GlickoRating,
pub true_skill: f64, // Hidden skill level for simulation
}
impl Player {
pub fn new_random() -> Self {
let mut rng = rand::thread_rng();
let name: String = Name().fake();
let email = format!("{}@example.com", name.to_lowercase().replace(' ', "."));
// Random true skill between 1200-1800
let true_skill = rng.gen_range(1200.0..1800.0);
Self {
name,
email,
singles: GlickoRating::new_player(),
doubles: GlickoRating::new_player(),
true_skill,
}
}
}
#[derive(Debug)]
pub struct Match {
pub match_type: MatchType,
pub team1: Vec<usize>, // Player indices
pub team2: Vec<usize>,
pub team1_score: i32,
pub team2_score: i32,
}
#[derive(Debug, Clone, Copy)]
pub enum MatchType {
Singles,
Doubles,
}
/// Simulate a match outcome based on true skill levels
pub fn simulate_match(
team1_skills: &[f64],
team2_skills: &[f64],
) -> (i32, i32) {
let mut rng = rand::thread_rng();
// Team skill is average
let team1_avg: f64 = team1_skills.iter().sum::<f64>() / team1_skills.len() as f64;
let team2_avg: f64 = team2_skills.iter().sum::<f64>() / team2_skills.len() as f64;
// Win probability based on skill difference
let skill_diff = team1_avg - team2_avg;
let win_prob = 1.0 / (1.0 + 10_f64.powf(-skill_diff / 400.0));
// Determine winner
let team1_wins = rng.gen::<f64>() < win_prob;
// Generate score (pickleball to 11)
if team1_wins {
let margin = rng.gen_range(1..10); // 1-9 point margin
let team2_score = 11 - margin;
(11, team2_score)
} else {
let margin = rng.gen_range(1..10);
let team1_score = 11 - margin;
(team1_score, 11)
}
}
/// Generate a session of matches
pub fn generate_session(players: &mut [Player], num_matches: usize) -> Vec<Match> {
let mut rng = rand::thread_rng();
let mut matches = Vec::new();
let calc = Glicko2Calculator::new();
for _ in 0..num_matches {
// Randomly choose singles or doubles
let match_type = if rng.gen_bool(0.5) {
MatchType::Singles
} else {
MatchType::Doubles
};
let match_result = match match_type {
MatchType::Singles => {
// Pick 2 random players
let p1_idx = rng.gen_range(0..players.len());
let mut p2_idx = rng.gen_range(0..players.len());
while p2_idx == p1_idx {
p2_idx = rng.gen_range(0..players.len());
}
let (team1_score, team2_score) = simulate_match(
&[players[p1_idx].true_skill],
&[players[p2_idx].true_skill],
);
// Update ratings with performance-based weighting
let p1_outcome = calculate_weighted_score(
players[p1_idx].singles.rating,
players[p2_idx].singles.rating,
team1_score,
team2_score
);
let p2_outcome = calculate_weighted_score(
players[p2_idx].singles.rating,
players[p1_idx].singles.rating,
team2_score,
team1_score
);
players[p1_idx].singles = calc.update_rating(
&players[p1_idx].singles,
&[(players[p2_idx].singles, p1_outcome)],
);
players[p2_idx].singles = calc.update_rating(
&players[p2_idx].singles,
&[(players[p1_idx].singles, p2_outcome)],
);
Match {
match_type,
team1: vec![p1_idx],
team2: vec![p2_idx],
team1_score,
team2_score,
}
}
MatchType::Doubles => {
// Pick 4 random players
let mut indices: Vec<usize> = (0..players.len()).collect();
indices.shuffle(&mut rng);
let team1_indices = vec![indices[0], indices[1]];
let team2_indices = vec![indices[2], indices[3]];
let team1_skills: Vec<f64> = team1_indices.iter()
.map(|&i| players[i].true_skill)
.collect();
let team2_skills: Vec<f64> = team2_indices.iter()
.map(|&i| players[i].true_skill)
.collect();
let (team1_score, team2_score) = simulate_match(&team1_skills, &team2_skills);
// Update doubles ratings (simplified - each player vs team average)
for &idx in &team1_indices {
let avg_opponent_rating = team2_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0;
let outcome = calculate_weighted_score(
players[idx].doubles.rating,
avg_opponent_rating,
team1_score,
team2_score
);
// Simulate vs average opponent
let avg_opponent = GlickoRating {
rating: avg_opponent_rating,
rd: team2_indices.iter().map(|&i| players[i].doubles.rd).sum::<f64>() / 2.0,
volatility: 0.06,
};
players[idx].doubles = calc.update_rating(
&players[idx].doubles,
&[(avg_opponent, outcome)],
);
}
for &idx in &team2_indices {
let avg_opponent_rating = team1_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0;
let outcome = calculate_weighted_score(
players[idx].doubles.rating,
avg_opponent_rating,
team2_score,
team1_score
);
let avg_opponent = GlickoRating {
rating: avg_opponent_rating,
rd: team1_indices.iter().map(|&i| players[i].doubles.rd).sum::<f64>() / 2.0,
volatility: 0.06,
};
players[idx].doubles = calc.update_rating(
&players[idx].doubles,
&[(avg_opponent, outcome)],
);
}
Match {
match_type,
team1: team1_indices,
team2: team2_indices,
team1_score,
team2_score,
}
}
};
matches.push(match_result);
}
matches
}
/// Print session summary
pub fn print_summary(players: &[Player], matches: &[Match]) {
println!("\n🏓 Session Summary");
println!("==================");
println!("\n{} matches played\n", matches.len());
println!("Match Results:");
for (i, m) in matches.iter().enumerate() {
match m.match_type {
MatchType::Singles => {
let p1 = &players[m.team1[0]];
let p2 = &players[m.team2[0]];
let winner = if m.team1_score > m.team2_score { &p1.name } else { &p2.name };
println!(
" {}. Singles: {} vs {}{} wins {}-{}",
i + 1,
p1.name,
p2.name,
winner,
m.team1_score.max(m.team2_score),
m.team1_score.min(m.team2_score)
);
}
MatchType::Doubles => {
let t1_names: Vec<&str> = m.team1.iter().map(|&i| players[i].name.as_str()).collect();
let t2_names: Vec<&str> = m.team2.iter().map(|&i| players[i].name.as_str()).collect();
let winner = if m.team1_score > m.team2_score { "Team 1" } else { "Team 2" };
println!(
" {}. Doubles: {} vs {}{} wins {}-{}",
i + 1,
t1_names.join(" & "),
t2_names.join(" & "),
winner,
m.team1_score.max(m.team2_score),
m.team1_score.min(m.team2_score)
);
}
}
}
println!("\n📊 Singles Leaderboard:");
let mut singles_sorted = players.to_vec();
singles_sorted.sort_by(|a, b| b.singles.rating.partial_cmp(&a.singles.rating).unwrap());
for (i, p) in singles_sorted.iter().take(10).enumerate() {
println!(
" {}. {} - {:.1} (RD: {:.1}, σ: {:.3})",
i + 1,
p.name,
p.singles.rating,
p.singles.rd,
p.singles.volatility
);
}
println!("\n📊 Doubles Leaderboard:");
let mut doubles_sorted = players.to_vec();
doubles_sorted.sort_by(|a, b| b.doubles.rating.partial_cmp(&a.doubles.rating).unwrap());
for (i, p) in doubles_sorted.iter().take(10).enumerate() {
println!(
" {}. {} - {:.1} (RD: {:.1}, σ: {:.3})",
i + 1,
p.name,
p.doubles.rating,
p.doubles.rd,
p.doubles.volatility
);
}
}