Two decisions taken before cutting 0.5.0. #42 — the `Schedule` trait was public API the engine never called. Its only call site was `Game::custom`, itself `#[doc(hidden)]`, and `EpsilonOrMax` was never constructed anywhere. Removing that surface showed the problem was larger than the issue described: with `custom` gone, the compiler found `Factor`, `BuiltinFactor`, `RankDiffFactor` and `TeamSumFactor` all dead too. `Game::run_chain` drives a local `DiffFactor` enum and bypasses the whole T1 abstraction — it has done since it was written. So this is not just an unused extension point but the machinery it was built on, and `CLAUDE.md` was documenting it as live architecture. Removed: `graph` module, `Schedule`, `EpsilonOrMax`, `ScheduleReport`, `Game::custom`, `Factor`, `BuiltinFactor`, `RankDiffFactor`, `TeamSumFactor`. `TruncFactor`, `MarginFactor`, `VarStore` and `VarId` stay — inference uses those. The measurement behind choosing removal over wiring is in #42: the within-game loop converges in 1 to 8 iterations against a cap of 30, so a `Residual` schedule has no headroom to reclaim, and `Damped` already shipped as `ConvergenceOptions::alpha`. #20 — `Outcome::winner` panicking on an out-of-range index. Kept, and the reasoning is now on the method. It is the only constructor here that validates, which looks inconsistent until you try deferring like its siblings: `winner(5, 2)` produces ranks `[1, 1]`, an all-tied draw that ingestion accepts without complaint when `p_draw > 0`. Asking "team 5 won" and silently getting "everyone drew" is the exact failure this crate keeps removing, so the check belongs where the mistake is. Adds `Outcome::try_winner` for indices that are computed or parsed rather than written literally, following the `new`/`try_new` convention. That is additive; the panicking form stays because every call site in this repo, its tests and its README passes literals, where a `?` would be noise. BREAKING CHANGE: the `graph` module and everything it exported are removed, as are `Game::custom` and `ScheduleReport`. Closes #42. Closes #20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
105 lines
2.6 KiB
Rust
105 lines
2.6 KiB
Rust
//! Factor graph machinery for within-game inference.
|
|
|
|
use crate::gaussian::Gaussian;
|
|
|
|
/// Identifier for a variable in a `VarStore`.
|
|
///
|
|
/// Variables hold the current Gaussian marginal and are owned by exactly one
|
|
/// `VarStore`. `VarId` is meaningful only within its owning store.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
|
pub struct VarId(pub u32);
|
|
|
|
/// Flat storage of variable marginals.
|
|
///
|
|
/// Variables are allocated by `alloc()` and accessed by `VarId`. The store is
|
|
/// reused across `Game::ranked_with_arena` calls (it lives in the `ScratchArena`); call
|
|
/// `clear()` before reuse.
|
|
#[derive(Debug, Default)]
|
|
pub struct VarStore {
|
|
pub(crate) marginals: Vec<Gaussian>,
|
|
}
|
|
|
|
impl VarStore {
|
|
/// Test-only: inference allocates its store through `ScratchArena`.
|
|
#[cfg(test)]
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.marginals.clear();
|
|
}
|
|
|
|
/// Test-only, as `new`.
|
|
#[cfg(test)]
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
self.marginals.len()
|
|
}
|
|
|
|
pub fn alloc(&mut self, init: Gaussian) -> VarId {
|
|
let id = VarId(self.marginals.len() as u32);
|
|
self.marginals.push(init);
|
|
id
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get(&self, id: VarId) -> Gaussian {
|
|
self.marginals[id.0 as usize]
|
|
}
|
|
|
|
pub fn set(&mut self, id: VarId, g: Gaussian) {
|
|
self.marginals[id.0 as usize] = g;
|
|
}
|
|
}
|
|
|
|
pub mod margin;
|
|
pub mod trunc;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::N_INF;
|
|
|
|
#[test]
|
|
fn alloc_assigns_sequential_ids() {
|
|
let mut store = VarStore::new();
|
|
let a = store.alloc(N_INF);
|
|
let b = store.alloc(N_INF);
|
|
let c = store.alloc(N_INF);
|
|
assert_eq!(a, VarId(0));
|
|
assert_eq!(b, VarId(1));
|
|
assert_eq!(c, VarId(2));
|
|
assert_eq!(store.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn get_returns_initial_value() {
|
|
let mut store = VarStore::new();
|
|
let g = Gaussian::from_ms(2.5, 1.0);
|
|
let id = store.alloc(g);
|
|
assert_eq!(store.get(id), g);
|
|
}
|
|
|
|
#[test]
|
|
fn set_updates_value() {
|
|
let mut store = VarStore::new();
|
|
let id = store.alloc(N_INF);
|
|
let new = Gaussian::from_ms(3.0, 0.5);
|
|
store.set(id, new);
|
|
assert_eq!(store.get(id), new);
|
|
}
|
|
|
|
#[test]
|
|
fn clear_resets_length_keeping_capacity() {
|
|
let mut store = VarStore::new();
|
|
store.alloc(N_INF);
|
|
store.alloc(N_INF);
|
|
let cap = store.marginals.capacity();
|
|
store.clear();
|
|
assert_eq!(store.len(), 0);
|
|
assert_eq!(store.marginals.capacity(), cap);
|
|
}
|
|
}
|