Skip to content

Low-hanging-fruit bug fixes: W5 gauntlet unpack, W1 atomic result, W11b SL lambda validation - #63

Merged
tachyon-beep merged 3 commits into
mainfrom
bugfix/low-hanging-fruit-w1-w5-w11
May 5, 2026
Merged

Low-hanging-fruit bug fixes: W5 gauntlet unpack, W1 atomic result, W11b SL lambda validation#63
tachyon-beep merged 3 commits into
mainfrom
bugfix/low-hanging-fruit-w1-w5-w11

Conversation

@tachyon-beep

Copy link
Copy Markdown
Collaborator

Summary

Three independent P1/P1/P1 bug fixes from the architecture critique
(docs/arch-analysis-2026-05-05-1007/05-architecture-critique.md,
weaknesses W5, W1, and the SLConfig half of W11). Each fix is small and
mechanical; each commit is self-contained with regression tests.

Issue Bug Commit
keisei-4509042dd1 Gauntlet tuple-unpacks MatchOutcome dataclass — every gauntlet match silently treated as failure 089149b
keisei-fa604bad63 _record_match_result writes match + Elo across four separate transactions — partial state on crash cf52793
keisei-678359b7aa SLConfig accepted negative lambdas — silent gradient ascent on whichever head 0d231c3

W11a (keisei-ca5e280cae, flush_timings() not called) was already fixed
on main at katago_loop.py:1618; verified by running the existing
test_torch_compile.py::test_*timing* cases.

The deeper NaN-poisoning bug in MultiHeadValueAdapter
(keisei-bef32b64a8, originally bundled with the SL fix in the critique)
is structurally larger — needs a graph-connected zero helper across
multiple branches with NaN-path test coverage — and is intentionally
deferred to its own PR.

Test plan

  • uv run pytest tests/test_historical_gauntlet.py — 17 pass (15 existing + 2 new)
  • uv run pytest tests/test_league_tournament.py — 46 pass (44 existing + 2 new)
  • uv run pytest tests/test_sl_config.py — 24 pass (new file)
  • uv run pytest tests/test_value_adapter.py tests/test_sl_*.py tests/test_concurrent_round.py — all green
  • Full suite: uv run pytest tests/ — 1616 passed, 1 skipped

Notes

  • The _record_match_result atomicity fix relies on OpponentStore.transaction()
    being reentrant via _transaction_depth — verified at
    opponent_store.py:436-466.
  • The gauntlet regression test deliberately does NOT mock play_match to
    return a tuple — it returns a real MatchOutcome instance so the
    unpacking path is exercised.
  • The atomicity regression test injects sqlite3.OperationalError on the
    second update_elo and asserts no league_results row, no
    elo_history rows, and original Elo / games_played survive.

🤖 Generated with Claude Code

tachyon-beep and others added 3 commits May 5, 2026 16:46
…042dd1)

historical_gauntlet.run_gauntlet did `wins, losses, draws = play_match(...)`
but play_match returns a MatchOutcome dataclass without __iter__, so every
real gauntlet match raised TypeError caught by the broad per-slot except,
silently logging a slot failure and recording zero gauntlet results.
Existing tests masked the bug by patching play_match to return raw tuples.

Replace the unpack with attribute access, update the four masking mocks to
return real MatchOutcome instances, and add TestMatchOutcomeUnpacking with
a hand-crafted always-win opponent that asserts the result row is recorded
and elo_after > elo_before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_record_match_result issued record_result, two update_elo calls, and
role_elo_tracker.update_from_result as four independent transactions on
OpponentStore. A failure between writes left a recorded match pointing at
stale Elo, corrupting subsequent ratings silently — and the training loop
concurrently wrote chart-continuity rows via carry_forward_elo, so it could
observe inconsistent intermediate state.

Wrap the four writes in `with self.store.transaction():`. The store's
transaction context manager tracks _transaction_depth, so the inner nested
transactions inside record_result/update_elo/update_role_elo see depth > 1
and skip commit/rollback — only this outer block decides atomicity.

Add TestRecordMatchResultAtomicity:
- failure injection on the second update_elo asserts no league_results
  row, no elo_history rows, no Elo or games_played changes survive
- happy-path test sanity-checks all four writes still land

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…i-678359b7aa)

SLConfig.__post_init__ validated grad_clip, total_epochs, batch_size,
learning_rate, and num_workers but never checked lambda_policy /
lambda_value / lambda_score. The trainer applies them directly, so a
negative lambda inverts gradient descent into gradient ascent for that
loss head while per-head metrics still look positive — silently corrupting
SL checkpoints. NaN or inf would poison the combined loss the same way.

Add a finite + non-negative check for the three lambdas. Zero is allowed
(legitimate disable for ablations), matching the contract already enforced
by MultiHeadValueAdapter (RL side) at value_adapter.py:67-70.

NOTE: this addresses keisei-678359b7aa (SLConfig validation only). The
related keisei-bef32b64a8 (0.0 * NaN = NaN poisoning in
MultiHeadValueAdapter when heads are disabled) is a structurally different
bug — needs a graph-connected zero helper applied across multiple branches
and is intentionally out of scope for this small-fix bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 5, 2026 06:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR applies three small, targeted bug fixes identified in the architecture critique: (1) correct handling of MatchOutcome in the historical gauntlet, (2) make tournament match-result persistence atomic to avoid partially-written Elo/result state on failure, and (3) validate SL loss-weight lambdas to prevent negative/NaN/inf values from silently corrupting optimization.

Changes:

  • Fix HistoricalGauntlet.run_gauntlet() to consume play_match()’s MatchOutcome return type instead of tuple-unpacking.
  • Wrap LeagueTournament._record_match_result()’s result + Elo writes in a single OpponentStore.transaction() for true all-or-nothing persistence.
  • Add SLConfig validation rejecting negative and non-finite lambda_* weights, with dedicated regression tests.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/test_sl_config.py Adds regression coverage ensuring SLConfig rejects negative/non-finite lambdas while preserving existing validation behavior.
tests/test_league_tournament.py Adds rollback/commit regression tests proving _record_match_result is atomic under mid-write failures.
tests/test_historical_gauntlet.py Updates existing gauntlet tests to use real MatchOutcome and adds new regression tests for the unpacking bug.
keisei/training/tournament.py Wraps match result recording + Elo updates (including role Elo) in one store transaction to prevent partial DB state.
keisei/training/historical_gauntlet.py Fixes gauntlet logic to extract wins/losses/draws from MatchOutcome fields.
keisei/sl/trainer.py Adds finite/non-negative validation for SL lambda weights in SLConfig.__post_init__.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@tachyon-beep
tachyon-beep merged commit e3fc865 into main May 5, 2026
6 of 8 checks passed
@tachyon-beep
tachyon-beep deleted the bugfix/low-hanging-fruit-w1-w5-w11 branch May 5, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants