Files
trueskill-tt/examples/atp.rs
T
logaritmiskandClaude Opus 5 31564b71a0 docs: document the whole public surface and deny(missing_docs)
80 undocumented public items, including three that are first contact:
`History::current_skill` — the method the crate's own first example calls
— `EventBuilder`, the type `h.event(t)` hands you, and `Gaussian::mu()`.
Now zero, and `#![deny(missing_docs)]` keeps it that way.

Several docs are measurements rather than readings of the code:

- `Outcome::Ranked` says ranks are used ordinally, so `[0, 1, 2]` and
  `[0, 5, 90]` are the same observation. Measured: bit-identical
  posteriors for both.
- `OwnedGame::log_evidence` says two identically-rated competitors give
  exactly `ln(0.5)`. Written as a doctest, so it runs.
- `Member::weight` says zero and negative are accepted. Measured.
- `ConvergenceReport::final_step` is `(|Δmu|, |Δsigma|)` in skill units,
  NOT natural parameters. That one had to be traced through
  `Gaussian::delta` rather than assumed from the neighbouring vocabulary.
- `GameOptions::score_sigma` rejects non-positive and NaN but accepts
  `+inf`, which is what the guard actually says.

README: it is the front door for a crate on a private registry, and it
opened with a link dump followed by 130 lines on drift. The first
`record_winner → converge → current_skill` block was at line 226 of 307.
It now leads with what the crate is, an install line, a quickstart, a
"which entry point?" table, and the `converge`-is-strict rationale that
was the crate's most opinionated recent decision and went unmentioned.
The two canonical examples disagreed on spelling (`History::default()`
vs `History::builder().build()`, `current_skill("a")` vs
`current_skill(&"a")`); they now agree. Five new README blocks are
doctested, taking the suite from 19 to 25.

`pub use smallvec;`. Four public items name `SmallVec` in their
signatures, and the only `Joint` example failed to compile from a
consumer crate with `unresolved import smallvec` — the dependency was in
the API but not reachable. Both worked examples now use the re-export,
so they teach the path that works downstream.

Vocabulary, from #75: "agent" was a fourth word for competitor, 200
occurrences, and it had reached public signatures before #73 un-exported
`TimeSlice`. Now zero.

Closes #77. Refs #75.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 21:53:50 +02:00

255 lines
7.3 KiB
Rust

use plotters::prelude::*;
use time::{Date, Month};
use trueskill_tt::{
Event, History, Member, Outcome, Team, drift::ConstantDrift, smallvec::smallvec,
};
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()
.key_type::<String>()
.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).unwrap() {
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).unwrap() {
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()]
}
}
}