Bench nondeterminism: identical-binary re-runs differ in bond-economy and micro-economy (+-1 win) #100

Closed
opened 2026-09-30 09:16:36 +00:00 by Winnie · 4 comments
Owner

Found independently by exp11 and exp16 during #97 round 2/3, verified both times from the bw85 container.

Symptom

Two runs of the SAME built binary, same bench settings (30 games x 13 presets, gauntlet 2), differ in exactly two presets: bond-economy and micro-economy. Magnitude is small: +-1 win (e.g. adapt 14<->15, super 6<->7 in micro-economy), avg-equity shifts of ~5-13. The other 11 presets are byte-identical across runs.

What was ruled out

  • Per-game RNG is seeded (g*7919+len(preset)); the Seed==0 fallback is never hit in bench.
  • Go map iteration traces resolve to deterministic tie-breaks (bid order = bankID, bondCheaper is a total order, alive() is a slice).
  • The two noisy presets are the most auction/bankruptcy-heavy ones.

The source is unidentified and somewhere else in the bench harness (or the auction/bankruptcy paths).

Impact

The team quotes wins per preset to +-1 in round tables; bond-economy and micro-economy totals should not be treated as exact until this is bisected. The win totals across the other presets are stable (e.g. exp16's tight-cash 7/30 reproduced across 4 runs).

Refs: #97 (live work), exp11.md and exp16.md in /root/bw85-reports/ on the host.

Found independently by exp11 and exp16 during #97 round 2/3, verified both times from the bw85 container. **Symptom** Two runs of the SAME built binary, same bench settings (30 games x 13 presets, gauntlet 2), differ in exactly two presets: bond-economy and micro-economy. Magnitude is small: +-1 win (e.g. adapt 14<->15, super 6<->7 in micro-economy), avg-equity shifts of ~5-13. The other 11 presets are byte-identical across runs. **What was ruled out** - Per-game RNG is seeded (g*7919+len(preset)); the Seed==0 fallback is never hit in bench. - Go map iteration traces resolve to deterministic tie-breaks (bid order = bankID, bondCheaper is a total order, alive() is a slice). - The two noisy presets are the most auction/bankruptcy-heavy ones. The source is unidentified and somewhere else in the bench harness (or the auction/bankruptcy paths). **Impact** The team quotes wins per preset to +-1 in round tables; bond-economy and micro-economy totals should not be treated as exact until this is bisected. The win totals across the other presets are stable (e.g. exp16's tight-cash 7/30 reproduced across 4 runs). Refs: #97 (live work), exp11.md and exp16.md in /root/bw85-reports/ on the host.
Author
Owner

Update from exp19 and exp23 (round 4) — stronger evidence, upgraded severity

exp19 measured: two binaries (canonical v0 vs v2) produce BYTE-IDENTICAL games in mega-loans and cash-glut (full-block diff of the 30 mega games: 0 differing lines out of 6,120), yet the aggregate rows differ (super mega avg equity 15,511 vs 16,008; +1 win). Both binaries are individually run-stable. Since the games are provably identical but res.wins/res.avgEq differ, the divergence must live in the bench's in-memory aggregation — prime suspect: the winner is decided by a strict > on b.LastReport.Equity while the printed ranking uses the recomputed end-of-game g.report(b).

exp23 found the cross-game cousin: the same canonical file gives micro-economy adapt 15/super 6 when games run standalone (clean process) but adapt 14/super 7 when the preset runs after others in the same bench process. A cross-game stateful leak in NewGame or the report path is the likely second source.

Impact on the team: treat +-1 rows in mega-loans, cash-glut, micro-economy, bond-economy as untrusted until this is bisected. Win deltas of +3 or larger in other presets are still meaningful. No experimenter is allowed to touch bench.go (team rule), so this needs an engine-side bisect.

**Update from exp19 and exp23 (round 4) — stronger evidence, upgraded severity** exp19 measured: two binaries (canonical v0 vs v2) produce BYTE-IDENTICAL games in mega-loans and cash-glut (full-block diff of the 30 mega games: 0 differing lines out of 6,120), yet the aggregate rows differ (super mega avg equity 15,511 vs 16,008; +1 win). Both binaries are individually run-stable. Since the games are provably identical but res.wins/res.avgEq differ, the divergence must live in the bench's in-memory aggregation — prime suspect: the winner is decided by a strict > on b.LastReport.Equity while the printed ranking uses the recomputed end-of-game g.report(b). exp23 found the cross-game cousin: the same canonical file gives micro-economy adapt 15/super 6 when games run standalone (clean process) but adapt 14/super 7 when the preset runs after others in the same bench process. A cross-game stateful leak in NewGame or the report path is the likely second source. Impact on the team: treat +-1 rows in mega-loans, cash-glut, micro-economy, bond-economy as untrusted until this is bisected. Win deltas of +3 or larger in other presets are still meaningful. No experimenter is allowed to touch bench.go (team rule), so this needs an engine-side bisect.
Author
Owner

Fixed. PR #101 (branch 100-nondet @ 73fdb47), review requested.

Root cause: the loan shelf (openLoanOffers) is the one phase-scratch global read before it's written — turn-1 deposit hooks see (market-loans) before the game's shelf opens, and the bench's map-random preset order let the PREVIOUS preset's last shelf bleed into the next game's first turn. That flipped close games in the attrition-heavy presets.

Fix: run() clears the shelf at game start. Verification: two identical 30-game benches that differed by 84 lines before the fix (the adapt 14<->15 and super 6<->7 flips) are now byte-identical (0 diff lines). Regression test plants a stale shelf and asserts turn 1 sees an empty market. Full suite + vet clean.

Fixed. **PR #101** (branch 100-nondet @ 73fdb47), review requested. Root cause: the loan shelf (`openLoanOffers`) is the one phase-scratch global read before it's written — turn-1 deposit hooks see `(market-loans)` before the game's shelf opens, and the bench's map-random preset order let the PREVIOUS preset's last shelf bleed into the next game's first turn. That flipped close games in the attrition-heavy presets. Fix: `run()` clears the shelf at game start. Verification: two identical 30-game benches that differed by 84 lines before the fix (the adapt 14<->15 and super 6<->7 flips) are now byte-identical (0 diff lines). Regression test plants a stale shelf and asserts turn 1 sees an empty market. Full suite + vet clean.
Author
Owner

Root cause found (exp23) — cross-game shelf leak

exp23 traced the leak: openLoanOffers (the Go global behind (market-loans)) is never cleared at game start, so turn 1 of game N+1 sees game N's leftover shelf. In the polluted micro-economy game, super's env held pool-est=1200 / prev-deposits=20000 — deposit-drought's config values, the preset that ran earlier in the same bench process. Same mechanism explains the earlier ±1 rows and the aggregation-row divergences exp19 measured (winner picked from b.LastReport while the printed ranking recomputes g.report).

Impact: every number measured on this engine carries turn-1 pollution, including the trunk 100g 389/1300. exp23's suggested fix: clear openLoanOffers in NewGame/assignPlayers, or have (market-loans) return empty outside the loan phase. Then re-baseline and re-measure everything.

Dispatching an engine-fix agent to implement + regression-test this on its own branch with a PR for review.

**Root cause found (exp23) — cross-game shelf leak** exp23 traced the leak: `openLoanOffers` (the Go global behind `(market-loans)`) is never cleared at game start, so turn 1 of game N+1 sees game N's leftover shelf. In the polluted micro-economy game, super's env held pool-est=1200 / prev-deposits=20000 — deposit-drought's config values, the preset that ran earlier in the same bench process. Same mechanism explains the earlier ±1 rows and the aggregation-row divergences exp19 measured (winner picked from b.LastReport while the printed ranking recomputes g.report). Impact: every number measured on this engine carries turn-1 pollution, including the trunk 100g 389/1300. exp23's suggested fix: clear `openLoanOffers` in NewGame/assignPlayers, or have `(market-loans)` return empty outside the loan phase. Then re-baseline and re-measure everything. Dispatching an engine-fix agent to implement + regression-test this on its own branch with a PR for review.
Author
Owner

Engine fix shipped as PR #102 (branch 100-shelf-reset, #102), review requested from robertismo.

What shipped: NewGame now nils openLoanOffers at the construction boundary. PR #101 cleared it at run() start, which fixed turn-1 hook reads, but brain files load in assignPlayers before run(), and defvar initializers evaluate AT LOAD, so a load-time read like (defvar pool-est (length (market-loans))) still caught the previous game's shelf and carried it for the whole game. The construction-boundary clear closes that path too; run()'s clear remains as a second guard.

Deliberately did NOT gate (market-loans) outside the loan phase: within a running game the shelf is the last-turn loan book, the same information regime as the #84 deposit/bond snapshots. Gating it would change what brains observe mid-game and shift bench numbers. The leak is a cross-game problem and the game-boundary clear fully closes it.

Regression test: TestNewGameClearsShelfBeforeBrainLoad mirrors the bench path — plants a stale shelf, constructs a new game, loads a probe brain whose defvar captures the shelf length at load, posts it on turn 1, and asserts 0. Verified red on master (posted 0.5000, the capped nonzero stale read) and green with the fix; it also asserts the hook really fired and the shelf rebuilt, so it can't pass vacuously.

Gates: go build OK, go test -count=1 . OK (includes #101's shelf test), go vet ./... OK. Diff is game.go + shelf_load_test.go only.

Engine fix shipped as PR #102 (branch 100-shelf-reset, https://git.machinetele.com/Winnie/bankwars/pulls/102), review requested from robertismo. What shipped: `NewGame` now nils `openLoanOffers` at the construction boundary. PR #101 cleared it at `run()` start, which fixed turn-1 hook reads, but brain files load in `assignPlayers` before `run()`, and defvar initializers evaluate AT LOAD, so a load-time read like `(defvar pool-est (length (market-loans)))` still caught the previous game's shelf and carried it for the whole game. The construction-boundary clear closes that path too; `run()`'s clear remains as a second guard. Deliberately did NOT gate `(market-loans)` outside the loan phase: within a running game the shelf is the last-turn loan book, the same information regime as the #84 deposit/bond snapshots. Gating it would change what brains observe mid-game and shift bench numbers. The leak is a cross-game problem and the game-boundary clear fully closes it. Regression test: `TestNewGameClearsShelfBeforeBrainLoad` mirrors the bench path — plants a stale shelf, constructs a new game, loads a probe brain whose defvar captures the shelf length at load, posts it on turn 1, and asserts 0. Verified red on master (posted 0.5000, the capped nonzero stale read) and green with the fix; it also asserts the hook really fired and the shelf rebuilt, so it can't pass vacuously. Gates: go build OK, go test -count=1 . OK (includes #101's shelf test), go vet ./... OK. Diff is game.go + shelf_load_test.go only.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Winnie/bankwars#100
No description provided.