refactor!: the joint is reached through Joint, not mirrored on History

`posterior_of`, `posterior_of_at` and `expected_variance_reduction`
existed twice: once on `Joint`, and once on `History` as one-shot
wrappers whose whole body was `self.joint()?.<same>(..)`.

The wrappers re-factorised on every call — their own docs said so,
warning the reader to take a `Joint` instead — and they were what
smuggled the scored-only precondition onto the flat surface. A user
following the quickstart builds a ranked history, sees `posterior_of` in
the method list, and it never works. `h.joint()?.posterior_of(..)` is
one call longer and tells the truth: you need a joint, and a joint needs
a scored history.

That leaves three tiers instead of a flat surface with a hidden
precondition: `History` fits and reads, `predict_*` forecasts, `Joint`
answers exact joint questions.

`predict_margin` was itself calling `self.posterior_of`; it goes through
`self.joint()?` directly now.

The `Joint` methods' docs referred back to the wrappers for their real
content ("Identical to `History::posterior_of`, without re-paying the
factorisation"), so they now carry it: what a linear functional means,
which appearance each competitor is read at, and why
`expected_variance_reduction` belongs on the handle.

`tests/joint_handle.rs` had three tests comparing the wrapper against
the handle. That comparison is gone, but the property behind it is not —
they now compare a *reused* joint against a *fresh* one per question,
which is the actual correctness claim behind caching the factorisation
(#51), without the wrapper in the middle.

Closes #78.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-10 06:40:31 +02:00
co-authored by Claude Opus 5
parent 56193609f7
commit e72bf3894c
10 changed files with 188 additions and 200 deletions
+17 -8
View File
@@ -78,14 +78,19 @@ const PAIRS: [(&str, &str); 6] = [
("c", "d"),
];
/// A joint reused across questions answers exactly what a fresh one per
/// question does. That is the whole correctness claim behind caching the
/// factorisation (#51); it used to be checked against the `History` one-shot
/// wrappers, which were deleted in #78, so it is checked against a fresh
/// factorisation instead — the same comparison, without the wrapper.
#[test]
fn a_joint_answers_exactly_what_the_one_shot_call_does() {
fn a_reused_joint_answers_exactly_what_a_fresh_one_does() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap();
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.mu(), cached.mu(), "{a} - {b}");
assert_eq!(one_shot.variance(), cached.variance(), "{a} - {b}");
@@ -100,7 +105,7 @@ fn a_joint_agrees_at_a_pinned_time_too() {
for time in 1..=5 {
for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of_at(time, &terms);
let one_shot = h.joint().unwrap().posterior_of_at(time, &terms);
let cached = joint.posterior_of_at(time, &terms);
match (one_shot, cached) {
(Ok(x), Ok(y)) => {
@@ -123,7 +128,11 @@ fn a_joint_scores_candidate_matchups_identically() {
for (x, y) in PAIRS {
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
let one_shot = h.expected_variance_reduction(&teams, &target).unwrap();
let one_shot = h
.joint()
.unwrap()
.expected_variance_reduction(&teams, &target)
.unwrap();
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
assert_eq!(one_shot, cached, "{x} vs {y}");
}
@@ -252,15 +261,15 @@ fn unknown_keys_are_rejected_per_query() {
}
/// Under `Prior`, an unseen competitor is independent of everything in the
/// history, and the cached path must add the same prior variance the one-shot
/// path does.
/// history, and a reused joint must add the same prior variance a fresh one
/// does.
#[test]
fn unseen_competitors_match_the_one_shot_path() {
fn unseen_competitors_match_a_fresh_factorisation() {
let h = fitted(UnknownKeys::Prior);
let joint = h.joint().unwrap();
let (a, z) = ("a", "nobody");
let terms = [(&a, 1.0), (&z, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap();
let one_shot = h.joint().unwrap().posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.mu(), cached.mu());
assert_eq!(one_shot.variance(), cached.variance());