`gamma` enters only as `gamma * gamma`, so the sign was squared away: measured against the old public-field form, `ConstantDrift(-0.0833)` produced results bit identical to `ConstantDrift(0.0833)`. The sign was neither rejected nor honoured — it vanished. It could not be checked while the field was a public tuple position, because there was nothing to intercept. Validating inside `variance_for_elapsed` would have been worse: it runs in the sweep, so a construction-time mistake would panic mid-inference, and `Gaussian::from_ms` is a worked example of why that is the wrong place — rejecting NaN there turned the NonFiniteResult reporting path into a crash. So `ConstantDrift::new` is the only way in and it checks, with `gamma()` to read the value back. 129 call sites rewritten across src, tests, benches, examples and the README. The dated plan and spec documents under docs/superpowers are left alone: they record what was built at the time, and rewriting them would falsify that. tests/constructor_validation.rs is the more valuable half. This defect class was closed three times in one session and reopened twice, because each fix validated the layer it had just touched and inferred the rest — `HistoryBuilder`, then `Game`'s own entry points, then the constructors beneath both. A per-site fix cannot notice the site nobody thought of, so that file enumerates every public entry point taking a magnitude and asserts each refuses negative and non-finite values. It found an eleventh defect on its first run: `HistoryBuilder::score_sigma` accepted infinity, because `inf > 0.0` is true and the assert only tested positivity. Fixed, and its own `should_panic` message updated to match. `Gaussian::from_ms` is deliberately exempt from the non-finite half, for the reason above: a broken fit produces a NaN sigma legitimately and `converge` must be allowed to report it. The convergence-level drift-variance check stays and is now tested through a custom `Drift` implementation, since `ConstantDrift` can no longer reach it. That check is the only thing standing between a third-party `Drift` and a NaN fit. BREAKING CHANGE: `ConstantDrift`'s field is private. Replace `ConstantDrift(x)` with `ConstantDrift::new(x)`, and `drift().0` with `drift().gamma()`. `HistoryBuilder::score_sigma` now rejects infinity. Closes #65 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
253 lines
7.3 KiB
Rust
253 lines
7.3 KiB
Rust
use plotters::prelude::*;
|
|
use smallvec::smallvec;
|
|
use time::{Date, Month};
|
|
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
|
|
|
|
fn main() {
|
|
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
|
|
|
|
let from = Date::from_calendar_date(1900, Month::January, 1).unwrap();
|
|
let time_format = time::format_description::parse("[year]-[month]-[day]").unwrap();
|
|
|
|
let mut events: Vec<Event<i64, String>> = Vec::new();
|
|
|
|
for row in csv.records() {
|
|
let date = Date::parse(&row["time_start"], &time_format).unwrap();
|
|
let time = (date - from).whole_days();
|
|
|
|
if &row["double"] == "t" {
|
|
events.push(Event {
|
|
time,
|
|
teams: smallvec![
|
|
Team::with_members([
|
|
Member::new(row["w1_id"].to_owned()),
|
|
Member::new(row["w2_id"].to_owned()),
|
|
]),
|
|
Team::with_members([
|
|
Member::new(row["l1_id"].to_owned()),
|
|
Member::new(row["l2_id"].to_owned()),
|
|
]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
});
|
|
} else {
|
|
events.push(Event {
|
|
time,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new(row["w1_id"].to_owned())]),
|
|
Team::with_members([Member::new(row["l1_id"].to_owned())]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
});
|
|
}
|
|
}
|
|
|
|
let mut hist: History<i64, _, _, String> = History::builder_with_key()
|
|
.sigma(1.6)
|
|
.drift(ConstantDrift::new(0.036))
|
|
.convergence(trueskill_tt::ConvergenceOptions {
|
|
// This history needs 30 sweeps to reach the epsilon below. It was
|
|
// capped at 10 until the `#[must_use]` on `ConvergenceReport`
|
|
// surfaced that the example had been shipping a short fit.
|
|
max_iter: 100,
|
|
epsilon: 0.01,
|
|
alpha: 1.0,
|
|
})
|
|
.build();
|
|
|
|
hist.add_events(events).unwrap();
|
|
|
|
// Read the report rather than discarding it. A fit that hits `max_iter`
|
|
// without reaching `epsilon` is not an error and does not look wrong — every
|
|
// rating comes back finite and sensibly ordered — so this flag is the only
|
|
// thing that says the numbers were still moving when the sweep stopped.
|
|
let report = hist.converge().unwrap();
|
|
eprintln!(
|
|
"converged={} after {} sweeps, final step {:?}",
|
|
report.converged, report.iterations, report.final_step
|
|
);
|
|
if !report.converged {
|
|
eprintln!(
|
|
"warning: stopped after {} sweeps with a final step of {:?}, \
|
|
short of epsilon — raise ConvergenceOptions::max_iter",
|
|
report.iterations, report.final_step
|
|
);
|
|
}
|
|
|
|
let players = [
|
|
("aggasi", "a092", 38800i64),
|
|
("borg", "b058", 30300),
|
|
("connors", "c044", 31250),
|
|
("courier", "c243", 35750),
|
|
("djokovic", "d643", i64::MAX),
|
|
("edberg", "e004", 34750),
|
|
("federer", "f324", i64::MAX),
|
|
("hewitt", "h432", 40750),
|
|
("mcenroe", "m047", 33000),
|
|
("lendl", "l018", 33750),
|
|
("murray", "mc10", 60750),
|
|
("nadal", "n409", i64::MAX),
|
|
("nastase", "n008", 28750),
|
|
("sampras", "s402", i64::MAX),
|
|
("wilander", "w023", 32600),
|
|
];
|
|
|
|
let mut x_spec = (f64::MAX, f64::MIN);
|
|
let mut y_spec = (f64::MAX, f64::MIN);
|
|
|
|
for &(_, id, cutoff) in &players {
|
|
for (ts, gs) in hist.learning_curve(id) {
|
|
if ts >= cutoff {
|
|
continue;
|
|
}
|
|
|
|
let ts = ts as f64;
|
|
|
|
if ts < x_spec.0 {
|
|
x_spec.0 = ts;
|
|
}
|
|
|
|
if ts > x_spec.1 {
|
|
x_spec.1 = ts;
|
|
}
|
|
|
|
let upper = gs.mu() + gs.sigma();
|
|
let lower = gs.mu() - gs.sigma();
|
|
|
|
if lower < y_spec.0 {
|
|
y_spec.0 = lower;
|
|
}
|
|
|
|
if upper > y_spec.1 {
|
|
y_spec.1 = upper;
|
|
}
|
|
}
|
|
}
|
|
|
|
let root = SVGBackend::new("plot.svg", (1280, 640)).into_drawing_area();
|
|
|
|
root.fill(&WHITE).unwrap();
|
|
|
|
let mut chart = ChartBuilder::on(&root)
|
|
.margin(5)
|
|
.x_label_area_size(30)
|
|
.y_label_area_size(30)
|
|
.build_cartesian_2d(x_spec.0..x_spec.1, y_spec.0..y_spec.1)
|
|
.unwrap();
|
|
|
|
chart.configure_mesh().draw().unwrap();
|
|
|
|
for (idx, &(player, id, cutoff)) in players.iter().enumerate() {
|
|
let mut data = Vec::new();
|
|
let mut upper = Vec::new();
|
|
let mut lower = Vec::new();
|
|
|
|
for (ts, gs) in hist.learning_curve(id) {
|
|
if ts >= cutoff {
|
|
continue;
|
|
}
|
|
|
|
data.push((ts as f64, gs.mu()));
|
|
upper.push((ts as f64, gs.mu() + gs.sigma()));
|
|
lower.push((ts as f64, gs.mu() - gs.sigma()));
|
|
}
|
|
|
|
let color = Palette99::pick(idx);
|
|
|
|
let band = upper
|
|
.into_iter()
|
|
.chain(lower.into_iter().rev())
|
|
.collect::<Vec<_>>();
|
|
|
|
chart
|
|
.plotting_area()
|
|
.draw(&Polygon::new(band, color.mix(0.15)))
|
|
.unwrap();
|
|
|
|
chart
|
|
.draw_series(LineSeries::new(data, &color))
|
|
.unwrap()
|
|
.label(player)
|
|
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], &color));
|
|
}
|
|
|
|
chart
|
|
.configure_series_labels()
|
|
.background_style(WHITE.mix(0.8))
|
|
.border_style(BLACK)
|
|
.draw()
|
|
.unwrap();
|
|
}
|
|
|
|
mod csv {
|
|
use std::{
|
|
fs::File,
|
|
io::{self, BufRead, BufReader, Lines},
|
|
ops,
|
|
path::Path,
|
|
};
|
|
|
|
pub struct Reader {
|
|
header_map: Vec<String>,
|
|
lines: Lines<BufReader<File>>,
|
|
}
|
|
|
|
impl Reader {
|
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
|
|
let mut lines = File::open(path).map(BufReader::new)?.lines();
|
|
|
|
let header_map = if let Some(header) = lines.next() {
|
|
let header = header?;
|
|
|
|
header.split(',').map(Into::into).collect::<Vec<_>>()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(Self { header_map, lines })
|
|
}
|
|
|
|
pub fn records(&mut self) -> Records<'_> {
|
|
Records {
|
|
header_map: &self.header_map,
|
|
lines: &mut self.lines,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct Records<'a> {
|
|
header_map: &'a Vec<String>,
|
|
lines: &'a mut Lines<BufReader<File>>,
|
|
}
|
|
|
|
impl<'a> Iterator for Records<'a> {
|
|
type Item = Record<'a>;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let line = self.lines.next()?;
|
|
|
|
Some(Record {
|
|
header_map: self.header_map,
|
|
columns: line.unwrap().split(',').map(Into::into).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct Record<'a> {
|
|
header_map: &'a Vec<String>,
|
|
columns: Vec<String>,
|
|
}
|
|
|
|
impl<'a> ops::Index<&str> for Record<'a> {
|
|
type Output = str;
|
|
|
|
fn index(&self, index: &str) -> &Self::Output {
|
|
&self.columns[self
|
|
.header_map
|
|
.iter()
|
|
.position(|header| header == index)
|
|
.unwrap()]
|
|
}
|
|
}
|
|
}
|