Investigation for #46 and #47, before touching either.
A scored history is linear-Gaussian, so its true joint posterior has a
closed form and the crate can be checked against ground truth. Measured
on five competitors:
means marginal sd (crate / exact)
tree (star) exact 1.000
loopy (robin) exact 0.502
On a tree the crate is exact in both. With cycles the means stay exact —
the standard Gaussian-BP result, and the property ratings rely on —
while marginal variances come out about half the true width.
That is the opposite direction from what #47 reports, so whatever is
happening in that consumer's model, the crate being conservative is not
it.
It also means #46 cannot be implemented as an added covariance accessor.
The exact correlation between two nodes here is +0.857, so ignoring it
overstates the width of a difference — but the too-narrow marginals
partially cancel that, leaving 1.327x rather than 2.646x. Adding true
correlations to these marginals without correcting them would give 0.765
against a true 1.524: overconfident, which is the direction the reporter
specifically called unsafe.
Pins the two real invariants (exactness on a tree, exact means with
cycles) and deliberately only records the variance gap, since closing it
is what #46 proposes.
Also records the working rules this project has converged on: investigate
before implementing, fix the root issue, and scout crates.io on measured
accuracy rather than adoption.
Refs #46, #47
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Follow-on from #41, which added `libm` for `erfc`. Surveying what else
the dependency offers: its unique surface over `std` is `erf`/`erfc`,
`lgamma`/`tgamma` and Bessel functions, and only the first was ever
needed. But the survey found something better than another special
function.
`std`'s `exp` and `ln` delegate to the *system* math library. IEEE 754
specifies the basic operations and `sqrt` exactly and says nothing about
transcendentals, so those differ per platform. Measured here over 200k
inputs:
exp: 19425/200000 differ from libm (worst 1 ulp)
log: 9932/200000 differ
Inference is an iterative fixed point, so a one-ULP difference can change
an iteration count and move the answer by more than one ULP. Routing
every transcendental through `libm` makes a fit reproducible across
platforms — a stronger guarantee than `tests/determinism.rs`, which only
covers thread counts.
It costs nothing. `Batch::iteration` measured -2.7% [-5.7%, -0.3%] with
the whole set swapped, and not one golden moved.
Also switches the two places that combined sigmas as
`sqrt(a^2 + b^2)` to `hypot`. Squaring overflows to infinity above
~1.3e154 and flushes to zero below ~1.5e-154 — measured, the naive form
returns `inf` where `hypot` returns 1.41e160 — and `Gaussian`'s
constructors are public, so a caller can reach both ends.
Deliberately not done: rewriting the KL divergence's `ln` of a ratio via
`ln_1p`. The cancellation is real as the ratio approaches one, but
measured absolute error is at most ~1e-11 in a quantity of order 0.4
nats, so it changes nothing.
The invariant is recorded in `CLAUDE.md` and on `erfc`'s own docs, since
nothing enforces it mechanically.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Four README code blocks no longer compiled: `Player` was renamed `Rating`
in T2, the `Drift` trait gained a `T: Time` parameter and a second method,
and two blocks were missing imports outright. The `Rating` example needed
more than a rename — with the binding unused, `T` is ambiguous because
`ConstantDrift` implements `Drift<T>` for every `T`, so it now carries an
explicit annotation.
Nothing compiled those blocks. `src/lib.rs` gains a `cfg(doctest)` struct
carrying `#[doc = include_str!("../README.md")]`, which turns every `rust`
block into a doctest without displacing the curated crate docs as the
front page. Verified it bites: reintroducing `Player` fails the build with
E0432 rather than shipping. Illustrative blocks are fenced `text` — note
that a bare fence defaults to `rust` under rustdoc, which is how the
`variance_delta = elapsed * γ²` formula became a compile error.
Prose fixes: README claimed `Gaussian::forget` takes a square root (it
works in variance space) and pointed at a `.gamma()` builder method that
does not exist. CLAUDE.md's data-flow diagram spliced the public ingestion
shape into the internal one — `Team` is not in that chain — listed
`cdf()`/`erfc()` as public when they are `pub(crate)` and private, and
called `SkillStore` public when only `CompetitorStore` escapes the crate.
Rustdoc fixes: `EventBuilder::scores_with_sigma` claimed a debug-assert
that `Outcome::scores_with_sigma` never had and whose own docs contradict;
rejection happens at ingestion as `InvalidParameter`. `event.rs` described
`add_events_with_prior` as replaced when it is still the ingestion
chokepoint. `factors.rs` advertised `Game::custom` without noting it is
`#[doc(hidden)]`. Internal T2/T4 milestone labels are dropped from public
items; the ones in the private `time_slice` module are left alone.
Closes#35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014b6wy2q8rnFK8U8GPJVQNU
The CLAUDE.md architecture section still described the pre-redesign engine:
its data flow named `Batch`, `Agent`, `Player` and `message.rs`, none of
which have existed since T2, and the public API it listed did not match
`lib.rs`. It is the first thing a fresh session reads, so it was actively
misleading. Rewritten against the current module layout, with the invariants
that are easy to violate — ties needing a positive `p_draw`, NaN never being
convergence, log-space evidence, color contiguity, `forbid(unsafe_code)`,
and ingestion-order equivalence — written down.
The README Todo list had five entries that were already done, including
"Time needs to be an enum": `Time` has been a trait since T2, and the
`batch::compute_elapsed()` it pointed at no longer exists. The genuinely
open item — cross-checking `quality()` against sublee/trueskill — stays.
`benches/ingest.rs` measures one-event-per-call against a single batched
call. The rest of the suite only measured batched construction, which is why
the quadratic fixed earlier on this branch went unnoticed for so long.
`TimeSlice::log_evidence` also hashes its target set once instead of
scanning the slice per player per event, so `log_evidence_for` with many
keys is no longer quadratic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej