Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 |
Expand Down
56 changes: 36 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -448,33 +454,40 @@ 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.
`report.collapse` states how much of the contract hangs off one milestone, whether a trivial baseline reaches it, and how many milestones first pass at the same instant of the reference.

| 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 |

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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions adapters/ale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading