Closes the last open item in #25. `cargo clippy -W missing_errors_doc -W missing_panics_doc -W must_use_candidate -W doc_markdown` went from 56 warnings to zero. The 13 hand-written sections name the actual variants each function returns rather than gesturing at "an error". Establishing that meant reading the error paths — `Game::ranked` alone returns four distinct variants, and `record_draw` can hit TieWithoutDrawProbability where `record_winner` provably cannot, since a two-team decisive outcome has nothing to tie. Documenting those as interchangeable would have been worse than leaving them undocumented, because a reader would trust it. Two existing doc comments already described panics in prose but not under a `# Panics` heading, so neither rustdoc nor clippy surfaced them: `Outcome::winner` and `EventBuilder::weights`. Both now carry the heading, and `Outcome::winner` gained the note that it ties every loser, so `n >= 3` needs a positive p_draw — the crate's easiest error to hit by accident. The 43 mechanical fixes (31 `#[must_use]` on pure accessors, 11 missing backticks) were applied with `cargo clippy --fix`. `#[must_use]` on Gaussian's arithmetic and on `posteriors()` matters: discarding those results is always a bug, and until now nothing said so. Also documented why `[profile.release] debug = true` exists — cargo-flamegraph needs the symbols, and library profile settings are ignored downstream, so it reads as an oversight without the note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
153 lines
5.2 KiB
Rust
153 lines
5.2 KiB
Rust
//! Schedule trait and built-in implementations.
|
|
//!
|
|
//! A schedule drives factor propagation to convergence. The default
|
|
//! `EpsilonOrMax` performs one `TeamSum` sweep (setup) then alternating
|
|
//! forward/backward sweeps over the iterating factors until the max
|
|
//! delta drops below epsilon or `max` iterations is reached.
|
|
|
|
use crate::factor::{BuiltinFactor, Factor, VarStore};
|
|
|
|
/// Result returned by a `Schedule::run` call.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ScheduleReport {
|
|
pub iterations: usize,
|
|
pub final_step: (f64, f64),
|
|
pub converged: bool,
|
|
}
|
|
|
|
/// Drives factor propagation to convergence.
|
|
pub trait Schedule: Send + Sync {
|
|
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport;
|
|
}
|
|
|
|
/// Default schedule: sweep forward then backward until step ≤ eps or iter == max.
|
|
///
|
|
/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the
|
|
/// same factor layout (`TeamSums` first, then alternating RankDiff/Trunc pairs).
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct EpsilonOrMax {
|
|
pub eps: f64,
|
|
pub max: usize,
|
|
}
|
|
|
|
impl Default for EpsilonOrMax {
|
|
fn default() -> Self {
|
|
// Derived from `ConvergenceOptions` so there is one source of truth for
|
|
// the tolerance and iteration cap. These previously disagreed: this
|
|
// default capped at 10 iterations while `ConvergenceOptions` allowed 30,
|
|
// and which applied depended on whether inference went through
|
|
// `run_chain` or a `Schedule`.
|
|
let defaults = crate::ConvergenceOptions::default();
|
|
|
|
Self {
|
|
eps: defaults.epsilon,
|
|
max: defaults.max_iter,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Schedule for EpsilonOrMax {
|
|
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport {
|
|
// Partition: leading run of TeamSum factors run exactly once (setup).
|
|
let n_setup = factors
|
|
.iter()
|
|
.position(|f| !matches!(f, BuiltinFactor::TeamSum(_)))
|
|
.unwrap_or(factors.len());
|
|
|
|
for f in factors[..n_setup].iter_mut() {
|
|
f.propagate(vars);
|
|
}
|
|
|
|
let mut iterations = 0;
|
|
// With no iterating factors the graph is already at its fixed point:
|
|
// the setup pass above is all there is to do. Reporting `converged:
|
|
// false` with an infinite step for that case gave callers a false
|
|
// negative.
|
|
let mut final_step = (0.0, 0.0);
|
|
let mut converged = true;
|
|
|
|
if n_setup < factors.len() {
|
|
final_step = (f64::INFINITY, f64::INFINITY);
|
|
converged = false;
|
|
for _ in 0..self.max {
|
|
let mut step = (0.0_f64, 0.0_f64);
|
|
|
|
// Forward sweep over iterating factors.
|
|
for f in factors[n_setup..].iter_mut() {
|
|
let d = f.propagate(vars);
|
|
step.0 = step.0.max(d.0);
|
|
step.1 = step.1.max(d.1);
|
|
}
|
|
|
|
// Backward sweep.
|
|
for f in factors[n_setup..].iter_mut().rev() {
|
|
let d = f.propagate(vars);
|
|
step.0 = step.0.max(d.0);
|
|
step.1 = step.1.max(d.1);
|
|
}
|
|
|
|
iterations += 1;
|
|
final_step = step;
|
|
|
|
if step.0 <= self.eps && step.1 <= self.eps {
|
|
converged = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
ScheduleReport {
|
|
iterations,
|
|
final_step,
|
|
converged,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{N_INF, factor::team_sum::TeamSumFactor, gaussian::Gaussian};
|
|
|
|
#[test]
|
|
fn schedule_runs_setup_factors_once() {
|
|
// Single TeamSum factor; schedule should propagate it exactly once and report 0 iterations.
|
|
let mut vars = VarStore::new();
|
|
let out = vars.alloc(N_INF);
|
|
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
|
|
inputs: vec![(Gaussian::from_ms(5.0, 1.0), 1.0)],
|
|
out,
|
|
})];
|
|
let schedule = EpsilonOrMax::default();
|
|
let report = schedule.run(&mut factors, &mut vars);
|
|
assert_eq!(report.iterations, 0);
|
|
// The team-perf var should hold the sum.
|
|
let result = vars.get(out);
|
|
assert!((result.mu() - 5.0).abs() < 1e-12);
|
|
}
|
|
|
|
#[test]
|
|
fn report_marks_converged_when_no_iterating_factors() {
|
|
// A graph of only setup factors has nothing to iterate, so it is at its
|
|
// fixed point after the setup pass: 0 iterations, and converged.
|
|
let mut vars = VarStore::new();
|
|
let out = vars.alloc(N_INF);
|
|
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
|
|
inputs: vec![(Gaussian::from_ms(0.0, 1.0), 1.0)],
|
|
out,
|
|
})];
|
|
let report = EpsilonOrMax::default().run(&mut factors, &mut vars);
|
|
assert_eq!(report.iterations, 0);
|
|
assert!(report.converged);
|
|
assert_eq!(report.final_step, (0.0, 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn default_matches_convergence_options() {
|
|
let schedule = EpsilonOrMax::default();
|
|
let options = crate::ConvergenceOptions::default();
|
|
assert_eq!(schedule.max, options.max_iter);
|
|
assert_eq!(schedule.eps, options.epsilon);
|
|
}
|
|
}
|