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
+8 -3
View File
@@ -80,7 +80,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
println!("\n== the same nodes via posterior_of (exact marginal) ==");
for k in players.iter().chain(holes.iter()) {
let g = h.posterior_of(&[(k, 1.0)]).unwrap();
let g = h.joint().unwrap().posterior_of(&[(k, 1.0)]).unwrap();
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
}
@@ -97,7 +97,7 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
vec![(&"h0", 1.0), (&"h1", -1.0)],
),
] {
let joint = h.posterior_of(&terms).unwrap();
let joint = h.joint().unwrap().posterior_of(&terms).unwrap();
// what a consumer gets today by adding marginals
let naive: f64 = terms
.iter()
@@ -132,7 +132,12 @@ fn additive_structure_makes_sums_wide_and_differences_tight() {
// shares with its partners is pinned only by the prior.
for k in players.iter().chain(holes.iter()) {
let bp = h.current_skill(k).unwrap().sigma();
let exact = h.posterior_of(&[(k, 1.0)]).unwrap().sigma();
let exact = h
.joint()
.unwrap()
.posterior_of(&[(k, 1.0)])
.unwrap()
.sigma();
assert!(
exact > 3.0 * bp,
"{k}: exact marginal {exact} should be much wider than the reported \
+6 -2
View File
@@ -84,13 +84,17 @@ fn fingerprint() -> String {
let known = "p0".to_string();
terms.push((&known, -1.0));
let posterior = h.posterior_of(&terms).unwrap();
let posterior = h.joint().unwrap().posterior_of(&terms).unwrap();
let a = "p0".to_string();
let b = "p1".to_string();
let target = [(&a, 1.0), (&b, -1.0)];
let teams: [&[&String]; 2] = [&[&a], &[&b]];
let evr = h.expected_variance_reduction(&teams, &target).unwrap();
let evr = h
.joint()
.unwrap()
.expected_variance_reduction(&teams, &target)
.unwrap();
let curves = h.learning_curves();
let mut curve_bits: u64 = 0;
+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());
+18 -11
View File
@@ -62,19 +62,26 @@ fn every_team_shaped_query_accepts_the_same_slice() {
#[test]
fn linear_combinations_take_bare_keys() {
// `&[(&K, f64)]` at `K = String` meant `&[(&String, f64)]` — no literals.
let h = owned();
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
// A scored history, because the joint needs one.
let mut h: Owned = History::builder().key_type::<String>().build();
for t in 1..=4 {
h.event(t)
.team([String::from("alice")])
.team([String::from("bob")])
.scores([21.0, 9.0])
.commit()
.expect("ingests");
}
h.converge().expect("converges");
// Ranked history, so the joint is unavailable — but the *call* compiles,
// which is what this pins. The error proves it reached the joint check
// rather than failing to resolve a key.
let err = h
let terms: &[(&str, f64)] = &[("alice", 1.0), ("bob", -1.0)];
let gap = h
.joint()
.expect("scored history has a joint")
.posterior_of(terms)
.expect_err("ranked history has no joint");
assert!(
format!("{err}").contains("ranked"),
"expected the joint-unavailable path, got {err}"
);
.expect("both keys are known");
assert!(gap.mu() > 0.0, "alice outscored bob every round");
}
/// `lookup` is gone with `Index` (#73); the accessors that answer the same
+5 -1
View File
@@ -281,6 +281,8 @@ fn posterior_of_matches_the_exact_joint() {
for (i, j) in [(0usize, 1usize), (0, 2), (1, 3), (2, 4)] {
let got = h
.joint()
.unwrap()
.posterior_of(&[(&key(i), 1.0), (&key(j), -1.0)])
.expect("scored slice should have a joint");
let exact_sd = (cov[i][i] + cov[j][j] - 2.0 * cov[i][j]).sqrt();
@@ -302,7 +304,7 @@ fn posterior_of_matches_the_exact_joint() {
// A single competitor: this is where the loopy marginal was 2x narrow.
for (i, row) in cov.iter().enumerate() {
let got = h.posterior_of(&[(&key(i), 1.0)]).unwrap();
let got = h.joint().unwrap().posterior_of(&[(&key(i), 1.0)]).unwrap();
let exact_sd = row[i].sqrt();
assert!(
(got.sigma() - exact_sd).abs() / exact_sd < 1e-9,
@@ -361,6 +363,8 @@ fn cost_scaling() {
let t = Instant::now();
let g = h
.joint()
.unwrap()
.posterior_of(&[(&names[0], 1.0), (&names[1], -1.0)])
.unwrap();
println!(" n={n:>4}: {:>10.2?} sigma {:.6}", t.elapsed(), g.sigma());
+2
View File
@@ -120,6 +120,8 @@ fn swapping_the_teams_negates_the_margin() {
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
let h = fitted(UnknownKeys::Prior);
let skill_gap = h
.joint()
.unwrap()
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
.unwrap();
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
+53 -12
View File
@@ -120,7 +120,11 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
// The crate reads each competitor at their latest appearance: a1, b1.
let exact_gap = (cov[2][2] + cov[3][3] - 2.0 * cov[2][3]).sqrt();
let got = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let got = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
assert!(
(got.sigma() - exact_gap).abs() / exact_gap < 1e-9,
"difference: got {} exact {exact_gap}",
@@ -128,7 +132,7 @@ fn a_two_slice_joint_matches_the_exact_posterior() {
);
let exact_single = cov[2][2].sqrt();
let got_single = h.posterior_of(&[(&"a", 1.0)]).unwrap();
let got_single = h.joint().unwrap().posterior_of(&[(&"a", 1.0)]).unwrap();
assert!(
(got_single.sigma() - exact_single).abs() / exact_single < 1e-9,
"single node: got {} exact {exact_single}",
@@ -154,6 +158,8 @@ fn competitors_last_seen_in_different_slices_are_comparable() {
// b last appeared at time 0; a and c at time 20. All three must resolve.
for (x, y) in [("a", "b"), ("b", "c"), ("a", "c")] {
let g = h
.joint()
.unwrap()
.posterior_of(&[(&x, 1.0), (&y, -1.0)])
.unwrap_or_else(|e| panic!("{x} - {y} should resolve across slices: {e}"));
assert!(g.sigma() > 0.0 && g.sigma().is_finite());
@@ -175,7 +181,7 @@ fn means_agree_with_the_marginals() {
for k in ["a", "b", "c"] {
let marginal = h.current_skill(&k).unwrap().mu();
let joint = h.posterior_of(&[(&k, 1.0)]).unwrap().mu();
let joint = h.joint().unwrap().posterior_of(&[(&k, 1.0)]).unwrap().mu();
assert!(
(marginal - joint).abs() < 1e-9,
"{k}: marginal {marginal}, joint {joint}"
@@ -197,7 +203,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
])
.unwrap();
let _ = h.converge().unwrap();
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
h.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap()
};
let together = {
let mut h = history(0.0);
@@ -208,7 +217,10 @@ fn zero_drift_makes_slice_layout_irrelevant() {
])
.unwrap();
let _ = h.converge().unwrap();
h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap()
h.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap()
};
assert!(
@@ -234,7 +246,11 @@ fn drift_widens_a_comparison_across_time() {
let _ = h.converge().unwrap();
// b was last seen at time 0; a at time 100.
let g = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let g = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
assert!(
g.sigma() > previous,
"gamma={gamma}: sigma {} did not exceed {previous}",
@@ -257,9 +273,21 @@ fn posterior_of_at_reads_as_of_a_time() {
.unwrap();
let _ = h.converge().unwrap();
let early = h.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let late = h.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let latest = h.posterior_of(&[(&"a", 1.0), (&"b", -1.0)]).unwrap();
let early = h
.joint()
.unwrap()
.posterior_of_at(0, &[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
let late = h
.joint()
.unwrap()
.posterior_of_at(20, &[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
let latest = h
.joint()
.unwrap()
.posterior_of(&[(&"a", 1.0), (&"b", -1.0)])
.unwrap();
// Asking as of the final slice is the same as asking for the latest.
assert!((late.mu() - latest.mu()).abs() < 1e-9);
@@ -275,7 +303,12 @@ fn posterior_of_at_reads_as_of_a_time() {
);
// A time before any event has nothing to read.
assert!(h.posterior_of_at(-1, &[(&"a", 1.0)]).is_err());
assert!(
h.joint()
.unwrap()
.posterior_of_at(-1, &[(&"a", 1.0)])
.is_err()
);
}
/// Times between slices resolve to the latest appearance at or before them.
@@ -289,8 +322,16 @@ fn a_time_between_slices_reads_the_previous_appearance() {
.unwrap();
let _ = h.converge().unwrap();
let at_zero = h.posterior_of_at(0, &[(&"a", 1.0)]).unwrap();
let between = h.posterior_of_at(50, &[(&"a", 1.0)]).unwrap();
let at_zero = h
.joint()
.unwrap()
.posterior_of_at(0, &[(&"a", 1.0)])
.unwrap();
let between = h
.joint()
.unwrap()
.posterior_of_at(50, &[(&"a", 1.0)])
.unwrap();
assert!((at_zero.mu() - between.mu()).abs() < 1e-12);
assert!((at_zero.sigma() - between.sigma()).abs() < 1e-12);
}
+46 -6
View File
@@ -60,15 +60,30 @@ fn fit(extra: Option<Event<i64, &'static str>>, policy: UnknownKeys) -> H {
fn the_closed_form_matches_an_actual_refit() {
let h = fit(None, UnknownKeys::Reject);
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
let before = h
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
for (x, y) in [("a", "b"), ("c", "d"), ("a", "c"), ("b", "d")] {
let predicted = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&x], &[&y]], &target)
.unwrap();
let after = fit(Some(round(x, y, 3.0, 1.0)), UnknownKeys::Reject);
let actual = before - after.posterior_of(&target).unwrap().sigma().powi(2);
let actual = before
- after
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
assert!(
(predicted - actual).abs() / actual.abs() < 1e-9,
@@ -84,12 +99,27 @@ fn the_closed_form_matches_an_actual_refit() {
fn the_outcome_does_not_change_the_reduction() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let h = fit(None, UnknownKeys::Reject);
let before = h.posterior_of(&target).unwrap().sigma().powi(2);
let before = h
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2);
let mut seen = Vec::new();
for (sa, sb) in [(3.0, 1.0), (100.0, -50.0), (0.0, 0.0)] {
let after = fit(Some(round("c", "d", sa, sb)), UnknownKeys::Reject);
seen.push(before - after.posterior_of(&target).unwrap().sigma().powi(2));
seen.push(
before
- after
.joint()
.unwrap()
.posterior_of(&target)
.unwrap()
.sigma()
.powi(2),
);
}
for w in seen.windows(2) {
assert!(
@@ -107,9 +137,13 @@ fn it_ranks_candidates_by_how_much_they_answer_the_question() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let direct = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"b"]], &target)
.unwrap();
let unrelated = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"c"], &[&"d"]], &target)
.unwrap();
@@ -128,6 +162,8 @@ fn an_unrelated_unseen_matchup_teaches_nothing_about_the_target() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
let reduction = h
.joint()
.unwrap()
.expected_variance_reduction(&[&[&"stranger"], &[&"nobody"]], &target)
.unwrap();
assert!(
@@ -142,7 +178,9 @@ fn shape_errors_are_reported() {
let target: Vec<(&&str, f64)> = vec![(&"a", 1.0), (&"b", -1.0)];
assert!(matches!(
h.expected_variance_reduction(&[&[&"a"]], &target),
h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"]], &target),
Err(InferenceError::MismatchedShape {
expected: 2,
got: 1,
@@ -150,7 +188,9 @@ fn shape_errors_are_reported() {
})
));
assert!(matches!(
h.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
h.joint()
.unwrap()
.expected_variance_reduction(&[&[&"a"], &[&"ghost"]], &target),
Err(InferenceError::UnknownKey { .. })
));
}