Skip to content

feat(episode): stop an episode when the game reports it is over - #25

Merged
drewstone merged 1 commit into
mainfrom
feat/episode-stops-at-game-over
Aug 23, 2026
Merged

feat(episode): stop an episode when the game reports it is over#25
drewstone merged 1 commit into
mainfrom
feat/episode-stops-at-game-over

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

The measurement that motivated this

A consumer running ale-breakout at maxTurns: 300 counted 163 of 300 decisions (54.3%) taken after lives reached 0, with the engine's own terminal flag set.
The ALE worker breaks out of its action-repeat loop once that flag holds, so none of those inputs reached the emulator: the decisions were inert, not merely unproductive.
The episode still reported 300 of 300 answered and looked healthy.

They could not fix it from outside. playEpisode had two stop conditions — the turn limit and the dollar budget — and no terminal concept in the published API.
The only other exit is an abort through signal, which throws inside advanceRollout before finalizeRecord and destroys the attestation the grade is made of.

What this adds

Game<S> gains an optional over(state): boolean.
A game that omits it is never over, so every existing adapter keeps its behaviour with no edit.
I checked whether the interface already exposed something usable before adding a member: each substrate does publish a terminal flag in evidence().engineState, but there is no shared spelling of it — ALE writes terminal, Gymnasium terminated and truncated, stable-retro episodeDone, the 2048 core gameOver.
A guess over field names in the harness would fail silently for the next adapter, so the mapping belongs to the adapter that knows its own engine. Each implementation is one line over evidence the adapter already published.
over must be pure like step, because a verifier recomputes the final state from the seed and the input log and asks again.

The stop is a loop exit, never a thrown abort.
advanceRollout returns 'gameOver', playEpisode calls finalizeRecord as it does for any other stop, and the record verifies by replay exactly as a turn-limit record does. This is the whole reason the existing signal path was unusable.

It is opt-in. playEpisode, runCampaign, and executeBenchmark take stopAtGameOver, off by default.
Episode length is the denominator a study divides by, and rounds compare only while every round played to the same turn limit; a default that shortened episodes would retroactively break a running comparison.
Cost of that choice: a consumer must pass one flag, and a run made before the flag existed still cannot be told apart from a run made with the flag off — except that the record now carries enough to answer the question directly (below).

The record says why it stopped. EpisodeRecord gains two fields:

  • stoppedBy: maxTurns | budget | gameOver | steering | analyst (the last two only for a campaign). Game over outranks the limits, so a run that reaches its last allowed turn and a finished game at the same instant reports gameOver.
  • gameOver: true / false / null when the game declares no terminal state at all. It is computed for every run, armed or not.

The two together state which mode produced a record: stoppedBy: 'maxTurns' next to gameOver: true can only come from a run played past the end, so the stop was not armed. Where the game never ended, both modes give the same length and the same record.

Real-emulator evidence

ALE Breakout, ale-py 0.12.1, seed 0, maxTurns: 300, one scripted policy that opens four milestones and then loses every life. Both runs are now gates in pnpm test:ale.

Run Decisions stoppedBy gameOver Milestones Replay
turn limit (today's behaviour) 300 maxTurns true 4 of 6 clean
stopAtGameOver: true 150 gameOver true 4 of 6 clean
ale: game-over stop — 150 of 300 decisions played, 150 dropped as inert (50% of the episode); milestones 4 of 6, unchanged from the full-length run

The milestone verdict is unchanged, and the dropped decisions are provably inert: every evidence channel — screen hash, save-state hash, engine state — is byte-identical from decision 150 to decision 300, while decision 149 to 150 did move the emulator.

Second real substrate, Gymnasium FrozenLake-v1, a gate in pnpm test:gym:

gymnasium: ... game-over stop at 6 of 26 FrozenLake decisions (20 dropped, milestones unchanged), teardown OK

Offline deterministic gates

New game-over.test.mts, registered in the test script:

  • the pathology reproduced without an emulator: 20 turns vs 8, same verified milestones, both replay-clean;
  • the three stop reasons recorded and distinctmaxTurns, budget, gameOver — with the limits still ending a run while the stop is armed;
  • the compatibility gate, explicit: engine-crawler declares no over(), and its record with the stop armed is deep-equal to its record without it, field for field except wall-clock ms; gameOver is null, not false;
  • game over on the very first decision (1 turn) and before any decision (0 turns, empty log, still a complete replay-checked record);
  • game over on the last allowed decision: same inputs and same attestation as the turn-limit run, only the stated reason differs;
  • an over() that does not return a boolean fails as a named adapter bug;
  • campaigns: the segment records gameOver, the whole-run record reports gameOver, a campaign resumed from a finished ledger plays nothing and writes no empty segment, and the same campaign without the flag runs to the turn limit.

I checked these bite: removing over() from screen-puzzle fails the first gate with actual: null, expected: true.

pnpm run ci

playproof-boundary: 106 files checked; framework is research-harness- and provider-neutral
playproof: 22 calibration gates green + review-fix regressions green
playproof calibration: separating and non-separating contracts, legible/opaque split, opaque-collision sweep, policy determinism, edge cases OK
playproof episode: loop, budget, and partial-progress semantics OK
playproof game-over stop: 8 moves to the gate, three distinct stop reasons, compatibility and replay OK
playproof observation: text default, image round-trip, bounds, evidence boundary, replay parity, and both drivers OK
playproof-platform: native process, frontier replay, explicit trust modes, signed execution, and red-team gates green
playproof-desktop-platforms: hardened native process, Steam, Xbox, bounded IO, and signed recorder composition green
playproof-drivers: CLI and OpenAI-compatible adapters green
playproof campaign: segment, steer, resume, and tamper semantics OK
playproof-package: 96 packed entries verified

Plus PLAYPROOF_REQUIRE_ALE=1 pnpm test:ale and PLAYPROOF_REQUIRE_GYM=1 pnpm test:gym locally on ale-py 0.12.1 and gymnasium 1.3.0.

Compatibility

  • No version bump; the CHANGELOG entry sits under ## Unreleased.
  • Game.over is optional; pyboy-*, retroarch, native-desktop, and both platform adapters are untouched and behave exactly as before.
  • The campaign ledger schema is unchanged, so a ledger written by 0.6.0 still loads. CampaignStop gains gameOver, which the ledger validator now accepts.
  • finalizeRecord takes one more argument. It is internal and is not exported from index.ts.

A consumer measured 163 of 300 decisions (54.3%) on ale-breakout played
after lives reached 0, with the engine's own terminal flag set. The ALE
worker breaks out of its action-repeat loop once that flag holds, so those
inputs never reached the emulator. The episode still reported 300 of 300
answered.

Game<S> gains an optional over(state). A game that omits it is never over,
so every existing adapter keeps its behaviour with no edit. playEpisode,
runCampaign, and executeBenchmark take stopAtGameOver, off by default,
because episode length is the denominator a study divides by.

The stop is an exit from the decision loop, not an abort. finalizeRecord
runs, and the record verifies by replay exactly as a turn-limited record
does. EpisodeRecord gains stoppedBy and gameOver, so a reader of an
artifact never infers why a run ended.
@drewstone
drewstone merged commit 3b213a5 into main Aug 23, 2026
6 checks passed
@drewstone
drewstone deleted the feat/episode-stops-at-game-over branch August 23, 2026 14:04
@drewstone drewstone mentioned this pull request Aug 23, 2026
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.

1 participant