Game has no public constructor — every Game::* returns OwnedGame #69

Closed
opened 2026-09-09 17:53:30 +00:00 by logaritmisk · 1 comment
Owner

lib.rs advertises Game in "Core types" as "one match in isolation". It cannot be constructed.

Measured

let g = Game::<i64, _>::ranked(&[&[a], &[a]], Outcome::winner(0, 2), &GameOptions::default())?;
println!("{}", std::any::type_name_of_val(&g));
// trueskill_tt::game::OwnedGame<i64, trueskill_tt::drift::ConstantDrift>

Every associated function named Game::* returns OwnedGame:

Game::ranked (game.rs:470) -> Result<OwnedGame<T, D>, _>
Game::scored (:530) -> Result<OwnedGame<T, D>, _>
Game::free_for_all (:601) -> Result<OwnedGame<T, D>, _>
Game::ranked_with_arena pub(crate)

So Game<'a, T, D> has no public constructor at all, and let g: Game = Game::ranked(..)? does not compile. Owned is an implementation detail — borrowed versus owned slices — that means nothing to someone scoring one match, and both types carry a posteriors() / log_evidence() pair, so rustdoc shows the surface twice.

one_v_one breaks the family a second way

game.rs:586:

pub fn one_v_one(a, b, outcome, options) -> Result<(Gaussian, Gaussian), _>

Every sibling returns a game; the one convenience wrapper returns a tuple, so it is the only constructor you cannot ask for log_evidence(). An auditor reading the family guessed both the arity and the return type wrong.

Fix

Swap the names so the public type is the one you get:

pub struct Game<T: Time = i64, D: Drift<T> = ConstantDrift> { .. }   // today's OwnedGame
pub(crate) struct GameRef<'a, T, D> { .. }                          // today's Game<'a>

impl<T: Time, D: Drift<T>> Game<T, D> {
    pub fn ranked(teams: &[&[Rating<T, D>]], outcome: Outcome, options: &GameOptions) -> Result<Self, InferenceError>;
    pub fn scored(..) -> Result<Self, InferenceError>;
    pub fn free_for_all(competitors: &[&Rating<T, D>], ..) -> Result<Self, InferenceError>;
    pub fn one_v_one(..) -> Result<Self, InferenceError>;   // was (Gaussian, Gaussian)
    pub fn posteriors(&self) -> Vec<Vec<Gaussian>>;
    pub fn log_evidence(&self) -> f64;
}

That removes a type from the public API, removes a lifetime parameter from Game, halves the apparent surface of the module, and makes the advertised core type constructible.

While here

Game::ranked, Game::scored and Game::free_for_all have /// # Errors as their entire doc — no summary line, so rustdoc's index renders the error list as the summary. There is no example of Game anywhere: not in the README, not in lib.rs, not on the type. It is advertised as a core type with no worked example.

free_for_all's parameter is players:, the only place the crate says "player" rather than "competitor" (236 uses elsewhere).

Breaks: removes OwnedGame from the public API; changes one_v_one's return type.

Found by an API audit, 2026-09-09; the return type reproduced independently.

`lib.rs` advertises `Game` in "Core types" as *"one match in isolation"*. It cannot be constructed. ## Measured ```rust let g = Game::<i64, _>::ranked(&[&[a], &[a]], Outcome::winner(0, 2), &GameOptions::default())?; println!("{}", std::any::type_name_of_val(&g)); // trueskill_tt::game::OwnedGame<i64, trueskill_tt::drift::ConstantDrift> ``` Every associated function named `Game::*` returns `OwnedGame`: | | | |---|---| | `Game::ranked` (`game.rs:470`) | `-> Result<OwnedGame<T, D>, _>` | | `Game::scored` (`:530`) | `-> Result<OwnedGame<T, D>, _>` | | `Game::free_for_all` (`:601`) | `-> Result<OwnedGame<T, D>, _>` | | `Game::ranked_with_arena` | `pub(crate)` | So `Game<'a, T, D>` has **no public constructor at all**, and `let g: Game = Game::ranked(..)?` does not compile. `Owned` is an implementation detail — borrowed versus owned slices — that means nothing to someone scoring one match, and both types carry a `posteriors()` / `log_evidence()` pair, so rustdoc shows the surface twice. ## `one_v_one` breaks the family a second way `game.rs:586`: ```rust pub fn one_v_one(a, b, outcome, options) -> Result<(Gaussian, Gaussian), _> ``` Every sibling returns a game; the one convenience wrapper returns a tuple, so it is the only constructor you cannot ask for `log_evidence()`. An auditor reading the family guessed both the arity and the return type wrong. ## Fix Swap the names so the public type is the one you get: ```rust pub struct Game<T: Time = i64, D: Drift<T> = ConstantDrift> { .. } // today's OwnedGame pub(crate) struct GameRef<'a, T, D> { .. } // today's Game<'a> impl<T: Time, D: Drift<T>> Game<T, D> { pub fn ranked(teams: &[&[Rating<T, D>]], outcome: Outcome, options: &GameOptions) -> Result<Self, InferenceError>; pub fn scored(..) -> Result<Self, InferenceError>; pub fn free_for_all(competitors: &[&Rating<T, D>], ..) -> Result<Self, InferenceError>; pub fn one_v_one(..) -> Result<Self, InferenceError>; // was (Gaussian, Gaussian) pub fn posteriors(&self) -> Vec<Vec<Gaussian>>; pub fn log_evidence(&self) -> f64; } ``` That removes a type from the public API, removes a lifetime parameter from `Game`, halves the apparent surface of the module, and makes the advertised core type constructible. ## While here `Game::ranked`, `Game::scored` and `Game::free_for_all` have `/// # Errors` as their **entire** doc — no summary line, so rustdoc's index renders the error list as the summary. There is no example of `Game` anywhere: not in the README, not in `lib.rs`, not on the type. It is advertised as a core type with no worked example. `free_for_all`'s parameter is `players:`, the only place the crate says "player" rather than "competitor" (236 uses elsewhere). **Breaks:** removes `OwnedGame` from the public API; changes `one_v_one`'s return type. Found by an API audit, 2026-09-09; the return type reproduced independently.
logaritmisk added the apibreaking labels 2026-09-09 17:58:01 +00:00
Author
Owner

Done in 92d690d (merged as 8e34410), exactly as proposed.

Game<T, D> is the public owned type — no lifetime parameter — and the borrowing form is pub(crate) GameRef<'a, T, D>. OwnedGame is gone from the public API.

trueskill_tt::game::Game<i64, trueskill_tt::drift::ConstantDrift>
one_v_one log_evidence = -0.6931471805599453

one_v_one returns Self. The test covering it now also asserts what it could not ask before: two identical ratings give exactly ln(0.5).

Two small consequences of the swap worth recording:

  • GameRef::log_evidence and GameRef::posteriors became dead once Game stopped delegating to them — every in-crate caller reads the fields directly. log_evidence is deleted; posteriors is kept #[cfg(test)], since the module's own goldens use it. Same shape as the earlier get_composition / get_results fix.
  • free_for_all's parameter was already competitors, so the "player" item was closed before this pass.

The doc items are done too: ranked, scored and free_for_all have summary lines, and Game carries a worked example — an underdog beating a favourite, asserting both posteriors moved the right way and that log_evidence is below ln(0.5) because an upset is improbable. It runs as a doctest.

Done in 92d690d (merged as 8e34410), exactly as proposed. `Game<T, D>` is the public owned type — no lifetime parameter — and the borrowing form is `pub(crate) GameRef<'a, T, D>`. `OwnedGame` is gone from the public API. ``` trueskill_tt::game::Game<i64, trueskill_tt::drift::ConstantDrift> one_v_one log_evidence = -0.6931471805599453 ``` `one_v_one` returns `Self`. The test covering it now also asserts what it could not ask before: two identical ratings give exactly `ln(0.5)`. Two small consequences of the swap worth recording: - `GameRef::log_evidence` and `GameRef::posteriors` became dead once `Game` stopped delegating to them — every in-crate caller reads the fields directly. `log_evidence` is deleted; `posteriors` is kept `#[cfg(test)]`, since the module's own goldens use it. Same shape as the earlier `get_composition` / `get_results` fix. - `free_for_all`'s parameter was already `competitors`, so the "player" item was closed before this pass. The doc items are done too: `ranked`, `scored` and `free_for_all` have summary lines, and `Game` carries a worked example — an underdog beating a favourite, asserting both posteriors moved the right way and that `log_evidence` is below `ln(0.5)` because an upset is improbable. It runs as a doctest.
Sign in to join this conversation.