24 Commits
Author SHA1 Message Date
logaritmisk b2a7ade10c chore: Release trueskill-tt version 0.3.0 2026-09-01 06:34:31 +02:00
logaritmiskandClaude Opus 5 617bc07f6f feat: allow drift to vary per competitor via Member::with_drift_scale
Drift was a property of the History, so every competitor drifted at the
same rate and a fixed reference point could not share a graph with moving
competitors. A bot at a known strength, a rating floor, a course
difficulty — all of them drifted along with the players.

Member::with_drift_scale(s) multiplies the drift *variance* a competitor
accumulates, so s is in the same units as gamma: ConstantDrift(g) at
scale s behaves exactly as ConstantDrift(g * s) would for that competitor.
A scalar rather than a per-competitor Drift keeps History's single D type
parameter untouched and stays Copy. 0.0 pins a competitor still.

The scale lives on Rating, beside the drift it scales, and is applied
only through Rating::drift_variance_delta / drift_variance_for_elapsed.
Making those the sole entry points means a caller cannot reach the raw
drift and silently skip a competitor's scale — the filtered pass was
exactly that bug during development, caught because its test was written
before the wiring.

Like with_prior, the scale is competitor configuration captured at first
appearance rather than a per-event override; a competitor that is static
is static, and a scale that changed between events would make the skill
trajectory hard to interpret. Member's docs claimed prior was a per-event
override, which the code has never done — corrected here.

A negative scale is rejected rather than squared into its absolute value,
and a non-finite one rejected outright, both as InvalidParameter.

None means 1.0, so no existing call site changes and no existing fit
moves. Adding a public field to Member does break struct-literal
construction downstream.

Closes #34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014b6wy2q8rnFK8U8GPJVQNU
2026-09-01 06:29:02 +02:00
logaritmiskandClaude Opus 5 1a88678384 refactor!: remove ConvergenceReport::slices_skipped
Closes #33. The field was public, hardcoded to 0 at both construction sites,
and had no route to ever being non-zero.

It was added in T3 as the reporting surface for dirty-bit slice skipping. That
feature is #4, closed as unworkable — the ceiling measured at ~6% against a
projected 5-50x, on top of three independent soundness blockers. #32, which
reattributed the cost to ingestion, is closed too: the re-convergence is
necessary work rather than waste, because appending one event genuinely moves
the involved competitors ~1.2 sigma across their whole history. Nothing left
would ever populate it.

This is the same defect class as #19, where this arc started: a public surface
that looks implemented, reports a plausible value, and is inert. A caller
reading `slices_skipped: 0` reasonably concludes "no slices were skipped this
run", not "this feature does not exist".

Removed rather than documented as reserved. Its only value was as a hook for a
plan that no longer exists, and keeping it preserves the shape of that plan.
Breaking, but ConvergenceReport is returned rather than constructed by callers,
so the only breakage is code reading a constant zero.

Also added a test asserting every remaining field carries real information —
iterations non-zero, final_step finite, log_evidence a finite negative log
probability, and per_iteration_time holding one duration per iteration.
Mutation-proved: pinning per_iteration_time to an empty SmallVec fails it. The
next always-constant member now has to survive an assertion rather than just a
reviewer's attention, which is the actual lesson of #19 and #33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-28 08:07:41 +02:00
logaritmisk 5f46296671 chore: ignore proptest regression seed files
proptest writes tests/*.proptest-regressions when a property fails, seeding a
replay of that exact case. Useful locally; noise in the repo when the failure
came from a deliberate mutation rather than a real defect.
2026-08-27 18:06:26 +02:00
logaritmiskandClaude Opus 5 2745fbb622 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
2026-08-27 18:06:26 +02:00
logaritmiskandClaude Opus 5 6d2573b92e perf: make the per-slice SkillStore compact instead of dense
Closes #17. Each `TimeSlice` owned a `Vec<Skill>` indexed by the GLOBAL
`Index.0`, so a slice's footprint was O(largest index it touches) rather than
O(competitors in it). Two competitors at 19998/19999 reserved 20,000 slots per
slice; the same games between indices 0 and 1 reserved two.

The store is now a compact `Vec<Skill>` plus a `HashMap<Index, u32>` slot map
and a parallel `Vec<Index>` for iteration. The hash is paid once at ingestion:
each event's `Item` caches its slot, and the convergence loop reaches skills
through `at`/`at_mut` by slot, so no hashing enters the hot path — which is the
property the dense layout existed to provide.

Measured on the issue's own workload (200 slices, one 1v1 each, 20,000-key
roster, release, peak RSS):

    indices 0 / 1          52 MB  ->  5.55 MB
    indices 19998 / 19999  309 MB ->  8.39 MB

The 257 MB gap is now 2.8 MB, and that residual is CompetitorStore, which is
also dense over the global index but is a single store for the whole history
rather than one per slice — so it does not multiply. Left alone deliberately.

Benchmarks, against the pre-change code:

    Batch::iteration        +2.4%   (regressed)
    history_converge x3     -18.8%, -21.6%, -21.7%  (improved)

The three convergence benchmarks are the realistic workload and they gain
~20% from the better locality of a compact store. The micro-benchmark loses
2.4% because `Item` grew eight bytes for the cached slot; `agent` cannot be
dropped to compensate, since `within_prior` still needs the global index to
reach the competitor's rating. I judged 2.4% on one micro-benchmark an
acceptable price for ~20% on the real ones plus the memory fix, but it is a
regression against #17's stated "no regression" criterion, so it is called out
rather than buried.

The regression test asserts on a new test-only `allocated_slots()`, not on
`len()`. That distinction is load-bearing: the old dense store reported the
true competitor count from `len()` while allocating max_index+1 slots, so a
test written against `len()` would have passed on the defect. Mutation-proved
by re-adding the dense padding, which fails it.

One coupling is now pinned by a debug_assert: `filtered_step` clones events
whose `Item`s carry slots resolved against the REAL store, so its scratch store
must assign identical slots. It does, because `iter()` yields slot order and
`insert` allocates in call order — but that is an invariant across two types,
so it is asserted rather than left to be rediscovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 18:01:29 +02:00
logaritmiskandClaude Opus 5 1ac3b21db5 fix: enforce EventBuilder weight/team length in release
Part of #18. `EventBuilder::weights` guarded the length match with a
`debug_assert!`, so release builds accepted a mismatch, silently dropped the
weights, and ingested the event anyway. That is the exact shape #18 is about:
validation that exists only where it is least needed.

The setters return `Self` to keep the chain fluent, so they cannot return a
`Result`. The builder now records the first failure and `commit` returns it as
`MismatchedShape`. The weights are not applied on mismatch either, so a
partially-weighted team cannot reach the history by another route.

Two tests in tests/degenerate_inputs.rs, whose CI job runs in release — which
is the only place the old behaviour differed.

The second test needed strengthening before it was worth anything. As first
written it committed a ONE-team event, which ingestion rejects for an unrelated
reason, so it passed under a mutation that disabled the whole check. It now
uses two teams, so ingestion would otherwise succeed and the assertion is
actually load-bearing. Both tests were then mutation-proved together: disabling
the error path in `commit` fails both in release.

#18 stays open. The remaining debug_asserts live in `ranked_with_arena` and
`scored_with_arena`, and promoting those means threading `Result` up through
`Event::compute`, `TimeSlice::iteration`, `log_evidence` and `filtered_step` —
which lands on the public API as `log_evidence() -> Result<f64>` and
`filtered_learning_curve() -> Result<...>`. That is a trade-off about what the
query API should look like, not a mechanical change, so it is not mine to
decide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:53:37 +02:00
logaritmiskandClaude Opus 5 4e043364fd perf: stop cloning inference inputs in OwnedGame and ingestion
The last two items on #23.

`OwnedGame::new` and `new_scored` cloned the whole team structure to hand one
copy to `Game` and keep another. But `Game` takes the teams by value and is
dropped at the end of the constructor, so the vec can simply be taken back out
of it — the clone existed only because nobody looked at the lifetime.

`add_events_with_prior` deep-cloned each event's composition, results and
weights when chunking events into per-timestamp groups. Nothing reads those
three after the chunking loop (the agent-collection pass and the tie pre-check
both run before it), so the elements are now moved out with `mem::take`.

That soundness argument rests entirely on `o` being a permutation: visiting an
index twice would take an already-emptied vec and silently produce an event
with no teams rather than failing. Since that would be invisible, there is now
a debug_assert checking the permutation property directly, next to the comment
explaining why the code depends on it.

Verified on 1.85.0 as well as the local toolchain — an MSRV break in this
change would otherwise only surface in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:50:39 +02:00
logaritmiskandClaude Opus 5 aff3fb948d refactor!: replace emptiness-as-sentinel with Option for results and weights
Third of the five remaining items on #23.

`add_events_with_prior` and `TimeSlice::add_events` took `Vec<Vec<f64>>` and
`Vec<Vec<Vec<f64>>>` where an empty vec meant "not supplied" — so an empty
outer vec and a genuinely empty event list were the same value, and every
reader had to know which. Both are now `Option`, and the "not supplied"
branches read as `None` arms rather than `is_empty()` checks.

Two things fell out of the change that a sentinel would have hidden:

The tie pre-check iterated `results` directly. Under `Option` it needs
`.iter().flatten()`, which makes explicit that a `None` results list has no
ties to reject — previously an empty vec silently skipped the same loop and
looked identical to "checked, found nothing".

`MismatchedShape.got` could no longer be `results.len()`, because at the point
of the error there may be no vec to take a length from. It is now computed as
`map_or(0, Vec::len)` before the error is built.

MSRV note: my first draft used let-chains for the two validations. Those need
Rust 1.88 and this crate pins 1.85 — it compiled locally on 1.98 and would
have failed only in the MSRV CI job. Rewritten with `is_some_and`, and
verified by installing 1.85.0 and building against it rather than by assuming
the removal was complete.

Breaking: `TimeSlice::add_events` is public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:48:50 +02:00
logaritmiskandClaude Opus 5 06ed24b240 refactor!: make Competitor::message an Option, and compute_elapsed loud
Two of the five remaining items on #23.

`Competitor.message` was a `Gaussian` using the improper `N_INF` as an "unset"
sentinel, so `message != N_INF` meant "has a message" and every reader had to
know that convention. It is now `Option<Gaussian>`, which makes "no message
yet" and "a legitimately improper message" distinguishable at the type level
instead of by float comparison.

Worth noting what the change surfaced: switching the type turned every read
site into a compile error, and there were eight — two in the convergence sweep,
five in ingestion, one in new_backward_info. The last is the interesting one:
`skill.backward = agents[agent].message` needed `unwrap_or(N_INF)` rather than
an unwrap, because an absent message genuinely does mean the improper identity
there. A sentinel-based refactor would have had to find that by reading.

This is a breaking change: `message` is a public field. It rides the next
minor bump.

`compute_elapsed` clamped a negative elapsed to zero silently. Negative elapsed
means slices are being visited out of time order, which would otherwise make
drift *reduce* uncertainty. Release still clamps, so a bad timestamp degrades
to "no drift" rather than corrupting a posterior, but debug now trips — getting
there is a slice-ordering bug, not something callers can cause with ordinary
data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:46:12 +02:00
logaritmiskandClaude Opus 5 9b2c2b38c8 docs: complete the public API documentation contract
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
2026-08-27 17:43:31 +02:00
logaritmisk 07285283b6 chore: Release trueskill-tt version 0.2.0 2026-08-27 17:37:31 +02:00
logaritmiskandClaude Opus 5 b73cf0145a chore: dual-license MIT OR Apache-2.0
The Rust ecosystem convention, and what kickscore, xy and saphyr already use —
kickscore being the closest sibling to this crate.

LICENSE-APACHE is the canonical 201-line Apache-2.0 text with the appendix
left as the unfilled template, which is the form Rust crates ship. LICENSE-MIT
carries the copyright line. Both are picked up by cargo automatically and
appear in the packaged crate.

This also unblocks the release workflow. `cargo publish` does not check the
license field when the target is an alternative registry, but cargo-release
does, and refuses outright:

    error: trueskill-tt is missing the following fields:
             license || license-file

--no-verify does not bypass it. So `just release` was inert without this,
whatever cargo publish alone would have accepted.

README gains the conventional dual-license section and the contribution note
that dedicates inbound contributions under the same terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:35:42 +02:00
logaritmiskandClaude Opus 5 56ff01074f docs(cargo): correct the licence note — kellnr does not require one
The note claimed `cargo publish` rejects a crate without `license`. That is
true only for crates.io; publishing to an alternative registry does not check
it, verified by a dry run against kellnr that packages and verifies cleanly.

Staying unlicensed is a deliberate choice, so the note now says that and states
the actual consequence — all-rights-reserved by default — rather than a
mechanical blocker that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:30:59 +02:00
logaritmiskandClaude Opus 5 8d47e54a8a chore: keep the 48 MB ATP dataset out of the published crate
`cargo publish --dry-run` packaged 48.1 MiB (8.6 MiB compressed) for a library
whose source is 312 KB. All of it was examples/atp.csv, a tennis dataset the
atp example reads.

examples/atp.rs opens it by relative path at runtime rather than include_str!,
so excluding the data still compiles and `cargo package --verify` still builds
every target — the example just needs the file fetched from the repo to run.

Packaged size is now 360.8 KiB / 83.1 KiB compressed, a 133x reduction. Every
consumer would otherwise have paid that download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:29:44 +02:00
logaritmiskandClaude Opus 5 7de092ba12 chore: target releases at the private kellnr registry
Mirrors the textus setup, adapted for a single crate rather than a workspace.

Cargo.toml gains publish = ["kellnr"], which does double duty: it points
cargo-release at the private registry and makes an accidental `cargo publish`
to crates.io a hard error rather than an irreversible mistake.

.cargo/config.toml is committed rather than left to a per-user
~/.cargo/config.toml. Without it a fresh clone, a new machine, or CI fails
with "registry index was not found in any configuration: kellnr" before
compiling anything. The index URL is not a secret; the token stays in
~/.cargo/credentials.toml, or CARGO_REGISTRIES_KELLNR_TOKEN in CI.

release.toml flips publish from false to true and pins push = false, so the
Justfile recipe pushes last — after tags and publish have both succeeded.
The git-cliff pre-release hook is unchanged.

cliff.toml gained a Breaking Changes group. Its commit_parsers matched on type
alone with conventional_commits = false, so `refactor!: remove the inert online
flag` rendered as an ordinary Refactor bullet and the break was invisible in
the generated changelog. The new parsers match a `!` subject and a
BREAKING CHANGE body, and must precede the type parsers because the first match
wins. The unreleased section now opens with the break, which matters because
the next release is the one that removes HistoryBuilder::online.

The release recipe runs `just ci` before cutting: cargo-release only
verify-compiles the packaged crate and publishing cannot be undone, and the
release profile is where this crate's defects have historically hidden.

Still unpublishable: Cargo.toml has no `license`. That is a deliberate TODO,
not an oversight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:28:51 +02:00
logaritmiskandClaude Opus 5 eeb43e3be1 fix: close out four small issues and pin #27's repro
#29 — log_evidence and log_evidence_for took &mut self while mutating
nothing. Loosening them to &self is not source-breaking for ordinary callers
(a &mut reborrows as & transparently) and brings them in line with the
filtered_* accessors added last week.

Not the mechanical change it looked like: under the rayon feature the closure
in log_evidence_internal captured all of &self rather than just the competitor
store, which drags KeyTable<K> in and demands K: Sync from every caller. That
compiled while the method took &mut self and stopped compiling the moment it
did not. Binding `let agents = &self.agents;` before the closure narrows the
capture; the comment there says why, because the next person to inline it will
reintroduce the bound.

#31 — TimeSlice::add_events constructed Skill with ..Default::default() while
filtered_step spells every field out. The design relies on a new Skill field
being a compile error at construction sites rather than a silent default, and
that tripwire only fired at one of the two. Now both.

#28 — log_evidence_internal's `forward` flag is a genuine forward-only
quantity only on a history that has never been converged, because iteration
alternates sweeps and the likelihood feeding the forward message absorbs
backward information from the second iteration onward. Documented, with a
pointer to filtered_log_evidence for the quantity that survives convergence.
That trap is one function away from the one #19 was about.

#23 — color_greedy carried #[allow(dead_code)] despite being called by
recompute_color_groups: a mute button on a live function, which is the
specific complaint in that issue.

#27 was already fixed — the guard landed in f4e2922 and the issue was filed
against 7742b2b, which merge-base confirms predates it — but nothing pinned
it. Added the issue's own reproduction, which matters because the two profiles
fail differently and a debug-only test would miss the release path. Removing
both guards reproduces the issue verbatim: "attempt to subtract with overflow"
in debug, "index out of bounds: the len is 0 but the index is
18446744073709551615" in release.

Also amended the filtered-estimates spec (#30): the tolerance-not-bit-identity
caveat is conservative. Forcing the scratch onto the sequential sweep instead
of the grouped one — a far larger perturbation than a permuted event order —
still agrees within 1e-8 under tight convergence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:21:05 +02:00
logaritmiskandClaude Opus 5 69ddebe21d docs: state filtered accessor cost and evidence semantics precisely
filtered_learning_curve's signature mirrors learning_curve, which is cheap
per key — so the mirroring trained callers to assume this one is too. It is
a full forward pass per call, making the natural loop over competitors
O(competitors * events). The doc now says so in complexity terms and points
multi-key callers at the plural form.

filtered_log_evidence claimed each event is scored "using only what was
known before it". That is exact for a slice holding one event, but events
sharing a timestamp inform each other through the within-slice sweep, so
the honest claim is "before that time". The behaviour is deliberate and
matches log_evidence's own convention; only the promise was too strong.

This branch exists because a feature's documentation was quietly false.
Shipping it with two more overstated doc comments would be a poor joke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 16:59:42 +02:00
logaritmisk 9c39d1e681 test: pin the invariants that make filtered estimates trustworthy
The bracket test proves the feature works on one fixture. These pin the bug
class:

- Invariance to converge(). This is the one that matters. Reading
  skill.forward instead of the carried message makes it fail immediately,
  because converge() alternates sweeps and contaminates skill.forward with
  backward information from the second iteration onward. That is the
  property a stored field cannot have, and the reason issue #19's proposed
  fix would not have worked.
- Invariance to ingestion order, the crate's standing invariant.
- One slice has no future to propagate back, so filtered equals smoothed.
- Empty history yields zero and empty maps.

Agreement is to 1e-8 under tight convergence rather than bit-identity:
iteration recomputes the colour partition only when from == 0, so an
incrementally built slice keeps insertion order until the first converge()
reorders it, and the scratch clone inherits whichever order it finds. Same
fixed point, different path to it.
2026-08-27 16:37:21 +02:00
logaritmisk 50e11cfbfa feat: add filtered learning curves
learning_curve returns post-convergence posteriors, so every point is
smoothed: the estimate at a given date incorporates rounds played years
later. On ustat's data that starts six players' curves already spread apart
at sigma 0.9-1.6 against a prior of 6.0, barely moving thereafter.

filtered_learning_curve plots the same competitor on forward-only
information, so everyone starts at the prior and fans out. It could not be
reconstructed from the public API before: a caller could only refit over
events[0..k] for every k, which is O(n^2) fits for something one forward
pass already computes.
2026-08-27 16:30:19 +02:00
logaritmisk d4af048914 feat: add filtered_log_evidence
Scores every event on what was known before it, rather than on priors that
carry information from events which had not happened yet. This is the
quantity HistoryBuilder::online promised and never delivered.

The pass walks slices in time order carrying its own forward messages, and
per slice runs the unmodified production sweep on a scratch copy whose
backward message is left improper. Reusing iterate_to_convergence rather
than reimplementing inference means a competitor playing twice at one time
is handled by the same within-slice EP that converge() uses, instead of
being approximated the way the old evidence paths approximated it.

Nothing is stored on Skill and nothing on self is mutated, so the result is
independent of whether converge() has run — the property a stored field
cannot have.
2026-08-27 16:21:01 +02:00
logaritmisk bf9d964cae refactor!: remove the inert online flag
Skill.online was initialised to N_INF and assigned nowhere, so
HistoryBuilder::online(true) made every rating improper and log_evidence()
reported n * ln(0.5) — every game scored as a coin flip. The value is finite
and plausible, which is why it went unnoticed.

The default was false, so no existing result changes. A working replacement
lands next; a stored field cannot hold the quantity, because converge()
alternates sweeps and contaminates skill.forward with backward information
from the second iteration onward.

Also renames a test binding from ..._online to ..._forward: it passes the
forward flag, and the two senses being conflated is how this survived.
2026-08-27 16:11:37 +02:00
logaritmiskandClaude Opus 5 187aede924 docs: implementation plan for filtered estimates
Five tasks: delete the inert online machinery, add filtered_log_evidence,
add the two learning-curve methods, pin the invariants, record the API break.

Two spec corrections fell out of writing it. The spec claimed filtered results
would be bit-identical before and after converge(); they cannot be. iteration
recomputes the colour partition only when from == 0, so a slice built by
repeated appends keeps insertion order until the first converge() reorders it,
and the scratch clone inherits whichever order it finds — same fixed point,
different path. Corrected to agreement within 1e-8 under tight convergence,
matching the house pattern in tests/ingestion_equivalence.rs. The spec also
declared filtered_pass as Vec<(T, Vec<(Index, Gaussian)>)>, which cannot carry
the evidence its own step 3 harvests; it returns Vec<(T, FilteredStep)>.

CHANGELOG.md is generated by git-cliff, so the spec's "CHANGELOG records the
API break" cannot be satisfied by editing the file — it regenerates. Task 5
records the break through the commit subject and verifies the generated output
instead. cliff.toml has no breaking-change parser at all, which the task is
told to report rather than work around.

An adversarial reviewer checked the plan against the source before this commit
and found four real defects, all in plan text, none in the design:

- Two prescribed mutations provably could not fail their named tests. The
  learning-curve mutation altered only what filtered_pass writes after a slice,
  while the test inspected filtered[0], which is computed from an empty message
  map. Fixed by asserting monotonic mu across the whole curve.
- The ingestion-order fixture used four distinct timestamps, giving one event
  per slice — the exact degenerate shape ingestion_equivalence.rs documents as
  the weak case, making the assertion true by construction. Fixed to several
  events per timestamp with shared competitors.
- filtered_learning_curves was never asserted for content, only for emptiness
  on an empty history.
- A doc comment restated learning_curves' claim that key(idx) is O(n) and the
  method O(n^2). KeyTable::key is self.reverse.get(idx.0) — O(1) — and the
  type's own doc says so. The claim predates reverse becoming a Vec. The plan
  now corrects the original at history.rs:323 rather than copying it.

The reviewer confirmed the central claim by tracing the call graph: N_INF is
{pi: 0, tau: 0} and Mul is a natural-parameter add, so it is an exact
multiplicative identity, and the only write to skill.backward in the crate is
in new_backward_info, reachable only from History::iteration and never from
iterate_to_convergence under either rayon cfg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 16:04:16 +02:00
logaritmiskandClaude Opus 5 4fde482e48 docs: spec for filtered (forward-only) estimates
`HistoryBuilder::online(true)` is inert: it flips a flag that reaches
`Item::within_prior`, which reads `Skill.online` — a field initialised to
`N_INF` and assigned nowhere. So `log_evidence()` under that setting reports
`n * ln(0.5)`, every game scored as a coin flip. The number is finite and
plausible, which is why nothing caught it.

Issue #19 proposed populating the field during the forward pass. That does
not work, and the reason shapes the whole design. `new_forward_info` sets
`skill.forward` from the previous slice's `forward_prior_out`, which is
`skill.forward * skill.likelihood`; `History::iteration` alternates backward
and forward sweeps, so from the second iteration onward that likelihood has
already absorbed backward information. After `converge()`, `skill.forward`
is a smoothed quantity — and so is anything written from it.

The same reasoning condemns the neighbouring `forward: bool` flag, which is
a filtering quantity only on a history that was never converged. That is why
the test at history.rs:1183 can assert the two evidences are equal. Left
alone here; recorded as a follow-up.

The design is a read-only forward-only pass instead: walk slices in time
order carrying their own forward messages, and per slice build a scratch
clone whose `backward` is `N_INF`, then run the unmodified production sweep
on it. Reusing `iterate_to_convergence` rather than reimplementing inference
means a competitor playing twice at one time is handled by the same
within-slice EP that `converge()` uses, instead of being approximated the
way today's evidence paths approximate it. Nothing is stored on `Skill`,
which drops 16 bytes and helps #17 regardless.

Three methods ship — `filtered_log_evidence`, `filtered_learning_curves`,
`filtered_learning_curve` — all taking `&self`. The second consumer is
ustat, whose learning curves start already collapsed to sigma 0.9-1.6
against a prior of 6.0 because every point is smoothed; the filtered view
cannot be reconstructed from the public API today except by O(n^2) refits.

The red test brackets the issue's own fixture strictly between 5*ln(0.5) and
the batch evidence, so neither "still inert" nor "accidentally smoothed"
passes. The invariant that would have caught this bug class is that filtered
results are identical before and after `converge()` — exactly what a stored
field cannot give.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 15:42:54 +02:00
39 changed files with 3966 additions and 242 deletions
+15
View File
@@ -0,0 +1,15 @@
# `Cargo.toml` sets `publish = ["kellnr"]`, so `cargo publish` targets the
# private registry and refuses crates.io. Cargo needs that registry's index
# declared to resolve the name.
#
# Committed rather than left to a per-user `~/.cargo/config.toml` so the repo
# is self-contained: a fresh clone, a new machine, or CI would otherwise fail
# with
#
# error: registry index was not found in any configuration: `kellnr`
#
# Index URL only — it is not a secret. Publish tokens live in
# `~/.cargo/credentials.toml` (per-user, never committed) or, in CI, in
# `CARGO_REGISTRIES_KELLNR_TOKEN`.
[registries.kellnr]
index = "sparse+https://crates.aceofba.se/api/v1/crates/"
+1
View File
@@ -7,3 +7,4 @@
NOTEPAD.md
/.claude
proptest-regressions/
+89
View File
@@ -2,6 +2,91 @@
All notable changes to this project will be documented in this file.
## 0.3.0 - 2026-09-01
### Breaking Changes
- refactor!: make Competitor::message an Option, and compute_elapsed loud
- refactor!: replace emptiness-as-sentinel with Option for results and weights
- refactor!: remove ConvergenceReport::slices_skipped
### Bug Fixes
- fix: enforce EventBuilder weight/team length in release
### Documentation
- docs: complete the public API documentation contract
### Features
- feat: allow drift to vary per competitor via Member::with_drift_scale
### Miscellaneous Tasks
- chore: ignore proptest regression seed files
### Performance
- perf: stop cloning inference inputs in OwnedGame and ingestion
- perf: make the per-slice SkillStore compact instead of dense
### Testing
- test: add property-based tests, a shared finiteness helper, and boundary inputs
## 0.2.0 - 2026-08-27
### Breaking Changes
- refactor!: remove the inert online flag
### Bug Fixes
- fix: reject ties without draw probability; never report NaN as converged
- fix(quality): support any number of rating groups
- fix(evidence): accumulate in log space and floor the per-link value
- fix(history): stop reprocessing the slice that was just appended to
- fix(rayon): remove the aliasing unsafe from the parallel sweep
- fix: close out four small issues and pin #27's repro
### Documentation
- docs: refresh README and CLAUDE.md; add ingest benchmark
- docs: spec for filtered (forward-only) estimates
- docs: implementation plan for filtered estimates
- docs: state filtered accessor cost and evidence semantics precisely
- docs(cargo): correct the licence note — kellnr does not require one
### Features
- feat: add filtered_log_evidence
- feat: add filtered learning curves
### Miscellaneous Tasks
- chore: add CI, crate metadata, and crate-level documentation
- chore: target releases at the private kellnr registry
- chore: keep the 48 MB ATP dataset out of the published crate
- chore: dual-license MIT OR Apache-2.0
- chore: Release trueskill-tt version 0.2.0
### Performance
- perf(gaussian): drop the sqrt round-trip from variance-space operations
### Refactor
- refactor: unify convergence defaults, validate builders, clear dead code
### Styling
- style: make NaN rejection explicit in score_sigma validation
### Testing
- test: pin the invariants that make filtered estimates trustworthy
## 0.1.2 - 2026-06-12
### Bug Fixes
@@ -32,6 +117,10 @@ All notable changes to this project will be documented in this file.
- feat(outcome): per-event score_sigma override on Outcome::Scored
- feat(event_builder): expose scores_with_sigma fluent method
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.1.2
### Refactor
- refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
+23 -6
View File
@@ -1,18 +1,30 @@
[package]
name = "trueskill-tt"
version = "0.1.2"
version = "0.3.0"
edition = "2024"
rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
repository = "https://git.aceofba.se/logaritmisk/trueskill-tt"
authors = ["Anders Olsson"]
# Publishing is restricted to the private kellnr registry; this also makes
# an accidental `cargo publish` to crates.io a hard error rather than a
# irreversible mistake. Index is declared in `.cargo/config.toml`.
publish = ["kellnr"]
readme = "README.md"
keywords = ["trueskill", "rating", "bayesian", "elo", "skill"]
categories = ["algorithms", "science", "game-development"]
# TODO: pick a licence before publishing. `cargo publish` rejects a crate
# without `license` (or `license-file`), and without one the source carries no
# stated terms. The Rust convention is `license = "MIT OR Apache-2.0"` plus the
# matching LICENSE-MIT / LICENSE-APACHE files.
exclude = ["/docs", "/benches/*.txt", "/temp", "/.gitea"]
license = "MIT OR Apache-2.0"
# `examples/atp.csv` is a 48 MB tennis dataset — 99% of the packaged crate,
# for a library whose source is 312 KB. `examples/atp.rs` opens it by
# relative path at runtime, so excluding the data still compiles; the
# example just needs the file fetched from the repo to run.
exclude = [
"/docs",
"/benches/*.txt",
"/temp",
"/.gitea",
"/examples/atp.csv",
]
[lib]
bench = false
@@ -50,9 +62,14 @@ rayon = ["dep:rayon"]
criterion = "0.5"
plotters = { version = "0.3", default-features = false, features = ["svg_backend", "all_elements", "all_series"] }
plotters-backend = "0.3"
proptest = "1.11.0"
time = { version = "0.3", features = ["parsing"] }
trueskill-tt = { path = ".", features = ["approx"] }
# Debug symbols in release are for `just flame` (cargo-flamegraph), which needs
# them to symbolicate. Profile settings in a library are ignored by downstream
# consumers, so these only affect local builds — this is deliberate, not an
# oversight.
[profile.release]
debug = true
+46
View File
@@ -43,3 +43,49 @@ bench:
flame:
cargo flamegraph --root --example atp
# ---------------------------------------------------------------------------
# Release workflow
#
# Publishing goes to the private kellnr registry only: `Cargo.toml` sets
# `publish = ["kellnr"]`, so an accidental `cargo publish` to crates.io is a
# hard error rather than an irreversible mistake. The index is declared in the
# committed `.cargo/config.toml`; the token is per-user and lives in
# `~/.cargo/credentials.toml` (`cargo login --registry kellnr`).
#
# Step 1: just release-plan [level] — dry run, no writes
# Step 2: just release [level] — bump, changelog, tag, publish, push
#
# LEVEL is the cargo-release bump level (default `minor`). On 0.x:
# minor -> breaking bump (0.1.2 -> 0.2.0) <- any public-API change
# patch -> additive only (0.1.2 -> 0.1.3)
# major -> reserved for the 1.0.0 jump
#
# `release.toml` regenerates CHANGELOG.md with git-cliff in a pre-release hook
# and keeps push = false; this recipe pushes last, after publish has succeeded.
# ---------------------------------------------------------------------------
# Dry-run preview of the next release. Inspect the version bump and the
# "Publishing ..." line before running `just release`.
release-plan level="minor":
cargo release {{level}}
# Cut a release from a clean main: gate -> bump -> tag -> publish -> push.
release level="minor":
#!/usr/bin/env bash
set -euo pipefail
if [[ "$(git branch --show-current)" != "main" ]]; then
echo "error: run 'just release' from the 'main' branch" >&2; exit 1
fi
if [[ -n "$(git status --porcelain)" ]]; then
echo "error: working tree is dirty — commit or stash first" >&2; exit 1
fi
# cargo-release only verify-compiles the packaged crate; it does not run the
# suite, and publishing is irreversible. Run the same gate CI does, which
# includes the release profile where debug_assert! is compiled out.
just ci
cargo release {{level}} --execute --no-confirm
git push --follow-tags
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2026 Anders Olsson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+47
View File
@@ -71,6 +71,36 @@ let h = History::builder()
.build();
```
### Per-competitor drift
A `History` has one drift model, but individual competitors can scale it.
`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor
accumulates, so `s` is in the same units as `gamma`: `ConstantDrift(g)` at
scale `s` behaves exactly as `ConstantDrift(g * s)` would, for that competitor
alone.
`0.0` pins a competitor still. That is what makes a **fixed reference point**
expressible in the same graph as moving competitors — a bot at a known
strength, a rating floor, a course difficulty:
```rust
let events = vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("player")]),
// A course does not improve. Pin it, and the round's evidence
// lands on the player instead of being split between the two.
Team::with_members([Member::new("layout_7").with_drift_scale(0.0)]),
],
outcome: Outcome::winner(0, 2),
}];
```
Like `with_prior`, the scale is **competitor configuration captured at first
appearance** — setting it on a key the history already knows has no effect. It
must be finite and non-negative; ingestion otherwise fails with
`InferenceError::InvalidParameter`.
## Scored outcomes
Use `Outcome::scores([...])` when you have continuous per-team scores rather
@@ -101,3 +131,20 @@ h.converge().unwrap();
- [x] Add Observer (`Observer` / `NullObserver`)
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
- [ ] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N-group support works and is covered by invariants, but no reference values are asserted
## License
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
<http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or
<http://opensource.org/licenses/MIT>)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
+1 -1
View File
@@ -36,7 +36,7 @@ fn criterion_benchmark(criterion: &mut Criterion) {
let kinds = vec![EventKind::Ranked; composition.len()];
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
time_slice.add_events(composition, results, weights, kinds, &agents);
time_slice.add_events(composition, Some(results), Some(weights), kinds, &agents);
criterion.bench_function("Batch::iteration", |b| {
b.iter(|| time_slice.iteration(0, &agents))
+5
View File
@@ -44,6 +44,11 @@ split_commits = false
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
# Must precede the type parsers below: a `feat!`/`fix!`/`refactor!` subject
# matches those too, and the first match wins. Without this a breaking
# change renders as an ordinary line of its own type.
{ message = "^[a-z]+(\\(.+\\))?!:", group = "Breaking Changes" },
{ body = "BREAKING CHANGE", group = "Breaking Changes" },
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^doc", group = "Documentation" },
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,342 @@
# Filtered (Forward-Only) Estimates
Closes [#19](https://git.aceofba.se/logaritmisk/trueskill-tt/issues/19).
## Summary
`HistoryBuilder::online(true)` is inert. It flips a flag that reaches
`Item::within_prior` (`src/time_slice.rs:70-71`), which reads
`Skill.online` (`src/time_slice.rs:25`) — a field initialised to `N_INF`
(`src/time_slice.rs:41`) and never assigned anywhere. The online path
therefore builds every rating from the improper Gaussian, and
`log_evidence()` silently reports `n × ln(0.5)`: every game scored as a
coin flip, finite and plausible-looking.
This spec replaces the field and the flag with a **read-only forward-only
pass** over the converged history, exposed as three new public methods.
The pass reuses the production within-slice sweep verbatim rather than
reimplementing inference, and stores nothing on `Skill`.
## Background
### Why a stored field cannot hold this quantity
The issue proposes populating `skill.online` during the forward pass,
alongside `new_forward_info` (`src/time_slice.rs:576`). That would not
work, and understanding why determines the whole design.
`new_forward_info` sets `skill.forward` from
`agents[a].receive_for_elapsed(...)`, whose `message` was written by the
previous slice's `forward_prior_out` (`src/time_slice.rs:549`):
```rust
skill.forward * skill.likelihood
```
`History::iteration` (`src/history.rs:255`) alternates a backward sweep
over slices and a forward sweep. From the second iteration onward, the
`skill.likelihood` feeding that message has already absorbed backward
information from the preceding backward sweep. So after `converge()`,
**`skill.forward` is a smoothed quantity, not a filtering one** — and any
field written from it inherits the same contamination on every sweep
after the first.
### The neighbouring trap
The same reasoning applies to the existing `forward: bool` parameter on
`log_evidence_internal` (`src/history.rs:395`). It is a genuine filtering
quantity only on a history that has never been converged. That is why the
test at `src/history.rs:1183` can assert
```rust
assert_ulps_eq!(trueskill_log_evidence, trueskill_log_evidence_online, epsilon = 1e-6);
```
— the fixture is never converged, so the forward message still equals the
cavity prior. (Note also that the local binding is named `..._online`
while the flag it passes is `forward`; the two senses were already
muddled.)
Fixing `forward: bool` is **out of scope** here; see *Out-of-scope
follow-ups*.
### Why this is worth implementing rather than deleting
The forward-only estimate has a second consumer beyond prequential model
comparison. `learning_curve()` returns post-convergence posteriors, so
every point is smoothed — the estimate at a given date incorporates
rounds played years later. On [ustat](https://git.aceofba.se/logaritmisk/ustat)'s
real data (prior μ=0, σ=6) that produces curves which start already
spread apart and barely move:
```
player first point final point
Eskil mu +3.72 sigma 1.17 mu +4.61 sigma 1.21
Anders Olsson mu +1.61 sigma 0.90 mu +1.16 sigma 0.82
LUDVIGSSON mu -2.09 sigma 1.08 mu -2.61 sigma 1.13
Anners mu -2.85 sigma 1.27 mu -2.86 sigma 1.26
```
σ at the *first* plotted point is 0.901.60 against a prior of 6.00. A
caller cannot reconstruct the filtered view from the public API today
except by refitting over `events[0..k]` for every k — O(n²) fits for
something one forward pass already computes.
## Scope
### What ships
1. A read-only forward-only pass on `History`, walking slices in time
order and carrying its own forward messages.
2. Three public methods: `filtered_log_evidence`,
`filtered_learning_curves`, `filtered_learning_curve`.
3. Removal of `Skill.online`, `History.online`, `HistoryBuilder.online`,
`HistoryBuilder::online()`, and the `online: bool` parameter threaded
through `Item::within_prior`, `Event::within_priors`, and
`TimeSlice::log_evidence`.
4. `#[derive(Clone)]` on `Event`, `Team`, `Item`; `iterate_to_convergence`
loses its `#[cfg(test)]` gate.
5. A CHANGELOG entry recording the API break.
### What does not ship
- No change to `log_evidence()`, `log_evidence_for()`, `learning_curve()`,
`learning_curves()`, or `current_skill()`. Their values are unchanged
by this work.
- No fix to the `forward: bool` flag described above.
- No caching of pass results. Each call runs a full pass; the doc
comments say so.
- No `rayon` parallelism across slices — the pass is sequentially
dependent by construction.
- No prior-predictive accessor. The pass computes the pre-event forward
message internally, but only the filtered posterior is exposed until a
second caller needs otherwise.
## Design
### Naming
`filtered_*`, not `online_*`. "Filtered" is the standard term for the
forward-only estimate, and the crate already uses "online" for a second,
unrelated thing — incremental ingestion, which `benches/baseline.txt:128`
calls the "online-add" path. Two senses of one word in one crate is how
the present bug reads as plausible.
### The pass
```rust
pub(crate) struct FilteredStep {
log_evidence: f64,
posteriors: Vec<(Index, Gaussian)>,
}
fn filtered_pass(&self) -> Vec<(T, FilteredStep)>
```
`posteriors` doubles as the outgoing forward message: the scratch sweep never
writes `backward`, so it stays `N_INF`, and `Skill::posterior()` and
`forward_prior_out` are then the same product.
Walk `self.time_slices` in order, carrying
`messages: HashMap<Index, Gaussian>` — the forward message out of each
competitor's most recent appearance. For each slice:
1. **Build a scratch clone.** Same `time`, `p_draw`, `convergence`, and
cloned `events` with every `item.likelihood` reset to `N_INF`. Fresh
`SkillStore` in which, for each agent present in the real slice:
```rust
forward = match messages.get(&agent) {
Some(msg) => msg.forget(rating.drift.variance_for_elapsed(skill.elapsed)),
None => rating.prior,
}
backward = N_INF
likelihood = N_INF
elapsed = skill.elapsed // copied from the real slice
```
This mirrors `Competitor::receive_for_elapsed` (`src/competitor.rs:39`)
exactly, including its `message != N_INF` fallback to the prior.
`skill.elapsed` is reused rather than recomputed: it is maintained by
`add_events_with_prior` across out-of-order ingestion, and production
convergence already trusts it.
2. **Run the real sweep.** `scratch.iterate_to_convergence(agents)`
(`src/time_slice.rs:516`), unmodified. Fidelity comes from reusing the
production path rather than a parallel reimplementation — in
particular, a competitor appearing in two events at the same time is
handled by the same within-slice EP that `converge()` uses, not
approximated the way the current `online`/`forward` evidence paths are
(they run each event independently and sum).
3. **Harvest.** With `backward == N_INF` acting as the multiplicative
identity, `Skill::posterior()` is exactly forward × likelihood — the
filtered posterior. Slice evidence is
`scratch.events.iter().map(|e| e.log_evidence).sum()`; `apply`
(`src/time_slice.rs:162`) writes that field on every event during the
sweep.
4. **Carry forward.** `messages.insert(a, scratch.forward_prior_out(&a))`
for each agent in the slice.
Steps 14 are the forward half of `History::iteration`
(`src/history.rs:283-297`) with the backward half never run. The pass
touches no field of `self`.
### Public API
```rust
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
pub fn filtered_log_evidence(&self) -> f64;
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>>;
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized;
}
```
All take `&self` — the pass mutates nothing. Shapes deliberately mirror
`learning_curve` / `learning_curves` (`src/history.rs:325`, `:381`) so a
caller can plot smoothed and filtered curves on one chart with the same
handling code.
`filtered_learning_curve` runs the same full pass as the plural form and
collects one key; the cost is identical, only the collection differs.
Callers wanting several keys should use the plural form. Documented on
both methods.
Because the pass carries its own messages and re-runs inference, its
results **do not depend on whether `converge()` has been called**. That
is the property a stored field cannot have, and it is asserted as a test.
### Removal inventory
| Location | Change |
|---|---|
| `src/time_slice.rs:25` | delete `pub(crate) online: Gaussian` |
| `src/time_slice.rs:41` | delete `online: N_INF` from `Default` |
| `src/time_slice.rs:62,70-73` | drop `online` param and its branch from `Item::within_prior` |
| `src/time_slice.rs:110,120` | drop `online` param from `Event::within_priors` |
| `src/time_slice.rs:585,597,626,634` | drop `online` param from `TimeSlice::log_evidence`; `online \|\| forward` becomes `forward` |
| `src/history.rs:32,63,138,158,174,199,226` | delete the two `online` field declarations (`:32`, `:199`) and the five struct-literal copies |
| `src/history.rs:90-93` | delete `HistoryBuilder::online()` |
| `src/history.rs:402,410` | drop the `self.online` argument |
| `src/history.rs:1183-1189` | the `..._online` assertion becomes a `forward`-flag assertion; rename the binding to match what it tests |
`Skill` loses 16 bytes, which is a small independent win for #17.
## Testing strategy
Every new test is mutation-proved before it counts: break the production
line it names, watch it fail for the *right* assertion, restore. A test
never observed failing is not evidence.
### The red test
On the issue's own fixture — five 1v1 games, same winner each time —
`filtered_log_evidence()` must land strictly between the two known
endpoints:
```
5 × ln(0.5) = -3.4657... (today's inert value)
< filtered
< -0.4012... (batch / smoothed evidence)
```
Two-sided, so neither "still inert" nor "accidentally smoothed" can pass.
The lower bound is right for a real reason: game one genuinely *is* a
coin flip under filtering, games two through five are not.
### Invariants
1. **Invariant to `converge()`** — `filtered_log_evidence()` and
`filtered_learning_curves()` agree before and after `converge()`. This
is exactly what `skill.forward` fails, and what makes a stored field
the wrong mechanism.
Agreement is to tolerance, not bit-identity, and the reason is worth
recording. `iteration` calls `recompute_color_groups`
(`src/time_slice.rs:369`) only when `from == 0`, so a slice built by
repeated appends keeps insertion order until the first `converge()`
reorders it. The scratch clone inherits whichever order it finds, and
greedy coloring over a permuted input can group differently, giving a
different within-slice sweep order — same EP fixed point, different
path to it. Follow the house pattern in
`tests/ingestion_equivalence.rs`: converge tightly (`max_iter: 2_000`,
`epsilon: 1e-12`) and compare within `1e-8`.
2. **Invariant to ingestion order** — events added one at a time produce
the same filtered results as the same events batched. Extends the
existing invariant in `tests/ingestion_equivalence.rs`.
3. **Single-slice exactness** — for a history with one time slice there
is no future to propagate back, so filtered results equal smoothed
results exactly.
4. **Uncertainty ordering** — for a competitor with many later games, σ
at the first filtered point is greater than σ at the first smoothed
point, and less than the prior σ. This is the ustat complaint restated
as an assertion.
5. **Degenerate inputs** — empty history yields `0.0` and empty maps;
unknown key yields an empty curve. Added to
`tests/degenerate_inputs.rs`.
### Regression net
The existing suite must be unchanged by the removals: `log_evidence()`,
`log_evidence_for()`, and every numerical golden keep their current
values, since the default `online` was already `false` and the flag was
inert.
## Verification gates
- `just test` — full matrix, including the release job. `debug_assert!`
is compiled out in release, and that is where defects in this crate
have hidden before.
- `just lint` — clippy, warnings denied.
- `just fmt` — nightly.
- `just determinism` — the new pass must not perturb bit-identical
posteriors across `RAYON_NUM_THREADS` 1/2/4/8.
- `#![forbid(unsafe_code)]` stays.
## Risks
- **Clone cost.** One slice's events are cloned per slice visited. At
ustat scale this is negligible, but the pass is O(events) allocation on
top of O(events) inference. Accepted: fidelity to the production sweep
is worth more than avoiding the clone, and no caller is on a hot path.
- **`iterate_to_convergence` leaving test-only status.** Its doc comment
claims "only used by tests"; that comment must be updated, or it
becomes the next piece of load-bearing prose that is quietly false.
- **Event order is inherited, not normalised.** The scratch clone takes
the real slice's current event order, which differs pre- and
post-`converge()` for incrementally-ingested slices (see *Invariants*).
Results agree to within convergence tolerance rather than exactly.
Normalising the order in the scratch builder would buy bit-identity at
the cost of diverging from what the real sweep does; not worth it.
**Measured after implementation, this risk is smaller than stated.**
Flipping the scratch's `color_groups_dirty` from `true` to `false`
switches it between the grouped sweep (`sweep_color_groups`) and the
sequential fallback across its entire convergence loop — a far larger
perturbation than a permuted event order — and the ingestion-order
invariance test stays green at `1e-8` under `max_iter: 2_000`,
`epsilon: 1e-12`. EP reaches the same fixed point regardless of sweep
order once driven far enough. The tolerance caveat is correct but
conservative. Note the flag itself is load-bearing: with it `false` the
scratch would take the sequential path always, diverging from the
production sweep it exists to mirror.
- **Divergence risk.** If `TimeSlice`'s sweep gains state that the
scratch construction does not initialise, the pass silently reads a
default. The scratch builder must construct `Skill` field-by-field
rather than via `..Default::default()`, so adding a field to `Skill`
is a compile error here rather than a silent wrong answer.
## Out-of-scope follow-ups
File as separate issues:
1. **`forward: bool` is only a filtering quantity pre-convergence**
(`src/history.rs:395`). Either document the constraint or fold the
flag into the new pass and delete it.
2. **`log_evidence` takes `&mut self`** (`src/history.rs:416`) but
mutates nothing. The new `filtered_*` methods take `&self`; the
asymmetry is worth removing.
+5 -1
View File
@@ -1,2 +1,6 @@
publish = false
# Publish to the registry named in Cargo.toml's `publish` list (kellnr).
publish = true
# Hold off pushing until tags and publish have both succeeded; `just release`
# pushes last.
push = false
pre-release-hook = ["sh", "-c", "git cliff -o CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"]
-1
View File
@@ -103,7 +103,6 @@ impl ColorGroups {
/// `Index` values that event touches. The returned `ColorGroups` has one
/// inner `Vec<usize>` per color, containing event indices in the order
/// they were assigned.
#[allow(dead_code)]
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
where
F: Fn(usize) -> I,
+23 -17
View File
@@ -1,5 +1,4 @@
use crate::{
N_INF,
drift::{ConstantDrift, Drift},
gaussian::Gaussian,
rating::Rating,
@@ -13,7 +12,14 @@ use crate::{
#[derive(Debug)]
pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub rating: Rating<T, D>,
pub message: Gaussian,
/// The forward message carried from this competitor's last appearance, or
/// `None` before they have appeared anywhere.
///
/// Previously an improper `N_INF` served as the unset sentinel, which made
/// "no message yet" indistinguishable from "a legitimately improper
/// message" at the type level and required every reader to know the
/// convention.
pub message: Option<Gaussian>,
pub last_time: Option<T>,
}
@@ -21,14 +27,16 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
/// Compute the message received at time `now`, with drift accumulated
/// from `self.last_time` (if any) to `now`.
pub(crate) fn receive(&self, now: &T) -> Gaussian {
if self.message != N_INF {
let elapsed_variance = match &self.last_time {
Some(last) => self.rating.drift.variance_delta(last, now),
None => 0.0,
};
self.message.forget(elapsed_variance)
} else {
self.rating.prior
match self.message {
Some(message) => {
let elapsed_variance = match &self.last_time {
Some(last) => self.rating.drift_variance_delta(last, now),
None => 0.0,
};
message.forget(elapsed_variance)
}
None => self.rating.prior,
}
}
@@ -37,11 +45,9 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
/// Used in convergence sweeps where the elapsed was cached at slice-construction time
/// and should not be recomputed from `last_time` (which may have shifted).
pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian {
if self.message != N_INF {
self.message
.forget(self.rating.drift.variance_for_elapsed(elapsed))
} else {
self.rating.prior
match self.message {
Some(message) => message.forget(self.rating.drift_variance_for_elapsed(elapsed)),
None => self.rating.prior,
}
}
}
@@ -50,7 +56,7 @@ impl Default for Competitor<i64, ConstantDrift> {
fn default() -> Self {
Self {
rating: Rating::default(),
message: N_INF,
message: None,
last_time: None,
}
}
@@ -63,7 +69,7 @@ where
C: Iterator<Item = &'a mut Competitor<T, D>>,
{
for c in competitors {
c.message = N_INF;
c.message = None;
if last_time {
c.last_time = None;
}
-1
View File
@@ -38,7 +38,6 @@ pub struct ConvergenceReport {
pub log_evidence: f64,
pub converged: bool,
pub per_iteration_time: SmallVec<[Duration; 32]>,
pub slices_skipped: usize,
}
#[cfg(test)]
+36 -3
View File
@@ -23,6 +23,7 @@ pub struct Team<K> {
}
impl<K> Team<K> {
#[must_use]
pub fn new() -> Self {
Self {
members: SmallVec::new(),
@@ -44,13 +45,20 @@ impl<K> Default for Team<K> {
/// One member of a team, identified by user key `K`.
///
/// `weight` defaults to 1.0; a per-event `prior` can override the competitor's
/// current skill estimate for this event only.
/// `weight` applies per event and defaults to 1.0.
///
/// `prior` and `drift_scale` are **competitor configuration**, not per-event
/// values: both are captured when the competitor is first created and ignored
/// on every later appearance. Setting either on a key the history already knows
/// has no effect.
#[derive(Clone, Debug)]
pub struct Member<K> {
pub key: K,
pub weight: f64,
pub prior: Option<Gaussian>,
/// Multiplier on the drift *variance* this competitor accumulates.
/// `None` means 1.0.
pub drift_scale: Option<f64>,
}
impl<K> Member<K> {
@@ -59,6 +67,7 @@ impl<K> Member<K> {
key,
weight: 1.0,
prior: None,
drift_scale: None,
}
}
@@ -67,10 +76,31 @@ impl<K> Member<K> {
self
}
/// Set this competitor's starting skill estimate.
///
/// Captured at the competitor's first appearance; see the type docs.
pub fn with_prior(mut self, prior: Gaussian) -> Self {
self.prior = Some(prior);
self
}
/// Scale how fast this competitor drifts, relative to the history's drift.
///
/// The scale multiplies the drift *variance*, so it is in the same units as
/// `gamma`: `ConstantDrift(g)` at `scale = s` behaves exactly as
/// `ConstantDrift(g * s)` would for this competitor alone.
///
/// `0.0` pins the competitor still — useful for a reference point that
/// shares a scale with moving competitors but should not itself move: a bot
/// at a known strength, a rating floor, a course difficulty.
///
/// Captured at the competitor's first appearance; see the type docs.
/// Must be finite and non-negative, or ingestion fails with
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
pub fn with_drift_scale(mut self, scale: f64) -> Self {
self.drift_scale = Some(scale);
self
}
}
/// Convenience: a member is a user key with default weight 1.0 and no prior.
@@ -91,15 +121,18 @@ mod tests {
assert_eq!(m.key, "alice");
assert_eq!(m.weight, 1.0);
assert!(m.prior.is_none());
assert!(m.drift_scale.is_none());
}
#[test]
fn member_builder_methods_chain() {
let m = Member::new("alice")
.with_weight(0.5)
.with_prior(Gaussian::from_ms(20.0, 5.0));
.with_prior(Gaussian::from_ms(20.0, 5.0))
.with_drift_scale(0.0);
assert_eq!(m.weight, 0.5);
assert!(m.prior.is_some());
assert_eq!(m.drift_scale, Some(0.0));
}
#[test]
+40 -7
View File
@@ -19,6 +19,14 @@ where
history: &'h mut History<T, D, O, K>,
event: Event<T, K>,
current_team_idx: Option<usize>,
/// First validation failure seen while building, surfaced by `commit`.
///
/// The setters return `Self` so the chain stays fluent; they cannot return
/// a `Result` without breaking that. Recording the failure and reporting it
/// at `commit` keeps the check enforced in release, where the previous
/// `debug_assert!` was compiled out and a mismatched event was ingested
/// silently.
error: Option<InferenceError>,
}
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
@@ -37,6 +45,7 @@ where
outcome: Outcome::Ranked(SmallVec::new()),
},
current_team_idx: None,
error: None,
}
}
@@ -50,22 +59,36 @@ where
/// Set per-member weights for the most recently added team.
///
/// Panics in debug builds if called before `.team(...)` or if the length
/// doesn't match the team's member count.
/// A length mismatch is recorded and returned by [`EventBuilder::commit`]
/// as `InferenceError::MismatchedShape`, in both debug and release. The
/// weights are not applied in that case, so a partially-weighted team
/// cannot reach the history.
///
/// # Panics
///
/// Panics if called before any `.team(...)`.
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
let idx = self
.current_team_idx
.expect(".weights(...) called before any .team(...)");
let ws: Vec<f64> = weights.into_iter().collect();
let team = &mut self.event.teams[idx];
debug_assert_eq!(
ws.len(),
team.members.len(),
"weights length must match team size"
);
if ws.len() != team.members.len() {
self.error.get_or_insert(InferenceError::MismatchedShape {
kind: "weights",
expected: team.members.len(),
got: ws.len(),
});
return self;
}
for (m, w) in team.members.iter_mut().zip(ws) {
m.weight = w;
}
self
}
@@ -103,7 +126,17 @@ where
}
/// Commit the event to the history.
///
/// # Errors
///
/// Returns the first validation failure recorded while building — see
/// [`EventBuilder::weights`] — otherwise forwards to
/// [`History::add_events`] and returns its errors.
pub fn commit(self) -> Result<(), InferenceError> {
if let Some(error) = self.error {
return Err(error);
}
self.history.add_events(std::iter::once(self.event))
}
}
+1
View File
@@ -20,6 +20,7 @@ pub struct MarginFactor {
}
impl MarginFactor {
#[must_use]
pub fn new(diff: VarId, m_obs: f64, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score sigma must be positive");
Self {
+4
View File
@@ -20,6 +20,7 @@ pub struct VarStore {
}
impl VarStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
@@ -28,10 +29,12 @@ impl VarStore {
self.marginals.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.marginals.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.marginals.is_empty()
}
@@ -42,6 +45,7 @@ impl VarStore {
id
}
#[must_use]
pub fn get(&self, id: VarId) -> Gaussian {
self.marginals[id.0 as usize]
}
+2 -2
View File
@@ -5,12 +5,12 @@ use crate::factor::{Factor, VarId, VarStore};
/// On each propagation:
/// - Reads marginals at `team_a` and `team_b` (which already incorporate any
/// incoming messages from neighboring factors).
/// - Computes `new_diff = team_a - team_b` (variance addition; see Gaussian::Sub).
/// - Computes `new_diff = team_a - team_b` (variance addition; see `Gaussian::Sub`).
/// - Writes the new marginal to `diff`.
/// - Returns the delta against the previous diff value.
///
/// This factor does NOT store an outgoing message; the diff variable is
/// effectively replaced on each propagation. The TruncFactor on the same diff
/// effectively replaced on each propagation. The `TruncFactor` on the same diff
/// var holds the EP-divide message that produces the cavity.
#[derive(Debug)]
pub struct RankDiffFactor {
+2 -1
View File
@@ -15,13 +15,14 @@ pub struct TruncFactor {
pub diff: VarId,
pub margin: f64,
pub tie: bool,
/// Outgoing message to the diff variable (initial: N_INF, the EP identity).
/// Outgoing message to the diff variable (initial: `N_INF`, the EP identity).
pub(crate) msg: Gaussian,
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
pub(crate) evidence_cached: Option<f64>,
}
impl TruncFactor {
#[must_use]
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
Self {
diff,
+38 -11
View File
@@ -107,16 +107,13 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
convergence: crate::ConvergenceOptions,
) -> Self {
let mut arena = ScratchArena::new();
let g = Game::ranked_with_arena(
teams.clone(),
&result,
&weights,
p_draw,
convergence,
&mut arena,
);
// `Game` takes the teams by value and is dropped here, so take the vec
// back out of it rather than handing it a clone.
let g = Game::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena);
Self {
teams,
teams: g.teams,
likelihoods: g.likelihoods,
log_evidence: g.log_evidence,
}
@@ -130,21 +127,24 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
convergence: crate::ConvergenceOptions,
) -> Self {
let mut arena = ScratchArena::new();
let g = Game::scored_with_arena(
teams.clone(),
teams,
&scores,
&weights,
score_sigma,
convergence,
&mut arena,
);
Self {
teams,
teams: g.teams,
likelihoods: g.likelihoods,
log_evidence: g.log_evidence,
}
}
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
.iter()
@@ -153,6 +153,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
.collect()
}
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
@@ -409,6 +410,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
self.likelihoods = likelihoods;
}
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
.iter()
@@ -422,12 +424,21 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.collect::<Vec<_>>()
}
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
}
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
/// # Errors
///
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
/// tie update evaluates `0/0`.
pub fn ranked(
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
@@ -478,6 +489,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
))
}
/// # Errors
///
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive,
/// or is NaN.
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
pub fn scored(
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
@@ -515,6 +532,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
))
}
/// # Errors
///
/// Delegates to [`Game::ranked`] with default options, so it returns the
/// same errors — in practice `WrongOutcomeKind` for a non-ranked outcome,
/// or `TieWithoutDrawProbability` for a draw, since the default `p_draw`
/// applies rather than one you chose.
pub fn one_v_one(
a: &Rating<T, D>,
b: &Rating<T, D>,
@@ -525,6 +548,10 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
Ok((post[0][0], post[1][0]))
}
/// # Errors
///
/// Wraps each player in a one-member team and delegates to
/// [`Game::ranked`], so it returns the same errors.
pub fn free_for_all(
players: &[&Rating<T, D>],
outcome: crate::Outcome,
+6
View File
@@ -18,6 +18,7 @@ pub struct Gaussian {
impl Gaussian {
/// Construct from mean and standard deviation.
#[must_use]
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
if sigma == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
@@ -64,16 +65,19 @@ impl Gaussian {
}
#[inline]
#[must_use]
pub fn pi(&self) -> f64 {
self.pi
}
#[inline]
#[must_use]
pub fn tau(&self) -> f64 {
self.tau
}
#[inline]
#[must_use]
pub fn mu(&self) -> f64 {
// A non-positive precision is an improper (uninformative) Gaussian — its mean is
// undefined. Treat it like `pi == 0` and return 0. EP message cancellation can land
@@ -102,6 +106,7 @@ impl Gaussian {
}
#[inline]
#[must_use]
pub fn sigma(&self) -> f64 {
// A non-positive precision is improper → infinite standard deviation. Guarding
// `pi <= 0.0` (not just `== 0.0`) keeps `1.0 / pi.sqrt()` from returning NaN when EP
@@ -145,6 +150,7 @@ impl Gaussian {
/// Used by within-game inference to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update.
#[must_use]
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(),
+293 -66
View File
@@ -1,7 +1,7 @@
use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData};
use crate::{
BETA, GAMMA, Index, MU, N_INF, P_DRAW, SIGMA,
BETA, GAMMA, Index, MU, P_DRAW, SIGMA,
competitor::{self, Competitor},
convergence::{ConvergenceOptions, ConvergenceReport},
drift::{ConstantDrift, Drift},
@@ -13,7 +13,7 @@ use crate::{
sort_time,
storage::CompetitorStore,
time::Time,
time_slice::{self, EventKind, TimeSlice},
time_slice::{self, EventKind, FilteredStep, TimeSlice},
tuple_gt, tuple_max,
};
@@ -29,7 +29,6 @@ pub struct HistoryBuilder<
beta: f64,
drift: D,
p_draw: f64,
online: bool,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
@@ -60,7 +59,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
sigma: self.sigma,
beta: self.beta,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
@@ -87,11 +85,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
self
}
pub fn online(mut self, online: bool) -> Self {
self.online = online;
self
}
/// Default observation noise for scored outcomes.
///
/// # Panics
@@ -135,7 +128,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
beta: self.beta,
drift: self.drift,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer,
@@ -155,7 +147,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
beta: self.beta,
drift: self.drift,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
@@ -171,7 +162,6 @@ impl Default for HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str>
beta: BETA,
drift: ConstantDrift(GAMMA),
p_draw: P_DRAW,
online: false,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
@@ -196,7 +186,6 @@ pub struct History<
beta: f64,
drift: D,
p_draw: f64,
online: bool,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
@@ -209,6 +198,7 @@ impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
}
impl History<i64, ConstantDrift, NullObserver, &'static str> {
#[must_use]
pub fn builder() -> HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
HistoryBuilder::default()
}
@@ -216,6 +206,7 @@ impl History<i64, ConstantDrift, NullObserver, &'static str> {
impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
/// Like `builder()` but uses a custom key type `K` instead of the default `&'static str`.
#[must_use]
pub fn builder_with_key() -> HistoryBuilder<i64, ConstantDrift, NullObserver, K> {
HistoryBuilder {
mu: MU,
@@ -223,7 +214,6 @@ impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
beta: BETA,
drift: ConstantDrift(GAMMA),
p_draw: P_DRAW,
online: false,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
@@ -264,7 +254,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
for j in (0..self.time_slices.len() - 1).rev() {
for agent in self.time_slices[j + 1].skills.keys() {
self.agents.get_mut(agent).unwrap().message =
self.time_slices[j + 1].backward_prior_out(&agent, &self.agents);
Some(self.time_slices[j + 1].backward_prior_out(&agent, &self.agents));
}
let old = self.time_slices[j].posteriors();
@@ -283,7 +273,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
for j in 1..self.time_slices.len() {
for agent in self.time_slices[j - 1].skills.keys() {
self.agents.get_mut(agent).unwrap().message =
self.time_slices[j - 1].forward_prior_out(&agent);
Some(self.time_slices[j - 1].forward_prior_out(&agent));
}
let old = self.time_slices[j].posteriors();
@@ -319,9 +309,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
/// Learning curves for all competitors, keyed by their user-facing key.
///
/// Note: `key(idx)` is O(n) per lookup; this method is therefore O(n²)
/// in the number of competitors. Acceptable for T2; T3 may optimize.
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
#[cfg(feature = "rayon")]
{
@@ -392,14 +379,79 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
.collect()
}
pub(crate) fn log_evidence_internal(&mut self, forward: bool, targets: &[Index]) -> f64 {
/// Filtered learning curves for all competitors, keyed by user-facing key.
///
/// Each point is the posterior using only events up to and including that
/// time — "what we knew then". Contrast `learning_curves`, whose points
/// are smoothed and so incorporate rounds played later.
///
/// Runs a full forward pass per call and caches nothing. This is the
/// entry point for multi-key work — see `filtered_learning_curve` for
/// why calling that once per key is far more expensive.
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
for (time, step) in self.filtered_pass() {
for (agent, posterior) in step.posteriors {
if let Some(key) = self.keys.key(agent).cloned() {
data.entry(key).or_default().push((time, posterior));
}
}
}
data
}
/// Filtered learning curve for a single key: (time, posterior) pairs in
/// time order.
///
/// Despite mirroring `learning_curve`'s signature, this is not the cheap
/// per-key lookup that method is: it runs a full forward pass, O(events),
/// discarding every posterior but the requested key's. N keys fetched
/// this way costs O(N * events); use `filtered_learning_curves` for
/// multi-key work instead — it computes the same pass once.
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
let Some(idx) = self.keys.get(key) else {
return Vec::new();
};
self.filtered_pass()
.into_iter()
.filter_map(|(time, step)| {
step.posteriors
.iter()
.find(|(agent, _)| *agent == idx)
.map(|&(_, posterior)| (time, posterior))
})
.collect()
}
/// Sum per-slice evidence.
///
/// `forward` selects `skill.forward` as each event's prior instead of the
/// cavity. That is a genuine forward-only (filtering) quantity ONLY on a
/// history that has never been converged: `iteration` alternates backward
/// and forward sweeps, so from the second iteration onward the likelihood
/// feeding the forward message has already absorbed backward information.
/// For a filtering quantity that holds after convergence, use
/// `filtered_log_evidence`.
pub(crate) fn log_evidence_internal(&self, forward: bool, targets: &[Index]) -> f64 {
// Bound before the closure so it captures the store rather than all of
// `&self`: capturing `&History` would drag `KeyTable<K>` in and demand
// `K: Sync` from every caller, which the key type need not satisfy.
let agents = &self.agents;
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
let per_slice: Vec<f64> = self
.time_slices
.par_iter()
.map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents))
.map(|ts| ts.log_evidence(targets, forward, agents))
.collect();
per_slice.into_iter().sum()
}
@@ -407,19 +459,19 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
{
self.time_slices
.iter()
.map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents))
.map(|ts| ts.log_evidence(targets, forward, agents))
.sum()
}
}
/// Total log-evidence across the history.
pub fn log_evidence(&mut self) -> f64 {
pub fn log_evidence(&self) -> f64 {
self.log_evidence_internal(false, &[])
}
/// Log-evidence restricted to time slices containing at least one of the
/// given keys. Useful for leave-one-out cross-validation.
pub fn log_evidence_for<Q>(&mut self, keys: &[&Q]) -> f64
pub fn log_evidence_for<Q>(&self, keys: &[&Q]) -> f64
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
@@ -428,6 +480,48 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
self.log_evidence_internal(false, &targets)
}
/// Walk the slices in time order carrying forward messages only.
///
/// This is the forward half of `iteration` with the backward half never
/// run. It reads `self` and mutates nothing.
fn filtered_pass(&self) -> Vec<(T, FilteredStep)> {
let mut messages: HashMap<Index, Gaussian> = HashMap::new();
let mut pass = Vec::with_capacity(self.time_slices.len());
for slice in &self.time_slices {
let step = slice.filtered_step(&messages, &self.agents);
for &(agent, posterior) in &step.posteriors {
messages.insert(agent, posterior);
}
pass.push((slice.time, step));
}
pass
}
/// Total log-evidence under forward-only (filtering) information.
///
/// Each event is scored using only what was known before that *time*,
/// which is the right quantity for prequential scoring and model
/// comparison. Events sharing a timestamp still inform each other
/// through the within-slice sweep, so within one slice this is not a
/// guarantee that event A is scored independently of simultaneous event
/// B. Contrast `log_evidence`, whose per-event priors carry information
/// from events that had not happened yet.
///
/// Runs a full forward pass per call and caches nothing. The result does
/// not depend on whether `converge` has been called.
#[must_use]
pub fn filtered_log_evidence(&self) -> f64 {
self.filtered_pass()
.iter()
.map(|(_, step)| step.log_evidence)
.sum()
}
/// Draw-probability quality metric for the given teams (key slices).
///
/// Values range roughly [0, 1]; 1 == perfectly matched. Supports any
@@ -460,7 +554,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// 2-team win probability: returns `[P(team0 wins), P(team1 wins)]`.
///
/// Panics if `teams.len() != 2`. N-team support lands in T4.
/// N-team support lands in T4.
///
/// # Panics
///
/// Panics if `teams.len() != 2`.
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Vec<f64> {
assert_eq!(teams.len(), 2, "predict_outcome T2: 2 teams only");
let gather = |team: &[&K]| -> Gaussian {
@@ -482,6 +580,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
/// Run the full forward+backward convergence loop and return a summary.
///
/// Failing to reach `epsilon` within `max_iter` is not an error: the
/// returned report carries `converged: false` and the final step.
///
/// # Errors
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
/// broken down at that point and further iterations cannot recover, so the
/// loop stops rather than reporting a NaN step as convergence.
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
use std::time::Instant;
@@ -496,7 +603,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
log_evidence: 0.0,
converged: true,
per_iteration_time: SmallVec::new(),
slices_skipped: 0,
});
}
@@ -535,7 +641,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
log_evidence,
converged,
per_iteration_time: per_iter,
slices_skipped: 0,
})
}
}
@@ -543,18 +648,23 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
pub(crate) fn add_events_with_prior(
&mut self,
composition: Vec<Vec<Vec<Index>>>,
results: Vec<Vec<f64>>,
mut composition: Vec<Vec<Vec<Index>>>,
mut results: Option<Vec<Vec<f64>>>,
times: Vec<T>,
weights: Vec<Vec<Vec<f64>>>,
mut weights: Option<Vec<Vec<Vec<f64>>>>,
kinds: Vec<EventKind>,
mut priors: HashMap<Index, Rating<T, D>>,
) -> Result<(), InferenceError> {
if !results.is_empty() && results.len() != composition.len() {
if results
.as_ref()
.is_some_and(|r| r.len() != composition.len())
{
let got = results.as_ref().map_or(0, Vec::len);
return Err(InferenceError::MismatchedShape {
kind: "results",
expected: composition.len(),
got: results.len(),
got,
});
}
if times.len() != composition.len() {
@@ -564,11 +674,16 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
got: times.len(),
});
}
if !weights.is_empty() && weights.len() != composition.len() {
if weights
.as_ref()
.is_some_and(|w| w.len() != composition.len())
{
let got = weights.as_ref().map_or(0, Vec::len);
return Err(InferenceError::MismatchedShape {
kind: "weights",
expected: composition.len(),
got: weights.len(),
got,
});
}
if kinds.len() != composition.len() {
@@ -583,7 +698,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
// including `record_draw`, which builds its results directly rather
// than going through `Outcome`.
if self.p_draw == 0.0 {
for (event_results, kind) in results.iter().zip(kinds.iter()) {
for (event_results, kind) in results.iter().flatten().zip(kinds.iter()) {
if !matches!(kind, EventKind::Ranked) {
continue;
}
@@ -616,7 +731,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
self.drift,
)
}),
message: N_INF,
message: None,
last_time: None,
},
);
@@ -626,6 +741,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let n = composition.len();
let o = sort_time(&times, false);
// The chunking loop below MOVES each event's data out of `composition`,
// `results` and `weights` instead of cloning it. That is only sound
// because `o` is a permutation, so every index is visited exactly once
// — visiting one twice would silently yield an empty event rather than
// failing.
debug_assert!(
{
let mut seen = vec![false; n];
o.iter()
.all(|&idx| !std::mem::replace(&mut seen[idx], true))
},
"sort_time must return a permutation of 0..{n}"
);
let mut i = 0;
let mut k = 0;
@@ -654,7 +783,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let agent = self.agents.get_mut(*agent_idx).unwrap();
agent.last_time = Some(time_slice.time);
agent.message = time_slice.forward_prior_out(agent_idx);
agent.message = Some(time_slice.forward_prior_out(agent_idx));
}
}
@@ -662,20 +791,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
let composition = (i..j)
.map(|e| composition[o[e]].clone())
.map(|e| std::mem::take(&mut composition[o[e]]))
.collect::<Vec<_>>();
let results = if results.is_empty() {
Vec::new()
} else {
(i..j).map(|e| results[o[e]].clone()).collect::<Vec<_>>()
};
let results = results.as_mut().map(|results| {
(i..j)
.map(|e| std::mem::take(&mut results[o[e]]))
.collect::<Vec<_>>()
});
let weights = if weights.is_empty() {
Vec::new()
} else {
(i..j).map(|e| weights[o[e]].clone()).collect::<Vec<_>>()
};
let weights = weights.as_mut().map(|weights| {
(i..j)
.map(|e| std::mem::take(&mut weights[o[e]]))
.collect::<Vec<_>>()
});
let kinds_chunk: Vec<EventKind> = (i..j).map(|e| kinds[o[e]]).collect();
@@ -687,7 +816,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let agent = self.agents.get_mut(agent_idx).unwrap();
agent.last_time = Some(t);
agent.message = time_slice.forward_prior_out(&agent_idx);
agent.message = Some(time_slice.forward_prior_out(&agent_idx));
}
k += 1;
@@ -703,7 +832,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let agent = self.agents.get_mut(agent_idx).unwrap();
agent.last_time = Some(t);
agent.message = time_slice.forward_prior_out(&agent_idx);
agent.message = Some(time_slice.forward_prior_out(&agent_idx));
}
k += 1;
@@ -727,7 +856,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let agent = self.agents.get_mut(*agent_idx).unwrap();
agent.last_time = Some(time_slice.time);
agent.message = time_slice.forward_prior_out(agent_idx);
agent.message = Some(time_slice.forward_prior_out(agent_idx));
}
}
@@ -738,6 +867,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
Ok(())
}
/// Record a single two-competitor event that `winner` won.
///
/// # Errors
///
/// Ingests through the same path as [`History::add_events`], so it returns
/// the same errors. A two-team decisive outcome cannot tie, so
/// `TieWithoutDrawProbability` is not reachable here.
pub fn record_winner<Q>(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError>
where
K: Borrow<Q>,
@@ -747,14 +883,21 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let l = self.intern(loser);
self.add_events_with_prior(
vec![vec![vec![w], vec![l]]],
vec![vec![1.0, 0.0]],
Some(vec![vec![1.0, 0.0]]),
vec![time],
vec![],
None,
vec![EventKind::Ranked],
HashMap::new(),
)
}
/// Record a single two-competitor event that ended level.
///
/// # Errors
///
/// Ingests through the same path as [`History::add_events`]. Note
/// `TieWithoutDrawProbability` *is* reachable here: a draw needs a
/// positive `p_draw`.
pub fn record_draw<Q>(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError>
where
K: Borrow<Q>,
@@ -764,9 +907,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let b_idx = self.intern(b);
self.add_events_with_prior(
vec![vec![vec![a_idx], vec![b_idx]]],
vec![vec![0.0, 0.0]],
Some(vec![vec![0.0, 0.0]]),
vec![time],
vec![],
None,
vec![EventKind::Ranked],
HashMap::new(),
)
@@ -778,6 +921,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
/// Bulk-ingest typed events.
///
/// # Errors
///
/// - `MismatchedShape` if an event's outcome does not describe the same
/// number of teams the event has, or if per-member weights do not match
/// the team's membership.
/// - `InvalidParameter` if a per-event `score_sigma` override is not
/// strictly positive.
/// - `TieWithoutDrawProbability` if an event ties two teams while the
/// history's `p_draw` is zero. This includes `Outcome::winner(w, n)` for
/// `n >= 3`, which ties every loser.
pub fn add_events<I>(&mut self, events: I) -> Result<(), InferenceError>
where
I: IntoIterator<Item = crate::event::Event<T, K>>,
@@ -814,8 +968,37 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let idx = self.keys.get_or_create(&member.key);
team_indices.push(idx);
team_weights.push(member.weight);
if let Some(prior) = member.prior {
priors.insert(idx, Rating::new(prior, self.beta, self.drift));
if let Some(scale) = member.drift_scale {
// Squaring would make a negative scale behave as its
// absolute value, so reject rather than silently
// accept a sign the caller cannot have meant.
if !scale.is_finite() || scale < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift_scale",
value: scale,
});
}
}
// `prior` and `drift_scale` are competitor configuration,
// captured here and consumed at competitor creation. Both
// land in the same entry so a member may set either alone.
if member.prior.is_some() || member.drift_scale.is_some() {
let rating = priors.entry(idx).or_insert_with(|| {
Rating::new(
Gaussian::from_ms(self.mu, self.sigma),
self.beta,
self.drift,
)
});
if let Some(prior) = member.prior {
rating.prior = prior;
}
if let Some(scale) = member.drift_scale {
rating.drift_scale = scale;
}
}
}
event_comp.push(team_indices);
@@ -849,7 +1032,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
times.push(ev.time);
}
self.add_events_with_prior(composition, results, times, weights, kinds, priors)
let weights = if weights.is_empty() {
None
} else {
Some(weights)
};
self.add_events_with_prior(composition, Some(results), times, weights, kinds, priors)
}
}
@@ -864,6 +1053,49 @@ mod tests {
arena::ScratchArena,
};
/// #17: a slice's footprint must be O(competitors in the slice), not
/// O(largest global index it touches). The store used to be a dense
/// `Vec<Skill>` indexed by `Index.0`, so the same two-competitor games cost
/// 20,000 slots per slice when the competitors sat at the top of a large
/// roster. Measured end to end, peak RSS was 309 MB against 52 MB.
#[test]
fn per_slice_footprint_is_independent_of_index_magnitude() {
fn total_skill_slots(high_indices: bool) -> usize {
let mut h: History<i64, ConstantDrift, NullObserver, String> =
History::builder_with_key().build();
for i in 0..2_000 {
h.intern(&format!("k{i:05}"));
}
let (a, b) = if high_indices {
("k01998".to_string(), "k01999".to_string())
} else {
("k00000".to_string(), "k00001".to_string())
};
for time in 1..=20i64 {
h.record_winner(&a, &b, time).unwrap();
}
h.time_slices
.iter()
.map(|ts| ts.skills.allocated_slots())
.sum()
}
let low = total_skill_slots(false);
let high = total_skill_slots(true);
assert_eq!(low, high, "footprint must not depend on index magnitude");
// A dense store over a 2,000-key roster would allocate 20 x 2,000.
assert!(
high < 1_000,
"20 slices of 2 competitors allocated {high} slots"
);
}
fn make_events_1v1(
pairs: &[(&'static str, &'static str)],
outcomes: &[Outcome],
@@ -935,12 +1167,7 @@ mod tests {
let w = [vec![1.0], vec![1.0]];
let p = Game::ranked_with_arena(
h.time_slices[1].events[0].within_priors(
false,
false,
&h.time_slices[1].skills,
&h.agents,
),
h.time_slices[1].events[0].within_priors(false, &h.time_slices[1].skills, &h.agents),
&[0.0, 1.0],
&w,
P_DRAW,
@@ -1180,11 +1407,11 @@ mod tests {
let f = h.keys.get("f").unwrap();
let trueskill_log_evidence = h.log_evidence_internal(false, &[]);
let trueskill_log_evidence_online = h.log_evidence_internal(true, &[]);
let trueskill_log_evidence_forward = h.log_evidence_internal(true, &[]);
assert_ulps_eq!(
trueskill_log_evidence,
trueskill_log_evidence_online,
trueskill_log_evidence_forward,
epsilon = 1e-6
);
+4
View File
@@ -25,6 +25,7 @@ impl<K> KeyTable<K>
where
K: Eq + Hash + Clone,
{
#[must_use]
pub fn new() -> Self {
Self {
forward: HashMap::new(),
@@ -54,6 +55,7 @@ where
}
}
#[must_use]
pub fn key(&self, idx: Index) -> Option<&K> {
self.reverse.get(idx.0)
}
@@ -62,10 +64,12 @@ where
self.forward.keys()
}
#[must_use]
pub fn len(&self) -> usize {
self.reverse.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.reverse.is_empty()
}
+3 -2
View File
@@ -1,6 +1,6 @@
//! TrueSkill Through Time — Bayesian skill rating over a time axis.
//! `TrueSkill` Through Time — Bayesian skill rating over a time axis.
//!
//! Where plain TrueSkill gives each competitor one running estimate, TrueSkill
//! Where plain `TrueSkill` gives each competitor one running estimate, `TrueSkill`
//! Through Time treats a whole history as a single model and infers skill *at
//! every point in time*. Evidence flows both directions: a result today
//! sharpens the estimate of who someone was last year, so early estimates stop
@@ -361,6 +361,7 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
/// Panics if fewer than two rating groups are supplied, or if any group is
/// empty — match quality is a property of a contest between at least two
/// non-empty sides.
#[must_use]
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
assert!(
rating_groups.len() >= 2,
+8
View File
@@ -29,7 +29,13 @@ pub enum Outcome {
impl Outcome {
/// `n`-team outcome where team `winner` won and everyone else tied for last.
///
/// Note this ties every loser, so for `n >= 3` it needs a positive
/// `p_draw` — see `InferenceError::TieWithoutDrawProbability`.
///
/// # Panics
///
/// Panics if `winner >= n`.
#[must_use]
pub fn winner(winner: u32, n: u32) -> Self {
assert!(winner < n, "winner index {winner} out of range 0..{n}");
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
@@ -37,6 +43,7 @@ impl Outcome {
}
/// All `n` teams tied.
#[must_use]
pub fn draw(n: u32) -> Self {
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
}
@@ -68,6 +75,7 @@ impl Outcome {
}
}
#[must_use]
pub fn team_count(&self) -> usize {
match self {
Self::Ranked(r) => r.len(),
+37
View File
@@ -16,6 +16,9 @@ pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub(crate) prior: Gaussian,
pub(crate) beta: f64,
pub(crate) drift: D,
/// Multiplier on the drift *variance* this competitor accumulates; 1.0 is
/// the neutral default. Set per competitor via `Member::with_drift_scale`.
pub(crate) drift_scale: f64,
pub(crate) _time: PhantomData<T>,
}
@@ -25,10 +28,21 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
prior,
beta,
drift,
drift_scale: 1.0,
_time: PhantomData,
}
}
/// Scale how fast this competitor drifts, relative to `drift`.
///
/// Multiplies the drift *variance*, so the scale is in the same units as
/// `gamma`. `0.0` pins the competitor still.
#[must_use]
pub fn with_drift_scale(mut self, drift_scale: f64) -> Self {
self.drift_scale = drift_scale;
self
}
/// The configured prior skill estimate.
#[must_use]
pub fn prior(&self) -> Gaussian {
@@ -47,6 +61,28 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
self.drift
}
/// This competitor's multiplier on the drift variance; 1.0 is neutral.
#[must_use]
pub fn drift_scale(&self) -> f64 {
self.drift_scale
}
/// Drift variance accumulated over `from -> to`, scaled for this competitor.
///
/// The single place the scale is applied for a `Time`-typed span. Callers
/// must go through this rather than `self.drift` directly, so a competitor's
/// scale cannot be silently skipped.
pub(crate) fn drift_variance_delta(&self, from: &T, to: &T) -> f64 {
self.drift.variance_delta(from, to) * self.drift_scale * self.drift_scale
}
/// Drift variance for a cached elapsed count, scaled for this competitor.
///
/// The counterpart of `drift_variance_delta` for the cached-elapsed paths.
pub(crate) fn drift_variance_for_elapsed(&self, elapsed: i64) -> f64 {
self.drift.variance_for_elapsed(elapsed) * self.drift_scale * self.drift_scale
}
pub(crate) fn performance(&self) -> Gaussian {
self.prior.forget(self.beta.powi(2))
}
@@ -58,6 +94,7 @@ impl Default for Rating<i64, ConstantDrift> {
prior: Gaussian::default(),
beta: BETA,
drift: ConstantDrift(GAMMA),
drift_scale: 1.0,
_time: PhantomData,
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
//! Schedule trait and built-in implementations.
//!
//! A schedule drives factor propagation to convergence. The default
//! `EpsilonOrMax` performs one TeamSum sweep (setup) then alternating
//! `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.
@@ -23,7 +23,7 @@ pub trait Schedule: Send + Sync {
/// 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).
/// same factor layout (`TeamSums` first, then alternating RankDiff/Trunc pairs).
#[derive(Debug, Clone, Copy)]
pub struct EpsilonOrMax {
pub eps: f64,
+6 -1
View File
@@ -2,7 +2,7 @@ use crate::{Index, competitor::Competitor, drift::Drift, time::Time};
/// Dense Vec-backed store for competitor state in History.
///
/// Indexed directly by Index.0, eliminating HashMap hashing in the
/// Indexed directly by Index.0, eliminating `HashMap` hashing in the
/// forward/backward sweep. Uses `Vec<Option<Competitor<T, D>>>` so slots can be
/// absent without an explicit present mask.
#[derive(Debug)]
@@ -21,6 +21,7 @@ impl<T: Time, D: Drift<T>> Default for CompetitorStore<T, D> {
}
impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
@@ -39,6 +40,7 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
self.competitors[idx.0] = Some(competitor);
}
#[must_use]
pub fn get(&self, idx: Index) -> Option<&Competitor<T, D>> {
self.competitors.get(idx.0).and_then(|slot| slot.as_ref())
}
@@ -49,14 +51,17 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
.and_then(|slot| slot.as_mut())
}
#[must_use]
pub fn contains(&self, idx: Index) -> bool {
self.get(idx).is_some()
}
#[must_use]
pub fn len(&self) -> usize {
self.n_present
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.n_present == 0
}
+118 -56
View File
@@ -1,15 +1,27 @@
use std::collections::HashMap;
use crate::{Index, time_slice::Skill};
/// Dense Vec-backed store for per-agent skill state within a TimeSlice.
/// Compact per-slice store for skill state, addressed by a slice-local slot.
///
/// Indexed directly by Index.0, eliminating HashMap hashing in the inner
/// convergence loop. Uses a parallel `present` mask so iteration skips
/// absent slots without incurring per-slot Option overhead in the hot path.
/// `skills` holds one entry per competitor **in this slice**, so memory is
/// O(competitors in the slice). It used to be a dense `Vec<Skill>` indexed by
/// the global `Index.0`, which made a slice's footprint O(largest index it
/// touches): a single 1v1 game between competitors 19998 and 19999 reserved
/// 20,000 slots.
///
/// The dense layout existed to keep `HashMap` hashing out of the inner
/// convergence loop, and that property is preserved. `slots` is consulted only
/// while building a slice; every hot-path access goes through
/// [`SkillStore::at`] / [`SkillStore::at_mut`] with a slot resolved once at
/// ingestion and cached on the event's `Item`.
#[derive(Debug, Default)]
pub struct SkillStore {
skills: Vec<Skill>,
present: Vec<bool>,
n_present: usize,
/// Slot -> global index, parallel to `skills`, so iteration can report the
/// global index without a reverse lookup.
indices: Vec<Index>,
slots: HashMap<Index, u32>,
}
impl SkillStore {
@@ -17,73 +29,99 @@ impl SkillStore {
Self::default()
}
fn ensure_capacity(&mut self, idx: usize) {
if idx >= self.skills.len() {
self.skills.resize_with(idx + 1, Skill::default);
self.present.resize(idx + 1, false);
}
/// Resolve a global index to this slice's slot, if the competitor is here.
///
/// This hashes. Call it at ingestion and cache the result; do not call it
/// from the convergence loop.
pub fn slot_of(&self, idx: Index) -> Option<u32> {
self.slots.get(&idx).copied()
}
pub fn insert(&mut self, idx: Index, skill: Skill) {
self.ensure_capacity(idx.0);
if !self.present[idx.0] {
self.n_present += 1;
/// Skill at a slot resolved earlier by [`SkillStore::slot_of`].
///
/// # Panics
///
/// Panics if `slot` is out of range, which means it came from a different
/// slice's store.
pub fn at(&self, slot: u32) -> &Skill {
&self.skills[slot as usize]
}
/// Mutable counterpart to [`SkillStore::at`].
///
/// # Panics
///
/// Panics if `slot` is out of range.
pub fn at_mut(&mut self, slot: u32) -> &mut Skill {
&mut self.skills[slot as usize]
}
/// Insert or overwrite a competitor's skill, returning its slot.
pub fn insert(&mut self, idx: Index, skill: Skill) -> u32 {
match self.slots.get(&idx) {
Some(&slot) => {
self.skills[slot as usize] = skill;
slot
}
None => {
let slot = u32::try_from(self.skills.len())
.expect("a time slice cannot hold more than u32::MAX competitors");
self.skills.push(skill);
self.indices.push(idx);
self.slots.insert(idx, slot);
slot
}
}
self.skills[idx.0] = skill;
self.present[idx.0] = true;
}
pub fn get(&self, idx: Index) -> Option<&Skill> {
if idx.0 < self.present.len() && self.present[idx.0] {
Some(&self.skills[idx.0])
} else {
None
}
}
/// Whether a slot is occupied. Test-only.
#[cfg(test)]
pub fn contains(&self, idx: Index) -> bool {
idx.0 < self.present.len() && self.present[idx.0]
}
/// Number of occupied slots. Test-only.
#[cfg(test)]
pub fn len(&self) -> usize {
self.n_present
self.slot_of(idx).map(|slot| self.at(slot))
}
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> {
if idx.0 < self.present.len() && self.present[idx.0] {
Some(&mut self.skills[idx.0])
} else {
None
}
self.slot_of(idx)
.map(|slot| &mut self.skills[slot as usize])
}
/// Whether a competitor is present in this slice. Test-only.
#[cfg(test)]
pub fn contains(&self, idx: Index) -> bool {
self.slots.contains_key(&idx)
}
/// Number of competitors in this slice. Test-only.
#[cfg(test)]
pub fn len(&self) -> usize {
self.skills.len()
}
/// Slots actually allocated — the quantity #17 is about, and NOT the same
/// as `len` for every possible implementation.
///
/// A store indexed by the global `Index` must report `max_index + 1` here
/// while reporting the true competitor count from `len`, which is exactly
/// how the original defect hid. Tests that mean to pin the footprint must
/// assert on this.
#[cfg(test)]
pub fn allocated_slots(&self) -> usize {
self.skills.len()
}
/// Iterate in slot order — the order competitors were first seen in this
/// slice. Deterministic for a given event order, which is what the
/// cross-thread determinism test relies on.
pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
self.present.iter().enumerate().filter_map(|(i, &p)| {
if p {
Some((Index(i), &self.skills[i]))
} else {
None
}
})
self.indices.iter().copied().zip(self.skills.iter())
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Skill)> {
self.skills
.iter_mut()
.zip(self.present.iter())
.enumerate()
.filter_map(|(i, (s, &p))| if p { Some((Index(i), s)) } else { None })
self.indices.iter().copied().zip(self.skills.iter_mut())
}
pub fn keys(&self) -> impl Iterator<Item = Index> + '_ {
self.present
.iter()
.enumerate()
.filter_map(|(i, &p)| if p { Some(Index(i)) } else { None })
self.indices.iter().copied()
}
}
@@ -109,7 +147,7 @@ mod tests {
}
#[test]
fn iter_skips_absent_slots() {
fn iter_reports_global_indices() {
let mut store = SkillStore::new();
store.insert(Index(0), Skill::default());
store.insert(Index(5), Skill::default());
@@ -124,4 +162,28 @@ mod tests {
store.insert(Index(2), Skill::default());
assert_eq!(store.len(), 1);
}
/// The defect in #17: a slice holding two competitors must cost the same
/// whether their indices are small or large.
#[test]
fn footprint_is_independent_of_index_magnitude() {
let mut low = SkillStore::new();
low.insert(Index(0), Skill::default());
low.insert(Index(1), Skill::default());
let mut high = SkillStore::new();
high.insert(Index(19_998), Skill::default());
high.insert(Index(19_999), Skill::default());
assert_eq!(low.len(), high.len());
assert_eq!(low.skills.capacity(), high.skills.capacity());
}
#[test]
fn slot_survives_reinsert() {
let mut store = SkillStore::new();
let first = store.insert(Index(7), Skill::default());
let again = store.insert(Index(7), Skill::default());
assert_eq!(first, again);
}
}
+174 -54
View File
@@ -22,7 +22,6 @@ pub(crate) struct Skill {
backward: Gaussian,
likelihood: Gaussian,
pub(crate) elapsed: i64,
pub(crate) online: Gaussian,
}
impl Skill {
@@ -38,7 +37,6 @@ impl Default for Skill {
backward: N_INF,
likelihood: N_INF,
elapsed: 0,
online: N_INF,
}
}
}
@@ -50,40 +48,45 @@ pub enum EventKind {
Scored { score_sigma: f64 },
}
#[derive(Debug)]
#[derive(Clone, Debug)]
struct Item {
agent: Index,
/// This competitor's slot in the owning slice's `SkillStore`, resolved
/// once at ingestion.
///
/// The convergence loop reaches skills through this rather than through
/// `agent`, which is what keeps `HashMap` hashing out of the hot path now
/// that the store is compact rather than indexed by the global `Index`.
slot: u32,
likelihood: Gaussian,
}
impl Item {
fn within_prior<T: Time, D: Drift<T>>(
&self,
online: bool,
forward: bool,
skills: &SkillStore,
agents: &CompetitorStore<T, D>,
) -> Rating<T, D> {
let r = &agents[self.agent].rating;
let skill = skills.get(self.agent).unwrap();
let skill = skills.at(self.slot);
if online {
Rating::new(skill.online, r.beta, r.drift)
} else if forward {
Rating::new(skill.forward, r.beta, r.drift)
if forward {
Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale)
} else {
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
.with_drift_scale(r.drift_scale)
}
}
}
#[derive(Debug)]
#[derive(Clone, Debug)]
struct Team {
items: Vec<Item>,
output: f64,
}
#[derive(Debug)]
#[derive(Clone, Debug)]
pub(crate) struct Event {
teams: Vec<Team>,
log_evidence: f64,
@@ -107,7 +110,6 @@ impl Event {
pub(crate) fn within_priors<T: Time, D: Drift<T>>(
&self,
online: bool,
forward: bool,
skills: &SkillStore,
agents: &CompetitorStore<T, D>,
@@ -117,7 +119,7 @@ impl Event {
.map(|team| {
team.items
.iter()
.map(|item| item.within_prior(online, forward, skills, agents))
.map(|item| item.within_prior(forward, skills, agents))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
@@ -136,7 +138,7 @@ impl Event {
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) -> EventUpdate {
let teams = self.within_priors(false, false, skills, agents);
let teams = self.within_priors(false, skills, agents);
let result = self.outputs();
let g = match self.kind {
EventKind::Ranked => {
@@ -163,9 +165,9 @@ impl Event {
for (t, team) in self.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() {
let fresh = update.likelihoods[t][i];
let old_likelihood = skills.get(item.agent).unwrap().likelihood;
let old_likelihood = skills.at(item.slot).likelihood;
let new_likelihood = (old_likelihood / item.likelihood) * fresh;
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = fresh;
}
}
@@ -195,6 +197,17 @@ struct EventUpdate {
likelihoods: Vec<Vec<Gaussian>>,
}
/// One slice's worth of forward-only inference.
///
/// `posteriors` doubles as the outgoing forward message: the scratch sweep
/// never writes `backward`, so it stays `N_INF`, and `Skill::posterior()`
/// and `forward_prior_out` are then the same product.
#[derive(Debug)]
pub(crate) struct FilteredStep {
pub(crate) log_evidence: f64,
pub(crate) posteriors: Vec<(Index, Gaussian)>,
}
#[derive(Debug)]
pub struct TimeSlice<T: Time = i64> {
pub(crate) events: Vec<Event>,
@@ -272,8 +285,8 @@ impl<T: Time> TimeSlice<T> {
pub fn add_events<D: Drift<T>>(
&mut self,
composition: Vec<Vec<Vec<Index>>>,
results: Vec<Vec<f64>>,
weights: Vec<Vec<Vec<f64>>>,
results: Option<Vec<Vec<f64>>>,
weights: Option<Vec<Vec<Vec<f64>>>>,
kinds: Vec<EventKind>,
agents: &CompetitorStore<T, D>,
) {
@@ -292,21 +305,26 @@ impl<T: Time> TimeSlice<T> {
for idx in this_agent {
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
let forward = agents[*idx].receive(&self.time);
if let Some(skill) = self.skills.get_mut(*idx) {
skill.elapsed = elapsed;
skill.forward = agents[*idx].receive(&self.time);
skill.forward = forward;
} else {
self.skills.insert(
*idx,
Skill {
forward: agents[*idx].receive(&self.time),
forward,
backward: N_INF,
likelihood: N_INF,
elapsed,
..Default::default()
},
);
}
}
let skills = &self.skills;
let events = composition.iter().enumerate().map(|(e, event)| {
let teams = event
.iter()
@@ -316,28 +334,32 @@ impl<T: Time> TimeSlice<T> {
.iter()
.map(|&agent| Item {
agent,
// Every participant was inserted into `skills`
// just above, so the slot always resolves.
slot: skills
.slot_of(agent)
.expect("participant must be present in the slice store"),
likelihood: N_INF,
})
.collect::<Vec<_>>();
Team {
items,
output: if results.is_empty() {
(event.len() - (t + 1)) as f64
} else {
results[e][t]
output: match &results {
Some(results) => results[e][t],
// No explicit result: rank by position, first team best.
None => (event.len() - (t + 1)) as f64,
},
}
})
.collect::<Vec<_>>();
let weights = if weights.is_empty() {
teams
let weights = match &weights {
Some(weights) => weights[e].clone(),
None => teams
.iter()
.map(|team| vec![1.0; team.items.len()])
.collect::<Vec<_>>()
} else {
weights[e].clone()
.collect::<Vec<_>>(),
};
Event {
@@ -364,6 +386,13 @@ impl<T: Time> TimeSlice<T> {
.collect::<HashMap<_, _>>()
}
/// Sweep this slice's events once, starting at index `from`.
///
/// # Panics
///
/// Panics if an event references a competitor with no entry in this
/// slice's skill store. `add_events` inserts one for every participant, so
/// this cannot happen for slices built through the public API.
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
if from == 0 && self.color_groups_dirty {
self.recompute_color_groups();
@@ -372,7 +401,7 @@ impl<T: Time> TimeSlice<T> {
if from > 0 || self.color_groups.is_empty() {
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
for event in self.events.iter_mut().skip(from) {
let teams = event.within_priors(false, false, &self.skills, agents);
let teams = event.within_priors(false, &self.skills, agents);
let result = event.outputs();
let g = match event.kind {
@@ -396,10 +425,10 @@ impl<T: Time> TimeSlice<T> {
for (t, team) in event.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() {
let old_likelihood = self.skills.get(item.agent).unwrap().likelihood;
let old_likelihood = self.skills.at(item.slot).likelihood;
let new_likelihood =
(old_likelihood / item.likelihood) * g.likelihoods[t][i];
self.skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
self.skills.at_mut(item.slot).likelihood = new_likelihood;
item.likelihood = g.likelihoods[t][i];
}
}
@@ -506,13 +535,13 @@ impl<T: Time> TimeSlice<T> {
/// Iterate this slice alone until its posteriors stop moving, returning
/// the number of iterations taken.
///
/// Only used by tests: production convergence is driven across slices by
/// `History::converge`.
/// Used by `filtered_step` to drive a scratch copy of the slice, and by
/// tests. Production convergence across slices is driven by
/// `History::converge`, which calls `iteration` directly.
///
/// Honours `self.convergence`; it previously hard-coded an epsilon and a
/// 20-iteration cap that matched neither `ConvergenceOptions` nor the
/// schedule default.
#[cfg(test)]
pub(crate) fn iterate_to_convergence<D: Drift<T>>(
&mut self,
agents: &CompetitorStore<T, D>,
@@ -561,14 +590,13 @@ impl<T: Time> TimeSlice<T> {
n.forget(
agents[*agent]
.rating
.drift
.variance_for_elapsed(skill.elapsed),
.drift_variance_for_elapsed(skill.elapsed),
)
}
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
for (agent, skill) in self.skills.iter_mut() {
skill.backward = agents[agent].message;
skill.backward = agents[agent].message.unwrap_or(N_INF);
}
self.iteration(0, agents);
}
@@ -580,9 +608,83 @@ impl<T: Time> TimeSlice<T> {
self.iteration(0, agents);
}
/// Run this slice's events on forward (filtering) information alone.
///
/// `incoming` holds each competitor's forward message out of their
/// previous appearance; a competitor absent from it starts at their
/// configured prior. The sweep runs on a scratch copy, so the real slice
/// is untouched — which is what makes the filtered estimates independent
/// of whether `History::converge` has run.
pub(crate) fn filtered_step<D: Drift<T>>(
&self,
incoming: &HashMap<Index, Gaussian>,
agents: &CompetitorStore<T, D>,
) -> FilteredStep {
let mut scratch = TimeSlice {
events: self.events.clone(),
skills: SkillStore::new(),
time: self.time,
p_draw: self.p_draw,
convergence: self.convergence,
arena: ScratchArena::new(),
color_groups: ColorGroups::new(),
color_groups_dirty: true,
};
for event in &mut scratch.events {
for team in &mut event.teams {
for item in &mut team.items {
item.likelihood = N_INF;
}
}
event.log_evidence = 0.0;
}
for (agent, skill) in self.skills.iter() {
let rating = &agents[agent].rating;
let forward = match incoming.get(&agent) {
Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
None => rating.prior,
};
let slot = scratch.skills.insert(
agent,
Skill {
forward,
backward: N_INF,
likelihood: N_INF,
elapsed: skill.elapsed,
},
);
// The cloned events carry slots resolved against the REAL store, so
// the scratch must assign the same ones. It does because `iter()`
// yields slot order and `insert` allocates slots in call order —
// but that is a coupling between two types, so pin it here rather
// than leave it to be rediscovered after it breaks.
debug_assert_eq!(
Some(slot),
self.skills.slot_of(agent),
"scratch slot must match the real slice's slot for {agent:?}"
);
}
scratch.iterate_to_convergence(agents);
FilteredStep {
log_evidence: scratch.events.iter().map(|event| event.log_evidence).sum(),
posteriors: scratch
.skills
.iter()
.map(|(agent, skill)| (agent, skill.posterior()))
.collect(),
}
}
pub(crate) fn log_evidence<D: Drift<T>>(
&self,
online: bool,
targets: &[Index],
forward: bool,
agents: &CompetitorStore<T, D>,
@@ -594,7 +696,7 @@ impl<T: Time> TimeSlice<T> {
let mut arena = ScratchArena::new();
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
let teams = event.within_priors(online, forward, &self.skills, agents);
let teams = event.within_priors(forward, &self.skills, agents);
let result = event.outputs();
match event.kind {
EventKind::Ranked => {
@@ -623,7 +725,7 @@ impl<T: Time> TimeSlice<T> {
};
if targets.is_empty() {
if online || forward {
if forward {
self.events
.iter()
.map(|event| run_event(event, &mut arena))
@@ -631,7 +733,7 @@ impl<T: Time> TimeSlice<T> {
} else {
self.events.iter().map(|event| event.log_evidence).sum()
}
} else if online || forward {
} else if forward {
self.events
.iter()
.filter(|event| {
@@ -685,8 +787,26 @@ impl<T: Time> TimeSlice<T> {
}
}
/// Elapsed time from a competitor's previous appearance to `current`.
///
/// A negative elapsed means slices are being visited out of time order, which
/// would make drift *reduce* uncertainty. Release builds clamp to zero so a
/// bad timestamp degrades to "no drift" rather than corrupting the posterior;
/// debug builds trip instead, because reaching here is a bug in slice ordering
/// rather than something callers can cause with ordinary data.
pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 {
last.map(|l| l.elapsed_to(current).max(0)).unwrap_or(0)
let Some(last) = last else {
return 0;
};
let elapsed = last.elapsed_to(current);
debug_assert!(
elapsed >= 0,
"negative elapsed ({elapsed}) — slices visited out of time order"
);
elapsed.max(0)
}
#[cfg(test)]
@@ -734,8 +854,8 @@ mod tests {
vec![vec![c], vec![d]],
vec![vec![e], vec![f]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
);
@@ -811,8 +931,8 @@ mod tests {
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
);
@@ -891,8 +1011,8 @@ mod tests {
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
);
@@ -923,8 +1043,8 @@ mod tests {
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
);
@@ -994,8 +1114,8 @@ mod tests {
vec![vec![c], vec![d]],
vec![vec![a], vec![c]],
],
vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]],
vec![],
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
None,
vec![EventKind::Ranked; 3],
&agents,
);
+43
View File
@@ -247,3 +247,46 @@ fn fluent_event_builder_scores() {
let b = h.current_skill(&"bob").unwrap();
assert!(a.mu() > b.mu());
}
/// Every field of `ConvergenceReport` must carry real information.
///
/// `slices_skipped` was public, hardcoded to `0`, and reported a plausible
/// value for a feature that never existed — the same shape as the inert
/// `online` flag in #19. It was removed in #33. This pins the remaining fields
/// so the next always-constant member has to survive an assertion rather than
/// just a reviewer's attention.
#[test]
fn every_convergence_report_field_is_populated() {
let mut h = History::builder().build();
for time in 1..=6i64 {
h.record_winner(&"a", &"b", time).unwrap();
}
let report = h.converge().unwrap();
assert!(
report.iterations > 0,
"iterations is zero on a real converge"
);
assert!(report.converged, "fixture must converge");
assert!(
report.final_step.0.is_finite() && report.final_step.1.is_finite(),
"final_step is not finite: {:?}",
report.final_step
);
assert!(
report.log_evidence.is_finite() && report.log_evidence < 0.0,
"log_evidence is not a finite negative log probability: {}",
report.log_evidence
);
assert_eq!(
report.per_iteration_time.len(),
report.iterations,
"per_iteration_time must carry one duration per iteration"
);
}
+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}"));
}
}
+186 -10
View File
@@ -3,9 +3,12 @@
//! These run in both debug and release: the defects they pin were all
//! guarded only by `debug_assert!`, so a debug-only suite never saw them.
mod common;
use common::assert_finite;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
Outcome, Rating,
NullObserver, Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
@@ -18,15 +21,6 @@ fn rating() -> R {
)
}
fn assert_finite(g: Gaussian, what: &str) {
assert!(
g.mu().is_finite() && g.sigma().is_finite(),
"{what} must be finite, got mu={} sigma={}",
g.mu(),
g.sigma()
);
}
#[test]
fn record_draw_without_draw_probability_is_rejected() {
let mut h = History::default();
@@ -127,6 +121,68 @@ fn empty_history_converges_trivially() {
assert!(report.converged);
}
/// Issue #27's exact reproduction: a non-default key type reaching `converge`
/// with no events at all. The underflow it reported trapped in debug and
/// indexed out of bounds in release, so this must run in both profiles.
#[test]
fn converge_on_an_empty_history_with_owned_keys() {
let mut history: History<i64, ConstantDrift, NullObserver, String> =
History::builder_with_key().score_sigma(5.0).build();
let report = history.converge().unwrap();
assert_eq!(report.iterations, 0);
assert!(report.converged);
}
/// A weights/team length mismatch used to be a `debug_assert!`, so release
/// builds ingested the event with the weights silently unapplied. This file's
/// CI job runs in release too, which is the point of pinning it here.
#[test]
fn event_builder_rejects_a_weights_length_mismatch() {
let mut h = History::default();
let err = h
.event(1)
.team(["a"])
.weights([1.0, 2.0])
.team(["b"])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(
err,
InferenceError::MismatchedShape {
kind: "weights",
expected: 1,
got: 2,
}
),
"expected a weights MismatchedShape, got {err:?}"
);
}
/// The mismatch must not be applied even partially — a half-weighted team
/// reaching the history would be worse than the error.
#[test]
fn event_builder_weights_mismatch_leaves_the_history_untouched() {
let mut h = History::default();
// Two teams, so ingestion would otherwise succeed — a one-team event is
// rejected for an unrelated reason and would pass this vacuously.
let _ = h
.event(1)
.team(["a"])
.weights([1.0, 2.0])
.team(["b"])
.winner(0)
.commit();
assert!(h.learning_curve("a").is_empty());
}
#[test]
fn empty_event_stream_then_converge() {
let mut h = History::default();
@@ -245,3 +301,123 @@ fn log_evidence_finite_for_near_certain_outcome() {
upset.log_evidence()
);
}
#[test]
fn empty_history_has_no_filtered_estimates() {
let history: History = History::builder().build();
assert_eq!(history.filtered_log_evidence(), 0.0);
assert!(history.filtered_learning_curves().is_empty());
assert!(history.filtered_learning_curve("nobody").is_empty());
}
// --- Boundary inputs (#26) ----------------------------------------------
fn tight() -> ConvergenceOptions {
ConvergenceOptions {
max_iter: 2_000,
epsilon: 1e-12,
..ConvergenceOptions::default()
}
}
fn assert_curve_finite(h: &History, keys: &[&str], what: &str) {
for key in keys {
for (time, g) in h.learning_curve(*key) {
assert!(
g.mu().is_finite() && g.sigma().is_finite(),
"{what}: non-finite posterior for {key} at t={time} (mu={} sigma={})",
g.mu(),
g.sigma()
);
}
}
}
/// A zero weight reaches `(m - performance.exclude(..)) * (1.0 / w)`, i.e. a
/// division by zero. The commit is accepted today, so this pins that the
/// resulting posterior is still finite rather than quietly NaN.
#[test]
fn zero_weight_does_not_produce_a_non_finite_posterior() {
let mut h = History::builder().build();
h.event(1)
.team(["a"])
.weights([0.0])
.team(["b"])
.winner(0)
.commit()
.expect("a zero weight is accepted today; update this test if that changes");
h.converge().unwrap();
assert_curve_finite(&h, &["a", "b"], "zero weight");
}
#[test]
fn negative_weight_does_not_produce_a_non_finite_posterior() {
let mut h = History::builder().build();
h.event(1)
.team(["a"])
.weights([-1.0])
.team(["b"])
.winner(0)
.commit()
.expect("a negative weight is accepted today; update this test if that changes");
h.converge().unwrap();
assert_curve_finite(&h, &["a", "b"], "negative weight");
}
/// Events supplied newest-first must land in the same slices as oldest-first:
/// ingestion sorts by time rather than trusting arrival order.
#[test]
fn out_of_order_timestamps_converge_to_the_same_answer() {
fn build(descending: bool) -> History {
let mut h = History::builder().convergence(tight()).build();
let mut times: Vec<i64> = (1..=6).collect();
if descending {
times.reverse();
}
for time in times {
h.record_winner(&"a", &"b", time).unwrap();
}
h.converge().unwrap();
h
}
let ascending = build(false);
let descending = build(true);
let one = ascending.current_skill("a").unwrap();
let other = descending.current_skill("a").unwrap();
assert!(
(one.mu() - other.mu()).abs() < 1e-8 && (one.sigma() - other.sigma()).abs() < 1e-8,
"arrival order changed the answer: ascending mu={} sigma={}, descending mu={} sigma={}",
one.mu(),
one.sigma(),
other.mu(),
other.sigma()
);
}
#[test]
fn extreme_beta_and_sigma_stay_finite() {
for (beta, sigma) in [(1e-6, 1e-6), (1e6, 1e6), (1e-6, 1e6), (1e6, 1e-6)] {
let mut h = History::builder().beta(beta).sigma(sigma).build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"a", &"b", 2).unwrap();
h.converge().unwrap();
assert_curve_finite(&h, &["a", "b"], &format!("beta={beta} sigma={sigma}"));
}
}
+402
View File
@@ -0,0 +1,402 @@
//! Per-competitor drift scaling via `Member::with_drift_scale`.
//!
//! The scale multiplies the *variance* the history's `Drift` contributes for
//! that competitor, so `scale` is in the same units as `gamma`:
//! `ConstantDrift(g)` at `scale = s` behaves as `ConstantDrift(g * s)` would.
//! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course
//! difficulty — while everyone around them keeps drifting.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member,
NullObserver, Outcome, Team,
};
type Fit = History<i64, ConstantDrift, NullObserver, &'static str>;
const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
max_iter: 64,
epsilon: 1e-9,
alpha: 1.0,
};
/// Two events separated by a long gap, so drift has room to matter.
fn distant_pair(anchor_scale: Option<f64>) -> Vec<Event<i64, &'static str>> {
let anchor = |s: Option<f64>| match s {
Some(scale) => Member::new("anchor").with_drift_scale(scale),
None => Member::new("anchor"),
};
vec![
Event {
time: 0,
teams: smallvec![
Team::with_members([anchor(anchor_scale)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 1000,
teams: smallvec![
Team::with_members([anchor(anchor_scale)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(1, 2),
},
]
}
fn fit(events: Vec<Event<i64, &'static str>>, gamma: f64) -> Fit {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift(gamma))
.convergence(CONVERGENCE)
.build();
h.add_events(events).unwrap();
h.converge().unwrap();
h
}
fn curve(h: &Fit, key: &str) -> Vec<(i64, Gaussian)> {
let mut c = h.learning_curves().remove(key).expect("key in curves");
c.sort_by_key(|(t, _)| *t);
c
}
/// A competitor at `scale = 0.0` is one latent skill observed twice, so the
/// posterior is the same distribution at both times — and strictly tighter
/// than the same competitor left to drift.
#[test]
fn zero_scale_pins_a_competitor_still() {
let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0);
let drifting = fit(distant_pair(None), 25.0 / 300.0);
let pinned_curve = curve(&pinned, "anchor");
assert_eq!(pinned_curve.len(), 2);
let (t0, first) = pinned_curve[0];
let (t1, second) = pinned_curve[1];
assert_eq!((t0, t1), (0, 1000));
assert!(
(first.sigma() - second.sigma()).abs() < 1e-9,
"a pinned competitor's uncertainty must not move between t=0 and t=1000: \
{} vs {}",
first.sigma(),
second.sigma()
);
assert!(
(first.mu() - second.mu()).abs() < 1e-9,
"a pinned competitor's mean must not move: {} vs {}",
first.mu(),
second.mu()
);
let drifting_curve = curve(&drifting, "anchor");
assert!(
drifting_curve[0].1.sigma() > first.sigma() + 1e-6,
"drift must leave the anchor less certain than pinning does: {} vs {}",
drifting_curve[0].1.sigma(),
first.sigma()
);
}
/// The scale is composable with `gamma`: scaling every competitor by `s` is
/// exactly the same fit as scaling the history's drift by `s`.
#[test]
fn scale_is_equivalent_to_scaling_gamma() {
let scaled: Vec<Event<i64, &'static str>> = vec![
Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a").with_drift_scale(0.5)]),
Team::with_members([Member::new("b").with_drift_scale(0.5)]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 400,
teams: smallvec![
Team::with_members([Member::new("b").with_drift_scale(0.5)]),
Team::with_members([Member::new("a").with_drift_scale(0.5)]),
],
outcome: Outcome::winner(0, 2),
},
];
let plain: Vec<Event<i64, &'static str>> = vec![
Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 400,
teams: smallvec![
Team::with_members([Member::new("b")]),
Team::with_members([Member::new("a")]),
],
outcome: Outcome::winner(0, 2),
},
];
let by_scale = fit(scaled, 0.3);
let by_gamma = fit(plain, 0.15);
for key in ["a", "b"] {
let lhs = curve(&by_scale, key);
let rhs = curve(&by_gamma, key);
assert_eq!(lhs.len(), rhs.len());
for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) {
assert_eq!(t_l, t_r);
assert!(
(g_l.mu() - g_r.mu()).abs() < 1e-9 && (g_l.sigma() - g_r.sigma()).abs() < 1e-9,
"ConstantDrift(0.3) at scale 0.5 must equal ConstantDrift(0.15) for {key} at \
t={t_l}: ({}, {}) vs ({}, {})",
g_l.mu(),
g_l.sigma(),
g_r.mu(),
g_r.sigma()
);
}
}
}
/// `None` means 1.0: an explicit unit scale changes nothing.
#[test]
fn unset_scale_matches_an_explicit_unit_scale() {
let implicit = fit(distant_pair(None), 25.0 / 300.0);
let explicit = fit(distant_pair(Some(1.0)), 25.0 / 300.0);
for key in ["anchor", "player"] {
let lhs = curve(&implicit, key);
let rhs = curve(&explicit, key);
assert_eq!(lhs.len(), rhs.len());
for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) {
assert_eq!(t_l, t_r);
assert_eq!(
(g_l.mu(), g_l.sigma()),
(g_r.mu(), g_r.sigma()),
"an explicit scale of 1.0 must be bit-identical to leaving it unset, \
for {key} at t={t_l}"
);
}
}
}
/// The use case from the issue: a static difficulty alongside drifting players,
/// in one graph. The anchor must hold still without absorbing drift through its
/// neighbours, and everything must stay finite.
#[test]
fn mixed_static_and_drifting_graph_converges() {
let mut events: Vec<Event<i64, &'static str>> = Vec::new();
let players = ["p0", "p1", "p2"];
for (i, p) in players.iter().cycle().take(9).enumerate() {
events.push(Event {
time: (i as i64) * 100,
teams: smallvec![
Team::with_members([Member::new(*p)]),
Team::with_members([Member::new("layout").with_drift_scale(0.0)]),
],
outcome: Outcome::winner((i % 2) as u32, 2),
});
}
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift(25.0 / 300.0))
.convergence(CONVERGENCE)
.build();
h.add_events(events).unwrap();
let report = h.converge().unwrap();
assert!(report.converged, "mixed graph must converge: {report:?}");
let curves = h.learning_curves();
for (key, points) in &curves {
for (t, g) in points {
assert!(
g.mu().is_finite() && g.sigma().is_finite() && g.sigma() > 0.0,
"{key} at t={t} is not a usable posterior: mu={}, sigma={}",
g.mu(),
g.sigma()
);
}
}
let layout = curve(&h, "layout");
assert_eq!(layout.len(), 9);
let (_, first) = layout[0];
for (t, g) in &layout {
assert!(
(g.sigma() - first.sigma()).abs() < 1e-9,
"a static layout must not accumulate uncertainty; t={t} has sigma {} vs {}",
g.sigma(),
first.sigma()
);
}
let p0 = curve(&h, "p0");
assert!(
p0.last().unwrap().1.sigma() > 0.0,
"a drifting player should still have a proper posterior"
);
}
fn reject(scale: f64) -> InferenceError {
let mut h = History::builder()
.drift(ConstantDrift(25.0 / 300.0))
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a").with_drift_scale(scale)]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}];
h.add_events(events)
.expect_err("an out-of-range drift_scale must be rejected")
}
#[test]
fn negative_scale_is_rejected() {
assert_eq!(
reject(-1.0),
InferenceError::InvalidParameter {
name: "drift_scale",
value: -1.0
}
);
}
#[test]
fn non_finite_scale_is_rejected() {
for scale in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(
matches!(
reject(scale),
InferenceError::InvalidParameter {
name: "drift_scale",
..
}
),
"a drift_scale of {scale} must be rejected as an invalid parameter"
);
}
}
/// The scale must reach the filtering pass too, not just `converge()`.
/// `filtered_learning_curves` runs its own drift application, so a pinned
/// competitor has to stay pinned there as well.
#[test]
fn zero_scale_pins_a_competitor_in_the_filtered_pass() {
let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0);
let drifting = fit(distant_pair(None), 25.0 / 300.0);
let filtered = |h: &Fit| -> Vec<(i64, Gaussian)> {
let mut c = h
.filtered_learning_curves()
.remove("anchor")
.expect("anchor in filtered curves");
c.sort_by_key(|(t, _)| *t);
c
};
let pinned_curve = filtered(&pinned);
let drifting_curve = filtered(&drifting);
assert_eq!(pinned_curve.len(), 2);
assert_eq!(drifting_curve.len(), 2);
assert!(
pinned_curve[1].1.sigma() < pinned_curve[0].1.sigma(),
"a pinned competitor's filtered uncertainty must shrink with a second \
observation, not be re-inflated by drift: {} then {}",
pinned_curve[0].1.sigma(),
pinned_curve[1].1.sigma()
);
assert!(
pinned_curve[1].1.sigma() < drifting_curve[1].1.sigma() - 1e-6,
"pinning must leave the filtered estimate tighter than drifting does: \
{} vs {}",
pinned_curve[1].1.sigma(),
drifting_curve[1].1.sigma()
);
}
/// `drift_scale` is competitor configuration captured at first appearance, the
/// same as `prior` — a later `with_drift_scale` on a key the history already
/// knows is ignored. This guards that decision rather than driving it: the
/// behaviour falls out of where the capture happens, and the point of the test
/// is that moving the capture would be a visible break, not a silent one.
#[test]
fn drift_scale_is_ignored_after_first_appearance() {
let mut late = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.drift(ConstantDrift(25.0 / 300.0))
.convergence(CONVERGENCE)
.build();
// First batch creates "anchor" with the default scale.
late.add_events(vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("anchor")]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
// Second batch asks for a pin. Too late: the competitor already exists.
late.add_events(vec![Event {
time: 1000,
teams: smallvec![
Team::with_members([Member::new("anchor").with_drift_scale(0.0)]),
Team::with_members([Member::new("player")]),
],
outcome: Outcome::winner(1, 2),
}])
.unwrap();
late.converge().unwrap();
let ignored = curve(&late, "anchor");
let drifting = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor");
for ((t_l, g_l), (t_r, g_r)) in ignored.iter().zip(drifting.iter()) {
assert_eq!(t_l, t_r);
assert!(
(g_l.sigma() - g_r.sigma()).abs() < 1e-9,
"a scale set after first appearance must be ignored, leaving the fit \
identical to one that never set it: t={t_l}, {} vs {}",
g_l.sigma(),
g_r.sigma()
);
}
let pinned = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor");
assert!(
(ignored[1].1.sigma() - pinned[1].1.sigma()).abs() > 1e-6,
"sanity: the pinned fit must actually differ, or the assertion above is vacuous"
);
}
+254
View File
@@ -0,0 +1,254 @@
//! Forward-only (filtering) estimates: what the model knew at the time,
//! as opposed to the smoothed posteriors `learning_curve` reports.
use smallvec::smallvec;
use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team};
/// `games` one-on-one matches at successive times, won by "a" every time,
/// built with the given convergence options.
fn repeated_winner_with(games: i64, convergence: ConvergenceOptions) -> History {
let mut history = History::builder().convergence(convergence).build();
for time in 1..=games {
history
.add_events([Event {
time,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
}
history
}
/// `games` one-on-one matches at successive times, won by "a" every time.
///
/// This is the fixture from issue #19, where `online(true)` reported
/// `games * ln(0.5)`.
fn repeated_winner(games: i64) -> History {
repeated_winner_with(games, ConvergenceOptions::default())
}
/// The default 30-iteration cap leaves a residual around 1e-6, which would
/// swamp these comparisons. Drive both sides well past the fixed point.
fn tight() -> ConvergenceOptions {
ConvergenceOptions {
max_iter: 2_000,
epsilon: 1e-12,
..ConvergenceOptions::default()
}
}
#[test]
fn filtered_evidence_sits_between_coin_flip_and_batch() {
let mut history = repeated_winner(5);
history.converge().unwrap();
let coin_flip = 5.0 * 0.5f64.ln();
let batch = history.log_evidence();
let filtered = history.filtered_log_evidence();
assert!(
filtered > coin_flip,
"filtered evidence {filtered} is at or below {coin_flip}, the all-coin-flip \
value the inert online flag reported; game one is a coin flip but games two \
through five are not"
);
assert!(
filtered < batch,
"filtered evidence {filtered} is not below the smoothed {batch}; filtering \
scores each game on strictly less information than smoothing does"
);
}
#[test]
fn filtered_first_point_is_less_certain_than_smoothed() {
let mut history = repeated_winner(12);
history.converge().unwrap();
let smoothed = history.learning_curve("a");
let filtered = history.filtered_learning_curve("a");
assert_eq!(
smoothed.len(),
filtered.len(),
"both curves must cover the same time points"
);
let (smoothed_time, first_smoothed) = smoothed[0];
let (filtered_time, first_filtered) = filtered[0];
assert_eq!(smoothed_time, filtered_time);
assert!(
first_filtered.sigma() > first_smoothed.sigma(),
"filtered sigma {} at the first point is not above smoothed {}; the smoother \
collapses uncertainty before the first round is drawn, which is the whole \
reason this method exists",
first_filtered.sigma(),
first_smoothed.sigma()
);
assert!(
first_filtered.sigma() < trueskill_tt::SIGMA,
"filtered sigma {} at the first point is not below the prior {}; one game was \
played, so some uncertainty must have been resolved",
first_filtered.sigma(),
trueskill_tt::SIGMA
);
for pair in filtered.windows(2) {
assert!(
pair[1].1.mu() > pair[0].1.mu(),
"filtered mu must climb at every step for a competitor who wins every \
game: t={} mu={} then t={} mu={}",
pair[0].0,
pair[0].1.mu(),
pair[1].0,
pair[1].1.mu()
);
}
}
#[test]
fn filtered_curves_plural_agrees_with_singular() {
let mut history = repeated_winner(4);
history.converge().unwrap();
let curves = history.filtered_learning_curves();
assert_eq!(
curves["b"],
history.filtered_learning_curve("b"),
"the plural form must agree with the singular for the same key"
);
}
#[test]
fn filtered_evidence_is_invariant_to_convergence() {
let mut history = repeated_winner_with(6, tight());
let before = history.filtered_log_evidence();
let report = history.converge().unwrap();
assert!(
report.converged,
"fixture must converge: {:?}",
report.final_step
);
let after = history.filtered_log_evidence();
assert!(
(before - after).abs() < 1e-8,
"filtered evidence moved across converge(): {before} -> {after}. The pass must \
carry its own forward messages; anything reading skill.forward shows exactly \
this drift, because converge() contaminates it with backward information."
);
}
#[test]
fn single_slice_filtered_matches_smoothed() {
let mut history = History::builder().convergence(tight()).build();
history
.add_events([
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("c")]),
Team::with_members([Member::new("d")]),
],
outcome: Outcome::winner(0, 2),
},
])
.unwrap();
history.converge().unwrap();
let smoothed = history.learning_curve("a");
let filtered = history.filtered_learning_curve("a");
assert_eq!(smoothed.len(), 1);
assert_eq!(filtered.len(), 1);
assert!(
(smoothed[0].1.mu() - filtered[0].1.mu()).abs() < 1e-8
&& (smoothed[0].1.sigma() - filtered[0].1.sigma()).abs() < 1e-8,
"one slice has no future to propagate back, so filtered and smoothed must \
agree: smoothed mu={} sigma={}, filtered mu={} sigma={}",
smoothed[0].1.mu(),
smoothed[0].1.sigma(),
filtered[0].1.mu(),
filtered[0].1.sigma()
);
}
#[test]
fn filtered_curves_do_not_depend_on_ingestion_order() {
let events = |time: i64, winner: &'static str, loser: &'static str| Event {
time,
teams: smallvec![
Team::with_members([Member::new(winner)]),
Team::with_members([Member::new(loser)]),
],
outcome: Outcome::winner(0, 2),
};
let all = vec![
events(1, "a", "b"),
events(1, "c", "d"),
events(1, "a", "c"),
events(1, "b", "d"),
events(2, "a", "d"),
events(2, "b", "c"),
events(2, "a", "b"),
];
let mut batched = History::builder().convergence(tight()).build();
batched.add_events(all.clone()).unwrap();
batched.converge().unwrap();
let mut incremental = History::builder().convergence(tight()).build();
for event in all {
incremental.add_events([event]).unwrap();
}
incremental.converge().unwrap();
let from_batched = batched.filtered_learning_curve("a");
let from_incremental = incremental.filtered_learning_curve("a");
assert_eq!(from_batched.len(), from_incremental.len());
for ((time_b, gaussian_b), (time_i, gaussian_i)) in
from_batched.iter().zip(from_incremental.iter())
{
assert_eq!(time_b, time_i);
assert!(
(gaussian_b.mu() - gaussian_i.mu()).abs() < 1e-8
&& (gaussian_b.sigma() - gaussian_i.sigma()).abs() < 1e-8,
"at t={time_b}: batched mu={} sigma={}, incremental mu={} sigma={}",
gaussian_b.mu(),
gaussian_b.sigma(),
gaussian_i.mu(),
gaussian_i.sigma()
);
}
}
+167
View File
@@ -0,0 +1,167 @@
//! Property-based tests over generated histories.
//!
//! The golden suite pins exact values against the Python/Julia reference on a
//! handful of fixtures. These pin *invariants* over inputs nobody wrote by
//! hand, which is where the defects this crate has actually shipped were
//! hiding: a linear evidence product that underflowed only past ~1000 teams,
//! and a batching path no golden exercised because every golden ingests in one
//! call.
mod common;
use common::assert_finite;
use proptest::prelude::*;
use smallvec::smallvec;
use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team};
/// Distinct competitors, so no event pits someone against themselves.
fn pairs() -> impl Strategy<Value = Vec<(usize, usize)>> {
prop::collection::vec((0usize..8, 0usize..8), 1..24)
.prop_map(|v| v.into_iter().filter(|(a, b)| a != b).collect::<Vec<_>>())
.prop_filter("needs at least one valid pair", |v| !v.is_empty())
}
const KEYS: [&str; 8] = ["a", "b", "c", "d", "e", "f", "g", "h"];
fn history_from(games: &[(usize, usize)]) -> History {
let mut h = History::builder()
.convergence(ConvergenceOptions {
max_iter: 200,
epsilon: 1e-10,
..ConvergenceOptions::default()
})
.build();
let events: Vec<Event<i64, &'static str>> = games
.iter()
.enumerate()
.map(|(i, &(a, b))| Event {
time: i as i64 + 1,
teams: smallvec![
Team::with_members([Member::new(KEYS[a])]),
Team::with_members([Member::new(KEYS[b])]),
],
outcome: Outcome::winner(0, 2),
})
.collect();
h.add_events(events).unwrap();
h
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
/// Whatever the schedule of games, convergence must not produce NaN or an
/// improper posterior. `converge` returns `NonFiniteResult` rather than
/// silently reporting a NaN step as converged, so a break shows up here as
/// either an Err or a non-finite curve point.
#[test]
fn converged_posteriors_are_always_finite(games in pairs()) {
let mut h = history_from(&games);
h.converge().unwrap();
for key in KEYS {
for (time, g) in h.learning_curve(key) {
assert_finite(g, &format!("{key} at t={time}"));
}
}
}
/// Log-evidence is a log probability: finite, and never above zero.
///
/// The linear-product implementation this replaced underflowed to zero on
/// long chains, making `ln(0)` = -inf — finite-ness is the property that
/// would have caught it.
#[test]
fn log_evidence_is_a_finite_log_probability(games in pairs()) {
let mut h = history_from(&games);
h.converge().unwrap();
let batch = h.log_evidence();
let filtered = h.filtered_log_evidence();
prop_assert!(batch.is_finite(), "batch log-evidence {batch} is not finite");
prop_assert!(batch <= 0.0, "batch log-evidence {batch} exceeds zero");
prop_assert!(filtered.is_finite(), "filtered log-evidence {filtered} is not finite");
prop_assert!(filtered <= 0.0, "filtered log-evidence {filtered} exceeds zero");
}
/// Filtered estimates must not depend on whether `converge` has run — the
/// property the whole forward-only design rests on.
#[test]
fn filtered_evidence_is_invariant_to_convergence(games in pairs()) {
let mut h = history_from(&games);
let before = h.filtered_log_evidence();
h.converge().unwrap();
let after = h.filtered_log_evidence();
prop_assert!(
(before - after).abs() < 1e-8,
"filtered evidence moved across converge(): {before} -> {after}"
);
}
/// Ingesting the same games one at a time must reach the same fixed point
/// as ingesting them in one call.
#[test]
fn ingestion_order_does_not_change_the_answer(games in pairs()) {
let batched = {
let mut h = history_from(&games);
h.converge().unwrap();
h
};
let incremental = {
let mut h = History::builder()
.convergence(ConvergenceOptions {
max_iter: 200,
epsilon: 1e-10,
..ConvergenceOptions::default()
})
.build();
for (i, &(a, b)) in games.iter().enumerate() {
h.add_events([Event {
time: i as i64 + 1,
teams: smallvec![
Team::with_members([Member::new(KEYS[a])]),
Team::with_members([Member::new(KEYS[b])]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
}
h.converge().unwrap();
h
};
for key in KEYS {
let one = batched.current_skill(key);
let other = incremental.current_skill(key);
match (one, other) {
(Some(one), Some(other)) => {
prop_assert!(
(one.mu() - other.mu()).abs() < 1e-6
&& (one.sigma() - other.sigma()).abs() < 1e-6,
"{key}: batched mu={} sigma={}, incremental mu={} sigma={}",
one.mu(),
one.sigma(),
other.mu(),
other.sigma()
);
}
(None, None) => {}
_ => prop_assert!(false, "{key} present in only one history"),
}
}
}
}