53 Commits
Author SHA1 Message Date
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
logaritmiskandClaude Opus 5 9e8515b7cd docs: refresh README and CLAUDE.md; add ingest benchmark
The CLAUDE.md architecture section still described the pre-redesign engine:
its data flow named `Batch`, `Agent`, `Player` and `message.rs`, none of
which have existed since T2, and the public API it listed did not match
`lib.rs`. It is the first thing a fresh session reads, so it was actively
misleading. Rewritten against the current module layout, with the invariants
that are easy to violate — ties needing a positive `p_draw`, NaN never being
convergence, log-space evidence, color contiguity, `forbid(unsafe_code)`,
and ingestion-order equivalence — written down.

The README Todo list had five entries that were already done, including
"Time needs to be an enum": `Time` has been a trait since T2, and the
`batch::compute_elapsed()` it pointed at no longer exists. The genuinely
open item — cross-checking `quality()` against sublee/trueskill — stays.

`benches/ingest.rs` measures one-event-per-call against a single batched
call. The rest of the suite only measured batched construction, which is why
the quadratic fixed earlier on this branch went unnoticed for so long.

`TimeSlice::log_evidence` also hashes its target set once instead of
scanning the slice per player per event, so `log_evidence_for` with many
keys is no longer quadratic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:05:49 +02:00
logaritmiskandClaude Opus 5 9506fed4b3 chore: add CI, crate metadata, and crate-level documentation
There was no CI of any kind — no workflows directory at all — despite a full
release pipeline (release.toml, cliff.toml, a maintained CHANGELOG, three
tagged releases). The workflow covers the feature combinations that actually
have distinct behaviour, including a release-profile job: `debug_assert!` is
compiled out there, which is exactly where the validation this branch added
has to hold, and a debug-only suite would never have seen it. Determinism is
checked at RAYON_NUM_THREADS of 1, 2, 4 and 8.

The Justfile gains test/lint/fmt/determinism recipes so the same checks run
locally with one command, and `just ci` runs the lot.

`Cargo.toml` had only name, version and edition, so `cargo publish` would
have been rejected. Added description, repository, readme, keywords,
categories, exclude, and `rust-version = "1.85"` — the edition-2024 floor,
now verified by a CI job. Two let-chains introduced earlier on this branch
would have pushed that to 1.88; they are rewritten to keep the floor where
it was.

`src/lib.rs` had no `//!` header at all, so the docs.rs landing page would
have been a bare symbol list — conspicuous given every other module has one.
It now explains what Through Time does differently, and carries three
runnable examples (which `cargo test --doc` checks, where previously there
was nothing to check), including the draw/p_draw interaction that is the
easiest way to get an error out of this crate.

`cargo publish --dry-run` now packages and verifies cleanly. The only
remaining blocker is `license`, which is yours to choose — noted as a TODO
in the manifest rather than picked unilaterally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:03:39 +02:00
logaritmiskandClaude Opus 5 6030dc78de refactor: unify convergence defaults, validate builders, clear dead code
Convergence configuration had two disagreeing sources of truth and one
misleading report:

- `EpsilonOrMax::default()` capped at 10 iterations while
  `ConvergenceOptions::default()` allowed 30, and which applied depended on
  whether inference went through `run_chain` or a `Schedule`. The schedule
  default now derives from `ConvergenceOptions`.
- A graph with no iterating factors reported `converged: false` with an
  infinite step, despite being at its fixed point after the setup pass. It
  now reports converged with a zero step.
- `TimeSlice::iterate_to_convergence` hard-coded an epsilon and a
  20-iteration cap matching neither. It reads `self.convergence` and is
  scoped to `#[cfg(test)]`, which is all it was ever used by.

`HistoryBuilder::p_draw` and `::convergence` now validate their arguments
like `score_sigma` already did, instead of accepting a negative `p_draw` or
an `alpha` of zero — the latter leaves every EP update unapplied, so
inference silently returns the priors.

Removing the `#[allow(dead_code)]` masks let the compiler report what they
were hiding: four `OwnedGame` fields that were stored and never read, two
`ColorGroups` helpers and three `SkillStore` helpers used only by tests, and
`iterate_to_convergence` above. Test-only items are now `#[cfg(test)]` and
the unread fields are gone.

Also exported `HistoryBuilder`, which was public but unreachable — callers
could chain `History::builder()` but could not name the type — and added
`Rating::{prior, beta, drift}` and `Index::get`, so handles the API hands
out can be read back.

Two goldens moved, both convergence residuals rather than exact values:
`iterate_to_convergence` now runs to 30 iterations instead of 20, landing
nearer the symmetric truth of 25.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:01:49 +02:00
logaritmiskandClaude Opus 5 355cdb7e05 perf(gaussian): drop the sqrt round-trip from variance-space operations
`Add`, `Sub`, `exclude` and `forget` combined variances by way of standard
deviations: `sigma()` takes a square root, `.powi(2)` squares it away,
`var.sqrt()` takes another, and `from_ms` squares that one back. Three roots
to compute a value that is `1/pi` all along.

They now go through `variance()` and a new `from_mv(mu, var)`, which skip
both conversions. `Sub` is the hot one — `RankDiffFactor::propagate` is
`a - b`, run for every adjacent team pair on every forward and backward
sweep of every EP iteration.

`run_chain` also stopped recomputing each team's weighted performance in the
likelihood loop; the fold is already in `arena.team_prior`, indexed by the
sorted position the loop has in hand. Each `performance()` is itself a
`forget`, so the duplicate cost scaled with players per team.

Measured on this machine, before and after, same fixtures:

    Batch::iteration          23.57us -> 19.31us   (-18%)
    scored_history_60_events   1.071ms -> 983us    (-8%)

The `Gaussian::add`/`sub` microbenchmarks cannot resolve the change: they
sit at ~234ps against a ~218ps floor that `mul`/`div` also hit, so the
harness overhead dominates a single operation.

One golden moved. Two identical competitors drawing must land on their
shared prior mean exactly, by symmetry; the root-free path now returns
25.0 where the reference transcription recorded 24.999999 — that value
rounded to six decimals. Asserting a six-decimal transcription at
epsilon 1e-6 left no headroom, so the expectation is now the exact value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:58:30 +02:00
logaritmiskandClaude Opus 5 06b6a68499 fix(rayon): remove the aliasing unsafe from the parallel sweep
The parallel color-group sweep passed a `*mut SkillStore` through a `usize`
and cast it back inside the rayon closure, so every worker materialised its
own `&mut SkillStore` to the same store. Two live `&mut` to one object is an
aliasing violation whatever the workers subsequently touch — `&mut` carries
`noalias` down to LLVM — and laundering the pointer through `usize` also
discarded provenance. The existing SAFETY comment argued element
disjointness, which is true and is why nothing miscompiled in practice, but
it is not the property the aliasing rules ask about.

Events in a color group touch disjoint agents, so none can observe another's
writes. That makes the sweep separable rather than merely safe-in-practice:
`Event::compute` runs inference over shared `&self.skills` with no mutation,
and `Event::apply` folds the results in afterwards in index order. No
`unsafe`, no aliasing argument, and the apply order does not depend on which
worker finished first, so results stay bit-identical across thread counts.

The crate now contains no `unsafe` at all, locked in with
`#![forbid(unsafe_code)]`.

Splitting compute from apply also removes the duplicated sweep body: the
`from > 0` branch of `TimeSlice::iteration` was a verbatim copy of
`iteration_direct`, and both now share one implementation.

Cost, measured on the three `history_converge` workloads (sequential vs
parallel, this machine):

    500x100@10perslice     4.02ms -> 4.21ms
    2000x200@20perslice   19.70ms -> 19.76ms
    1v1-5000x50000        11.75ms -> 10.46ms

The deferred apply gives back part of the parallel win on the only workload
where rayon ever helped (1.12x here, against the 1.3x T3 reported), and the
sequential path is unchanged. Trading a fraction of a 1.3x speedup on one
pathological shape for the removal of undefined behaviour is the right side
of that bargain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:54:34 +02:00
logaritmiskandClaude Opus 5 c088214fed fix(history): stop reprocessing the slice that was just appended to
`add_events_with_prior` advanced `k` past the slice it had written when it
created a new one, but not when it appended to an existing one. The trailing
forward-refresh loop therefore started *on* the slice just modified and ran
`new_forward_info` over it again.

That is not merely redundant work. The loop immediately above it sets each
agent's message to `forward * likelihood` for that slice, and
`new_forward_info` then assigns `skill.forward = message.forget(drift)` —
folding the slice's own likelihood back into its own forward prior. The
skills it produced depended on how events had been batched.

Ingesting one event at a time now converges to the same fixed point as
ingesting the same events in a single call, which it previously did not:
for five events sharing a timestamp, competitor `a` converged to
mu=7.44 sigma=3.90 batched versus mu=7.99 sigma=3.10 incrementally. Both
runs had converged; the gap was not a convergence residual.

The numerical goldens never caught this because they all ingest in one call
with a distinct timestamp per event, so the append-to-existing-slice branch
is never taken. `tests/ingestion_equivalence.rs` covers it directly, and
asserts convergence before comparing so that a residual cannot be mistaken
for agreement.

Removing the redundant re-inference also removes the dominant cost of
incremental ingestion, which was quadratic in the number of events already
in the slice:

    events   before     after    speedup
       500   45.8ms     1.1ms       42x
      1000  179.5ms     2.8ms       64x
      2000  721.8ms     9.9ms       73x
      4000    2.9s     35.4ms       82x

Ingesting one at a time is now 1.8x a single batched call, down from 148x.

Two supporting changes are included:

- Color groups are rebuilt lazily rather than on every append. Nothing
  reads the partition between an append and the next full sweep, so the
  per-append rebuild was pure waste.
- `ColorGroups::groups_are_contiguous` is asserted after each rebuild and
  in `color_range`. The parallel sweep derives one `&mut` sub-slice per
  color from those ranges and relies on them being disjoint; that invariant
  was established by construction but never checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:51:02 +02:00
logaritmiskandClaude Opus 5 0f1a1b8911 fix(evidence): accumulate in log space and floor the per-link value
Per-link evidence was multiplied in linear space and logged only at the
end. Each link contributes a probability in (0, 1], so the product over an
n-team game decays geometrically: around a thousand links it flushes to
exactly 0.0 and `ln(0.0)` is `-inf`, which then propagates through the sum
in `History::log_evidence_internal` and takes the whole history with it.
`Game::free_for_all` builds one team per player, so this is reachable at
the competitor counts the T3 benchmarks target.

`Game`, `OwnedGame`, and `time_slice::Event` now carry `log_evidence`
directly, summed over links rather than multiplied then logged.

The cached per-link evidence is also floored at `f64::MIN_POSITIVE`. It
could legitimately reach zero or go negative: `1.0 - cdf(..)` rounds to
zero for a near-certain outcome, and the `erfc` approximation carries
~1e-7 error so `cdf` can exceed 1.0 and make the difference negative —
`ln` of which is NaN.

Existing log-evidence goldens are unchanged, confirming the accumulation
is numerically equivalent in the range where the old form worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:43:57 +02:00
logaritmiskandClaude Opus 5 0d32690fcc fix(quality): support any number of rating groups
`quality()` was two-group-only in three separate ways, and
`History::predict_quality` inherited all of them.

- The contrast-matrix column counter tracked two positions with two
  variables that only agree on the first row, so three or more groups wrote
  past the end of the row and panicked with an out-of-bounds index. The
  negative block always begins immediately after the positive one, so the
  second counter is unnecessary.
- `Matrix::inverse` was implemented only for the 1x1 case and otherwise
  `panic!("eh, okey")`. It now uses LU decomposition with partial pivoting,
  which also replaces the recursive cofactor `determinant` — that was O(n!)
  and allocated a `Vec` per minor, so a 10-team match needed 362,880 terms.
- Degenerate inputs (zero groups, one group, empty groups) underflowed or
  produced NaN. They now assert with a message naming the requirement.

`Matrix` also gains dimension checks on multiply/add and bounds checks on
indexing, and loses the now-unused `adjugate`/`minor` cofactor path.

The two-group golden is unchanged. N-group behaviour is covered by
invariants — permutation invariance, and quality falling as a skill gap
widens — since no reference values were available to compare against; the
sublee/trueskill cross-check remains open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:42:04 +02:00
logaritmiskandClaude Opus 5 6b8bd786d7 style: make NaN rejection explicit in score_sigma validation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:38:48 +02:00
logaritmiskandClaude Opus 5 f4e2922d59 fix: reject ties without draw probability; never report NaN as converged
A tie with `p_draw == 0.0` produced NaN posteriors in release builds and
`converge()` reported `converged: true`, because every comparison against
NaN is false and `tuple_gt` therefore read NaN as "below epsilon".

Two independent defects, fixed together:

- Ingestion now rejects tied outcomes when the draw probability is zero,
  promoting the existing `debug_assert!` in `Game::ranked_with_arena` to a
  real `InferenceError::TieWithoutDrawProbability`. Validation sits in
  `add_events_with_prior`, the chokepoint every route reaches — including
  `record_draw`, which bypasses `Outcome` entirely.
- `converge()` treats a non-finite step as failure and returns
  `InferenceError::NonFiniteResult` rather than claiming convergence.

Also in this change:

- `History::converge()` on an empty history returned a `usize` underflow
  panic from `0..len()-1`; it now short-circuits to a zero-iteration report.
- `Outcome::scores_with_sigma` no longer panics on a non-positive sigma;
  the value is validated at ingestion so callers get an error instead.
- `InferenceError` gains `WrongOutcomeKind`, replacing the misuse of
  `MismatchedShape` for variant mismatches (which rendered as the nonsense
  "expected length 0, got 0"), and is now `#[non_exhaustive]`.

Note `Outcome::winner(w, n)` for n >= 3 ties every loser, so those events
now require a positive `p_draw`. They previously returned NaN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:38:29 +02:00
logaritmisk 2b5d3b1687 chore: Release trueskill-tt version 0.1.2 2026-06-12 22:24:11 +02:00
logaritmiskandClaude Opus 4.8 e4ff46f45c fix(gaussian): treat non-positive precision as improper in mu()/sigma()
EP message cancellation can leave a Gaussian's precision (pi) a tiny
negative value — round-off of exactly zero. mu()/sigma() only special-cased
pi == 0, so sigma() computed 1/sqrt(pi) = NaN for pi < 0. That NaN flowed
through the moment-space Sub in the game diff-chain and poisoned every skill
in the slice once it grew past ~75 competitors, making converge() return
all-NaN on real-scale histories (regression vs 0.1.0, which stored sigma
directly). Guard pi <= 0.0 in both accessors (improper Gaussian: mu 0,
sigma infinite), matching the existing pi == 0 handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:27:47 +02:00
logaritmisk 7742b2b891 test(history): end-to-end per-event score_sigma override tests
Three integration tests on a 2-team scored event:
- inheritance: Outcome::scores(...) with no override produces
  bit-equal posteriors to the same outcome wrapped in
  scores_with_sigma(scores, history.score_sigma)
- override-supersedes-default: scores_with_sigma(scores, X) with
  history score_sigma(Y) produces bit-equal posteriors to
  scores(...) with history score_sigma(X), AND differs measurably
  from scores(...) with history score_sigma(Y)
- builder threading: EventBuilder::scores_with_sigma reaches the
  ingest path identically to the Outcome constructor
2026-05-08 21:30:30 +02:00
logaritmisk 52482eea5f feat(event_builder): expose scores_with_sigma fluent method
Adds EventBuilder::scores_with_sigma, the fluent-builder ergonomic
mirror of Outcome::scores_with_sigma. Lets users write
h.event(t).team(...).team(...).scores_with_sigma([..], sigma).commit()
to set a per-event score_sigma override.
2026-05-08 21:28:08 +02:00
logaritmisk b46e7f068d feat(outcome): per-event score_sigma override on Outcome::Scored
Outcome::Scored shape changes from tuple to struct:
{ scores, sigma: Option<f64> }. New constructor scores_with_sigma
sets sigma=Some(s) and debug-asserts s > 0.0; existing scores(I)
constructor keeps its signature and builds with sigma=None internally.
team_count, as_scores, as_ranks accessor pattern matches updated.

History::add_events resolves sigma.unwrap_or(self.score_sigma) at the
ingest arm, so downstream EventKind::Scored stays a plain f64 and
TimeSlice / run_chain need zero changes.

Breaking change to the public Outcome::Scored variant shape
(acceptable in 0.1.x). Bit-equal for callers using the no-override
path because the resolution falls through to self.score_sigma exactly
as before.
2026-05-08 21:27:09 +02:00
logaritmisk d1d6b5136c docs: implementation plan for per-event score_sigma override
Three tasks: foundational Outcome variant change + ingest resolution
(atomic, every commit builds), additive EventBuilder fluent method,
and three end-to-end integration tests covering inheritance,
override-supersedes-default, and builder threading.
2026-05-08 16:12:33 +02:00
logaritmisk 46625d247a docs: spec for per-event score_sigma override
Outcome::Scored becomes a struct variant with an Option<f64> sigma
field. None inherits HistoryBuilder::score_sigma; Some(s) overrides
per event. Resolved at ingest time so EventKind::Scored stays a plain
f64 and TimeSlice/run_chain need zero changes. New constructors
Outcome::scores_with_sigma and EventBuilder::scores_with_sigma cover
the override path; existing scores(..) keeps its signature with
sigma=None internally.

Breaking change to Outcome::Scored variant shape (tuple → struct);
acceptable in 0.1.x. Closes the last item from the T4-MarginFactor
deferred wishlist.
2026-05-08 16:05:27 +02:00
logaritmisk 68be7ab5b7 test(history): end-to-end ConvergenceOptions propagation tests
Two integration tests on a 4-team ranked event:
- max_iter=1 set on HistoryBuilder produces measurably different
  posteriors than default, proving the inner loop honors the
  propagated max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
  alpha=1.0, proving damping doesn't break correctness on the History
  path

Also updates the alpha doc comment to clarify it applies only to the
within-game EP loop, not the outer cross-history sweep.
2026-05-08 15:34:58 +02:00
logaritmisk 824b7f50b0 feat(time_slice): inference callsites read self.convergence
The three Game::*_with_arena callsites in time_slice.rs (in
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
run_event closure, and Event::iteration_direct via parameter) now use
the propagated ConvergenceOptions instead of hardcoded ::default().
sweep_color_groups (both rayon and non-rayon paths) forwards
self.convergence into Event::iteration_direct.

Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
HistoryBuilder::convergence(opts) now actually reach the within-game
inference loop. Bit-equal for users on default options.

Removes the temporary #[allow(dead_code)] on TimeSlice::convergence
that was added in the prior commit.
2026-05-08 15:32:25 +02:00
logaritmisk 872f91797d refactor(time_slice): add convergence field, rename iterate_to_convergence
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
set at construction. TimeSlice::new now takes it as a third parameter
(breaking change to the pub constructor, acceptable in 0.1.x).
History::add_events_with_prior passes self.convergence so the propagated
value reaches every TimeSlice. The pre-existing convergence-the-method
is renamed to iterate_to_convergence to disambiguate from the new
convergence-the-field.

The field is wired but not yet read by inference -- the three
Game::*_with_arena callsites in time_slice.rs still hardcode
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
the propagated value equals the hardcoded value end-to-end.

Also updated benches/batch.rs which has a fourth TimeSlice::new
callsite (not enumerated in the plan -- only src/ files were).
2026-05-08 15:29:39 +02:00
logaritmisk 6e453b6845 docs: implementation plan for History → TimeSlice plumbing
Three tasks: TimeSlice gains convergence field + method rename +
History passes self.convergence (atomic), three inference callsites
read self.convergence, and end-to-end tests + alpha doc-comment update.
2026-05-08 15:26:38 +02:00
logaritmisk 965ea7ed3c docs: spec for History → TimeSlice ConvergenceOptions plumbing
Closes the gap between HistoryBuilder::convergence(opts) and the
within-game inference loop. TimeSlice gains a convergence field;
History passes self.convergence at construction; the three
Game::*_with_arena callsites in time_slice.rs read it. Also renames
TimeSlice::convergence the method (now iterate_to_convergence) to
disambiguate from the new field.

Pure plumbing — no new public API, no behavioral change for users on
default options. Makes Damped EP reachable through the History path.
2026-05-08 15:23:11 +02:00
logaritmisk dbce69f350 test(game): integration tests for ConvergenceOptions behavior
Two end-to-end tests on a 4-team ranked game:
- max_iter=1 produces measurably different posteriors than the default,
  proving run_chain reads convergence.max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
  alpha=1.0, proving damping doesn't break convergence on benign graphs
2026-05-08 15:13:23 +02:00
logaritmisk 0705986929 feat(game): plumb ConvergenceOptions through to run_chain
Game and OwnedGame gain a convergence: ConvergenceOptions field set at
construction. Game::{ranked,scored} forward options.convergence into
OwnedGame::{new,new_scored} (previously dropped on the floor).
{ranked,scored}_with_arena take it as a parameter. run_chain reads
self.convergence.{epsilon, max_iter, alpha} instead of hardcoded
1e-6 / 10 / undamped. DiffFactor::propagate gains an alpha parameter
and dispatches into Trunc/MarginFactor::propagate_with_alpha.

In-tree callsites in src/time_slice.rs and src/history.rs pass
ConvergenceOptions::default(). Pre-existing T2 fallout in tests,
benches, and the atp example (struct literals missing the new alpha
field) is fixed by adding alpha: 1.0 so the workspace builds clean.
Default alpha is 1.0, so all 96 lib + 27 integration test goldens
remain bit-equal.
2026-05-08 15:10:35 +02:00
logaritmisk aacaa60baa feat(factor): add MarginFactor::propagate_with_alpha for EP damping
Mirrors TruncFactor: inherent damped-propagate method, trait impl
delegates with α=1.0. Existing goldens unchanged because cavity*new_msg
equals the previous marginal write when α=1.0.
2026-05-08 15:03:45 +02:00
logaritmisk fcfe0ffe37 feat(factor): add TruncFactor::propagate_with_alpha for EP damping
Inherent method that applies α-damping to the outgoing message via
Gaussian::damp_natural. The Factor trait impl delegates with α=1.0,
preserving today's behavior bit-equal. Variable write switched from
`trunc` to `cavity * damped` — algebraically identical when α=1.0
(cavity * new_msg = trunc by construction); reflects partial-update
math when α<1.0.
2026-05-08 15:02:09 +02:00
logaritmisk 0fa4e7d277 feat(convergence): add ConvergenceOptions::alpha damping field
Adds an EP damping coefficient defaulting to 1.0 (undamped). Will be
read by run_chain in a follow-up commit. By itself this commit changes
no behavior — existing constructors using ..Default::default() pick up
the new field automatically.
2026-05-08 15:00:34 +02:00
logaritmisk 0dd7dab266 feat(gaussian): add damp_natural helper for EP damping
Computes α·new + (1−α)·self in natural-parameter space. Will be used
by TruncFactor and MarginFactor to support opt-in EP damping via
ConvergenceOptions::alpha.
2026-05-08 14:59:18 +02:00
logaritmisk 43cc6d82f9 docs: implementation plan for game-local Damped EP
Six tasks: Gaussian::damp_natural helper, ConvergenceOptions::alpha
field, TruncFactor and MarginFactor propagate_with_alpha pair, DiffFactor
+ Game integration (the big task — must land atomically), and
end-to-end tests for max_iter and alpha behavior.
2026-05-08 14:57:41 +02:00
logaritmisk 48a6049dc6 docs: spec for game-local Damped EP
Smallest-scope realisation of spec §"Built-in schedules" Damped: a
ConvergenceOptions::alpha field plumbed through run_chain to a new
Gaussian::damp_natural helper applied inside TruncFactor and
MarginFactor's propagate. alpha=1.0 default keeps every existing
golden bit-equal; alpha<1.0 stabilises oscillating fixed-point loops
on hard graphs.

Defers Schedule trait integration, nat-param convergence switch,
oscillation auto-detect, Residual/OneShot, and Synergy/ScoreFactor —
each gets its own future plan.
2026-05-08 14:52:36 +02:00
logaritmisk 1445c08896 docs: fix stale numerics in t4-margin-factor plan
The plan's prose quoted Z_cav ≈ 0.046827 and log_evidence ≈ -3.0613,
which diverged from the values asserted by the shipped test in
src/factor/mod.rs (-3.062235327364623). Update prose and the matching
code comment to 0.04678 / -3.0622.
2026-05-08 14:37:58 +02:00
logaritmisk f6a83e4dc6 refactor: make BuiltinFactor::log_evidence match exhaustive
Replace the `_ => 0.0` wildcard with explicit
`Self::TeamSum(_) | Self::RankDiff(_) => 0.0`. No behavioral change;
future variants now produce a compile error instead of being silently
absorbed by the wildcard.
2026-05-08 14:37:13 +02:00
logaritmisk 68b589b965 refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
Both methods were 95-line near-duplicates differing only in the closure
that builds the per-diff DiffFactor. Extract the shared body as a
private run_chain<F>(&self, arena, make_link) helper that returns
(evidence, likelihoods); the two callers shrink to ~10 lines each.

Pure code-shape change: posteriors and evidence remain bit-equal; all
existing tests (lib + integration) pass unchanged.
2026-05-08 14:36:35 +02:00
logaritmisk 7481c31ad8 docs: implementation plan for post-T4-MarginFactor tech debt cleanup
Three-task plan covering the run_chain dedup, exhaustive BuiltinFactor
log_evidence match, and stale-numerics fix in the T4 plan doc.
2026-05-08 14:28:10 +02:00
logaritmisk a69a3004b2 docs: spec for post-T4-MarginFactor tech debt cleanup
Three independent cleanups: dedupe Game::likelihoods and likelihoods_scored
via a run_chain helper taking a make_link closure, make BuiltinFactor's
log_evidence match exhaustive, and fix stale numerics in the T4 plan doc.
2026-05-08 14:24:48 +02:00
logaritmisk dbaad0e7d2 fix: release generated CHANGELOG at the wrong location 2026-04-27 09:02:38 +02:00
logaritmisk 8069941a81 chore: Release trueskill-tt version 0.1.1 2026-04-27 09:01:46 +02:00
logaritmiskandClaude Opus 4.7 8b53cacd64 T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence
Adds soft Gaussian-observation evidence on the per-pair diff variable,
enabling continuous score margins as a richer alternative to ranks.

Public API:
- `Outcome::Scored([scores])` (non-breaking enum extension under
  `#[non_exhaustive]`).
- `Game::scored(teams, outcome, options)` constructor parallel to
  `Game::ranked`.
- `EventBuilder::scores([...])` fluent helper.
- `HistoryBuilder::score_sigma(σ)` knob (default 1.0, validated > 0).
- `GameOptions::score_sigma`.
- `EventKind` re-exported from `lib.rs` (annotated `#[non_exhaustive]`).
- New `InferenceError::InvalidParameter { name, value }` variant.

Internals:
- `MarginFactor` (`factor/margin.rs`): Gaussian observation factor that
  closes in one EP step; cavity-cached log-evidence mirrors `TruncFactor`.
- `BuiltinFactor::Margin` dispatch arm.
- `DiffFactor` enum in `game.rs` lets `Game::likelihoods` and the new
  `likelihoods_scored` share the per-pair link abstraction.
- Per-event `EventKind { Ranked, Scored { score_sigma } }` routed through
  `TimeSlice::add_events`, `iteration_direct`, and `log_evidence`.

Tests: 88 lib + 27 integration (4 new in `tests/scored.rs`); existing
goldens byte-identical.  Bench: `benches/scored.rs` baseline ~960µs for
60 events × 20-player pool with default convergence.

Plan: docs/superpowers/plans/2026-04-27-t4-margin-factor.md
Spec item marked Done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:47:36 +02:00
logaritmisk 6bf3e7e294 T3: rayon-backed concurrency (opt-in) (#2)
Implements T3 of `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md` Section 6. Plan: `docs/superpowers/plans/2026-04-24-t3-concurrency.md` (11 tasks).

## Summary

### Breaking

- `Send + Sync` bounds added to public traits: `Time`, `Drift<T>`, `Observer<T>`, `Factor`, `Schedule`. All built-in impls satisfy these via auto-derive; downstream custom impls will need the bounds.

### New

- Opt-in `rayon` cargo feature. When enabled:
  - Within-slice event iteration runs color-group events in parallel via `par_iter_mut` (`TimeSlice::sweep_color_groups`).
  - `History::learning_curves` computes per-slice posteriors in parallel; merges sequentially in slice order.
  - `History::log_evidence` / `log_evidence_for` use per-slice parallel computation with deterministic sequential reduction (sum in slice order) — bit-identical to the sequential baseline.
- `ColorGroups` infrastructure (`src/color_group.rs`) with greedy graph coloring. Events sharing no `Index` go into the same color group; events in the same group can run concurrently without touching each other's skills.
- `tests/determinism.rs` asserts bit-identical posteriors across `RAYON_NUM_THREADS={1, 2, 4, 8}`.
- `benches/history_converge.rs` measures end-to-end convergence on three workload shapes.

## Performance

### Sequential (no rayon, default build)

| Metric | Before T3 | After T3 | Delta |
|---|---|---|---|
| `Batch::iteration` | 22.88 µs | 23.23 µs | **+1.5%** (noise) |
| `Gaussian::*` | ≈218–264 ps | ≈236 ps | within noise |

**No sequential regression.** Default build is as fast as T2.

### Parallel (`--features rayon`, Apple M5 Pro, auto thread count)

| Workload | Sequential | Parallel | Speedup |
|---|---:|---:|---:|
| 500 events / 100 competitors / 10 per slice | 4.03 ms | 4.24 ms | **1.0×** |
| 2000 events / 200 competitors / 20 per slice | 20.18 ms | 19.82 ms | **1.0×** |
| 5000 events / 50000 competitors / 1 slice | 11.88 ms | 9.10 ms | **1.3×** |

### ⚠️ The spec's >=2× target was not met on realistic workloads.

T3's within-slice color-group parallelism only shows material benefit when a slice holds many events AND the competitor pool is large enough to give the greedy coloring room to partition. Typical TrueSkill workloads (tens of events per slice) don't fit that profile — rayon's task-spawn overhead dominates.

**Cross-slice parallelism (dirty-bit slice skipping per spec Section 5) is the natural next step** for real-workload speedup and would deliver the spec's ~50–500× online-add speedup. Deferred to a future tier.

## Determinism

`tests/determinism.rs` runs a 200-event history at thread counts {1, 2, 4, 8} via `rayon::ThreadPoolBuilder::install` and asserts every `(time, posterior)` pair has bit-identical `mu` and `sigma` (compared via `f64::to_bits()`). Passes.

## Internals

- Parallel path uses an `unsafe` block to concurrently write to `SkillStore` from color-group-disjoint events. Soundness rests on the color-group invariant (events in the same color touch no shared `Index`), guaranteed by construction in `TimeSlice::recompute_color_groups`. Sequential path unchanged from T2.
- `RAYON_THRESHOLD = 64` — color groups smaller than this fall back to sequential inside `sweep_color_groups` to avoid task-spawn overhead.
- Thread-local `ScratchArena` per rayon worker thread.

## Test plan

- [x] `cargo test --features approx` — 96 tests pass (74 lib + 22 integration)
- [x] `cargo test --features approx,rayon` — 97 tests pass (+1 determinism)
- [x] `cargo clippy --all-targets --features approx -- -D warnings` — clean
- [x] `cargo clippy --all-targets --features approx,rayon -- -D warnings` — clean
- [x] `cargo +nightly fmt --check` — clean
- [x] `cargo bench --bench batch --features approx` — 23.23 µs (no regression vs T2)
- [x] `cargo bench --bench history_converge --features approx,rayon` — runs on all three workloads
- [x] Bit-identical posteriors across `RAYON_NUM_THREADS={1, 2, 4, 8}` — verified

## Commit history

13 commits on `t3-concurrency`. Each task is self-contained and bisectable. See `git log main..t3-concurrency` for the full list.

## Deferred

- **Cross-slice parallelism** (dirty-bit slice skipping) — the path that would actually speed up typical TrueSkill workloads.
- **Default-on `rayon` feature** — spec called for default-on; we keep it opt-in until the feature proves stable in production use.
- **Synchronous-EP schedule with barrier merge** — alternative parallel strategy per spec Section 6.
- **`MarginFactor` / `Outcome::Scored`** — T4.
- **`Damped` / `Residual` schedules** — T4.
- **N-team `predict_outcome`** — T4.
- **`Game::custom` full ergonomics** — T4.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #2
Co-authored-by: Anders Olsson <anders.e.olsson@gmail.com>
Co-committed-by: Anders Olsson <anders.e.olsson@gmail.com>
2026-04-24 13:01:01 +00:00
logaritmisk d2aab82c1e T0 + T1 + T2: engine redesign through new API surface (#1)
Implements tiers T0, T1, T2 of `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`. All three tiers have landed together on this branch because they build on one another; this PR rolls them up for a single review pass.

Per-tier plans:
- T0: `docs/superpowers/plans/2026-04-23-t0-numerical-parity.md`
- T1: `docs/superpowers/plans/2026-04-24-t1-factor-graph.md`
- T2: `docs/superpowers/plans/2026-04-24-t2-new-api-surface.md`

## Summary

### T0 — Numerical parity (internal)

- `Gaussian` switched to natural-parameter storage `(pi, tau)`; mul/div now ~7× faster (218 ps vs 1.57 ns).
- `HashMap<Index, _>` → dense `Vec<_>` keyed by `Index.0` (via `AgentStore<D>`, `SkillStore`).
- `ScratchArena` eliminates per-event allocations in `Game::likelihoods`.
- `InferenceError` seed type added (1 variant).
- 38 → 53 tests passing through T1.
- Benchmark: `Batch::iteration` 29.84 → 21.25 µs.

### T1 — Factor graph machinery (internal)

- `Factor` trait + `BuiltinFactor` enum (TeamSum / RankDiff / Trunc) driving within-game inference.
- `VarStore` flat storage for variable marginals.
- `Schedule` trait + `EpsilonOrMax` impl replacing the hand-rolled EP loop.
- `Game::likelihoods` rebuilt on the factor-graph machinery; iteration counts and goldens preserved to within 1e-6.
- 53 tests passing.
- Benchmark: `Batch::iteration` 23.01 µs (slight regression absorbed in T2).

### T2 — New API surface (breaking)

**Renames:**
- `IndexMap → KeyTable`, `Player → Rating`, `Agent → Competitor`, `Batch → TimeSlice`

**New types:**
- `Time` trait with `Untimed` ZST and `i64` impls; `Drift<T>`, `Rating<T, D>`, `Competitor<T, D>`, `TimeSlice<T>`, `History<T, D, O, K>` all generic.
- `Event<T, K>`, `Team<K>`, `Member<K>`, `Outcome` (`Ranked` variant; `#[non_exhaustive]`).
- `Observer<T>` trait + `NullObserver`.
- `ConvergenceOptions`, `ConvergenceReport`.
- `GameOptions`, `OwnedGame<T, D>`.

**Three-tier ingestion:**
- `history.record_winner(&K, &K, T)` / `record_draw(&K, &K, T)` — 1v1 convenience.
- `history.add_events(iter)` — typed bulk.
- `history.event(T).team([...]).weights([...]).ranking([...]).commit()` — fluent.

**Query API:** `current_skill`, `learning_curve`, `learning_curves` (keyed on `K`), `log_evidence`, `log_evidence_for`, `predict_quality`, `predict_outcome`.

**Game constructors:** `ranked`, `one_v_one`, `free_for_all`, `custom` — all returning `Result<_, InferenceError>`.

**`factors` module:** `Factor`, `Schedule`, `VarStore`, `VarId`, `BuiltinFactor`, `EpsilonOrMax`, `ScheduleReport`, `TeamSumFactor`, `RankDiffFactor`, `TruncFactor` now public.

**Errors:** `InferenceError` gains `MismatchedShape`, `InvalidProbability`, `ConvergenceFailed`; boundary panics converted to `Result`.

**Removed (breaking):** `History::convergence(iters, eps, verbose)`, `HistoryBuilder::gamma(f64)`, `HistoryBuilder::time(bool)`, `History.time: bool`, `learning_curves_by_index`, nested-Vec public `add_events`.

## Behavior change (documented in CHANGELOG)

`Time = Untimed` has `elapsed_to → 0`, so no drift accumulates between slices. The old `time=false` mode implicitly forced `elapsed=1` on reappearance via an `i64::MAX` sentinel — that quirk is not reproducible under a typed time axis. Tests that depended on it now use `History::<i64, _>` with explicit `1..=n` timestamps. One test (`test_env_ttt`) had 3 Gaussian goldens updated to reflect the corrected semantics; documented in commit `33a7d90`.

## Final numbers

| Metric | Before T0 | After T2 | Delta |
|---|---|---|---|
| `Batch::iteration` | 29.84 µs | 21.36 µs | **-28%** |
| `Gaussian::mul` | 1.57 ns | 219 ps | **-86%** |
| `Gaussian::div` | 1.57 ns | 219 ps | **-86%** |
| Tests passing | 38 | 90 | +52 |

All other Gaussian ops unchanged (~219 ps add/sub, ~264 ps pi/tau reads).

## Test plan

- [x] `cargo test --features approx` — 90/90 pass (68 lib + 10 api_shape + 6 game + 4 record_winner + 2 equivalence)
- [x] `cargo clippy --all-targets --features approx -- -D warnings` — clean
- [x] `cargo +nightly fmt --check` — clean
- [x] `cargo bench --bench batch` — 21.36 µs
- [x] `cargo bench --bench gaussian` — unchanged from T1
- [x] `cargo run --example atp --features approx` — rewritten in new API, runs clean
- [x] Historical Game-level goldens preserved in `tests/equivalence.rs`
- [x] Public API matches spec Section 4 (verified by integration tests in `tests/api_shape.rs`)

## Commit history

~45 commits total across T0 + T1 + T2. Each task is self-contained and individually tested; the branch is bisectable. See `git log main..t2-new-api-surface` for the full list.

## Deferred to later tiers

- `Outcome::Scored` + `MarginFactor` — T4
- `Damped` / `Residual` schedules — T4
- `Send + Sync` bounds + Rayon parallelism — T3
- N-team `predict_outcome` — T4
- `Game::custom` full ergonomics — T4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #1
Co-authored-by: Anders Olsson <anders.e.olsson@gmail.com>
Co-committed-by: Anders Olsson <anders.e.olsson@gmail.com>
2026-04-24 11:20:04 +00:00
81 changed files with 24344 additions and 2048 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/"
+87
View File
@@ -0,0 +1,87 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# The build most consumers get.
- name: default
features: ""
profile: ""
# Most numerical goldens need `approx` for assert_ulps_eq.
- name: approx
features: "--features approx"
profile: ""
# The parallel path, including tests/determinism.rs.
- name: rayon
features: "--features approx,rayon"
profile: ""
# Critical: debug_assert! is compiled out here, which is where the
# tie/p_draw and score_sigma validation actually has to hold.
- name: release
features: "--features approx"
profile: "--release"
name: test (${{ matrix.name }})
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }}
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }} --doc
determinism:
name: determinism across thread counts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# Posteriors must be bit-identical regardless of how many rayon workers
# run the color-group sweep.
- run: |
for threads in 1 2 4 8; do
echo "== RAYON_NUM_THREADS=$threads =="
RAYON_NUM_THREADS=$threads cargo test --release \
--features approx,rayon --test determinism
done
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets --all-features -- -D warnings
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# rustfmt.toml uses nightly-only options (imports_granularity).
- uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt
- run: cargo +nightly fmt --check
msrv:
name: minimum supported Rust version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.85.0
- uses: Swatinem/rust-cache@v2
- run: cargo check --all-targets --features approx,rayon
+111
View File
@@ -2,6 +2,115 @@
All notable changes to this project will be documented in this file.
## 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
### 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
- fix: release generated CHANGELOG at the wrong location
- fix(gaussian): treat non-positive precision as improper in mu()/sigma()
### Documentation
- docs: spec for post-T4-MarginFactor tech debt cleanup
- docs: implementation plan for post-T4-MarginFactor tech debt cleanup
- docs: fix stale numerics in t4-margin-factor plan
- docs: spec for game-local Damped EP
- docs: implementation plan for game-local Damped EP
- docs: spec for History → TimeSlice ConvergenceOptions plumbing
- docs: implementation plan for History → TimeSlice plumbing
- docs: spec for per-event score_sigma override
- docs: implementation plan for per-event score_sigma override
### Features
- feat(gaussian): add damp_natural helper for EP damping
- feat(convergence): add ConvergenceOptions::alpha damping field
- feat(factor): add TruncFactor::propagate_with_alpha for EP damping
- feat(factor): add MarginFactor::propagate_with_alpha for EP damping
- feat(game): plumb ConvergenceOptions through to run_chain
- feat(time_slice): inference callsites read self.convergence
- 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
- refactor: make BuiltinFactor::log_evidence match exhaustive
- refactor(time_slice): add convergence field, rename iterate_to_convergence
### Testing
- test(game): integration tests for ConvergenceOptions behavior
- test(history): end-to-end ConvergenceOptions propagation tests
- test(history): end-to-end per-event score_sigma override tests
## 0.1.1 - 2026-04-27
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.1.1
### Other (unconventional)
- T0 + T1 + T2: engine redesign through new API surface (#1)
- T3: rayon-backed concurrency (opt-in) (#2)
- T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence
## 0.1.0 - 2026-04-23
### Features
@@ -12,6 +121,8 @@ All notable changes to this project will be documented in this file.
- chore: added cliff.toml, release.toml and rustfmt.toml
- chore: clean up
- chore: make cargo release add CHANGELOG.md before commit
- chore: do not publish
### Other (unconventional)
+80 -25
View File
@@ -5,41 +5,96 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands
```bash
cargo build # Build the library
cargo test --lib # Run all library tests
cargo test --lib <test_name> # Run a single test by name
cargo test --lib -- --nocapture # Run tests with stdout output
cargo clippy # Lint
cargo bench # Run benchmarks (criterion)
just test # Full suite across every feature combination CI checks
just check # Fast inner loop: cargo test --features approx
just lint # clippy, warnings denied
just fmt # ALWAYS nightly — rustfmt.toml uses nightly-only options
just determinism # Bit-identical posteriors at RAYON_NUM_THREADS 1/2/4/8
just ci # Everything CI runs
cargo test --lib <test_name> # A single test by name
cargo bench # Criterion benchmarks
```
The `approx` feature enables `approx::AbsDiffEq` for `Gaussian`:
```bash
cargo test --features approx
```
**Run tests in release too.** `debug_assert!` is compiled out there, and that
is where several defects have hidden — a debug-only run is not evidence.
`just test` includes a release job.
### Feature flags
- `approx``approx::AbsDiffEq` etc. for `Gaussian`. Most numerical goldens need it.
- `rayon` — opt-in parallel within-slice sweep and per-slice query passes.
## Architecture
This is a Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py) — a Bayesian skill rating system that tracks skill evolution over time using Gaussian message passing.
A Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py):
Bayesian skill rating that infers skill at every point in time, propagating
evidence both forward and backward across a history.
### Data flow
```
History → Batch[] → Game[] → teams/players
History → TimeSlice[] → Event[] → Team[] → Item[]
Game (factor graph) → Schedule → BuiltinFactor[]
```
- **`History`** (`history.rs`) — top-level container. Organizes games by time into `Batch`es, runs forward/backward message passing across batches, and exposes `learning_curves()` and `log_evidence()`.
- **`Batch`** (`batch.rs`) — all games at a single time step. Runs `iteration()` to update skill estimates via `Game::posteriors()`, collecting `Skill` distributions per player.
- **`Game`** (`game.rs`) — a single match. Given teams (slices of `Gaussian`), computes posterior skill distributions using Gaussian factor graphs and `message.rs` helpers.
- **`Agent`** (`agent.rs`) — wraps a `Player` with temporal state (`last_time`, `message`). `receive()` applies time-decay (`gamma`) when the player reappears after a gap.
- **`Player`** (`player.rs`) — static configuration: prior `Gaussian`, `beta` (performance noise), `gamma` (skill drift per time unit).
- **`Gaussian`** (`gaussian.rs`) — core probability type. Stored as natural parameters (`pi = 1/sigma²`, `tau = mu/sigma²`). Arithmetic ops implement message multiplication/division in the factor graph.
- **`message.rs`** — `TeamMessage` and `DiffMessage`: intermediate factor graph messages used inside `Game`.
- **`lib.rs`** — exports the public API (`Game`, `Gaussian`, `History`, `Player`) and standalone functions (`quality()`, `pdf()`, `cdf()`, `erfc()`). Also defines global defaults: `MU=0.0`, `SIGMA=6.0`, `BETA=1.0`, `GAMMA=0.03`, `P_DRAW=0.0`, `EPSILON=1e-6`, `ITERATIONS=30`.
- **`History`** (`history.rs`) — top level. Interns keys, groups events into
`TimeSlice`s by time, runs the forward/backward sweep in `converge()`, and
answers `learning_curves()`, `current_skill()`, `log_evidence()`,
`predict_quality()`, `predict_outcome()`. Built via `HistoryBuilder`.
- **`TimeSlice`** (`time_slice.rs`) — all events at one time. Owns a
`SkillStore` and a `ScratchArena`; `iteration()` sweeps its events, using
`ColorGroups` to partition independent ones.
- **`Event`** (`time_slice.rs`) — one match. `compute()` runs inference reading
skills immutably; `apply()` folds the result back. The split is what lets a
color group run in parallel with no `unsafe`.
- **`Game`** (`game.rs`) — a single match's factor graph. `run_chain` builds the
diff chain between rank-adjacent teams and drives it to convergence.
- **`Gaussian`** (`gaussian.rs`) — natural parameters (`pi = 1/sigma²`,
`tau = mu/sigma²`). `Mul`/`Div` are the EP product/cavity: pure adds and
subtracts. Variance-space ops (`Add`, `Sub`, `exclude`, `forget`) go through
`from_mv`/`variance()` and take no square root.
- **`factor/`** — `TeamSumFactor`, `RankDiffFactor`, `TruncFactor` (ranked),
`MarginFactor` (scored), over a flat `VarStore`. `BuiltinFactor` dispatches
by enum rather than `dyn`.
- **`Schedule`** (`schedule.rs`) — drives factor propagation. `EpsilonOrMax` is
the only implementation.
- **`Competitor`** (`competitor.rs`) — per-history temporal state (`message`,
`last_time`). **`Rating`** (`rating.rs`) — static config (prior, `beta`, drift).
- **`storage/`** — `SkillStore` (per slice) and `CompetitorStore` (per history),
both dense `Vec`s indexed by `Index`.
- **`KeyTable`** (`key_table.rs`) — user key ↔ `Index`, both directions O(1).
- **`Drift`** (`drift.rs`) / **`Time`** (`time.rs`) — traits. `Time` is a *trait*
(`i64`, `Untimed`), not an enum.
- **`lib.rs`** — public exports, global defaults (`MU`, `SIGMA`, `BETA`,
`GAMMA`, `P_DRAW`, `EPSILON`, `ITERATIONS`), and the standalone `quality()`,
`cdf()`, `erfc()`.
### Key design points
### Invariants worth knowing
- `History` uses `IndexMap<K>` (defined in `lib.rs`) to map arbitrary player keys to `Agent` state.
- Convergence is measured by the maximum `delta()` across all skill distributions; iteration stops when below `EPSILON` or after `ITERATIONS` rounds.
- The `approx` feature gates `AbsDiffEq` on `Gaussian` for use in tests — the feature is optional and only needed for approximate equality assertions.
- `time` in `History`/`Batch` is currently an `f64`; the README notes it needs to become an enum to support richer temporal states.
- **A tie needs `p_draw > 0`.** With `p_draw == 0.0` the truncation margin is
zero and the two-sided tie update evaluates `0/0`. Ingestion rejects such
events with `InferenceError::TieWithoutDrawProbability`. This includes
`Outcome::winner(w, n)` for `n >= 3`, which ties every loser.
- **NaN is never convergence.** Comparisons against NaN are all false, so
`tuple_gt` reads NaN as "below epsilon". Use `step_converged` /
`step_is_finite`, never `!tuple_gt(..)` alone.
- **Evidence accumulates in log space.** A linear product over a long diff
chain underflows to zero, and `ln(0)` is `-inf`.
- **Colors are contiguous.** `recompute_color_groups` reorders events so each
color occupies one range; `ColorGroups::groups_are_contiguous` asserts it.
- **The crate is `#![forbid(unsafe_code)]`.** Keep it that way.
- **Ingestion order must not change the answer.** Events added one at a time
must converge to the same fixed point as the same events batched — see
`tests/ingestion_equivalence.rs`.
### Testing notes
- Numerical goldens are cross-validated against the Python/Julia reference.
Some are *convergence residuals*, not exact values; treat a small movement
as suspicious but check whether the new value is closer to the analytic
truth (symmetric fixtures converge to their prior mean exactly) before
assuming a regression.
- `tests/degenerate_inputs.rs` covers empty/boundary/error paths,
`tests/ingestion_equivalence.rs` covers batching order, `tests/quality.rs`
covers N-group quality, `tests/determinism.rs` covers thread counts.
+42 -1
View File
@@ -1,7 +1,30 @@
[package]
name = "trueskill-tt"
version = "0.1.0"
version = "0.2.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"]
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
@@ -14,8 +37,26 @@ harness = false
name = "gaussian"
harness = false
[[bench]]
name = "history_converge"
harness = false
[[bench]]
name = "scored"
harness = false
[[bench]]
name = "ingest"
harness = false
[dependencies]
approx = { version = "0.5.1", optional = true }
rayon = { version = "1", optional = true }
smallvec = "1"
[features]
approx = ["dep:approx"]
rayon = ["dep:rayon"]
[dev-dependencies]
criterion = "0.5"
+81
View File
@@ -1,4 +1,39 @@
alias b := bench
alias t := test
# Run the full test suite across the feature combinations CI checks.
test:
cargo test
cargo test --features approx
cargo test --features approx,rayon
cargo test --release --features approx
# Fast inner-loop tests.
check:
cargo test --features approx
# Posteriors must be bit-identical across rayon worker counts.
determinism:
#!/usr/bin/env bash
set -euo pipefail
for threads in 1 2 4 8; do
echo "== RAYON_NUM_THREADS=$threads =="
RAYON_NUM_THREADS=$threads cargo test --release \
--features approx,rayon --test determinism
done
lint:
cargo clippy --all-targets --all-features -- -D warnings
# Always nightly: rustfmt.toml uses nightly-only options.
fmt:
cargo +nightly fmt
fmt-check:
cargo +nightly fmt --check
# Everything CI runs.
ci: fmt-check lint test determinism
store:
cargo bench -- --save-baseline base
@@ -8,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.
+43 -5
View File
@@ -71,12 +71,50 @@ let h = History::builder()
.build();
```
## Scored outcomes
Use `Outcome::scores([...])` when you have continuous per-team scores rather
than just ranks. Adjacent score margins flow into a `MarginFactor` that adds
soft Gaussian evidence about the latent performance diff. Configure
`HistoryBuilder::score_sigma(σ)` to control how much you trust the margins
(smaller σ = more trust).
```rust
use trueskill_tt::{History, Outcome};
let mut h = History::builder().score_sigma(2.0).build();
h.event(1)
.team(["alice"])
.team(["bob"])
.scores([21.0, 9.0])
.commit()
.unwrap();
h.converge().unwrap();
```
## Todo
- [x] Implement approx for Gaussian
- [x] Add more tests from `TrueSkillThroughTime.jl`
- [ ] Add tests for `quality()` (Use [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) as reference)
- [ ] Benchmark Batch::iteration()
- [ ] Time needs to be an enum so we can have multiple states (see `batch::compute_elapsed()`)
- [ ] Add examples (use same TrueSkillThroughTime.(py|jl))
- [ ] Add Observer (see [argmin](https://docs.rs/argmin/latest/argmin/core/trait.Observe.html) for inspiration)
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
- [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.
+132
View File
@@ -0,0 +1,132 @@
# Baseline numbers captured before T0 changes
# Hardware: lrrr.local / Apple M5 Pro
# Date: 2026-04-24
Batch::iteration 29.840 µs
Gaussian::add 219.58 ps
Gaussian::sub 219.41 ps
Gaussian::mul 1.568 ns ← hot path; target ≥1.5× improvement
Gaussian::div 1.572 ns ← hot path; target ≥1.5× improvement
Gaussian::pi 262.89 ps
Gaussian::tau 262.47 ps
Gaussian::pi_tau_combined 219.40 ps
# After T0 (2026-04-24, same hardware)
Batch::iteration 21.253 µs (1.40× — below 3× target; see post-mortem)
Gaussian::add 218.62 ps (1.00× — unchanged, Add/Sub use moment form)
Gaussian::sub 220.15 ps (1.00×)
Gaussian::mul 218.69 ps (7.17× — nat-param: now two f64 adds, no sqrt)
Gaussian::div 218.64 ps (7.19× — nat-param: now two f64 subs, no sqrt)
Gaussian::pi 263.19 ps (1.00× — now a field read, same cost)
Gaussian::tau 263.51 ps (1.00× — now a field read, same cost)
Gaussian::pi_tau_combined 219.13 ps (1.00×)
# Post-mortem: Batch::iteration 1.40× vs. 3× target
#
# Root cause: the bench has 100 tiny 2-team events. Each event still allocates
# ~10 Vecs per iteration (down from ~18). The arena covers teams/diffs/ties/margins
# (was 4 Vecs, now 0 new allocs) but the following remain:
# - within_priors() returns Vec<Vec<Player<D>>>: 3 Vecs per event (300 total)
# - event.outputs() returns Vec<f64>: 1 Vec per event (100 total)
# - sort_perm() allocates 2 scratch Vecs: 200 total
# - Game::likelihoods = collect() allocates Vec<Vec<Gaussian>>: 4 Vecs (400 total)
# Total remaining: ~1000 allocs per iteration call vs. ~1800 before (44% reduction).
#
# The HashMap → dense Vec win (target 24×) benefits the History-level forward/backward
# sweep, NOT Batch::iteration in isolation — so this bench doesn't show it.
#
# To hit ≥3× on Batch::iteration:
# - Arena-ify sort_perm (use a stack-fixed array for small n_teams)
# - Pass a within_priors output buffer through the arena
# - Make Game::likelihoods write into an arena slice rather than allocating
# These land in T1 (factor graph) when we redesign Game's internals.
# After T1 (2026-04-24, same hardware)
Batch::iteration 23.010 µs (1.08× vs T0 21.253 µs — slight regression)
Gaussian::add 231.23 ps (unchanged)
Gaussian::sub 235.38 ps (unchanged)
Gaussian::mul 234.55 ps (unchanged — nat-param storage)
Gaussian::div 233.27 ps (unchanged)
Gaussian::pi 272.68 ps (unchanged)
Gaussian::tau 272.73 ps (unchanged)
Gaussian::pi_tau_combined 234.xx ps (unchanged)
# Notes:
# - Batch::iteration 23.0 µs vs target ≤ 21.5 µs (8% above target).
# Root cause: TruncFactor::propagate adds one extra Gaussian mul + div per
# diff vs the old inline EP computation. trunc Vec is still a fresh
# per-game allocation (borrow checker prevents putting it in the arena
# alongside vars). These are addressable in T2.
# - arena.team_prior, lhood_lose, lhood_win, inv_buf, sort_buf all reuse
# capacity across games (pooled in ScratchArena). sort_perm() allocation
# eliminated. message.rs deleted.
# - Gaussian operations unchanged vs T0.
# - All 53 tests pass. factor graph infrastructure (VarStore, Factor trait,
# BuiltinFactor, TruncFactor, EpsilonOrMax schedule) in place for T2.
# After T2 (2026-04-24, same hardware)
Batch::iteration 21.36 µs (1.07× vs T1 22.88 µs — 7% improvement)
Gaussian::add 218.97 ps (unchanged)
Gaussian::sub 218.58 ps (unchanged)
Gaussian::mul 218.59 ps (unchanged)
Gaussian::div 218.57 ps (unchanged)
Gaussian::pi 264.20 ps (unchanged)
Gaussian::tau 260.80 ps (unchanged)
# Notes:
# - API-only tier; hot inference path unchanged. The 7% improvement on
# Batch::iteration likely comes from the typed add_events(iter) path
# being slightly more direct than the nested-Vec path it replaced
# (one less layer of composition construction per event).
# - Public surface now matches spec Section 4:
# record_winner / record_draw / add_events(iter) / event(t).team().commit()
# converge() -> Result<ConvergenceReport, InferenceError>
# learning_curve(&K) / learning_curves() / current_skill(&K)
# log_evidence() / log_evidence_for(&[&K])
# predict_quality / predict_outcome
# Game::ranked / one_v_one / free_for_all / custom
# factors module (pub Factor/Schedule/VarStore/EpsilonOrMax/BuiltinFactor)
# - Breaking type renames: Batch→TimeSlice, Player→Rating, Agent→Competitor,
# IndexMap→KeyTable.
# - Generic over T: Time (default i64), D: Drift<T>, O: Observer<T>,
# K: Eq + Hash + Clone (default &'static str).
# - Legacy removed: History::convergence(iters, eps, verbose),
# HistoryBuilder::gamma(), HistoryBuilder::time(bool), History::time field,
# learning_curves_by_index(), nested-Vec public add_events().
# - 90 tests green: 68 lib + 10 api_shape + 6 game + 4 record_winner +
# 2 equivalence.
# After T3 (2026-04-24, same hardware)
Batch::iteration (seq, no rayon) 23.23 µs (matches T2 baseline; no regression)
Batch::iteration (rayon, small slice) 24.57 µs (within noise; small workloads pay rayon overhead)
Gaussian::add 236.62 ps (unchanged)
Gaussian::sub 236.43 ps (unchanged)
Gaussian::mul 237.05 ps (unchanged)
Gaussian::div 236.07 ps (unchanged)
# End-to-end history_converge benchmark (Apple M5 Pro, RAYON_NUM_THREADS=auto):
# workload seq rayon speedup
# 500 events, 100 competitors, 10/slice 4.03 ms 4.24 ms 1.0x
# 2000 events, 200 competitors, 20/slice 20.18 ms 19.82 ms 1.0x
# 5000 events, 50000 competitors, 1 slice 11.88 ms 9.10 ms 1.3x
#
# Notes:
# - T3's within-slice color-group parallelism only materializes a speedup
# when a slice holds many events with disjoint competitor sets. Typical
# TrueSkill workloads (tens of events per slice) don't show measurable
# benefit from rayon.
# - The pre-revert SmallVec experiment hit 2x on the 5000-event workload
# but regressed sequential Batch::iteration by 28%. The tradeoff wasn't
# worth it for typical workloads — ShipVec<[_; 8]> inline size (1 KB per
# Game struct) hurt cache locality on the hot path.
# - Cross-slice parallelism (dirty-bit slice skipping per spec Section 5)
# is the natural next step for realistic TrueSkill workloads and would
# deliver the spec's ~50-500x online-add speedup. Deferred to T4+.
# - Determinism verified: tests/determinism.rs asserts bit-identical
# posteriors across RAYON_NUM_THREADS={1, 2, 4, 8}.
# - Send + Sync bounds added on Time, Drift<T>, Observer<T>, Factor, Schedule.
# - Rayon is opt-in via `--features rayon`. Default build is unchanged from T2.
+18 -34
View File
@@ -1,45 +1,27 @@
use std::collections::HashMap;
use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::{
BETA, GAMMA, IndexMap, MU, P_DRAW, SIGMA, agent::Agent, batch::Batch, drift::ConstantDrift,
gaussian::Gaussian, player::Player,
BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA,
TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
};
fn criterion_benchmark(criterion: &mut Criterion) {
let mut index = IndexMap::new();
let mut index_map = KeyTable::new();
let a = index.get_or_create("a");
let b = index.get_or_create("b");
let c = index.get_or_create("c");
let a = index_map.get_or_create("a");
let b = index_map.get_or_create("b");
let c = index_map.get_or_create("c");
let agents = {
let mut map = HashMap::new();
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
map.insert(
a,
Agent {
player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
for agent in [a, b, c] {
agents.insert(
agent,
Competitor {
rating: Rating::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
..Default::default()
},
);
map.insert(
b,
Agent {
player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
..Default::default()
},
);
map.insert(
c,
Agent {
player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)),
..Default::default()
},
);
map
};
}
let mut composition = Vec::new();
let mut results = Vec::new();
@@ -51,11 +33,13 @@ fn criterion_benchmark(criterion: &mut Criterion) {
weights.push(vec![vec![1.0], vec![1.0]]);
}
let mut batch = Batch::new(1, P_DRAW);
batch.add_events(composition, results, weights, &agents);
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);
criterion.bench_function("Batch::iteration", |b| {
b.iter(|| batch.iteration(0, &agents))
b.iter(|| time_slice.iteration(0, &agents))
});
}
+4 -1
View File
@@ -23,8 +23,11 @@ fn benchmark_gaussian_arithmetic(criterion: &mut Criterion) {
});
// Benchmark division
// NOTE: numerator must have higher precision (smaller sigma) than the
// denominator in this representation; g2 (sigma=1) / g1 (sigma=8.33) is
// well-defined, whereas g1 / g2 underflows and panics in mu_sigma.
criterion.bench_function("Gaussian::div", |bencher| {
bencher.iter(|| g1 / g2);
bencher.iter(|| g2 / g1);
});
// Benchmark natural parameter conversions
+117
View File
@@ -0,0 +1,117 @@
//! End-to-end History::converge benchmark.
//!
//! Workload shapes designed to expose rayon's within-slice color-group
//! parallelism. Events in the same color group are processed in parallel
//! via direct-write with disjoint index sets (no data races). Color groups
//! smaller than a threshold fall back to the sequential path to avoid
//! rayon overhead on small workloads.
//!
//! On Apple M5 Pro, the P-core count (6) is the optimal thread count.
//! The rayon thread pool is initialised to `min(P-cores, available)` to
//! avoid scheduling onto the slower E-cores.
//!
//! ## Results (Apple M5 Pro, 2026-04-24, after SmallVec revert)
//!
//! | Workload | Sequential | Parallel | Speedup |
//! |---------------------------------------------|------------:|-----------:|--------:|
//! | History::converge/500x100@10perslice | 4.03 ms | 4.24 ms | 1.0× |
//! | History::converge/2000x200@20perslice | 20.18 ms | 19.82 ms | 1.0× |
//! | History::converge/1v1-5000x50000@5000perslice| 11.88 ms | 9.10 ms | 1.3× |
//!
//! T3 acceptance gate: ≥2× speedup on at least one workload — NOT achieved after revert.
//! The SmallVec storage that enabled the 2× gate caused a +28% regression in the
//! sequential Batch::iteration benchmark and was reverted. Small workloads still fall
//! below the RAYON_THRESHOLD (64 events/color) and run sequentially with near-zero overhead.
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
};
fn build_history_1v1(
n_events: usize,
n_competitors: usize,
events_per_slice: usize,
seed: u64,
) -> History<i64, ConstantDrift, NullObserver, String> {
let mut rng = seed;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
rng
};
let mut h = History::<i64, _, _, String>::builder_with_key()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(n_events);
for ev_i in 0..n_events {
let a = (next() as usize) % n_competitors;
let mut b = (next() as usize) % n_competitors;
while b == a {
b = (next() as usize) % n_competitors;
}
events.push(Event {
time: (ev_i as i64 / events_per_slice as i64) + 1,
teams: smallvec![
Team::with_members([Member::new(format!("p{a}"))]),
Team::with_members([Member::new(format!("p{b}"))]),
],
outcome: Outcome::winner((next() % 2) as u32, 2),
});
}
h.add_events(events).unwrap();
h
}
fn bench_converge(c: &mut Criterion) {
// Two original task workloads (small per-slice event count;
// fall below RAYON_THRESHOLD so sequential path runs — near-zero overhead).
c.bench_function("History::converge/500x100@10perslice", |b| {
b.iter_batched(
|| build_history_1v1(500, 100, 10, 42),
|mut h| {
h.converge().unwrap();
},
BatchSize::SmallInput,
);
});
c.bench_function("History::converge/2000x200@20perslice", |b| {
b.iter_batched(
|| build_history_1v1(2000, 200, 20, 42),
|mut h| {
h.converge().unwrap();
},
BatchSize::SmallInput,
);
});
// Large single-slice workload: 5000 events, 50000 competitors.
// All events in one slice → color-0 gets ~4900 disjoint events, well above
// the 64-event RAYON_THRESHOLD. 30 iterations × 1 slice = 30 sweeps, each
// parallelised across P-core threads. Shows ≥2× speedup.
c.bench_function("History::converge/1v1-5000x50000@5000perslice", |b| {
b.iter_batched(
|| build_history_1v1(5000, 50000, 5000, 42),
|mut h| {
h.converge().unwrap();
},
BatchSize::SmallInput,
);
});
}
criterion_group!(benches, bench_converge);
criterion_main!(benches);
+62
View File
@@ -0,0 +1,62 @@
//! Ingestion cost: one event per call versus one batched call.
//!
//! The rest of the suite only measures batched construction, which is why a
//! quadratic in the incremental path went unnoticed — `record_winner` and
//! `event(..).commit()` each ingest a single event, so a caller looping over a
//! match feed takes that path.
use std::hint::black_box;
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use smallvec::smallvec;
use trueskill_tt::{Event, History, Member, Outcome, Team};
fn events(n: usize, time: i64) -> Vec<Event<i64, String>> {
(0..n)
.map(|i| Event {
time,
teams: smallvec![
Team::with_members([Member::new(format!("p{}", 2 * i))]),
Team::with_members([Member::new(format!("p{}", 2 * i + 1))]),
],
outcome: Outcome::winner(0, 2),
})
.collect()
}
fn bench_ingest(c: &mut Criterion) {
let mut group = c.benchmark_group("ingest");
for n in [250usize, 500, 1000] {
group.bench_with_input(BenchmarkId::new("one-at-a-time", n), &n, |b, &n| {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
for ev in evs {
h.add_events(std::iter::once(ev)).unwrap();
}
black_box(h.time_slices_len())
},
criterion::BatchSize::SmallInput,
);
});
group.bench_with_input(BenchmarkId::new("single-batch", n), &n, |b, &n| {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
h.add_events(evs).unwrap();
black_box(h.time_slices_len())
},
criterion::BatchSize::SmallInput,
);
});
}
group.finish();
}
criterion_group!(benches, bench_ingest);
criterion_main!(benches);
+38
View File
@@ -0,0 +1,38 @@
use criterion::{Criterion, criterion_group, criterion_main};
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn bench_scored_history(c: &mut Criterion) {
c.bench_function("scored_history_60_events_30_iter", |bencher| {
bencher.iter(|| {
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.03))
.score_sigma(2.0)
.build();
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(60);
for i in 0..60 {
let a = format!("p{}", i % 20);
let b = format!("p{}", (i + 7) % 20);
let s_a = (i as f64 * 0.3).sin().abs() * 21.0;
let s_b = (i as f64 * 0.3).cos().abs() * 21.0;
events.push(Event {
time: 1 + (i / 6) as i64,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([s_a, s_b]),
});
}
h.add_events(events).unwrap();
h.converge().unwrap();
});
});
}
criterion_group!(benches, bench_scored_history);
criterion_main!(benches);
+14
View File
@@ -0,0 +1,14 @@
Finished `bench` profile [optimized + debuginfo] target(s) in 0.02s
Running benches/scored.rs (target/release/deps/scored-988d1798504ff7d2)
Gnuplot not found, using plotters backend
Benchmarking scored_history_60_events_30_iter
Benchmarking scored_history_60_events_30_iter: Warming up for 3.0000 s
Benchmarking scored_history_60_events_30_iter: Collecting 100 samples in estimated 9.7418 s (10k iterations)
Benchmarking scored_history_60_events_30_iter: Analyzing
scored_history_60_events_30_iter
time: [959.36 µs 962.68 µs 966.13 µs]
Found 11 outliers among 100 measurements (11.00%)
1 (1.00%) low mild
5 (5.00%) high mild
5 (5.00%) high severe
+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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,593 @@
# History → TimeSlice ConvergenceOptions Plumbing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Thread `ConvergenceOptions` from `History` through `TimeSlice` to the three `Game::*_with_arena` callsites in `time_slice.rs`, so users who set `HistoryBuilder::convergence(opts)` actually get those options applied to within-game inference (including Damped's `alpha`).
**Architecture:** `TimeSlice<T>` gains a `convergence: ConvergenceOptions` field set at construction. `History::add_events_with_prior` passes `self.convergence`. The three `Game::*_with_arena` callsites in `time_slice.rs` swap their hardcoded `ConvergenceOptions::default()` for the propagated value. The pre-existing `TimeSlice::convergence` method is renamed to `iterate_to_convergence` to disambiguate from the new field. No new public API on `History` or `HistoryBuilder``convergence(opts)` already exists and works.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-history-convergence-plumbing-design.md`
## Pre-flight context for the implementer
- `HistoryBuilder::convergence(opts)` already exists at `src/history.rs:91`. `History` already stores `convergence: ConvergenceOptions` at `src/history.rs:166`. `History::converge()` already reads `self.convergence.{epsilon, max_iter}` at `src/history.rs:437-447` for the OUTER cross-history loop.
- `TimeSlice<T>` is at `src/time_slice.rs:172-180`. Currently has fields `events`, `skills`, `time`, `p_draw`, `arena`, `color_groups`. No convergence field yet.
- `TimeSlice::new(time, p_draw)` at `src/time_slice.rs:183-192` is `pub`. Five test callsites use it with `(0i64, 0.0)`. One production callsite in `History::add_events_with_prior` at `src/history.rs:597` uses `(t, self.p_draw)`.
- Three callsites in `time_slice.rs` call `Game::*_with_arena` with hardcoded `crate::ConvergenceOptions::default()`:
- `Event::iteration_direct` at `src/time_slice.rs:131-169` — does NOT have `&self` access to a TimeSlice. Currently takes `(skills, agents, p_draw, arena)`. Needs to gain a `convergence` parameter.
- `TimeSlice::iteration` at `src/time_slice.rs:322-363` — has `&mut self`, so reads `self.convergence` directly.
- `TimeSlice::log_evidence` at `src/time_slice.rs:505-540` — has `&self`, so reads `self.convergence` directly.
- The rayon path in `sweep_color_groups` at `src/time_slice.rs:376-423` uses a `move` closure capturing `p_draw` by value. The same pattern applies to `convergence` (it's `Copy`, so captures cleanly).
- `TimeSlice::convergence` (the **method** at `src/time_slice.rs:447`) shares its name with the new field. Rust technically allows this (different namespaces), but it's a readability hazard — must be renamed. The method is called from 4 test sites in `time_slice.rs` (lines 693, 755, 817, 851). It is NOT called from `history.rs`.
- `ConvergenceOptions` is `Copy + Clone + Debug`. Pass by value everywhere.
## File map
| File | Why touched |
|---|---|
| `src/time_slice.rs` | TimeSlice gains `convergence` field, `new` signature change, rename `convergence` method, three callsites read `self.convergence`, `Event::iteration_direct` gains parameter, rayon closure captures it |
| `src/history.rs` | `add_events_with_prior` passes `self.convergence` to `TimeSlice::new`; two integration tests added; alpha doc-comment update happens in `convergence.rs` not here |
| `src/convergence.rs` | One-sentence addition to `alpha` doc comment clarifying within-game-only scope |
---
### Task 1: TimeSlice gains `convergence` field; signature/rename land atomically
This task does five things atomically — they cannot land separately because intermediate states won't compile:
1. Add `pub(crate) convergence: ConvergenceOptions` field to `TimeSlice<T>`.
2. Change `TimeSlice::new` signature to take `convergence: ConvergenceOptions` as the third parameter.
3. Update the production callsite in `History::add_events_with_prior` (`src/history.rs:597`) to pass `self.convergence`.
4. Update the five test callsites in `src/time_slice.rs` (lines 646, 723, 803, 901 — the four with `TimeSlice::new(0i64, 0.0)`, plus the one inside the test module's `iterate_through_color_groups` test if it exists; locate via `grep -n "TimeSlice::new" src/time_slice.rs`).
5. Rename the existing `pub(crate) fn convergence` method (at `src/time_slice.rs:447`) to `iterate_to_convergence`. Update its 4 in-file call sites.
After this task the convergence field is wired but **unused** by inference (Task 2 makes the three Game callsites read it). All existing tests must pass bit-equal because the propagated value still equals `ConvergenceOptions::default()` end-to-end.
**Files:**
- Modify: `src/time_slice.rs`
- Modify: `src/history.rs:597`
- [ ] **Step 1: Locate all `TimeSlice::new` and `convergence`-method callsites**
Run:
```bash
grep -n "TimeSlice::new\|\.convergence(" src/time_slice.rs src/history.rs
```
Expected: 1 production callsite of `TimeSlice::new` in `history.rs`, 5 test callsites in `time_slice.rs`, and 4 method-style `.convergence(` calls in `time_slice.rs` test module. (No `.convergence(` calls in `history.rs` — those are field accesses.)
Save the line numbers — you'll need them in Step 4 and Step 6.
- [ ] **Step 2: Add the `convergence` field to `TimeSlice<T>`**
In `src/time_slice.rs`, modify the `TimeSlice<T>` struct (currently at `src/time_slice.rs:172-180`):
```rust
#[derive(Debug)]
pub struct TimeSlice<T: Time = i64> {
pub(crate) events: Vec<Event>,
pub(crate) skills: SkillStore,
pub(crate) time: T,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
arena: ScratchArena,
pub(crate) color_groups: ColorGroups,
}
```
Code won't compile until Step 3.
- [ ] **Step 3: Change `TimeSlice::new` signature**
In `src/time_slice.rs`, replace the existing `pub fn new` (currently at `src/time_slice.rs:183-192`) with:
```rust
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
Self {
events: Vec::new(),
skills: SkillStore::new(),
time,
p_draw,
convergence,
arena: ScratchArena::new(),
color_groups: ColorGroups::new(),
}
}
```
- [ ] **Step 4: Update the production callsite in `history.rs`**
In `src/history.rs:597`, replace:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw);
```
with:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
```
- [ ] **Step 5: Update test callsites of `TimeSlice::new`**
Run `cargo build --tests` to surface every remaining compile error. Each error is a `TimeSlice::new(time, p_draw)` callsite missing the third argument. The fix: add `crate::ConvergenceOptions::default(),` (inside `src/time_slice.rs` test modules use the path relative to where `ConvergenceOptions` is in scope — if it's not imported in that test mod, add `use crate::ConvergenceOptions;` at the top of the mod and pass `ConvergenceOptions::default()`).
Example transformation. Before:
```rust
let mut time_slice = TimeSlice::new(0i64, 0.0);
```
After:
```rust
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
```
Apply to all 5 test callsites identified in Step 1. Repeat `cargo build --tests` until it succeeds.
- [ ] **Step 6: Rename the `convergence` method to `iterate_to_convergence`**
In `src/time_slice.rs`, find the method definition at `src/time_slice.rs:447`:
```rust
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
```
Rename to:
```rust
pub(crate) fn iterate_to_convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
```
Then update the 4 call sites (located in Step 1 — `time_slice.rs:693, 755, 817, 851` or wherever your grep found them). At each site, replace `time_slice.convergence(&agents)` with `time_slice.iterate_to_convergence(&agents)`.
- [ ] **Step 7: Build and run the full test suite**
Run: `cargo build && cargo test --lib`
Expected: all 98 lib tests pass. Bit-equal goldens — the convergence field is wired but the three inference callsites still hardcode `ConvergenceOptions::default()` (Task 2 changes that), and the propagated default equals what was hardcoded before, so behavior is identical.
If any test fails: investigate. The most likely cause is a missed `TimeSlice::new` callsite or a `.convergence(` call site that needs renaming.
- [ ] **Step 8: Run integration tests**
Run: `cargo test`
Expected: all 27 integration tests still pass.
- [ ] **Step 9: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 10: Commit**
```bash
git add src/time_slice.rs src/history.rs
git commit -m "$(cat <<'EOF'
refactor(time_slice): add convergence field, rename iterate_to_convergence
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
set at construction. TimeSlice::new now takes it as a third parameter
(breaking change to the pub constructor, acceptable in 0.1.x).
History::add_events_with_prior passes self.convergence so the propagated
value reaches every TimeSlice. The pre-existing convergence-the-method
is renamed to iterate_to_convergence to disambiguate from the new
convergence-the-field.
The field is wired but not yet read by inference — the three
Game::*_with_arena callsites in time_slice.rs still hardcode
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
the propagated value equals the hardcoded value end-to-end.
EOF
)"
```
---
### Task 2: Read `self.convergence` at the three inference callsites
This task switches the three `Game::*_with_arena` callsites in `time_slice.rs` from hardcoded `ConvergenceOptions::default()` to the propagated `self.convergence` (or for `Event::iteration_direct`, a passed-in parameter). After this task, Damped EP set on `HistoryBuilder` actually reaches the within-game loop.
**Files:**
- Modify: `src/time_slice.rs` (only)
- [ ] **Step 1: Add a `convergence` parameter to `Event::iteration_direct`**
In `src/time_slice.rs`, modify the existing `iteration_direct` signature (currently at `src/time_slice.rs:131-137`):
```rust
fn iteration_direct<T: Time, D: Drift<T>>(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<T, D>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) {
```
Inside the body (around `src/time_slice.rs:140-156`), replace both `crate::ConvergenceOptions::default()` arguments with `convergence`:
```rust
let g = match self.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&self.weights,
p_draw,
convergence,
arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&self.weights,
score_sigma,
convergence,
arena,
),
};
```
- [ ] **Step 2: Update the rayon path in `sweep_color_groups` (cfg=rayon)**
In `src/time_slice.rs`, the rayon-feature `sweep_color_groups` (currently at `src/time_slice.rs:376-423`) captures `p_draw` by value into a `move` closure and calls `ev.iteration_direct(skills, agents, p_draw, &mut arena)`. Capture `convergence` the same way and pass it:
Above the rayon `for_each` at the line `let p_draw = self.p_draw;`, add:
```rust
let convergence = self.convergence;
```
Then update the call inside the closure (currently `ev.iteration_direct(skills, agents, p_draw, &mut arena);`):
```rust
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
```
The `else` branch (sequential fallback) at `src/time_slice.rs:417-421` calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` — also update:
```rust
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
```
(Note: this branch reads `self.convergence` directly because no `move` closure is involved here.)
- [ ] **Step 3: Update the non-rayon path in `sweep_color_groups`**
In `src/time_slice.rs`, the `#[cfg(not(feature = "rayon"))]` `sweep_color_groups` (currently at `src/time_slice.rs:428-444`) calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` at `src/time_slice.rs:441`. Replace with:
```rust
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
```
- [ ] **Step 4: Update `TimeSlice::iteration`'s sequential branch**
In `src/time_slice.rs`, modify `TimeSlice::iteration` (at `src/time_slice.rs:322-363`). The sequential branch (when `from > 0 || self.color_groups.is_empty()`) has two `Game::*_with_arena` callsites at `src/time_slice.rs:330-346` that hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
```rust
let g = match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
&mut self.arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
&mut self.arena,
),
};
```
- [ ] **Step 5: Update `TimeSlice::log_evidence`**
In `src/time_slice.rs`, modify `TimeSlice::log_evidence` (at `src/time_slice.rs:505-540`). The two `Game::*_with_arena` callsites in the inner `run_event` closure at `src/time_slice.rs:519-538` hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
```rust
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs();
match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
arena,
)
.evidence
.ln(),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
arena,
)
.evidence
.ln(),
}
};
```
(`self.convergence` is `Copy`, so the closure captures it by value naturally without needing a `let` binding outside.)
- [ ] **Step 6: Build and run the full test suite — bit-equal regression net**
Run: `cargo build && cargo test --lib`
Expected: all 98 lib tests still pass. Bit-equal goldens — every existing test uses `History::default()` or `HistoryBuilder::default()` (which sets `convergence = ConvergenceOptions::default()`), so the propagated value equals what the hardcoded default was. No test exercises a non-default convergence through History today, so no behavior changes.
If any test fails: investigate. The most likely cause is a stale `crate::ConvergenceOptions::default()` call missed in steps 1-5 — re-grep with `grep -n "ConvergenceOptions::default" src/time_slice.rs` to find any remaining hardcoded sites.
- [ ] **Step 7: Run integration tests**
Run: `cargo test`
Expected: all 27 integration tests still pass.
- [ ] **Step 8: Confirm no `crate::ConvergenceOptions::default()` remains in time_slice.rs**
Run: `grep -n "ConvergenceOptions::default" src/time_slice.rs`
Expected: only test-mod hits (in `TimeSlice::new(0i64, 0.0, ConvergenceOptions::default())` callsites from Task 1 step 5). NO production-code hits in `Event::iteration_direct`, `sweep_color_groups`, `TimeSlice::iteration`, or `TimeSlice::log_evidence`.
- [ ] **Step 9: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 10: Commit**
```bash
git add src/time_slice.rs
git commit -m "$(cat <<'EOF'
feat(time_slice): inference callsites read self.convergence
The three Game::*_with_arena callsites in time_slice.rs (in
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
run_event closure, and Event::iteration_direct via parameter) now use
the propagated ConvergenceOptions instead of hardcoded ::default().
sweep_color_groups (both rayon and non-rayon paths) forwards
self.convergence into Event::iteration_direct.
Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
HistoryBuilder::convergence(opts) now actually reach the within-game
inference loop. Bit-equal for users on default options.
EOF
)"
```
---
### Task 3: Doc-comment update + end-to-end integration tests
**Files:**
- Modify: `src/convergence.rs` (alpha doc comment)
- Modify: `src/history.rs` (two integration tests in the existing `#[cfg(test)] mod tests` block)
- [ ] **Step 1: Update `ConvergenceOptions::alpha` doc comment**
In `src/convergence.rs`, find the existing doc comment on the `alpha` field. Replace it with:
```rust
/// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
/// undamped (default); `< 1.0` stabilises oscillating fixed-point
/// loops at the cost of more iterations. Must be in `(0.0, 1.0]`.
///
/// Applies only to the within-game EP loop (`run_chain`). The outer
/// `History::converge` cross-history sweep is undamped regardless of
/// this value — cross-slice damping is a different concept and not
/// in scope.
pub alpha: f64,
```
- [ ] **Step 2: Locate the `#[cfg(test)] mod tests` block in `src/history.rs`**
Run: `grep -n "#\[cfg(test)\]" src/history.rs`
Identify the test module (there should be one near the bottom of the file). Read the imports at the top of that module so the new tests can reuse the existing test helpers and scope.
- [ ] **Step 3: Write the failing tests**
Add the following two tests at the end of the test module in `src/history.rs` (just before the module's closing `}`):
```rust
#[test]
fn history_propagates_convergence_to_inner_run_chain() {
use crate::ConvergenceOptions;
// 4-team ranked game; each event needs more than one inner EP iter
// to fully converge.
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
crate::observer::NullObserver, &'static str>| {
for &name in &["a", "b", "c", "d"] {
h.new_agent(name);
}
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.commit()
.unwrap();
};
let mut h_capped = crate::History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_capped);
h_capped.converge().unwrap();
let mut h_full = crate::History::builder().build();
events_for(&mut h_full);
h_full.converge().unwrap();
let curves_capped = h_capped.learning_curves();
let curves_full = h_full.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, capped_pts) in curves_capped.iter() {
let full_pts = curves_full.get(key).expect("agent missing in full");
for (capped, full) in capped_pts.iter().zip(full_pts.iter()) {
max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs());
max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"max_iter=1 inner loop should differ from default; max_diff={max_diff}"
);
}
#[test]
fn history_with_damping_reaches_same_fixed_point_as_undamped() {
use crate::ConvergenceOptions;
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
crate::observer::NullObserver, &'static str>| {
for &name in &["a", "b", "c", "d"] {
h.new_agent(name);
}
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.commit()
.unwrap();
};
let mut h_undamped = crate::History::builder().build();
events_for(&mut h_undamped);
h_undamped.converge().unwrap();
let mut h_damped = crate::History::builder()
.convergence(ConvergenceOptions {
alpha: 0.5,
max_iter: 200,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_damped);
h_damped.converge().unwrap();
let curves_u = h_undamped.learning_curves();
let curves_d = h_damped.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, u_pts) in curves_u.iter() {
let d_pts = curves_d.get(key).expect("agent missing in damped");
for (u, d) in u_pts.iter().zip(d_pts.iter()) {
max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs());
max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs());
}
}
assert!(
max_diff < 1e-3,
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
);
}
```
If the import or method names (e.g. `History::builder()`, `event(...).team(...).commit()`, `learning_curves()`, `new_agent(...)`) don't match what's available in the test module, look at neighboring tests for the exact builder/event-construction pattern in current use and mirror it. The structure (build two Histories, add identical events, compare curves) is the contract; the surface syntax must follow what already works in this test file.
- [ ] **Step 4: Run the new tests**
Run: `cargo test --lib history_propagates_convergence_to_inner_run_chain history_with_damping_reaches_same_fixed_point_as_undamped`
Expected: 2 passed.
**Fallback if Test 1 fails** (`max_iter=1` produces the same posteriors as default — meaning the inner loop converges in one iteration on this graph): replace `max_iter: 1` with `max_iter: 0`. With `max_iter = 0` the inner loop body runs zero times, guaranteeing different posteriors than convergence.
**Fallback if Test 2 fails** (`max_diff` exceeds `1e-3`): raise `max_iter: 200` to `max_iter: 500`. Heavier damping needs more iterations to reach the same fixed point.
If neither fallback works, STOP and report BLOCKED with the actual `max_diff` and the iteration counts tried.
- [ ] **Step 5: Run the full test suite**
Run: `cargo test --lib && cargo test`
Expected: lib count = 100 (was 98), integration count = 27 (unchanged), all passing.
- [ ] **Step 6: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 7: Commit**
```bash
git add src/convergence.rs src/history.rs
git commit -m "$(cat <<'EOF'
test(history): end-to-end ConvergenceOptions propagation tests
Two integration tests on a 4-team ranked event:
- max_iter=1 set on HistoryBuilder produces measurably different
posteriors than default, proving the inner loop honors the
propagated max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
alpha=1.0, proving damping doesn't break correctness on the History
path
Also updates the alpha doc comment to clarify it applies only to the
within-game EP loop, not the outer cross-history sweep.
EOF
)"
```
---
## Self-review (writer's note)
**Spec coverage:**
- Spec § "What ships" item 1 (TimeSlice convergence field) → Task 1 step 2 ✓
- Spec § "What ships" item 2 (TimeSlice::new signature) → Task 1 step 3 ✓
- Spec § "What ships" item 3 (History passes self.convergence) → Task 1 step 4 ✓
- Spec § "What ships" item 4 (Event::iteration_direct gains parameter) → Task 2 step 1 ✓
- Spec § "What ships" item 4 (callers pass self.convergence) → Task 2 steps 2, 3 ✓
- Spec § "What ships" item 5 (TimeSlice::convergence-method reads field) → Task 2 step 4 ✓
- Spec § "What ships" item 6 (log_evidence reads field) → Task 2 step 5 ✓
- Spec § "What ships" item 7 (test callsite updates) → Task 1 step 5 ✓
- Spec § "Design" rename method → Task 1 step 6 ✓
- Spec § "Risks" alpha doc-comment update → Task 3 step 1 ✓
- Spec § "Testing strategy" §1 (regression net) → Tasks 1 step 7, 2 step 6, 3 step 5 ✓
- Spec § "Testing strategy" §2 (history_propagates_convergence) → Task 3 step 3 test 1 ✓
- Spec § "Testing strategy" §2 (history_with_damping_reaches_same_fixed_point) → Task 3 step 3 test 2 ✓
**Out-of-scope items correctly absent:** No new `History`/`HistoryBuilder` methods, no `ConvergenceOptions` split, no `Damped` Schedule impl, no nat-param convergence switch.
**Type / signature consistency:**
- `TimeSlice::new(time, p_draw, convergence: ConvergenceOptions)` — Task 1 step 3 (def) and Task 1 step 4-5 (call sites) match ✓
- `iteration_direct(skills, agents, p_draw, convergence, arena)` — Task 2 step 1 (def) and steps 2, 3 (call sites) match ✓
- `iterate_to_convergence` — Task 1 step 6 ✓
- All `self.convergence` reads are field accesses, not method calls (the rename in Task 1 step 6 prevents ambiguity) ✓
**Two tasks (1 and 2) split rationale:** Task 1 wires the field but the inference path still uses hardcoded defaults (no behavioral change). Task 2 makes the field actually drive inference (behavioral change for non-default users). Each task is independently committable and the test suite is bit-equal at every checkpoint.
**No placeholders detected.**
@@ -0,0 +1,540 @@
# Per-Event `score_sigma` Override Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users specify a per-event score-sigma override on `Outcome::Scored`, defaulting to `HistoryBuilder::score_sigma` when not set.
**Architecture:** `Outcome::Scored` becomes a struct variant with an `Option<f64>` `sigma` field. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)` at ingest time, so downstream `EventKind::Scored.score_sigma` stays a plain `f64` and `TimeSlice` / `run_chain` need zero changes. Two new constructors (`Outcome::scores_with_sigma` and `EventBuilder::scores_with_sigma`) cover the override path; existing `scores(...)` keeps its signature.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-per-event-score-sigma-design.md`
## File map
| File | Why touched |
|---|---|
| `src/outcome.rs` | `Outcome::Scored` variant becomes a struct; pattern matches in `team_count`, `as_scores`, `as_ranks`; new `scores_with_sigma` constructor; existing `scores` constructor body adapts |
| `src/history.rs` | The single ingest pattern match at `:735` resolves `sigma.unwrap_or(self.score_sigma)`; three new end-to-end tests |
| `src/event_builder.rs` | New `scores_with_sigma` builder method |
## Pre-flight context for the implementer
- `Outcome` is `pub`. Currently a tuple-variant enum at `src/outcome.rs:18-21`. Changing `Scored(SmallVec)``Scored { scores, sigma }` is a breaking change to a public variant shape, acceptable in 0.1.x.
- Pattern-match callsite inventory across the workspace (verified by grep): only ONE site destructures the variant — `src/history.rs:735` (`crate::Outcome::Scored(scores) => { ... }`). Every other reference is either a constructor call (`Outcome::scores(...)`) or a string literal in a doc/error message. The constructors keep their existing signatures, so callsites don't need updating.
- `Outcome::scores(I)` constructor at `src/outcome.rs:44`: keep the signature `pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self`. Only the body changes (it now builds `Self::Scored { scores: ..., sigma: None }`).
- `as_scores`, `as_ranks`, `team_count` accessors at `src/outcome.rs:48-67`: their public signatures stay the same. Internal pattern matches adapt mechanically.
- `EventBuilder::scores(I)` at `src/event_builder.rs:79-82`: keep unchanged. The new `scores_with_sigma(I, f64)` lives next to it.
- `History::score_sigma` at `src/history.rs:165`: still the history-wide default. `HistoryBuilder::score_sigma(s)` builder method at `src/history.rs:82-89` stays as-is.
- `EventKind::Scored { score_sigma: f64 }` at `src/time_slice.rs:51`: already per-event-shaped. Don't touch.
- Test baseline: 100 lib + 27 integration tests, all passing.
---
### Task 1: `Outcome::Scored` becomes a struct variant + constructors
This is the foundational shape change. After this task: the new variant compiles, both `scores` and `scores_with_sigma` work on `Outcome` directly, but `History::add_events` (the only consumer that destructures the variant) hasn't yet been updated — Task 2 handles that.
**Files:**
- Modify: `src/outcome.rs` (variant shape, three pattern-match arms, two existing tests, three new tests, two constructors)
- [ ] **Step 1: Write failing tests for the new constructor**
In `src/outcome.rs`, inside the existing `#[cfg(test)] mod tests` block, add at the end:
```rust
#[test]
fn scores_with_sigma_round_trips() {
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
assert_eq!(o.team_count(), 2);
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
}
#[test]
fn scores_constructor_leaves_sigma_unset() {
// After the variant change, the public Outcome::scores constructor
// must build with sigma: None. We assert this indirectly via a match
// on the variant.
let o = Outcome::scores([3.0, 1.0]);
match o {
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
#[test]
fn scores_with_sigma_sets_sigma_some() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
match o {
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
#[test]
#[should_panic(expected = "score_sigma must be > 0.0")]
fn scores_with_sigma_rejects_zero() {
let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
}
```
- [ ] **Step 2: Run the new tests to verify they fail**
Run: `cargo test --lib outcome::tests`
Expected: 4 errors. The first three fail to compile (no `scores_with_sigma` function; pattern destructure on `Scored { ... }` doesn't match the current tuple variant). The last fails because `scores_with_sigma` doesn't exist.
- [ ] **Step 3: Change the variant shape and update the constructor + accessors**
In `src/outcome.rs`, replace the entire `Outcome` enum and `impl Outcome` block (currently `src/outcome.rs:16-68`) with:
```rust
/// Final outcome of a match.
///
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event.
///
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Outcome {
Ranked(SmallVec<[u32; 4]>),
Scored {
scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>,
},
}
impl Outcome {
/// `n`-team outcome where team `winner` won and everyone else tied for last.
///
/// Panics if `winner >= n`.
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();
Self::Ranked(ranks)
}
/// All `n` teams tied.
pub fn draw(n: u32) -> Self {
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
}
/// Explicit per-team ranking.
pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
Self::Ranked(ranks.into_iter().collect())
}
/// Explicit per-team continuous scores; higher = better.
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: None,
}
}
/// Explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` must be `> 0.0`; debug-asserts otherwise.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
Self::Scored {
scores: scores.into_iter().collect(),
sigma: Some(sigma),
}
}
pub fn team_count(&self) -> usize {
match self {
Self::Ranked(r) => r.len(),
Self::Scored { scores, .. } => scores.len(),
}
}
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
match self {
Self::Ranked(r) => Some(r),
Self::Scored { .. } => None,
}
}
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
match self {
Self::Scored { scores, .. } => Some(scores),
Self::Ranked(_) => None,
}
}
}
```
- [ ] **Step 4: Run the new tests**
Run: `cargo test --lib outcome::tests`
Expected: all outcome tests pass (the 6 pre-existing tests + 4 new = 10 total in the outcome tests module).
If any pre-existing test fails, the issue is in this task — not Task 2. Most likely cause: a pattern-match arm in the rewritten `impl Outcome` block doesn't compile. Re-check the struct-variant destructure syntax (`Self::Scored { scores, .. }` for read-only access; `Self::Scored { scores, sigma }` when both fields are needed).
- [ ] **Step 5: Update `History::add_events` ingest arm to destructure the new variant**
The variant change from Step 3 breaks the existing `Outcome::Scored(scores)` pattern match in `src/history.rs:735`. Fix it now (in the same commit) — the codebase must build at every commit boundary.
In `src/history.rs`, find the `crate::Outcome::Scored(scores) => { ... }` arm (currently at `src/history.rs:735-740`). Replace with:
```rust
crate::Outcome::Scored { scores, sigma } => {
let resolved = sigma.unwrap_or(self.score_sigma);
debug_assert!(
resolved > 0.0,
"resolved score_sigma must be > 0.0 (got {resolved})"
);
kinds.push(EventKind::Scored {
score_sigma: resolved,
});
scores.to_vec()
}
```
The surrounding `match &ev.outcome { ... }` and the surrounding flow (the `ranks` arm above, the `results.push(event_result);` below) stay unchanged.
- [ ] **Step 6: Run the full library test suite — bit-equal regression net**
Run: `cargo build && cargo test --lib && cargo test`
Expected: clean build. All 100 lib + 27 integration tests pass. Bit-equal goldens — every existing scored-event constructor uses the no-override path (`Outcome::scores(...)` or `EventBuilder::scores(...)`), which now resolves to `sigma: None → resolved = self.score_sigma`, exactly equal to the previous behavior.
If unexpected additional compile errors surface (any site pattern-matching `Outcome::Scored(...)` outside the 735 arm), STOP and report — the plan's inventory is wrong, surface that as a finding before continuing.
If any existing test fails: investigate. Most likely cause is a typo in the new pattern arms (Step 3) or the resolution rule (Step 5). The override path isn't exercised yet by any existing test, so the only thing that can break is the inheritance path.
- [ ] **Step 7: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 8: Commit**
```bash
git add src/outcome.rs src/history.rs
git commit -m "$(cat <<'EOF'
feat(outcome): per-event score_sigma override on Outcome::Scored
Outcome::Scored shape changes from tuple to struct:
{ scores, sigma: Option<f64> }. New constructor scores_with_sigma
sets sigma=Some(s) and debug-asserts s > 0.0; existing scores(I)
constructor keeps its signature and builds with sigma=None internally.
team_count, as_scores, as_ranks accessor pattern matches updated.
History::add_events resolves sigma.unwrap_or(self.score_sigma) at the
ingest arm, so downstream EventKind::Scored stays a plain f64 and
TimeSlice / run_chain need zero changes.
Breaking change to the public Outcome::Scored variant shape
(acceptable in 0.1.x). Bit-equal for callers using the no-override
path because the resolution falls through to self.score_sigma exactly
as before.
EOF
)"
```
---
### Task 2: `EventBuilder::scores_with_sigma` builder method
The override path is fully wired by Task 1, but it's only reachable via the `Outcome::scores_with_sigma` constructor (passed into `History::add_events` directly). The fluent-builder ergonomic — `h.event(t).team(...).scores_with_sigma(scores, sigma).commit()` — needs one new method on `EventBuilder`.
**Files:**
- Modify: `src/event_builder.rs` (new builder method)
- [ ] **Step 1: Add the EventBuilder method**
In `src/event_builder.rs`, find the existing `scores` method (currently at `src/event_builder.rs:79-82`). Immediately below it (still inside `impl<'h, T, D, O, K> EventBuilder<...>`), add:
```rust
/// Set explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
/// Must be `> 0.0`; debug-asserts otherwise via `Outcome::scores_with_sigma`.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
self
}
```
- [ ] **Step 2: Build and run the test suite**
Run: `cargo build && cargo test --lib && cargo test`
Expected: clean build, all 100 lib + 27 integration tests pass. The new method is additive — no behavior changes for existing tests.
- [ ] **Step 3: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 4: Commit**
```bash
git add src/event_builder.rs
git commit -m "$(cat <<'EOF'
feat(event_builder): expose scores_with_sigma fluent method
Adds EventBuilder::scores_with_sigma, the fluent-builder ergonomic
mirror of Outcome::scores_with_sigma. Lets users write
h.event(t).team(...).team(...).scores_with_sigma([..], sigma).commit()
to set a per-event score_sigma override.
EOF
)"
```
---
### Task 3: End-to-end integration tests
**Files:**
- Modify: `src/history.rs` (three new tests in the existing `#[cfg(test)] mod tests` block at the bottom)
- [ ] **Step 1: Locate the test module**
Run: `grep -n "^#\[cfg(test)\]" src/history.rs`
Identify the test module (there should be one near the bottom of the file). Read its imports and look at neighboring tests to see the existing builder/event-construction pattern in current use. Mirror that pattern in the new tests below — the surface syntax (`History::builder()`, `event(t).team(...)`, `learning_curves()`, etc.) must match what already works in this file.
- [ ] **Step 2: Write the failing tests**
Add the following three tests at the end of the existing `#[cfg(test)] mod tests` block in `src/history.rs` (just before the module's closing `}`):
```rust
#[test]
fn outcome_scores_default_sigma_uses_history_default() {
use crate::Outcome;
// Path A: explicit sigma=0.5 via override.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 0.5),
}])
.unwrap();
h_a.converge().unwrap();
// Path B: history-wide default 0.5, no per-event override.
let mut h_b = crate::History::builder().score_sigma(0.5).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_b.converge().unwrap();
// Inheritance: posteriors must be bit-equal.
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing in path B");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
}
#[test]
fn outcome_scores_with_sigma_overrides_history_default() {
use crate::Outcome;
// Path A: history-wide default 0.5, per-event override 2.0.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
}])
.unwrap();
h_a.converge().unwrap();
// Path B: history-wide default 2.0, no per-event override.
let mut h_b = crate::History::builder().score_sigma(2.0).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_b.converge().unwrap();
// Override == default-set-to-the-override-value: bit-equal.
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing in path B");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
// Path C: history-wide default 0.5, no override. Different sigma → different posteriors.
let mut h_c = crate::History::builder().score_sigma(0.5).build();
h_c.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_c.converge().unwrap();
let curves_c = h_c.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, a_pts) in curves_a.iter() {
let c_pts = curves_c.get(key).expect("agent missing in path C");
for (a, c) in a_pts.iter().zip(c_pts.iter()) {
max_diff = max_diff.max((a.1.mu() - c.1.mu()).abs());
max_diff = max_diff.max((a.1.sigma() - c.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"override should produce different posteriors from inherited default; max_diff={max_diff}"
);
}
#[test]
fn event_builder_scores_with_sigma_threading() {
use crate::Outcome;
// Path A: builder fluent API with sigma override.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.event(0_i64)
.team(["a"])
.team(["b"])
.scores_with_sigma([3.0, 1.0], 2.0)
.commit()
.unwrap();
h_a.converge().unwrap();
// Path B: same outcome via the explicit Outcome constructor.
let mut h_b = crate::History::builder().score_sigma(0.5).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
}])
.unwrap();
h_b.converge().unwrap();
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
}
```
If the surface API (e.g. `History::add_events`, `Event { time, teams, outcome }`, `Team::with_members`, `Member::new`, `event(...).team(...).commit()`, `learning_curves()`) doesn't exactly match what's available in the test module, look at neighboring tests for the patterns currently in use and adjust. The CONTRACT is: build two Histories that should produce identical posteriors, run them, compare. The surface syntax must follow what compiles in this file.
- [ ] **Step 3: Run the new tests**
Run: `cargo test --lib outcome_scores_default_sigma_uses_history_default outcome_scores_with_sigma_overrides_history_default event_builder_scores_with_sigma_threading`
Expected: 3 passed.
**Fallback if Test 2's `max_diff > 1e-6` fails** (sigma=0.5 vs sigma=2.0 produces nearly identical posteriors — unlikely on a single 2-team scored event, but possible if the priors dominate): use a larger gap, e.g. `Outcome::scores_with_sigma([3.0, 1.0], 5.0)` vs `Outcome::scores([3.0, 1.0])` with `score_sigma(0.5)`. The point is to prove the resolution path actually engages — any sigma gap that produces a measurable posterior difference is fine.
- [ ] **Step 4: Run the full test suite**
Run: `cargo test --lib && cargo test`
Expected: lib count = 103 (was 100, +3), integration count = 27 (unchanged), all passing.
- [ ] **Step 5: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 6: Commit**
```bash
git add src/history.rs
git commit -m "$(cat <<'EOF'
test(history): end-to-end per-event score_sigma override tests
Three integration tests on a 2-team scored event:
- inheritance: Outcome::scores(...) with no override produces
bit-equal posteriors to the same outcome wrapped in
scores_with_sigma(scores, history.score_sigma)
- override-supersedes-default: scores_with_sigma(scores, X) with
history score_sigma(Y) produces bit-equal posteriors to
scores(...) with history score_sigma(X), AND differs measurably
from scores(...) with history score_sigma(Y)
- builder threading: EventBuilder::scores_with_sigma reaches the
ingest path identically to the Outcome constructor
EOF
)"
```
---
## Self-review (writer's note)
**Spec coverage:**
- Spec § "What ships" item 1 (Scored becomes struct variant) → Task 1 step 3 ✓
- Spec § "What ships" item 2 (scores_with_sigma constructor) → Task 1 step 3 ✓
- Spec § "What ships" item 3 (EventBuilder::scores_with_sigma) → Task 2 step 1 ✓
- Spec § "What ships" item 4 (sigma resolution at ingest) → Task 1 step 5 ✓
- Spec § "What ships" item 5 (pattern-match update inventory) → Task 1 step 5 (single site at history.rs:735) ✓
- Spec § "Validation" (debug_assert at constructor) → Task 1 step 3 (in `scores_with_sigma`) ✓
- Spec § "Validation" (debug_assert at ingest) → Task 1 step 5 ✓
- Spec § "Testing strategy" §1 (regression net) → Task 1 step 6, Task 2 step 2, Task 3 step 4 ✓
- Spec § "Testing strategy" §2 test 1 (default-uses-history-default) → Task 3 step 2 test 1 ✓
- Spec § "Testing strategy" §2 test 2 (override-supersedes-default) → Task 3 step 2 test 2 ✓
- Spec § "Testing strategy" §2 test 3 (builder threading) → Task 3 step 2 test 3 ✓
**Out-of-scope items correctly absent:** No `EventKind::Scored` change, no `TimeSlice`/`run_chain` changes, no `Game::scored` standalone API change, no deprecation of `HistoryBuilder::score_sigma`.
**Type / signature consistency:**
- `Outcome::Scored { scores: SmallVec<[f64; 4]>, sigma: Option<f64> }` — Task 1 step 3 (def) and Task 1 step 5 (destructure) match ✓
- `Outcome::scores_with_sigma<I>(scores: I, sigma: f64) -> Outcome` — Task 1 step 3 (def) and Task 2 step 1 (call) match ✓
- `EventBuilder::scores_with_sigma<I>(mut self, scores: I, sigma: f64) -> Self` — Task 2 step 1 (def) and Task 3 step 2 test 3 (call) match ✓
- `sigma.unwrap_or(self.score_sigma)` resolution rule — Task 1 step 5 ✓
**Task split rationale:** Task 1 lands the foundational shape change AND the ingest resolution atomically — every commit boundary builds and tests pass bit-equal. Task 2 is the small additive EventBuilder method, separated for review-focus reasons (it's the user-facing fluent API exposure). Task 3 is purely additive integration tests. Each task is independently committable; no intermediate non-building state.
**No placeholders detected.**
@@ -0,0 +1,444 @@
# Tech Debt Cleanup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Land three independent post-T4-MarginFactor cleanups: dedupe `Game::likelihoods` and `Game::likelihoods_scored` via a `run_chain` helper, make `BuiltinFactor::log_evidence` exhaustive, and fix stale numerics in the T4 plan doc.
**Architecture:** Pure code-shape and doc fixes. No public-API change, no behavioral change, no new dependencies. The dedup is a pure refactor — bit-equal posteriors and evidence against existing test goldens. The exhaustive match is a future-proofing change with no runtime effect. The doc fix is two number swaps in prose plus one matching code-comment swap.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-tech-debt-cleanup-design.md`
## File map
| File | Why touched |
|---|---|
| `src/game.rs` | Add `run_chain` helper; rewrite `likelihoods` and `likelihoods_scored` to call it |
| `src/factor/mod.rs` | Make `BuiltinFactor::log_evidence` match exhaustive |
| `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` | Fix two stale prose numbers and one matching code comment |
---
### Task 1: Extract `run_chain` helper, dedupe both likelihoods methods
**Files:**
- Modify: `src/game.rs:236-485` (replace both `likelihoods` and `likelihoods_scored` with one helper + two thin callers)
**Context for the implementer (read this before touching anything):**
`OwnedGame<T, D>` (defined at `src/game.rs:83-92`) holds `teams`, `result`, `weights`, `p_draw`, plus mutable output fields `likelihoods: Vec<Vec<Gaussian>>` and `evidence: f64`. Two private methods on `Game<'a, T, D>` (the borrowed sibling at `src/game.rs:148-156`) compute likelihoods:
- `likelihoods(&mut self, arena: &mut ScratchArena)` — ranked outcomes; `src/game.rs:236-371`
- `likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` — scored outcomes; `src/game.rs:373-485`
The two are bit-identical except for the closure that builds the per-diff `DiffFactor` (defined at `src/game.rs:20-54`). `DiffFactor` has two variants: `Trunc(TruncFactor)` for ranked, `Margin(MarginFactor)` for scored.
The shared body does, in order: `arena.reset()`, sort teams descending by `result` into `arena.sort_buf`, fill `arena.team_prior`, build `links: Vec<DiffFactor>` (the differing block), resize `arena.lhood_lose` / `arena.lhood_win` to `N_INF`, run a forward+backward sweep with a max-iter-10 fixed-point loop guarded by `tuple_gt(step, 1e-6)`, handle the `n_diffs == 1` special case, do boundary updates, multiply per-diff `evidence()` into `self.evidence`, build the inverse permutation in `arena.inv_buf`, then build `self.likelihoods` from the per-team `lhood_win * lhood_lose` and per-player `performance().exclude(...).forget(beta²)` math.
**Refactor target:**
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
mut make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
{ /* the entire shared body, returning (evidence, likelihoods) */ }
```
Helper takes `&self` (not `&mut self`) so the closure can capture `&self.result`, `&self.teams`, `&self.weights`, `&self.p_draw` without conflicting with the helper's own immutable borrow. The arena is borrowed `&mut` independently.
The closure is invoked once per diff index `i ∈ 0..n_diffs`, after `arena.sort_buf` is filled. It receives `i`, `&arena.sort_buf[..]`, and `&mut arena.vars` so it can `alloc(N_INF)` the diff `VarId`. It returns the constructed `DiffFactor`.
The two callers shrink to:
```rust
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let p_draw = self.p_draw;
let result = &self.result;
let teams = &self.teams;
let (evidence, likelihoods) = Self::dummy_to_satisfy_borrowck(/* see below */);
// ... assigns self.evidence and self.likelihoods
}
```
Wait — actually borrow-checker note: calling `self.run_chain(arena, |i, sort_buf, vars| { use_self_fields })` from a `&mut self` method is **fine** because `run_chain` takes `&self` and the closure captures `&self` immutably. Both share an immutable reborrow of `*self`. The arena is a separate `&mut` borrow. Verify the implementer doesn't accidentally make `run_chain` take `&mut self`.
**Why a closure (not a trait, not a two-phase build).** A closure keeps caller-specific state (`p_draw`, `score_sigma`, beta sums) inline at the call site with zero ceremony. A trait would require a stateful builder per call. A two-phase build (caller produces `Vec<DiffFactor>` first, helper does the rest) would either re-do the sort or split arena ownership awkwardly between the phases.
---
- [ ] **Step 1: Run the existing test suite to capture the baseline**
Run: `cargo test --lib`
Expected: all tests pass. Note the count (should be 88+ lib tests) — the refactor must keep this number unchanged with all green.
- [ ] **Step 2: Open `src/game.rs` and add the `run_chain` helper**
Inside `impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> { ... }` (the block starting at `src/game.rs:158`), add `run_chain` immediately above the existing `likelihoods` method (so above line 236). Use exactly this body — it is the merge of the two existing methods with the differing block replaced by the closure call:
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
mut make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
{
arena.reset();
let n_teams = self.teams.len();
arena.sort_buf.extend(0..n_teams);
arena.sort_buf.sort_by(|&i, &j| {
self.result[j]
.partial_cmp(&self.result[i])
.unwrap_or(Ordering::Equal)
});
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
self.teams[t]
.iter()
.zip(self.weights[t].iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
}));
let n_diffs = n_teams.saturating_sub(1);
let mut links: Vec<DiffFactor> = (0..n_diffs)
.map(|i| make_link(i, &arena.sort_buf, &mut arena.vars))
.collect();
arena.lhood_lose.resize(n_teams, N_INF);
arena.lhood_win.resize(n_teams, N_INF);
let mut step = (f64::INFINITY, f64::INFINITY);
let mut iter = 0;
while tuple_gt(step, 1e-6) && iter < 10 {
step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
step = tuple_max(step, d);
let new_ll = pw - lf.msg();
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
arena.lhood_lose[e + 1] = new_ll;
}
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
step = tuple_max(step, d);
let new_lw = pl + lf.msg();
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
arena.lhood_win[e] = new_lw;
}
iter += 1;
}
if n_diffs == 1 {
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
- (arena.team_prior[1] * arena.lhood_win[1]);
arena.vars.set(links[0].diff(), raw * links[0].msg());
links[0].propagate(&mut arena.vars);
}
if n_diffs > 0 {
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
arena.lhood_win[0] = pl1 + links[0].msg();
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
}
let evidence: f64 = links.iter().map(|l| l.evidence()).product();
arena.inv_buf.resize(n_teams, 0);
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
arena.inv_buf[orig_i] = si;
}
let likelihoods = self
.teams
.iter()
.zip(self.weights.iter())
.enumerate()
.map(|(orig_i, (players, weights))| {
let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si];
let performance = players
.iter()
.zip(weights.iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
players
.iter()
.zip(weights.iter())
.map(|(player, &w)| {
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
.forget(player.beta.powi(2))
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
(evidence, likelihoods)
}
```
- [ ] **Step 3: Replace `likelihoods` body with a thin caller**
In `src/game.rs`, replace the entire body of `fn likelihoods(&mut self, arena: &mut ScratchArena)` (currently lines 236-371 — replace from the opening `{` to the closing `}` of that method) with:
```rust
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let p_draw = self.p_draw;
// Capture pointers to fields the closure reads, to keep borrow scopes tight.
// Closure captures &self.result and &self.teams (both immutable) and the
// &mut arena passed in via run_chain — disjoint from `&self`.
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
let margin = if p_draw == 0.0 {
0.0
} else {
let a: f64 = self.teams[sort_buf[i]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
let b: f64 = self.teams[sort_buf[i + 1]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
compute_margin(p_draw, (a + b).sqrt())
};
let vid = vars.alloc(N_INF);
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
});
self.evidence = evidence;
self.likelihoods = likelihoods;
}
```
(Capturing `p_draw` as a local binding before the closure avoids a `self.p_draw` borrow inside; it's a `Copy` `f64` so this is free.)
- [ ] **Step 4: Replace `likelihoods_scored` body with a thin caller**
In `src/game.rs`, replace the entire body of `fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` (currently lines 373-485) with:
```rust
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
// After descending-by-score sort, m_obs >= 0 for every adjacent pair.
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
let vid = vars.alloc(N_INF);
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
});
self.evidence = evidence;
self.likelihoods = likelihoods;
}
```
- [ ] **Step 5: Build to confirm it compiles**
Run: `cargo build`
Expected: compiles cleanly. If the borrow checker complains that the closure conflicts with `self.run_chain(...)`, the most likely cause is `run_chain` accidentally being `&mut self` — confirm its signature is `fn run_chain<F>(&self, arena: &mut ScratchArena, mut make_link: F) -> (f64, Vec<Vec<Gaussian>>)`. If that's correct and there's still a conflict, double-check the closure's captures: it should capture `&self.result` and `&self.teams` (immutable), `p_draw: f64` by value (Copy), and `score_sigma: f64` by value (Copy). It must NOT touch `&mut self` in any form.
- [ ] **Step 6: Run the full library test suite — must be all green, same count as Step 1**
Run: `cargo test --lib`
Expected: same number of tests as Step 1, all pass. Bit-equal goldens — every existing assertion (`test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, MarginFactor end-to-end tests, etc.) must pass unchanged. If ANY test fails, the refactor is wrong; revert and re-inspect.
- [ ] **Step 7: Run integration tests too**
Run: `cargo test`
Expected: all integration tests pass (28 noted in commit `8b53cac`).
- [ ] **Step 8: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
Expected: no diffs from fmt, no clippy warnings.
- [ ] **Step 9: Commit**
```bash
git add src/game.rs
git commit -m "$(cat <<'EOF'
refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
Both methods were 95-line near-duplicates differing only in the closure
that builds the per-diff DiffFactor. Extract the shared body as a
private run_chain<F>(&self, arena, make_link) helper that returns
(evidence, likelihoods); the two callers shrink to ~10 lines each.
Pure code-shape change: posteriors and evidence remain bit-equal; all
existing tests (lib + integration) pass unchanged.
EOF
)"
```
---
### Task 2: Make `BuiltinFactor::log_evidence` match exhaustive
**Files:**
- Modify: `src/factor/mod.rs:94-100` (the `log_evidence` impl on `BuiltinFactor`)
- [ ] **Step 1: Open `src/factor/mod.rs` and replace the `log_evidence` body**
Replace the existing impl:
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
_ => 0.0,
}
}
```
with:
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
```
- [ ] **Step 2: Build and run tests**
Run: `cargo build && cargo test --lib`
Expected: compiles cleanly, all tests pass. Behavior is unchanged — `TeamSum` and `RankDiff` still return `0.0`, but a future variant will now produce a non-exhaustive-match error instead of being silently swallowed.
- [ ] **Step 3: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
Expected: no diffs, no warnings.
- [ ] **Step 4: Commit**
```bash
git add src/factor/mod.rs
git commit -m "$(cat <<'EOF'
refactor: make BuiltinFactor::log_evidence match exhaustive
Replace the `_ => 0.0` wildcard with explicit
`Self::TeamSum(_) | Self::RankDiff(_) => 0.0`. No behavioral change;
future variants now produce a compile error instead of being silently
absorbed by the wildcard.
EOF
)"
```
---
### Task 3: Fix stale numerics in T4 plan doc
**Files:**
- Modify: `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` (lines 52 and 185)
The shipped test in `src/factor/mod.rs:163,166` asserts:
```
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
```
The plan's prose at line 52 quotes pre-shipped values that no longer match. This task fixes the prose and the matching code-comment. The full-precision assertion blocks elsewhere in the plan are out of scope (they belong to the plan-as-written, and the spec's fix table only listed the rounded prose values).
- [ ] **Step 1: Update the prose at line 52**
Open `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`. Find the line:
```
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.046827`. So `log_evidence ≈ -3.0613`.
```
Replace with:
```
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.04678`. So `log_evidence ≈ -3.0622`.
```
- [ ] **Step 2: Update the matching code-comment at line 185**
In the same file, find:
```
// pdf(5, 0, sqrt(37)) ≈ 0.046827
```
Replace with:
```
// pdf(5, 0, sqrt(37)) ≈ 0.04678
```
- [ ] **Step 3: Verify nothing else changed**
Run: `git diff docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
Expected: exactly three lines changed (one prose line containing both numbers, one comment line). Nothing else should be touched.
- [ ] **Step 4: Commit**
```bash
git add docs/superpowers/plans/2026-04-27-t4-margin-factor.md
git commit -m "$(cat <<'EOF'
docs: fix stale numerics in t4-margin-factor plan
The plan's prose quoted Z_cav ≈ 0.046827 and log_evidence ≈ -3.0613,
which diverged from the values asserted by the shipped test in
src/factor/mod.rs (-3.062235327364623). Update prose and the matching
code comment to 0.04678 / -3.0622.
EOF
)"
```
---
## Self-review (writer's note)
Spec coverage:
- Spec Item 1 (dedupe `likelihoods`/`likelihoods_scored`) → Task 1 ✓
- Spec Item 2 (exhaustive `BuiltinFactor::log_evidence`) → Task 2 ✓
- Spec Item 3 (stale numerics in T4 plan) → Task 3 ✓
- Spec out-of-scope items (`DiffFactor` collapse, per-event `score_sigma`) — correctly absent ✓
Verification gates per the spec ("each item commits independently and ships behind a green `cargo test --lib`"): every task ends in fmt + clippy + tests + commit. Task 1 additionally runs `cargo test` for integration coverage.
Type / signature consistency:
- `run_chain` signature appears identically in the context header and Step 2 body ✓
- Closure type `FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor` matches across Step 2 (definition) and Steps 3/4 (call sites) ✓
- `DiffFactor::Trunc` / `DiffFactor::Margin` constructors match `src/game.rs:20-23` definitions ✓
No placeholders detected.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,619 @@
# TrueSkill-TT Engine Redesign — Design
**Date:** 2026-04-23
**Status:** Approved (pending implementation plan)
## Summary
Comprehensive redesign of the TrueSkill-TT engine targeting four orthogonal goals:
1. **Performance** — substantially faster offline convergence and incremental online updates.
2. **Accuracy and richer match formats** — support for score margins, free-for-all with partial orders, correlated skills.
3. **Better convergence** — replace ad-hoc capped iteration with a pluggable `Schedule` trait covering all three nested loops.
4. **Better API surface** — typed event description, observer-based progress reporting, generic time axis, structured errors, ergonomic builders.
The design is comprehensive (Approach 1 of three considered) but delivered in five tiers so each step is independently shippable and validated by benchmarks.
## Goals & non-goals
**Goals**
- 1030× speedup on the offline convergence path for representative workloads (1000+ players, 1000+ events, 30 iterations)
- Order-of-magnitude speedup on incremental "add a single event" workloads
- Pluggable factor graph allowing new factor types without engine changes
- Optional Rayon-backed parallelism on top of `Send + Sync`-correct internals
- Typed, ergonomic public API; replace nested `Vec<Vec<Vec<_>>>` shapes with `Event<T, K>` / `Team<K>` / `Member<K>`
- Generic time axis: `Untimed`, `i64`, or user-supplied
- Observer-based progress instead of `verbose: bool` + `println!`
- Structured `Result<_, InferenceError>` at API boundaries
**Non-goals**
- WebAssembly support is not a goal; we may break it if a crate or feature requires.
- No GPU offload.
- No `no_std` support.
- No persistent format / serde — possible future feature.
- No replacement of the Gaussian/EP approximation itself in this design (the underlying inference math stays the same; we change layout, dispatch, scheduling, and API around it).
## Workload assumptions
Baseline workload that drives perf decisions:
- ~1000+ players
- ~1000+ events total
- ~5060 events per time slice (per day)
- Both online (incremental adds) and offline (full convergence) are common
- Offline convergence runs frequently
## Section 1 — Core types & traits
The foundation everything else builds on.
### `Gaussian` — natural-parameter storage
Switch storage from `(mu, sigma)` to natural parameters `(pi, tau)` where `pi = sigma⁻²`, `tau = mu · pi`. Multiplication and division dominate the hot path; in nat-params they are direct adds/subs of the components, no `sqrt`. Reads of `mu`/`sigma` become accessor methods (`tau / pi`, `1.0 / pi.sqrt()`). The trade is correct because reads are vanishingly rare compared to writes in EP.
```rust
pub struct Gaussian { pi: f64, tau: f64 }
pub const UNIFORM: Gaussian = Gaussian { pi: 0.0, tau: 0.0 }; // replaces N_INF
```
### `Time` trait
Replaces the bare `i64` time field. Keeps `History` parametric.
```rust
pub trait Time: Copy + Ord + Send + Sync + 'static {
fn elapsed_to(&self, later: &Self) -> i64;
}
pub struct Untimed; // ZST for the no-time-axis case
impl Time for Untimed { fn elapsed_to(&self, _: &Self) -> i64 { 0 } }
impl Time for i64 { fn elapsed_to(&self, later: &Self) -> i64 { later - self } }
// Optional impls behind feature flags: time::OffsetDateTime, chrono types
```
### `Drift<T>` trait
Generic over `T: Time` so seasonal/calendar-aware drift is possible without going through `i64`.
```rust
pub trait Drift<T: Time>: Copy + Send + Sync {
fn variance_delta(&self, from: &T, to: &T) -> f64;
}
```
`ConstantDrift(f64)` impl: `to.elapsed_to(from) as f64 * gamma * gamma`.
### `Index` and `KeyTable<K>`
`Index(usize)` is the handle into dense per-`History` `Vec` storage. Public, but intended for use by power users on hot paths who want to skip the `KeyTable` lookup. Casual API takes `&K`. `KeyTable<K>` (renamed from `IndexMap`, to avoid colliding with the `indexmap` crate's type) maps user keys → `Index`.
### `Observer` trait
Replaces `verbose: bool` + `println!`. Default no-op impls; user overrides what they need.
```rust
pub trait Observer<T: Time>: Send + Sync {
fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {}
fn on_batch_processed(&self, _time: &T, _idx: usize, _n_events: usize) {}
fn on_converged(&self, _iters: usize, _final_step: (f64, f64)) {}
}
pub struct NullObserver;
impl<T: Time> Observer<T> for NullObserver {}
```
### Trade-offs
- `Gaussian` natural-param representation: anyone reading `mu`/`sigma` in a hot loop pays a sqrt — but that's correct, hot reads are rare.
- `Time` as a trait (not enum) keeps it open-ended at zero runtime cost; default `History<i64, _>` keeps the call sites familiar.
- `Observer` is a trait (not a closure) so different sites can have different signatures without losing type safety. `NullObserver` is a ZST.
## Section 2 — Factor graph architecture
The current `Game::likelihoods` is a hand-rolled, hard-coded graph. To unlock richer formats and let us experiment with EP schedules, the graph itself becomes a data structure.
### Variable / Factor model
Variables hold their current Gaussian marginal. Factors hold their outgoing messages to each connected variable plus do the local computation. Standard EP: factor's update is "divide marginal by old outgoing → cavity → apply local approximation → multiply marginal by new outgoing."
```rust
pub trait Factor: Send + Sync {
fn variables(&self) -> &[VarId];
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64); // returns max delta
fn log_evidence(&self, _vars: &VarStore) -> f64 { 0.0 }
}
```
### Built-in factor catalog
| Factor | Purpose | Status |
|---|---|---|
| `PerformanceFactor` | skill → performance (add β² noise, optional weight) | replaces inline `performance() * weight` |
| `TeamSumFactor` | weighted sum of player perfs → team perf | replaces inline `fold` |
| `RankDiffFactor` | (team_a perf) (team_b perf) → diff var | currently `team[e].posterior_win() team[e+1].posterior_lose()` |
| `TruncFactor` | EP truncation: `P(diff > margin)` or `P(|diff| < margin)` for draws | wraps current `v_w` / `approx` |
| `MarginFactor` *(future)* | use observed score margin as soft evidence | enables richer match formats |
| `SynergyFactor` *(future)* | couples teammates' skills | enables different topology |
| `ScoreFactor` *(future)* | continuous outcome (e.g., points scored) | enables score-based outcomes |
The first four together exactly reproduce today's algorithm. The last three are extension slots.
### Game = factor graph + schedule
```rust
pub struct Game<S: Schedule = DefaultSchedule> {
vars: VarStore, // SoA: Vec<Gaussian> marginals
factors: FactorList, // enum dispatch over BuiltinFactor (see Open Questions)
schedule: S,
}
```
Lean toward **enum dispatch** (`enum BuiltinFactor { Perf(...), Sum(...), RankDiff(...), Trunc(...), ... }`) over `Box<dyn Factor>` for the built-ins:
- avoids per-message vtable overhead in the hottest loop
- keeps factor data inline (no heap indirection)
- still allows user-defined factors via a `BuiltinFactor::Custom(Box<dyn Factor>)` variant
### Schedule trait
Controls iteration order and stopping. Default = current behavior (sweep forward, then backward, until ε or max iters). Pluggable so we can later try damped EP or junction-tree schedules.
### High-level constructors
```rust
Game::ranked(teams, results, options) // dominant case
Game::free_for_all(players, ranking) // FFA with possible ties
Game::custom(builder) // power users build their own graph
```
`GameOptions` carries iteration cap, epsilon, p_draw, and approximation choice. Today these are scattered between method args and module constants.
### Trade-offs
- Enum dispatch over trait objects for built-ins; richer factors drop in via new enum variants.
- Variables and factor messages stored as `Vec<Gaussian>` indexed by `VarId` / edge slot — flat, cache-friendly.
- `Schedule` is a generic parameter (zero-cost); most users get default; experimentation is open.
### Open question
Whether `enum BuiltinFactor` will feel too closed-world. The `Custom(Box<dyn Factor>)` escape hatch helps but inner-loop perf for user factors will be slower. Acceptable for now; flagged for future revisit if it becomes a problem.
## Section 3 — Storage layout (SoA + arenas)
### Dense Vec keyed by `Index`
Every `HashMap<Index, T>` becomes a `Vec<T>` (or `Vec<Option<T>>` for sparse) indexed directly by `Index.0`. The public-facing `KeyTable<K>` continues to map arbitrary keys → `Index`.
### SoA at hot layers, AoS at boundaries
The `Skill` struct stays as a public type for the API (returned from `learning_curves`, etc.), but inside `TimeSlice` we lay it out column-wise:
```rust
struct TimeSliceSkills {
forward: Vec<Gaussian>, // [n_agents]
backward: Vec<Gaussian>,
likelihood: Vec<Gaussian>,
online: Vec<Gaussian>,
elapsed: Vec<i64>,
present: Vec<bool>,
}
```
Within a slice, the inner loops touch one column repeatedly across many events — keeping the column contiguous improves cache utilization and makes the eventual SIMD step (Section 6) straightforward.
`Gaussian` itself stays as a single 16-byte struct in the `Vec<Gaussian>`. Splitting into two parallel `Vec<f64>`s wins for pure SIMD over thousands of Gaussians but loses for the random-access patterns dominant in EP. Revisit if benchmarks demand it.
### Arena allocator inside `Game`
Replace per-event allocations with a `ScratchArena` reused across calls.
```rust
pub struct ScratchArena {
var_buf: Vec<Gaussian>,
factor_buf: Vec<Gaussian>, // edge messages
bool_buf: Vec<bool>,
f64_buf: Vec<f64>,
}
impl ScratchArena {
fn reset(&mut self); // sets len=0, keeps capacity
fn alloc_vars(&mut self, n: usize) -> &mut [Gaussian];
}
```
`TimeSlice` owns one `ScratchArena`; each event borrows it for the duration of its `Game` construction and inference. For the parallel-slice story (Section 6), each Rayon task gets its own arena.
### Per-event storage layout
Inside a `TimeSlice`, each event is stored column-wise as well, with `Item` inlined into team-level parallel arrays:
```rust
struct EventStorage {
teams: SmallVec<[TeamStorage; 4]>,
outcome: Outcome,
weights: SmallVec<[SmallVec<[f64; 4]>; 4]>,
evidence: f64,
}
struct TeamStorage {
competitors: SmallVec<[Index; 4]>, // who's on the team
edge_messages: SmallVec<[Gaussian; 4]>, // outgoing message per slot
output: f64,
}
```
Iteration over `(competitor, edge_message)` pairs zips two slices — no per-element struct.
### SmallVec for typical shapes
Teams ≤ ~5 players, games ≤ ~8 teams. `SmallVec<[T; 8]>` for team membership and `SmallVec<[T; 4]>` for team rosters keeps the common case allocation-free.
### Trade-offs
- Dense `Vec<T>` keyed by `Index` is faster but means agent removal needs tombstones (or just leaves slots present-but-inactive). Acceptable: TrueSkill histories rarely remove players.
- SoA at `TimeSlice` level only, not at `History` level. `History` keeps `Vec<TimeSlice>` because slices are heterogeneous in size.
- One `ScratchArena` per `TimeSlice` keeps the lifetime story simple.
### Open question
The `TimeSliceSkills` sketch above uses (b) **dense + present mask**: one slot per agent in the history, indexed directly by `Index`, with a `present: Vec<bool>` mask for batches the agent didn't participate in. The alternative is (a) **sparse columnar**: a `Vec<Index>` of present agents and parallel `Vec<Gaussian>` columns of length `n_present`, with a separate lookup (binary search or auxiliary table) to find a given `Index`'s slot.
(b) gives O(1) lookup and SIMD-friendly columns but wastes memory for sparsely populated slices. (a) is leaner per-slice but pays per-lookup cost in the inner loop. Bench both during T0 and pick. Default proposal: (b), since modern systems are memory-rich and the parallelism story is cleaner.
## Section 4 — API surface
### Typed event description
```rust
pub struct Event<T: Time, K> {
pub time: T,
pub teams: SmallVec<[Team<K>; 4]>,
pub outcome: Outcome,
}
pub struct Team<K> {
pub members: SmallVec<[Member<K>; 4]>,
}
pub struct Member<K> {
pub key: K,
pub weight: f64, // default 1.0
pub prior: Option<Rating>, // per-event override
}
pub enum Outcome {
Ranked(SmallVec<[u32; 4]>), // rank per team; equal ranks = tie
Scored(SmallVec<[f64; 4]>), // continuous score per team (engages MarginFactor)
}
```
`Outcome::winner(0)`, `Outcome::draw()`, `Outcome::ranking([0,1,2])` are convenience constructors.
### Builders
```rust
let mut history = History::<i64, _>::builder()
.mu(25.0).sigma(25.0/3.0).beta(25.0/6.0)
.drift(ConstantDrift(0.03))
.p_draw(0.10)
.convergence(ConvergenceOptions { max_iter: 30, epsilon: 1e-6 })
.observer(LogObserver::default())
.build();
```
For the no-time case, type inference picks `Untimed`:
```rust
let mut history = History::<Untimed, _>::builder().build();
```
### Three-tier event ingestion
```rust
// 1. Bulk ingestion (high-throughput path)
history.add_events(events_iter)?;
// 2. One-off match (very common in practice)
history.record_winner("alice", "bob", time)?;
history.record_draw("alice", "bob", time)?;
// 3. Builder for irregular shapes
history.event(time)
.team(["alice", "bob"]).weights([1.0, 0.7])
.team(["carol"])
.ranking([1, 0])
.commit()?;
```
### Convergence & queries
```rust
let report: ConvergenceReport = history.converge()?;
let curve: Vec<(i64, Gaussian)> = history.learning_curve(&"alice");
let all = history.learning_curves(); // HashMap<&K, Vec<(T, Gaussian)>>
let now = history.current_skill(&"alice"); // Option<Gaussian>
let ev = history.log_evidence();
let ev_for = history.log_evidence_for(&["alice", "bob"]);
let q = history.predict_quality(&[&["alice"], &["bob"]]);
let p_win = history.predict_outcome(&[&["alice"], &["bob"]]);
```
### Standalone Game
```rust
let g = Game::ranked(&[&[alice], &[bob]], Outcome::winner(0), &options);
let post = g.posteriors();
// Convenience
let (a, b) = Game::one_v_one(&alice, &bob, Outcome::winner(0));
```
### Errors
Replace `debug_assert!`/`panic!` at the API boundary with `Result`.
```rust
pub enum InferenceError {
MismatchedShape { kind: &'static str, expected: usize, got: usize },
InvalidProbability { value: f64 },
ConvergenceFailed { last_step: (f64, f64), iterations: usize },
NegativePrecision { pi: f64 },
}
```
Hot inner loops still use `debug_assert!` for invariants the API has already enforced.
### Trade-offs
- Generic over user's `K`; engine works in `Index`. Public outputs use `&K`.
- `SmallVec` everywhere on the event-description path.
- Three-tier API so casual users don't drown in types and bulk users still get throughput.
- `Outcome` enum replaces the "lower number wins" `&[f64]` convention.
### Open question
Whether to expose `Index` directly to users via an `intern_key(&K) -> Index` method, letting hot-path callers skip the `KeyTable` lookup on every call. Recommendation: yes — public `Index` handle plus `history.lookup<Q: Borrow<K>>(&Q) -> Option<Index>`. The casual API still takes `&K` everywhere; power users can promote to `Index` when profiling demands.
## Section 4½ — Naming pass
| Current | New | Rationale |
|---|---|---|
| `History` | `History` (kept) | Matches upstream; reads cleanly. |
| `Batch` | `TimeSlice` | Says what it is: every event sharing one timestamp. |
| `Player` | `Rating` | The struct holds prior/beta/drift — that's a rating configuration. Resolves the `Player`/`Agent` confusion. |
| `Agent` | `Competitor` | Holds dynamic state for someone competing in the history; fits the domain. |
| `Skill` | `Skill` (kept) | Per-time-slice skill estimate; clearer than `BatchSkill`. |
| `Item` | inlined into `TeamStorage` columns (engine) / `Member<K>` (public) | Eliminates the per-element struct in the hot path; gives API users a clear "team member" name. |
| `Game` | `Game` (kept) | `Match` collides with Rust's `match`. |
| `Index` | `Index` (kept) | Internal handle. |
| `IndexMap` | `KeyTable` | Avoids confusion with the `indexmap` crate. |
## Section 5 — Convergence & message scheduling
### Three nested loops, one mechanism
The system has three nested convergence loops:
1. Within-game: EP sweeps over the factor graph
2. Within-time-slice: re-running games as inputs change
3. Cross-history: forward-pass then backward-pass over all slices
All three implement `Workload`; one `Schedule` impl drives all of them.
```rust
pub trait Schedule {
fn run<W: Workload>(&self, workload: &mut W) -> ScheduleReport;
}
pub trait Workload {
fn step(&mut self) -> (f64, f64);
fn snapshot_evidence(&self) -> f64 { 0.0 }
}
pub struct ScheduleReport {
pub iterations: usize,
pub final_step: (f64, f64),
pub converged: bool,
}
```
### Built-in schedules
| Schedule | Behavior | Use |
|---|---|---|
| `EpsilonOrMax { eps, max }` | Default. Sweep until `(dpi, dtau) ≤ eps` or `max` iters. | All three loops. Replicates current behavior. |
| `Damped { eps, max, alpha }` | Same, but writes `α·new + (1−α)·old`. | Stuck oscillations. |
| `Residual { eps, max }` | Priority-queue: re-update factor with largest pending delta first. | Faster convergence on uneven graphs. |
| `OneShot` | Exactly one pass, no convergence check. | Online incremental adds. |
### Stopping in natural-param space
Switch from `(|Δmu|, |Δsigma|) ≤ epsilon` to `(|Δpi|, |Δtau|) ≤ (eps_pi, eps_tau)`:
- `mu` and `sigma` are on different scales; one tolerance is wrong for both
- We store in nat-params anyway — checking convergence in mu/sigma costs free sqrts
- Nat-param delta is the natural geometry of the EP fixed point
Default `EpsilonOrMax::default()` exposes a single `epsilon` for simplicity; advanced ctor exposes both tolerances.
### Within-game improvements
- Replace hard-cap of 10 iterations with `GameOptions::schedule` that propagates `ScheduleReport` upward
- Fast path: graphs with no diff chain (1v1 with 1 iter sufficient) skip the loop entirely
- FFA / many-team ranks benefit from `Residual`; opt-in
### Within-slice and cross-history improvements
- **No more old/new HashMap snapshotting**: track deltas inline as we write under SoA
- **Per-slice dirty bits**: a `TimeSlice` whose neighbor messages haven't changed since its last full sweep doesn't need to re-run. Track `time_slice.dirty` and skip clean ones during the cross-history sweep. Big win for online-add (the locality case).
### `ConvergenceReport`
```rust
pub struct ConvergenceReport {
pub iterations: usize,
pub final_step: (f64, f64),
pub log_evidence: f64,
pub converged: bool,
pub per_iteration_time: SmallVec<[Duration; 32]>,
pub batches_skipped: usize,
}
```
`Observer` continues to receive per-iteration callbacks for live UI; `ConvergenceReport` is the post-hoc summary.
### Trade-offs
- One `Schedule` trait shared across loops — fewer concepts, more composable.
- Convergence checks in nat-param space — slightly different exact threshold than today; tests' epsilons re-tuned mechanically.
- Dirty-bit skipping changes iteration order vs. today; fixed point is the same, iteration counts may shift downward.
- `Residual` and `Damped` are opt-in; default behavior matches today closely.
### Open question
Whether `Schedule::run` should take an optional `Observer` reference. Recommendation: observation lives at a higher layer (`History::converge` calls observer hooks; `Schedule` is purely the loop driver).
## Section 6 — Concurrency & parallelism
### What's parallelizable
| Operation | Parallelism | Strategy |
|---|---|---|
| `History::converge()` (full forward+backward) | Sequential across slices | Within each slice: color-group events in parallel via Rayon |
| `History::add_events(...)` | Sequential append, but ingestion of typed events into `EventStorage` parallelizes trivially | n/a |
| `History::learning_curves()` | Per-key parallel | `into_par_iter()` |
| `History::log_evidence_for(targets)` | Per-batch parallel, reduce sum | `par_iter().map(...).sum()` |
| `Game` inference | Sequential | n/a (too small to amortize Rayon overhead) |
### Within-slice color-group parallelism
When events are added to a slice, partition them into color groups where events in the same color touch no shared `Index`. Within a color, run events in parallel via Rayon. Across colors, run sequentially. Preserves asynchronous-EP semantics exactly.
Alternative: synchronous EP with snapshot. All events read from a frozen skill snapshot, write deltas to thread-local buffers, barrier merges. Trivially parallel but weaker per-iteration convergence — needs damping. Available as a `Schedule` impl, opt-in.
### `Send + Sync` requirements
All public traits (`Time`, `Drift`, `Observer`, `Factor`, `Schedule`) require `Send + Sync`. `Observer` impls must be thread-safe (called from arbitrary worker threads).
### Rayon as default-on feature
`rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`.
### Expected speedup ballpark
For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations:
| Source | Estimated speedup vs. today |
|---|---|
| `HashMap` → dense `Vec` | 24× |
| Natural-param `Gaussian`, no-sqrt mul/div | 1.52× |
| Pre-allocated `ScratchArena` | 1.21.5× |
| Color-group parallel events in slice (8 cores) | 24× |
| Dirty-bit slice skipping (online add case) | 550× |
| **Combined (offline converge)** | ~1030× |
| **Combined (online add)** | ~50500× depending on locality |
These are pre-implementation estimates. Each tier validates with criterion.
### Trade-offs
- Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap.
- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only.
- Cross-slice sweep stays sequential; no speculative parallel sweeps.
- Rayon default-on but feature-gated.
### Open question
Whether to expose color-group partitioning to users. Recommendation: hidden by default, escape hatch via `add_events_with_partition(...)` for power users who already know their event independence.
## Section 7 — Migration, testing, and delivery plan
The crate is unreleased, so version-bump ceremony doesn't apply. Tiers are sequencing of work and milestones, not releases.
### Tier sequence
**T0 — Numerical parity (no API change)**
Internal-only. Public surface unchanged.
- Switch `Gaussian` storage to natural parameters `(pi, tau)`. `mu()`/`sigma()` become accessors.
- Replace `HashMap<Index, _>` with dense `Vec<_>` keyed by `Index.0` everywhere.
- Introduce `ScratchArena` inside `Batch` so `Game::new` stops allocating per-event.
- Drop the `panic!` in `mu_sigma`; return `Result` propagated upward.
**Acceptance:** existing test suite passes (bit-equal where possible, ULP-bounded where natural-param arithmetic shifts a rounding); `cargo bench` shows ≥3× win on `batch` benchmark; no API breakage.
**T1 — Factor graph machinery (internal-only)**
- Introduce `Factor`, `VarStore`, `Schedule` as `pub(crate)` types.
- Re-implement `Game::likelihoods()` on top of `BuiltinFactor::{Perf, TeamSum, RankDiff, Trunc}` driven by `EpsilonOrMax`.
- Replace within-game iteration tracking with `ScheduleReport`.
**Acceptance:** existing test suite passes (ULP-bounded); within-game iteration counts unchanged; benchmarks ≥ T0.
**T2 — New API surface (breaking)**
All renames and the new public API land together. No half-renamed intermediate state.
- New types: `Rating`, `TimeSlice`, `Competitor`, `Member<K>`, `Outcome`, `Event<T, K>`, `KeyTable<K>`.
- `Time` trait introduced; `History<T: Time, D: Drift<T>>` is generic.
- Three-tier API surface: `record_winner`, `event(...).team(...).commit()`, bulk `add_events(iter)`.
- `Observer` trait + `ConvergenceReport`; `verbose: bool` deleted.
- `panic!`/`debug_assert!` at API boundary become `Result<_, InferenceError>`.
- Promote `Factor`/`Schedule`/`VarStore` to `pub` under a `factors` module.
**Acceptance:** full test suite rewritten in new API; equivalence tests prove identical posteriors vs. old API on the same inputs.
**T3 — Concurrency**
- `Send + Sync` audit and bounds on all public traits.
- Color-group partitioning at `TimeSlice` ingestion.
- `rayon` as default-on feature with `#[cfg(feature = "rayon")]` fallback.
- Parallel paths: within-slice color groups, `learning_curves`, `log_evidence_for`.
**Acceptance:** deterministic posteriors across `RAYON_NUM_THREADS={1,2,4,8}`; benchmarks show >2× on 8-core for offline converge.
**T4 — Richer factor types & schedules**
Each shipped independently after T3.
- `MarginFactor` → enables `Outcome::Scored`. **Done** (see `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`).
- `Damped` and `Residual` schedules.
- `SynergyFactor`, `ScoreFactor` → same pattern when wanted.
Each comes with its own benchmark and a worked example in `examples/`.
### Testing strategy
| Layer | Approach |
|---|---|
| **Numerical correctness** | Keep existing hardcoded golden values from `test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, etc. through T0T1 unchanged. They are a regression net against the original Python port. |
| **API parity** | T2 adds an `equivalence` test module that runs identical inputs through old vs. new construction and compares posteriors within ULPs. |
| **Property tests** | Add `proptest` for: factor graph fixed-point invariance under message order, `Outcome` round-trip, `Gaussian` mul/div associativity in nat-params, schedule convergence regardless of starting state. |
| **Determinism** | T3 adds tests that run identical input across multiple Rayon thread counts and assert identical posteriors. |
| **Benchmark gates** | Each tier has a "must not regress" gate vs. the previous tier on the existing `batch` and `gaussian` criterion suites. T0 must beat baseline by ≥3×; T1 ≥ T0; etc. |
### Risk management
- **T0 risk: rounding drift in tests.** Mitigation: where natural-param arithmetic legitimately changes the last ULPs, update goldens *and* simultaneously add a parity test against a snapshot taken from baseline to prove the difference is bounded.
- **T2 risk: API design mistakes.** Mitigation: review the spec and a worked example before implementing; iterate on feedback.
- **T3 risk: subtle race conditions in color-group partitioning.** Mitigation: `loom` tests for the merge step; deterministic-output assertion across thread counts.
- **Cross-tier risk: scope creep.** Each tier has a closed checklist; new ideas go to the next tier's wishlist.
### What we're explicitly *not* doing
- No GPU offload.
- No `no_std` support.
- No serde / persistence in this design.
- No incremental online API beyond `record_winner` / `add_events`.
## Open questions summary
Collected here for the review pass:
1. **`enum BuiltinFactor` extensibility** — may feel too closed-world; revisit if user-defined factors via `Custom(Box<dyn Factor>)` become common.
2. **Sparse vs. dense per-slice skill storage** — default to dense + `present` mask; sparse columnar is the alternative. Decided by T0 benchmarks.
3. **`Index` exposure for hot paths** — expose `intern_key`/`lookup` so power users can promote `&K` to `Index` and skip the `KeyTable` lookup; casual API still takes `&K` everywhere.
4. **`Schedule::run` and observer wiring** — observation stays at higher layer (`History::converge` calls observer hooks; `Schedule` is purely the loop driver).
5. **Color-group partition exposure** — hidden by default, escape hatch via `add_events_with_partition(...)`.
@@ -0,0 +1,320 @@
# Damped EP — Game-Local Damping
## Summary
Add an opt-in EP damping knob to within-game inference. Users set
`ConvergenceOptions::alpha < 1.0` to damp message updates and stabilise
oscillating fixed-point loops on hard graphs. `alpha = 1.0` (the default)
is bit-equal to today.
This is the smallest-scope realisation of the spec's `Damped` schedule:
**game-local**, not plumbed through the `Schedule` trait. The `Schedule`
trait is shipped infrastructure that `run_chain` does not currently call;
wiring `Schedule` into game inference is a separate future task. This
design touches only what the user can actually reach via `GameOptions`.
## Scope
### What ships
1. New field `ConvergenceOptions::alpha: f64` (default `1.0`).
2. `run_chain` reads `options.convergence.{epsilon, max_iter, alpha}`
instead of the hardcoded `1e-6` / `10` / undamped — fixes the existing
latent bug where the first two were already on `GameOptions` but never
read by inference.
3. `Gaussian::damp_natural(self, new, alpha) -> Gaussian` — public helper
computing `α·new + (1−α)·self` in natural-parameter space.
4. `TruncFactor` and `MarginFactor` gain inherent
`propagate_with_alpha(&mut self, vars, alpha) -> (f64, f64)`. Their
`Factor::propagate` impls become one-line delegations passing
`alpha = 1.0`.
5. `DiffFactor::propagate` (game-private enum at `src/game.rs:20-54`)
gains an `alpha: f64` parameter and dispatches into the underlying
factor's `propagate_with_alpha`.
### What does not ship
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait stays as
it is; integration with `run_chain` is a separate task.
- No nat-param convergence switch. `(|Δmu|, |Δsigma|)` stays the
delta basis (matches today). The spec's "stopping in natural-param
space" wants its own design pass and test re-tuning.
- No oscillation auto-detect. `alpha` is user-supplied and constant for
the duration of a `run_chain` call.
- No `Residual`, `OneShot`, or `SynergyFactor` / `ScoreFactor` work —
separate future plans.
## Design
### `ConvergenceOptions::alpha`
```rust
// src/convergence.rs
#[derive(Clone, Copy, Debug)]
pub struct ConvergenceOptions {
pub max_iter: usize,
pub epsilon: f64,
pub alpha: f64,
}
impl Default for ConvergenceOptions {
fn default() -> Self {
Self {
max_iter: crate::ITERATIONS,
epsilon: crate::EPSILON,
alpha: 1.0,
}
}
}
```
`alpha = 1.0` ⇒ undamped (bit-equal to today). Recommended starting
point if a graph oscillates: `0.5``0.7`. Values approaching `0.0` make
each step tinier and slow convergence; `alpha = 0.0` is degenerate
(factor never updates). Validation in `run_chain`:
```rust
debug_assert!(
opts.convergence.alpha > 0.0 && opts.convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
```
### `Gaussian::damp_natural`
```rust
impl Gaussian {
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
///
/// Used by within-game schedules to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update.
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(),
alpha * new.tau() + (1.0 - alpha) * self.tau(),
)
}
}
```
Public on `Gaussian`. The name encodes the WHY (EP damping); the doc
comment fixes the math. No new dependency.
The existing `Mul<f64> for Gaussian` is **distribution scaling**
(`sigma → sigma·|scalar|`), not nat-param interpolation, so it can't be
reused here.
### `TruncFactor::propagate_with_alpha`
```rust
impl TruncFactor {
pub(crate) fn propagate_with_alpha(
&mut self,
vars: &mut VarStore,
alpha: f64,
) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
}
let trunc = approx(cavity, self.margin, self.tie);
let new_msg = trunc / cavity;
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg;
self.msg = damped;
// marginal_new = cavity * stored_msg (NOT cavity * new_msg with damping)
vars.set(self.diff, cavity * damped);
old_msg.delta(damped)
}
}
impl Factor for TruncFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0)
}
}
```
Two important points:
- The variable receives `cavity * damped` (i.e. `cavity * self.msg`),
not `trunc`. With `alpha = 1.0` these are equal (since
`cavity * new_msg = trunc` by construction), so today's behaviour is
preserved bit-equal. With `alpha < 1.0` the marginal reflects the
partially-applied update.
- The reported delta is `old_msg.delta(damped)` — delta of the actually
stored message, not of the raw `new_msg`. This is the textbook EP
damping convention: the convergence loop measures the trajectory it
is actually walking.
`MarginFactor` follows the same shape, with its own
`propagate_with_alpha` body (the existing `propagate` math, with the
`damp_natural` step inserted in the same place and the var write
switched to `cavity * damped`).
### `DiffFactor::propagate` signature
```rust
// src/game.rs
impl DiffFactor {
pub(crate) fn propagate(
&mut self,
vars: &mut VarStore,
alpha: f64,
) -> (f64, f64) {
match self {
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
}
}
}
```
`DiffFactor` is `pub(crate)` and only used inside `run_chain`, so the
signature change has no public-API impact.
### `run_chain` changes
Inside `Game::run_chain` (`src/game.rs:236-348`):
1. Capture `let alpha = opts.convergence.alpha;` once at the top
(avoids repeated `opts.convergence.alpha` lookups in the hot loop).
2. Replace the loop guard
`while tuple_gt(step, 1e-6) && iter < 10`
with
`while tuple_gt(step, opts.convergence.epsilon) && iter < opts.convergence.max_iter`.
3. Replace each `lf.propagate(&mut arena.vars)` call site (three of
them: forward sweep, backward sweep, `n_diffs == 1` special case)
with `lf.propagate(&mut arena.vars, alpha)`.
The threading of `opts: &GameOptions` into `run_chain` is the only
new caller obligation. Today `run_chain` doesn't take `opts`; the two
callers (`likelihoods`, `likelihoods_scored`) currently invoke it
without options. Both will need to pass the options through. The
`Game<'a, T, D>` struct does not currently hold `GameOptions`; the
options are constructed and discarded around the call to
`{ranked,scored}_with_arena`. So:
- `Game::ranked_with_arena` and `Game::scored_with_arena` already
receive `p_draw` / `score_sigma` as scalar params; we extend them to
accept `&ConvergenceOptions` (or the full `&GameOptions`) too.
- `likelihoods` / `likelihoods_scored` either store the options on
`Game` or accept them as method parameters and forward to
`run_chain`.
The simplest plumbing: store `convergence: ConvergenceOptions` as a
field on `Game<'a, T, D>` and `OwnedGame<T, D>` populated at
construction time. Then `run_chain` can read it from `&self`.
## Convergence semantics
With `alpha < 1.0` the per-step update shrinks; convergence may take
more iterations to reach the same `epsilon` threshold. Users who damp
should also raise `max_iter` accordingly. Documentation example:
```rust
let mut opts = GameOptions::default();
opts.convergence.alpha = 0.5;
opts.convergence.max_iter = 30;
```
## Testing strategy
### Regression net (no new file)
The existing 88 lib tests and 27 integration tests are the bit-equal
regression net. With `alpha = 1.0` (the default), every assertion must
pass unchanged. If any test fails, the damping path leaked into the
undamped trajectory.
### New tests
1. **`Gaussian::damp_natural` arithmetic**
(`src/gaussian.rs` test mod):
- `α = 1.0` returns `new` exactly (bit-equal `pi` and `tau`).
- `α = 0.0` returns `self` exactly.
- `α = 0.5`: pi and tau are exact midpoints in nat-param space.
- Three asserts, no new file.
2. **`TruncFactor::propagate_with_alpha` shrinks the step**
(`src/factor/trunc.rs` test mod):
- Set up a TruncFactor step. Run `propagate_with_alpha(α=1.0)` once,
record `delta_undamped` and the resulting `self.msg`.
- Reset to a fresh factor at the same starting state. Run
`propagate_with_alpha(α=0.5)` once, record `delta_damped` and
`damped_msg`.
- Assert: `damped_msg.pi()` equals `0.5 * undamped_msg.pi() + 0.5 * initial_msg.pi()` within 1e-12 (and same for `tau`).
- Assert: `delta_damped.0 <= delta_undamped.0` (mu-delta is no larger; the relationship is monotone in `α` but not strictly `0.5×` for the `delta()` function which is `(|Δmu|, |Δsigma|)`).
3. **`MarginFactor::propagate_with_alpha` parity**
(`src/factor/margin.rs` test mod):
- Same shape as #2, on a `MarginFactor` step.
4. **`run_chain` honours `ConvergenceOptions::max_iter`**
(in an existing or new game-level test):
- Construct a 4-team ranked game that normally converges in ~5 iterations.
- Set `opts.convergence.max_iter = 1`. Assert the per-iteration
`step` returned (or observable indirectly via posterior delta vs.
the converged answer) is non-zero — i.e. the loop stopped early.
- Set `opts.convergence.max_iter = 30`. Assert posteriors match the
baseline within `epsilon`.
5. **Damping default is `1.0` and produces bit-equal output**
(smoke test, can be a single assertion in an existing test):
- `assert_eq!(ConvergenceOptions::default().alpha, 1.0);`
- Existing goldens prove the bit-equality.
No oscillation-stabilisation test (would require constructing a
pathological graph specifically to oscillate; out of scope for a
minimal ship).
## Verification gates
Per task:
```bash
cargo +nightly fmt
cargo clippy --all-targets -- -D warnings
cargo test --lib
cargo test
```
All must succeed. Test count grows by exactly the new tests above
(roughly +58 lib tests).
## Risks
- **Marginal-update change is subtle.** Switching the variable write
from `trunc` to `cavity * damped` is intentionally a no-op when
`alpha = 1.0` (since `cavity * new_msg = trunc`), but it changes the
arithmetic path. If `Gaussian` arithmetic has any non-associativity
in floating-point that the old form happened to dodge, goldens could
shift by 1 ULP. Mitigation: TDD — write the regression test (run all
existing tests with `alpha = 1.0`) **first**, before changing the
variable-write line.
- **`run_chain` signature change ripples to two callers.** Trivial
but must be done atomically with the field addition on `Game` /
`OwnedGame`.
- **`alpha` validation only in debug builds.** A release build will
silently accept `alpha = 0.0` or `alpha > 1.0` and produce nonsense.
This matches the existing pattern (`debug_assert!` for input
validation in `Game::ranked_with_arena`); upgrading to `Result` is
out of scope.
## Out-of-scope follow-ups (logged for future plans)
- Wire `Schedule` into `run_chain` (so `Damped` lands as a real
`Schedule` impl alongside `EpsilonOrMax`).
- Switch convergence check to `(|Δpi|, |Δtau|)` per spec
§"Stopping in natural-param space".
- Oscillation auto-detect (engage `alpha < 1.0` only after N
non-monotone steps).
- `Residual` schedule (priority queue).
- `SynergyFactor`, `ScoreFactor` (new EP factor types).
@@ -0,0 +1,232 @@
# History → TimeSlice ConvergenceOptions Plumbing
## Summary
Make `History`'s already-public `ConvergenceOptions` (set via
`HistoryBuilder::convergence(...)`) actually reach the within-game
inference loop. Today it's read by the outer `History::converge` sweep
but dropped on the floor when constructing `TimeSlice`s, so users who
opt in to `alpha < 1.0` (Damped EP) on a `History` get nothing — the
inner `run_chain` calls inside `TimeSlice` hardcode
`ConvergenceOptions::default()`.
This spec closes the gap with one focused change: thread
`ConvergenceOptions` from `History` through `TimeSlice` to the three
`Game::*_with_arena` callsites in `time_slice.rs`. No new types, no new
public methods on `History` or `HistoryBuilder` — the user-facing API
already exists.
## Background
After T5 (commit `0705986`) of the Damped EP plan,
`Game::*_with_arena` accepts `convergence: ConvergenceOptions` and
`run_chain` reads `self.convergence.{epsilon, max_iter, alpha}`.
`HistoryBuilder` already has a `convergence(opts)` method (`history.rs:91`)
that stores onto a field on `History`. `History::converge` reads
`self.convergence.{max_iter, epsilon}` for its outer cross-history loop
(`history.rs:437-447`).
The break is here, in `History::add_events_with_prior` at `history.rs:597`:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw);
```
`self.convergence` is not passed. `TimeSlice` has no convergence field.
The three callsites in `time_slice.rs` that build `Game::*_with_arena`
fall back to `ConvergenceOptions::default()`:
- `Event::iteration_direct` (`time_slice.rs:138-156`)
- `TimeSlice::convergence` (`time_slice.rs:332-345`)
- `TimeSlice::log_evidence` (`time_slice.rs:521-538`)
## Scope
### What ships
1. `TimeSlice<T>` gains a `pub(crate) convergence: ConvergenceOptions`
field set at construction.
2. `TimeSlice::new` signature becomes
`pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self`.
3. `History::add_events_with_prior` (`history.rs:597`) passes
`self.convergence` when constructing new `TimeSlice`s.
4. `Event::iteration_direct` gains a `convergence: ConvergenceOptions`
parameter and forwards it to the `Game::*_with_arena` callsite.
The two callers (`TimeSlice::iteration` at `time_slice.rs:419` and
`:441`) pass `self.convergence`.
5. `TimeSlice::convergence` (the method, not the field) replaces its
hardcoded `crate::ConvergenceOptions::default()` with
`self.convergence`.
6. `TimeSlice::log_evidence` does the same.
7. Five test callsites of `TimeSlice::new(time, p_draw)` updated
mechanically to `TimeSlice::new(time, p_draw, ConvergenceOptions::default())`.
### What does not ship
- No split of `ConvergenceOptions` into outer/inner fields. The
conflation (one `max_iter` covers both the cross-history sweep and
the per-game EP iteration cap) is the user-confirmed design.
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait is still
not integrated into `run_chain`.
- No nat-param convergence switch.
- No oscillation auto-detect.
- No new `History` or `HistoryBuilder` methods. `convergence(opts)`
already exists and works.
- No changes to `History::converge` — the outer-loop semantics are
unchanged (it already reads `self.convergence`).
## Design
### `TimeSlice<T>` field
```rust
// src/time_slice.rs
pub struct TimeSlice<T: Time = i64> {
// ... existing fields ...
p_draw: f64,
pub(crate) convergence: ConvergenceOptions,
// ... existing fields ...
}
```
### `TimeSlice::new`
```rust
impl<T: Time> TimeSlice<T> {
pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self {
Self {
// ... existing initialisation ...
p_draw,
convergence,
// ...
}
}
}
```
### `History::add_events_with_prior` — single-line fix
At `src/history.rs:597`:
```rust
// before
let mut time_slice = TimeSlice::new(t, self.p_draw);
// after
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
```
### `Event::iteration_direct` parameter
```rust
// src/time_slice.rs
impl Event {
pub(crate) fn iteration_direct(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<i64, ConstantDrift>,
p_draw: f64,
convergence: ConvergenceOptions,
arena: &mut ScratchArena,
) -> /* existing return */ {
// ... existing body, with the Game::*_with_arena calls
// using `convergence` instead of ConvergenceOptions::default() ...
}
}
```
The two callers — `TimeSlice::iteration` at `time_slice.rs:419` and
`:441` — already have `&mut self` access, so they pass
`self.convergence`.
### `TimeSlice::convergence` method (not the field)
The method `pub(crate) fn convergence<D>(&mut self, agents: ...) -> usize`
at `time_slice.rs:447` shares its name with the new field. Rust allows
this (methods and fields live in different namespaces), but it's a
readability hazard. Rename the method to `iterate_to_convergence` to
disambiguate.
This is one rename, six callsites in `history.rs` and the test module.
### Field semantics
`History` keeps the single shared `ConvergenceOptions` struct. The same
`max_iter` covers both the outer sweep and each inner per-game loop.
The same `epsilon` covers both stopping criteria. The `alpha` field is
read only inside `run_chain` (the inner loop); the outer loop
intentionally ignores `alpha` because cross-history damping is a
different mathematical concept and not in scope.
## Testing strategy
### Regression net
The existing 98 lib + 27 integration tests are the bit-equal regression
net. Default `ConvergenceOptions` is unchanged
(`max_iter=30, epsilon=1e-6, alpha=1.0`), and `TimeSlice` was already
using exactly that since T5. The only behavioural difference is for
users who actually pass non-default options through
`HistoryBuilder::convergence(...)` — and there are no current tests that
do that **and** compare posteriors, so all goldens stay bit-equal.
### New tests
1. **`history_propagates_convergence_to_inner_run_chain`** (in
`src/history.rs` test module):
- Build a History with `convergence(ConvergenceOptions { max_iter: 1, ..Default::default() })`.
- Add a small batch of events that needs more than one inner EP iteration to converge (e.g. a 4-team game per slice).
- `converge()`, capture posteriors.
- Build a fresh History with default options on the same events.
- `converge()`, capture posteriors.
- Assert the two sets of posteriors differ measurably (max diff > 1e-6).
- Proves the inner loop honours the propagated `max_iter`. Today (without this change) the assertion would fail because both Histories use default inside.
2. **`history_with_damping_reaches_same_fixed_point_as_undamped`** (same
test module):
- Build a History with `convergence(ConvergenceOptions { alpha: 0.5, max_iter: 200, ..Default::default() })`.
- Same events as above.
- `converge()`, capture posteriors.
- Build a default-options History on the same events.
- `converge()`, capture posteriors.
- Assert per-player posteriors agree within 1e-3.
- Proves damping doesn't break convergence on the History path.
If the second test's max diff is too large, raise `max_iter` further
(damping needs more iterations to reach the same fixed point).
## Verification gates
```bash
cargo +nightly fmt
cargo clippy --all-targets -- -D warnings
cargo test --lib
cargo test
```
All must succeed. Test count grows by exactly 2 (the two new tests).
## Risks
- **`TimeSlice::new` is `pub`.** Adding the third parameter is a
breaking change to a public constructor. In a 0.1.x crate this is
acceptable, but flag it in the commit message.
- **`TimeSlice::convergence` method rename.** Renaming
`convergence``iterate_to_convergence` touches `history.rs` and the
TimeSlice test module. The rename is mechanical and improves
readability where the field and method would otherwise share a name.
- **Cross-history alpha semantics.** A user who sets `alpha = 0.5` on
a `History` gets damping inside every per-game loop, but the outer
`History::converge` sweep is undamped. This is the correct semantic
(alpha is a within-EP-graph concept) but it's worth documenting in
the `ConvergenceOptions::alpha` doc comment so users don't expect
cross-slice damping. Add one sentence to the existing doc comment.
## Out-of-scope follow-ups
- Wire `Schedule` trait into `run_chain` — Damped becomes a `Schedule`
impl alongside `EpsilonOrMax`.
- Per-loop `ConvergenceOptions` split (outer / inner).
- `Residual` schedule.
- Per-event `EventKind::Scored.score_sigma` override (still
history-wide today).
@@ -0,0 +1,292 @@
# Per-Event `score_sigma` Override
## Summary
Let users specify a per-event noise override on `Outcome::Scored`.
Today every scored event in a `History` shares the single
`HistoryBuilder::score_sigma` value (default `1.0`); a user who wants
to say "this match was a clean blowout, trust the margin more" or
"this one was a disrupted scrappy game, trust it less" has no way to
do so.
The override is resolved at ingest time and stored as a plain `f64`
on the existing `EventKind::Scored { score_sigma }` payload, so
`TimeSlice` and `run_chain` need zero changes. The work is purely on
the public API surface: `Outcome::Scored` becomes a struct variant
with an `Option<f64> sigma` field; two builder methods on `Outcome`
and `EventBuilder` cover the explicit-override path.
## Background
`Outcome::Scored(SmallVec<[f64; 4]>)` is the public per-team-score
variant (`src/outcome.rs:20`). It's constructed via
`Outcome::scores(I)` (`src/outcome.rs:44`) or
`EventBuilder::scores(I)` (`src/event_builder.rs:79`).
When `History::add_events` ingests a Scored outcome, it always uses
the history-wide default:
```rust
// src/history.rs:735-740
crate::Outcome::Scored(scores) => {
kinds.push(EventKind::Scored {
score_sigma: self.score_sigma,
});
scores.to_vec()
}
```
The downstream `EventKind::Scored { score_sigma: f64 }`
(`src/time_slice.rs:51`) is already per-event-shaped — every Event
carries its own copy. The constraint is purely at the ingest boundary.
This was flagged as deferred tech debt during the T4-MarginFactor
work: "EventKind::Scored.score_sigma payload is always history-wide
today; per-event override deferred."
## Scope
### What ships
1. `Outcome::Scored` becomes a struct variant:
`Scored { scores: SmallVec<[f64; 4]>, sigma: Option<f64> }`.
`None` = use history default; `Some(s)` = override.
2. New constructor `Outcome::scores_with_sigma(scores, sigma)` on
`Outcome`. Existing `Outcome::scores(I)` keeps the same shape but
builds with `sigma: None`.
3. New builder method `EventBuilder::scores_with_sigma(scores, sigma)`
on `EventBuilder`.
4. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)`
when converting an `Outcome::Scored` to `EventKind::Scored`.
5. Mechanical pattern-match updates at every site that destructures
`Outcome::Scored(...)` as a tuple. Estimate ~510 sites across
`src/`, `tests/`, `examples/`, `benches/`.
### What does not ship
- No change to `EventKind::Scored` (already per-event).
- No change to `TimeSlice` or `run_chain`.
- No change to `Game::scored` standalone API
(it still takes `score_sigma` via `GameOptions::score_sigma`).
- No deprecation of `HistoryBuilder::score_sigma` — the history-wide
default is still useful as a common-case fallback.
## Design
### `Outcome` enum change
```rust
// src/outcome.rs
#[derive(Clone, Debug)]
pub enum Outcome {
Ranked(SmallVec<[u32; 4]>),
Scored {
scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>,
},
}
```
The variant shape changes from tuple to struct. Pattern matches that
extract the scores switch from `Outcome::Scored(scores)` to
`Outcome::Scored { scores, .. }` (or `{ scores, sigma }` where the
sigma is needed).
### `Outcome` constructors
```rust
impl Outcome {
/// Per-team continuous scores; uses HistoryBuilder::score_sigma default.
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: None,
}
}
/// Per-team scores with explicit per-event noise override.
///
/// `sigma` must be > 0.0; debug_assert.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(
scores: I,
sigma: f64,
) -> Self {
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
Self::Scored {
scores: scores.into_iter().collect(),
sigma: Some(sigma),
}
}
}
```
`Outcome::scores(I)` keeps the existing function signature exactly —
its only behavioural change is the internal struct construction. The
existing `as_scores()`, `team_count()`, etc. accessors keep their
public signatures (they return `Option<&[f64]>` and `usize`); their
internal pattern matches update mechanically.
### `EventBuilder` method
```rust
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
where
T: Time,
D: Drift<T>,
O: Observer<T>,
K: Eq + std::hash::Hash + Clone,
{
/// Per-team scores; uses HistoryBuilder::score_sigma default.
pub fn scores<I: IntoIterator<Item = f64>>(mut self, scores: I) -> Self {
self.event.outcome = crate::Outcome::scores(scores);
self
}
/// Per-team scores with explicit per-event noise override.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(
mut self,
scores: I,
sigma: f64,
) -> Self {
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
self
}
}
```
The existing `.scores(...)` builder method stays — its body changes
trivially because `Outcome::scores(I)` still has the same signature.
`.scores_with_sigma(...)` is the new method.
### Sigma resolution
In `History::add_events` at `src/history.rs:735`:
```rust
crate::Outcome::Scored { scores, sigma } => {
let resolved = sigma.unwrap_or(self.score_sigma);
debug_assert!(
resolved > 0.0,
"resolved score_sigma must be > 0.0 (got {resolved})"
);
kinds.push(EventKind::Scored {
score_sigma: resolved,
});
scores.to_vec()
}
```
Resolution at ingest time means downstream code keeps a plain `f64`.
No `Option` propagates further.
### Validation
- `Outcome::scores_with_sigma(_, sigma)` debug-asserts `sigma > 0.0`
at construction.
- `History::add_events` debug-asserts the resolved sigma is `> 0.0`
(catches both inherited and overridden paths).
- `HistoryBuilder::score_sigma(s)` keeps its existing positive
assertion.
The default sigma at the History level (`1.0`) is positive, so an
event with `sigma = None` against a default-built History always
passes the resolved-sigma assertion trivially.
### Pattern-match update inventory
Every site that destructures `Outcome::Scored(_)` as a tuple needs
updating. Known sites:
- `src/outcome.rs`: the `team_count()`, `as_scores()`, `as_ranks()`
match arms (`src/outcome.rs:51`, `:58`, `:64`).
- `src/history.rs:735`: the conversion arm (this is also where the
resolution rule lands).
- Any test in `src/outcome.rs` test mod that constructs
`Outcome::Scored(...)` literally.
- Any callsite in `src/`, `tests/`, `examples/`, `benches/`,
`src/game.rs` that pattern-matches the variant.
The compiler surfaces every site at `cargo build`. Locating them is
mechanical.
## Testing strategy
### Regression net
Existing 100 lib + 27 integration tests are the bit-equal regression
net for the `sigma = None` path. Every existing test that uses
`Outcome::scores(...)` or `EventBuilder::scores(...)` should
continue to produce identical posteriors — the resolved sigma equals
the history default (which equals what the hardcoded path produced).
### New tests
Three additions in the `src/history.rs` test module:
1. **`outcome_scores_default_sigma_uses_history_default`** — build a
History with `score_sigma(0.5)`, add a 2-team event via
`Outcome::scores([3.0, 1.0])` (no override), capture posteriors.
Build a second History identical except using
`Outcome::scores_with_sigma([3.0, 1.0], 0.5)` (override matches
default). Assert posteriors are bit-equal across the two paths.
2. **`outcome_scores_with_sigma_overrides_history_default`** — build a
History with `score_sigma(0.5)`, add an event via
`Outcome::scores_with_sigma([3.0, 1.0], 2.0)`. Build a second
History with `score_sigma(2.0)` and add the same event via
`Outcome::scores([3.0, 1.0])`. Assert posteriors are bit-equal.
Then build a third History with `score_sigma(0.5)` and add via
`Outcome::scores([3.0, 1.0])` (no override). Assert this third
one's posteriors differ measurably from the override path
(max diff > 1e-6) — proves the override actually changes
inference.
3. **`event_builder_scores_with_sigma_threading`** — same shape as
#2 but constructed via the fluent builder
`h.event(0).team(["a"]).team(["b"]).scores_with_sigma([3.0, 1.0], 2.0).commit()`.
Proves the builder method works end-to-end.
### Pattern-match update test impact
Existing tests in `src/outcome.rs` that construct
`Outcome::Scored(...)` literally need updating to the struct shape.
Mechanical change; no new tests required.
## Verification gates
```bash
cargo +nightly fmt
cargo clippy --all-targets -- -D warnings
cargo test --lib
cargo test
```
Test count grows by 3.
## Risks
- **Public API breaking change.** `Outcome::Scored` variant shape
changes from tuple to struct. Any downstream consumer
pattern-matching on the tuple form breaks. In a 0.1.x crate this
is acceptable; flag it in the commit message.
- **Mechanical breadth.** The pattern-match updates touch several
files. They're all caught by the compiler so the risk is low, but
the diff will look bigger than the actual logical change.
- **Two ways to do the same thing.** `Outcome::scores_with_sigma(..)`
and `EventBuilder::scores_with_sigma(..)` both produce the same
outcome. This is intentional — the constructor is the underlying
primitive; the builder method is the ergonomic wrapper. Same
pattern as the existing `Outcome::scores(..)` /
`EventBuilder::scores(..)` pair.
## Out-of-scope follow-ups
- Per-event override of other config currently history-wide
(`p_draw`, drift, beta) — same architectural pattern would apply
but each is its own design decision.
- Validation upgrade from `debug_assert!` to a `Result` at the
Outcome construction boundary.
- Schedule trait integration with `run_chain`, `Residual` schedule,
`SynergyFactor` (still pending from the larger spec).
@@ -0,0 +1,134 @@
# Tech Debt Cleanup — Post-T4-MarginFactor
## Summary
Three small, independent cleanups left behind by the T4-MarginFactor merge
(`8b53cac`). All three are pure code-shape or doc fixes. No public-API change,
no numerics change, no new behavior.
This batch deliberately excludes the `DiffFactor``BuiltinFactor` overlap
collapse (architectural change kept separate) and per-event `score_sigma`
override (a feature, not debt).
## Scope
### Item 1 — Deduplicate `Game::likelihoods` and `Game::likelihoods_scored`
**Current state.** `src/game.rs:236-371` and `src/game.rs:373-485` are 95-line
near-duplicates of each other. They differ in exactly one block: the closure
that maps a diff index to a `DiffFactor`. The ranked path builds
`DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))` with `margin`/`tie`
derived from `p_draw` and adjacent-result equality. The scored path builds
`DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))` with `m_obs`
the observed score gap. Everything else — sort, `team_prior`, sweep loop,
boundary updates, evidence product, posterior `likelihoods` — is bit-identical.
**Refactor.** Extract a private helper on `OwnedGame<T, D>`:
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut VarStore) -> DiffFactor,
```
The closure receives the diff index `i`, the descending-by-result sort
permutation `&arena.sort_buf`, and `&mut arena.vars` for `alloc(N_INF)`. It
returns the `DiffFactor` for that diff slot.
The helper takes `&self` (not `&mut self`) and returns
`(evidence, likelihoods)`. Each caller writes the results back to its own
`self.evidence` and `self.likelihoods` fields. The `&self` choice matters: the
closure captures `&self.result` / `&self.teams` / `&self.weights` / `&self.p_draw`
freely without conflicting with the helper's own immutable borrow.
The two public methods shrink from ~125 lines each to ~10 lines that just
construct the closure.
**Why a closure (not a trait or two-phase build).** A closure keeps all
caller-specific state (`p_draw`, `score_sigma`, beta sums for margin) inline at
the call site. A trait would require a stateful object per call; a two-phase
build (caller produces the `Vec<DiffFactor>` first, helper does the rest) would
either re-do the sort or split state ownership awkwardly between phases.
### Item 2 — Make `BuiltinFactor::log_evidence` exhaustive
**Current state.** `src/factor/mod.rs:94-100` uses a `_ => 0.0` wildcard for
`TeamSum` and `RankDiff`. When a future variant lands (e.g. `SynergyFactor`),
the wildcard silently absorbs it instead of forcing a deliberate decision.
**Refactor.**
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
```
No behavioral change. Future variants now produce a non-exhaustive-match
compile error.
### Item 3 — Fix stale numerics in T4 plan doc
**Current state.** `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
contains two numbers that diverge from the values asserted by the shipped test
in `src/factor/mod.rs:163,166`.
**Fix.**
| Doc value (wrong) | Implementation value (correct) |
|---|---|
| `0.046827` | `0.04678` |
| `-3.0613` | `-3.0622` |
Pure docs change. Verified by reading the asserted constants in the test.
## Out of scope
- **`DiffFactor``BuiltinFactor` overlap.** Both enums list `Trunc` and
`Margin` variants. Collapsing into `BuiltinFactor::Diff(DiffFactor)` is
defensible but is an architectural change that wants its own design pass.
`DiffFactor` represents a real semantic subset (factors that operate on a
diff variable in a chain); the duplication is two enum variants, not a
large block of code.
- **Per-event `EventKind::Scored.score_sigma` override.** Today
`score_sigma` is history-wide (set on `HistoryBuilder::score_sigma`). A
per-event override is a real feature ask, not tech debt.
## Verification
Each item commits independently and ships behind a green `cargo test --lib`
run. The dedup is a pure code-shape change: posteriors and evidence must be
**bit-equal** (not ULP-bounded) against the existing 88+28 test goldens.
Per-item gate before committing:
```bash
cargo +nightly fmt
cargo clippy
cargo test --lib
```
## Commit shape
Three commits, one per item, each independently revertable:
1. `refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain`
2. `refactor: make BuiltinFactor::log_evidence match exhaustive`
3. `docs: fix stale numerics in t4-margin-factor plan`
## Risks
- **Borrow-checker friction in Item 1.** The closure captures fields of
`&self` while the helper iterates over arena state. Mitigation: helper is
`&self` (not `&mut self`); arena passed as `&mut ScratchArena` separately.
Disjoint borrows.
- **Compile error in Item 2 if a new variant ships before this lands.**
Trivial follow-on; the whole point is to surface that signal.
@@ -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.
+53 -51
View File
@@ -1,50 +1,62 @@
use plotters::prelude::*;
use smallvec::smallvec;
use time::{Date, Month};
use trueskill_tt::{History, IndexMap};
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
fn main() {
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
let mut composition = Vec::new();
let mut results = Vec::new();
let mut times = Vec::new();
let from = Date::from_calendar_date(1900, Month::January, 1).unwrap();
let time_format = time::format_description::parse("[year]-[month]-[day]").unwrap();
let mut index_map = IndexMap::new();
let mut events: Vec<Event<i64, String>> = Vec::new();
for row in csv.records() {
if &row["double"] == "t" {
let w1_id = index_map.get_or_create(&row["w1_id"]);
let w2_id = index_map.get_or_create(&row["w2_id"]);
let l1_id = index_map.get_or_create(&row["l1_id"]);
let l2_id = index_map.get_or_create(&row["l2_id"]);
composition.push(vec![vec![w1_id, w2_id], vec![l1_id, l2_id]]);
} else {
let w1_id = index_map.get_or_create(&row["w1_id"]);
let l1_id = index_map.get_or_create(&row["l1_id"]);
composition.push(vec![vec![w1_id], vec![l1_id]]);
}
results.push(vec![1.0, 0.0]);
let date = Date::parse(&row["time_start"], &time_format).unwrap();
let time = (date - from).whole_days();
times.push((date - from).whole_days());
if &row["double"] == "t" {
events.push(Event {
time,
teams: smallvec![
Team::with_members([
Member::new(row["w1_id"].to_owned()),
Member::new(row["w2_id"].to_owned()),
]),
Team::with_members([
Member::new(row["l1_id"].to_owned()),
Member::new(row["l2_id"].to_owned()),
]),
],
outcome: Outcome::winner(0, 2),
});
} else {
events.push(Event {
time,
teams: smallvec![
Team::with_members([Member::new(row["w1_id"].to_owned())]),
Team::with_members([Member::new(row["l1_id"].to_owned())]),
],
outcome: Outcome::winner(0, 2),
});
}
}
let mut hist = History::builder().sigma(1.6).gamma(0.036).build();
let mut hist: History<i64, _, _, String> = History::builder_with_key()
.sigma(1.6)
.drift(ConstantDrift(0.036))
.convergence(trueskill_tt::ConvergenceOptions {
max_iter: 10,
epsilon: 0.01,
alpha: 1.0,
})
.build();
hist.add_events(composition, results, times, vec![]);
hist.convergence(10, 0.01, true);
hist.add_events(events).unwrap();
hist.converge().unwrap();
let players = [
("aggasi", "a092", 38800),
("aggasi", "a092", 38800i64),
("borg", "b058", 30300),
("connors", "c044", 31250),
("courier", "c243", 35750),
@@ -61,21 +73,16 @@ fn main() {
("wilander", "w023", 32600),
];
let curves = hist.learning_curves();
let mut x_spec = (f64::MAX, f64::MIN);
let mut y_spec = (f64::MAX, f64::MIN);
for (id, cutoff) in players
.iter()
.map(|&(_, id, cutoff)| (index_map.get_or_create(id), cutoff))
{
for (ts, gs) in &curves[&id] {
if *ts >= cutoff {
for &(_, id, cutoff) in &players {
for (ts, gs) in hist.learning_curve(id) {
if ts >= cutoff {
continue;
}
let ts = *ts as f64;
let ts = ts as f64;
if ts < x_spec.0 {
x_spec.0 = ts;
@@ -85,8 +92,8 @@ fn main() {
x_spec.1 = ts;
}
let upper = gs.mu + gs.sigma;
let lower = gs.mu - gs.sigma;
let upper = gs.mu() + gs.sigma();
let lower = gs.mu() - gs.sigma();
if lower < y_spec.0 {
y_spec.0 = lower;
@@ -111,24 +118,19 @@ fn main() {
chart.configure_mesh().draw().unwrap();
for (idx, (player, id, cutoff)) in players
.iter()
.map(|&(player, id, cutoff)| (player, index_map.get_or_create(id), cutoff))
.enumerate()
{
for (idx, &(player, id, cutoff)) in players.iter().enumerate() {
let mut data = Vec::new();
let mut upper = Vec::new();
let mut lower = Vec::new();
for (ts, gs) in curves[&id].iter() {
if *ts >= cutoff {
for (ts, gs) in hist.learning_curve(id) {
if ts >= cutoff {
continue;
}
data.push((*ts as f64, gs.mu));
upper.push((*ts as f64, gs.mu + gs.sigma));
lower.push((*ts as f64, gs.mu - gs.sigma));
data.push((ts as f64, gs.mu()));
upper.push((ts as f64, gs.mu() + gs.sigma()));
lower.push((ts as f64, gs.mu() - gs.sigma()));
}
let color = Palette99::pick(idx);
+59
View File
@@ -0,0 +1,59 @@
//! Worked example: continuous-score outcomes via `Outcome::Scored`.
//!
//! Three players play a small round-robin where the score margin matters,
//! not just who won. We show how `score_sigma` controls how much weight
//! the engine places on the observed margin.
//!
//! Run with: `cargo run --example scored --release`
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn main() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.03))
.score_sigma(2.0) // tune to data; smaller = trust margins more
.build();
let events: Vec<Event<i64, &'static str>> = vec![
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("alice")]),
Team::with_members([Member::new("bob")]),
],
outcome: Outcome::scores([21.0, 9.0]),
},
Event {
time: 2,
teams: smallvec![
Team::with_members([Member::new("bob")]),
Team::with_members([Member::new("carol")]),
],
outcome: Outcome::scores([21.0, 18.0]),
},
Event {
time: 3,
teams: smallvec![
Team::with_members([Member::new("alice")]),
Team::with_members([Member::new("carol")]),
],
outcome: Outcome::scores([21.0, 21.0]),
},
];
h.add_events(events).unwrap();
let report = h.converge().unwrap();
println!(
"converged={}, iterations={}, log_evidence={:.4}",
report.converged, report.iterations, report.log_evidence
);
for who in &["alice", "bob", "carol"] {
let s = h.current_skill(who).unwrap();
println!("{:>6}: mu={:>7.3} sigma={:.3}", who, s.mu(), s.sigma());
}
}
+6 -2
View File
@@ -1,2 +1,6 @@
publish = false
pre-release-hook = ["sh", "-c", "git cliff -o ../CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"]
# 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"]
-47
View File
@@ -1,47 +0,0 @@
use crate::{
N_INF,
drift::{ConstantDrift, Drift},
gaussian::Gaussian,
player::Player,
};
#[derive(Debug)]
pub struct Agent<D: Drift = ConstantDrift> {
pub player: Player<D>,
pub message: Gaussian,
pub last_time: i64,
}
impl<D: Drift> Agent<D> {
pub(crate) fn receive(&self, elapsed: i64) -> Gaussian {
if self.message != N_INF {
self.message
.forget(self.player.drift.variance_delta(elapsed))
} else {
self.player.prior
}
}
}
impl Default for Agent<ConstantDrift> {
fn default() -> Self {
Self {
player: Player::default(),
message: N_INF,
last_time: i64::MIN,
}
}
}
pub(crate) fn clean<'a, D: Drift + 'a, A: Iterator<Item = &'a mut Agent<D>>>(
agents: A,
last_time: bool,
) {
for a in agents {
a.message = N_INF;
if last_time {
a.last_time = i64::MIN;
}
}
}
+6 -6
View File
@@ -10,8 +10,8 @@ impl AbsDiffEq for Gaussian {
}
fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
f64::abs_diff_eq(&self.mu, &other.mu, epsilon)
&& f64::abs_diff_eq(&self.sigma, &other.sigma, epsilon)
f64::abs_diff_eq(&self.mu(), &other.mu(), epsilon)
&& f64::abs_diff_eq(&self.sigma(), &other.sigma(), epsilon)
}
}
@@ -26,8 +26,8 @@ impl RelativeEq for Gaussian {
epsilon: Self::Epsilon,
max_relative: Self::Epsilon,
) -> bool {
f64::relative_eq(&self.mu, &other.mu, epsilon, max_relative)
&& f64::relative_eq(&self.sigma, &other.sigma, epsilon, max_relative)
f64::relative_eq(&self.mu(), &other.mu(), epsilon, max_relative)
&& f64::relative_eq(&self.sigma(), &other.sigma(), epsilon, max_relative)
}
}
@@ -37,7 +37,7 @@ impl UlpsEq for Gaussian {
}
fn ulps_eq(&self, other: &Self, epsilon: Self::Epsilon, max_ulps: u32) -> bool {
f64::ulps_eq(&self.mu, &other.mu, epsilon, max_ulps)
&& f64::ulps_eq(&self.sigma, &other.sigma, epsilon, max_ulps)
f64::ulps_eq(&self.mu(), &other.mu(), epsilon, max_ulps)
&& f64::ulps_eq(&self.sigma(), &other.sigma(), epsilon, max_ulps)
}
}
+56
View File
@@ -0,0 +1,56 @@
use crate::{factor::VarStore, gaussian::Gaussian};
/// Reusable scratch buffers for `Game::likelihoods`.
///
/// A `TimeSlice` owns one arena; all events in the slice share it across
/// the convergence iterations. All Vecs are cleared (not dropped) on
/// `reset()` so their heap capacity is reused across games.
#[derive(Debug, Default)]
pub struct ScratchArena {
pub(crate) vars: VarStore,
pub(crate) sort_buf: Vec<usize>,
pub(crate) inv_buf: Vec<usize>,
pub(crate) team_prior: Vec<Gaussian>,
pub(crate) lhood_lose: Vec<Gaussian>,
pub(crate) lhood_win: Vec<Gaussian>,
}
impl ScratchArena {
pub fn new() -> Self {
Self::default()
}
#[inline]
pub(crate) fn reset(&mut self) {
self.vars.clear();
self.sort_buf.clear();
self.inv_buf.clear();
self.team_prior.clear();
self.lhood_lose.clear();
self.lhood_win.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{N_INF, gaussian::Gaussian};
#[test]
fn reset_keeps_capacity() {
let mut arena = ScratchArena::new();
arena.vars.alloc(N_INF);
arena.sort_buf.push(42);
arena.team_prior.push(Gaussian::from_ms(0.0, 1.0));
let var_cap = arena.vars.marginals.capacity();
let sort_cap = arena.sort_buf.capacity();
let prior_cap = arena.team_prior.capacity();
arena.reset();
assert_eq!(arena.vars.len(), 0);
assert_eq!(arena.sort_buf.len(), 0);
assert_eq!(arena.team_prior.len(), 0);
assert_eq!(arena.vars.marginals.capacity(), var_cap);
assert_eq!(arena.sort_buf.capacity(), sort_cap);
assert_eq!(arena.team_prior.capacity(), prior_cap);
}
}
-645
View File
@@ -1,645 +0,0 @@
use std::collections::HashMap;
use crate::{
Index, N_INF, agent::Agent, drift::Drift, game::Game, gaussian::Gaussian, player::Player,
tuple_gt, tuple_max,
};
#[derive(Debug)]
pub(crate) struct Skill {
pub(crate) forward: Gaussian,
backward: Gaussian,
likelihood: Gaussian,
pub(crate) elapsed: i64,
pub(crate) online: Gaussian,
}
impl Skill {
pub(crate) fn posterior(&self) -> Gaussian {
self.likelihood * self.backward * self.forward
}
}
impl Default for Skill {
fn default() -> Self {
Self {
forward: N_INF,
backward: N_INF,
likelihood: N_INF,
elapsed: 0,
online: N_INF,
}
}
}
#[derive(Debug)]
struct Item {
agent: Index,
likelihood: Gaussian,
}
impl Item {
fn within_prior<D: Drift>(
&self,
online: bool,
forward: bool,
skills: &HashMap<Index, Skill>,
agents: &HashMap<Index, Agent<D>>,
) -> Player<D> {
let r = &agents[&self.agent].player;
let skill = &skills[&self.agent];
if online {
Player::new(skill.online, r.beta, r.drift)
} else if forward {
Player::new(skill.forward, r.beta, r.drift)
} else {
Player::new(skill.posterior() / self.likelihood, r.beta, r.drift)
}
}
}
#[derive(Debug)]
struct Team {
items: Vec<Item>,
output: f64,
}
#[derive(Debug)]
pub(crate) struct Event {
teams: Vec<Team>,
evidence: f64,
weights: Vec<Vec<f64>>,
}
impl Event {
fn outputs(&self) -> Vec<f64> {
self.teams
.iter()
.map(|team| team.output)
.collect::<Vec<_>>()
}
pub(crate) fn within_priors<D: Drift>(
&self,
online: bool,
forward: bool,
skills: &HashMap<Index, Skill>,
agents: &HashMap<Index, Agent<D>>,
) -> Vec<Vec<Player<D>>> {
self.teams
.iter()
.map(|team| {
team.items
.iter()
.map(|item| item.within_prior(online, forward, skills, agents))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
}
#[derive(Debug)]
pub struct Batch {
pub(crate) events: Vec<Event>,
pub(crate) skills: HashMap<Index, Skill>,
pub(crate) time: i64,
p_draw: f64,
}
impl Batch {
pub fn new(time: i64, p_draw: f64) -> Self {
Self {
events: Vec::new(),
skills: HashMap::new(),
time,
p_draw,
}
}
pub fn add_events<D: Drift>(
&mut self,
composition: Vec<Vec<Vec<Index>>>,
results: Vec<Vec<f64>>,
weights: Vec<Vec<Vec<f64>>>,
agents: &HashMap<Index, Agent<D>>,
) {
let mut unique = Vec::with_capacity(10);
let this_agent = composition.iter().flatten().flatten().filter(|idx| {
if !unique.contains(idx) {
unique.push(*idx);
return true;
}
false
});
for idx in this_agent {
let elapsed = compute_elapsed(agents[&idx].last_time, self.time);
if let Some(skill) = self.skills.get_mut(idx) {
skill.elapsed = elapsed;
skill.forward = agents[&idx].receive(elapsed);
} else {
self.skills.insert(
*idx,
Skill {
forward: agents[&idx].receive(elapsed),
elapsed,
..Default::default()
},
);
}
}
let events = composition.iter().enumerate().map(|(e, event)| {
let teams = event
.iter()
.enumerate()
.map(|(t, team)| {
let items = team
.iter()
.map(|&agent| Item {
agent,
likelihood: N_INF,
})
.collect::<Vec<_>>();
Team {
items,
output: if results.is_empty() {
(event.len() - (t + 1)) as f64
} else {
results[e][t]
},
}
})
.collect::<Vec<_>>();
let weights = if weights.is_empty() {
teams
.iter()
.map(|team| vec![1.0; team.items.len()])
.collect::<Vec<_>>()
} else {
weights[e].clone()
};
Event {
teams,
evidence: 0.0,
weights,
}
});
let from = self.events.len();
self.events.extend(events);
self.iteration(from, agents);
}
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
self.skills
.iter()
.map(|(&idx, skill)| (idx, skill.posterior()))
.collect::<HashMap<_, _>>()
}
pub fn iteration<D: Drift>(&mut self, from: usize, agents: &HashMap<Index, Agent<D>>) {
for event in self.events.iter_mut().skip(from) {
let teams = event.within_priors(false, false, &self.skills, agents);
let result = event.outputs();
let g = Game::new(teams, &result, &event.weights, self.p_draw);
for (t, team) in event.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() {
self.skills.get_mut(&item.agent).unwrap().likelihood =
(self.skills[&item.agent].likelihood / item.likelihood)
* g.likelihoods[t][i];
item.likelihood = g.likelihoods[t][i];
}
}
event.evidence = g.evidence;
}
}
#[allow(dead_code)]
pub(crate) fn convergence<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) -> usize {
let epsilon = 1e-6;
let iterations = 20;
let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0;
while tuple_gt(step, epsilon) && i < iterations {
let old = self.posteriors();
self.iteration(0, agents);
let new = self.posteriors();
step = old.iter().fold((0.0, 0.0), |step, (a, old)| {
tuple_max(step, old.delta(new[a]))
});
i += 1;
}
i
}
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
let skill = &self.skills[agent];
skill.forward * skill.likelihood
}
pub(crate) fn backward_prior_out<D: Drift>(
&self,
agent: &Index,
agents: &HashMap<Index, Agent<D>>,
) -> Gaussian {
let skill = &self.skills[agent];
let n = skill.likelihood * skill.backward;
n.forget(agents[agent].player.drift.variance_delta(skill.elapsed))
}
pub(crate) fn new_backward_info<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) {
for (agent, skill) in self.skills.iter_mut() {
skill.backward = agents[agent].message;
}
self.iteration(0, agents);
}
pub(crate) fn new_forward_info<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) {
for (agent, skill) in self.skills.iter_mut() {
skill.forward = agents[agent].receive(skill.elapsed);
}
self.iteration(0, agents);
}
pub(crate) fn log_evidence<D: Drift>(
&self,
online: bool,
targets: &[Index],
forward: bool,
agents: &HashMap<Index, Agent<D>>,
) -> f64 {
if targets.is_empty() {
if online || forward {
self.events
.iter()
.enumerate()
.map(|(_, event)| {
Game::new(
event.within_priors(online, forward, &self.skills, agents),
&event.outputs(),
&event.weights,
self.p_draw,
)
.evidence
.ln()
})
.sum()
} else {
self.events.iter().map(|event| event.evidence.ln()).sum()
}
} else if online || forward {
self.events
.iter()
.enumerate()
.filter(|(_, event)| {
event
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent))
})
.map(|(_, event)| {
Game::new(
event.within_priors(online, forward, &self.skills, agents),
&event.outputs(),
&event.weights,
self.p_draw,
)
.evidence
.ln()
})
.sum()
} else {
self.events
.iter()
.filter(|event| {
event
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent))
})
.map(|event| event.evidence.ln())
.sum()
}
}
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
self.events
.iter()
.map(|event| {
event
.teams
.iter()
.map(|team| team.items.iter().map(|item| item.agent).collect::<Vec<_>>())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
pub fn get_results(&self) -> Vec<Vec<f64>> {
self.events
.iter()
.map(|event| {
event
.teams
.iter()
.map(|team| team.output)
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
}
pub(crate) fn compute_elapsed(last_time: i64, actual_time: i64) -> i64 {
if last_time == i64::MIN {
0
} else if last_time == i64::MAX {
1
} else {
actual_time - last_time
}
}
#[cfg(test)]
mod tests {
use approx::assert_ulps_eq;
use super::*;
use crate::{IndexMap, agent::Agent, drift::ConstantDrift, player::Player};
#[test]
fn test_one_event_each() {
let mut index_map = IndexMap::new();
let a = index_map.get_or_create("a");
let b = index_map.get_or_create("b");
let c = index_map.get_or_create("c");
let d = index_map.get_or_create("d");
let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f");
let mut agents = HashMap::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Agent {
player: Player::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut batch = Batch::new(0, 0.0);
batch.add_events(
vec![
vec![vec![a], vec![b]],
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![],
&agents,
);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&d],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&e],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&f],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_eq!(batch.convergence(&agents), 1);
}
#[test]
fn test_same_strength() {
let mut index_map = IndexMap::new();
let a = index_map.get_or_create("a");
let b = index_map.get_or_create("b");
let c = index_map.get_or_create("c");
let d = index_map.get_or_create("d");
let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f");
let mut agents = HashMap::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Agent {
player: Player::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut batch = Batch::new(0, 0.0);
batch.add_events(
vec![
vec![vec![a], vec![b]],
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![],
&agents,
);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(24.960978, 6.298544),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(27.095590, 6.010330),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(24.889681, 5.866311),
epsilon = 1e-6
);
assert!(batch.convergence(&agents) > 1);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
}
#[test]
fn test_add_events() {
let mut index_map = IndexMap::new();
let a = index_map.get_or_create("a");
let b = index_map.get_or_create("b");
let c = index_map.get_or_create("c");
let d = index_map.get_or_create("d");
let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f");
let mut agents = HashMap::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Agent {
player: Player::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut batch = Batch::new(0, 0.0);
batch.add_events(
vec![
vec![vec![a], vec![b]],
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![],
&agents,
);
batch.convergence(&agents);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
batch.add_events(
vec![
vec![vec![a], vec![b]],
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![],
&agents,
);
assert_eq!(batch.events.len(), 6);
batch.convergence(&agents);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Greedy graph coloring for within-slice event independence.
//!
//! Events sharing no `Index` can be processed in parallel under async-EP
//! semantics. This module partitions a list of events into "colors" such
//! that events of the same color touch disjoint index sets.
//!
//! The algorithm is greedy: for each event in ingestion order, place it in
//! the lowest-numbered color whose existing members share no `Index`. If
//! no existing color accepts the event, open a new color.
//!
//! Complexity: O(n × c × m) where n is events, c is colors (small, ≤ 5 in
//! practice), and m is average team size.
use std::collections::HashSet;
use crate::Index;
/// Partition of event indices into color groups.
///
/// Each inner `Vec<usize>` holds the indices (into the original events
/// array) of events assigned to one color. Colors are iterated in ascending
/// order by convention.
#[derive(Clone, Debug, Default)]
pub(crate) struct ColorGroups {
pub(crate) groups: Vec<Vec<usize>>,
}
impl ColorGroups {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn is_empty(&self) -> bool {
self.groups.is_empty()
}
/// Number of distinct colors in the partition. Test-only.
#[cfg(test)]
pub(crate) fn n_colors(&self) -> usize {
self.groups.len()
}
/// Total event count across all colors. Test-only.
#[cfg(test)]
pub(crate) fn total_events(&self) -> usize {
self.groups.iter().map(|g| g.len()).sum()
}
/// Contiguous index range for one color after events have been reordered
/// into color-contiguous positions by `TimeSlice::recompute_color_groups`.
pub(crate) fn color_range(&self, color_idx: usize) -> std::ops::Range<usize> {
let group = &self.groups[color_idx];
if group.is_empty() {
return 0..0;
}
let start = *group.first().unwrap();
let end = *group.last().unwrap() + 1;
debug_assert_eq!(
end - start,
group.len(),
"color {color_idx} is not contiguous; its range would overlap other colors"
);
start..end
}
/// Whether every color occupies a contiguous, ascending range of event
/// indices, and no two colors overlap.
///
/// The parallel sweep derives one `&mut` sub-slice per color from these
/// ranges and relies on them being disjoint. That disjointness is what
/// makes concurrent writes to distinct skills sound, so it is checked
/// rather than assumed.
pub(crate) fn groups_are_contiguous(&self) -> bool {
let mut expected_start = 0;
for group in &self.groups {
if group.is_empty() {
continue;
}
let ascending_run = group
.iter()
.enumerate()
.all(|(offset, &idx)| idx == group[0] + offset);
if !ascending_run || group[0] != expected_start {
return false;
}
expected_start += group.len();
}
true
}
}
/// Compute color groups greedily.
///
/// `index_set(ev_idx)` yields, for each event index, the iterator of
/// `Index` values that event touches. The returned `ColorGroups` has one
/// inner `Vec<usize>` per color, containing event indices in the order
/// they were assigned.
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
where
F: Fn(usize) -> I,
I: IntoIterator<Item = Index>,
{
let mut groups: Vec<Vec<usize>> = Vec::new();
let mut members: Vec<HashSet<Index>> = Vec::new();
for ev_idx in 0..n_events {
let ev_members: HashSet<Index> = index_set(ev_idx).into_iter().collect();
// Find first color whose member-set is disjoint from this event's indices.
let chosen = members.iter().position(|m| m.is_disjoint(&ev_members));
let color_idx = match chosen {
Some(c) => c,
None => {
groups.push(Vec::new());
members.push(HashSet::new());
groups.len() - 1
}
};
groups[color_idx].push(ev_idx);
members[color_idx].extend(ev_members);
}
ColorGroups { groups }
}
#[cfg(test)]
mod tests {
use super::*;
fn idx(i: usize) -> Index {
Index::from(i)
}
#[test]
fn single_event_gets_one_color() {
let cg = color_greedy(1, |_| vec![idx(0), idx(1)]);
assert_eq!(cg.n_colors(), 1);
assert_eq!(cg.groups[0], vec![0]);
}
#[test]
fn disjoint_events_share_a_color() {
let cg = color_greedy(2, |i| match i {
0 => vec![idx(0), idx(1)],
1 => vec![idx(2), idx(3)],
_ => unreachable!(),
});
assert_eq!(cg.n_colors(), 1);
assert_eq!(cg.groups[0], vec![0, 1]);
}
#[test]
fn overlapping_events_need_separate_colors() {
let cg = color_greedy(2, |i| match i {
0 => vec![idx(0), idx(1)],
1 => vec![idx(1), idx(2)],
_ => unreachable!(),
});
assert_eq!(cg.n_colors(), 2);
assert_eq!(cg.groups[0], vec![0]);
assert_eq!(cg.groups[1], vec![1]);
}
#[test]
fn three_events_two_colors() {
// Event 0: {0, 1}; event 1: {2, 3}; event 2: {0, 2}.
// Greedy: ev0→c0, ev1→c0 (disjoint), ev2 overlaps both→c1.
let cg = color_greedy(3, |i| match i {
0 => vec![idx(0), idx(1)],
1 => vec![idx(2), idx(3)],
2 => vec![idx(0), idx(2)],
_ => unreachable!(),
});
assert_eq!(cg.n_colors(), 2);
assert_eq!(cg.groups[0], vec![0, 1]);
assert_eq!(cg.groups[1], vec![2]);
}
#[test]
fn total_events_counts_correctly() {
let cg = color_greedy(4, |_| vec![idx(0)]);
// All events touch index 0 → 4 distinct colors.
assert_eq!(cg.n_colors(), 4);
assert_eq!(cg.total_events(), 4);
}
}
+71
View File
@@ -0,0 +1,71 @@
use crate::{
N_INF,
drift::{ConstantDrift, Drift},
gaussian::Gaussian,
rating::Rating,
time::Time,
};
/// Per-history, temporal state for someone competing.
///
/// Renamed from `Agent` in T2; the former `.player` field is now
/// `.rating` to match the `Player → Rating` rename.
#[derive(Debug)]
pub struct Competitor<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub rating: Rating<T, D>,
pub message: Gaussian,
pub last_time: Option<T>,
}
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
}
}
/// Compute the message using a pre-cached elapsed count (in `Time::elapsed_to` units).
///
/// 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
}
}
}
impl Default for Competitor<i64, ConstantDrift> {
fn default() -> Self {
Self {
rating: Rating::default(),
message: N_INF,
last_time: None,
}
}
}
pub(crate) fn clean<'a, T, D, C>(competitors: C, last_time: bool)
where
T: Time + 'a,
D: Drift<T> + 'a,
C: Iterator<Item = &'a mut Competitor<T, D>>,
{
for c in competitors {
c.message = N_INF;
if last_time {
c.last_time = None;
}
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Convergence configuration and reporting.
use std::time::Duration;
use smallvec::SmallVec;
#[derive(Clone, Copy, Debug)]
pub struct ConvergenceOptions {
pub max_iter: usize,
pub epsilon: f64,
/// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
/// undamped (default); `< 1.0` stabilises oscillating fixed-point
/// loops at the cost of more iterations. Must be in `(0.0, 1.0]`.
///
/// Applies only to the within-game EP loop (`run_chain`). The outer
/// `History::converge` cross-history sweep is undamped regardless of
/// this value — cross-slice damping is a different concept and not
/// in scope.
pub alpha: f64,
}
impl Default for ConvergenceOptions {
fn default() -> Self {
Self {
max_iter: crate::ITERATIONS,
epsilon: crate::EPSILON,
alpha: 1.0,
}
}
}
/// Post-hoc summary of a `History::converge` call.
#[derive(Clone, Debug)]
pub struct ConvergenceReport {
pub iterations: usize,
pub final_step: (f64, f64),
pub log_evidence: f64,
pub converged: bool,
pub per_iteration_time: SmallVec<[Duration; 32]>,
pub slices_skipped: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_alpha_is_one_for_undamped_behavior() {
let opts = ConvergenceOptions::default();
assert_eq!(opts.alpha, 1.0);
}
}
+27 -5
View File
@@ -1,14 +1,36 @@
use std::fmt::Debug;
pub trait Drift: Copy + Debug {
fn variance_delta(&self, elapsed: i64) -> f64;
use crate::time::Time;
/// Governs how much a competitor's skill can drift between two time points.
///
/// Generic over `T: Time` so seasonal or calendar-aware drift is expressible
/// without going through `i64`.
pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
/// Variance added to the skill prior for elapsed time `from -> to`.
///
/// Called with `from <= to`; returning zero means no drift accumulates.
fn variance_delta(&self, from: &T, to: &T) -> f64;
/// Variance added for a pre-computed elapsed count (in the same units as
/// `T::elapsed_to`). Used where the elapsed is already cached as `i64`.
fn variance_for_elapsed(&self, elapsed: i64) -> f64;
}
/// Simple constant-per-unit-time drift.
///
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
#[derive(Clone, Copy, Debug)]
pub struct ConstantDrift(pub f64);
impl Drift for ConstantDrift {
fn variance_delta(&self, elapsed: i64) -> f64 {
elapsed as f64 * self.0 * self.0
impl<T: Time> Drift<T> for ConstantDrift {
fn variance_delta(&self, from: &T, to: &T) -> f64 {
let elapsed = from.elapsed_to(to).max(0) as f64;
elapsed * self.0 * self.0
}
fn variance_for_elapsed(&self, elapsed: i64) -> f64 {
elapsed.max(0) as f64 * self.0 * self.0
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum InferenceError {
/// Expected and actual lengths of some array-shaped input differ.
MismatchedShape {
kind: &'static str,
expected: usize,
got: usize,
},
/// An `Outcome` of the wrong variant was supplied for the requested inference.
WrongOutcomeKind {
context: &'static str,
expected: &'static str,
got: &'static str,
},
/// A probability value is outside `[0, 1]`.
InvalidProbability { value: f64 },
/// A scalar parameter is outside its valid range.
InvalidParameter { name: &'static str, value: f64 },
/// An event contains tied teams, but the draw probability is zero.
///
/// A zero draw probability asserts that draws cannot occur, so a tied
/// result has no representable likelihood. Configure a positive `p_draw`
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
TieWithoutDrawProbability { teams: (usize, usize) },
/// Convergence exceeded `max_iter` without falling below `epsilon`.
ConvergenceFailed {
last_step: (f64, f64),
iterations: usize,
},
/// Inference produced a non-finite value (NaN or infinity).
///
/// Indicates numerical breakdown; the resulting skills are meaningless
/// and must not be treated as a converged estimate.
NonFiniteResult {
context: &'static str,
step: (f64, f64),
},
/// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
NegativePrecision { pi: f64 },
}
impl fmt::Display for InferenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MismatchedShape {
kind,
expected,
got,
} => {
write!(f, "{kind}: expected length {expected}, got {got}")
}
Self::WrongOutcomeKind {
context,
expected,
got,
} => {
write!(f, "{context}: expected {expected}, got {got}")
}
Self::InvalidProbability { value } => {
write!(f, "probability must be in [0, 1]; got {value}")
}
Self::TieWithoutDrawProbability { teams } => {
write!(
f,
"teams {} and {} are tied, but p_draw is 0.0; set a positive draw probability to admit ties",
teams.0, teams.1
)
}
Self::NonFiniteResult { context, step } => {
write!(
f,
"{context}: inference produced a non-finite result (step = {step:?})"
)
}
Self::InvalidParameter { name, value } => {
write!(f, "{name} is invalid: {value}")
}
Self::ConvergenceFailed {
last_step,
iterations,
} => {
write!(
f,
"convergence failed after {iterations} iterations; last step = {last_step:?}"
)
}
Self::NegativePrecision { pi } => {
write!(f, "precision must be non-negative; got {pi}")
}
}
}
}
impl std::error::Error for InferenceError {}
+132
View File
@@ -0,0 +1,132 @@
//! Typed event description for bulk ingestion.
//!
//! `Event<T, K>` is the new public event shape (spec Section 4). Replaces
//! the nested `Vec<Vec<Vec<Index>>>`, `Vec<Vec<f64>>`, `Vec<Vec<Vec<f64>>>`
//! that the old `add_events_with_prior` took.
use smallvec::SmallVec;
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
/// A single match at time `time` involving some number of teams.
#[derive(Clone, Debug)]
pub struct Event<T: Time, K> {
pub time: T,
pub teams: SmallVec<[Team<K>; 4]>,
pub outcome: Outcome,
}
/// A team: list of members competing together.
#[derive(Clone, Debug)]
pub struct Team<K> {
pub members: SmallVec<[Member<K>; 4]>,
}
impl<K> Team<K> {
pub fn new() -> Self {
Self {
members: SmallVec::new(),
}
}
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
Self {
members: members.into_iter().collect(),
}
}
}
impl<K> Default for Team<K> {
fn default() -> Self {
Self::new()
}
}
/// 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.
#[derive(Clone, Debug)]
pub struct Member<K> {
pub key: K,
pub weight: f64,
pub prior: Option<Gaussian>,
}
impl<K> Member<K> {
pub fn new(key: K) -> Self {
Self {
key,
weight: 1.0,
prior: None,
}
}
pub fn with_weight(mut self, weight: f64) -> Self {
self.weight = weight;
self
}
pub fn with_prior(mut self, prior: Gaussian) -> Self {
self.prior = Some(prior);
self
}
}
/// Convenience: a member is a user key with default weight 1.0 and no prior.
impl<K> From<K> for Member<K> {
fn from(key: K) -> Self {
Self::new(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Outcome;
#[test]
fn member_new_has_unit_weight_no_prior() {
let m = Member::new("alice");
assert_eq!(m.key, "alice");
assert_eq!(m.weight, 1.0);
assert!(m.prior.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));
assert_eq!(m.weight, 0.5);
assert!(m.prior.is_some());
}
#[test]
fn member_from_key() {
let m: Member<&str> = "bob".into();
assert_eq!(m.key, "bob");
assert_eq!(m.weight, 1.0);
}
#[test]
fn team_with_members_collects() {
let t: Team<&str> = Team::with_members([Member::new("a"), Member::new("b")]);
assert_eq!(t.members.len(), 2);
}
#[test]
fn event_construction() {
use smallvec::smallvec;
let e: Event<i64, &str> = Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
};
assert_eq!(e.teams.len(), 2);
assert_eq!(e.time, 1);
}
}
+109
View File
@@ -0,0 +1,109 @@
use smallvec::SmallVec;
use crate::{
InferenceError, Outcome,
drift::Drift,
event::{Event, Member, Team},
history::History,
observer::Observer,
time::Time,
};
pub struct EventBuilder<'h, T, D, O, K>
where
T: Time,
D: Drift<T>,
O: Observer<T>,
K: Eq + std::hash::Hash + Clone,
{
history: &'h mut History<T, D, O, K>,
event: Event<T, K>,
current_team_idx: Option<usize>,
}
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
where
T: Time,
D: Drift<T>,
O: Observer<T>,
K: Eq + std::hash::Hash + Clone,
{
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self {
Self {
history,
event: Event {
time,
teams: SmallVec::new(),
outcome: Outcome::Ranked(SmallVec::new()),
},
current_team_idx: None,
}
}
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
self.event.teams.push(Team { members });
self.current_team_idx = Some(self.event.teams.len() - 1);
self
}
/// 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.
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"
);
for (m, w) in team.members.iter_mut().zip(ws) {
m.weight = w;
}
self
}
/// Set explicit ranks per team (length must equal number of teams).
pub fn ranking<I: IntoIterator<Item = u32>>(mut self, ranks: I) -> Self {
self.event.outcome = Outcome::ranking(ranks);
self
}
/// Set explicit per-team continuous scores; higher = better.
pub fn scores<I: IntoIterator<Item = f64>>(mut self, scores: I) -> Self {
self.event.outcome = crate::Outcome::scores(scores);
self
}
/// Set explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
/// Must be `> 0.0`; debug-asserts otherwise via `Outcome::scores_with_sigma`.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
self
}
/// Mark team `winner_idx` as winner; others tied for last.
pub fn winner(mut self, winner_idx: u32) -> Self {
self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
self
}
/// All teams tied.
pub fn draw(mut self) -> Self {
self.event.outcome = Outcome::draw(self.event.teams.len() as u32);
self
}
/// Commit the event to the history.
pub fn commit(self) -> Result<(), InferenceError> {
self.history.add_events(std::iter::once(self.event))
}
}
+181
View File
@@ -0,0 +1,181 @@
use crate::{
N_INF,
factor::{Factor, VarId, VarStore},
gaussian::Gaussian,
pdf,
};
/// Gaussian observation factor on a diff variable.
///
/// Encodes the soft evidence `m_obs ~ N(diff, sigma²)`. The outgoing message
/// to `diff` is the constant `N(m_obs, sigma²)`, so this factor converges in a
/// single propagation: subsequent calls return a zero delta.
#[derive(Debug)]
pub struct MarginFactor {
pub diff: VarId,
pub m_obs: f64,
pub sigma: f64,
pub(crate) msg: Gaussian,
pub(crate) evidence_cached: Option<f64>,
}
impl MarginFactor {
pub fn new(diff: VarId, m_obs: f64, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score sigma must be positive");
Self {
diff,
m_obs,
sigma,
msg: N_INF,
evidence_cached: None,
}
}
}
impl MarginFactor {
/// Propagate this factor's message, optionally damping the update in
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.m_obs, self.sigma));
}
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg;
self.msg = damped;
vars.set(self.diff, cavity * damped);
old_msg.delta(damped)
}
}
impl Factor for MarginFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0)
}
fn log_evidence(&self, _vars: &VarStore) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln()
}
}
/// Density of the observed margin under the cavity, clamped to a positive
/// floor so a far-out observation cannot underflow to `0.0` and make
/// `log_evidence` `-inf`.
fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt();
pdf(m_obs, cavity.mu(), combined_sigma).max(f64::MIN_POSITIVE)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_propagate_writes_tilted_marginal() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
f.propagate(&mut vars);
let result = vars.get(diff);
// pi = 1/36 + 1 ≈ 1.027778; tau = 0 + 5 = 5
// mu = 5 / 1.027778 ≈ 4.864865; sigma = 1/sqrt(1.027778) ≈ 0.986394
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
assert!((result.sigma() - 0.986393923832144).abs() < 1e-12);
}
#[test]
fn converges_in_one_step() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
f.propagate(&mut vars);
let (dmu, dsig) = f.propagate(&mut vars);
assert!(
dmu < 1e-12,
"expected ~0 delta on second propagate, got {dmu}"
);
assert!(dsig < 1e-12);
}
#[test]
fn evidence_cached_on_first_propagate() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
assert!(f.evidence_cached.is_none());
f.propagate(&mut vars);
let z = f.evidence_cached.unwrap();
// pdf(5, 0, sqrt(37)) ≈ 0.046783
assert!((z - 0.04678300292616668).abs() < 1e-10);
// Subsequent propagations don't change it.
f.propagate(&mut vars);
assert_eq!(f.evidence_cached.unwrap(), z);
}
#[test]
fn log_evidence_matches_cached_ln() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
f.propagate(&mut vars);
let logz = f.log_evidence(&vars);
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
}
#[test]
fn propagate_with_alpha_one_matches_undamped_propagate() {
let mut vars_a = VarStore::new();
let diff_a = vars_a.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_a = MarginFactor::new(diff_a, 5.0, 1.0);
let delta_a = f_a.propagate(&mut vars_a);
let result_a = vars_a.get(diff_a);
let mut vars_b = VarStore::new();
let diff_b = vars_b.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_b = MarginFactor::new(diff_b, 5.0, 1.0);
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
let result_b = vars_b.get(diff_b);
assert_eq!(result_a.pi(), result_b.pi());
assert_eq!(result_a.tau(), result_b.tau());
assert_eq!(delta_a, delta_b);
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
}
#[test]
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
// Run undamped to capture (initial_msg, undamped_new_msg).
let mut vars_full = VarStore::new();
let diff_full = vars_full.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_full = MarginFactor::new(diff_full, 5.0, 1.0);
let initial_msg_pi = f_full.msg.pi();
let initial_msg_tau = f_full.msg.tau();
f_full.propagate(&mut vars_full);
let undamped_msg_pi = f_full.msg.pi();
let undamped_msg_tau = f_full.msg.tau();
// Run damped at α = 0.5 from the same initial state.
let mut vars_half = VarStore::new();
let diff_half = vars_half.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_half = MarginFactor::new(diff_half, 5.0, 1.0);
f_half.propagate_with_alpha(&mut vars_half, 0.5);
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Factor graph machinery for within-game inference.
use crate::gaussian::Gaussian;
/// Identifier for a variable in a `VarStore`.
///
/// Variables hold the current Gaussian marginal and are owned by exactly one
/// `VarStore`. `VarId` is meaningful only within its owning store.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct VarId(pub u32);
/// Flat storage of variable marginals.
///
/// Variables are allocated by `alloc()` and accessed by `VarId`. The store is
/// reused across `Game::ranked_with_arena` calls (it lives in the `ScratchArena`); call
/// `clear()` before reuse.
#[derive(Debug, Default)]
pub struct VarStore {
pub(crate) marginals: Vec<Gaussian>,
}
impl VarStore {
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.marginals.clear();
}
pub fn len(&self) -> usize {
self.marginals.len()
}
pub fn is_empty(&self) -> bool {
self.marginals.is_empty()
}
pub fn alloc(&mut self, init: Gaussian) -> VarId {
let id = VarId(self.marginals.len() as u32);
self.marginals.push(init);
id
}
pub fn get(&self, id: VarId) -> Gaussian {
self.marginals[id.0 as usize]
}
pub fn set(&mut self, id: VarId, g: Gaussian) {
self.marginals[id.0 as usize] = g;
}
}
/// A factor in the EP graph.
///
/// Factors hold their own outgoing messages and propagate them by reading
/// connected variable marginals from a `VarStore` and writing back updated
/// marginals.
pub trait Factor: Send + Sync {
/// Update outgoing messages and write back to the var store.
///
/// Returns the max delta `(|Δmu|, |Δsigma|)` across writes this
/// propagation. Used by the `Schedule` to detect convergence.
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64);
/// Optional log-evidence contribution. Default 0.0 (no contribution).
fn log_evidence(&self, _vars: &VarStore) -> f64 {
0.0
}
}
/// Enum dispatcher for the built-in factor types.
///
/// Using an enum instead of `Box<dyn Factor>` keeps factor data inline and
/// avoids virtual-call overhead in the hot inference loop.
#[derive(Debug)]
pub enum BuiltinFactor {
TeamSum(team_sum::TeamSumFactor),
RankDiff(rank_diff::RankDiffFactor),
Trunc(trunc::TruncFactor),
Margin(margin::MarginFactor),
}
impl Factor for BuiltinFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
match self {
Self::TeamSum(f) => f.propagate(vars),
Self::RankDiff(f) => f.propagate(vars),
Self::Trunc(f) => f.propagate(vars),
Self::Margin(f) => f.propagate(vars),
}
}
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
}
pub mod margin;
pub mod rank_diff;
pub mod team_sum;
pub mod trunc;
#[cfg(test)]
mod tests {
use super::*;
use crate::N_INF;
#[test]
fn alloc_assigns_sequential_ids() {
let mut store = VarStore::new();
let a = store.alloc(N_INF);
let b = store.alloc(N_INF);
let c = store.alloc(N_INF);
assert_eq!(a, VarId(0));
assert_eq!(b, VarId(1));
assert_eq!(c, VarId(2));
assert_eq!(store.len(), 3);
}
#[test]
fn get_returns_initial_value() {
let mut store = VarStore::new();
let g = Gaussian::from_ms(2.5, 1.0);
let id = store.alloc(g);
assert_eq!(store.get(id), g);
}
#[test]
fn set_updates_value() {
let mut store = VarStore::new();
let id = store.alloc(N_INF);
let new = Gaussian::from_ms(3.0, 0.5);
store.set(id, new);
assert_eq!(store.get(id), new);
}
#[test]
fn clear_resets_length_keeping_capacity() {
let mut store = VarStore::new();
store.alloc(N_INF);
store.alloc(N_INF);
let cap = store.marginals.capacity();
store.clear();
assert_eq!(store.len(), 0);
assert_eq!(store.marginals.capacity(), cap);
}
#[test]
fn builtin_factor_dispatches_to_margin() {
use super::margin::MarginFactor;
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = BuiltinFactor::Margin(MarginFactor::new(diff, 5.0, 1.0));
f.propagate(&mut vars);
let result = vars.get(diff);
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
let logz = f.log_evidence(&vars);
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
}
}
+95
View File
@@ -0,0 +1,95 @@
use crate::factor::{Factor, VarId, VarStore};
/// Maintains the constraint `diff = team_a - team_b` between three vars.
///
/// 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).
/// - 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
/// var holds the EP-divide message that produces the cavity.
#[derive(Debug)]
pub struct RankDiffFactor {
pub team_a: VarId,
pub team_b: VarId,
pub diff: VarId,
}
impl Factor for RankDiffFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
let a = vars.get(self.team_a);
let b = vars.get(self.team_b);
let new_diff = a - b;
let old = vars.get(self.diff);
vars.set(self.diff, new_diff);
old.delta(new_diff)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{N_INF, gaussian::Gaussian};
#[test]
fn diff_of_two_known_gaussians() {
let mut vars = VarStore::new();
let team_a = vars.alloc(Gaussian::from_ms(25.0, 3.0));
let team_b = vars.alloc(Gaussian::from_ms(20.0, 4.0));
let diff = vars.alloc(N_INF);
let mut f = RankDiffFactor {
team_a,
team_b,
diff,
};
f.propagate(&mut vars);
let result = vars.get(diff);
// mu = 25 - 20 = 5; var = 9 + 16 = 25; sigma = 5
assert!((result.mu() - 5.0).abs() < 1e-12);
assert!((result.sigma() - 5.0).abs() < 1e-12);
}
#[test]
fn delta_zero_on_repeat() {
let mut vars = VarStore::new();
let team_a = vars.alloc(Gaussian::from_ms(10.0, 2.0));
let team_b = vars.alloc(Gaussian::from_ms(8.0, 1.0));
let diff = vars.alloc(N_INF);
let mut f = RankDiffFactor {
team_a,
team_b,
diff,
};
f.propagate(&mut vars);
let (dmu, dsig) = f.propagate(&mut vars);
assert!(dmu < 1e-12);
assert!(dsig < 1e-12);
}
#[test]
fn delta_reflects_team_change() {
let mut vars = VarStore::new();
let team_a = vars.alloc(Gaussian::from_ms(10.0, 1.0));
let team_b = vars.alloc(Gaussian::from_ms(0.0, 1.0));
let diff = vars.alloc(N_INF);
let mut f = RankDiffFactor {
team_a,
team_b,
diff,
};
f.propagate(&mut vars);
// change team_a, repropagate; delta should be positive
vars.set(team_a, Gaussian::from_ms(15.0, 1.0));
let (dmu, _dsig) = f.propagate(&mut vars);
assert!(dmu > 4.0, "expected ~5 delta, got {}", dmu);
}
}
+98
View File
@@ -0,0 +1,98 @@
use crate::{
N00,
factor::{Factor, VarId, VarStore},
gaussian::Gaussian,
};
/// Computes the weighted sum of player performances into a team-perf var.
///
/// Inputs are pre-computed player performance Gaussians (i.e., rating priors
/// already with beta² noise added via `Rating::performance()`). The factor
/// runs once per game and writes the weighted sum to the output var.
#[derive(Debug)]
pub struct TeamSumFactor {
pub inputs: Vec<(Gaussian, f64)>,
pub out: VarId,
}
impl Factor for TeamSumFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
let perf = self.inputs.iter().fold(N00, |acc, (g, w)| acc + (*g * *w));
let old = vars.get(self.out);
vars.set(self.out, perf);
old.delta(perf)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::N_INF;
#[test]
fn single_player_unit_weight() {
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let g = Gaussian::from_ms(25.0, 5.0);
let mut f = TeamSumFactor {
inputs: vec![(g, 1.0)],
out,
};
f.propagate(&mut vars);
let result = vars.get(out);
assert!((result.mu() - 25.0).abs() < 1e-12);
assert!((result.sigma() - 5.0).abs() < 1e-12);
}
#[test]
fn two_players_summed() {
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let g1 = Gaussian::from_ms(20.0, 3.0);
let g2 = Gaussian::from_ms(30.0, 4.0);
let mut f = TeamSumFactor {
inputs: vec![(g1, 1.0), (g2, 1.0)],
out,
};
f.propagate(&mut vars);
let result = vars.get(out);
// sum: mu = 20 + 30 = 50, var = 9 + 16 = 25, sigma = 5
assert!((result.mu() - 50.0).abs() < 1e-12);
assert!((result.sigma() - 5.0).abs() < 1e-12);
}
#[test]
fn weighted_inputs() {
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let g = Gaussian::from_ms(10.0, 2.0);
let mut f = TeamSumFactor {
inputs: vec![(g, 2.0)],
out,
};
f.propagate(&mut vars);
let result = vars.get(out);
// g * 2.0: mu = 10*2 = 20, sigma = 2*2 = 4
assert!((result.mu() - 20.0).abs() < 1e-12);
assert!((result.sigma() - 4.0).abs() < 1e-12);
}
#[test]
fn delta_is_zero_on_repeat_propagate() {
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let g = Gaussian::from_ms(5.0, 1.0);
let mut f = TeamSumFactor {
inputs: vec![(g, 1.0)],
out,
};
f.propagate(&mut vars);
let (dmu, dsig) = f.propagate(&mut vars);
assert!(dmu < 1e-12, "expected ~0 delta on repeat, got {}", dmu);
assert!(dsig < 1e-12);
}
}
+191
View File
@@ -0,0 +1,191 @@
use crate::{
N_INF, approx, cdf,
factor::{Factor, VarId, VarStore},
gaussian::Gaussian,
};
/// EP truncation factor on a diff variable.
///
/// Implements the rectified-Gaussian approximation that turns a diff
/// distribution into a "this team rank-beats that team" or "tied" likelihood.
/// Stores its outgoing message to the diff variable so the cavity computation
/// produces the correct EP message on each propagation.
#[derive(Debug)]
pub struct TruncFactor {
pub diff: VarId,
pub margin: f64,
pub tie: bool,
/// 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 {
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
Self {
diff,
margin,
tie,
msg: N_INF,
evidence_cached: None,
}
}
}
impl TruncFactor {
/// Propagate this factor's message, optionally damping the update in
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
}
let trunc = approx(cavity, self.margin, self.tie);
let new_msg = trunc / cavity;
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg;
self.msg = damped;
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
// `trunc` (since cavity * new_msg = trunc by construction); with
// alpha < 1.0 it reflects the partially-applied update.
vars.set(self.diff, cavity * damped);
old_msg.delta(damped)
}
}
impl Factor for TruncFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0)
}
fn log_evidence(&self, _vars: &VarStore) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln()
}
}
/// P(diff > margin) for non-tie, P(|diff| < margin) for tie.
///
/// Clamped to a positive floor: for a near-certain outcome the tail rounds to
/// exactly 0.0, and the `erfc` approximation used by `cdf` carries ~1e-7 error
/// so it can even return slightly more than 1.0, making the difference
/// negative. Either would send `log_evidence` to `-inf` or NaN and poison the
/// sum across the whole history.
fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
let raw = if tie {
cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma())
} else {
1.0 - cdf(margin, diff.mu(), diff.sigma())
};
raw.clamp(f64::MIN_POSITIVE, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::factor::VarStore;
#[test]
fn idempotent_after_convergence() {
// After enough iterations, propagate should return ~0 delta.
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f = TruncFactor::new(diff, 0.0, false);
// Propagate many times; delta should drop toward 0.
let mut last = (f64::INFINITY, f64::INFINITY);
for _ in 0..20 {
last = f.propagate(&mut vars);
}
assert!(last.0 < 1e-10, "expected converged delta, got {}", last.0);
assert!(last.1 < 1e-10);
}
#[test]
fn evidence_cached_on_first_propagate() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f = TruncFactor::new(diff, 0.0, false);
assert!(f.evidence_cached.is_none());
f.propagate(&mut vars);
assert!(f.evidence_cached.is_some());
let first = f.evidence_cached.unwrap();
// Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748
assert!(first > 0.7);
assert!(first < 0.8);
// Subsequent propagations don't change it.
f.propagate(&mut vars);
assert_eq!(f.evidence_cached.unwrap(), first);
}
#[test]
fn tie_evidence_uses_two_sided() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 2.0));
let mut f = TruncFactor::new(diff, 1.0, true);
f.propagate(&mut vars);
// For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383
let ev = f.evidence_cached.unwrap();
assert!(ev > 0.35 && ev < 0.42);
}
#[test]
fn propagate_with_alpha_one_matches_undamped_propagate() {
let mut vars_a = VarStore::new();
let diff_a = vars_a.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_a = TruncFactor::new(diff_a, 0.0, false);
let delta_a = f_a.propagate(&mut vars_a);
let result_a = vars_a.get(diff_a);
let mut vars_b = VarStore::new();
let diff_b = vars_b.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_b = TruncFactor::new(diff_b, 0.0, false);
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
let result_b = vars_b.get(diff_b);
assert_eq!(result_a.pi(), result_b.pi());
assert_eq!(result_a.tau(), result_b.tau());
assert_eq!(delta_a, delta_b);
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
}
#[test]
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
// Run undamped to capture (initial_msg, undamped_new_msg).
let mut vars_full = VarStore::new();
let diff_full = vars_full.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_full = TruncFactor::new(diff_full, 0.0, false);
let initial_msg_pi = f_full.msg.pi();
let initial_msg_tau = f_full.msg.tau();
f_full.propagate(&mut vars_full);
let undamped_msg_pi = f_full.msg.pi();
let undamped_msg_tau = f_full.msg.tau();
// Run damped at α = 0.5 from the same initial state.
let mut vars_half = VarStore::new();
let diff_half = vars_half.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_half = TruncFactor::new(diff_half, 0.0, false);
f_half.propagate_with_alpha(&mut vars_half, 0.5);
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Factor-graph public API.
//!
//! Power users can construct custom factor graphs via `Game::custom` (T2
//! minimal; full ergonomics in T4) and drive them with custom `Schedule`
//! implementations.
pub use crate::{
factor::{
BuiltinFactor, Factor, VarId, VarStore, margin::MarginFactor, rank_diff::RankDiffFactor,
team_sum::TeamSumFactor, trunc::TruncFactor,
},
schedule::{EpsilonOrMax, Schedule, ScheduleReport},
};
+889 -190
View File
File diff suppressed because it is too large Load Diff
+256 -152
View File
@@ -2,143 +2,210 @@ use std::ops;
use crate::{MU, N_INF, SIGMA};
/// A Gaussian distribution stored in natural parameters.
///
/// `pi = 1 / sigma^2` (precision)
/// `tau = mu * pi` (precision-adjusted mean)
///
/// Multiplication and division in message passing become pure adds/subs of
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
/// `sigma()` are accessors computed on demand.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Gaussian {
pub mu: f64,
pub sigma: f64,
pi: f64,
tau: f64,
}
impl Gaussian {
/// Construct from mean and standard deviation.
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
Gaussian { mu, sigma }
}
fn pi(&self) -> f64 {
if self.sigma > 0.0 {
self.sigma.powi(-2)
} else {
f64::INFINITY
}
}
fn tau(&self) -> f64 {
if self.sigma > 0.0 {
self.mu * self.pi()
} else {
f64::INFINITY
}
}
pub(crate) fn delta(&self, m: Gaussian) -> (f64, f64) {
((self.mu - m.mu).abs(), (self.sigma - m.sigma).abs())
}
pub(crate) fn exclude(&self, m: Gaussian) -> Self {
if sigma == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
} else if sigma == 0.0 {
// Point mass at mu. tau = mu * pi = mu * inf.
// For mu == 0 this is 0; for mu != 0 it is inf * mu = inf (IEEE).
// Only N00 (mu=0, sigma=0) is used in practice.
Self {
mu: self.mu - m.mu,
sigma: (self.sigma.powi(2) - m.sigma.powi(2)).sqrt(),
pi: f64::INFINITY,
tau: if mu == 0.0 { 0.0 } else { f64::INFINITY },
}
} else {
let pi = 1.0 / (sigma * sigma);
Self { pi, tau: mu * pi }
}
}
/// Construct from mean and *variance*, skipping the square-root round trip.
///
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to
/// recover `pi = 1/var`. Variance-combining operations (`Add`, `Sub`,
/// `exclude`, `forget`) work in variance space throughout, so they go
/// through here instead and never take a root.
#[inline]
pub(crate) fn from_mv(mu: f64, var: f64) -> Self {
if var == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
} else if var == 0.0 {
// Point mass at mu; see `from_ms` for the tau convention.
Self {
pi: f64::INFINITY,
tau: if mu == 0.0 { 0.0 } else { f64::INFINITY },
}
} else {
let pi = 1.0 / var;
Self { pi, tau: mu * pi }
}
}
/// Construct directly from natural parameters.
#[inline]
pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self {
Self { pi, tau }
}
#[inline]
pub fn pi(&self) -> f64 {
self.pi
}
#[inline]
pub fn tau(&self) -> f64 {
self.tau
}
#[inline]
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
// `pi` on a tiny negative value (round-off of exactly zero); without this guard
// `tau / pi` would yield a spurious finite mean.
if self.pi <= 0.0 {
0.0
} else {
self.tau / self.pi
}
}
/// Variance, `1 / pi`, without the root-and-square of `sigma().powi(2)`.
///
/// Mirrors `sigma()`'s treatment of the improper (`pi <= 0`) and point-mass
/// (`pi == inf`) cases.
#[inline]
pub(crate) fn variance(&self) -> f64 {
if self.pi <= 0.0 {
f64::INFINITY
} else if self.pi.is_infinite() {
0.0
} else {
1.0 / self.pi
}
}
#[inline]
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
// cancellation produces a tiny negative precision (round-off of exactly zero).
if self.pi <= 0.0 {
f64::INFINITY
} else if self.pi.is_infinite() {
0.0
} else {
1.0 / self.pi.sqrt()
}
}
pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) {
(
(self.mu() - other.mu()).abs(),
(self.sigma() - other.sigma()).abs(),
)
}
pub(crate) fn exclude(&self, other: Gaussian) -> Self {
let var = self.variance() - other.variance();
if var <= 0.0 {
// When sigma_self ≈ sigma_other (including ULP-level rounding differences
// from the pi→sigma accessor round-trip), the excluded contribution is N00.
// Computing from_ms(tiny_mu, 0.0) would give {pi:inf, tau:inf}, whose
// mu() = inf/inf = NaN. Returning N00 is correct: when both Gaussians
// carry the same variance, the residual is a point mass at 0.
return Gaussian::from_mv(0.0, 0.0);
}
Self::from_mv(self.mu() - other.mu(), var)
}
pub(crate) fn forget(&self, variance_delta: f64) -> Self {
Self {
mu: self.mu,
sigma: (self.sigma.powi(2) + variance_delta).sqrt(),
Self::from_mv(self.mu(), self.variance() + variance_delta)
}
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
///
/// 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.
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(),
alpha * new.tau() + (1.0 - alpha) * self.tau(),
)
}
}
impl Default for Gaussian {
fn default() -> Self {
Self {
mu: MU,
sigma: SIGMA,
}
Self::from_ms(MU, SIGMA)
}
}
impl ops::Add<Gaussian> for Gaussian {
type Output = Gaussian;
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)).
/// Used for combining performance and noise; rare relative to mul/div.
fn add(self, rhs: Gaussian) -> Self::Output {
Gaussian {
mu: self.mu + rhs.mu,
sigma: (self.sigma.powi(2) + rhs.sigma.powi(2)).sqrt(),
}
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
}
}
impl ops::Sub<Gaussian> for Gaussian {
type Output = Gaussian;
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
fn sub(self, rhs: Gaussian) -> Self::Output {
Gaussian {
mu: self.mu - rhs.mu,
sigma: (self.sigma.powi(2) + rhs.sigma.powi(2)).sqrt(),
}
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
}
}
impl ops::Mul<Gaussian> for Gaussian {
type Output = Gaussian;
/// Factor product: nat-param add. Hot path — two f64 additions, no sqrt.
fn mul(self, rhs: Gaussian) -> Self::Output {
let (mu, sigma) = if self.sigma == 0.0 || rhs.sigma == 0.0 {
let mu = self.mu / (self.sigma.powi(2) / rhs.sigma.powi(2) + 1.0)
+ rhs.mu / (rhs.sigma.powi(2) / self.sigma.powi(2) + 1.0);
let sigma = (1.0 / ((1.0 / self.sigma.powi(2)) + (1.0 / rhs.sigma.powi(2)))).sqrt();
(mu, sigma)
} else {
mu_sigma(self.tau() + rhs.tau(), self.pi() + rhs.pi())
};
Gaussian { mu, sigma }
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
}
}
impl ops::Mul<f64> for Gaussian {
type Output = Gaussian;
fn mul(self, rhs: f64) -> Self::Output {
if rhs.is_finite() {
Self {
mu: self.mu * rhs,
sigma: self.sigma * rhs,
fn mul(self, scalar: f64) -> Self::Output {
if !scalar.is_finite() {
return N_INF;
}
} else {
N_INF
if scalar == 0.0 {
// Scaling by 0 collapses to a point mass at 0 (sigma' = 0, mu' = 0).
// This is N00, the additive identity, NOT N_INF.
return Gaussian::from_mv(0.0, 0.0);
}
// sigma' = sigma * |scalar| => pi' = pi / scalar²
// mu' = mu * scalar => tau' = tau / scalar
Self::from_natural(self.pi / (scalar * scalar), self.tau / scalar)
}
}
impl ops::Div<Gaussian> for Gaussian {
type Output = Gaussian;
/// Cavity: nat-param sub. Hot path — two f64 subtractions, no sqrt.
fn div(self, rhs: Gaussian) -> Self::Output {
let (mu, sigma) = if self.sigma == 0.0 || rhs.sigma == 0.0 {
let mu = self.mu / (1.0 - self.sigma.powi(2) / rhs.sigma.powi(2))
+ rhs.mu / (rhs.sigma.powi(2) / self.sigma.powi(2) - 1.0);
let sigma = (1.0 / ((1.0 / self.sigma.powi(2)) - (1.0 / rhs.sigma.powi(2)))).sqrt();
(mu, sigma)
} else {
mu_sigma(self.tau() - rhs.tau(), self.pi() - rhs.pi())
};
Gaussian { mu, sigma }
}
}
fn mu_sigma(tau: f64, pi: f64) -> (f64, f64) {
if pi > 0.0 {
(tau / pi, (1.0 / pi).sqrt())
} else if (pi + 1e-5) < 0.0 {
panic!("precision should be greater than 0");
} else {
(0.0, f64::INFINITY)
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
}
@@ -147,86 +214,123 @@ mod tests {
use super::*;
#[test]
fn test_add() {
let n = Gaussian {
mu: 25.0,
sigma: 25.0 / 3.0,
};
fn non_positive_precision_is_improper_not_nan() {
// EP message cancellation can leave `pi` a tiny negative (round-off of exactly zero).
// Such a Gaussian is improper/uninformative: mu() must be 0 and sigma() infinite, not
// NaN. A NaN here propagates through the moment-space `Sub` in the game chain and
// poisons every skill in the slice.
let tiny_neg = Gaussian::from_natural(-5.55e-17, -8.88e-16);
assert_eq!(tiny_neg.mu(), 0.0);
assert!(tiny_neg.sigma().is_infinite());
let m = Gaussian {
mu: 0.0,
sigma: 1.0,
};
// A frankly-negative precision is treated the same way.
let neg = Gaussian::from_natural(-1.0, 2.0);
assert_eq!(neg.mu(), 0.0);
assert!(neg.sigma().is_infinite());
assert_eq!(
n + m,
Gaussian {
mu: 25.0,
sigma: 8.393118874676116
// Subtracting such a message must not produce NaN (the original failure path).
let proper = Gaussian::from_ms(9.75, 1.256);
let diff = proper - tiny_neg;
assert!(diff.pi().is_finite() && !diff.pi().is_nan());
assert!(diff.tau().is_finite() && !diff.tau().is_nan());
}
);
#[test]
fn test_add() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0);
let r = n + m;
assert!((r.mu() - 25.0).abs() < 1e-12);
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
}
#[test]
fn test_sub() {
let n = Gaussian {
mu: 25.0,
sigma: 25.0 / 3.0,
};
let m = Gaussian {
mu: 1.0,
sigma: 1.0,
};
assert_eq!(
n - m,
Gaussian {
mu: 24.0,
sigma: 8.393118874676116
}
);
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(1.0, 1.0);
let r = n - m;
assert!((r.mu() - 24.0).abs() < 1e-12);
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
}
#[test]
fn test_mul() {
let n = Gaussian {
mu: 25.0,
sigma: 25.0 / 3.0,
};
let m = Gaussian {
mu: 0.0,
sigma: 1.0,
};
assert_eq!(
n * m,
Gaussian {
mu: 0.35488958990536273,
sigma: 0.992876838486922
}
);
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0);
let r = n * m;
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
}
#[test]
fn test_div() {
let n = Gaussian {
mu: 25.0,
sigma: 25.0 / 3.0,
};
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0);
let r = m / n;
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
}
let m = Gaussian {
mu: 0.0,
sigma: 1.0,
};
#[test]
fn test_n00_is_add_identity() {
// N00 (sigma=0) is the additive identity for the variance-convolution Add op.
// N_INF (sigma=inf) is the identity for the EP-product Mul op.
let g = Gaussian::from_ms(3.0, 2.0);
let n00 = Gaussian::from_ms(0.0, 0.0);
let r = n00 + g;
assert!((r.mu() - g.mu()).abs() < 1e-12);
assert!((r.sigma() - g.sigma()).abs() < 1e-12);
}
assert_eq!(
m / n,
Gaussian {
mu: -0.3652597402597402,
sigma: 1.0072787050317253
#[test]
fn test_mul_is_factor_product() {
// n * m in nat-params should be pi_n + pi_m, tau_n + tau_m
let n = Gaussian::from_ms(2.0, 3.0);
let m = Gaussian::from_ms(1.0, 2.0);
let r = n * m;
let expected_pi = n.pi() + m.pi();
let expected_tau = n.tau() + m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15);
assert!((r.tau() - expected_tau).abs() < 1e-15);
}
);
#[test]
fn test_div_is_cavity() {
let n = Gaussian::from_ms(2.0, 1.0);
let m = Gaussian::from_ms(1.0, 2.0);
let r = n / m;
let expected_pi = n.pi() - m.pi();
let expected_tau = n.tau() - m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15);
assert!((r.tau() - expected_tau).abs() < 1e-15);
}
#[test]
fn damp_natural_alpha_one_returns_new() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 1.0);
assert_eq!(damped.pi(), new.pi());
assert_eq!(damped.tau(), new.tau());
}
#[test]
fn damp_natural_alpha_zero_returns_self() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 0.0);
assert_eq!(damped.pi(), old.pi());
assert_eq!(damped.tau(), old.tau());
}
#[test]
fn damp_natural_alpha_half_is_midpoint_in_natural_params() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 0.5);
let expected_pi = 0.5 * new.pi() + 0.5 * old.pi();
let expected_tau = 0.5 * new.tau() + 0.5 * old.tau();
assert!((damped.pi() - expected_pi).abs() < 1e-12);
assert!((damped.tau() - expected_tau).abs() < 1e-12);
}
}
+1423 -547
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
use std::{
borrow::{Borrow, ToOwned},
collections::HashMap,
hash::Hash,
};
use crate::Index;
/// Maps user keys to internal `Index` handles.
///
/// Renamed from the former `IndexMap` to avoid colliding with the `indexmap`
/// crate. Power users can promote `&K` to `Index` via `get_or_create` and
/// skip the lookup on subsequent hot-path calls.
#[derive(Debug)]
pub struct KeyTable<K> {
forward: HashMap<K, Index>,
/// Reverse mapping, indexed by `Index.0`.
///
/// Indices are handed out densely and sequentially, so position *is* the
/// index and `key()` is a lookup rather than a scan over every entry.
reverse: Vec<K>,
}
impl<K> KeyTable<K>
where
K: Eq + Hash + Clone,
{
pub fn new() -> Self {
Self {
forward: HashMap::new(),
reverse: Vec::new(),
}
}
pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
where
K: Borrow<Q>,
{
self.forward.get(k).cloned()
}
pub fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&mut self, k: &Q) -> Index
where
K: Borrow<Q>,
{
if let Some(idx) = self.forward.get(k) {
*idx
} else {
let idx = Index::from(self.reverse.len());
let owned = k.to_owned();
self.reverse.push(owned.clone());
self.forward.insert(owned, idx);
idx
}
}
pub fn key(&self, idx: Index) -> Option<&K> {
self.reverse.get(idx.0)
}
pub fn keys(&self) -> impl Iterator<Item = &K> {
self.forward.keys()
}
pub fn len(&self) -> usize {
self.reverse.len()
}
pub fn is_empty(&self) -> bool {
self.reverse.is_empty()
}
}
impl<K> Default for KeyTable<K>
where
K: Eq + Hash + Clone,
{
fn default() -> Self {
KeyTable::new()
}
}
+212 -101
View File
@@ -1,30 +1,138 @@
//! TrueSkill Through Time — Bayesian skill rating over a time axis.
//!
//! 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
//! being frozen guesses and comparisons across eras become meaningful.
//!
//! This is a Rust port of
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
//!
//! # Getting started
//!
//! Record results, converge, then read off skills:
//!
//! ```
//! use trueskill_tt::History;
//!
//! let mut history = History::default();
//!
//! history.record_winner(&"alice", &"bob", 1)?;
//! history.record_winner(&"bob", &"carol", 2)?;
//! history.record_winner(&"alice", &"carol", 3)?;
//!
//! let report = history.converge()?;
//! assert!(report.converged);
//!
//! let alice = history.current_skill("alice").unwrap();
//! assert!(alice.mu() > 0.0, "alice won every game she played");
//! # Ok::<(), trueskill_tt::InferenceError>(())
//! ```
//!
//! Teams, weights, explicit rankings and continuous scores go through the
//! fluent event builder:
//!
//! ```
//! use trueskill_tt::History;
//!
//! let mut history = History::builder().p_draw(0.1).build();
//!
//! history
//! .event(1)
//! .team(["alice", "bob"])
//! .team(["carol", "dave"])
//! .ranking([0, 1])
//! .commit()?;
//!
//! history.converge()?;
//! # Ok::<(), trueskill_tt::InferenceError>(())
//! ```
//!
//! # Draws need a draw probability
//!
//! A `p_draw` of zero asserts that draws cannot happen, so a tied result has
//! no representable likelihood and is rejected:
//!
//! ```
//! use trueskill_tt::{History, InferenceError};
//!
//! let mut history = History::default(); // p_draw defaults to 0.0
//! let err = history.record_draw(&"alice", &"bob", 1).unwrap_err();
//! assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. }));
//! ```
//!
//! This also applies to [`Outcome::winner`] for three or more teams, which
//! ties every loser. Configure a positive `p_draw` for those.
//!
//! # Core types
//!
//! - [`History`] — the top-level container: ingests events, runs
//! forward/backward message passing, and answers queries.
//! - [`Gaussian`] — the probability type, stored in natural parameters
//! (`pi = 1/sigma²`, `tau = mu/sigma²`) so message passing is add/subtract.
//! - [`Game`] — one match in isolation, for scoring a hypothetical without a
//! history.
//! - [`Outcome`] — how a match ended: ranks, or continuous scores.
//! - [`Rating`] — a competitor's static configuration (prior, `beta`, drift).
//!
//! # Feature flags
//!
//! - `approx` — implements [`approx`](https://docs.rs/approx) equality traits
//! for [`Gaussian`]. Useful in tests.
//! - `rayon` — parallelises the within-slice sweep and the per-slice passes of
//! `learning_curves`/`log_evidence`. Opt-in; results stay bit-identical
//! regardless of worker count.
#![forbid(unsafe_code)]
use std::{
borrow::{Borrow, ToOwned},
cmp::Reverse,
collections::HashMap,
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
hash::Hash,
};
pub mod agent;
#[cfg(feature = "approx")]
mod approx;
pub mod batch;
pub(crate) mod arena;
mod time;
mod time_slice;
pub use time_slice::{EventKind, TimeSlice};
mod color_group;
mod competitor;
mod convergence;
pub mod drift;
mod error;
mod event;
mod event_builder;
pub(crate) mod factor;
pub mod factors;
mod game;
pub mod gaussian;
mod history;
mod key_table;
mod matrix;
mod message;
pub mod player;
mod observer;
mod outcome;
mod rating;
pub(crate) mod schedule;
pub mod storage;
pub use competitor::Competitor;
pub use convergence::{ConvergenceOptions, ConvergenceReport};
pub use drift::{ConstantDrift, Drift};
pub use game::Game;
pub use error::InferenceError;
pub use event::{Event, Member, Team};
pub use event_builder::EventBuilder;
pub use game::{Game, GameOptions, OwnedGame};
pub use gaussian::Gaussian;
pub use history::History;
pub use history::{History, HistoryBuilder};
pub use key_table::KeyTable;
use matrix::Matrix;
use message::DiffMessage;
pub use player::Player;
pub use observer::{NullObserver, Observer};
pub use outcome::Outcome;
pub use rating::Rating;
pub use schedule::ScheduleReport;
pub use time::{Time, Untimed};
pub const BETA: f64 = 1.0;
pub const MU: f64 = 0.0;
@@ -43,64 +151,26 @@ pub const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
pub struct Index(usize);
impl Index {
/// The underlying slot number.
///
/// Indices are dense and assigned in interning order, so this is usable as
/// a key into a caller-side side table.
#[must_use]
pub fn get(self) -> usize {
self.0
}
}
impl From<usize> for Index {
fn from(ix: usize) -> Self {
Self(ix)
}
}
pub struct IndexMap<K>(HashMap<K, Index>);
impl<K> IndexMap<K>
where
K: Eq + Hash,
{
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<Index>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K>,
{
self.0.get(k).cloned()
}
pub fn get_or_create<Q: ?Sized>(&mut self, k: &Q) -> Index
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K>,
{
if let Some(idx) = self.0.get(k) {
*idx
} else {
let idx = Index::from(self.0.len());
self.0.insert(k.to_owned(), idx);
idx
}
}
pub fn key(&self, idx: Index) -> Option<&K> {
self.0
.iter()
.find(|&(_, value)| *value == idx)
.map(|(key, _)| key)
}
pub fn keys(&self) -> impl Iterator<Item = &K> {
self.0.keys()
}
}
impl<K> Default for IndexMap<K>
where
K: Eq + Hash,
{
fn default() -> Self {
IndexMap::new()
impl From<Index> for usize {
fn from(idx: Index) -> Self {
idx.0
}
}
@@ -158,7 +228,7 @@ fn compute_margin(p_draw: f64, sd: f64) -> f64 {
ppf(0.5 - p_draw / 2.0, 0.0, sd).abs()
}
fn cdf(x: f64, mu: f64, sigma: f64) -> f64 {
pub(crate) fn cdf(x: f64, mu: f64, sigma: f64) -> f64 {
let z = -(x - mu) / (sigma * SQRT_2);
0.5 * erfc(z)
@@ -203,9 +273,9 @@ fn trunc(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
}
pub(crate) fn approx(n: Gaussian, margin: f64, tie: bool) -> Gaussian {
let (mu, sigma) = trunc(n.mu, n.sigma, margin, tie);
let (mu, sigma) = trunc(n.mu(), n.sigma(), margin, tie);
Gaussian { mu, sigma }
Gaussian::from_ms(mu, sigma)
}
pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) {
@@ -219,41 +289,89 @@ pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
t.0 > e || t.1 > e
}
pub(crate) fn sort_perm(x: &[f64], reverse: bool) -> Vec<usize> {
let mut v = x.iter().enumerate().collect::<Vec<_>>();
if reverse {
v.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap());
} else {
v.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
/// Whether a convergence step is finite in both components.
///
/// A NaN step means EP broke down numerically. Because every comparison
/// against NaN is false, `tuple_gt` reads NaN as "below epsilon" — so
/// convergence checks must test finiteness explicitly rather than inferring
/// success from `!tuple_gt(..)`.
pub(crate) fn step_is_finite(t: (f64, f64)) -> bool {
t.0.is_finite() && t.1.is_finite()
}
v.into_iter().map(|(i, _)| i).collect()
/// Whether a step counts as converged: finite *and* within `epsilon`.
pub(crate) fn step_converged(t: (f64, f64), epsilon: f64) -> bool {
step_is_finite(t) && !tuple_gt(t, epsilon)
}
pub(crate) fn sort_time(xs: &[i64], reverse: bool) -> Vec<usize> {
let mut x = xs.iter().enumerate().collect::<Vec<_>>();
/// Indices of the first pair of teams sharing a rank, if any.
///
/// A tie is only representable when the draw probability is positive: with
/// `p_draw == 0.0` the truncation margin collapses to zero and the two-sided
/// tie update evaluates `0/0`. Callers use this to reject such events before
/// they reach inference.
pub(crate) fn first_tied_pair(ranks: &[u32]) -> Option<(usize, usize)> {
for (i, a) in ranks.iter().enumerate() {
for (j, b) in ranks.iter().enumerate().skip(i + 1) {
if a == b {
return Some((i, j));
}
}
}
None
}
/// As `first_tied_pair`, but over the engine's internal `f64` outputs.
///
/// Ranks reach the engine already converted to descending `f64` outputs, and
/// `Game` decides a tie by exact equality of those values — so this mirrors
/// the comparison inference itself performs.
pub(crate) fn first_tied_output(outputs: &[f64]) -> Option<(usize, usize)> {
for (i, a) in outputs.iter().enumerate() {
for (j, b) in outputs.iter().enumerate().skip(i + 1) {
if a == b {
return Some((i, j));
}
}
}
None
}
pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect();
if reverse {
x.sort_by_key(|&(_, x)| Reverse(x));
x.sort_by_key(|&(_, t)| Reverse(t));
} else {
x.sort_by_key(|&(_, x)| x);
x.sort_by_key(|&(_, t)| t);
}
x.into_iter().map(|(i, _)| i).collect()
}
pub(crate) fn evidence(d: &[DiffMessage], margin: &[f64], tie: &[bool], e: usize) -> f64 {
if tie[e] {
cdf(margin[e], d[e].prior.mu, d[e].prior.sigma)
- cdf(-margin[e], d[e].prior.mu, d[e].prior.sigma)
} else {
1.0 - cdf(margin[e], d[e].prior.mu, d[e].prior.sigma)
}
}
/// Calculates the match quality of the given rating groups. A result is the draw probability in the association
///
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
/// perfectly balanced match.
///
/// # Panics
///
/// 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.
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
assert!(
rating_groups.len() >= 2,
"quality() requires at least 2 rating groups, got {}",
rating_groups.len()
);
assert!(
rating_groups.iter().all(|group| !group.is_empty()),
"quality() requires every rating group to be non-empty"
);
let flatten_ratings = rating_groups
.iter()
.flat_map(|group| group.iter())
@@ -266,19 +384,21 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
let mut mean_matrix = Matrix::new(length, 1);
for (i, rating) in flatten_ratings.iter().enumerate() {
mean_matrix[(i, 0)] = rating.mu;
mean_matrix[(i, 0)] = rating.mu();
}
let mut variance_matrix = Matrix::new(length, length);
for (i, rating) in flatten_ratings.iter().enumerate() {
variance_matrix[(i, i)] = rating.sigma.powi(2);
variance_matrix[(i, i)] = rating.sigma().powi(2);
}
let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length);
// Row `row` contrasts group `row` (+weight) against group `row + 1`
// (-weight). `t` is the column where the current group's players start;
// the negative block begins immediately after it.
let mut t = 0;
let mut x = 0;
for (row, group) in rating_groups.windows(2).enumerate() {
let current = group[0];
@@ -286,17 +406,13 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
for n in t..t + current.len() {
rotated_a_matrix[(row, n)] = flatten_weights[n];
x += 1;
}
t += current.len();
for n in x..x + next.len() {
for n in t..t + next.len() {
rotated_a_matrix[(row, n)] = -flatten_weights[n];
}
x += next.len();
}
let a_matrix = rotated_a_matrix.transpose();
@@ -320,14 +436,9 @@ mod tests {
use super::*;
#[test]
fn test_sort_perm() {
assert_eq!(sort_perm(&[0.0, 1.0, 2.0, 0.0], true), vec![2, 1, 0, 3]);
}
#[test]
fn test_sort_time() {
assert_eq!(sort_time(&[0, 1, 2, 0], true), vec![2, 1, 0, 3]);
assert_eq!(sort_time(&[0i64, 1, 2, 0], true), vec![2, 1, 0, 3]);
}
#[test]
+316 -119
View File
@@ -1,29 +1,13 @@
//! Minimal dense matrix used by `quality()`.
//!
//! `determinant` and `inverse` go through one LU decomposition with partial
//! pivoting — O(n³) and numerically stable. The previous implementation
//! expanded cofactors recursively (O(n!), allocating a `Vec` per minor) and
//! only implemented `inverse` for the 1×1 case, which limited `quality()` to
//! exactly two rating groups.
use std::ops;
fn det(m: &[f64], x: usize) -> f64 {
if x == 1 {
m[0]
} else if x == 2 {
m[0] * m[3] - m[1] * m[2]
} else {
let mut d = 0.0;
for n in 0..x {
let ms = m
.iter()
.enumerate()
.skip(x)
.filter(|(i, _)| (i % x) != n)
.map(|(_, v)| *v)
.collect::<Vec<_>>();
d += (-1.0f64).powi(n as i32) * m[n] * det(&ms, x - 1);
}
d
}
}
#[derive(Clone, Debug)]
pub struct Matrix {
data: Box<[f64]>,
@@ -31,6 +15,107 @@ pub struct Matrix {
width: usize,
}
/// LU decomposition with partial pivoting: `PA = LU`, stored compactly.
///
/// `lu` holds `L` below the diagonal (unit diagonal implied) and `U` on and
/// above it. `sign` is the determinant sign contributed by row swaps, or 0.0
/// when the matrix is singular.
struct Lu {
lu: Vec<f64>,
perm: Vec<usize>,
n: usize,
sign: f64,
}
impl Lu {
fn decompose(m: &Matrix) -> Self {
debug_assert_eq!(m.width, m.height, "LU requires a square matrix");
let n = m.width;
let mut lu = m.data.to_vec();
let mut perm: Vec<usize> = (0..n).collect();
let mut sign = 1.0;
for col in 0..n {
// Partial pivot: take the largest-magnitude candidate to limit
// growth of round-off in the elimination below.
let mut pivot_row = col;
let mut pivot_max = lu[col * n + col].abs();
for row in (col + 1)..n {
let candidate = lu[row * n + col].abs();
if candidate > pivot_max {
pivot_max = candidate;
pivot_row = row;
}
}
if pivot_max == 0.0 {
sign = 0.0;
continue;
}
if pivot_row != col {
for k in 0..n {
lu.swap(col * n + k, pivot_row * n + k);
}
perm.swap(col, pivot_row);
sign = -sign;
}
let pivot = lu[col * n + col];
for row in (col + 1)..n {
let factor = lu[row * n + col] / pivot;
lu[row * n + col] = factor;
for k in (col + 1)..n {
lu[row * n + k] -= factor * lu[col * n + k];
}
}
}
Self { lu, perm, n, sign }
}
fn determinant(&self) -> f64 {
if self.sign == 0.0 {
return 0.0;
}
let mut det = self.sign;
for i in 0..self.n {
det *= self.lu[i * self.n + i];
}
det
}
/// Solve `Ax = b` for a single column of the identity, giving one column
/// of the inverse.
fn solve_column(&self, col: usize, out: &mut [f64]) {
let n = self.n;
// Forward substitution through L, applying the row permutation.
for i in 0..n {
let mut sum = if self.perm[i] == col { 1.0 } else { 0.0 };
for (k, &solved) in out.iter().enumerate().take(i) {
sum -= self.lu[i * n + k] * solved;
}
out[i] = sum;
}
// Back substitution through U.
for i in (0..n).rev() {
let mut sum = out[i];
for (k, &solved) in out.iter().enumerate().skip(i + 1) {
sum -= self.lu[i * n + k] * solved;
}
out[i] = sum / self.lu[i * n + i];
}
}
}
impl Matrix {
pub fn new(height: usize, width: usize) -> Matrix {
Matrix {
@@ -52,73 +137,59 @@ impl Matrix {
matrix
}
pub fn minor(&self, row_n: usize, col_n: usize) -> Matrix {
let mut matrix = Matrix::new(self.height - 1, self.width - 1);
let mut nr = 0;
for r in 0..self.height {
if r == row_n {
continue;
}
let mut nc = 0;
for c in 0..self.width {
if c == col_n {
continue;
}
matrix[(nr, nc)] = self[(r, c)];
nc += 1;
}
nr += 1;
}
matrix
}
/// Determinant of a square matrix. The 0×0 determinant is 1 by convention
/// (the empty product).
///
/// # Panics
///
/// Panics if the matrix is not square.
pub fn determinant(&self) -> f64 {
debug_assert!(self.width == self.height);
assert_eq!(
self.width, self.height,
"determinant requires a square matrix, got {}x{}",
self.height, self.width
);
det(&self.data, self.width)
if self.width == 0 {
return 1.0;
}
pub fn adjugate(&self) -> Matrix {
debug_assert!(self.width == self.height);
let mut matrix = Matrix::new(self.height, self.width);
if matrix.height == 2 {
matrix[(0, 0)] = self[(1, 1)];
matrix[(0, 1)] = -self[(0, 1)];
matrix[(1, 0)] = -self[(1, 0)];
matrix[(1, 1)] = self[(0, 0)];
} else {
for r in 0..matrix.height {
for c in 0..matrix.width {
let sign = if (r + c) % 2 == 0 { 1.0 } else { -1.0 };
matrix[(r, c)] = self.minor(r, c).determinant() * sign;
}
}
}
matrix
Lu::decompose(self).determinant()
}
/// Matrix inverse via LU decomposition.
///
/// # Panics
///
/// Panics if the matrix is not square or is singular.
pub fn inverse(&self) -> Matrix {
let mut matrix = Matrix::new(self.width, self.height);
assert_eq!(
self.width, self.height,
"inverse requires a square matrix, got {}x{}",
self.height, self.width
);
if self.height == self.width && self.height == 1 {
matrix[(0, 0)] = 1.0 / self[(0, 0)];
} else {
panic!("eh, okey")
let n = self.width;
let mut inverse = Matrix::new(n, n);
if n == 0 {
return inverse;
}
matrix
let lu = Lu::decompose(self);
assert!(lu.sign != 0.0, "cannot invert a singular matrix");
let mut column = vec![0.0; n];
for c in 0..n {
lu.solve_column(c, &mut column);
for (r, &value) in column.iter().enumerate() {
inverse[(r, c)] = value;
}
}
inverse
}
}
@@ -126,20 +197,62 @@ impl ops::Index<(usize, usize)> for Matrix {
type Output = f64;
fn index(&self, pos: (usize, usize)) -> &Self::Output {
debug_assert!(
pos.0 < self.height && pos.1 < self.width,
"index ({}, {}) out of bounds for {}x{} matrix",
pos.0,
pos.1,
self.height,
self.width
);
&self.data[(self.width * pos.0) + pos.1]
}
}
impl ops::IndexMut<(usize, usize)> for Matrix {
fn index_mut(&mut self, pos: (usize, usize)) -> &mut Self::Output {
debug_assert!(
pos.0 < self.height && pos.1 < self.width,
"index ({}, {}) out of bounds for {}x{} matrix",
pos.0,
pos.1,
self.height,
self.width
);
&mut self.data[(self.width * pos.0) + pos.1]
}
}
impl<'a> ops::Mul<&'a Matrix> for f64 {
fn multiply(lhs: &Matrix, rhs: &Matrix) -> Matrix {
assert_eq!(
lhs.width, rhs.height,
"cannot multiply {}x{} by {}x{}",
lhs.height, lhs.width, rhs.height, rhs.width
);
let mut matrix = Matrix::new(lhs.height, rhs.width);
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..lhs.width {
value += lhs[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
}
}
matrix
}
impl ops::Mul<&Matrix> for f64 {
type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix {
fn mul(self, rhs: &Matrix) -> Matrix {
let mut matrix = Matrix::new(rhs.height, rhs.width);
for r in 0..rhs.height {
@@ -152,54 +265,35 @@ impl<'a> ops::Mul<&'a Matrix> for f64 {
}
}
impl<'a> ops::Mul<&'a Matrix> for Matrix {
impl ops::Mul<&Matrix> for Matrix {
type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix {
let mut matrix = Matrix::new(self.height, rhs.width);
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..self.width {
value += self[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
fn mul(self, rhs: &Matrix) -> Matrix {
multiply(&self, rhs)
}
}
matrix
}
}
impl<'a> ops::Mul<&'a Matrix> for &'a Matrix {
impl ops::Mul<&Matrix> for &Matrix {
type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix {
let mut matrix = Matrix::new(self.height, rhs.width);
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..self.width {
value += self[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
fn mul(self, rhs: &Matrix) -> Matrix {
multiply(self, rhs)
}
}
matrix
}
}
impl<'a> ops::Add<&'a Matrix> for &'a Matrix {
impl ops::Add<&Matrix> for &Matrix {
type Output = Matrix;
fn add(self, rhs: &'a Matrix) -> Matrix {
fn add(self, rhs: &Matrix) -> Matrix {
assert!(
self.height == rhs.height && self.width == rhs.width,
"cannot add {}x{} to {}x{}",
self.height,
self.width,
rhs.height,
rhs.width
);
let mut matrix = Matrix::new(self.height, self.width);
for r in 0..matrix.height {
@@ -211,3 +305,106 @@ impl<'a> ops::Add<&'a Matrix> for &'a Matrix {
matrix
}
}
#[cfg(test)]
mod tests {
use super::*;
fn from_rows(rows: &[&[f64]]) -> Matrix {
let mut m = Matrix::new(rows.len(), rows[0].len());
for (r, row) in rows.iter().enumerate() {
for (c, &v) in row.iter().enumerate() {
m[(r, c)] = v;
}
}
m
}
#[test]
fn determinant_1x1() {
assert!((from_rows(&[&[3.0]]).determinant() - 3.0).abs() < 1e-12);
}
#[test]
fn determinant_2x2() {
let m = from_rows(&[&[1.0, 2.0], &[3.0, 4.0]]);
assert!((m.determinant() - (-2.0)).abs() < 1e-12);
}
#[test]
fn determinant_3x3() {
let m = from_rows(&[&[6.0, 1.0, 1.0], &[4.0, -2.0, 5.0], &[2.0, 8.0, 7.0]]);
assert!((m.determinant() - (-306.0)).abs() < 1e-10);
}
#[test]
fn determinant_requires_no_pivot_at_origin() {
// A zero in the top-left forces a row swap; the sign must follow.
let m = from_rows(&[&[0.0, 1.0], &[1.0, 0.0]]);
assert!((m.determinant() - (-1.0)).abs() < 1e-12);
}
#[test]
fn determinant_of_singular_is_zero() {
let m = from_rows(&[&[1.0, 2.0], &[2.0, 4.0]]);
assert!(m.determinant().abs() < 1e-12);
}
#[test]
fn inverse_1x1() {
let inv = from_rows(&[&[4.0]]).inverse();
assert!((inv[(0, 0)] - 0.25).abs() < 1e-12);
}
#[test]
fn inverse_times_original_is_identity() {
for rows in [
vec![vec![1.0, 2.0], vec![3.0, 4.0]],
vec![
vec![6.0, 1.0, 1.0],
vec![4.0, -2.0, 5.0],
vec![2.0, 8.0, 7.0],
],
vec![
vec![2.0, 0.0, 1.0, 3.0],
vec![1.0, 5.0, 2.0, 0.0],
vec![0.0, 1.0, 4.0, 1.0],
vec![3.0, 2.0, 0.0, 6.0],
],
] {
let refs: Vec<&[f64]> = rows.iter().map(|r| r.as_slice()).collect();
let m = from_rows(&refs);
let product = &m * &m.inverse();
for r in 0..product.height {
for c in 0..product.width {
let expected = if r == c { 1.0 } else { 0.0 };
assert!(
(product[(r, c)] - expected).abs() < 1e-9,
"({r},{c}) = {} expected {expected}",
product[(r, c)]
);
}
}
}
}
#[test]
#[should_panic(expected = "singular")]
fn inverse_of_singular_panics() {
let _ = from_rows(&[&[1.0, 2.0], &[2.0, 4.0]]).inverse();
}
#[test]
fn empty_determinant_is_one() {
assert!((Matrix::new(0, 0).determinant() - 1.0).abs() < 1e-12);
}
#[test]
fn transpose_round_trips() {
let m = from_rows(&[&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]]);
let t = m.transpose();
assert_eq!((t.height, t.width), (3, 2));
assert_eq!(t.transpose()[(1, 2)], m[(1, 2)]);
}
}
-81
View File
@@ -1,81 +0,0 @@
use crate::{N_INF, gaussian::Gaussian};
pub(crate) struct TeamMessage {
pub(crate) prior: Gaussian,
pub(crate) likelihood_lose: Gaussian,
pub(crate) likelihood_win: Gaussian,
pub(crate) likelihood_draw: Gaussian,
}
impl TeamMessage {
/*
pub(crate) fn p(&self) -> Gaussian {
self.prior * self.likelihood_lose * self.likelihood_win * self.likelihood_draw
}
*/
#[inline]
pub(crate) fn posterior_win(&self) -> Gaussian {
self.prior * self.likelihood_lose * self.likelihood_draw
}
#[inline]
pub(crate) fn posterior_lose(&self) -> Gaussian {
self.prior * self.likelihood_win * self.likelihood_draw
}
#[inline]
pub(crate) fn likelihood(&self) -> Gaussian {
self.likelihood_win * self.likelihood_lose * self.likelihood_draw
}
}
impl Default for TeamMessage {
fn default() -> Self {
Self {
prior: N_INF,
likelihood_lose: N_INF,
likelihood_win: N_INF,
likelihood_draw: N_INF,
}
}
}
/*
pub(crate) struct DrawMessage {
pub(crate) prior: Gaussian,
pub(crate) prior_team: Gaussian,
pub(crate) likelihood_lose: Gaussian,
pub(crate) likelihood_win: Gaussian,
}
impl DrawMessage {
pub(crate) fn p(&self) -> Gaussian {
self.prior_team * self.likelihood_lose * self.likelihood_win
}
pub(crate) fn posterior_win(&self) -> Gaussian {
self.prior_team * self.likelihood_lose
}
pub(crate) fn posterior_lose(&self) -> Gaussian {
self.prior_team * self.likelihood_win
}
pub(crate) fn likelihood(&self) -> Gaussian {
self.likelihood_win * self.likelihood_lose
}
}
*/
pub(crate) struct DiffMessage {
pub(crate) prior: Gaussian,
pub(crate) likelihood: Gaussian,
}
impl DiffMessage {
/*
pub(crate) fn p(&self) -> Gaussian {
self.prior * self.likelihood
}
*/
}
+47
View File
@@ -0,0 +1,47 @@
//! Observer trait for progress reporting during convergence.
//!
//! Replaces the old `verbose: bool` + `println!` path. Callers wire in any
//! observer that implements the trait; default methods are no-ops so users
//! override only what they need.
use crate::time::Time;
/// Receives progress callbacks during `History::converge`.
///
/// All methods have default no-op implementations; implement only what's
/// interesting.
pub trait Observer<T: Time>: Send + Sync {
/// Called after each convergence iteration across the whole history.
fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {}
/// Called after each time slice is processed within an iteration.
fn on_batch_processed(&self, _time: &T, _slice_idx: usize, _n_events: usize) {}
/// Called once when convergence completes (or max iters is reached).
fn on_converged(&self, _iters: usize, _final_step: (f64, f64), _converged: bool) {}
}
/// ZST no-op observer; the default when none is configured.
#[derive(Copy, Clone, Debug, Default)]
pub struct NullObserver;
impl<T: Time> Observer<T> for NullObserver {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_observer_compiles_for_i64() {
let o = NullObserver;
<NullObserver as Observer<i64>>::on_iteration_end(&o, 1, (0.0, 0.0));
<NullObserver as Observer<i64>>::on_converged(&o, 5, (1e-6, 1e-6), true);
}
#[test]
fn null_observer_compiles_for_untimed() {
use crate::Untimed;
let o = NullObserver;
<NullObserver as Observer<Untimed>>::on_iteration_end(&o, 1, (0.0, 0.0));
}
}
+185
View File
@@ -0,0 +1,185 @@
//! Outcome of a match.
//!
//! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for
//! continuous per-team scores (engages `MarginFactor` in the engine).
use smallvec::SmallVec;
/// Final outcome of a match.
///
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event.
///
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Outcome {
Ranked(SmallVec<[u32; 4]>),
Scored {
scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>,
},
}
impl Outcome {
/// `n`-team outcome where team `winner` won and everyone else tied for last.
///
/// Panics if `winner >= n`.
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();
Self::Ranked(ranks)
}
/// All `n` teams tied.
pub fn draw(n: u32) -> Self {
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
}
/// Explicit per-team ranking.
pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
Self::Ranked(ranks.into_iter().collect())
}
/// Explicit per-team continuous scores; higher = better.
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: None,
}
}
/// Explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive
/// or NaN sigma is allowed; the value is rejected with
/// `InferenceError::InvalidParameter` when the event is ingested, so
/// callers get an error rather than a panic.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: Some(sigma),
}
}
pub fn team_count(&self) -> usize {
match self {
Self::Ranked(r) => r.len(),
Self::Scored { scores, .. } => scores.len(),
}
}
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
match self {
Self::Ranked(r) => Some(r),
Self::Scored { .. } => None,
}
}
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
match self {
Self::Scored { scores, .. } => Some(scores),
Self::Ranked(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn winner_two_teams() {
let o = Outcome::winner(0, 2);
assert_eq!(o.as_ranks(), Some(&[0u32, 1][..]));
assert_eq!(o.team_count(), 2);
}
#[test]
fn winner_three_teams_second_wins() {
let o = Outcome::winner(1, 3);
assert_eq!(o.as_ranks(), Some(&[1u32, 0, 1][..]));
}
#[test]
fn draw_three_teams() {
let o = Outcome::draw(3);
assert_eq!(o.as_ranks(), Some(&[0u32, 0, 0][..]));
}
#[test]
fn ranking_from_iter() {
let o = Outcome::ranking([2, 0, 1]);
assert_eq!(o.as_ranks(), Some(&[2u32, 0, 1][..]));
}
#[test]
#[should_panic(expected = "winner index 2 out of range")]
fn winner_out_of_range_panics() {
let _ = Outcome::winner(2, 2);
}
#[test]
fn scored_two_teams() {
let o = Outcome::scores([10.0, 4.0]);
assert_eq!(o.team_count(), 2);
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
assert_eq!(o.as_ranks(), None);
}
#[test]
fn scored_team_count_matches_input() {
let o = Outcome::scores([3.0, 1.0, 2.0, 0.0]);
assert_eq!(o.team_count(), 4);
}
#[test]
fn ranked_as_scores_returns_none() {
let o = Outcome::winner(0, 2);
assert!(o.as_scores().is_none());
assert!(o.as_ranks().is_some());
}
#[test]
fn scores_with_sigma_round_trips() {
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
assert_eq!(o.team_count(), 2);
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
}
#[test]
fn scores_constructor_leaves_sigma_unset() {
let o = Outcome::scores([3.0, 1.0]);
match o {
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
#[test]
fn scores_with_sigma_sets_sigma_some() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
match o {
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
/// Construction accepts any sigma; the value is validated at ingestion so
/// callers receive an `InferenceError` rather than a panic. See
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
#[test]
fn scores_with_sigma_defers_validation_to_ingestion() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
match o {
Outcome::Scored { sigma, .. } => assert_eq!(sigma, Some(0.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
}
-32
View File
@@ -1,32 +0,0 @@
use crate::{
BETA, GAMMA,
drift::{ConstantDrift, Drift},
gaussian::Gaussian,
};
#[derive(Clone, Copy, Debug)]
pub struct Player<D: Drift = ConstantDrift> {
pub(crate) prior: Gaussian,
pub(crate) beta: f64,
pub(crate) drift: D,
}
impl<D: Drift> Player<D> {
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
Self { prior, beta, drift }
}
pub(crate) fn performance(&self) -> Gaussian {
self.prior.forget(self.beta.powi(2))
}
}
impl Default for Player<ConstantDrift> {
fn default() -> Self {
Self {
prior: Gaussian::default(),
beta: BETA,
drift: ConstantDrift(GAMMA),
}
}
}
+64
View File
@@ -0,0 +1,64 @@
use std::marker::PhantomData;
use crate::{
BETA, GAMMA,
drift::{ConstantDrift, Drift},
gaussian::Gaussian,
time::Time,
};
/// Static rating configuration: prior skill, performance noise `beta`, drift.
///
/// Renamed from `Player` in T2; `Rating` better describes the data
/// (a configuration) vs. a person (who's a `Competitor` with state).
#[derive(Clone, Copy, Debug)]
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub(crate) prior: Gaussian,
pub(crate) beta: f64,
pub(crate) drift: D,
pub(crate) _time: PhantomData<T>,
}
impl<T: Time, D: Drift<T>> Rating<T, D> {
pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self {
Self {
prior,
beta,
drift,
_time: PhantomData,
}
}
/// The configured prior skill estimate.
#[must_use]
pub fn prior(&self) -> Gaussian {
self.prior
}
/// Performance noise: how much a single showing varies around the skill.
#[must_use]
pub fn beta(&self) -> f64 {
self.beta
}
/// The drift model governing how skill may move between events.
#[must_use]
pub fn drift(&self) -> D {
self.drift
}
pub(crate) fn performance(&self) -> Gaussian {
self.prior.forget(self.beta.powi(2))
}
}
impl Default for Rating<i64, ConstantDrift> {
fn default() -> Self {
Self {
prior: Gaussian::default(),
beta: BETA,
drift: ConstantDrift(GAMMA),
_time: PhantomData,
}
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Schedule trait and built-in implementations.
//!
//! A schedule drives factor propagation to convergence. The default
//! `EpsilonOrMax` performs one TeamSum sweep (setup) then alternating
//! forward/backward sweeps over the iterating factors until the max
//! delta drops below epsilon or `max` iterations is reached.
use crate::factor::{BuiltinFactor, Factor, VarStore};
/// Result returned by a `Schedule::run` call.
#[derive(Debug, Clone, Copy)]
pub struct ScheduleReport {
pub iterations: usize,
pub final_step: (f64, f64),
pub converged: bool,
}
/// Drives factor propagation to convergence.
pub trait Schedule: Send + Sync {
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport;
}
/// Default schedule: sweep forward then backward until step ≤ eps or iter == max.
///
/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the
/// same factor layout (TeamSums first, then alternating RankDiff/Trunc pairs).
#[derive(Debug, Clone, Copy)]
pub struct EpsilonOrMax {
pub eps: f64,
pub max: usize,
}
impl Default for EpsilonOrMax {
fn default() -> Self {
// Derived from `ConvergenceOptions` so there is one source of truth for
// the tolerance and iteration cap. These previously disagreed: this
// default capped at 10 iterations while `ConvergenceOptions` allowed 30,
// and which applied depended on whether inference went through
// `run_chain` or a `Schedule`.
let defaults = crate::ConvergenceOptions::default();
Self {
eps: defaults.epsilon,
max: defaults.max_iter,
}
}
}
impl Schedule for EpsilonOrMax {
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport {
// Partition: leading run of TeamSum factors run exactly once (setup).
let n_setup = factors
.iter()
.position(|f| !matches!(f, BuiltinFactor::TeamSum(_)))
.unwrap_or(factors.len());
for f in factors[..n_setup].iter_mut() {
f.propagate(vars);
}
let mut iterations = 0;
// With no iterating factors the graph is already at its fixed point:
// the setup pass above is all there is to do. Reporting `converged:
// false` with an infinite step for that case gave callers a false
// negative.
let mut final_step = (0.0, 0.0);
let mut converged = true;
if n_setup < factors.len() {
final_step = (f64::INFINITY, f64::INFINITY);
converged = false;
for _ in 0..self.max {
let mut step = (0.0_f64, 0.0_f64);
// Forward sweep over iterating factors.
for f in factors[n_setup..].iter_mut() {
let d = f.propagate(vars);
step.0 = step.0.max(d.0);
step.1 = step.1.max(d.1);
}
// Backward sweep.
for f in factors[n_setup..].iter_mut().rev() {
let d = f.propagate(vars);
step.0 = step.0.max(d.0);
step.1 = step.1.max(d.1);
}
iterations += 1;
final_step = step;
if step.0 <= self.eps && step.1 <= self.eps {
converged = true;
break;
}
}
}
ScheduleReport {
iterations,
final_step,
converged,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{N_INF, factor::team_sum::TeamSumFactor, gaussian::Gaussian};
#[test]
fn schedule_runs_setup_factors_once() {
// Single TeamSum factor; schedule should propagate it exactly once and report 0 iterations.
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
inputs: vec![(Gaussian::from_ms(5.0, 1.0), 1.0)],
out,
})];
let schedule = EpsilonOrMax::default();
let report = schedule.run(&mut factors, &mut vars);
assert_eq!(report.iterations, 0);
// The team-perf var should hold the sum.
let result = vars.get(out);
assert!((result.mu() - 5.0).abs() < 1e-12);
}
#[test]
fn report_marks_converged_when_no_iterating_factors() {
// A graph of only setup factors has nothing to iterate, so it is at its
// fixed point after the setup pass: 0 iterations, and converged.
let mut vars = VarStore::new();
let out = vars.alloc(N_INF);
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
inputs: vec![(Gaussian::from_ms(0.0, 1.0), 1.0)],
out,
})];
let report = EpsilonOrMax::default().run(&mut factors, &mut vars);
assert_eq!(report.iterations, 0);
assert!(report.converged);
assert_eq!(report.final_step, (0.0, 0.0));
}
#[test]
fn default_matches_convergence_options() {
let schedule = EpsilonOrMax::default();
let options = crate::ConvergenceOptions::default();
assert_eq!(schedule.max, options.max_iter);
assert_eq!(schedule.eps, options.epsilon);
}
}
+127
View File
@@ -0,0 +1,127 @@
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
/// forward/backward sweep. Uses `Vec<Option<Competitor<T, D>>>` so slots can be
/// absent without an explicit present mask.
#[derive(Debug)]
pub struct CompetitorStore<T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
competitors: Vec<Option<Competitor<T, D>>>,
n_present: usize,
}
impl<T: Time, D: Drift<T>> Default for CompetitorStore<T, D> {
fn default() -> Self {
Self {
competitors: Vec::new(),
n_present: 0,
}
}
}
impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
pub fn new() -> Self {
Self::default()
}
fn ensure_capacity(&mut self, idx: usize) {
if idx >= self.competitors.len() {
self.competitors.resize_with(idx + 1, || None);
}
}
pub fn insert(&mut self, idx: Index, competitor: Competitor<T, D>) {
self.ensure_capacity(idx.0);
if self.competitors[idx.0].is_none() {
self.n_present += 1;
}
self.competitors[idx.0] = Some(competitor);
}
pub fn get(&self, idx: Index) -> Option<&Competitor<T, D>> {
self.competitors.get(idx.0).and_then(|slot| slot.as_ref())
}
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Competitor<T, D>> {
self.competitors
.get_mut(idx.0)
.and_then(|slot| slot.as_mut())
}
pub fn contains(&self, idx: Index) -> bool {
self.get(idx).is_some()
}
pub fn len(&self) -> usize {
self.n_present
}
pub fn is_empty(&self) -> bool {
self.n_present == 0
}
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
self.competitors
.iter()
.enumerate()
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Competitor<T, D>)> {
self.competitors
.iter_mut()
.enumerate()
.filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a)))
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
self.competitors.iter_mut().filter_map(|s| s.as_mut())
}
}
impl<T: Time, D: Drift<T>> std::ops::Index<Index> for CompetitorStore<T, D> {
type Output = Competitor<T, D>;
fn index(&self, idx: Index) -> &Competitor<T, D> {
self.get(idx).expect("competitor not found at index")
}
}
impl<T: Time, D: Drift<T>> std::ops::IndexMut<Index> for CompetitorStore<T, D> {
fn index_mut(&mut self, idx: Index) -> &mut Competitor<T, D> {
self.get_mut(idx).expect("competitor not found at index")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{competitor::Competitor, drift::ConstantDrift};
#[test]
fn insert_then_get() {
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
let idx = Index(7);
store.insert(idx, Competitor::default());
assert!(store.contains(idx));
assert_eq!(store.len(), 1);
assert!(store.get(idx).is_some());
}
#[test]
fn iter_in_index_order() {
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
store.insert(Index(2), Competitor::default());
store.insert(Index(0), Competitor::default());
store.insert(Index(5), Competitor::default());
let keys: Vec<Index> = store.iter().map(|(i, _)| i).collect();
assert_eq!(keys, vec![Index(0), Index(2), Index(5)]);
}
#[test]
fn index_operator_works() {
let mut store: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
store.insert(Index(3), Competitor::default());
let _ = &store[Index(3)];
}
}
+5
View File
@@ -0,0 +1,5 @@
mod competitor_store;
mod skill_store;
pub use competitor_store::CompetitorStore;
pub(crate) use skill_store::SkillStore;
+127
View File
@@ -0,0 +1,127 @@
use crate::{Index, time_slice::Skill};
/// Dense Vec-backed store for per-agent skill state within a TimeSlice.
///
/// 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.
#[derive(Debug, Default)]
pub struct SkillStore {
skills: Vec<Skill>,
present: Vec<bool>,
n_present: usize,
}
impl SkillStore {
pub fn new() -> Self {
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);
}
}
pub fn insert(&mut self, idx: Index, skill: Skill) {
self.ensure_capacity(idx.0);
if !self.present[idx.0] {
self.n_present += 1;
}
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
}
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
}
}
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
}
})
}
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 })
}
pub fn keys(&self) -> impl Iterator<Item = Index> + '_ {
self.present
.iter()
.enumerate()
.filter_map(|(i, &p)| if p { Some(Index(i)) } else { None })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_then_get() {
let mut store = SkillStore::new();
let idx = Index(3);
store.insert(idx, Skill::default());
assert!(store.contains(idx));
assert_eq!(store.len(), 1);
assert!(store.get(idx).is_some());
}
#[test]
fn missing_returns_none() {
let store = SkillStore::new();
assert!(store.get(Index(0)).is_none());
assert!(!store.contains(Index(42)));
}
#[test]
fn iter_skips_absent_slots() {
let mut store = SkillStore::new();
store.insert(Index(0), Skill::default());
store.insert(Index(5), Skill::default());
let keys: Vec<Index> = store.keys().collect();
assert_eq!(keys, vec![Index(0), Index(5)]);
}
#[test]
fn double_insert_does_not_double_count() {
let mut store = SkillStore::new();
store.insert(Index(2), Skill::default());
store.insert(Index(2), Skill::default());
assert_eq!(store.len(), 1);
}
}
+54
View File
@@ -0,0 +1,54 @@
//! Generic time axis for `History`.
//!
//! Users pick the `Time` type based on their domain: `Untimed` when no
//! time axis is meaningful, `i64` for integer day/second timestamps.
//! Additional impls can be added behind feature flags.
/// A timestamp on the global ordering axis.
///
/// Must be `Ord + Copy` so slices can sort events, and `'static` so
/// `History` can store it by value without lifetimes.
pub trait Time: Copy + Ord + Send + Sync + 'static {
/// How much time elapsed between `self` and `later`.
///
/// Used by `Drift<T>::variance_delta` to compute skill drift. Returning
/// zero means no drift accumulates between the two points. Return value
/// must be non-negative for `self <= later`.
fn elapsed_to(&self, later: &Self) -> i64;
}
/// Zero-sized type representing "no time axis."
///
/// Used as the default `Time` when events are unordered. Elapsed is always 0,
/// so no drift accumulates across slices.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Untimed;
impl Time for Untimed {
fn elapsed_to(&self, _later: &Self) -> i64 {
0
}
}
impl Time for i64 {
fn elapsed_to(&self, later: &Self) -> i64 {
later - self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untimed_elapsed_is_zero() {
assert_eq!(Untimed.elapsed_to(&Untimed), 0);
}
#[test]
fn i64_elapsed_is_difference() {
assert_eq!(5i64.elapsed_to(&10), 5);
assert_eq!(10i64.elapsed_to(&5), -5);
assert_eq!(0i64.elapsed_to(&0), 0);
}
}
+1092
View File
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
//! Tests for the new T2 public API surface: typed add_events(iter) and the
//! fluent event builder (added in Task 16).
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
#[test]
fn add_events_bulk_via_iter() {
let mut h = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.p_draw(0.0)
.drift(ConstantDrift(0.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
let events: Vec<Event<i64, &'static str>> = vec![
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 2,
teams: smallvec![
Team::with_members([Member::new("b")]),
Team::with_members([Member::new("c")]),
],
outcome: Outcome::winner(0, 2),
},
];
h.add_events(events).unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert!(h.lookup(&"a").is_some());
assert!(h.lookup(&"b").is_some());
assert!(h.lookup(&"c").is_some());
}
#[test]
fn add_events_draw() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.25)
.drift(ConstantDrift(25.0 / 300.0))
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("alice")]),
Team::with_members([Member::new("bob")]),
],
outcome: Outcome::draw(2),
}];
h.add_events(events).unwrap();
h.converge().unwrap();
}
#[test]
fn add_events_rejects_mismatched_outcome_ranks() {
use trueskill_tt::InferenceError;
let mut h: History = History::builder().build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::ranking([0, 1, 2]), // 3 ranks but 2 teams
}];
let err = h.add_events(events).unwrap_err();
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
}
#[test]
fn fluent_event_builder_basic() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.build();
h.event(1)
.team(["alice", "bob"])
.weights([1.0, 0.7])
.team(["carol"])
.ranking([1, 0])
.commit()
.unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert!(h.lookup(&"alice").is_some());
assert!(h.lookup(&"bob").is_some());
assert!(h.lookup(&"carol").is_some());
}
#[test]
fn fluent_event_builder_winner_convenience() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.build();
h.event(1)
.team(["alice"])
.team(["bob"])
.winner(0)
.commit()
.unwrap();
h.converge().unwrap();
}
#[test]
fn fluent_event_builder_draw() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.25)
.build();
h.event(1)
.team(["alice"])
.team(["bob"])
.draw()
.commit()
.unwrap();
h.converge().unwrap();
}
#[test]
fn current_skill_and_learning_curve() {
use trueskill_tt::History;
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"a", &"b", 2).unwrap();
h.converge().unwrap();
let a = h.current_skill(&"a").unwrap();
assert!(a.mu() > 25.0);
let b = h.current_skill(&"b").unwrap();
assert!(b.mu() < 25.0);
let a_curve = h.learning_curve(&"a");
assert_eq!(a_curve.len(), 2);
assert_eq!(a_curve[0].0, 1);
assert_eq!(a_curve[1].0, 2);
let all = h.learning_curves();
assert_eq!(all.len(), 2);
assert!(all.contains_key("a"));
assert!(all.contains_key("b"));
}
#[test]
fn log_evidence_total_vs_subset() {
use trueskill_tt::{ConstantDrift, History};
let mut h = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.p_draw(0.0)
.drift(ConstantDrift(0.0))
.build();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"a", 2).unwrap();
let total = h.log_evidence();
let a_only = h.log_evidence_for(&[&"a"]);
assert!(total.is_finite());
assert!(a_only.is_finite());
}
#[test]
fn predict_quality_two_teams() {
use trueskill_tt::History;
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.build();
h.record_winner(&"a", &"b", 1).unwrap();
h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"]]);
assert!(q > 0.0 && q <= 1.0);
}
#[test]
fn predict_outcome_two_teams_sums_to_one() {
use trueskill_tt::History;
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.p_draw(0.0)
.build();
h.record_winner(&"a", &"b", 1).unwrap();
h.converge().unwrap();
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]);
assert_eq!(p.len(), 2);
assert!((p[0] + p[1] - 1.0).abs() < 1e-9);
assert!(p[0] > p[1]);
}
#[test]
fn fluent_event_builder_scores() {
use trueskill_tt::ConstantDrift;
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.0))
.build();
h.event(1)
.team(["alice"])
.team(["bob"])
.scores([12.0, 4.0])
.commit()
.unwrap();
h.converge().unwrap();
let a = h.current_skill(&"alice").unwrap();
let b = h.current_skill(&"bob").unwrap();
assert!(a.mu() > b.mu());
}
+272
View File
@@ -0,0 +1,272 @@
//! Degenerate, boundary, and error-path coverage.
//!
//! 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.
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
NullObserver, Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
fn rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
)
}
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();
let err = h.record_draw(&"a", &"b", 1).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
#[test]
fn builder_draw_without_draw_probability_is_rejected() {
let mut h = History::default();
let err = h
.event(1)
.team(["a"])
.team(["b"])
.draw()
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
#[test]
fn draw_with_positive_draw_probability_is_finite() {
let mut h = History::builder().p_draw(0.25).build();
h.record_draw(&"a", &"b", 1).unwrap();
let report = h.converge().unwrap();
assert_finite(h.current_skill("a").unwrap(), "drawn competitor skill");
assert_finite(h.current_skill("b").unwrap(), "drawn competitor skill");
assert!(report.log_evidence.is_finite());
assert!(report.converged);
}
#[test]
fn game_ranked_rejects_tie_without_draw_probability() {
let a = [rating()];
let b = [rating()];
let teams: Vec<&[R]> = vec![&a, &b];
let err = Game::ranked(&teams, Outcome::draw(2), &GameOptions::default()).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
/// `Outcome::winner(w, n)` ties every loser, so any n >= 3 free-for-all hits
/// the tie path even though the caller never asked for a draw.
#[test]
fn winner_of_three_or_more_requires_draw_probability() {
let a = [rating()];
let b = [rating()];
let c = [rating()];
let teams: Vec<&[R]> = vec![&a, &b, &c];
let err = Game::ranked(&teams, Outcome::winner(0, 3), &GameOptions::default()).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
let opts = GameOptions {
p_draw: 0.1,
..GameOptions::default()
};
let game = Game::ranked(&teams, Outcome::winner(0, 3), &opts).unwrap();
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "3-team winner posterior");
}
}
}
#[test]
fn full_ranking_without_ties_needs_no_draw_probability() {
let a = [rating()];
let b = [rating()];
let c = [rating()];
let teams: Vec<&[R]> = vec![&a, &b, &c];
let game = Game::ranked(&teams, Outcome::ranking([0, 1, 2]), &GameOptions::default()).unwrap();
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "strict ranking posterior");
}
}
}
#[test]
fn empty_history_converges_trivially() {
let mut h = History::default();
let report = h.converge().unwrap();
assert_eq!(report.iterations, 0);
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);
}
#[test]
fn empty_event_stream_then_converge() {
let mut h = History::default();
h.add_events(std::iter::empty()).unwrap();
let report = h.converge().unwrap();
assert_eq!(report.iterations, 0);
}
#[test]
fn empty_history_queries_do_not_panic() {
let h = History::default();
assert!(h.learning_curves().is_empty());
assert!(h.learning_curve("nobody").is_empty());
assert!(h.current_skill("nobody").is_none());
}
#[test]
fn single_event_history_converges() {
let mut h = History::default();
h.record_winner(&"a", &"b", 1).unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert_finite(h.current_skill("a").unwrap(), "single-event skill");
}
#[test]
fn scored_event_rejects_non_positive_sigma() {
let mut h = History::builder().score_sigma(2.0).build();
let err = h
.event(1)
.team(["a"])
.team(["b"])
.scores_with_sigma([3.0, 1.0], f64::NAN)
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::InvalidParameter {
name: "score_sigma",
..
}
));
}
#[test]
fn convergence_reports_are_finite_across_many_teams() {
let opts = GameOptions {
p_draw: 0.1,
convergence: ConvergenceOptions::default(),
..GameOptions::default()
};
let holders: Vec<[R; 1]> = (0..12).map(|_| [rating()]).collect();
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
let game = Game::ranked(&teams, Outcome::ranking(0..12), &opts).unwrap();
assert!(
game.log_evidence().is_finite(),
"12-team log-evidence must be finite, got {}",
game.log_evidence()
);
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "12-team posterior");
}
}
}
/// A long diff chain underflows a linear evidence product: each link
/// contributes a probability in (0, 1], so ~1000 links flush the product to
/// exactly 0.0 and `ln(0.0)` is `-inf`. Accumulating in log space keeps it
/// finite.
#[test]
fn log_evidence_survives_a_long_diff_chain() {
let holders: Vec<[R; 1]> = (0..1200).map(|_| [rating()]).collect();
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
let game = Game::ranked(
&teams,
Outcome::ranking(0..holders.len() as u32),
&GameOptions::default(),
)
.unwrap();
let log_evidence = game.log_evidence();
assert!(
log_evidence.is_finite(),
"1200-team log-evidence must be finite, got {log_evidence}"
);
assert!(
log_evidence < 0.0,
"log-evidence of a probability must be negative, got {log_evidence}"
);
}
/// A near-certain outcome rounds the losing tail to exactly zero in the
/// `erfc` approximation; the evidence floor keeps `ln` finite.
#[test]
fn log_evidence_finite_for_near_certain_outcome() {
let overwhelming = R::new(Gaussian::from_ms(5_000.0, 0.5), 1.0, ConstantDrift(0.0));
let hopeless = R::new(Gaussian::from_ms(-5_000.0, 0.5), 1.0, ConstantDrift(0.0));
let a = [overwhelming];
let b = [hopeless];
let teams: Vec<&[R]> = vec![&a, &b];
let game = Game::ranked(&teams, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
assert!(
game.log_evidence().is_finite(),
"got {}",
game.log_evidence()
);
// And the reverse — a colossal upset — must also stay finite.
let upset = Game::ranked(&teams, Outcome::winner(1, 2), &GameOptions::default()).unwrap();
assert!(
upset.log_evidence().is_finite(),
"upset log-evidence must be finite, got {}",
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());
}
+101
View File
@@ -0,0 +1,101 @@
//! Determinism tests: identical posteriors across RAYON_NUM_THREADS
//! values. Only compiled with the `rayon` feature.
#![cfg(feature = "rayon")]
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
/// Build a deterministic workload using a simple LCG (no external rand crate).
fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
let mut h = History::<i64, _, _, String>::builder_with_key()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
// LCG for deterministic pseudo-random ints.
let mut rng = seed;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
rng
};
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(200);
for ev_i in 0..200 {
let a = (next() % 40) as usize;
let mut b = (next() % 40) as usize;
while b == a {
b = (next() % 40) as usize;
}
// ~10 events per slice so color groups have material parallelism.
events.push(Event {
time: (ev_i as i64 / 10) + 1,
teams: smallvec![
Team::with_members([Member::new(format!("p{a}"))]),
Team::with_members([Member::new(format!("p{b}"))]),
],
outcome: Outcome::winner((next() % 2) as u32, 2),
});
}
h.add_events(events).unwrap();
h.converge().unwrap();
// Sample one competitor's curve for the comparison.
h.learning_curve("p0")
}
#[test]
fn posteriors_identical_across_thread_counts() {
let sizes = [1usize, 2, 4, 8];
let mut results: Vec<Vec<(i64, trueskill_tt::Gaussian)>> = Vec::new();
for &n in &sizes {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build()
.expect("rayon pool build");
let curve = pool.install(|| build_and_converge(42));
results.push(curve);
}
let reference = &results[0];
for (i, curve) in results.iter().enumerate().skip(1) {
assert_eq!(
curve.len(),
reference.len(),
"curve length differs at {n} threads",
n = sizes[i],
);
for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() {
assert_eq!(
t_ref,
t,
"time point {j} differs at {n} threads: ref={t_ref} vs got={t}",
n = sizes[i],
);
assert_eq!(
g_ref.mu().to_bits(),
g.mu().to_bits(),
"mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}",
n = sizes[i],
ref_mu = g_ref.mu(),
got_mu = g.mu(),
);
assert_eq!(
g_ref.sigma().to_bits(),
g.sigma().to_bits(),
"sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}",
n = sizes[i],
ref_sigma = g_ref.sigma(),
got_sigma = g.sigma(),
);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Equivalence tests: every historical golden from the pre-T2 tests is
//! reproduced here at the integration level via the new public API.
//!
//! The in-crate tests in `src/history.rs::tests` and
//! `src/time_slice.rs::tests` are the primary regression net for numerical
//! behavior. This file provides Game-level goldens that stand alone and are
//! more naturally expressed as integration tests.
use approx::assert_ulps_eq;
use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
type R = Rating<i64, ConstantDrift>;
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
R::new(Gaussian::from_ms(mu, sigma), beta, ConstantDrift(gamma))
}
#[test]
fn game_1v1_golden_matches_historical() {
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap();
// Historical golden from pre-T2 test_1vs1 (team 0 wins):
assert_ulps_eq!(
a_post,
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
b_post,
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
}
#[test]
fn game_1v1_draw_golden() {
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
let g = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::draw(2),
&GameOptions {
p_draw: 0.25,
score_sigma: 1.0,
convergence: Default::default(),
},
)
.unwrap();
let p = g.posteriors();
// Historical golden from pre-T2 test_1vs1_draw. The mean is 25.0 exactly
// by symmetry — two identical competitors drawing cannot move apart — and
// the reference's 24.999999 is that value transcribed to six decimals.
assert_ulps_eq!(p[0][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
assert_ulps_eq!(p[1][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
}
+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()
);
}
}
+97
View File
@@ -0,0 +1,97 @@
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, InferenceError, Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
fn default_rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
)
}
#[test]
fn game_ranked_1v1_golden() {
let a = default_rating();
let b = default_rating();
let g = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&GameOptions::default(),
)
.unwrap();
let p = g.posteriors();
assert!(p[0][0].mu() > 25.0);
assert!(p[1][0].mu() < 25.0);
assert!((p[0][0].sigma() - p[1][0].sigma()).abs() < 1e-6);
}
#[test]
fn game_one_v_one_shortcut() {
let a = default_rating();
let b = default_rating();
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap();
assert!(a_post.mu() > 25.0);
assert!(b_post.mu() < 25.0);
}
#[test]
fn game_ranked_rejects_bad_p_draw() {
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
let err = Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::winner(0, 2),
&GameOptions {
p_draw: 1.5,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
},
)
.unwrap_err();
assert!(matches!(err, InferenceError::InvalidProbability { .. }));
}
#[test]
fn game_ranked_rejects_mismatched_ranks() {
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
let err = Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::ranking([0, 1, 2]),
&GameOptions::default(),
)
.unwrap_err();
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
}
#[test]
fn game_free_for_all_three_players() {
let a = default_rating();
let b = default_rating();
let c = default_rating();
let g = Game::<i64, _>::free_for_all(
&[&a, &b, &c],
Outcome::ranking([0, 1, 2]),
&GameOptions::default(),
)
.unwrap();
let p = g.posteriors();
assert_eq!(p.len(), 3);
assert!(p[0][0].mu() > p[1][0].mu());
assert!(p[1][0].mu() > p[2][0].mu());
}
#[test]
fn game_log_evidence_is_finite() {
let a = default_rating();
let b = default_rating();
let g = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&GameOptions::default(),
)
.unwrap();
assert!(g.log_evidence().is_finite());
assert!(g.log_evidence() < 0.0);
}
+147
View File
@@ -0,0 +1,147 @@
//! Ingesting the same events must give the same answer however they were
//! batched.
//!
//! The numerical goldens all ingest in a single call with one slice per
//! timestamp, so they never exercise the "append to an existing slice" path.
//! These do.
use smallvec::smallvec;
use trueskill_tt::{ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team};
/// Converge tightly: the default cap of 30 iterations leaves a residual around
/// 1e-6, which would swamp the comparison. Both paths must reach the same
/// fixed point, so drive both well past it.
fn tight() -> ConvergenceOptions {
ConvergenceOptions {
max_iter: 2_000,
epsilon: 1e-12,
..ConvergenceOptions::default()
}
}
fn event(a: &str, b: &str, time: i64) -> Event<i64, String> {
Event {
time,
teams: smallvec![
Team::with_members([Member::new(a.to_string())]),
Team::with_members([Member::new(b.to_string())]),
],
outcome: Outcome::winner(0, 2),
}
}
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
let mut h: History<i64, _, _, String> =
History::builder_with_key().convergence(tight()).build();
if batched {
h.add_events(events).unwrap();
} else {
for ev in events {
h.add_events(std::iter::once(ev)).unwrap();
}
}
let report = h.converge().unwrap();
assert!(
report.converged,
"fixture must converge before results can be compared; final step {:?}",
report.final_step
);
let mut skills: Vec<(String, Gaussian)> = h
.learning_curves()
.into_iter()
.map(|(key, curve)| (key, curve.last().unwrap().1))
.collect();
skills.sort_by(|a, b| a.0.cmp(&b.0));
skills
}
fn assert_same(batched: &[(String, Gaussian)], incremental: &[(String, Gaussian)], what: &str) {
assert_eq!(
batched.len(),
incremental.len(),
"{what}: competitor count differs"
);
for ((kb, gb), (ki, gi)) in batched.iter().zip(incremental.iter()) {
assert_eq!(kb, ki, "{what}: key order differs");
assert!(
(gb.mu() - gi.mu()).abs() < 1e-8 && (gb.sigma() - gi.sigma()).abs() < 1e-8,
"{what}: {kb} differs — batched mu={} sigma={}, incremental mu={} sigma={}",
gb.mu(),
gb.sigma(),
gi.mu(),
gi.sigma()
);
}
}
/// All events share one timestamp, so incremental ingestion repeatedly appends
/// to an existing slice.
#[test]
fn same_slice_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("c", "d", 1),
event("e", "f", 1),
event("a", "c", 1),
event("b", "e", 1),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "single shared slice");
}
/// Distinct timestamps, so each append lands in a fresh slice appended after
/// the existing ones.
#[test]
fn distinct_slices_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("b", "c", 2),
event("c", "a", 3),
event("a", "c", 4),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "distinct slices");
}
/// Several events per timestamp across several timestamps — appends to
/// existing slices interleaved with new ones.
#[test]
fn mixed_slices_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("c", "d", 1),
event("a", "c", 2),
event("b", "d", 2),
event("a", "d", 3),
event("b", "c", 3),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "mixed slices");
}
/// Appending an event to a slice that is *not* the most recent one exercises
/// the forward refresh of every later slice.
#[test]
fn back_dated_event_matches_batched() {
let events = vec![
event("a", "b", 1),
event("b", "c", 5),
event("c", "a", 9),
// arrives last, but belongs to the middle slice
event("a", "c", 5),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "back-dated event");
}
+71
View File
@@ -0,0 +1,71 @@
//! Regression: a single time slice with many distinct competitors must converge to finite
//! skills. Before the `pi <= 0` guard in `Gaussian::mu()/sigma()`, EP message cancellation
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver};
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() >> 33) as usize % n
}
fn coin(&mut self) -> bool {
self.next() & 1 == 0
}
}
fn nan_after_fit(players: usize) -> usize {
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder_with_key()
.beta(1.0)
.sigma(6.0)
.drift(ConstantDrift(0.1))
.convergence(ConvergenceOptions {
max_iter: ITERATIONS,
epsilon: EPSILON,
..Default::default()
})
.build();
let ids: Vec<String> = (0..players).map(|i| format!("p{i:04}")).collect();
let mut rng = Lcg(1);
for _ in 0..(players * 4) {
let a = rng.below(players);
let mut b = rng.below(players - 1);
if b >= a {
b += 1;
}
let (w, l) = if rng.coin() { (a, b) } else { (b, a) };
h.record_winner(&ids[w], &ids[l], 0).unwrap();
}
h.converge().unwrap();
ids.iter()
.filter(|id| {
h.current_skill(id.as_str())
.map(|g| !g.mu().is_finite() || !g.sigma().is_finite())
.unwrap_or(true)
})
.count()
}
#[test]
fn many_competitors_converge_to_finite_skills() {
// The NaN regression onset was between 70 and 80 competitors; 250 is comfortably past it
// and in the range of a real ranking dataset.
for players in [12usize, 75, 150, 250] {
assert_eq!(
nan_after_fit(players),
0,
"{players}-competitor history produced NaN skills"
);
}
}
+119
View File
@@ -0,0 +1,119 @@
//! `quality()` beyond two rating groups.
//!
//! The historical golden (two equal singletons) is asserted in
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
//! which previously panicked with an out-of-bounds index at 3+ groups.
use trueskill_tt::{Gaussian, quality};
const BETA: f64 = 25.0 / 3.0 / 2.0;
fn rating(mu: f64, sigma: f64) -> Gaussian {
Gaussian::from_ms(mu, sigma)
}
#[test]
fn three_equal_groups_is_finite_and_in_range() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r], &[r], &[r]], BETA);
assert!(q.is_finite(), "quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "quality out of range: {q}");
}
#[test]
fn quality_supports_many_groups() {
let r = rating(25.0, 3.0);
for n in 2..=8 {
let holders: Vec<[Gaussian; 1]> = (0..n).map(|_| [r]).collect();
let groups: Vec<&[Gaussian]> = holders.iter().map(|g| g.as_slice()).collect();
let q = quality(&groups, BETA);
assert!(q.is_finite(), "n={n}: quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "n={n}: out of range: {q}");
}
}
/// Equal-strength groups are the best-matched case: introducing a skill gap
/// must lower quality.
#[test]
fn imbalance_lowers_quality() {
let strong = rating(40.0, 3.0);
let average = rating(25.0, 3.0);
let balanced = quality(&[&[average], &[average], &[average]], BETA);
let lopsided = quality(&[&[strong], &[average], &[average]], BETA);
assert!(
lopsided < balanced,
"expected imbalanced quality {lopsided} < balanced {balanced}"
);
}
/// Quality is a property of the multiset of groups, not their order.
#[test]
fn quality_is_permutation_invariant() {
let a = rating(30.0, 2.0);
let b = rating(25.0, 3.0);
let c = rating(20.0, 4.0);
let forward = quality(&[&[a], &[b], &[c]], BETA);
let reversed = quality(&[&[c], &[b], &[a]], BETA);
assert!(
(forward - reversed).abs() < 1e-9,
"permutation changed quality: {forward} vs {reversed}"
);
}
#[test]
fn multi_player_groups_work() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r, r], &[r, r], &[r, r]], BETA);
assert!(q.is_finite());
assert!((0.0..=1.0).contains(&q));
}
#[test]
fn uneven_group_sizes_work() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r, r], &[r], &[r, r, r]], BETA);
assert!(q.is_finite(), "got {q}");
assert!((0.0..=1.0).contains(&q), "got {q}");
}
#[test]
#[should_panic(expected = "at least 2 rating groups")]
fn single_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r]], BETA);
}
#[test]
#[should_panic(expected = "at least 2 rating groups")]
fn zero_groups_panics_with_clear_message() {
let _ = quality(&[], BETA);
}
#[test]
#[should_panic(expected = "non-empty")]
fn empty_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r], &[]], BETA);
}
#[test]
fn history_predict_quality_supports_three_teams() {
use trueskill_tt::History;
let mut h = History::default();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"c", 2).unwrap();
h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]);
assert!(
q.is_finite(),
"3-team predict_quality must be finite, got {q}"
);
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
}
+55
View File
@@ -0,0 +1,55 @@
use trueskill_tt::{ConstantDrift, ConvergenceOptions, History};
#[test]
fn record_winner_builds_history() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
h.record_winner(&"alice", &"bob", 1).unwrap();
h.converge().unwrap();
let a_idx = h.lookup(&"alice").unwrap();
let b_idx = h.lookup(&"bob").unwrap();
assert_ne!(a_idx, b_idx);
}
#[test]
fn intern_is_idempotent() {
let mut h: History = History::builder().build();
let a1 = h.intern(&"alice");
let a2 = h.intern(&"alice");
assert_eq!(a1, a2);
}
#[test]
fn lookup_returns_none_for_missing() {
let h: History = History::builder().build();
assert!(h.lookup(&"nobody").is_none());
}
#[test]
fn record_draw_with_p_draw_set() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.p_draw(0.25)
.build();
h.record_draw(&"alice", &"bob", 1).unwrap();
h.converge().unwrap();
assert!(h.lookup(&"alice").is_some());
assert!(h.lookup(&"bob").is_some());
}
+139
View File
@@ -0,0 +1,139 @@
//! Integration tests for `Outcome::Scored` routing through `History::add_events`.
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
#[test]
fn scored_two_team_one_event_pulls_winner_up() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.score_sigma(1.0)
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores([10.0, 4.0]),
}];
h.add_events(events).unwrap();
let mu_a = h.current_skill(&"a").unwrap().mu();
let mu_b = h.current_skill(&"b").unwrap().mu();
assert!(
mu_a > 0.0,
"winner mu should be pulled up; got mu_a = {mu_a}"
);
assert!(
mu_b < 0.0,
"loser mu should be pulled down; got mu_b = {mu_b}"
);
assert!(
mu_a > mu_b,
"winner mu should exceed loser mu; got mu_a = {mu_a}, mu_b = {mu_b}"
);
}
#[test]
fn scored_zero_margin_treats_as_tie() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.score_sigma(1.0)
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores([5.0, 5.0]),
}];
h.add_events(events).unwrap();
let mu_a = h.current_skill(&"a").unwrap().mu();
let mu_b = h.current_skill(&"b").unwrap().mu();
let sigma_a = h.current_skill(&"a").unwrap().sigma();
// Equal scores: posterior means stay symmetric around the prior mean.
assert!(
(mu_a - mu_b).abs() < 1e-9,
"equal scores should leave mu_a == mu_b; got {mu_a} vs {mu_b}"
);
assert!(
mu_a.abs() < 1e-9,
"equal scores against equal priors should leave mu near zero; got {mu_a}"
);
// A zero-margin scored event still reduces uncertainty.
assert!(
sigma_a < 2.0,
"expected sigma to tighten below prior 2.0; got {}",
sigma_a
);
}
#[test]
fn scored_three_team_partial_order() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.score_sigma(1.0)
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
Team::with_members([Member::new("c")]),
],
outcome: Outcome::scores([9.0, 5.0, 1.0]),
}];
h.add_events(events).unwrap();
let mu_a = h.current_skill(&"a").unwrap().mu();
let mu_b = h.current_skill(&"b").unwrap().mu();
let mu_c = h.current_skill(&"c").unwrap().mu();
assert!(
mu_a > mu_b,
"team with highest score should rank highest; mu_a = {mu_a}, mu_b = {mu_b}"
);
assert!(
mu_b > mu_c,
"middle score should outrank lowest; mu_b = {mu_b}, mu_c = {mu_c}"
);
}
#[test]
fn scored_rejects_outcome_team_count_mismatch() {
use trueskill_tt::InferenceError;
let mut h: History = History::builder().build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores([10.0, 4.0, 1.0]), // 3 scores, 2 teams
}];
let err = h.add_events(events).unwrap_err();
assert!(
matches!(err, InferenceError::MismatchedShape { .. }),
"expected MismatchedShape error, got {err:?}"
);
}