test: add property-based tests, a shared finiteness helper, and boundary inputs

Most of what remained on #26.

**Property tests (`tests/properties.rs`, proptest as a dev-dependency).** Four
invariants over generated 1v1 schedules rather than hand-written fixtures,
which is where this crate's shipped defects actually hid — a linear evidence
product that underflowed only past ~1000 teams, and a batching path no golden
exercised because every golden ingests in one call:

- converged posteriors are always finite with positive sigma
- log-evidence, batch and filtered, is finite and never above zero
- filtered evidence is invariant to whether `converge` has run
- one-at-a-time ingestion reaches the same fixed point as batched

The invariance property was mutation-proved: making `filtered_step` read
`skill.forward` instead of the carried message fails it with
`-1.1038430064192069 -> -1.1135747072822761`.

**Shared finiteness helper (`tests/common/mod.rs`).** `assert_finite` was local
to `degenerate_inputs.rs`. It now also rejects a non-positive sigma, which the
old version let through — `Gaussian::sigma` reports a non-positive precision as
improper rather than trapping, so a collapsed posterior would have passed a
finite-only check.

**Boundary inputs.** Zero and negative weights, out-of-order timestamps, and
extreme beta/sigma combinations. Worth recording that zero weight reaches
`(m - performance.exclude(..)) * (1.0 / w)` — a division by zero — and the
posterior comes out finite anyway; the test pins that rather than asserting
what ought to happen. The weight tests `expect()` the commit rather than
returning early on error, because an early return would have made them vacuous
the moment validation changed. I checked that specifically by turning the
return into a failure and confirming it did not fire.

Not done, and left on #26: benchmark regression gating. Nothing fails on a
regression today; making it fail needs a threshold chosen against how noisy the
shared runner is, which is a policy call rather than a mechanical one.

60 test binaries, up from 56. MSRV 1.85 verified with proptest in the graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
This commit is contained in:
2026-08-27 18:06:26 +02:00
co-authored by Claude Opus 5
parent 6d2573b92e
commit 2745fbb622
4 changed files with 318 additions and 9 deletions
+38
View File
@@ -0,0 +1,38 @@
//! Helpers shared across the integration suites.
//!
//! Each integration file is its own binary, so `mod common;` compiles a copy
//! per suite. Anything unused in a given suite would warn, hence the
//! `#![allow(dead_code)]`.
#![allow(dead_code)]
use trueskill_tt::Gaussian;
/// A posterior must be finite with a strictly positive sigma.
///
/// A non-finite posterior is the failure mode this crate is most prone to —
/// EP breaking down produces NaN rather than an error — and a zero or negative
/// sigma means the precision went non-positive, which `Gaussian::sigma` reports
/// as improper rather than trapping.
pub fn assert_finite(g: Gaussian, what: &str) {
assert!(
g.mu().is_finite(),
"{what}: mu is not finite (mu={}, sigma={})",
g.mu(),
g.sigma()
);
assert!(
g.sigma().is_finite() && g.sigma() > 0.0,
"{what}: sigma must be finite and positive (mu={}, sigma={})",
g.mu(),
g.sigma()
);
}
/// Every point on every learning curve must be finite.
pub fn assert_curve_finite(curve: &[(i64, Gaussian)], who: &str) {
for (time, g) in curve {
assert_finite(*g, &format!("{who} at t={time}"));
}
}