Files
trueskill-tt/docs/superpowers/plans/2026-05-08-history-convergence-plumbing.md
T
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

25 KiB
Raw Blame History

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 HistoryBuilderconvergence(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:

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):

#[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:

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:

let mut time_slice = TimeSlice::new(t, self.p_draw);

with:

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:

let mut time_slice = TimeSlice::new(0i64, 0.0);

After:

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:

pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {

Rename to:

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
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):

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:

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:

let convergence = self.convergence;

Then update the call inside the closure (currently ev.iteration_direct(skills, agents, p_draw, &mut arena);):

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:

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:

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:

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:

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
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:

    /// 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 }):

#[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
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.