perf: make the per-slice SkillStore compact instead of dense

Closes #17. Each `TimeSlice` owned a `Vec<Skill>` indexed by the GLOBAL
`Index.0`, so a slice's footprint was O(largest index it touches) rather than
O(competitors in it). Two competitors at 19998/19999 reserved 20,000 slots per
slice; the same games between indices 0 and 1 reserved two.

The store is now a compact `Vec<Skill>` plus a `HashMap<Index, u32>` slot map
and a parallel `Vec<Index>` for iteration. The hash is paid once at ingestion:
each event's `Item` caches its slot, and the convergence loop reaches skills
through `at`/`at_mut` by slot, so no hashing enters the hot path — which is the
property the dense layout existed to provide.

Measured on the issue's own workload (200 slices, one 1v1 each, 20,000-key
roster, release, peak RSS):

    indices 0 / 1          52 MB  ->  5.55 MB
    indices 19998 / 19999  309 MB ->  8.39 MB

The 257 MB gap is now 2.8 MB, and that residual is CompetitorStore, which is
also dense over the global index but is a single store for the whole history
rather than one per slice — so it does not multiply. Left alone deliberately.

Benchmarks, against the pre-change code:

    Batch::iteration        +2.4%   (regressed)
    history_converge x3     -18.8%, -21.6%, -21.7%  (improved)

The three convergence benchmarks are the realistic workload and they gain
~20% from the better locality of a compact store. The micro-benchmark loses
2.4% because `Item` grew eight bytes for the cached slot; `agent` cannot be
dropped to compensate, since `within_prior` still needs the global index to
reach the competitor's rating. I judged 2.4% on one micro-benchmark an
acceptable price for ~20% on the real ones plus the memory fix, but it is a
regression against #17's stated "no regression" criterion, so it is called out
rather than buried.

The regression test asserts on a new test-only `allocated_slots()`, not on
`len()`. That distinction is load-bearing: the old dense store reported the
true competitor count from `len()` while allocating max_index+1 slots, so a
test written against `len()` would have passed on the defect. Mutation-proved
by re-adding the dense padding, which fails it.

One coupling is now pinned by a debug_assert: `filtered_step` clones events
whose `Item`s carry slots resolved against the REAL store, so its scratch store
must assign identical slots. It does, because `iter()` yields slot order and
`insert` allocates in call order — but that is an invariant across two types,
so it is asserted rather than left to be rediscovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
This commit is contained in:
2026-08-27 18:01:29 +02:00
co-authored by Claude Opus 5
parent 1ac3b21db5
commit 6d2573b92e
3 changed files with 196 additions and 64 deletions
+43
View File
@@ -1026,6 +1026,49 @@ mod tests {
arena::ScratchArena,
};
/// #17: a slice's footprint must be O(competitors in the slice), not
/// O(largest global index it touches). The store used to be a dense
/// `Vec<Skill>` indexed by `Index.0`, so the same two-competitor games cost
/// 20,000 slots per slice when the competitors sat at the top of a large
/// roster. Measured end to end, peak RSS was 309 MB against 52 MB.
#[test]
fn per_slice_footprint_is_independent_of_index_magnitude() {
fn total_skill_slots(high_indices: bool) -> usize {
let mut h: History<i64, ConstantDrift, NullObserver, String> =
History::builder_with_key().build();
for i in 0..2_000 {
h.intern(&format!("k{i:05}"));
}
let (a, b) = if high_indices {
("k01998".to_string(), "k01999".to_string())
} else {
("k00000".to_string(), "k00001".to_string())
};
for time in 1..=20i64 {
h.record_winner(&a, &b, time).unwrap();
}
h.time_slices
.iter()
.map(|ts| ts.skills.allocated_slots())
.sum()
}
let low = total_skill_slots(false);
let high = total_skill_slots(true);
assert_eq!(low, high, "footprint must not depend on index magnitude");
// A dense store over a 2,000-key roster would allocate 20 x 2,000.
assert!(
high < 1_000,
"20 slices of 2 competitors allocated {high} slots"
);
}
fn make_events_1v1(
pairs: &[(&'static str, &'static str)],
outcomes: &[Outcome],