diff --git a/CHANGELOG.md b/CHANGELOG.md index 975d5c3..9082ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to Playproof are documented here. ## Unreleased +### The Breakout contract now measures how well a run played, not that it played + +- **The defect.** The packaged ALE Breakout contract was derived from a reference that reached score 5 over 210 inputs, so its top achievement was `score >= 4`. Nothing in it could tell 7 points from 24. Excluding the point for dying stopped it ranking the worse player first; it did not give it any resolution above the bottom of the range. + + | control @600 decisions, seed 0 | game score | lives left | achievements, before | after | + |---|---|---|---|---| + | `screen-blind`, a fixed 3-word cycle | 7 | 0 | 3 of 5 | 3 of 7 | + | `steer-from-ascii` | 7 | 0 | 3 of 5 | 3 of 7 | + | `steer-from-ram` | **24** | **5** | 3 of 5 | **5 of 7** | + + Every program that played at all saturated the same three achievements. Under the new ladder the RAM control ranks strictly first at 300 decisions (4 of 7 against 3 of 7) and at 600, and two rungs are still open above it. +- **A new reference, recorded once at seed 0.** A predictive paddle controller reading the RAM ball and paddle channels: it estimates the ball velocity from the previous decision, reflects the predicted path off the side walls, and steers to the crossing point. It reaches score 64 over 839 inputs. The script ends at the input that opened the last rung. +- **The ladder doubles.** The reference declares trigger points 1, 2, 4, 8, 16, 32, 64 and `deriveContract` samples the score that actually held at each, so the packaged checks are `score >= 1, 2, 4, 8, 18, 32, 64`. The fifth trigger is written as 18 because the reference's score steps 14 to 18 when it clears a four-point row, and a trigger inside that step would derive a check that disagrees with its own name. +- **All seven trivial baselines still score zero**, at 210, 300, 450, 600, 900 and 1,200 inputs. `PackagedContract.calibrate` passes with no declaration at all: nothing to accept as opaque, nothing to record as attrition, nothing the whole contract hangs off. +- **The contract states no hash.** `frame-at-first-score` and `save-at-first-score` pinned the screen and the save state at the first point scored. Measured by the substitution sweep: 56 of 96 single-input substitutions of the 32-input prefix still satisfy them, at 21 of 32 turns, for at least 5.22 × 10¹¹ distinct 32-input logs. No independent control has ever landed on that state, so the two points were denominator only the reference could score against. +- **The contract states no `life-lost` milestone.** It is `lives == 4`, earned by dying, and the achievement split below already excluded it from the achievement score. Removing it makes the whole-contract score equal the achievement score, so no consumer can pick the number that ranks a control that died above one that did not. +- **No rung requires another.** `engineState.score` rises and never falls, measured over the reference and every baseline, so `score >= 18` cannot pass before `score >= 8` and a `requires` edge would restate the check while reporting a collapse a seven-rung ladder does not have. `report.collapse.collapses` is now `false` for this target, and its seven rungs first pass at seven distinct inputs (32, 71, 137, 259, 404, 636, 839). +- **`ale.test.mts` keeps a demonstration contract** derived from the same reference, with the two hash tiers, a `requires` chain and `life-lost`. The screen-frame and save-file evidence tiers, the opaque-collision sweep, the attrition classifier and the collapse gate stay under test on the real emulator; they are simply no longer things a Breakout player is graded on. `screen-blind` joins the two steering controls as a permanent gate, and the test now asserts the strongest control does NOT reach the top rung. +- **What a consumer must do.** The packaged reference file, the derived contract and its hash all change. A stored `Attestation` or `EpisodeRecord` against the old contract does not verify against the new one, and a milestone id from it (`score-opened`, `score-tier-2`, `score-tier-4`, `life-lost`, `frame-at-first-score`, `save-at-first-score`) no longer exists. Pass the previous reference through `makeAle({ game: 'breakout', reference })` to keep the old contract. No adapter behaviour changed: the worker, the evidence keys, the observation, the input vocabulary and the seed handling are untouched. + ### A milestone earned by dying is not evidence of skill - **The measurement.** ALE Breakout, ale-py 0.12.1, seed 0. Two deterministic controls with the same control law and the same deadzone in screen pixels, differing only in what they read: the ASCII frame at four screen pixels per character, or the `ram_ball_x`/`ram_paddle_x` channels at one pixel each. Neither carries state between decisions and neither costs a model call. @@ -13,7 +33,7 @@ All notable changes to Playproof are documented here. | `steer-from-ascii` | 6 | 7 | 0 (dead after 375) | **4 of 6** | 3 of 5 | | `steer-from-ram` | **9** | **24** | **5** | 3 of 6 | 3 of 5 | - The RAM control wins every column the game itself reports and never dies. It scored one milestone LOWER, because the packaged `life-lost` milestone is `lives == 4`: a point for dying. A program that never dies capped at 3 of 6 at any horizon. + The RAM control wins every column the game itself reports and never dies. It scored one milestone LOWER, because the packaged `life-lost` milestone is `lives == 4`: a point for dying. A program that never dies capped at 3 of 6 at any horizon. Those counts are against the six-milestone contract that the section above then replaced. - **`ProgressionKind` is `achievement` or `attrition`.** A milestone is attrition when a resource running down earns it. It marks progress REACHED and never competence shown, because the shortest path to it is to play badly. Recording it is legitimate; scoring it as competence is not. - **The split is measured, not declared, and it reads no field name.** `measureProgressions` watches every numeric channel the evidence publishes across the reference and every baseline, and calls a milestone attrition when three measured statements hold: its check reads a numeric channel; that channel never rose and fell at least once, over every snapshot of every trajectory; and the check does not hold at the initial value of that channel. Attrition propagates through `requires`, because `MilestoneTracker` admits a milestone only after its prerequisites passed. - A hardcoded `lives`/`health`/`shields` list would have failed the way a name match already failed one layer down: ALE spells its terminal flag `terminal`, Gymnasium `terminated`, stable-retro `episodeDone`. A fixture channel named `lives` that counts rescued divers only rises, and the measurement classifies it as the achievement it is. @@ -27,7 +47,7 @@ All notable changes to Playproof are documented here. | packaged contract | gated behind | of | prerequisite reached by a baseline | open at one instant | |---|---|---|---|---| - | ALE Breakout | `score-opened` | 6 of 6 | no, 0 of 7 | 3 of 6, after 32 inputs | + | ALE Breakout, before the ladder above | `score-opened` | 6 of 6 | no, 0 of 7 | 3 of 6, after 32 inputs | | stable-retro Airstriker | `score-opened` | 5 of 5 | yes, 2 of 28 | 3 of 5, after 41 inputs | | Gymnasium CartPole | `survived-25-steps` | 5 of 5 | yes, 2 of 6 | 3 of 5, after 25 steps | | Gymnasium FrozenLake | `reached-goal` | 3 of 3 | no, 0 of 8 | 3 of 3, after 6 steps | diff --git a/README.md b/README.md index 09a6124..9802819 100644 --- a/README.md +++ b/README.md @@ -170,12 +170,12 @@ Aborting through `signal` is a different thing: it throws inside the loop, befor ### Measured -ALE Breakout, ale-py 0.12.1, seed 0, 300 turns, one scripted policy that opens four milestones and then loses every life: +ALE Breakout, ale-py 0.12.1, seed 0, 300 turns, one scripted policy that opens the first rung of the ladder and then loses every life: | Run | Decisions | `stoppedBy` | `gameOver` | Milestones | |---|---|---|---|---| -| turn limit | 300 | `maxTurns` | `true` | 4 of 6 | -| game-over stop | 150 | `gameOver` | `true` | 4 of 6 | +| turn limit | 300 | `maxTurns` | `true` | 1 of 7 | +| game-over stop | 150 | `gameOver` | `true` | 1 of 7 | The 150 dropped decisions are inert, not merely unproductive. The ALE worker breaks out of its action-repeat loop once the game is over, so every evidence channel is byte-identical from decision 150 to decision 300. @@ -210,7 +210,7 @@ The emulator adapters were already capturing the screen. `ale/worker.py` calls `getScreenRGB()`, hashes those pixels into `frameHash` for verification, and used to throw the picture away; the agent received a luminance-to-ASCII downsample of it. That is a perception limit the harness created, not a result about the agent. -Measured on ALE Breakout: `stealth/ox-alpha`, a `text+image->text` model, and `liquid/lfm-2.5-2.6b:free` both scored 0 of 6 milestones, and their own transcripts show them reading the ASCII as a maze — "exploring the map", "positioned near the goal area" — rather than a paddle-and-ball game. +Measured on ALE Breakout, against the six-milestone contract of the time: `stealth/ox-alpha`, a `text+image->text` model, and `liquid/lfm-2.5-2.6b:free` both scored 0 of 6 milestones, and their own transcripts show them reading the ASCII as a maze — "exploring the map", "positioned near the goal area" — rather than a paddle-and-ball game. One of them pressed `FIRE` twice in 45 turns, so no ball was ever in play. ### Bounds @@ -347,13 +347,16 @@ Legibility is derived from the check kind, so no contract changes and no author import { contractLegibility, formatMilestoneScore } from '@tangle-network/playproof' contractLegibility(contract) -// { legible: ['score-opened', 'score-tier-2', 'score-tier-4', 'life-lost'], +// { legible: ['score-opened', 'life-lost'], // opaque: ['frame-at-first-score', 'save-at-first-score'], // reasons: { 'frame-at-first-score': 'its frame-hash check states its requirement as a hash, …' } } -formatMilestoneScore(record.score) // '3 of 6' +formatMilestoneScore(record.score) // '3 of 4' ``` +The contract above is the demonstration contract in `ale.test.mts`, not the packaged Breakout one. +The packaged contract states no hash at all, for the measured reason two sections below. + Every milestone is a point, hashes included, so the denominator of a score is the contract's milestone count. `Attestation` and `EpisodeRecord` carry `verified` and `score`; a campaign segment report carries `scoreSoFar`. @@ -410,14 +413,17 @@ A hash milestone that a later derivation adds therefore cannot enter a published `calibrateContract` also runs `probeOpaqueCollisions`: it replaces one input of the reference at a time, over the prefix that ends where an opaque check first passes, and counts the perturbed logs that still satisfy it. A hash a large family of logs satisfies is a weak check, and `weakChecks` is where an author accepts the measured number by id. -Measured on ALE Breakout, whose two hashes fire after 32 inputs over `NOOP/FIRE/RIGHT/LEFT`: +Measured on a Breakout contract that pins the screen and the save state at the first point scored, whose two hashes fire after 32 inputs over `NOOP/FIRE/RIGHT/LEFT`: | Measurement | Value | |---|---| | single-input substitutions of the 32-turn prefix | 96 | -| substitutions that still reproduce both hashes | 40, at 16 of the 32 turns | -| all 16 applied at once | still reproduces both | -| distinct 32-input logs that satisfy the hashes | at least 3.82 × 10⁸ | +| substitutions that still reproduce both hashes | 56, at 21 of the 32 turns | +| all 21 applied at once | does not reproduce them | +| distinct 32-input logs that satisfy the hashes | at least 5.22 × 10¹¹ | + +The packaged Breakout contract therefore states no hash. +Half a trillion logs stand in that state, and no independent control in `ale.test.mts` has ever landed on it: the two points were a denominator only the reference could score against. `FIRE` while the ball is already in flight is a state no-op, so those logs reach a bit-identical emulator state. @@ -448,18 +454,23 @@ scoreMilestones(contract, verified) // how far the run got scoreAchievements(contract, profile, verified) // how well it played ``` -Measured on ALE Breakout, ale-py 0.12.1, seed 0. Two deterministic controls with the same control law and the same deadzone in screen pixels, differing only in what they read: +Measured on ALE Breakout, ale-py 0.12.1, seed 0. Three deterministic controls, none of which costs a model call. Two share a control law and a deadzone in screen pixels and differ only in what they read; the third reads nothing and repeats a fixed three-word cycle, and it is the strongest screen-blind program a sweep of all 340 input patterns of period four or less found: -| control | reads | game score @300 | @600 | lives left | milestones | achievements | +| control | reads | game score @300 | @600 | lives left | achievements @300 | @600 | |---|---|---|---|---|---|---| -| `steer-from-ascii` | the ASCII frame, 4 px per character | 6 | 7 | 0 | **4 of 6** | 3 of 5 | -| `steer-from-ram` | `ram_ball_x`, `ram_paddle_x` | **9** | **24** | **5** | 3 of 6 | 3 of 5 | +| `screen-blind` | nothing | 7 | 7 | 0 | 3 of 7 | 3 of 7 | +| `steer-from-ascii` | the ASCII frame, 4 px per character | 6 | 7 | 0 | 3 of 7 | 3 of 7 | +| `steer-from-ram` | `ram_ball_x`, `ram_paddle_x` | **9** | **24** | **5** | **4 of 7** | **5 of 7** | -The RAM control wins every column the game reports and never dies. -Under the whole contract it scored LOWER, because `life-lost` is `lives == 4`: a point for dying. +The RAM control wins every column the game reports and never dies, and the contract now says so at both budgets. +It did not always. Against a six-milestone contract whose top achievement was `score >= 4`, all three controls tied at 3 of 5 achievements, and the whole-contract score put the ASCII control FIRST at 4 of 6, because `life-lost` is `lives == 4`: a point for dying. `separating` and `separates` therefore count legible **achievement** milestones only, and `attritionSeparating` records the rest. Declare an attrition milestone to keep it: `assertContractSeparates(report, { attritionChecks: ['life-lost'] })`. +Excluding a point for dying was necessary and it was not sufficient. +A contract whose top rung is `score >= 4` cannot tell 7 from 24 however it scores, so the packaged Breakout ladder now doubles — `score >= 1, 2, 4, 8, 18, 32, 64` — over a reference that reaches 64. +The strongest control reaches 5 of those 7, so the ladder still has rungs above the best program anyone has written for it. + ### A contract that grades on one event `requires` is a partial order, so a contract can state six progressions and demand exactly one event. @@ -467,7 +478,6 @@ Declare an attrition milestone to keep it: `assertContractSeparates(report, { at | contract | gated behind | of | prerequisite reached by a baseline | open at one instant | |---|---|---|---|---| -| ALE Breakout | `score-opened` | 6 of 6 | no | 3 of 6, after 32 inputs | | stable-retro Airstriker | `score-opened` | 5 of 5 | yes: `round-robin`, `pseudo-random` | 3 of 5, after 41 inputs | | Gymnasium CartPole | `survived-25-steps` | 5 of 5 | yes: `round-robin`, `pseudo-random` | 3 of 5, after 25 steps | | Gymnasium FrozenLake | `reached-goal` | 3 of 3 | no | 3 of 3, after 6 steps | @@ -475,6 +485,9 @@ Declare an attrition milestone to keep it: `assertContractSeparates(report, { at A run that misses the prerequisite scores zero however well it played; where a baseline reaches it, the whole contract opens for free. The gate refuses until the author declares the structure with `{ gatedBehind: 'score-opened' }`. +ALE Breakout used to head that table at 6 of 6, gated behind `score-opened`, with three milestones opening at input 32. +Its seven rungs now chain nothing and open at seven distinct inputs: `engineState.score` never falls, so `score >= 18` cannot pass before `score >= 8` and a `requires` edge would only restate the check. + ### A packaged contract carries the calibration that justified it Calibration used to be optional, and an optional gate is a gate nothing has to pass. @@ -496,6 +509,9 @@ const packaged = PackagedContract.calibrate(game, contract, { packaged.report.separates // the verdict travels with the contract ``` +A contract with nothing to declare passes with no `declare` at all. +The packaged ALE Breakout contract is calibrated that way in `ale.test.mts`: seven legible achievement rungs, no hash, no attrition milestone, and no prerequisite the whole contract hangs off. + A target that is not meant to separate — a tier demonstration, a smoke fixture — says so in words, and the declaration is refused when it goes stale: ```ts @@ -510,7 +526,7 @@ No trivial baseline reproduces either hash, which is exactly why the baseline su | Contract | Milestones | Legible | Reference score | Best trivial baseline | |---|---|---|---|---| -| ALE Breakout | 6 | 4 | 6 of 6 | 0 legible | +| ALE Breakout | 7 | 7 | 7 of 7 | 0 legible | | Libbet through `pyboy-generic` | 6 | 4 | 3 of 6 | 3 legible | Breakout separates on its legible milestones. @@ -614,9 +630,9 @@ The [Arcade Learning Environment](https://github.com/Farama-Foundation/Arcade-Le - **Inputs.** The game's minimal action set, as ALE `Action` names: `NOOP`, `FIRE`, `UP`, `RIGHT`, `LEFT`, `DOWN`, `UPRIGHT`, and the rest. Unknown words are no-ops. Each input is held for `frames` emulator frames, four by default. - **Observation.** An ASCII downsample of the screen plus a one-line score, lives, and frame summary. `screenImage: true` adds the rendered screen as a PNG, with `screenScale` repeating whole pixels; at 3x a Breakout frame encodes to about 2.4 KB. - **Evidence.** Cumulative score, lives, the emulator frame counters, and the RAM bytes the caller names as `channels`. The 128-byte RAM page is never published whole. Joined by the rendered-frame hash and the serialized emulator-state hash. -- **Verification.** `replay`. Screens, RAM, counters, and the serialized `ALEState` were measured byte-identical across separate worker processes at all 211 snapshots of the Breakout reference, so a save-file milestone is honest here even though the same tier is not honest on stable-retro. See [Execution adapters](docs/adapters.md) for the numbers. +- **Verification.** `replay`. Screens, RAM, counters, and the serialized `ALEState` were measured byte-identical across separate worker processes at all 840 snapshots of the Breakout reference, so a save-file milestone is honest here even though the same tier is not honest on stable-retro. See [Execution adapters](docs/adapters.md) for the numbers. -`ale-py` bundles the Atari ROM set, so the adapter and its test run on a clean CI machine with no download and no secret. The bundled reference plays Breakout; supply a reference playthrough through `options.reference` for any of the other ROMs. +`ale-py` bundles the Atari ROM set, so the adapter and its test run on a clean CI machine with no download and no secret. The bundled reference plays Breakout to a score of 64 over 839 inputs, and its contract is seven rungs of one progression: `score >= 1, 2, 4, 8, 18, 32, 64`. Supply a reference playthrough through `options.reference` for any of the other ROMs. ### Any Gymnasium environment diff --git a/adapters/ale.ts b/adapters/ale.ts index f1453e5..4c7408c 100644 --- a/adapters/ale.ts +++ b/adapters/ale.ts @@ -20,8 +20,8 @@ * * `saveBlobHash` is published here, and that is the opposite of what * `adapters/stable-retro` concluded on its own substrate. Measured on Breakout - * with ale-py 0.12.1: over the 210-input reference, two separate worker - * processes produced byte-identical `ALEState` serializations at all 211 + * with ale-py 0.12.1: over the 839-input reference, two separate worker + * processes produced byte-identical `ALEState` serializations at all 840 * snapshots, alongside identical screens, RAM, and counters. A verifier that * never shares the emulator can therefore recompute a save-file milestone. * Each substrate earns its tiers with its own measurement. diff --git a/ale.test.mts b/ale.test.mts index 00c9665..73e3bf9 100644 --- a/ale.test.mts +++ b/ale.test.mts @@ -6,50 +6,119 @@ * absent, unless PLAYPROOF_REQUIRE_ALE=1, which turns the missing dependency * into a loud failure (that is how CI proves the job really executed). * - * Battery: contract derivation across three evidence tiers, known-good - * attestation, garbage rejection, graded partial credit, calibration with the - * legible/opaque split and the opaque-collision sweep, cross-process - * determinism including the save-state hash, - * checkpoint round-trip, unknown-input no-op, the observation image channel, - * and worker teardown. Zero model spend. + * Battery: derivation of the packaged score ladder, known-good attestation, + * garbage rejection, graded partial credit, the publication gate + * (`PackagedContract.calibrate`, with no declaration to make), three + * hand-written controls the ladder must rank in the order the game itself + * reports, a demonstration contract that carries the hash tiers, a `requires` + * chain and a milestone a lost life earns, cross-process determinism including + * the save-state hash, checkpoint round-trip, unknown-input no-op, the + * observation image channel, and worker teardown. Zero model spend. */ import { strict as assert } from 'node:assert' import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' +import { deriveContract } from './authoring' import { attestRun, MilestoneTracker } from './attestation' -import { assertContractSeparates, assertOpaqueChecksDeclared, calibrateContract, measureProgressions } from './calibration' +import { + assertContractSeparates, + assertOpaqueChecksDeclared, + calibrateContract, + measureProgressions, + PackagedContract, +} from './calibration' import { playEpisode, scriptedDriver } from './episode' import { isGameOver, logFrom, observationOf } from './runtime' import { decodePng, unscale } from './test-png.mts' -import { contractLegibility, formatMilestoneScore, scoreAchievements, scoreMilestones, validateContract } from './schema' +import { + contractHash, + contractLegibility, + formatMilestoneScore, + scoreAchievements, + scoreMilestones, + validateContract, +} from './schema' import { AleRpc } from './adapters/ale-rpc' -import { bundledReference, makeAle, type Ale, type AleState } from './adapters/ale' +import { aleMarks, bundledReference, makeAle, type Ale, type AleMilestoneRule, type AleState } from './adapters/ale' const GAME = 'breakout' /** - * The opaque-collision sweep on the packaged Breakout contract, pinned. + * The packaged ladder, as the reference attains it. + * + * The reference file declares trigger points that double — 1, 2, 4, 8, 16, 32, + * 64 — and `deriveContract` samples the score that actually held when each one + * fired, so this is the contract a consumer gets rather than a restatement of + * the reference. The fifth rung reads 18 because the reference's score steps 14 + * to 18 when it clears a four-point row, and its trigger is written as 18 so + * the milestone id and its derived check agree. + */ +const LADDER = [1, 2, 4, 8, 18, 32, 64] as const + +/** Reference inputs consumed before each rung of the ladder first passes. */ +const LADDER_FIRST_PASS = [32, 71, 137, 259, 404, 636, 839] + +/** + * A second contract, derived from the SAME reference, for everything the + * packaged ladder deliberately no longer carries. + * + * The packaged contract is seven rungs of one legible progression: every point + * says how many bricks the run broke, and a reader can weigh every one. That + * left three mechanisms with no live subject on a real emulator — the + * screen-frame and save-file evidence tiers, a `requires` chain, and a + * milestone a lost life earns. They are exercised here instead, so removing + * them from the published contract does not quietly stop testing them. + */ +const DEMO_RULES: AleMilestoneRule[] = [ + { id: 'score-opened', tier: 'engine-state', variable: 'score', op: '>=', trigger: 1 }, + { + id: 'frame-at-first-score', tier: 'screen-frame', sample: 'frame-hash', + variable: 'score', op: '>=', trigger: 1, requires: ['score-opened'], + }, + { + id: 'save-at-first-score', tier: 'save-file', sample: 'save-hash', + variable: 'score', op: '>=', trigger: 1, requires: ['score-opened'], + }, + { + id: 'life-lost', tier: 'engine-state', variable: 'lives', op: '<', + trigger: 5, sampleOp: '==', requires: ['score-opened'], + }, +] + +/** + * Inputs the demonstration contract is calibrated over. The reference loses its + * first life after 226, so a shorter budget would leave `lives` motionless and + * the attrition classifier with nothing to read. + */ +const DEMO_TURNS = 240 + +/** + * The opaque-collision sweep on the demonstration contract, pinned. * * Both hashes fire after 32 inputs. Over the vocabulary NOOP/FIRE/RIGHT/LEFT - * that prefix admits 96 single-input substitutions, and 40 of them still - * reproduce both hashes, at 16 of the 32 turns. Applying one alternative at - * every free turn at once reproduces them too, which is why the family bound - * multiplies out instead of adding. + * that prefix admits 96 single-input substitutions, and 56 of them still + * reproduce both hashes, at 21 of the 32 turns. Applying one alternative at + * every free turn at once does NOT, so the family bound is a product the sweep + * has not shown to be reachable jointly. * * These numbers are the correction to #22, which concluded from a clean * trivial-baseline result that a hash could only be reached by replaying the - * reference. A regression here means ale-py, the ROM, or the reference moved. + * reference. They are also the reason the packaged contract carries no hash: a + * check half a billion 32-input logs satisfy states nothing a reader can weigh, + * and no independent control in this file has ever earned one. + * + * A regression here means ale-py, the ROM, or the reference moved. */ -const ALE_BREAKOUT_COLLISIONS = [ +const DEMO_COLLISIONS = [ { milestone: 'frame-at-first-score', firesAfter: 32, probedTurns: 32, substitutions: 96, - collisions: 40, freeTurns: 16, jointCollision: true, family: 382205952, + collisions: 56, freeTurns: 21, jointCollision: false, family: 521838526464, }, { milestone: 'save-at-first-score', firesAfter: 32, probedTurns: 32, substitutions: 96, - collisions: 40, freeTurns: 16, jointCollision: true, family: 382205952, + collisions: 56, freeTurns: 21, jointCollision: false, family: 521838526464, }, ] const python = process.env.PLAYPROOF_PYTHON ?? 'python3' @@ -105,15 +174,20 @@ if (!pythonHasAle()) { ]) // Authoring: contract derived from the reference with event-anchored - // marks. No hash, position, or threshold is typed into the adapter. + // marks. No hash, position, or threshold is typed into the adapter — the + // reference declares a trigger and the replay decides what held there. assert.deepEqual(validateContract(adapter.contract), []) assert.equal(adapter.contract.milestones.length, reference.milestones.length) - const tiers = new Set(adapter.contract.milestones.map((m) => m.tier)) - assert.ok(tiers.has('engine-state') && tiers.has('screen-frame') && tiers.has('save-file'), - `expected engine-state, screen-frame and save-file tiers, got ${[...tiers].join(',')}`) - const kinds = new Set(adapter.contract.milestones.map((m) => m.check.kind)) - assert.ok(kinds.has('state-path') && kinds.has('frame-hash') && kinds.has('save-hash'), - `expected state-path, frame-hash and save-hash checks, got ${[...kinds].join(',')}`) + assert.deepEqual(adapter.contract.milestones.map((m) => m.id), LADDER.map((v) => `score-${v}`)) + assert.deepEqual( + adapter.contract.milestones.map((m) => m.check), + LADDER.map((value) => ({ kind: 'state-path', path: 'score', op: '>=', value })), + ) + // No rung requires another. `engineState.score` never falls (asserted from + // the calibration motion below), so a run cannot pass `score >= 18` without + // having passed `score >= 8`, and a `requires` edge would only restate the + // check while making the contract report that it grades one event. + assert.deepEqual(adapter.contract.milestones.flatMap((m) => m.requires ?? []), []) // Known-good: the reference verifies every milestone. const all = adapter.contract.milestones.map((m) => m.id) @@ -135,166 +209,99 @@ if (!pythonHasAle()) { // actually reached and is rejected only for the ones it claims beyond. const partial = attestRun(adapter.game, adapter.contract, adapter.seed, logFrom(adapter.seed, adapter.reference.slice(0, 100)), all) assert.equal(partial.verdict, 'rejected') - assert.deepEqual(partial.verified, ['score-opened', 'frame-at-first-score', 'save-at-first-score', 'score-tier-2']) - assert.deepEqual(partial.reasons, ['claimed-not-reproduced:score-tier-4,life-lost']) - - // A hash check identifies a STATE, not a trajectory. Both hashes here name - // the exact screen and save state the reference stood in when it first - // scored, and any policy that stands in that state earns them. They are - // points, so the honest denominator is six. - // - // What they are not is readable: `frame-at-first-score` says nothing a - // contract reader can weigh, while `score-tier-4` says score >= 4. - assert.deepEqual(contractLegibility(adapter.contract), { - legible: ['score-opened', 'score-tier-2', 'score-tier-4', 'life-lost'], - opaque: ['frame-at-first-score', 'save-at-first-score'], - reasons: { - 'frame-at-first-score': - 'its frame-hash check states its requirement as a hash, so a reader cannot see what it demands', - 'save-at-first-score': - 'its save-hash check states its requirement as a hash, so a reader cannot see what it demands', - }, - }) - assert.deepEqual(good.score, { verified: 6, total: 6 }) - assert.equal(formatMilestoneScore(partial.score), '4 of 6') - - // A run that played Breakout and did not land on the pinned state scores - // three of six. Three of four was the wrong statement: it removed two - // points from the denominator that a policy can, and does, reach. - const played = ['score-opened', 'score-tier-2', 'life-lost'] - assert.deepEqual(scoreMilestones(adapter.contract, played), { verified: 3, total: 6 }) - assert.equal(formatMilestoneScore(scoreMilestones(adapter.contract, played)), '3 of 6') - - // Calibration on the real emulator. Every baseline plays the reference's - // 210 turns, so the comparison is length-matched. - const calibration = calibrateContract(adapter.game, adapter.contract, { + assert.deepEqual(partial.verified, ['score-1', 'score-2']) + assert.deepEqual(partial.reasons, ['claimed-not-reproduced:score-4,score-8,score-18,score-32,score-64']) + + // Every point of the packaged contract is one rung of one legible ladder, + // and each rung says the same thing in a louder voice: this run broke this + // many bricks. Nothing is stated as a hash, so a reader can weigh all seven + // and the honest denominator is seven. + assert.deepEqual(contractLegibility(adapter.contract), { legible: all, opaque: [], reasons: {} }) + assert.deepEqual(good.score, { verified: 7, total: 7 }) + assert.equal(formatMilestoneScore(partial.score), '2 of 7') + + // A run that scored 9 earns four rungs out of seven. The old contract's top + // milestone was `score >= 4`, so 9 and 24 and 64 were the same number to it. + const played = ['score-1', 'score-2', 'score-4', 'score-8'] + assert.deepEqual(scoreMilestones(adapter.contract, played), { verified: 4, total: 7 }) + assert.equal(formatMilestoneScore(scoreMilestones(adapter.contract, played)), '4 of 7') + + // Calibration on the real emulator, through the gate a published target has + // to pass. `PackagedContract.calibrate` is the only way to build a + // `PackagedContract`, and this call carries NO declaration: the ladder + // states no opaque check to accept, no attrition milestone to record, and no + // prerequisite the whole contract hangs off. Every baseline plays the + // reference's 839 turns, so the comparison is length-matched. + const packaged = PackagedContract.calibrate(adapter.game, adapter.contract, { reference: adapter.reference, vocabulary: adapter.inputs, seed: adapter.seed, }) + const calibration = packaged.report for (const outcome of [calibration.reference, ...calibration.baselines]) { console.log(` ${outcome.id.padEnd(32)} ${String(outcome.verified.length).padStart(2)} ${outcome.verdict} ${outcome.verified.join(',') || '-'}`) } - assert.deepEqual(calibration.legible, ['score-opened', 'score-tier-2', 'score-tier-4', 'life-lost']) - assert.deepEqual(calibration.opaque, ['frame-at-first-score', 'save-at-first-score']) - assert.deepEqual(calibration.referenceScore, { verified: 6, total: 6 }) - // No trivial policy reproduced either hash. That was the whole of the - // evidence #22 had for calling them unearnable, and it proves nothing: the - // baseline suite cannot serve a ball, let alone score. - assert.deepEqual(calibration.opaqueReproduced, []) - - // The substitution sweep is the prober that can. Both hashes fire after 32 - // inputs; the sweep replaces one input of that prefix at a time over - // NOOP/FIRE/RIGHT/LEFT and counts the logs that still satisfy the check. - // FIRE while the ball is already in flight is a state no-op, so a large - // family of logs reaches a bit-identical emulator state. - for (const row of calibration.collisions) { - console.log( - ` ${row.milestone.padEnd(24)} fires after ${row.firesAfter}; ` + - `${row.collisions}/${row.substitutions} substitutions collide at ${row.freeTurns}/${row.probedTurns} turns; ` + - `joint=${row.jointCollision}; family >= ${row.family.toExponential(2)}`, - ) - } - assert.deepEqual(calibration.collisions, ALE_BREAKOUT_COLLISIONS) - - // Undeclared, the contract cannot ship, whatever the separation verdict. - assert.throws(() => assertOpaqueChecksDeclared(calibration), /states 2 of 6 milestone\(s\) as a hash/u) - // Declaring them opaque is still not enough, because the sweep measured - // the collisions. The author must accept the weakness by id. - assert.throws( - () => assertOpaqueChecksDeclared(calibration, { opaqueChecks: ['frame-at-first-score', 'save-at-first-score'] }), - /2 opaque milestone\(s\) are satisfied by input logs other than the reference/u, - ) - const opacity = { - opaqueChecks: ['frame-at-first-score', 'save-at-first-score'], - weakChecks: ['frame-at-first-score', 'save-at-first-score'], - } - assertOpaqueChecksDeclared(calibration, opacity) - - // `life-lost` is `lives == 4`: the reference earns it by DYING. The - // classification reads no field name — it reads the motion of the channel, - // which falls and never rises across all eight replayed trajectories, and - // a check that does not hold at the starting value of a channel that only - // falls can only be earned by letting the resource run down. - // - // Before this split it sat in `separating`, so the contract's measured - // discriminating power included a point for dying. Two hand-written - // controls that differ only in how well they steer therefore ranked in the - // wrong order: the one that never died capped at 3 of 6. - assert.deepEqual(calibration.progression.attrition, ['life-lost']) - assert.deepEqual(calibration.progression.unmeasured, ['frame-at-first-score', 'save-at-first-score']) - assert.match( - calibration.progression.reasons['life-lost'] ?? '', - /reads engineState\.lives, which fell \d+ time\(s\) and never rose/u, - ) - assert.equal(calibration.progression.motion.find((row) => row.channel === 'engineState.lives')?.rises, 0) - assert.equal(calibration.progression.motion.find((row) => row.channel === 'engineState.score')?.falls, 0) - assert.deepEqual(calibration.attritionSeparating, ['life-lost']) - assert.ok(!calibration.separating.includes('life-lost')) - assert.deepEqual(calibration.referenceAchievementScore, { verified: 5, total: 5 }) - - // Every milestone requires `score-opened`, and three of the six open at the - // same instant of the reference. Six milestone ids, one decision point. - assert.equal(calibration.collapse.prerequisite, 'score-opened') - assert.equal(calibration.collapse.gated, 6) - assert.equal(calibration.collapse.total, 6) - assert.equal(calibration.collapse.collapses, true) - assert.deepEqual(calibration.collapse.earnedByBaseline, []) - assert.deepEqual(calibration.collapse.simultaneous, ['score-opened', 'frame-at-first-score', 'save-at-first-score']) - assert.equal(calibration.collapse.simultaneousAfter, 32) - - // Undeclared, the packaged contract does not pass the gate, and the two - // findings are reported together with their numbers. - assert.throws( - () => assertContractSeparates(calibration, opacity), - (error: unknown) => { - const message = (error as Error).message - assert.match(message, /states 1 of 6 milestone\(s\) that a resource running down earns/u) - assert.match(message, /attritionChecks: \['life-lost'\]/u) - assert.match(message, /collapses to one event: 6 of 6 milestone\(s\) require score-opened/u) - assert.match(message, /3 of 6 milestone\(s\) first pass at the same reference input \(after 32\)/u) - return true - }, - ) - - const declared = { ...opacity, attritionChecks: ['life-lost'], gatedBehind: 'score-opened' } - // Whether Breakout separates is a fact about the ROM, not about this - // change: assert only that the legible ACHIEVEMENT comparison is the one - // being made. - const referenceAchievement = calibration.reference.verified - .filter((id) => calibration.legible.includes(id) && calibration.progression.kinds[id] === 'achievement').length - assert.equal( - calibration.separates, - calibration.separating.length > 0 && referenceAchievement > calibration.bestBaselineAchievementCount, - ) - if (calibration.separates) assertContractSeparates(calibration, declared) - else assert.throws(() => assertContractSeparates(calibration, declared), /does not separate/u) + assert.equal(packaged.hash, contractHash(adapter.contract)) + assert.deepEqual(packaged.declaration, {}) + assert.equal(calibration.turns, adapter.reference.length) + assert.deepEqual(calibration.legible, all) + assert.deepEqual(calibration.opaque, []) + assert.deepEqual(calibration.collisions, []) + assert.deepEqual(calibration.trivial, []) + assert.deepEqual(calibration.separating, all) + assert.deepEqual(calibration.attritionSeparating, []) + assert.equal(calibration.bestBaselineAchievementCount, 0) + assert.deepEqual(calibration.referenceScore, { verified: 7, total: 7 }) + assert.deepEqual(calibration.referenceAchievementScore, { verified: 7, total: 7 }) + assert.equal(calibration.separates, true) + + // Nothing is attrition and nothing is unmeasured: every check reads + // `engineState.score`, which rose and never fell across all eight replayed + // trajectories. The whole-contract score and the achievement score are + // therefore the same number, so no consumer can pick the one that ranks a + // run that died above a run that did not. + assert.deepEqual(calibration.progression.achievement, all) + assert.deepEqual(calibration.progression.attrition, []) + assert.deepEqual(calibration.progression.unmeasured, []) + const scoreMotion = calibration.progression.motion.find((row) => row.channel === 'engineState.score') + assert.equal(scoreMotion?.falls, 0) + assert.ok((scoreMotion?.rises ?? 0) > 0, 'the score channel never rose') + + // The contract no longer resolves a run on one event. Seven rungs open at + // seven distinct instants of the reference, so no moment of play wears + // several milestone ids. + assert.equal(calibration.collapse.prerequisite, null) + assert.equal(calibration.collapse.collapses, false) + assert.deepEqual(calibration.collapse.simultaneous, []) + assert.equal(calibration.collapse.simultaneousAfter, -1) + assert.deepEqual(all.map((id) => calibration.collapse.firstPassAt[id]), LADDER_FIRST_PASS) console.log( `ale: calibration — reference ${formatMilestoneScore(calibration.referenceScore)}, ` + - `achievements ${formatMilestoneScore(calibration.referenceAchievementScore)}, ` + `best trivial baseline ${calibration.bestBaselineLegibleCount} legible over ${calibration.turns} turns, ` + - `separating=${calibration.separating.join(',') || 'nothing'}, ` + - `attrition-only=${calibration.attritionSeparating.join(',') || 'nothing'}, ` + - `separates=${calibration.separates}, collapses=${calibration.collapse.collapses}`, + `separating=${calibration.separating.join(',')}, separates=${calibration.separates}, ` + + `collapses=${calibration.collapse.collapses}, opaque=${calibration.opaque.length}, ` + + `attrition=${calibration.progression.attrition.length}`, ) - // --- the ordering the split exists to fix ----------------------------- + // --- the ordering the ladder exists to produce --------------------------- // - // Two deterministic controls with the SAME control law and the same - // deadzone in screen pixels, differing only in what they read: the ASCII - // frame the agent sees, at four screen pixels per character, or the RAM - // channels the adapter publishes, at one pixel each. Neither carries state - // between decisions and neither costs a model call. + // Three deterministic controls, none of which carries state between + // decisions and none of which costs a model call. // - // The RAM control wins every column the game itself reports — score 9 to 6 - // at 300 decisions, 24 to 7 at 600 — and it never dies, while the ASCII - // control runs out of lives after 375. Under the whole contract the ASCII - // control still scores HIGHER, because `life-lost` is a point for dying. - // Under achievements alone the ordering is no longer backwards. + // Two share a control law and a deadzone in screen pixels and differ only in + // what they read: the ASCII frame the agent sees, at four screen pixels per + // character, or the RAM channels the adapter publishes, at one pixel each. + // The third reads nothing at all — it repeats a fixed three-word cycle. It + // is the strongest screen-blind program a sweep of every input pattern of + // period four or less found (340 patterns, best game score 7 at both 300 + // and 600 decisions). // - // The two achievement scores tie rather than separating, and the reason is - // the second finding this contract carries: its top achievement is - // `score >= 4`, so nothing in it can tell 7 from 24. + // The RAM control wins every column the game itself reports: score 9 to 6 + // at 300 decisions and 24 to 7 at 600, and it never dies while the other two + // run out of lives. Under the contract this file replaced, all three tied at + // 3 of 5 achievements, because its top achievement was `score >= 4`. The + // ladder is here so that the ordering the game reports is the ordering the + // contract reports. const PLAY_ROWS = [11, 20] as const const PADDLE_ROWS = [21, 22] as const const marked = (row: string): number[] => { @@ -326,6 +333,11 @@ if (!pythonHasAle()) { // and 4 units is the 4-pixel character the ASCII control resolves. return steerTowards(ball, (engine.ram_paddle_x ?? 0) + 10, 4) } + const SCREEN_BLIND_CYCLE = ['NOOP', 'FIRE', 'LEFT'] + const screenBlind = (): (() => string) => { + let turn = 0 + return () => SCREEN_BLIND_CYCLE[turn++ % SCREEN_BLIND_CYCLE.length]! + } const readCounter = (frame: string, key: string): number => Number(new RegExp(`${key}=(-?\\d+)`, 'u').exec(frame)?.[1] ?? -1) @@ -350,17 +362,22 @@ if (!pythonHasAle()) { } } - const controls = [300, 600].flatMap((turns) => [ - control('steer-from-ascii', steerFromAscii, turns), - control('steer-from-ram', steerFromRam, turns), - ]) + const controls = [300, 600].flatMap((turns) => { + const blind = screenBlind() + return [ + control('steer-from-ascii', steerFromAscii, turns), + control('steer-from-ram', steerFromRam, turns), + control('screen-blind', blind, turns), + ] + }) // The classification is measured over the controls too, so the ordering // claim is not made against a profile fitted to the reference alone. const profile = measureProgressions(adapter.game, adapter.contract, { trajectories: [adapter.reference, ...controls.map((run) => run.inputs)], seed: adapter.seed, }) - assert.deepEqual(profile.attrition, ['life-lost']) + assert.deepEqual(profile.attrition, []) + assert.deepEqual(profile.achievement, all) for (const run of controls) { console.log( @@ -371,27 +388,159 @@ if (!pythonHasAle()) { ) } for (const turns of [300, 600]) { - const ascii = controls.find((run) => run.id === 'steer-from-ascii' && run.turns === turns)! - const ram = controls.find((run) => run.id === 'steer-from-ram' && run.turns === turns)! + const at = (id: string) => controls.find((run) => run.id === id && run.turns === turns)! + const ascii = at('steer-from-ascii') + const ram = at('steer-from-ram') + const blind = at('screen-blind') // The RAM control plays better by the game's own report. assert.ok(ram.gameScore > ascii.gameScore, `ram ${ram.gameScore} must beat ascii ${ascii.gameScore} at ${turns}`) - assert.ok(ram.lives > ascii.lives) - // Only the ASCII control dies, so only it earns the attrition milestone. - assert.ok(ascii.verified.includes('life-lost')) - assert.ok(!ram.verified.includes('life-lost')) - // The defect: the whole-contract score ranks the worse player first. - const wholeAscii = scoreMilestones(adapter.contract, ascii.verified) - const wholeRam = scoreMilestones(adapter.contract, ram.verified) - assert.ok(wholeAscii.verified > wholeRam.verified, - `the whole-contract score is expected to rank ascii first at ${turns}; if it no longer does, the reference moved`) - // The fix: under achievements alone it does not. - const achieveAscii = scoreAchievements(adapter.contract, profile, ascii.verified) - const achieveRam = scoreAchievements(adapter.contract, profile, ram.verified) - assert.ok(achieveRam.verified >= achieveAscii.verified, - `achievement score must not rank the control that died first at ${turns}: ` + - `ram ${formatMilestoneScore(achieveRam)}, ascii ${formatMilestoneScore(achieveAscii)}`) + assert.ok(ram.gameScore > blind.gameScore, `ram ${ram.gameScore} must beat screen-blind ${blind.gameScore} at ${turns}`) + assert.ok(ram.lives > ascii.lives && ram.lives > blind.lives) + + // The claim this contract exists to make: the achievement score ranks the + // better player STRICTLY first, at both budgets, over both weaker + // controls. A tie here is the defect the ladder replaced. + const achieve = (run: typeof ram) => scoreAchievements(adapter.contract, profile, run.verified) + const achieveRam = achieve(ram) + assert.ok(achieveRam.verified > achieve(ascii).verified, + `achievement score must rank the RAM control above the ASCII control at ${turns}: ` + + `ram ${formatMilestoneScore(achieveRam)}, ascii ${formatMilestoneScore(achieve(ascii))}`) + assert.ok(achieveRam.verified > achieve(blind).verified, + `achievement score must rank the RAM control above the screen-blind control at ${turns}: ` + + `ram ${formatMilestoneScore(achieveRam)}, blind ${formatMilestoneScore(achieve(blind))}`) + + // The contract carries no attrition milestone, so the whole-contract score + // is the achievement score and no consumer can pick the number that ranks + // a control that died above one that did not. + for (const run of [ascii, ram, blind]) { + assert.deepEqual(scoreMilestones(adapter.contract, run.verified), achieve(run)) + } + + // The ceiling is not the binding limit any more: the strongest control + // still has rungs above it, so a better program has somewhere to go. + assert.ok(achieveRam.verified < achieveRam.total, + `the ladder must leave headroom above the strongest control at ${turns}, ` + + `and it scored ${formatMilestoneScore(achieveRam)}`) } + // --- the machinery the packaged ladder no longer carries ----------------- + // + // One contract, derived from the SAME reference and the same replay, that + // states two hash milestones, a `requires` chain, and a milestone a lost + // life earns. It is not published: it exists so that removing those three + // things from the packaged contract does not quietly stop testing them, and + // so the numbers that justified removing them stay measured. + const demo = deriveContract(adapter.game, adapter.seed, adapter.reference, aleMarks(DEMO_RULES)) + assert.deepEqual(validateContract(demo), []) + const demoTiers = new Set(demo.milestones.map((m) => m.tier)) + assert.ok(demoTiers.has('engine-state') && demoTiers.has('screen-frame') && demoTiers.has('save-file'), + `expected engine-state, screen-frame and save-file tiers, got ${[...demoTiers].join(',')}`) + const demoKinds = new Set(demo.milestones.map((m) => m.check.kind)) + assert.ok(demoKinds.has('state-path') && demoKinds.has('frame-hash') && demoKinds.has('save-hash'), + `expected state-path, frame-hash and save-hash checks, got ${[...demoKinds].join(',')}`) + assert.deepEqual(contractLegibility(demo), { + legible: ['score-opened', 'life-lost'], + opaque: ['frame-at-first-score', 'save-at-first-score'], + reasons: { + 'frame-at-first-score': + 'its frame-hash check states its requirement as a hash, so a reader cannot see what it demands', + 'save-at-first-score': + 'its save-hash check states its requirement as a hash, so a reader cannot see what it demands', + }, + }) + + const demoReport = calibrateContract(adapter.game, demo, { + reference: adapter.reference, + vocabulary: adapter.inputs, + seed: adapter.seed, + turns: DEMO_TURNS, + }) + // No trivial policy reproduced either hash. That was the whole of the + // evidence #22 had for calling them unearnable, and it proves nothing: the + // baseline suite cannot serve a ball, let alone score. + assert.deepEqual(demoReport.opaqueReproduced, []) + // The substitution sweep is the prober that can. Both hashes fire after 32 + // inputs; the sweep replaces one input of that prefix at a time over + // NOOP/FIRE/RIGHT/LEFT and counts the logs that still satisfy the check. + // FIRE while the ball is already in flight is a state no-op, so a large + // family of logs reaches a bit-identical emulator state. + for (const row of demoReport.collisions) { + console.log( + ` ${row.milestone.padEnd(24)} fires after ${row.firesAfter}; ` + + `${row.collisions}/${row.substitutions} substitutions collide at ${row.freeTurns}/${row.probedTurns} turns; ` + + `joint=${row.jointCollision}; family >= ${row.family.toExponential(2)}`, + ) + } + assert.deepEqual(demoReport.collisions, DEMO_COLLISIONS) + + // `life-lost` is `lives == 4`: the reference earns it by DYING. The + // classification reads no field name — it reads the motion of the channel, + // which falls and never rises across every replayed trajectory, and a check + // that does not hold at the starting value of a channel that only falls can + // only be earned by letting the resource run down. + assert.deepEqual(demoReport.progression.attrition, ['life-lost']) + assert.deepEqual(demoReport.progression.unmeasured, ['frame-at-first-score', 'save-at-first-score']) + assert.match( + demoReport.progression.reasons['life-lost'] ?? '', + /reads engineState\.lives, which fell \d+ time\(s\) and never rose/u, + ) + assert.equal(demoReport.progression.motion.find((row) => row.channel === 'engineState.lives')?.rises, 0) + assert.deepEqual(demoReport.attritionSeparating, ['life-lost']) + assert.ok(!demoReport.separating.includes('life-lost')) + assert.deepEqual(demoReport.separating, ['score-opened']) + assert.deepEqual(demoReport.referenceAchievementScore, { verified: 3, total: 3 }) + + // Every milestone requires `score-opened`, and three of the four open at the + // same instant of the reference. Four milestone ids, two decision points. + assert.equal(demoReport.collapse.prerequisite, 'score-opened') + assert.equal(demoReport.collapse.gated, 4) + assert.equal(demoReport.collapse.total, 4) + assert.equal(demoReport.collapse.collapses, true) + assert.deepEqual(demoReport.collapse.earnedByBaseline, []) + assert.deepEqual(demoReport.collapse.simultaneous, ['score-opened', 'frame-at-first-score', 'save-at-first-score']) + assert.equal(demoReport.collapse.simultaneousAfter, 32) + + // Undeclared, this contract cannot ship, whatever its separation verdict. + assert.throws(() => assertOpaqueChecksDeclared(demoReport), /states 2 of 4 milestone\(s\) as a hash/u) + // Declaring them opaque is still not enough, because the sweep measured the + // collisions. The author must accept the weakness by id. + assert.throws( + () => assertOpaqueChecksDeclared(demoReport, { opaqueChecks: ['frame-at-first-score', 'save-at-first-score'] }), + /2 opaque milestone\(s\) are satisfied by input logs other than the reference/u, + ) + const opacity = { + opaqueChecks: ['frame-at-first-score', 'save-at-first-score'], + weakChecks: ['frame-at-first-score', 'save-at-first-score'], + } + assertOpaqueChecksDeclared(demoReport, opacity) + assert.throws( + () => assertContractSeparates(demoReport, opacity), + (error: unknown) => { + const message = (error as Error).message + assert.match(message, /states 1 of 4 milestone\(s\) that a resource running down earns/u) + assert.match(message, /attritionChecks: \['life-lost'\]/u) + assert.match(message, /collapses to one event: 4 of 4 milestone\(s\) require score-opened/u) + assert.match(message, /3 of 4 milestone\(s\) first pass at the same reference input \(after 32\)/u) + return true + }, + ) + const demoDeclaration = { ...opacity, attritionChecks: ['life-lost'], gatedBehind: 'score-opened' } + assertContractSeparates(demoReport, demoDeclaration) + const demoPackaged = PackagedContract.calibrate(adapter.game, demo, { + reference: adapter.reference, + vocabulary: adapter.inputs, + seed: adapter.seed, + turns: DEMO_TURNS, + declare: demoDeclaration, + }) + assert.equal(demoPackaged.hash, contractHash(demo)) + console.log( + `ale: demonstration contract — ${demo.milestones.length} milestones over ${demoTiers.size} evidence tiers, ` + + `${demoReport.opaque.length} opaque, ${demoReport.progression.attrition.length} attrition, ` + + `collapses=${demoReport.collapse.collapses} over ${demoReport.turns} turns; ` + + `the packaged ladder states none of it`, + ) + // Determinism: two replays in this worker and one in a freshly spawned // worker must agree on every frame hash, every save-state hash, and every // privileged variable. Cross-process is the load-bearing case, because a @@ -489,7 +638,7 @@ if (!pythonHasAle()) { // none of those inputs reached the emulator. // // The script reproduces the shape deterministically — 40 inputs of the - // reference, which open four milestones, then FIRE until the last life is + // reference, which open the first rung, then FIRE until the last life is // gone. A regression in these turn counts means ale-py, the ROM, or the // reference moved. const GAME_OVER_TURNS = 300 @@ -509,7 +658,7 @@ if (!pythonHasAle()) { assert.equal(atGameOver.record.stoppedBy, 'gameOver') assert.equal(atGameOver.record.gameOver, true) // The milestone verdict is unchanged: the 150 dropped decisions bought - // nothing, on either the score or the two pinned hashes. + // nothing on the score ladder. assert.deepEqual(atGameOver.record.verified, fullLength.record.verified) assert.deepEqual(atGameOver.record.score, fullLength.record.score) assert.equal(atGameOver.record.verified.length > 0, true) @@ -545,10 +694,11 @@ if (!pythonHasAle()) { const finalState = JSON.parse(first[first.length - 1]!)[2] console.log( `ale: ${adapter.identity.game} on ${adapter.inputs.length} actions — ` + - `derivation, ${adapter.contract.milestones.length}-milestone contract over ${tiers.size} evidence tiers, ` + - `known-good, false-claim, graded partial, cross-process determinism over ${first.length} snapshots ` + - `(screen, save state, and engine state), checkpoint round-trip, unknown-input no-op, teardown OK ` + - `(reference reaches score ${finalState.score} with ${finalState.lives} lives)`, + `derivation, ${adapter.contract.milestones.length}-rung score ladder ` + + `(${LADDER.join(', ')}), known-good, false-claim, graded partial, cross-process determinism over ` + + `${first.length} snapshots (screen, save state, and engine state), checkpoint round-trip, ` + + `unknown-input no-op, teardown OK (reference reaches score ${finalState.score} with ` + + `${finalState.lives} lives over ${adapter.reference.length} inputs)`, ) } finally { if (second) second.dispose() diff --git a/ale/reference-breakout.json b/ale/reference-breakout.json index 074b059..e052451 100644 --- a/ale/reference-breakout.json +++ b/ale/reference-breakout.json @@ -23,108 +23,185 @@ ], "milestones": [ { - "id": "score-opened", + "id": "score-1", "tier": "engine-state", "variable": "score", "op": ">=", "trigger": 1 }, { - "id": "frame-at-first-score", - "tier": "screen-frame", - "sample": "frame-hash", + "id": "score-2", + "tier": "engine-state", + "variable": "score", + "op": ">=", + "trigger": 2 + }, + { + "id": "score-4", + "tier": "engine-state", "variable": "score", "op": ">=", - "trigger": 1, - "requires": [ - "score-opened" - ] + "trigger": 4 }, { - "id": "save-at-first-score", - "tier": "save-file", - "sample": "save-hash", + "id": "score-8", + "tier": "engine-state", "variable": "score", "op": ">=", - "trigger": 1, - "requires": [ - "score-opened" - ] + "trigger": 8 }, { - "id": "score-tier-2", + "id": "score-18", "tier": "engine-state", "variable": "score", "op": ">=", - "trigger": 2, - "requires": [ - "score-opened" - ] + "trigger": 18 }, { - "id": "score-tier-4", + "id": "score-32", "tier": "engine-state", "variable": "score", "op": ">=", - "trigger": 4, - "requires": [ - "score-opened" - ] + "trigger": 32 }, { - "id": "life-lost", + "id": "score-64", "tier": "engine-state", - "variable": "lives", - "op": "<", - "trigger": 5, - "sampleOp": "==", - "requires": [ - "score-opened" - ] + "variable": "score", + "op": ">=", + "trigger": 64 } ], "inputs": [ - "FIRE", "LEFT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", - "NOOP", "RIGHT", "LEFT", "LEFT", "RIGHT", "NOOP", "LEFT", "LEFT", - "NOOP", "NOOP", "LEFT", "LEFT", "LEFT", "NOOP", "RIGHT", "NOOP", - "LEFT", "RIGHT", "RIGHT", "LEFT", "NOOP", "RIGHT", "RIGHT", "LEFT", - "LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", - "NOOP", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", - "FIRE", "FIRE", "RIGHT", "RIGHT", "NOOP", "RIGHT", "LEFT", "LEFT", - "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", - "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", - "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "LEFT", "NOOP", "RIGHT", - "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "RIGHT", - "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "RIGHT", - "FIRE", "RIGHT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "LEFT", - "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", + "FIRE", "LEFT", "LEFT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "RIGHT", "NOOP", + "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "NOOP", "LEFT", "RIGHT", + "RIGHT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "NOOP", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "LEFT", "NOOP", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "RIGHT", + "RIGHT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", + "NOOP", "RIGHT", "LEFT", "NOOP", "RIGHT", "LEFT", "NOOP", "RIGHT", + "LEFT", "NOOP", "RIGHT", "LEFT", "NOOP", "RIGHT", "LEFT", "RIGHT", + "NOOP", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "NOOP", "LEFT", "RIGHT", "LEFT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", + "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", "NOOP", "RIGHT", "LEFT", + "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "NOOP", "LEFT", + "RIGHT", "LEFT", "NOOP", "RIGHT", "LEFT", "NOOP", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "NOOP", "RIGHT", "LEFT", "NOOP", "RIGHT", + "LEFT", "RIGHT", "NOOP", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "NOOP", "RIGHT", + "LEFT", "NOOP", "RIGHT", "LEFT", "LEFT", "RIGHT", "NOOP", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "RIGHT", "RIGHT", "RIGHT", "NOOP", "RIGHT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "LEFT", "RIGHT", "LEFT", "NOOP", "RIGHT", "LEFT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "NOOP", "LEFT", "FIRE", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "NOOP", "LEFT", "NOOP", + "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "NOOP", "LEFT", "RIGHT", + "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "NOOP", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "LEFT", "LEFT", "NOOP", "LEFT", "LEFT", "NOOP", "RIGHT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", + "RIGHT", "LEFT", "NOOP", "RIGHT", "RIGHT", "RIGHT", "LEFT", "RIGHT", + "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", + "RIGHT", "LEFT", "RIGHT", "LEFT", "NOOP", "RIGHT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "RIGHT", "FIRE", "RIGHT", "LEFT", "LEFT", "LEFT", "RIGHT", + "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "RIGHT", "NOOP", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "NOOP", + "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", + "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "NOOP", "LEFT", + "RIGHT", "NOOP", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "NOOP", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "RIGHT", + "RIGHT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", + "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", + "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "NOOP", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "NOOP", "LEFT", "LEFT", + "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", + "NOOP", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "RIGHT", "NOOP", "LEFT", "RIGHT", "RIGHT", "RIGHT", "NOOP", + "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "LEFT", "RIGHT", "LEFT", "RIGHT", "LEFT", "NOOP", "RIGHT", + "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "NOOP", "LEFT", "FIRE", "LEFT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "NOOP", + "LEFT", "NOOP", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "NOOP", "RIGHT", + "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", + "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "RIGHT", "RIGHT", "RIGHT", "NOOP", "NOOP", "RIGHT", "LEFT", + "RIGHT", "NOOP", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "NOOP", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "LEFT", + "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "LEFT", "LEFT", "LEFT", "NOOP", "LEFT", "RIGHT", "LEFT", "LEFT", + "RIGHT", "LEFT", "FIRE", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "NOOP", "LEFT", "NOOP", + "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "RIGHT", "RIGHT", "LEFT", "NOOP", "RIGHT", "RIGHT", "RIGHT", + "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "LEFT", "RIGHT", "RIGHT", + "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", - "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "LEFT", "NOOP", "RIGHT", - "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "RIGHT", - "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "RIGHT", - "RIGHT", "FIRE", "LEFT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", - "LEFT", "LEFT", "RIGHT", "NOOP", "LEFT", "LEFT", "RIGHT", "NOOP", - "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "LEFT", "LEFT", "RIGHT", - "NOOP", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "RIGHT", - "NOOP", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "FIRE", "RIGHT", - "RIGHT", "LEFT", "NOOP", "RIGHT", "NOOP", "LEFT", "RIGHT", "RIGHT", - "LEFT", "LEFT", "RIGHT", "FIRE", "FIRE", "FIRE", "FIRE", "FIRE", - "LEFT", "LEFT", "LEFT", "RIGHT", "RIGHT", "LEFT", "NOOP", "RIGHT", - "RIGHT", "LEFT" + "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", + "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "LEFT", + "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "RIGHT", "NOOP", "LEFT", + "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", + "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT", + "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", "LEFT", "LEFT", "RIGHT", + "LEFT", "LEFT", "LEFT", "LEFT", "RIGHT", "LEFT", "NOOP", "RIGHT", + "LEFT", "LEFT", "RIGHT", "LEFT", "RIGHT", "RIGHT", "LEFT" ], "provenance": { - "source": "paddle-following policy recorded once against the emulator; the policy located the paddle and the ball on the rendered screen and steered towards the ball, and FIRE relaunched the ball after each serve", + "source": "a predictive paddle controller recorded once against the emulator; it read the RAM ball and paddle channels this reference declares, estimated the ball velocity from the previous decision, reflected the predicted path off the side walls, and steered the paddle towards the predicted crossing point, with FIRE to relaunch the ball after each serve", "package": "ale-py 0.12.1", "rom": "the Atari ROM set ships inside ale-py, so no download and no user-supplied ROM are required", "observed": { - "scoreOpensAfterInputs": 32, - "scoreReaches2AfterInputs": 73, - "scoreReaches4AfterInputs": 158, - "lifeLostAfterInputs": 199, - "finalScore": 5, - "finalLives": 4 + "scoreReaches1AfterInputs": 32, + "scoreReaches2AfterInputs": 71, + "scoreReaches4AfterInputs": 137, + "scoreReaches8AfterInputs": 259, + "scoreReaches18AfterInputs": 404, + "scoreReaches32AfterInputs": 636, + "scoreReaches64AfterInputs": 839, + "livesLostAfterInputs": [ + 226, + 370, + 556, + 706 + ], + "finalScore": 64, + "finalLives": 1 }, - "determinism": "two separate worker processes produced identical screen hashes, RAM channels, counters, and ALEState serializations at all 211 snapshots of this script", + "ladder": "the trigger points double — 1, 2, 4, 8, 16, 32, 64 — so no two rungs measure the same moment of play and the ladder spans the range a real player reaches. The fifth rung is stated as 18 rather than 16 because the score of this trajectory steps 14 to 18 when it clears a four-point row: a trigger inside that step would derive a check that disagrees with its own name.", + "determinism": "two separate worker processes produced identical screen hashes, RAM channels, counters, and ALEState serializations at all 840 snapshots of this script", "note": "Thresholds here are trigger points only. Every milestone value in the contract is sampled from the replayed trajectory by deriveContract, never copied by hand." } } diff --git a/ale/worker.py b/ale/worker.py index 58eb2b7..77c3401 100644 --- a/ale/worker.py +++ b/ale/worker.py @@ -22,9 +22,9 @@ restore {state} shutdown {} -Determinism, measured on Breakout with ale-py 0.12.1 over the 210-input +Determinism, measured on Breakout with ale-py 0.12.1 over the 839-input reference: the rendered screen, the RAM, the emulator counters, and the -serialized `ALEState` are byte-identical at all 211 snapshots of two separate +serialized `ALEState` are byte-identical at all 840 snapshots of two separate worker processes. Unlike the libretro save state behind `adapters/stable-retro`, the ALE state blob IS reproducible across processes, so this worker publishes `saveBlobHash` and a contract may pin it. diff --git a/calibration.ts b/calibration.ts index ccf3065..247176a 100644 --- a/calibration.ts +++ b/calibration.ts @@ -215,11 +215,13 @@ function firstPassTurn( * reference with one input replaced, over the prefix that ends where the check * first passed. Milestones that fire at the same turn share one sweep. * - * Measured on ALE Breakout, whose two hashes fire after 32 inputs over the - * vocabulary NOOP/FIRE/RIGHT/LEFT: 40 of the 96 single-input substitutions - * still reproduced both hashes, at 16 of the 32 turns, and all 16 applied at - * once reproduced them too. `FIRE` while the ball is already in flight is a - * state no-op, so those logs reach a bit-identical emulator state. + * Measured on a Breakout contract whose two hashes fire after 32 inputs over + * the vocabulary NOOP/FIRE/RIGHT/LEFT: 56 of the 96 single-input substitutions + * still reproduced both hashes, at 21 of the 32 turns. `FIRE` while the ball is + * already in flight is a state no-op, so those logs reach a bit-identical + * emulator state. That number is why the packaged Breakout contract states no + * hash at all: the check names a state half a trillion 32-input logs can stand + * in, and no independent control ever reached it. */ export function probeOpaqueCollisions( game: Game, @@ -549,11 +551,11 @@ export interface ContractCollapse extends ContractGate { * input, in contract order. * * This is the second way a contract counts one event more than once, and it - * is independent of `requires`. Measured on the packaged contracts: ALE - * Breakout opens `score-opened`, `frame-at-first-score`, and - * `save-at-first-score` at input 32, and stable-retro Airstriker opens three - * of its five at one input too. Three milestone ids for one moment of play is - * three points of a score that a run either has all of or none of. + * is independent of `requires`. Measured on stable-retro Airstriker: three of + * its five milestones open at one input. Three milestone ids for one moment of + * play is three points of a score that a run either has all of or none of. + * The packaged ALE Breakout contract used to open three at input 32 and now + * opens its seven rungs at seven distinct inputs. */ simultaneous: string[] /** The input index those milestones share, or -1 when no two share one. */ diff --git a/docs/adapters.md b/docs/adapters.md index a9c5f8a..75302d9 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -83,7 +83,7 @@ Its environments here observe a vector or an `ansi` string rather than a framebu That is a new dependency for a picture of a cart and a pole, so the adapter does without and says so. Each gate proves the identity rather than the plumbing: it undoes the whole-pixel upscale, hashes the recovered native buffer, and asserts it equals the `frameHash` a verifier recomputes. -Measured on CI hardware — ALE Breakout at 3x: a 480x630 PNG of 2,450 bytes with 9 distinct colours; stable-retro Airstriker at 2x: 640x448, 1,968 bytes, 9 colours; PyBoy Libbet at 3x: 480x432, 1,228 bytes, 1 colour, because Libbet under the blind generic preamble draws an all-white screen from about the fortieth reference input onward. +Measured on CI hardware — ALE Breakout at 3x: a 480x630 PNG of 2,488 bytes with 9 distinct colours; stable-retro Airstriker at 2x: 640x448, 1,968 bytes, 9 colours; PyBoy Libbet at 3x: 480x432, 1,228 bytes, 1 colour, because Libbet under the blind generic preamble draws an all-white screen from about the fortieth reference input onward. **The pixels are observation, never evidence.** They do not enter the input log, the contract, or the attestation, and an adapter that put privileged state into an image caption would be breaking the same boundary that already forbids putting it into the frame text. @@ -104,9 +104,9 @@ For stable-retro this was measured, not assumed, and the result shaped the adapt ALE was measured the same way and gives the opposite answer: -- Screens, RAM, emulator counters, and the serialized `ALEState` are byte-identical across separate worker processes at all 211 snapshots of the Breakout reference, on ale-py 0.12.1. +- Screens, RAM, emulator counters, and the serialized `ALEState` are byte-identical across separate worker processes at all 840 snapshots of the Breakout reference, on ale-py 0.12.1. - The state blob is 7,705 bytes and the same length at every snapshot. -- The adapter therefore publishes `saveBlobHash`, and the bundled contract pins a save-file milestone that a verifier can recompute. +- The adapter therefore publishes `saveBlobHash`, and a save-file milestone derived against it is one a verifier can recompute. The bundled Breakout contract states none, for the separate reason below: a hash names a state a great many logs reach. The PyBoy adapter reaches the same conclusion as ALE on its own substrate and does publish a save-state hash. No answer generalizes. A new replay adapter measures its own substrate before it declares a tier. @@ -160,7 +160,7 @@ Every milestone is a point, so the **Milestones** column is the denominator of a | Contract | Milestones | Legible | Opaque | |---|---|---|---| -| ALE Breakout | 6 | 4 | 2 — `frame-at-first-score`, `save-at-first-score` | +| ALE Breakout | 7 | 7 | 0 | | Libbet through `pyboy-generic` | 6 | 4 | 2 — `state-at-first-progression`, `frame-at-first-progression` | | PyBoy Tetris | 5 | 3 | 2 — `game-started`, `state-at-line-1` | | stable-retro Airstriker | 5 | 4 | 1 — `frame-at-first-score` | @@ -187,7 +187,8 @@ assertContractSeparates(report, { ``` `report.collisions` carries the measured strength of each opaque check: how many single-input substitutions of the reference still satisfy it, at how many turns, and a lower bound on the family of logs that do. -On Breakout that bound is 3.82 × 10⁸ distinct 32-input logs, so the two hashes name a state a great many trajectories reach. +Measured on a Breakout contract that pins the screen and save state at the first point scored: 56 of 96 substitutions still satisfy both hashes, and the bound is 5.22 × 10¹¹ distinct 32-input logs. +That number is why the packaged Breakout contract states no hash: no independent control in `ale.test.mts` has ever earned one, and a point only the reference can score is a point that grades nothing. ## Libretro consoles through stable-retro @@ -274,9 +275,11 @@ The gate in `ale.test.mts` decodes the produced PNG, checks the dimensions and t **Contracts.** A reference file declares the trigger for each milestone. `deriveContract` replays the reference and samples the value or hash that actually held at that instant. No threshold or hash is written by hand. +The bundled Breakout contract is seven rungs of one progression — `score >= 1, 2, 4, 8, 18, 32, 64` — so a run that broke 24 bricks and one that broke 64 do not score the same. **ROMs.** `ale-py` bundles the Atari ROM set, so this adapter needs no download and no secret. -The bundled reference plays Breakout and reaches a score of 5 over 210 inputs, which opens milestones on all three evidence tiers. +The bundled reference plays Breakout and reaches a score of 64 over 839 inputs, which opens all seven rungs of the packaged ladder. +It was recorded once from a predictive paddle controller reading the RAM channels the reference declares. Supply a reference playthrough through `options.reference` for any other ROM. ## Any Gymnasium environment diff --git a/schema.ts b/schema.ts index 0ebdb05..b50a296 100644 --- a/schema.ts +++ b/schema.ts @@ -236,9 +236,10 @@ export function formatMilestoneScore(score: MilestoneScore): string { * `attrition` — the run let a resource run down. Lives, health, shields, time * remaining. Such a milestone marks progress REACHED and never competence * shown, because the shortest path to it is to play badly. Measured on ALE - * Breakout: the packaged `life-lost` milestone is `lives == 4`, so a program - * that never dies cannot score it, and two hand-written controls that differ - * only in how well they steer rank in the wrong order because of it. + * Breakout: a `life-lost` milestone is `lives == 4`, so a program that never + * dies cannot score it, and two hand-written controls that differ only in how + * well they steer ranked in the wrong order because of it. The packaged + * Breakout contract states no such milestone for that reason. * * Recording an attrition milestone is legitimate — "the reference got far * enough to lose a life" is a real fact about a trajectory. Scoring it as @@ -340,8 +341,11 @@ export function scoreAchievements( * still demand exactly one event: if every other milestone requires the first, * a run that misses the first scores zero however well it played. That is one * bit of resolution wearing five milestones. Measured on the packaged - * contracts: ALE Breakout gates 6 of 6 milestones behind `score-opened`, and - * stable-retro Airstriker gates 5 of 5 behind its own `score-opened`. + * contracts: stable-retro Airstriker gates 5 of 5 milestones behind its + * `score-opened`. The packaged ALE Breakout contract gated 6 of 6 behind its + * own and now chains nothing: its checks read a counter that never falls, so a + * `requires` edge would restate the check and report a collapse that a + * seven-rung ladder does not have. * * This structure says nothing about how hard the prerequisite is. Whether a * trivial baseline earns it is a measurement, and `calibrateContract` reports