Three-task plan covering the run_chain dedup, exhaustive BuiltinFactor log_evidence match, and stale-numerics fix in the T4 plan doc.
18 KiB
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 bothlikelihoodsandlikelihoods_scoredwith 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-371likelihoods_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:
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:
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.rsand add therun_chainhelper
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:
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
likelihoodsbody 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:
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_scoredbody 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:
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
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(thelog_evidenceimpl onBuiltinFactor) -
Step 1: Open
src/factor/mod.rsand replace thelog_evidencebody
Replace the existing impl:
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:
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
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
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 (
DiffFactorcollapse, per-eventscore_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_chainsignature appears identically in the context header and Step 2 body ✓- Closure type
FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactormatches across Step 2 (definition) and Steps 3/4 (call sites) ✓ DiffFactor::Trunc/DiffFactor::Marginconstructors matchsrc/game.rs:20-23definitions ✓
No placeholders detected.