feat(episode): stop an episode when the game reports it is over - #25
Merged
Conversation
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.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The measurement that motivated this
A consumer running
ale-breakoutatmaxTurns: 300counted 163 of 300 decisions (54.3%) taken after lives reached 0, with the engine's ownterminalflag 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.
playEpisodehad 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 insideadvanceRolloutbeforefinalizeRecordand destroys the attestation the grade is made of.What this adds
Game<S>gains an optionalover(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 writesterminal, Gymnasiumterminatedandtruncated, stable-retroepisodeDone, the 2048 coregameOver.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.
overmust be pure likestep, 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.
advanceRolloutreturns'gameOver',playEpisodecallsfinalizeRecordas 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 existingsignalpath was unusable.It is opt-in.
playEpisode,runCampaign, andexecuteBenchmarktakestopAtGameOver, 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.
EpisodeRecordgains 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 reportsgameOver.gameOver:true/false/nullwhen 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 togameOver: truecan 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 inpnpm test:ale.stoppedBygameOvermaxTurnstruestopAtGameOver: truegameOvertrueThe 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:Offline deterministic gates
New
game-over.test.mts, registered in thetestscript:maxTurns,budget,gameOver— with the limits still ending a run while the stop is armed;engine-crawlerdeclares noover(), and its record with the stop armed is deep-equal to its record without it, field for field except wall-clockms;gameOverisnull, notfalse;over()that does not return a boolean fails as a named adapter bug;gameOver, the whole-run record reportsgameOver, 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()fromscreen-puzzlefails the first gate withactual: null, expected: true.pnpm run ciPlus
PLAYPROOF_REQUIRE_ALE=1 pnpm test:aleandPLAYPROOF_REQUIRE_GYM=1 pnpm test:gymlocally on ale-py 0.12.1 and gymnasium 1.3.0.Compatibility
## Unreleased.Game.overis optional;pyboy-*,retroarch,native-desktop, and both platform adapters are untouched and behave exactly as before.CampaignStopgainsgameOver, which the ledger validator now accepts.finalizeRecordtakes one more argument. It is internal and is not exported fromindex.ts.