Author SHA1 Message Date
kartiandClaude Opus 5 7abbd24d34 docs: sentence-case the redacted node name
arena-environments / validate (3.11) (push) Successful in 1m9s
arena-environments / validate (3.12) (push) Successful in 1m21s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012AaUFYkUTsJn1fnJ89qbvW
2026-09-15 12:16:04 -07:00
kartiandClaude Opus 5 ef01bde068 docs: redact the reference node's address and hostname
arena-environments / validate (3.11) (push) Successful in 1m23s
arena-environments / validate (3.12) (push) Successful in 1m8s
The eval docs named spark-1 by fleet hostname and published its inference
endpoint (a tailnet address and port) plus the fact that it answers without
an API key. This repo is PUBLIC, so that was a standing disclosure of an
internal, unauthenticated service to anonymous readers.

Endpoints now come from $SPARK_BASE_URL, which the operator exports, and the
host is referred to as "the reference node" -- the convention the compute
repo's docs already use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012AaUFYkUTsJn1fnJ89qbvW
2026-09-15 12:15:37 -07:00
karti 3025c4cf85 Document the current public Arena boundary
arena-environments / validate (3.11) (push) Successful in 1m16s
arena-environments / validate (3.12) (push) Successful in 1m43s
2026-08-25 14:59:00 -07:00
karti 4836d3e644 Move maintained Tera evaluations to private source
arena-environments / validate (3.11) (push) Successful in 1m14s
arena-environments / validate (3.12) (push) Successful in 1m15s
2026-08-25 13:08:23 -07:00
karti 61939676a5 Pin Arena environments to stable Verifiers
arena-environments / validate (3.11) (push) Successful in 1m11s
arena-environments / validate (3.12) (push) Successful in 1m12s
2026-08-25 10:33:35 -07:00
kartiandClaude Opus 5 3305be4ff7 gates: give two of them a margin, and make the probe see components
The probe printed one blended number per policy, so a component pinned at 0.000
across every rung was invisible. Four gates hid there for a month. It now reports
floor / best-below-oracle / oracle / ceiling for 25 components across 8
environments, and a component flat across every rung is fatal.

bot-detection GATE_SLACK = 1 — fires 5/32 on the real rollouts, was 0/32. The
max(1, reference_caught - SLACK) guard is verified by construction, not by
sampling: without it the required count reaches 0 and an EMPTY accusation list
clears the gate. Observed reference_caught is 4-6, so no amount of sampling would
have found that hole.

schema-migration GATE_MARGIN = 0.15 — fires 4/32, was 0/32. The cost is disclosed
and bounded: the naive split clears it on 5.5% of 1,000 unseen seeds, fenced by
an assert at 10%. Margin 0.10 keeps the leak at zero and fires 0/32, i.e. stays
dead. A live gradient with a bounded leak beats a clean corpse.

redaction-pressure is NOT given a margin, and that is the result rather than a
failure. The only setting that fires at all leaves half the secrets standing and
pays a four-of-seven ruleset on five seeds in six — a margin that pays for
inaction is strictly worse than a dead gate. Recall maxes at 0.852 and no rollout
ever cleared both clauses in one episode. It is genuinely hard, not miscalibrated.

⚠️ The per-component check did not catch the defect it was built for. Reverting
schema-migration's margin to 0.0 — restoring the exact dead gate — printed ok and
exited 0, because the near-oracle rung scrapes the unmargined gate on ~2 seeds in
24 and that kept best<oracle non-zero. Every assertion bounded how much a margin
may PAY; none noticed if it stopped existing. migration() now carries the mirror
of bot-detection's guard, and reverting the margin fails with "the margin is dead
and the component carries no gradient between the crude answer and the exact one".

canary-trap's oracle-minus-one rung is documented as degenerate rather than
quietly relied on: it is identical to the oracle to four decimals, so it measures
specificity and gate at the ceiling, not mid-ladder as its comment claimed.

The CI lock policy asks git instead of the disk. It was checking the working
tree, where a lock file is a normal by-product of uv sync, so it passed in a clean
checkout and failed on every machine that had run an eval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 23:14:05 -07:00
kartiandClaude Opus 5 122a858b61 pin verifiers 0.3.1.dev59, and make every config say subprocess
The 600-second per-model-call ceiling that capped the thinking-on run at n=6 is
gone: DEFAULT_TIMEOUT is read=None in all nine venvs.

There is NO verifiers 0.4.0. The `version = "0.4.0"` in the vendored tree belongs
to a [[tool.uv.dependency-metadata]] block for nemo-gym, and reading it as
verifiers' own sends you hunting a release that does not exist and concluding the
bump is blocked. The vendored checkout is v0.3.0-59-g4bcb48e5, published to PyPI
as 0.3.1.dev59, and the installed wheel diffs clean against the whole vendored
v1 tree. Pinned as an ordinary index dependency: a path source is reproducible
only on this box and a git URL pins a commit no wheel matches.

⚠️ dev59 changes the default agent runtime from subprocess to `prime`, a REMOTE
sandbox. Without an explicit runtime every eval here exits "not authenticated
with prime" before a single rollout — having already created its output directory
and a zero-line traces.jsonl, so it reads as a failed eval rather than an
unauthenticated one. All nine configs now say subprocess. Nothing here needs a
sandbox: the reward is a pure function of the trace and the harness is null.

Output layout is flat now — <env>--<model>--<harness>--<hex8>/ with configs/,
traces.jsonl and a logs/eval.log that 0.3.0 never wrote. `-o` is the output_dir,
not the run dir; `--run.dir` pins the leaf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 23:14:05 -07:00
kartiandClaude Opus 5 8048a61a1b docs: run-20260821-1401 was sampled with thinking off, and four gates are dead
Two measurement findings, both larger than the lanes that found them.

FIRST_EVAL.md: every number in the first run was sampled with thinking OFF —
0 of 224 traces carry reasoning_content or a <think> block, and spark-1 serves
with enable_thinking False. No artefact of the run recorded that. Five of the
seven numbers still stand; two do not. grand-exchange 0.0055 measured nothing
about the environment — with thinking on it scores 0.1667 on six rollouts and
one of them scored a clean 1.000, so it is fully solvable. drop-table-inference
0.4174 is a mixture of two regimes (0.0894 short, 0.6465 long) and should be
reported split or not at all. The defect is the missing sampling footnote, not
the values.

The 600-second per-call ceiling that blocked an n=32 thinking-on run is the
INSTALLED wheel, not upstream: verifiers fixed it in a298bcfe on 2026-08-08 and
0.3.0 predates it. The remedy is a dependency bump, not a wait.

GATE_DIAGNOSIS.md: the `gate` component scored exactly 0.000, max 0.000, across
all 32 rollouts in four environments. One shape explains three of them — exact
equality. Nothing below the oracle earns a fraction: not the plausible strategy,
not six of redaction's seven rules, not the oracle bot list minus one account.
bot-detection and schema-migration are a threshold set at the ceiling;
redaction-pressure is that and genuinely hard; grand-exchange's zero was the
sampling artefact above. The repo already contains the fix and already uses it
twice — drop-table's GATE_MARGIN and grand-exchange's TARGET_SHARE are margins,
not equalities. Recommended, not yet measured.

Consequence worth stating plainly: 25-30% of the reward mass on three
environments carries identically zero gradient, so they train against a
0.70-0.75 objective while being scored out of 1.00. The mirror failure exists
too — fault-localisation's evidence and fault components are pinned at exactly
1.0 on all 32 rollouts, so half its headline 0.9531 is constant.

tools/gate_probe.py is the reproduction, moved out of the gitignored outputs/
so the finding survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:44:46 -07:00
kartiandClaude Opus 5 4147de0282 tera-crow-nav: the first spatial taskset, and the hold sweep that sized its budget
The bridge becomes a runnable taskset. The crux is that these are continuous
control environments and the agent emits text: the model issues a control
decision that is held for N steps, and the hold length is measured rather than
guessed. At one decision every 32 steps the scripted baseline returns -2.807 and
reaches the waypoint 0% of the time, against 5.484 and 100% at every frame.

Open loop — one decision for the whole flight — returns -3.764 against an
inaction floor of -4.161. A bird that sets a course and leaves does no better
than one that does nothing, which is why crow-nav carries the strongest rule-4
claim of the five spatial environments. The budget bites from both ends: 8 turns
reach the goal 44% of the time, 12 reach 88%, 14 reach 100%.

32 episodes against Qwen3.8-27B: mean 0.4603, no malformed replies in 412, every
episode replaying to an identical checksum, and 16 of 32 spending all 16 turns.
The gate fires 43.75% of the time and separates the four scenarios cleanly —
whatever is wrong with the four dead data-environment gates, it is not that
binary gates cannot discriminate.

The oracle comes from the worker's own op. Nothing recomputes in Python a number
that came out of TypeScript, least of all the reward denominator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:44:46 -07:00
kartiandClaude Opus 5 94ab513eda probe: gate grand-exchange-live, the first interactive environment
Eight environments gated, one warning left (tera-crow-nav). The probe calls
measure_ladder.ladder() rather than re-deriving the policies, and seeds from the
skip-aware baskets() helper — a contiguous range() grades a different set of
baskets than the model is served.

Beyond the four-rung ladder it asserts what house rule 4 actually claims: the
turn budget binds in every block of twenty-four, sweeps all 64 cells of the
reference family, scans the rendered turns for held-out ticks, and checks that
hostile replies parse to a hold and score zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:44:20 -07:00
kartiandClaude Opus 5 f953c03bd1 grand-exchange-live: protocol layer, taskset, config, widened reference gate
The stepped engine becomes a runnable taskset. protocol.py is both the
render/parse boundary and the security boundary: TurnView carries the live
Market, so view.market.items[i].prices is the whole held-out series and a
foresight policy reading it earned +9.4% on the oracle. The renderer emits only
observed ticks, the trace carries plain scalars, and a sentinel scan of every
rendered turn fails if either reopens. House rule 1 had no other enforcement.

Replies are a delta sheet and parsing never raises — NaN, Infinity, 1e309 and
20k-deep nesting all resolve to a hold, because the one-shot form has already
shown what a real model sends when it gives up.

LiveExchangeEnv.run drives the engine host-side with the harness left null. Two
things learned the hard way and worth keeping: a terminated Segment mid-window
holds the remaining looks instead of raising, and rewards are recorded INSIDE
the interaction, before close. Recording after close writes the traces correctly
and leaves every eval.log line reading reward=0.000 — the canonical verifiers
reference does it after the block; do not copy it there.

The reference-family gate is widened from four named rungs to the whole 8x8
(requote_band, idle_share) family: no member may out-earn the shipped reference
by more than 1.02x, and any that does must still score >= 0.90. That is the check
that caught the first draft's fake 0.152 gap, now shipped rather than remembered.

Also guards `clean` on the reference being profitable, here and in the one-shot
form. Against a reference that lost money the bar is negative, so doing nothing
cleared it and inaction collected a quarter of the reward. Unreachable while
viable_market admits only profitable references; one relaxed filter from
reachable, and exactly the floor house rule 3 forbids. Probe values unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:44:20 -07:00
kartiandClaude Opus 5 e4cc2bf1c3 docs: the scope, the plan, the post-review execution plan, and the eval runbook
MULTI_TURN.md scopes the multi-turn gap and records the finding that made it
cheap: multi-turn needs no container and no real harness. vf.Env.run programs the
control flow host-side and the simulator runs in-process as the user, with the
harness left null.

PLAN.md carries the settled decisions. EXECUTION.md is the post-adversarial-review
plan — every position in it survived a reviewer, and where a design spec was
overturned the corrected position is what is written down.

Two corrections are marked in place rather than silently rewritten, because both
were believed and acted on:

- The provenance hole is src/arena/index.ts, not offices/sites.ts. Mutating
  offices/sites.ts produces a bit-identical episode; index.ts re-exports the
  scripted baselines that ARE the reward denominator, and a halved office-nav
  baseline moves return 4.744 -> 4.098 while every tera hash gate prints ok.
- office-jobs-v1 is not phantom. It was absent at 13:00 and present at 15:50,
  because another session fast-forward-merged it into the shared tera tree
  mid-session. The counts are twelve, five and twenty.

docs/FIRST_EVAL.md is the runbook for the first evaluation Arena ever completed:
seven environments, 224 episodes, 223 scored, against brain-qwen38-dspark. It
warns that the mean is sum(score x weight) — an unweighted mean over components
is a different number, and it is how six of seven means were first misreported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:55:43 -07:00
kartiandClaude Opus 5 162d67a83c tera-spatial: an NDJSON replay bridge to the TypeScript spatial environments
Tera's four (now five) spatial environments already implement a Gym-style
contract with checksums, train/dev splits and a replay gate — in TypeScript,
unreachable from `uv run eval`. This bridges them rather than porting them: a
resident NDJSON worker over stdio drives the vendored TS closure, and no reward
arithmetic exists in Python anywhere in the package, including the denominator.

Correctness is replay, not assertion: every scenario x seed must round-trip
through Python and replay to an identical FNV-1a-64 checksum. 32/32 do, and 52
tamper attempts are rejected, 24 of them re-sealed with Tera's own checksum so
it is replay() catching divergence rather than the seal.

Lives in arena rather than tera because tera has no Python toolchain. That
overturns the earlier plan's claim that this workstream shares no files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:55:12 -07:00
kartiandClaude Opus 5 00d21025ff grand-exchange-live: the stepped market engine, and the turn budget binds
Arena's first interactive environment, as its own wheel beside grand-exchange so
the one-shot scores stay comparable rather than conflated. One basket of orders
becomes a loop: quote, see fills and the next tick, re-quote, with inventory,
open orders and cash carrying between turns.

The engine and its measurement only. No taskset, no config, no probe row — the
spec's constants were derived from a throwaway engine and did not reconcile
(impatient computed to 0.612 against a reported 0.623), so every constant here
is chosen from a ladder re-measured in the code that ships.

House rule 4 asks whether the turn budget binds. It does: exhaustive 0.631 vs
oracle 1.000, gap 0.369, stdev 0.054 over five blocks, worst block 0.316, and
0.354-0.405 at four further seed bases. That is 18x the proposed margin and it
clears the 0.25 quantum of the binary gate outright, so the comparison is not a
cliff. The saturation worry does not fire: per-basket clipping is not clipping
the mean, so exhaustive averages 0.849 on profit_ratio rather than 1.000.

The first draft's gap was FAKE — a clean-looking 0.152 produced by an under-tuned
denominator whose every-look sibling earned 22% more gp while scoring below it.
It was caught only by sweeping the reference's own family. Nothing that scores
below the oracle may earn more than it; that check belongs in every interactive
environment that follows this one.

freeze is derived from market.seed inside build_market so viability and scoring
see one value, roc is on peak rather than average capital employed, and the
reference is cached per seed so scoring never re-simulates the played episode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:55:12 -07:00
kartiandClaude Opus 5 8fe348ca82 probe: discover environments from manifests, not a hardcoded list
The environment list was written out four times — probe.py's shim tuple and its
results dict, tests/test_probe.py's ENVIRONMENTS, and ci.yml's wheel loop — so
adding an environment meant four edits and five workstreams collided on three
files. Discovery is now a tomllib scan of environments/*/pyproject.toml plus a
@probes(name) registry, with an optional [tool.arena] tasksets key for one
package that ships several (tera_spatial declares four).

An environment discovered without a registered probe WARNS rather than exits 1,
because interactive environments land their engine before their probe. That is
temporary; it becomes a hard failure with the last registration.

The warning is not a licence to stop being gated. test_probe.py pins the
ungated set to an explicit PENDING_PROBES allowlist that only ever shrinks —
without it, removing a @probes decorator moved the name from the table into a
warning line that already carries five, and every remaining assertion stayed
vacuously true while the environment silently stopped being gated.

Also untracks three environments/*/uv.lock files, which had been failing the
lock-policy step on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:54:56 -07:00
kartiandClaude Opus 5 16f4ee21ae Extend the merged CI and probe test to all seven environments
arena-environments / validate (3.11) (push) Failing after 5s
arena-environments / validate (3.12) (push) Failing after 5s
The redaction-pressure hardening landed a test asserting probe.py gates every
environment and a CI step building every wheel, and both carried a hardcoded
list of four. Merging three more environments in left the assertion counting
four "ok" lines against seven, and left the new wheels unbuilt by CI.

Both lists now name all seven. The count assertion is kept rather than relaxed:
a new environment missing from the list fails the build instead of quietly
narrowing what the test covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 03:58:25 -07:00
karti 7428e91415 Merge the redaction-pressure hardening with the three OSRS environments 2026-08-19 03:57:10 -07:00
kartiandClaude Opus 5 0defaed9c0 Three OSRS environments, and the first real model scores
grand-exchange, bot-detection and drop-table-inference. All three produce
unbounded or irreducible-error scores, unlike the first four, so all three
normalise against a REFERENCE STRATEGY rather than an absolute -- which is what
makes 1.000 reachable rather than aspirational.

drop-table-inference is the clearest case. Scoring KL against the true drop
table would put the ceiling out of reach, because sampling error is irreducible:
ground truth scores 0.900 against the Bayesian posterior's 1.000, and that
ordering is correct. The best estimate available from 1,200 kills is not the
true table, and an environment that demands it is measuring luck.

grand-exchange needed the market to carry structure a model can actually infer,
or profit is noise and no oracle exists. Measured over 120 baskets: the
reference earns 38,821 gp and is profitable in 120/120, random orders lose 561,
and trading only the random-walk items -- which look like the widest-swinging
lines on the board -- loses 16,860.

bot-detection generates naive bots, cloaked bots that jitter on purpose, and
efficient humans who look bot-like on every naive statistic. Timing features are
drawn BEFORE the generator decides who is a bot, so every latency rule sits at
chance. Reference discriminator F1 1.000, random 0.430.

Each was built by one agent then attacked by two independent reviewers on
exploitability and soundness. They earned their keep: drop-table-inference's
first reward let a memorised constant score 0.901 without opening the kill log,
and its reference was 19% worse than a flat number. probe.py now carries three
named fences asserting those attacks stay dead.

First evaluation also lands. Nemotron 3.5 Lightning, thinking off, 32 rollouts
per environment: 0.570 down to 0.059, every gate at or near zero, nothing
solved and nothing unsolvable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 03:56:50 -07:00
karti d691acf131 Harden redaction-pressure reward and provenance
arena-environments / validate (3.11) (push) Successful in 1m8s
arena-environments / validate (3.12) (push) Successful in 31s
2026-08-19 01:26:21 -07:00
kartiandClaude Opus 5 5a99eb86a5 Three more environments, and probe.py to gate all four
canary-trap, fault-localisation and schema-migration, in the three domains the
Environments Hub has nothing in: contamination, operations, data engineering.
None is a port. Each is graded on a slice the model never read.

canary-trap runs a probe set against a model that memorised the corpus AND one
that learned the same facts from a reworded copy with every identifier
regenerated. The obvious probe -- plant a GUID, ask for it back -- is sound and
caps at 0.525 because it cannot see the paraphrase. It scored higher than that
at first: facts with no distinct rewording had the paraphrase restating the
corpus verbatim, so GUID probes caught it by accident. Every fact now carries a
"core" token present in both wordings and in no public one, asserted at import,
which is also what makes the ceiling reachable.

fault-localisation puts the fault upstream and the noise downstream, the way
incidents actually present. The broken service logs one line and goes quiet
while its dependents log a dozen timeouts, so ranking by error volume answers
the victim -- 0/64 incidents have the loudest service as the root, and probe.py
asserts it rather than trusting the generator to stay that way.

schema-migration executes the model's SQL against forty held-out rows carrying
thousands separators, negatives, a unit with a slash in it and a value with no
unit. Row preservation started as a reward beside fidelity and paid 0.15 for
adding two empty columns and touching nothing; it is a multiplier now, so
losing rows still zeroes a perfect migration and keeping them earns nothing on
its own.

probe.py is house rule 3 as an exit code: 0.000 for inaction and 1.000 for an
oracle, or it returns 1. It loads the leaf modules through a namespace shim
rather than the packages, so it runs without verifiers installed -- a self-check
that needs the training stack is a self-check nobody runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:03:05 -07:00
77 changed files with 12534 additions and 3667 deletions
+59
View File
@@ -0,0 +1,59 @@
name: arena-environments
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
version: "0.12.5"
enable-cache: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
# Asks git, not the disk. `find` was checking the working tree, where a lock file is a
# normal by-product of `uv sync` — so the step passed in CI's clean checkout and failed
# on every developer machine that had ever run an eval. The policy is about what is
# TRACKED: an environment is a library and must not pin its consumers' resolution.
- name: Enforce library lock policy
run: |
tracked="$(git ls-files 'environments/*/uv.lock')"
test -z "$tracked" || { echo "tracked lock files: $tracked"; exit 1; }
# An environment is `environments/*/pyproject.toml` and nothing else — the same
# denominator probe.py's discover() uses. A half-created scaffold directory with no
# manifest is invisible to both, so the two can never disagree about what exists.
- name: Refuse an empty environment scan
run: |
set -euo pipefail
count=$(find environments -mindepth 2 -maxdepth 2 -name pyproject.toml | wc -l)
echo "discovered $count environment manifests"
test "$count" -gt 0
- name: Run the floor and ceiling probe for every discovered environment
run: uv run --with regex python probe.py
- name: Run root probe integration test
run: uv run --with regex python -m unittest discover -s tests -v
- name: Install Redaction Pressure v0.2
run: uv sync --project environments/redaction_pressure --python ${{ matrix.python }}
- name: Run Redaction Pressure tests
run: >-
uv run --project environments/redaction_pressure
python -m unittest discover -s environments/redaction_pressure/tests -v
- name: Build all environment distributions
run: |
set -euo pipefail
for manifest in environments/*/pyproject.toml; do
environment=$(basename "$(dirname "$manifest")")
uv build "environments/$environment" --out-dir "dist/$environment"
done
- name: Verify documented Redaction Pressure entrypoint
run: uv run --project environments/redaction_pressure eval --help >/dev/null
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc
.venv/
outputs/
environments/*/uv.lock
+281
View File
@@ -0,0 +1,281 @@
# Execution plan — Arena / Lumbridge
Generated 2026-08-21 by a 13-agent design + adversarial-verification workflow.
Companion to `PLAN.md` (decisions) and `MULTI_TURN.md` (scope).
⚠️ Every position here is the POST-REVIEW position. Where an adversarial reviewer
overturned a design spec, the spec's original claim was discarded. Do not resurrect
a claim from an earlier document that this one corrects.
# Arena / Lumbridge — one execution plan
Synthesis of six adversarially-reviewed workstream specs, 2026-08-21. Every position below is the **post-review** position. Where a reviewer overturned a spec, the spec's original claim is marked and discarded.
Verified independently for this document (amd-server, 2026-08-21): `arena/probe.py` is 772 lines with a literal 7-name shim tuple at :89-93 and a literal `results` dict at :752-760; `tests/test_probe.py` has a 7-name `ENVIRONMENTS` tuple at :13-21 and `timeout=30` at :31; `.gitea/workflows/ci.yml` is 42 lines, its probe step is still named "Run four-environment floor and ceiling probe" (:26) for a seven-environment probe, its wheel loop (:36-40) is a hardcoded bash `for` over seven names, and its lock-policy check (:24) is failing on main because **three** `uv.lock` files are tracked (`bot_detection`, `drop_table_inference`, `grand_exchange`); `outputs/` contains exactly one file, a `config.toml` with `push = true` and `base_url = https://api.pinference.ai/api/v1`**no `traces.jsonl` exists anywhere on this box**; `~/.prime/config.json` has an empty `api_key`; `tera/src/arena/index.ts:74-79` `ARENA_MANIFESTS` holds four.
---
## 1. Verdicts applied — what each spec becomes
### A — `grand-exchange-live` · feasible, but **not in the order written**
Stands: the multi-turn mechanism (`Env.run` + `interaction.turn()`, null harness, no container), the delta-sheet action grammar, the malformed-reply policy, `roc` on **peak** capital employed (average-employed is a measured free-points bug), the vendored-generator equality assertion, no turn-count reward term.
Corrected, blocking:
1. **The measured ladder does not reconcile with the spec's own reward.** At ref 72,758 gp and `TARGET_SHARE = 0.85`, target = 61,844; impatient at 50,470 gp gives `profit_ratio` 0.816, `discipline` ≤ 0.816, `clean` false → max 0.612, not the reported 0.623. Churn and exhaustive *do* reconcile, so this is a specific defect. **Nothing in §1/§4 is locked until it is re-measured with the shipped engine.**
2. **`viable_market` retargeted at `live_reference` is circular as written.** Viability needs `freeze`; `freeze` is drawn in `Taskset.load()` after viability returns. Fix: derive `freeze` deterministically from `market.seed` (e.g. `random.Random(seed ^ FREEZE_SALT)`), inside `build_market`, so viability and scoring see the same value. This is also the fix for the unstated RNG source — without it, `scan()` recomputes a reference the model never played against.
3. **§6's held-out claim is inverted.** Live is *weaker* than one-shot: the model sees ~50 of 60 graded ticks. Rule 1 still passes; the README must say "held out: the tail after the last observation, ≥4 ticks", not "stronger by construction". Add a probe assertion on the *size* of the held-out slice, not only the frontier.
4. **The probe battery blows CI's own harness.** `probe.py` runs in 3.4 s today under `timeout=30`. §11 adds 9 policies × blocks × 240 baskets + two sweeps + a 240-seed generator check + a hostile battery replayed per turn. Raising the timeout is B1's job (below), but A must budget the cost, not append a name to a list.
5. **Taskset load cost is unbounded.** `num_tasks` defaults 48, `VIABILITY_TRIES = 64`; a live `viable_market` runs a full 8-turn stepped reference per candidate → ~3,072 simulations before the first token, on every eval and every `--dry-run`. Cache the reference per seed, or cap tries for live.
6. **`import` vs `copy` contradiction.** §3 says "import `grand_exchange.book.parse_orders`", §9 says vendor. It is a copy. Say copy.
7. **`[env.agent] max_turns = 8` is an off-by-one risk, not free insurance.** Verify how the framework counts, or set 9.
8. **`trace.info["episode"]` must be plain JSON types.** A dataclass or numpy scalar in `audit()` fails the trace write for the whole rollout.
9. **Effort is L/XL, not L** — it is A plus most of B.
**Revised A build order (this is the change that matters):** `market.py`/`book.py` copies + equality assertion → **`live.py` + unit tests → re-measure the full ladder in the shipped engine** → only then lock `horizon`, `FREEZE_RANGE`, `TARGET_SHARE`, `TURNS``strategy.py` sweep → `ledger.py``protocol.py``taskset.py` → probe.
### B — probe policies + de-hardcoding · **verdict: not feasible as written. Split it.**
The reviewer is right and the split is the fix.
**B1 — de-hardcode environment discovery. Sound, S/M, ships immediately, behaviour-preserving.** `tomllib` over `environments/*/pyproject.toml` replaces the shim tuple, the `main()` results dict (via a `@probes(name)` registry decorator), `ENVIRONMENTS` in `tests/test_probe.py`, and the bash wheel loop. **The CI environment list was never a matrix** — it is a `for` loop in one step (verified) — so no generator job, no `fromJSON` matrix, no act_runner risk. Take B1 now and the repo's known collision point disappears for one afternoon's work.
Two mandatory amendments to B1:
- **Do not make "discovered but unregistered" a hard exit 1 while A is in flight.** The moment A commits a scaffold `pyproject.toml`, `probe.py` exits 1 and CI goes red for every session on the repo. Make it a warning until an env registers a probe, and flip it to exit 1 in the same commit as A's `exchange_live()`.
- **Make the two guards use the same denominator.** `ci.yml`'s "empty scan" counts directories; `discover()` counts `*/pyproject.toml`. A scaffold dir without a pyproject makes them disagree. Count `*/pyproject.toml` in both.
- Raise `tests/test_probe.py` `timeout=30 → 180`, keep the lock check *before* any `uv sync`, and fix the "four-environment" step name.
**B2 — the `Interactive` policy machinery. Redesign required; do not build it before A's engine exists.** The structural claim ("oracle and exhaustive share `answer`, so rule 4 follows") is **false when the reward saturates**, and Arena's rewards all saturate: `profit_ratio` and `efficiency` clip at 1.0 and `clean` is a boolean band. If exhaustive also clears the band it scores 1.000 and the gate reads "budget is free". Further:
- `BIND_MARGIN = 0.02` is staked on realised-P&L differences over 24 seeds, against a block-to-block variance `probe.py:349-352` already documents at 0.48 vs 0.18. The five-block ladder is **required for all three rule-4 comparisons**, not an optional mitigation — multiply the cost estimate by ~5.
- `gate` is binary at weight 0.25, so the exhaustive/oracle gap is quantized in 0.25 steps. A 0.02 margin against a 0.25 quantum is a cliff, not a calibration.
- `play()` never terminates on an answer; "impatient answers turn 1" is an unstated precondition on A's `step()`.
- Seeds must come from A's `baskets()` skip-aware helper, not a contiguous `range`.
- B2's §1.8 dictates A's private module layout, class names, weight vector and turn mechanism. **Delete that.** A owns its contract; B2 adapts to it. This is the collision the workstream exists to remove.
**Net: B1 is unblocked and independent of A. B2 depends on A's *measured* engine, not A's first draft.** PLAN.md's "B depends on A" edge survives for B2 and dies for B1.
### D — Tera bridge · feasible, but **it is no longer parallel with A/B, and PLAN.md must be amended**
Stands and is the strongest evidence in the six specs: 23-file / 283 KB / zero-bare-specifier TS closure; `node` type-strips raw `.ts`; **32/32 scenario × seed traces round-trip through Python `json` and replay to identical checksums**; one long-lived NDJSON worker (84 ms start, 22 µs RTT, 217 µs/step, 87 MB RSS) beats HTTP and beats per-rollout processes; the measured hold-length sweep.
Corrected, blocking:
1. **Role name vs config key — the spec will not load.** `agents.pilot` requires a config field named `pilot`, but all four TOMLs write `[env.agent.harness]`, and `EnvConfig` is `extra="forbid"`. **Name the role `agent`**, matching `SingleAgentEnvConfig`. Same trap A must resolve; settle it once for both (see Decision 1).
2. **`CONFIG = ...` classvar does not exist in verifiers v1.** Config is resolved by `concrete_type()` through `__orig_bases__`. Use the wordle form: `class CrowNavTaskset(TeraSpatialTaskset, vf.Taskset[TeraSpatialTask, CrowNavConfig])`. As written, every per-env `max_turns`/`max_hold` silently does nothing.
3. **The provenance claim is real but was mis-located — CORRECTED 2026-08-21.** `check-arena-source-hashes.mjs` pins 19 of 23 vendored files. The claim that `offices/sites.ts` is the material hole is **FALSE and was refuted twice independently**: mutating it (elevation 4→4000, heading 0→90, width 54→5400) produces a **bit-identical** episode, so it does not reach the simulation. ⚠️ **The real hole is `src/arena/index.ts` alone**, and it is worse than the original claim: rewriting it so `officeNavScriptedBaseline` re-exports a halved policy moves office-nav from 68 steps / return 4.744 to 136 steps / 4.098 — **while `npm run arena:source-hashes` prints "ok" for all four environments and exits 0**, `envHash` and `simulatorHash` unchanged. That file is the reward DENOMINATOR. Fix: pin `src/arena/index.ts` in `tera/scripts/check-arena-source-hashes.mjs` (a one-line `tera`-repo change) and regenerate the four pairs in `sourceHashes.ts`.
4. **`oracle.py` reimplementing the four TS baselines in Python contradicts the bridge's own rule** ("never recompute a number that came out of TypeScript") — and it does so for the reward *denominator*. Add an `oracle` op to `worker.mjs`; the four baselines are already exported from `src/arena/index.ts`.
5. **`drive-101` measures nothing, and the 0.9 ceiling band makes it worse than "no rule-4 claim".** Return is monotone in throttle, 12/12 goals at every value; `0.9 × 4.339 = 3.905` and throttle 0.2 already returns 3.723 → quality 0.953. Any positive constant throttle scores ~0.951.00. Same collapse on office-nav: any goal-reaching run clears the band, so `control` (0.50) and `gate` (0.20) are constants and only `efficiency` carries signal.
6. **office-nav's rule-4 argument is backwards under its own formula.** `efficiency = oracle_turns / max(turns, oracle_turns)` only ever *penalises* extra turns, and past `ceil(1.5 × 1.8) = 3` turns `clean` also drops. The formula punishes exactly the "precision buys turns" behaviour cited as evidence.
7. **`gate` is a separate additive term for goal-attainment that is already inside `ts_return` as the success bonus** — double-counted, additively. That is the failure `probe.py:15-17` records verbatim. Make it a multiplier on the sum.
8. **Missing:** no `@vf.stop`; no handling of a mid-loop `terminated` Segment (the loop as written hits `RuntimeError('the exchange is over')`); no `Taskset.load()` / prompt-vs-bare-`turn()` decision; an empty `{"plan":[]}` costs zero steps while a malformed reply costs 10, inverting the spec's own principle; no guard for `oracle_return <= 0`; `max_hold = 400` exceeds `MAX_STEPS = 360`.
9. **Scope.** PLAN.md:138 and the parallelization map say D lives in `tera` and shares no files with A/B/C. This spec puts everything in `arena` and edits `probe.py`, `tests/test_probe.py`, `ci.yml`, `README.md`, `configs/`, plus two `lumbridge-v4` files. That is a real decision (the packaging argument is correct — `tera` has no Python toolchain), but **PLAN.md must be amended to record it**, because it converts D from "parallel with everything" into "collides with A, B and W".
**Revised D:** ship the bridge + replay gate + `tera_crow_nav` first. `drive-101` ships only as a labelled bridge-correctness environment or not at all (Decision 3). `office-nav` and `california-flight` follow. D's probe integration waits for B2.
### P — `app.lumbridgecorp.com` · feasible; **Phase 1 as written cannot ship**
Stands and is high-value: the endpoint inventory (29 routes, not 31), the 23 dead `api.ts` methods, one-build/two-hosts with app.* as the **only** auth origin, `bind 10.0.0.2` + the empty-200 verification, the `isAppHost` branch in `sendShell`, `WORLD_LAUNCHER_URL` convergence for `/go/:slug`, `World1` moved not deleted.
Corrected, blocking:
1. **"Ingest the runs already on amd-server" is impossible — there are none.** One `config.toml`, zero episodes, and that config points at `api.pinference.ai` with `PRIME_API_KEY`, which is empty. **Phase 1 must contain "execute one eval to completion against the reference node" as an explicit prerequisite**, or Arena's dashboard ships empty.
2. **The leaderboard is operator-gameable and mis-specified.** (a) The server must recompute every aggregate in SQL from the `arena_episodes` it just inserted, never trust the pusher's numbers. (b) Ranking on a mean over *scored* episodes is Arena's own "score what survived" trap. Store `reward_mean_scored` **and** `reward_mean_attempted` (errors as 0), rank on attempted, refuse to rank below a scored-fraction floor.
3. **`arena_episodes` flattens a list.** `Episode.traces` is a list; `reward`/`rewards`/`stop_condition`/`last_reply` are per-**Trace**. Primary key must be `(run_id, episode_id, trace_id)` with an `agent_name` column — otherwise the schema is wrong for `grand-exchange-live` (simulator as second role) and for `redaction-pressure` (genuine two-agent).
4. **The trace viewer publishes the held-out slice.** `GET /api/arena/runs/:id/episodes/:eid` at `user` scope returns full transcripts, which contain held-out task data and, for oracle runs, the answers. One leaked invite publishes the held-out corpus. **Transcripts are admin-only or redacted.** This settles the spec's open question on read scope.
5. **The compute-scene-vocabulary blocker is fabricated.** `computeScene` is a free-form slug regex, not `z.enum(SCENE_NAMES)`; `stock`, `nemotron` and `qwen38` all pass today. Delete that precondition.
6. **`portal_host_samples` and `portal_container_samples` have no node identity** — no `node_id`, no host column, no PK. Per-node charts need `ALTER TABLE … ADD COLUMN IF NOT EXISTS node_id text` plus an index first.
7. **`scored = ok AND rewards non-empty` is wrong.** `write_episode` dumps with `exclude_none=True`, so a `None` reward is dropped from the file entirely — "not measured" and "key absent" are indistinguishable on the wire. Require at least one non-null `Reward`, and carry the run's declared reward-name set from the config.
8. **`Reward.value` is a `@property`** and never appears in dumped JSON. Compute `score × weight` in the ingester.
9. ⚠️ **CORRECTED 2026-08-25: there is NO verifiers 0.4.0.** The `version = "0.4.0"` at `~/vendor/prime-intellect/verifiers/pyproject.toml:136` belongs to a `[[tool.uv.dependency-metadata]]` block for **nemo-gym**, not to verifiers. Stable **`verifiers==0.3.1`** was published 2026-08-24 and is what all eight current public manifests pin; the Tera spatial package moved to private source. The layout warning itself is correct: 0.3.1 produces one flat `<env>--<model>--<harness>--<hex8>` directory with no nested UUID, and `RunConfig.id` is a `PrivateAttr` absent from `configs/eval.json`. Derive the run id from the directory basename.
10. **Name the private console `/nodes`**, not `/compute``/compute` is already a public marketing section with its own nav.
11. Put `isAppHost` in `packages/shared` — two independent string checks that must agree, with a silent asymmetric failure mode (dashboard rendered under a 404 status).
12. `sendShell(url, reply)` takes no request; the signature and both call sites change. `spa-fallback.test.ts` needs a case. `@radix-ui/react-select` is **not** installed.
**Revised P phasing.** The honest first ship is **P1: Caddy host + host-aware routing + `renderAppShell` + AppShell + Overview + Worlds + Org + Inbox + the `api.ts` trisection.** All of that runs on endpoints that exist today. **Arena (schema, 6 routes, ingester, 4 pages) is P2, a full workstream of its own**, and it is gated on a real eval having run.
### W — website visual vocabulary · feasible; **the data pipeline must be redirected, not built**
Stands: the diagnosis (the site draws what it hasn't measured and narrates what it has), `MeasureTable` / `Bar` / `FlowStrip` / `SpecSheet`, inline SVG with `currentColor` and **no charting dependency** (the `gold-500 → gold-700` theme flip alone settles this), the grouped `StateList`, and the four live factual errors.
Corrected:
1. **`arena-runs.json` has no machine source and must not be created.** `probe.py` does not run models; there is no `traces.jsonl`. **The site already has the right contract: `apps/web/src/data/eval-results.ts` + `eval-results.json` + `eval-results.parse.test.ts`, with a `{model, host, quant, serving, checkpoint}` target tuple and an explicit "null means not measured" rule.** The Nemotron run belongs there. Creating a second incompatible file guarantees the drift the pipeline exists to prevent — and putting it in `eval-results.json` also fixes **a fifth live error the audit missed**: `arena.tsx:519` says "No model scores are published yet, so nothing appears on Evals" while `README.md:88-105` publishes seven.
2. **The crude-maximiser labels have no machine source either.** `probe.py`'s functions return only `{inaction, crude, plausible, oracle}`; "redact everything" / "ban everyone" / "buy and sell at market" exist only as markdown italics. Emitting that column means **changing `probe.py`'s return shape — which is B's file.** Fold it into B1's registry work or accept it as a hand-typed literal, but do not schedule it as a third-party edit to `probe.py`.
3. **`check:arena` must not execute `probe.py`.** The `sync-scenes.mjs` precedent parses committed YAML from an anonymous public clone; running a 772-line probe under `uv run --with regex` is a slow, flaky gate that contradicts the hermetic-build rule. Emit JSON in `arena`'s CI, commit it, and have `check:arena` compare bytes.
4. **`Diagram` fails DESIGN.md §4.2's own gate** — no variants, no CVA variants object. It is a one-off `<figure>` per page, exactly like `scaling-plot.tsx` / `spread-field.tsx` already are. Do not put it in `components/ui/`.
5. **Do not put a nine-verb FlowStrip above the fold on `/forge`.** Every StateList item on that page is `done: false`, including "No repository, no tag, no release." Drawing an unbuilt pipeline is the exact failure the audit indicts.
6. **The landing-page markup claim is wrong**, and it invalidates the landing plan's premise: `landing.tsx` contains **two** `<svg>` elements, not six. `ForgeChain` is an `<ol>`, `ArenaBand` is nested `<div>`s and 32 `<span>`s, `StreamSignal` and `SimulateViewport` are divs. The real coupling is ~33 class references into `machine-home.css` (35 KB) carrying reveal/scan animations. **Treat the landing as its own step with its own CSS audit.**
7. **`Bar` has one existing hand-rolled call site, not two** — `BudgetMeter` is an N-segment `flexGrow` track with a composed `aria-label`, and `BarProps` cannot express it. `Bar` earns its place through `MeasureTable`'s cells, not through BudgetMeter.
8. ~~**The scenario count is 16, not 20**~~ ⚠️ **WRONG as of 2026-08-21 15:50 — it is 20**, five registries × four, and `office-jobs-v1` is not phantom. Another session merged it into the shared `tera` tree mid-session. Counts are twelve / five / twenty; the site's original copy was right and W0's correction was reverted.
9. Four more table-shaped components exist that the "seven call sites" audit missed: `components/compute/model-table.tsx`, `components/publish/concurrency-table.tsx`, `ledger.tsx`, `rate-strip.tsx`. Design `MeasureTable`'s API against them.
10. No tests are proposed. New generated data + a check script are exactly what gets a parse test in this repo.
**Revised W:** `/arena` first (it is also a truth fix), `/bench` second, `/stream` third — but `/arena`'s number pipeline routes through `eval-results.json`, and the `office-jobs-v1` deletion happens **once**, in one place, coordinated with D and P.
### C-compute — rent to train, Spark to serve · feasible; **its headline correction is false**
Stands and is the most immediately actionable: the policy itself; the measured the reference node tenancy (121 GB total / 84 GB used / 36 GB free, SGLang + Chatterbox + vox + ASR co-resident, Chatterbox's ~8.4 GB/hr leak having killed brain before); `prime login` as step zero because `api_key` is empty (verified); **Arena → prime-rl needs no adapter**`EnvConfig` is `SerializeAsAny[vf.EnvConfig]` and the taskset tree is the same shape, prefixed; the seat-name-is-a-TOML-key trap; Hosted Training being unavailable while E is parked (`rl.py:1522-1524` — bare v1 ids do not resolve through the Hub); the sm_121 lock contradiction in Forge; `TRAINERS = ("axolotl","trl")`; **no price is hardcoded anywhere and none should be invented.**
Corrected, blocking:
1. **`--model brain` does NOT 404.** SGLang does not validate the model field on the chat route; it returns 200 with a real completion. Two of the four "stale facts" and two proposed file edits rest on a false claim. **The real hazard is the opposite and worse: it silently succeeds.** Re-running the `README.md:88-89` baseline today produces **Qwen3.8-27B** numbers under a command that reads `--model brain`, and they will be tabulated beside Nemotron numbers as comparable. The correction the arena docs need is: **pin the served id `brain-qwen38-dspark` and the scene name in every reproduction command**, not "fix a 404".
2. **`uv run eval grand-exchange-live --harness.id null` is not valid.** `EvalConfig` has no top-level `harness`; `--harness.` is admitted only to render a did-you-mean. Canonical: `--env.agent.harness.id null`, or the TOML block `configs/grand_exchange.toml` already uses.
3. **`uv sync --package prime-rl --package grand-exchange-live` cannot work.** prime-rl's `[tool.uv.workspace] members` is a closed list of its own vendored envs. Use `uv pip install` of a built wheel or git URL into the env-server venv, or add a path source.
4. **`forge.tsx:114` is not stale.** The remote is private, `git tag` is empty, `status="In development"`, no `source=` prop. Nobody can clone it. The page's own README warns "This section has been wrong three times" — this edit would make it four.
5. **Workstream-letter collision:** PLAN.md already defines a "workstream C" (more interactive environments). Rename this one — **"Compute Policy"**, no letter.
6. The wordle wall-clock anchors are fabricated (the README says 2 GPUs, the toml declares 8 and 200 steps; neither states a duration). Delete them and produce our own.
7. `torchao` and `nixl-cu12` are pinned to **x86_64-only** wheel URLs while `torchao` is a hard runtime dep — the "aarch64 wheels for every heavy dependency" claim does not hold, which sharpens the aarch64-variance risk.
8. `prime disks` attach with `--disks`, not `--attach-disk`.
9. **Two things nobody has planned:** how the DCP checkpoint gets *off* the pod before `prime pods terminate` destroys the disk (needs a `--disks` volume at create time), and the DCP → HF shards → NVFP4 → Compute model-id conversion, which nothing on disk automates end-to-end (prime-rl has `convert_to_hf` but no CLI entrypoint).
10. **The link nobody drew:** rental hours are driven by whether the turn budget binds. `group_size × batch × steps` of *multi-turn* rollouts against a rented inference fleet is real money. If B2's exhaustive policy scores at the oracle, the run burns GPU hours measuring nothing. **Rule 4 is a compute-policy constraint, not only a design one.**
---
## 2. Parallelization map and file collisions
### What can start today, concurrently
| Lane | Work | Repo | Blocked by |
|---|---|---|---|
| **1** | **B1** — discovery de-hardcode + timeout + step name + `uv.lock` cleanup | `arena` | nothing |
| **2** | **A step 2**`live.py` engine + unit tests + **re-measure the ladder** | `arena` | nothing (own worktree) |
| **3** | **D step 1** — worker, vendored TS, hashes, `test_replay.py` | `arena` (new dir only) | nothing |
| **4** | **Compute Policy step 0**`prime login`, catalogue + prices, doc the corrected the reference node facts | none / `arena/docs` | nothing |
| **5** | **P1** — Caddy host, host-aware routing, AppShell, Overview/Worlds/Org/Inbox | `lumbridge-v4` | nothing |
| **6** | **W step 0** — the five factual corrections + `eval-results.json` decision | `lumbridge-v4` | nothing |
Six lanes, and only lanes 1/2/3 share a repo. **Use a worktree per lane** — this repo took divergent concurrent pushes on 2026-08-19. Merge, never force.
### True dependency edges
```
B1 ──────────────────────────────────────────┐
├──► A step 7 (probe row) ──► A ships
A steps 1-2 (engine) ──► A re-measure ──► A steps 3-6 ─┘
└──► B2 (policy machinery, calibrated on real numbers)
├──► C1 drop-table
├──► C2 bot-detection
├──► C3 fault-localisation
├──► C4 redaction (2-agent)
└──► D step 5 (tera probe rows)
D step 1 (replay gate) ──► D crow-nav ──► D flight/office
A ships ──► one real eval run ──► traces.jsonl exists ──► P2 (Arena dashboard)
└──► W /arena numbers (via eval-results.json)
prime login ──► rented-pod runbook ──► first RL run ──► Forge prime-rl renderer
```
Two edges that were wrong in the source specs and are now correct:
- **B1 does not depend on A** (PLAN.md's "B depends on A" applies only to B2).
- **D is no longer file-independent of A/B/W** — it lives in `arena` and edits `probe.py`, `ci.yml`, `test_probe.py`, `README.md`, plus `arena.tsx` and `seo.ts`.
### Merge-conflict map — the specific paths
**Tier 1 — will conflict, must be sequenced or de-hardcoded first:**
| Path | Claimed by | Resolution |
|---|---|---|
| `arena/probe.py` | A (§11), B1 (registry), B2 (machinery), D (§7), C1-C4, W (needs a return-shape change for crude labels) | **B1 lands first.** After B1, adding an env is one appended function at EOF — a mergeable case. Nothing else touches this file until B1 is merged. |
| `arena/tests/test_probe.py` | A, B1, D | B1 removes it from every future workstream's diff. Never edited again. |
| `arena/.gitea/workflows/ci.yml` | A, B1, D (needs Node + replay test + a one-dir-four-wheels change) | B1 lands the glob; D then adds *steps*, not list entries. **D's one-directory-four-tasksets layout actively breaks B1's one-dir-one-wheel discovery** — D must add a `[tool.arena] tasksets = [...]` key or equivalent, and B1 should be told to read it. |
| `arena/configs/grand_exchange_live.toml` | **A, B(§1.7.2), Compute Policy** — three specs create the same file | **A owns it.** Delete it from the other two file lists. |
| `arena/environments/grand_exchange_live/*/taskset.py` and `session.py` | **A and B both specify the module layout and class signatures** | **A owns the contract.** B2 adapts. |
| `arena/README.md` | A (rows), D (rows + house rule 4), Compute Policy (baseline qualifier), W (source of truth for the site tables) | Sequence: A → D → Compute Policy. W reads it, never writes it. |
| `arena/PLAN.md` + `MULTI_TURN.md` | B, D (scope amendment), Compute Policy | One session, one pass, after the D-scope decision lands. |
**Tier 2 — cross-repo, same file:**
| Path | Claimed by | Resolution |
|---|---|---|
| `lumbridge-v4/apps/web/src/pages/v4/arena.tsx` | **D** (delete `office-jobs-v1` at :203 and :511) and **W** (full rebuild of the page) | **W owns the file.** D contributes the *fact* (four manifests, 16 scenarios), not the edit. |
| `lumbridge-v4/services/api/src/seo.ts` | **D** (:271 twelve→eleven, five→four) and **P** (`renderAppShell`, drop `/app` from `APP_ROUTE_PATTERNS` at :698) | Different regions; sequence P after D's one-line copy fix, or vice versa. Low risk but both must run `seo.test.ts` (`ROUTER_PATHS` at :36) and `spa-fallback.test.ts`. |
| `lumbridge-v4/apps/web/src/pages/v4/forge.tsx` | **W** (FlowStrip, StateList) and **Compute Policy** (:59-62 delegation copy) | W owns it. Compute Policy contributes one sentence, **only after a rented run has actually happened**. The `:114` edit is cancelled (see verdict). |
| `lumbridge-v4/apps/web/src/data/eval-results.json` | **W** (Nemotron run) and **P2** (the dashboard's run store) | The public file is a curated summary; the dashboard is the archive. Decide the direction (Decision 6) before either writes. |
**Tier 3 — three tracked `uv.lock` files are failing CI on main right now.** `git rm --cached` them in B1's commit, before anyone blames a new failure on a new environment.
---
## 3. Sequencing
### Phase 1 — genuinely shippable, ~2 weeks, six parallel lanes
1. **B1** (arena): tomllib discovery, `@probes` registry, timeout 180, `uv.lock` untracking, step rename. *Gate: `probe.py` still prints seven `ok` rows; CI green with zero list edits.*
2. **A steps 1-2 + re-measurement** (arena, worktree): frozen generator copy + equality assertion; `LiveBook`/`Leg`/`Ledger` with unit tests; **then re-run the full policy ladder and publish the corrected numbers.** *Gate: the ladder reconciles with the reward formula; `horizon`, `FREEZE_RANGE`, `TARGET_SHARE`, `TURNS` are chosen from that measurement, not from the spec.*
3. **D step 1** (arena, new dir): worker + vendored TS + `hashes.py` + `sync_tera.py` + `test_replay.py`. *Gate: 32/32 replay exact, tampering rejected, hash check green — and a decision on the 4 unpinned files.*
4. **Compute Policy step 0**: `prime login`; record real `availability` output and prices; write `arena/docs/COMPUTE_POLICY.md` with the corrected the reference node facts (`brain-qwen38-dspark`, silent-wrong-model hazard, 36 GB headroom).
5. **P1** (lumbridge-v4): Caddy `app.` block with `bind 10.0.0.2` and the non-empty-body check; host-aware `App.tsx`; `renderAppShell` + `sendShell` signature change + `seo.test.ts` + `spa-fallback.test.ts`; AppShell; Overview / Worlds / Org / Inbox; delete the 23 dead `api.ts` methods.
6. **W step 0** (lumbridge-v4): the five factual corrections — `arena.tsx:330` "Nine tasks", `landing.tsx` card 02, the 4-row probe table → 7 rows, `office-jobs-v1` deleted, `arena.tsx:519` "no model scores published". Ship the Nemotron run into `eval-results.json`.
**Phase 1 ends with**: a green de-hardcoded CI, a measured live engine, a proven TS replay bridge, a live private console on real endpoints, a truthful public site, and a Prime Intellect account that can rent.
### Phase 2
7. **A steps 3-9**`grand-exchange-live` ships with its probe row (needs B1 merged).
8. **One real eval run** against the reference node, pinned to `brain-qwen38-dspark`, with the empty-rewards count checked. *This is a hard prerequisite for P2 and for any site number.*
9. **D crow-nav** end to end (role named `agent`, `vf.Taskset[...]` generic form, `oracle` op in the worker, `gate` as a multiplier).
10. **W /arena rebuild** + `/bench` on the new primitives.
11. **First rented run**: pod create with an attached disk, `install.sh`, wheel install, baseline eval, `uv run rl`, re-eval, before/after table.
### Phase 3
12. **B2** — policy machinery, calibrated against A's measured exhaustive/oracle gap, with block-wise ladders replacing the single 0.02 margin.
13. **P2** — Arena schema (per-trace PK), server-side aggregates, admin-only transcripts, ingester for both output layouts, the four pages.
14. **C1-C4** — the four remaining interactive environments, fanning out from B2.
15. **D flight + office-nav**; drive-101 per Decision 3.
16. **Forge**: `prime-rl` renderer (LoRA RL first), x86_64 train lock, retitled aarch64 lock, DECISIONS entry, checkpoint→HF→NVFP4→Compute-model-id stage.
17. **W /stream**, landing (with its own `machine-home.css` audit), compute/simulate/about.
18. **E** — Hub push, when un-parked.
---
## 4. Decisions Karti still owes, ranked by what they block
| # | Decision | Blocks | Recommendation |
|---|---|---|---|
| **1** | **The agent seat name.** `agent` (matching `SingleAgentEnvConfig`) or a custom name? It becomes the TOML key in `configs/*.toml` **and** in every prime-rl `rl.toml`, on both A and D. Renaming later parses fine and silently has no seat. | A, D, every future env, every training config | **`agent`, everywhere.** One word, decided once, written into each env's README. |
| **2** | **Does D live in `arena` or `tera`?** The spec's packaging argument is strong (`tera` has no Python toolchain) but it overturns PLAN.md:138 and makes D collide with A/B/W. | D's entire file layout, the CI wheel loop, PLAN.md | **`arena`**, and amend PLAN.md's parallelization map in the same commit. |
| **3** | **Does `tera-drive-101` ship?** Measured: monotone in throttle, optimal policy is one constant string, and the 0.9 band makes any positive throttle score ~0.95+. Also decides whether the site says four spatial environments or three. | D's scope, `arena.tsx`/`seo.ts` copy, the Hub listing | **Ship it labelled a bridge-correctness environment with no rule-4 and no rule-3 claim, or defer.** Do not list it as a fifth RL environment. |
| **4** | **A's freeze mechanic: flat per-look, or proportional to items re-quoted?** Flat is measured and works; per-item is the better mechanism, makes the look count genuinely vary, and forces a much larger surgical reference. | A's engine, and therefore B2's calibration | **Ship flat.** Record per-item as the identified strengthening. Do not build an unmeasured mechanism on the critical path. |
| **5** | **Arena transcript visibility.** Full transcripts contain the held-out slice and, for oracle runs, the answers. | P2's route scoping, and house rule 1's integrity | **Admin-only.** This is not an org-politics question; it is a contamination question, and that settles it. |
| **6** | **Where does model-run data live?** Public `eval-results.json` (already typed, tested, rendered on `/evals`) vs P2's `arena_runs`/`arena_episodes` tables. | W's whole data pipeline, P2's schema | **Both, with a direction:** the dashboard is the archive, `eval-results.json` is a curated public summary emitted from it. Never two independent hand-maintained files. |
| **7** | **Is `nemotron` coming back as the production brain, or is `qwen38` the default?** `README.md`'s baseline, D23/D24's Forge preflight, and the site's Arena numbers all assume Nemotron; the box serves Qwen3.8-27B. | Every reproduction of the baseline; what "inference in production on the Spark" names | Needs an answer before anyone re-runs a benchmark. |
| **8** | **First Forge/prime-rl integration: LoRA RL or full-FT?** LoRA reuses merge + attest unchanged and matches `alphabet-sort`; but LoRA rejects `--gpu-type` and falls back to filesystem weight broadcast. Full-FT needs a new node kind and the tensor-delta-map gate never fires. | Forge's roadmap, and what its manifest is for | **LoRA first** — it keeps Forge's headline claim intact. |
| **9** | **Is app.\* the only authenticated origin?** Yes means the apex loses `/login`, `/signup`, `/app`, `/go`, and all three existing sessions are signed out on cutover day. | P1's scope, apex route deletions | **Yes**, and state the sign-out as an accepted cost for three accounts. |
| **10** | **W's audience.** Arena/Bench/Stream (brokenness × argument) vs Arena/Landing/Bench (the Prime Intellect Hub reader). | W's priority order only | Answer when P1 lands; it does not block anything upstream. |
---
## 5. Risk register, highest first
| # | Risk | Retired by |
|---|---|---|
| **1** | **The whole reward design of `grand-exchange-live` rests on numbers from a throwaway engine that no longer exists, and the ladder does not reconcile with the reward formula.** `horizon=60`, `FREEZE_RANGE=(1,4)`, `TARGET_SHARE=0.85` and `TURNS=8` are all locked against arithmetic that is off by 0.011 in the one place it can be checked. | Building `live.py` FIRST and re-measuring every row before any constant is frozen. This is Phase 1, lane 2, and it is the single most important task in the programme. |
| **2** | **Every reward in Arena saturates, so "exhaustive < oracle" may be structurally unreachable.** `profit_ratio` and `efficiency` clip at 1.0; `clean` is a boolean band. A 0.02 margin against a 0.25 quantum is a cliff. If the gate can't open, rule 4 is unenforceable and every interactive environment is unfalsifiable. | A's re-measurement producing a real exhaustive/oracle gap; B2 then calibrated against it with block-wise ladders. If no gap exists, the reward shape must change before C1-C4 are built on it. |
| **3** | **No `traces.jsonl` exists anywhere.** Everything downstream — the dashboard, the site's numbers, the leaderboard, the before/after training table — is waiting on one eval that has never completed. The one run that was attempted pointed at Prime's inference API with an empty key. | One completed eval against `brain-qwen38-dspark` with the empty-rewards count checked. Cheap, and it unblocks P2, W and Compute Policy simultaneously. |
| **4** | **`eval` exits 0 when every rollout errors, and multi-turn has far more ways to error than one-shot.** `turn()` on a closed interaction raises; `turn(msg)` on turn 1 of a prompted task raises. Combined with risk 3, a "successful" run can be entirely empty. | A CI/runbook step that asserts non-empty `rewards` on ≥N episodes, in `arena`, in D's replay gate, and in P2's ingester. Never trust the exit code. |
| **5** | **Silent wrong-model comparison.** `--model brain` returns 200 from a box serving Qwen3.8-27B. Re-running the Nemotron baseline today produces different-model numbers under an identical command, and they will be tabulated as comparable. | Pinning `brain-qwen38-dspark` (and the scene) in every command in `README.md`, `PLAN.md`, `MULTI_TURN.md` and `COMPUTE_POLICY.md`. Trivial; do it in Phase 1. |
| **6** | **`probe.py`, `tests/test_probe.py` and `ci.yml` are edited by five of six workstreams, in a repo that already took divergent concurrent pushes.** | B1, first, before anything else touches `arena`. |
| **7** | **The reward denominator is editable with every `tera` gate green.** ⚠️ CORRECTED 2026-08-21: not `offices/sites.ts` (mutating it produces a bit-identical episode — refuted twice). The hole is **`src/arena/index.ts`**, which re-exports the scripted baselines that ARE the denominator. Demonstrated: a halved office-nav baseline changes return 4.744 → 4.098 while `arena:source-hashes` prints ok and exits 0. | Pinning `src/arena/index.ts` in `tera/scripts/check-arena-source-hashes.mjs` and regenerating `sourceHashes.ts`. One line, in a repo this session did not own. |
| **8** | **The reference node has 36 GB of 121 GB free with four resident model services, one of which leaks ~8.4 GB/hour and has killed the brain before.** "Production inference on the Spark" is a tenancy claim held together by the governor on :8011. | A mandatory headroom check before any new resident model, and Compute's watchdog verified live. |
| **9** | **`PRIME_API_KEY` is empty**, blocking not just the parked Hub push but every rental command, every price, and the entire training half of the compute policy. | `prime login`. One command. |
| **10** | **Two of D's four environments cannot discriminate under the 0.9 ceiling band** — drive-101 gives ~0.95+ to any constant throttle, office-nav collapses to `efficiency` alone. | Re-deriving the band per environment against the measured return distribution, or shipping fewer environments. |
| **11** | **The held-out corpus leaks through P2's trace viewer at `user` scope.** One invite code. | Decision 5 — admin-only. |
| **12** | **Effort is understated across the board.** A is L/XL; B2 is not B's to size (its §1.8 is L/XL and belongs to A); D is L/XL; P's "Phase 1" is XL; W's item 1 alone is L/XL across two repos. Read as written, this programme looks like six L's and is closer to four XLs. | The phasing in §3, which cuts P1 out of P, B1 out of B, and D-step-1 out of D. |
| **13** | **Rented GPU hours are burned by a non-binding turn budget.** `group_size × batch × steps` of multi-turn rollouts against a rented inference fleet is real money, and nothing in the compute doc connects to rule 4. | Making "exhaustive < oracle by a measured margin" a precondition of dispatching any RL run — write it into `COMPUTE_POLICY.md`. |
| **14** | **The checkpoint has no exit path.** `prime pods terminate` destroys ephemeral disk; the DCP → HF → NVFP4 → Compute-model-id conversion is unautomated. | `--disks` at pod-create time, plus a `forge quantize/publish` stage. Discover this before the first successful training run, not after. |
| **15** | **GH200 may not appear in `prime availability` at all** — the CLI names only H100_80GB, H200_141GB, B200_180GB, RTX_PRO_6000. "GH200-class" may mean H200_141GB in practice. And `perf.py` has no GH200 branch, so MFU may silently fall back to A100's 312 TFLOPS. | `prime login`, then one `availability gpu-types`, then one `nvidia-smi --query-gpu=name` on a real pod. |
---
## 6. Build this first
**`environments/grand_exchange_live/grand_exchange_live/live.py` — the stepped engine and its unit tests — and, concurrently, workstream B1's discovery de-hardcoding.**
Everything else in the programme is downstream of two facts we do not yet have: whether the live market's reward ladder actually reconciles (risk 1, which invalidates A's horizon, freeze, TARGET_SHARE and turn budget if it does not, and takes B2, C1-C4 and half the compute argument with it), and whether three files can stop being a five-way merge conflict before five workstreams reach for them. B1 is an afternoon and removes the repo's only structural collision permanently; `live.py` is the smallest artifact that can turn six specs' worth of `/tmp` measurements into something the shipped code agrees with.
+209
View File
@@ -0,0 +1,209 @@
# Scope: the multi-turn gap
Status: scoping document, 2026-08-21. Nothing here is built yet.
Every environment in this repository sets `[env.agent.harness] id = "null"` — one
completion, no tools, no runtime. That is the right choice for a pure-trace data
environment and it is also the whole of what Arena currently measures. Nothing here
scores an agent *acting*: choosing what to look at next, paying for a turn, or
revising after seeing a consequence.
This document scopes closing that gap. The headline finding is that it is three
separable tiers, not one, and the cheapest tier needs no new infrastructure at all.
## What upstream actually requires
Read from stable `verifiers==0.3.1` and the matching checkout under
`~/vendor/prime-intellect/verifiers`, not
from blog posts.
**Multi-turn does not imply a container.** `vf.Env.run(task, agents)` programs the
control flow host-side. `agents.<role>.interaction(task)` opens a rollout and holds
it open; `await interaction.turn(observation)` sends one user turn, runs one harness
segment, and returns a `Segment` with `.last_reply` and `.terminated`. The simulator
runs in-process, as the user. The harness can stay `null` throughout.
The reference implementation is `verifiers/v1/tasksets/textarena/taskset.py` — 90
lines, drives a real game engine, scores off the engine directly. Read it before
writing anything.
**An Env ships inside the taskset's own wheel.** `EnvConfig.id` empty means "the
taskset's own env, else `SingleAgentEnv`". Export the `Env` subclass from the
package `__init__.py`, exactly as `environments/wordle` exports `TextArenaEnv`, and
`[env.taskset] id = "..."` picks it up with no extra config.
**Available in the installed version** — confirmed, not assumed:
- harnesses: `null`, `bash`, `browser_use`, `claude_code`, `codex`, `hermes_agent`,
`kimi_code`, `mini_swe_agent`, `openclaw`, `pi`, `pool`, `rlm`, `terminus_2`
- runtimes: `subprocess` (debug only — rollouts share side effects), `docker`,
`modal`, `prime`
- prebuilt envs: `single_agent`, `best_of_n`, `agentic_judge`,
`shared_agentic_judge`, `user_sim`
**The interception server** sits between harness and provider. It speaks each
harness's native dialect (Anthropic Messages for Claude Code, OpenAI Responses for
Codex), builds the trace live, forces sampling params the harness will not expose,
and can rewrite tool results and web-search results to block reward hacks. It is
mandatory for tiers 2 and 3 and free for tier 1.
## The three tiers
| | Mechanism | Harness | Runtime | New infra |
|---|---|---|---|---|
| **1. Interactive** | `vf.Env.run` + `interaction.turn`, simulator in-process | `null` | none | **none** |
| **2. Tool-using** | Taskset exports a `Toolset`, served as MCP | `bash` | `subprocess` / `docker` | image per env |
| **3. Agentic** | Full coding agent in a sandbox | `claude_code`, `codex`, `openclaw`, `pi` | `docker` / `prime` | image + interception + budget |
Tier 1 is the one to build first, and it is where our existing simulators already
sit. Tiers 2 and 3 are real work with real cost and should follow evidence from
tier 1, not precede it.
## What we already have that plugs in
**Six of the seven environments here own a simulator that is already steppable.**
They are one-shot only because the taskset hands the model the whole observation up
front. Each has an obvious interactive form:
| environment | one-shot today | interactive form | tier |
|---|---|---|---|
| `grand-exchange` | read 30 ticks of history, place one basket of orders | quote, see fills and the next tick, re-quote, over a turn budget — inventory and unfilled orders carry | 1 |
| `drop-table-inference` | read a fixed kill log, estimate the table | **buy** observations: each turn spends budget on more kills, then answers — measures when to stop looking | 1 |
| `fault-localisation` | read the whole incident log, name the cause | the log is *not* given: query it by service, time window and level under a query budget | 1 → 2 |
| `redaction-pressure` | write rules, scored by a scanner | two agents: a redactor and an extractor scored on what it recovers — the counterweight becomes an opponent | 1 (multi-agent) |
| `schema-migration` | emit a migration, executed against held-out rows | a real SQLite in a container the agent runs SQL against, with rollback | 3 |
| `bot-detection` | classify from six labelled examples | request labels one at a time under a budget (active learning) | 1 |
| `canary-trap` | write probes | no natural interactive form — leave one-shot | — |
**Tera's four spatial environments are already multi-turn — in the wrong language.**
`~/repos/gitea/tera/src/arena/` implements `tera.arena/v1`: Gym-style
`reset(seed, scenario)` / `step(action)` / `snapshot` / `restore` / `trace` /
`replay`, FNV-1a-64 checksums, SHA-256 source pins, train/dev splits, counterweighted
rewards, and a CI gate that proves inaction finishes below zero and a scripted
baseline finishes positive. That is a stricter contract than most of what is on the
Hub — and none of it is reachable from `uv run eval`, because it is TypeScript with
its own contract rather than a verifiers taskset.
The work there is a **bridge**, not a simulator: a Python `vf.Env` whose `run()`
drives the TS package over a subprocess or a small local HTTP loop, turning
`step()` observations into `interaction.turn()` messages and the existing component
rewards into `trace.record_reward` calls. The replay checksum makes the bridge
verifiable — a bridged episode must replay identically in the TS package or the
bridge is wrong. That is a rare property and worth building the bridge around.
⚠️ **RESOLVED 2026-08-21, 15:50 — `office-jobs-v1` EXISTS.** It was absent at 13:00 and
present at 15:50, because another session fast-forward-merged `feat/tera-ultracode-integration`
into `~/repos/gitea/tera` mid-session (reflog `8738367`). It is committed (`e378a03`,
`120eac8`, dated 2026-08-19), is in `ARENA_MANIFESTS`, is documented in `tera/ARENA.md`, and
passes `npm run arena:source-hashes`. **The true counts are twelve environments, five spatial,
twenty scenarios** — measured, not inferred. The site's original copy was right; the
correction to eleven/four/sixteen was right when made and wrong an hour later, and has been
reverted. Lesson: `tera` is a shared tree with live concurrent sessions — re-check its HEAD
before acting on a claim about what it contains.
~~⚠️ **`office-jobs-v1` does not exist.** `lumbridgecorp.com/arena` and
`services/api/src/seo.ts` both advertise five spatial environments; `ARENA_MANIFESTS`
in `src/arena/index.ts` holds four (`drive-101-v1`, `office-nav-v1`, `crow-nav-v1`,
`california-flight-v1`) and there is no `officeJobs.ts`. The real total is eleven,
not twelve. Decide whether to build it or correct the page before publishing
anything that cites the count.~~ (superseded — see above)
## The house rules under multi-turn
The three rules were written for one-shot scoring. Two survive unchanged and one
does not, and there is a fourth the interactive tier forces.
1. **Graded on what the model did not see** — unchanged and easier: a stepped
simulator generates its held-out continuation by construction.
2. **No single-sided reward***harder*. Multi-turn adds degenerate strategies that
have no one-shot analogue: stalling to burn the clock when the score is
time-averaged, spamming cheap turns when observations are free, refusing to act
when acting risks a penalty. Every new reward needs a counterweight in the *turn*
dimension, not just the answer dimension.
3. **A zero floor and a reachable ceiling, demonstrated** — unchanged, but the floor
now has to hold across a whole trajectory, not one reply.
4. **New: the budget must bind.** An interactive environment where the optimal
policy is "use every turn" is measuring nothing the one-shot version did not.
Observations must cost something, and the oracle must be shown to stop early.
**`probe.py` must grow degenerate *policies*, not just degenerate answers.** Today
it scores four fixed strings per environment. The interactive tier needs at minimum:
the staller (never acts), the spammer (acts every turn at random), the impatient
(answers on turn 1 with no observation), and the exhaustive (spends the entire
budget then answers). The gate stays the same shape — inaction 0.000, oracle 1.000,
exit 1 otherwise — plus a new assertion that the exhaustive policy scores *below*
the oracle. If it does not, rule 4 is violated and the environment does not ship.
This is the differentiator. Nothing in `prime-envs` (88 environments) or
`community-environments` (109) ships a degenerate-policy gate.
## Dual publishing
Both targets take the same wheel; the repo layout already mirrors verifiers' own.
- **Prime Intellect Hub**, our own namespace: `prime env push <name> -v PUBLIC`.
We keep authorship and versioning, and the listing can link back to
lumbridgecorp.com/arena.
- **`community-environments`**: a PR to that repo, published under the
`primeintellect` org by their CI on a version bump. Extra distribution, but it
hands the namespace to them. Secondary, not primary.
- **lumbridgecorp.com/arena**: unchanged — the page documents the same wheel.
⚠️ Its README still documents the **legacy** flow (`load_environment`, `vf-eval`,
`uv pip install -e .`). Ignore it; follow `verifiers/docs/v1/`.
⚠️ **Blocker: `PRIME_API_KEY` is not set on amd-server.** `prime config view` shows
`API Key: Not set`. The `pit_` key lives in karti's memory note on cloud-1
(`.claude/projects/-home-ubuntu/memory/prime-intellect.md`) and is the same
credential for `api.pinference.ai` and `api.primeintellect.ai`. Nothing pushes until
it is configured here.
## Preconditions, verified on this box
- `uv run eval @ configs/canary_trap.toml --dry-run` → exit 0, config written ✓
- stable verifiers 0.3.1 with upstream harnesses and runtimes present ✓
- Docker 29.1.3 running (tier 2/3 runtime) ✓
- the reference node `brain` on `$SPARK_BASE_URL`, model id `brain`
- `prime` CLI installed (0.6.25; 0.6.26 available) ✓
- `PRIME_API_KEY` ✗ — see above
## Proposed sequence
**Phase 1 — one interactive environment, end to end.** `grand-exchange` is the
candidate: the market simulation already steps, the counterweight (liquidity, the
random-walk trap) already exists, and turning one basket into a quote/fill loop is
the smallest change that produces a genuinely new measurement. Deliverable: a
`vf.Env` in the existing wheel, a second config, and probe coverage for the four
degenerate policies. This is where rule 4 gets written for real.
**Phase 2 — extend `probe.py` to policies and re-gate everything.** Whatever
phase 1 teaches about degenerate trajectories applies to every later environment.
Do this before building a second one, not after. Note that `tests/test_probe.py`
and the wheel-build loop in `.gitea/workflows/ci.yml` hardcode the environment
list — both need editing or CI goes red.
**Phase 3 — the Tera bridge.** One Python `vf.Env` that drives the existing TS
package, validated by replaying every bridged episode through `replay()` and
requiring an identical checksum. Lands four environments at once and makes the
strongest existing asset publishable. Larger and riskier than phase 1; the
process boundary and the action encoding are the unknowns.
**Phase 4 — first Hub push**, once phases 13 give three to four interactive
environments alongside the seven one-shot ones. Needs the API key.
**Phase 5 — tier 2, then tier 3.** `fault-localisation` as a query-budgeted
tool-using environment, then `schema-migration` against a real database in a
container. Only after tier 1 has produced measured scores worth extending, and
with the concurrency cost of a container per rollout understood first.
## Open questions
- Does a bridged Tera episode replay to an identical checksum through a process
boundary? If not, the bridge cannot be verified and phase 3 gets much more
expensive.
- What is the per-rollout wall-clock cost of the `docker` runtime here, at the
concurrency an eval sweep needs? Unmeasured. It gates tier 3 entirely.
- Should the interactive form of an environment be a **separate** taskset id
(`grand-exchange-live`) or a config flag on the existing one? Separate ids keep
the one-shot scores comparable across time; a flag keeps the wheel count down.
Leaning separate.
+243
View File
@@ -0,0 +1,243 @@
# Arena build plan
Companion to `MULTI_TURN.md` (the scope). This is the sequencing: what gets built,
in what order, what can run in parallel, and what collides.
Status: plan, 2026-08-21. ✅ = settled. ❓ = blocks the work under it. ⛔ = parked by decision.
## Where Arena sits
Lumbridge is an open-source company shipping six self-hostable things:
**Compute** (governed compute for unified-memory hardware), **Bench** (model quality
and serving measured against one pinned host × quant × config tuple), **Simulate**
(the Tera/Spaces 3D engine), **Stream** (a headless broadcast rig), **Forge**
(post-training as a content-addressed build), and **Arena**. Four of the six are
public. `apps/web/src/data/projects.ts` in `lumbridge-v4` is the canonical list.
**Arena is the RL-environment project — yes, `/arena` is the section.** It is
appended last in the project list for numbering reasons, but semantically it sits
beside Bench and feeds Forge: Bench measures a model, Arena produces the gradient,
Forge runs the training. That triangle is the reason Arena is worth doing well
rather than as a side quest — we own all three corners, which nobody on the Hub does.
Arena spans **two repositories on one page**, and that is deliberate:
- `git.karti.ai/lumbridge-public/arena` — the Python data environments (this repo)
- `git.karti.ai/lumbridge-public/tera` — the TypeScript spatial environments
## What we take from Prime Intellect, and what stays ours
All of PI's stack is open source and we use it rather than rebuild it. Checkouts are
at `~/vendor/prime-intellect/` (46 repos; see its README).
| We use, unmodified | We build |
|---|---|
| `verifiers` — the v1 spec, the taskset/harness/trace model, `vf.Env` | The environments |
| `prime-rl` — the trainer traces feed | The house rules |
| `prime-agent` (`pi`) — a harness, already embedded in Piggy | `probe.py` — the degenerate-policy gate |
| `prime` CLI — `env push`, `env action` | The Tera bridge |
| The Environments Hub — distribution | lumbridgecorp.com/arena — documentation |
We do **not** build a harness, a trainer, or a hub. Every one of those exists,
is permissively licensed, and is better than what we would write.
## Decisions
**✅ 1 — Hub org handle: `lumbridge`.** Settled 2026-08-21. Environments will
publish as `lumbridge/<env-name>`.
**✅ 2 — Attribution: Lumbridge as author.** Settled 2026-08-21. `karti.ai` stays
the code host (`git.karti.ai`), Lumbridge is the author on every surface. One
identity across all six projects; no per-project byline.
**⛔ Hub push is ON HOLD.** Decided 2026-08-21: keep building, publish nothing to
the Prime Intellect Hub yet. Workstream E is parked — not cancelled, not blocked on
anything technical. `PRIME_API_KEY` is therefore not urgent.
**✅ 7 — Production brain: Qwen3.8-27B.** Settled 2026-08-21. The reference node serves
`brain-qwen38-dspark`; that is the default. The Nemotron baseline is ARCHIVED, not
deleted, and Arena's seven scores get re-run and republished against Qwen.
⚠️ **`--model brain` does NOT 404 — it silently succeeds and records `brain` in the
trace while the box serves Qwen.** Verified by direct call 2026-08-21. Pin
`brain-qwen38-dspark` in every reproduction command, everywhere.
**✅ 8 — `tera-drive-101` ships labelled.** Settled 2026-08-21. Published as a
**bridge-correctness** environment with NO rule-3 and NO rule-4 claim (its return is
monotone in throttle; any positive constant scores ~0.95+). Site says **four**
spatial environments. It is not listed as a fifth RL environment.
**✅ 9 — `app.lumbridgecorp.com` is the ONLY authenticated origin.** Settled
2026-08-21. The apex loses `/login`, `/signup`, `/app`, `/go`. All existing sessions
sign out once on cutover; accepted cost for three accounts.
**✅ 10 — Adopted defaults** (technical, recommended by the synthesis, settled
2026-08-21): the agent seat is named **`agent`** everywhere (matching
`SingleAgentEnvConfig`) — it becomes the TOML key in every `configs/*.toml` AND every
prime-rl `rl.toml`, and renaming later parses fine while silently having no seat;
**D lives in the `arena` repo**, not `tera` (amend the parallelization map);
**A ships the FLAT freeze**, with per-item recorded as the identified strengthening;
**Arena transcripts are ADMIN-ONLY** (they carry the held-out slice and oracle
answers — a contamination question, not an org-politics one); **the dashboard is the
archive and `eval-results.json` a curated summary emitted from it**, never two
hand-maintained files; **Forge integrates prime-rl with LoRA RL first**.
**✅ 3 — `office-jobs-v1` EXISTS; the counts are twelve / five / twenty.** Resolved
2026-08-21 15:50 when another session fast-forward-merged it into the shared `tera` tree
mid-session. It is committed, in `ARENA_MANIFESTS`, and passes `arena:source-hashes`.
The site's original copy was correct. ⚠️ `tera` has live concurrent sessions — re-check
its HEAD before acting on any claim about its contents.
~~**❓ 3 — `office-jobs-v1`.** Advertised on the live page and in `seo.ts`; absent
from `ARENA_MANIFESTS`. Build it, or correct the page to eleven. Blocks any copy
that cites the count, including a Hub listing.
**✅ 4 — Interactive envs get their own taskset id.** Settled 2026-08-21. The first
is **`grand-exchange-live`**, a separate wheel beside `grand-exchange`. One-shot
scores stay comparable across time; the two forms are measured, not conflated.
**✅ 5 — App is a subdomain: `app.lumbridgecorp.com`.** Settled 2026-08-21. Matches
the two-surface model (public site + private platform). Public site stays a site.
**✅ 6 — Compute policy.** Settled 2026-08-21. **Training runs on rented GPUs**
(GH200-class, from Prime Intellect) — rented because it is faster, and because
renting keeps the hardware choice open per run (the GB300-vs-alternatives call is
one we want to keep making). **Inference in production runs on the DGX Spark.**
We do **not** train on the Spark.
## Workstreams
Five, with the dependency edges called out. **A and D can start immediately and in
parallel. B must land before C or E.**
### A — `grand-exchange` interactive *(start now)*
The first tier-1 environment: quote → see fills → re-quote over a turn budget,
inventory and unfilled orders carrying between turns. Chosen because the market
simulation already steps, the counterweight (liquidity in gp/tick, the random-walk
trap) already exists, and it is the smallest change that yields a genuinely new
measurement.
Deliverable: a `vf.Env` subclass exported from the wheel's `__init__.py`, a config,
and a measured run against the reference node's `brain`. Pattern to copy:
`verifiers/v1/tasksets/textarena/taskset.py`.
This workstream is where **rule 4 — the budget must bind** gets written for real.
Whatever it learns about degenerate trajectories is the input to B.
Depends on: nothing — ✅4 settled.
### B — `probe.py` grows degenerate *policies* *(after A's first draft)*
Today the probe scores four fixed strings per environment. Tier 1 needs policies:
the **staller** (never acts), the **spammer** (acts every turn at random), the
**impatient** (answers turn 1 with no observation), the **exhaustive** (spends the
whole budget, then answers). Same gate shape — inaction 0.000, oracle 1.000, exit 1
otherwise — plus a new assertion that **exhaustive scores below oracle**. If it does
not, rule 4 is violated and the environment does not ship.
Do this before building a second interactive environment, not after. It is the
differentiator: nothing across `prime-envs` (88) or `community-environments` (109)
ships a degenerate-policy gate.
Depends on: A (needs one real interactive env to gate against).
Blocks: C, E.
### C — More interactive environments *(after B)*
In rough order of value:
1. `drop-table-inference` — buy observations under budget; measures when to stop looking
2. `bot-detection` — request labels one at a time (active learning)
3. `fault-localisation` — the log is not given; query it by service, window and level
4. `redaction-pressure` — two agents, redactor vs extractor; the counterweight becomes an opponent
Each is independently parallelizable **once B exists**. `redaction-pressure` is the
most interesting and the least certain — a genuine multi-agent env, not a stepped one.
Depends on: B.
### D — The Tera bridge *(start now, independent repo)*
One Python `vf.Env` driving the existing TypeScript `tera.arena/v1` package over a
process boundary, turning `step()` observations into `interaction.turn()` messages
and existing component rewards into `trace.record_reward` calls.
Validated by replay: every bridged episode must `replay()` through the TS package to
an identical FNV-1a-64 checksum, or the bridge is wrong. That property is rare and
the bridge should be built around it.
Lands four environments at once and makes our strongest existing asset publishable.
Larger and riskier than A — the process boundary and the action encoding are the
unknowns. **Touches the `tera` repo, not this one, so it does not collide with A/B/C.**
Depends on: nothing.
### E — First Hub push *(PARKED)*
Push once there are three to four interactive environments alongside the seven
one-shot ones. `prime env push <name> -v PUBLIC` to our own namespace is primary —
we keep authorship and versioning. A PR into `community-environments` is optional
extra distribution that hands the namespace to `primeintellect`; secondary.
Then update `lumbridgecorp.com/arena` to document the same wheels.
⛔ On hold by decision, 2026-08-21. Resume when Karti says so; ❓3 still gates any
copy that cites the environment count.
## ⚠️ The collision point
**`tests/test_probe.py` and the wheel-build loop in `.gitea/workflows/ci.yml`
hardcode the environment list.** Every workstream that adds an environment edits
both files. Two sessions adding environments concurrently *will* conflict there, and
CI goes red if either is missed.
Mitigation, in order of preference: (1) B refactors both to discover environments
from `environments/*/pyproject.toml` instead of a literal list — do this as part of
B and the collision disappears; (2) failing that, one session at a time touches
those two files.
Separately: this repo already took divergent remote pushes from concurrent sessions
on 2026-08-19. **Merge, never force.** Use a worktree per workstream.
## Parallelization map
```
now ──┬── A (grand-exchange interactive) ──┬── B (probe → policies, de-hardcode CI) ──┬── C1 drop-table
│ │ ├── C2 bot-detection
│ │ ├── C3 fault-localisation
│ │ └── C4 redaction (2-agent)
│ │
└── D (Tera bridge, tera repo) ──────┴──────────────────────────────── E (Hub push) ⛔ PARKED
```
Two sessions can start today: **A** in this repo, **D** in `tera`. They share no
files. Everything downstream of B fans out four ways.
## Preconditions, verified on amd-server
- `uv run eval @ configs/<env>.toml --dry-run` → exit 0 ✓
- stable verifiers 0.3.1, using its upstream `null` harness and runtimes ✓
- Docker 29.1.3 (tier 2/3 only; tier 1 needs none) ✓
- the reference node `brain` at `$SPARK_BASE_URL`, model id `brain`
- `prime` CLI 0.6.25 ✓
- `PRIME_API_KEY` ✗ — blocks E, which is parked anyway
---
## ⚠️ Superseded by `EXECUTION.md`
The workstream descriptions and the parallelization map above were written before the
2026-08-21 design workflow. **`EXECUTION.md` is now authoritative** for sequencing,
file ownership and risk. Three structural corrections it makes:
1. **B splits.** **B1** (de-hardcode environment discovery) does NOT depend on A and
ships immediately; the CI env list was never a matrix, just a bash `for` loop.
**B2** (degenerate-policy machinery) was found NOT FEASIBLE as written and must be
calibrated against A's *measured* engine, not A's first draft.
2. **D is no longer file-independent.** It lives in `arena` and edits `probe.py`,
`ci.yml`, `test_probe.py`, `README.md` plus two `lumbridge-v4` files.
3. **The top risk is that every Arena reward SATURATES**, so house rule 4
("exhaustive < oracle") may be structurally unreachable. If it is, every
interactive environment is unfalsifiable and the reward shape must change before
C1C4 are built on it. This is also a compute-policy constraint: a non-binding
turn budget burns rented GPU hours measuring nothing.
+197 -25
View File
@@ -1,41 +1,213 @@
# Arena
RL environments from Lumbridge. Apache-2.0, built to the [`verifiers`](https://github.com/PrimeIntellect-ai/verifiers)
v1 spec so they install and run on the Prime Intellect Environments Hub unchanged.
v1 spec so the same wheel can run locally or, after a separate owner-approved release,
through the Prime Intellect Environments Hub unchanged.
An environment here is an eval you take the gradient of. That is not a rename — it changes
what the artifact has to carry. A benchmark's score is its output, so a benchmark can be a
number in a table. An environment's score is the input to the next weight update, so it has
to be reproducible, gameable-proof, and traceable back to the code that produced it, or the
model that trained on it inherits whatever was wrong with it silently.
An environment is an eval you take the gradient of. That is not a rename — it changes what
the artifact has to carry. A benchmark's score is its output, so a benchmark can be a number
in a table. An environment's score is the input to the next weight update, so a defect in one
is inherited by the model rather than printed beside it.
Three rules follow, and every environment in this repository is held to them:
Most of what is on the Hub today is a benchmark that was ported into the spec. A port cannot
generate a held-out slice, because its dataset is fixed — so it is contaminated the moment
anyone trains on it. And an exact-match reward is single-sided by construction, so it teaches
whatever maxes the string comparison. Nothing here is a port.
1. **Graded on what the model did not see.** Rules, plans and policies fitted to visible
examples pass on visible examples. If a held-out slice cannot be generated, the
## The house rules
Every environment in this repository is held to three, and ships the probe that proves it.
An interactive one is held to a fourth.
1. **Graded on what the model did not see.** If a held-out slice cannot be generated, the
environment does not measure generalisation and does not ship.
2. **No single-sided reward.** Every reward has a counterweight in the same episode. A
reward you can max by doing the crude thing is a reward that teaches the crude thing.
3. **A reachable ceiling and a zero floor, demonstrated.** Each environment ships the check
that shows an oracle scores 1.0 and inaction scores 0.0. An unreachable component is
dead weight in the gradient; a floor above zero pays for doing nothing.
2. **No single-sided reward.** Every reward has a counterweight in the same episode. A reward
you can max by doing the crude thing is a reward that teaches the crude thing.
3. **A zero floor and a reachable ceiling, demonstrated — per component, not per
environment.** Inaction scores 0.0 and an oracle scores 1.0, measured, in `probe.py`. An
unreachable component is dead weight in the gradient; a floor above zero pays for doing
nothing. The qualifier was added on 2026-08-21 and it was expensive: for a month this
file printed `oracle 1.000` and `ok` for four environments whose `gate` component scored
exactly 0.000 mean and 0.000 max over 32 real rollouts each. The blend was true and the
thing inside it was a constant. `probe.py` reports every weighted component's floor and
ceiling now, and exits 1 on one that never moves.
4. **A turn budget that binds.** In an environment the model acts in over several turns, a
policy that spends every turn must score measurably below one that spends the turns worth
spending. Every reward here saturates, so without a cost for looking the budget is free
and the multi-turn form measures nothing the one-shot form did not. In
`grand-exchange-live` the gap is 0.369, stdev 0.054 over five blocks of twenty-four,
worst block 0.316.
## Environments
Rules 3 and 4 are not decoration. Every environment here was wrong the first time and the
probe is what caught it — see the table below, and the git history.
| | |
|---|---|
| [`redaction-pressure`](environments/redaction_pressure) | Write redaction rules for a ticket corpus. Graded on the next records the generator would have produced. Every secret class is shape-matched to a decoy, so a rule keyed on shape earns on one and pays on the other. Reward is a scanner — no judge model, no runtime, milliseconds per rollout. |
## The environments
| | What it measures | The trap |
|---|---|---|
| [`redaction-pressure`](environments/redaction_pressure) | Write provenance-safe redaction rules for a ticket corpus, run against the next records the generator would have produced. | Partial edits remain residual secrets, every non-secret source character is protected, and hostile regexes share one episode budget. |
| [`canary-trap`](environments/canary_trap) | Write contamination probes that catch a model trained on your eval set. | Probes are run against a model that memorised the corpus **and** one that learned the same facts from a reworded copy. GUID-recall probes are sound and see only half of what is there. |
| [`fault-localisation`](environments/fault_localisation) | Read an incident log and name the root cause, its class, and the line proving it. | The service that broke emits one line and goes quiet; its dependents emit a dozen timeouts. Ranking by error volume answers the victim, every time. |
| [`schema-migration`](environments/schema_migration) | Split a text column into a number and a unit, executed against held-out rows. | The visible rows are tidy. The graded ones carry thousands separators, negatives, a unit containing a slash, and a value with no unit at all. |
| [`bot-detection`](environments/bot_detection) | Classify accounts as bot or human from activity logs, learning the rule from six labelled examples. | Click latency, session length and route count are drawn before the generator decides who is a bot, so every timing rule sits at chance. The bots that cloak leak on one behavioural channel each, and there are three. |
| [`drop-table-inference`](environments/drop_table_inference) | Estimate a monster's drop rates from a kill log, graded on the next forty thousand kills. | Copying the observed frequencies is right about the common drops and asserts that the item which appeared zero times is impossible. Nothing in the counts distinguishes one silent item from another, so the only evidence about them is the rungs the observed items did not take and what the silent ones are worth. |
| [`grand-exchange`](environments/grand_exchange) | Read price and volume history for a basket of items and place limit orders, executed against the next thirty ticks. | One or two items per basket are a random walk, not a mean-reverting one — the widest-swinging lines on the board and the ones with no anchor to revert to — and you are not told how many there are, so counting is not a substitute for the shape statistic. And liquidity in gp per tick is uncorrelated with price, so the fattest visible margins sit where the purse cannot go. |
| [`grand-exchange-live`](environments/grand_exchange_live) | The same basket, traded while the window is running: eight looks over sixty ticks, with stock, offers standing between looks, and a purse that sale proceeds come back to. | Looking is not free. Re-quoting an item that already carries an offer freezes the **whole** book for one to four ticks, an amended offer restarts at the back of the fill queue, and the per-item buy limit is cumulative over the window rather than per offer — so the policy that re-quotes on every look scores 0.631 against a reference that looks four times and scores 1.000. |
Tera world-simulation environments are maintained in the private Tera
repository. Arena keeps the generic, Apache-2.0 environment work above; it does
not ship current proprietary Tera source. See [the source boundary](docs/TERA_BOUNDARY.md).
## What the probes measure
Reward for the degenerate strategies and for an oracle, from `uv run python probe.py`:
| environment | inaction | crude maximiser | plausible attempt | oracle |
|---|---|---|---|---|
| `redaction-pressure` | 0.000 | 0.387 *(redact everything)* | 0.549 | **1.000** |
| `canary-trap` | 0.000 | 0.000 *(public-knowledge probes)* | 0.525 *(GUID recall)* | **1.000** |
| `fault-localisation` | 0.000 | 0.250 *(blame the loudest)* | 0.500 | **1.000** |
| `schema-migration` | 0.000 | 0.000 *(add columns, touch nothing)* | 0.641 | **1.000** |
| `bot-detection` | 0.000 | 0.000 *(ban everyone)* | 0.400 *(one tell of three)* | **1.000** |
| `drop-table-inference` | 0.000 | 0.179 *(copy the frequencies, floor the rest at a memorised constant)* | 0.476 *(per-item posterior over the grid)* | **1.000** |
| `grand-exchange` | 0.000 | 0.082 *(buy and sell at market)* | 0.608 *(anchor on the mean and size to the volume, trap included)* | **1.000** |
| `grand-exchange-live` | 0.000 | 0.126 *(buy and sell at market, on one look)* | 0.406 *(anchor on the mean, no trap filter, one look and no re-quote)* | **1.000** |
`schema-migration`'s plausible rung moved from 0.630 to 0.641 on 2026-08-21 when its gate
gained a margin — the naive split clears the loosened gate on one seed in twenty-four. Every
other cell is unchanged.
### And the same table one reward component at a time
The four columns above are blends, and a blend cannot show a constant inside it. Below each
component's **weighted** floor, the best any rung below the oracle manages, and its ceiling.
The interesting column is the middle one: where it equals the floor, nothing short of the
oracle earns a fraction of that term.
| environment | component | floor | best below oracle | ceiling |
|---|---|---|---|---|
| `bot-detection` | caught | 0.000 | 0.278 | 0.350 |
| `bot-detection` | spared | 0.000 | 0.317 | 0.400 |
| `bot-detection` | gate | 0.000 | 0.250 | 0.250 |
| `canary-trap` | detection | 0.000 | 0.350 | 0.350 |
| `canary-trap` | specificity | 0.000 | 0.350 | 0.350 |
| `canary-trap` | gate | 0.000 | 0.300 | 0.300 |
| `drop-table-inference` | fit | 0.000 | 0.242 | 0.450 |
| `drop-table-inference` | rare | 0.000 | 0.326 | 0.350 |
| `drop-table-inference` | gate | 0.000 | 0.025 | 0.200 |
| `fault-localisation` | service | 0.000 | 0.300 | 0.300 |
| `fault-localisation` | fault | 0.000 | 0.250 | 0.250 |
| `fault-localisation` | evidence | 0.000 | 0.250 | 0.250 |
| `fault-localisation` | gate | 0.000 | **0.000** | 0.200 |
| `grand-exchange` | profit | 0.000 | 0.450 | 0.450 |
| `grand-exchange` | discipline | 0.000 | 0.298 | 0.300 |
| `grand-exchange` | gate | 0.000 | 0.244 | 0.250 |
| `grand-exchange-live` | profit | 0.000 | 0.382 | 0.450 |
| `grand-exchange-live` | discipline | 0.000 | 0.201 | 0.300 |
| `grand-exchange-live` | gate | 0.000 | 0.048 | 0.250 |
| `redaction-pressure` | recall | 0.000 | 0.304 | 0.350 |
| `redaction-pressure` | precision | 0.000 | 0.350 | 0.350 |
| `redaction-pressure` | gate | 0.000 | **0.000** | 0.300 |
| `schema-migration` | schema | 0.000 | 0.300 | 0.300 |
| `schema-migration` | integrity | 0.000 | 0.422 | 0.450 |
| `schema-migration` | gate | 0.000 | 0.240 | 0.250 |
Two components are bold, and `probe.py` names both of them on the way past. They are not the
same problem. `fault-localisation`'s gate wants three fields correct at once and fires on
**29 of 32** real rollouts — the healthiest gate here; the ladder simply has no rung standing
at two-of-three. `redaction-pressure`'s fires on **0 of 32**, and unlike the two that were
fixed below, no margin rescues it: see the sweep in its scanner.
The probe cannot tell those apart, which is why it warns rather than fails there. What tells
them apart is `tools/regate.py` — it replays the real traces in `outputs/` through a
candidate gate and reports the fire rate, holding the model's behaviour fixed and varying
only the reward.
### The margins, and what they were measured against
Two gates demanded exact equality with the reference and paid **nothing at all** for anything
short of it. Both were re-scored over the 32 real rollouts in `outputs/run-20260821-1401`
before being changed, and both floors were re-measured after:
| environment | gate | before | after | inaction | crude | plausible |
|---|---|---|---|---|---|---|
| `bot-detection` | `GATE_SLACK = 1` — the reference's bots bar one, still zero wrongful bans | 0/32 | **5/32 = 0.156** | 0.000 | 0.000 | 0.000 |
| `schema-migration` | `GATE_MARGIN = 0.15` — 34 of 40 rows recompose; schema and row count still exact | 0/32 | **4/32 = 0.125** | 0.000 | 0.000 | 0.042 |
| `redaction-pressure` | unchanged, and deliberately | 0/32 | 0/32 | 0.000 | 0.000 | 0.000 |
The slack is on the catching clause only in both cases. A single wrongful ban still shuts
bot-detection's gate outright, and a migration that leaves `value_text` standing or loses a
row still fails schema-migration's — those are format preconditions, not matters of degree.
`schema-migration`'s 0.042 is the price of the margin, named rather than hidden: the naive
parser's luckiest draw of forty awkward rows recomposes exactly 34 of them, and `probe.py`
asserts that number stays under a tenth.
`redaction-pressure` was swept and left alone, which is the honest outcome rather than a
gap. Every margin loose enough to fire on the measured population — half the secrets left
standing — also pays a four-of-seven ruleset on five seeds in six; every margin tight enough
to keep that at zero fires on none of the 32. The failure is joint rather than a threshold:
recall maxed at 0.852 with never fewer than four secrets left, `collateral_hits == 0` held
on 6 of 32, and no rollout managed both at once. That gate is hard, not dead, and forcing a
margin onto it would trade the meaning of "redacted" for nothing measurable.
## Running one
```bash
uv run eval @ configs/redaction_pressure.toml --model <model-id>
uv run --project environments/redaction_pressure eval @ configs/redaction_pressure.toml --model <model-id>
```
## Why these, and not benchmark reimplementations
Run its release gate from the repository root:
The Hub is filling with reimplementations, which are worth doing and are not what we have.
What Lumbridge has is a set of tools that already do verifiable work — Bench's
contamination gate, Forge's signed build chain — and those gates make good rewards
precisely because they were built to be un-arguable about a machine. `redaction-pressure`
is Forge's `redact` stage with the exit code turned into a gradient.
```bash
uv sync --project environments/redaction_pressure
uv run --project environments/redaction_pressure \
python -m unittest discover -s environments/redaction_pressure/tests -v
uv run --with regex python probe.py
```
Arena contains independently published environment libraries, so per-environment
`uv.lock` files are intentionally not committed. Redaction v0.2 pins its runtime contract
in `pyproject.toml`; CI resolves it on Python 3.11 and 3.12, builds all eight public
environments, and runs the auto-discovered probe for every one of them.
## Publishing (deferred)
The layout mirrors `verifiers`' own, so an environment can eventually go to the Hub without
a fork. No current environment is published there from this repository. Hub publication is
an explicit owner-approved outward action; ordinary development and CI must never run it.
After that separate approval, the upstream command is:
```bash
prime env push redaction-pressure -v PUBLIC
```
## What a model actually scores
First evaluation, 2026-08-19: **Nemotron 3.5 Lightning 30B-A3B** (NVFP4, thinking off) served
on one DGX Spark. 16 tasks x 2 rollouts per environment, 32 rollouts each.
| environment | reward | where it fails |
|---|---|---|
| `canary-trap` | 0.570 | specificity 1.00 — it never accuses a clean model. Detection 0.55: it writes GUID-recall probes and is blind to the paraphrase, which is the failure the environment was built to expose |
| `fault-localisation` | 0.352 | fault class 0.56, evidence 0.53, **service 0.16**. In 12 of the 17 cases where it cited the correct line it still named the wrong service — the service is printed on that line |
| `schema-migration` | 0.350 | schema 0.59, integrity 0.38. It reaches the target shape and loses the awkward rows |
| `redaction-pressure` | 0.343 | recall 0.45, precision 0.53 |
| `grand-exchange` | 0.219 | profit 0.33, discipline 0.18 |
| `bot-detection` | 0.142 | caught 0.19, spared 0.19 — it accuses humans, which is the expensive error |
| `drop-table-inference` | 0.059 | fit 0.01. It answers in fractions (`48/128`, `1/3072`), so it has the structural idea, and the rates are still wrong |
**Every gate is at or near zero** — 0.00 on four of the seven. None of these is solved, and
`probe.py` shows none is unsolvable. That gap is the whole point: an environment a model
already passes has no gradient left in it, and one nothing can pass has none yet.
⚠️ Four of those zeros were **0.000 mean and 0.000 max on all 32 rollouts**, which is a
different claim: not a hard gate, a constant. `docs/GATE_DIAGNOSIS.md` is the post-mortem.
Two have since been given measured margins, so re-scoring the same traces under the shipped
scorers moves `bot-detection` from 0.3488 to **0.3879** and `schema-migration` from 0.4432
to **0.4745**. Those two rows are stale above and will be replaced by a fresh run, not
patched — and every number in that table was sampled with thinking OFF, which is its own
retraction and is documented in `docs/FIRST_EVAL.md`.
The replies parse: 32/32 rollouts produced non-zero metrics in every environment, so these are
model scores rather than a harness failing to read its own output.
+21
View File
@@ -0,0 +1,21 @@
# bot-detection — labels are held out; the batch is classified from six examples.
#
# uv run eval @ configs/bot_detection.toml --model <model-id>
num_tasks = 16
num_rollouts = 2
[env.taskset]
id = "bot-detection"
graded = 12 # unlabelled accounts the answer is scored on; 9 is the floor
[env.agent.harness]
id = "null" # pure-trace reward: one completion, no tools, no runtime
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+20
View File
@@ -0,0 +1,20 @@
# canary-trap — see environments/canary_trap for what it measures.
#
# uv run eval @ configs/canary_trap.toml --model <model-id>
num_tasks = 16
num_rollouts = 2
[env.taskset]
id = "canary-trap"
[env.agent.harness]
id = "null" # pure-trace reward: one completion, no tools, no runtime
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+22
View File
@@ -0,0 +1,22 @@
# drop-table-inference — the estimate is graded on the kills the model never saw.
#
# uv run eval @ configs/drop_table_inference.toml --model <model-id>
num_tasks = 16
num_rollouts = 2
[env.taskset]
id = "drop-table-inference"
visible_kills = 1200 # kills the model reads, as a tally
held_out_kills = 40000 # kills it is scored on
[env.agent.harness]
id = "null" # pure-trace reward: one completion, no tools, no runtime
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+20
View File
@@ -0,0 +1,20 @@
# fault-localisation — see environments/fault_localisation for what it measures.
#
# uv run eval @ configs/fault_localisation.toml --model <model-id>
num_tasks = 16
num_rollouts = 2
[env.taskset]
id = "fault-localisation"
[env.agent.harness]
id = "null" # pure-trace reward: one completion, no tools, no runtime
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+23
View File
@@ -0,0 +1,23 @@
# grand-exchange — see environments/grand_exchange for what it measures.
#
# uv run eval @ configs/grand_exchange.toml --model <model-id>
num_tasks = 16
num_rollouts = 2
[env.taskset]
id = "grand-exchange"
num_items = 5 # items in the basket, one per price tier
visible = 56 # ticks the model reads
held_out = 30 # ticks its orders are executed against
[env.agent.harness]
id = "null" # pure-trace reward: one completion, no tools, no runtime
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+32
View File
@@ -0,0 +1,32 @@
# grand-exchange-live — see environments/grand_exchange_live for what it measures.
#
# uv run --project environments/grand_exchange_live eval @ configs/grand_exchange_live.toml \
# --model <model-id>
#
# Eight looks per episode against one basket, so a rollout here costs roughly eight times a
# one-shot `grand-exchange` rollout in requests and rather more than that in tokens: the
# whole conversation is replayed to the model on every segment. `num_tasks` is half the
# one-shot config's for that reason, not because fewer baskets measure better — realised
# P&L over sixty ticks is the noisiest number in this repository and the ladder is read
# over five blocks of twenty-four.
num_tasks = 8
num_rollouts = 2
[env.taskset]
id = "grand-exchange-live"
num_items = 5 # items in the basket, one per price tier
visible = 56 # warmup ticks, read before the first look
# The graded window is not configurable: 8 looks x 7 ticks + a 4-tick tail = 60, and every
# constant in the reward was measured against that shape (`measure_ladder.py --sweep-shape`).
[env.agent.harness]
id = "null" # pure-trace reward: a chat loop, no tools, no runtime
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+10
View File
@@ -6,8 +6,18 @@ num_rollouts = 2
[env.taskset]
id = "redaction-pressure"
split = "dev" # public deterministic development split; never use it as a private holdout
visible = 4 # records the model reads
held_out = 12 # records it is scored on
[env.agent.harness]
id = "null" # a pure-trace reward: one completion, no tools, no runtime
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+20
View File
@@ -0,0 +1,20 @@
# schema-migration — see environments/schema_migration for what it measures.
#
# uv run eval @ configs/schema_migration.toml --model <model-id>
num_tasks = 16
num_rollouts = 2
[env.taskset]
id = "schema-migration"
[env.agent.harness]
id = "null" # pure-trace reward: one completion, no tools, no runtime
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+666
View File
@@ -0,0 +1,666 @@
# Running an eval — the runbook
Written 2026-08-21, the day Arena first measured anything end to end. Before this,
`outputs/` held one `config.toml` and zero episodes: every number downstream of an
eval — the dashboard, `eval-results.json`, the leaderboard, the before/after
training table — was waiting on a run that had never completed.
This is that run, written down so the next one is a copy-paste.
⚠️ **Updated 2026-08-25.** The pin is now the stable `verifiers==0.3.1`
release published 2026-08-24. It removes the 600-second model-call ceiling §5 blames for
every truncation here, and moves the output layout. §1 and §2 are the post-bump
instructions; §4 and §5 are kept as written because they are the record of what the
thinking-**off** run measured, and §6 is the thinking-**on** re-measurement the bump made
possible. **Every reward number in this file now carries its sampling configuration.**
---
## 1. The command that works
```bash
cd ~/repos/gitea/arena
export SPARK_API_KEY=dummy
# The OpenAI-compatible endpoint of the reference node, e.g. http://<host>:8001/v1.
# Kept out of this repo on purpose: it is an internal address, not a public one.
export SPARK_BASE_URL=...
uv run --project environments/canary_trap eval @ configs/canary_trap.toml \
--model brain-qwen38-dspark \
--client.base-url "$SPARK_BASE_URL" \
--client.api-key-var SPARK_API_KEY \
--env.agent.runtime.type subprocess \
--no-push --no-rich \
-c 8 -o outputs/run-<stamp> --run.dir canary-trap
```
Swap `environments/<pkg>` and `configs/<pkg>.toml` for any of the eight in `configs/`. The
thinking-**on** sweep is `outputs/thinking-n32/run_all.sh` (gitignored, and it takes a
`flock` — see below); the counters are `outputs/count_rewards.py` and
`outputs/summarise_run.py`.
**Thinking is off unless you ask for it**, and asking goes in a config file, not a flag —
see §5. The seven data environments have thinking-on copies in `outputs/thinking-n32/*.toml`;
§6 is what they measured.
### Why each flag is there
| flag | why |
|---|---|
| `--model brain-qwen38-dspark` | **The served id, pinned.** `--model brain` does *not* 404. SGLang does not validate the model field on the chat route: it returns 200, Qwen answers, and the trace records `brain`. The old `outputs/canary-trap--brain--null/` dir is exactly that mistake. Pin the id `GET /v1/models` reports. |
| `--client.base-url` | The default is `https://api.pinference.ai/api/v1`, which needs a `PRIME_API_KEY` this box does not have. |
| `--client.api-key-var SPARK_API_KEY` | The reference node does not authenticate with a Prime key. The client still resolves *some* env var, so point it at a dummy one rather than leaving it on `PRIME_API_KEY` (empty → the failure mode of the one earlier attempt). |
| `--no-push` | Hub push is parked (PLAN.md). Push is **on by default** and needs `$PRIME_API_KEY`. |
| `--no-rich` | The live dashboard is in-process only and unreadable in a log. Without it you get one `rollout done: … reward=…` line per rollout. |
| `-c 8` | The reference node is shared with Chatterbox, vox and ASR. The config's own `max_concurrent = 128` will hammer it. |
| `--env.agent.runtime.type subprocess` | ⚠️ **New, and not optional.** 0.3.0 defaulted the agent runtime to `subprocess`; `0.3.1` defaults it to `prime` — a *remote* sandbox. A remote role makes `Env._runs_local()` false, which makes the interception pool mint a Prime tunnel, which calls `ensure_prime_auth()` and raises `SystemExit: not authenticated with prime`. The run dies before a single rollout, having already created its output directory and an empty `traces.jsonl`. The null harness needs no runtime at all; naming a local one is the whole fix. |
| `-o <dir>` + `--run.dir <name>` | `-o` is `output_dir` and the run writes to `output_dir / run.dir`. On 0.3.0 a bare `-o` gave you a flat directory; on 0.3.1 it gives you `outputs/<your-dir>/<env>--<model>--<harness>--<hex8>/`. Pass `--run.dir` to name the leaf yourself. |
### Flags our own docs get wrong
- `README.md`'s "Running one" line omits `--client.base-url` / `--client.api-key-var`
entirely, so copy-pasting it points at Prime's inference API with an empty key.
That is the one attempt that was ever made, and why `outputs/` was empty.
- `MULTI_TURN.md`'s preconditions say the the reference node model id is `brain`. The served
id is `brain-qwen38-dspark`; `brain` silently mislabels the trace.
- `EXECUTION.md` is right that `--harness.id null` is not valid — `EvalConfig` has
no top-level `harness`. The configs already carry `[env.agent.harness] id = "null"`,
so nothing needs passing on the command line. The CLI form, if you ever need it,
is `--env.agent.harness.id null`.
- The `@ config.toml` form is a *positional* argument, not a flag: `eval @ file.toml`.
Inside a shell script quote it as `"@"` so it is not glob-expanded.
---
## 2. The pin, and the output layout it produces
### There is no `verifiers` 0.4.0
`~/vendor/prime-intellect/verifiers` is **not** a 0.4.0 release, and nothing on any index
is. `git describe --tags` there says `v0.3.0-59-g4bcb48e5`: fifty-nine commits past the
0.3.0 tag, version derived from git tags by `hatch-vcs`. The `version = "0.4.0"` that
earlier notes read off its `pyproject.toml:136` belongs to a `[[tool.uv.dependency-metadata]]`
block for **nemo-gym**, not to verifiers. Anyone who goes looking for `verifiers==0.4.0`
will not find it.
The stable `0.3.1` wheel was published to PyPI on 2026-08-24 after the dev stream.
It contains the unbounded model-call timeout and output-layout changes this runbook
requires; `verifiers/v1/clients/base.py` reads:
```python
DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=None, write=None, pool=None)
```
**So the pin is an ordinary index dependency, not a path or git source.** Every
`environments/*/pyproject.toml` now reads `verifiers==0.3.1`. It is an ordinary
stable index dependency — no `--prerelease=allow`, no `[tool.uv.sources]`, no
`tool.uv` block, and nothing that resolves differently in CI than it does here. A path
dependency on `~/vendor` would have been reproducible only on this box; a git URL would
have pinned a commit no wheel matches. Neither was necessary.
```
Resolved 114 packages in 620ms
- verifiers==0.3.0
+ verifiers==0.3.1
```
`uv sync` each environment after pulling. Verify the thing you bumped *for*, not the
version string:
```bash
for d in environments/*/; do printf "%-34s " "$d"
grep -h "DEFAULT_TIMEOUT = " $d/.venv/lib/python*/site-packages/verifiers/v1/clients/base.py
done
# environments/bot_detection/ DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=None, write=None, pool=None)
# ... eight of eight
```
`uv sync` rewrites each `environments/*/uv.lock`; none of them are tracked any more
(`.gitignore:5` is `environments/*/uv.lock`, and `git ls-files environments | grep uv.lock`
is empty), so the bump does not put a lock file into a commit. CI's lock-policy step is
`test -z "$(find environments -name uv.lock -print -quit)"` — it looks on **disk**, not in
git, so it passes on a fresh checkout and would fail on a working tree that has been
synced. Do not run it locally and conclude the repo is broken.
### The layout 0.3.1 writes
`verifiers/v1/cli/output.py``output_path()` is `config.output_dir / config.run.dir`,
and `run.dir` defaults to `run.name`, which is
`<env>--<model>--<harness>--<uuid4-hex8>`, lowercased, with slashes in the model id
becoming `--`. Measured, not read off the source:
```
outputs/<output-dir>/<env>--<model>--<harness>--<hex8>/
├── configs/eval.json # the RESOLVED config, re-runnable as `eval @ configs/eval.json`
├── traces.jsonl # one JSON *Episode* per line, appended as rollouts land
└── logs/eval.log # written for you now — no redirect needed
```
Three differences from 0.3.0 that an ingester or a script has to handle:
1. **The uuid leaf is gone.** 0.3.0 wrote `<env>--<model>--<harness>/<uuid>/`; 0.3.1
writes one flat directory with an 8-hex suffix on the name. `-o` no longer flattens
anything — it sets `output_dir`, and the run still makes its own leaf underneath.
`--run.dir <name>` is what pins the leaf, and it is what `outputs/thinking-n32/run_all.sh`
uses.
2. **`config.toml``configs/eval.json`.** JSON because JSON keeps nulls, so an explicit
`None` round-trips on re-parse. `RunConfig._id` is a `PrivateAttr` and is **not** in it —
derive the run id from the directory basename.
3. **`logs/eval.log` is written unconditionally.** On 0.3.0 you only got it if you
redirected stdout yourself, which is why `outputs/run-20260821-1401/*.log` sit beside
their run dirs rather than inside them. Anything that globs for a log should look in
both places.
4. **`--resume` exists now**, and it is the answer to §5's "a timed-out run leaves no
evidence": `uv run eval @ <run-dir>/configs/eval.json --resume` re-runs only the
missing and errored rollouts, in place. (Its own `--help` still says
`@ <run-dir>/config.toml` — the 0.3.0 filename. The file is `configs/eval.json`.)
The episode schema did **not** move, despite `4bcb48e5 feat(v1): make episodes
training-native` landing in this range. One Episode per line, not one Trace:
```jsonc
{"id": "...", "env": {...}, "ok": true, "errors": [],
"traces": [ { "id": "...", "agent": "...", "rewards": {...},
"metrics": {...}, "stop_condition": "agent_completed",
"errors": [], "timing": {...} } ] }
```
`outputs/count_rewards.py`, written against 0.3.0, reproduces every published number from
`run-20260821-1401` unchanged against a 0.3.1 run. The two traps in that file are also
unchanged, and both still bite an ingester:
1. **`Reward.value` is a `@property` and never appears in the JSON.** Each entry
in `rewards` is `{"score": float, "weight": float}`. The episode total is
`sum(score * weight)`. The `reward=0.525` in the log line is computed, not stored.
2. **`write_episode` dumps with `exclude_none=True`.** A `None` reward is dropped
from the file entirely, so "not measured" and "key absent" are indistinguishable
on the wire. Carry the run's declared reward-name set from the resolved config if you
need to tell them apart.
### What the bump does not fix
`sampling` in `configs/eval.json` now carries `chat_template_kwargs` through resolution, so
a run's own config records whether thinking was requested. **The trace still does not.**
Every `calls[].sampling` is `{}``ChatDialect.parse_sampling` keeps an allow-list of
known keys — and `usage.reasoning_tokens` is still null, because SGLang reports it at the
top level of `usage` while `Usage.from_openai` reads it out of `completion_tokens_details`.
The only in-trace evidence that a model thought is a `reasoning_content` field on the
assistant message. `outputs/summarise_run.py` counts exactly that, and prints the run's
resolved `sampling` beside every mean it reports.
## 3. Counting what actually scored
**`eval` exits 0 even when every rollout errors.** An errored trace is written with
`rewards: {}`. Never report a run on its exit code.
```bash
uv run python outputs/count_rewards.py outputs/run-<stamp>/<env>
```
It prints `traces_scored` (non-empty `rewards`), `traces_errored` (`rewards: {}`),
`provider_errors`, the stop-condition histogram, and two means:
- `reward_mean_scored` — the mean over what survived. This is Arena's own
"score what survived" trap in reporting form: a run where 30 of 32 rollouts
errored and the 2 survivors scored 0.9 reads as 0.900.
- `reward_mean_attempted` — errors counted as 0. **Rank on this one**, and refuse
to rank at all below a scored-fraction floor.
A quick eyeball without the script:
```bash
jq -r '.traces[] | if (.rewards|length)>0 then "scored" else "errored" end' \
outputs/run-<stamp>/<env>/traces.jsonl | sort | uniq -c
```
### And under what sampling configuration
`count_rewards.py` answers *did anything score*. It does not answer the question that made
`run-20260821-1401` unpublishable: *how was it sampled*. `outputs/summarise_run.py` adds
that, and nothing else you cannot get from the first script:
```bash
uv run --project environments/<pkg> python outputs/summarise_run.py outputs/<run-dir>
```
It reads the run's own resolved config (`configs/eval.json` on dev59, `config.toml` on
0.3.0) and prints, beside the means:
- `sampling` — the run's own record of `max_tokens` / `chat_template_kwargs`. A run whose
`sampling` is `{}` was sampled at the reference node's server-side default, which is
`enable_thinking: false`.
- `traces_with_reasoning` — assistant messages carrying `reasoning_content` (or a literal
`<think>` block). **This is the only in-trace evidence that the model was allowed to
think.** Verify the flag from here, never from the fact that you passed it.
- `finish_reasons` and the completion-token quartiles — `length` in that histogram means
truncation, and a truncated answer scores whatever an empty answer scores.
- `reward_mean_from_eval_log` — the mean of the `reward=` field on the harness's own
`rollout done` lines. It must equal `reward_mean_scored` exactly. An unweighted mean over
reward *components* does not, which is how six of seven means were misreported once.
Its arithmetic is checked against the published run: it reproduces `fault-localisation`
0.9531, `canary-trap` 0.6289, `grand-exchange` 0.0055 and `drop-table-inference` 0.4174
from `run-20260821-1401`, with `reward_mean_from_eval_log` agreeing in every case, and
`traces_with_reasoning` 0/32 in every case.
---
## 4. What it cost, and what it scored
**The run: `outputs/run-20260821-1401/`.** Seven data environments, 32 rollouts each,
224 episodes, **223 scored**. Model `brain-qwen38-dspark` (Qwen3.8-27B) on the reference node.
Wall-clock 14:03 → 14:58, 55 minutes, `-c 8`.
> ⚠️ **Sampling configuration for every number in this section — read it before quoting
> one.** `verifiers==0.3.0`; `sampling = {}`, i.e. no `max_tokens` and no
> `chat_template_kwargs`, so the reference node's server-side default applied and
> **`enable_thinking` was `false`**; `temperature`, `top_p` and `reasoning_effort` all
> unset; `-c 8`; harness `null`; agent runtime `subprocess`. `traces_with_reasoning` is
> **0 of 224**. These are thinking-**off** numbers. §6 re-measures the same eight
> environments with thinking on, and the two columns are not interchangeable —
> `grand-exchange` moves by two orders of magnitude between them.
⚠️ **Compute the mean as `sum(score × weight)` per trace.** An unweighted mean over the
reward components is a different number and it is wrong — it disagrees with what the
harness itself prints. Cross-check against the `reward=` field on each env's 32
`rollout done` lines in `eval.log`; the two must agree exactly. The first transcription
of this run got six of seven means wrong by taking the unweighted mean.
| environment | traces | scored | errored | reward_mean_attempted | reward_mean_scored |
|---|---|---|---|---|---|
| `fault-localisation` | 32 | 32 | 0 | **0.9531** | 0.9531 |
| `canary-trap` | 32 | 32 | 0 | **0.6289** | 0.6289 |
| `schema-migration` | 32 | 32 | 0 | **0.4432** | 0.4432 |
| `drop-table-inference` | 32 | 31 | 1 | **0.4174** | 0.4309 |
| `redaction-pressure` | 32 | 32 | 0 | **0.4040** | 0.4040 |
| `bot-detection` | 32 | 32 | 0 | **0.3488** | 0.3488 |
| `grand-exchange` | 32 | 32 | 0 | **0.0055** | 0.0055 |
The single errored episode is a genuine the reference node gateway timeout (`ProviderError`, 504) in
`drop-table-inference` — not a scoring bug. **Rank on `_attempted`** (errors as 0); the
`_scored` column is Arena's own "score what survived" trap in reporting form.
### Two numbers not to publish as capability scores
⚠️ **`grand-exchange` 0.0055 measures a sampling configuration, not a capability.**
In **31 of 32 rollouts** the model returned a valid, parseable, degenerate answer
(27 of them in exactly 18 completion tokens; the full distribution is
`{18: 27, 19: 1, 24: 3, 4954: 1}`) —
`{"expected_profit": 0, "orders": []}` in 18 completion tokens, `finish_reason=stop`,
never entering a thinking block. The one rollout that did reason (4,954 completion tokens)
placed 4 real orders and scored 0.176 weighted, with `filled=1233` against `planned=2100`.
**The parser, the execution engine and the reward all work end to end** — the environment
is not broken. But `probe.py` rates the *crude* baseline here at 0.082, so 0.0055 sits
**below crude** and barely above inaction. Re-run with thinking forced on before this
number goes anywhere near `/evals`. **That re-run happened — see §5.** With thinking on the
same model scored **1.000 on every component** of the one rollout that finished inside its
token budget.
⚠️ **`drop-table-inference` 0.4174 is two populations, not one.** Twelve of its 31 scored
rollouts answered in 116174 tokens and averaged 0.0894; the other nineteen wrote
4,58511,034 tokens and averaged 0.6465. §5 has the distribution.
⚠️ **`fault-localisation` 0.9531 has two of four components pegged at maximum.**
`evidence` mean 1.000 (max 1.000) and `fault` mean 1.000 (max 1.000) across all 32
rollouts; only `gate` (0.9062) and `service` (0.9062) still move. Half the reward mass
no longer discriminates at this capability level. Nemotron scored 0.352 here on 19 August —
a 2.7× swing that needs separating into a stronger model, a prompting difference, or an
environment that has become too easy, before it appears on a scoreboard.
### The `gate` component carried no gradient in four of seven environments
Across all 32 rollouts each, `gate` scored **exactly 0.000, max 0.000**, in
`bot-detection` (weight 0.25), `grand-exchange` (0.25), `redaction-pressure` (0.30) and
`schema-migration` (0.25). It fired elsewhere — `canary-trap` 0.2188, `drop-table` 0.2258,
`fault-localisation` 0.9062.
This is **not** a house-rule-3 violation: `probe.py`'s oracle row is 1.000 for every one of
those environments, which requires `gate = 1`, so it is reachable. But a quarter to a third
of the reward mass produced no gradient in this run, and any ranking or before/after
training table over those four environments is really a ranking over their two soft
components. Understand that before training against them.
---
## 5. The run was measured with thinking OFF, and nothing in the run says so
Appended 2026-08-21, after re-measuring `grand-exchange`.
### The cause
The reference node's SGLang is started with a default that overrides the chat template:
```bash
curl -s "${SPARK_BASE_URL%/v1}/get_server_info" | jq '.default_chat_template_kwargs, .reasoning_parser'
# {"enable_thinking": false}
# "qwen3"
```
**Every one of the 224 episodes in `run-20260821-1401` was sampled with Qwen3's thinking
mode disabled**, and not one of them requested otherwise. Confirmed from the traces: no
assistant message in any of the seven environments carries a `reasoning_content` field, and
none contains a `<think>` block — 0 of 223 scored traces.
Nothing in the run's own artefacts records this. The resolved `config.toml` says
`sampling = {}`, and `ChatDialect.parse_sampling` keeps only an allow-list of known keys, so
even a run that *does* pass `chat_template_kwargs` writes a `calls[].sampling` that does not
mention it. `usage.reasoning_tokens` is null too: SGLang reports `reasoning_tokens` at the
top level of `usage`, while `Usage.from_openai` reads it out of `completion_tokens_details`.
**A trace in this repository cannot tell you whether the model was allowed to think.**
### Turning it on
`SamplingConfig` is `extra="allow"` and `ChatDialect.apply_overrides` merges the whole dump
into the request body, so an untyped key rides through to the provider untouched. There is
no CLI flag — `--sampling.*` is typed — so it goes in a config file:
```toml
[sampling]
chat_template_kwargs = { enable_thinking = true }
max_tokens = 16384
```
`outputs/thinking/grand_exchange_thinking.toml` is that file. **Verify it worked from the
trace, not from the flag**: the assistant message gains a `reasoning_content` field.
### What thinking on actually did to `grand-exchange`
**`outputs/thinking/ge-think-n6/`** — 6 tasks × 1 rollout, `-c 2`, `max_tokens = 16384`,
thinking on. All 6 traces carry `reasoning_content`, so the flag reached the model.
| tokens | `finish_reason` | content | weighted reward |
|---|---|---|---|
| 16384 | length | 0 chars | 0.000 |
| 16384 | length | 0 chars | 0.000 |
| 16384 | length | 0 chars | 0.000 |
| 16384 | length | 0 chars | 0.000 |
| 16384 | length | 0 chars | 0.000 |
| **8335** | **stop** | 1089 chars | **1.000** |
**Mean 0.1667** against 0.0055 with thinking off — a 30× move, and above `probe.py`'s crude
baseline of 0.082 for the first time. But read the column, not the mean: it is one perfect
run and five truncations.
The run that finished scored **1.000 on every component**`profit` 1.000, `discipline`
1.000, **`gate` 1.000** — with `realised` 40,081 gp against a `reference_realised` of
36,157, `committed` 0.994 of the purse and `conversion` 0.835. Four orders, a written
rationale, `orders_dropped` 0:
```json
{"expected_profit": 15000, "orders": [
{"item": "Chipped bone charm", "quantity": 400, "buy": 120, "sell": 134},
{"item": "Coarse fletching feather", "quantity": 700, "buy": 97, "sell": 114},
{"item": "Emberglass shard", "quantity": 80, "buy": 1420, "sell": 1640},
{"item": "Heart of the sunken cairn", "quantity": 2, "buy": 9500, "sell": 13500}]}
```
⚠️ **So the environment is not merely unbroken — it is fully solvable by this model, gate
included, and the thinking-off number measured none of that.** `arith_ok` was still 0.0 on
that rollout (`expected_profit` 15,000 against an implied 40,184, `arith_error` 0.627),
which is exactly the line the taskset docstring says it records for this reason.
The five truncations are not junk either. The reasoning is the analysis the environment asks
for, item by item — counting crossings, applying the 25%-of-volume cap, sizing against the
buy limit — it simply does not converge inside 16k:
```
**Heart of the sunken cairn**:
Buy ticks (<=10000): 9267, 8846, 9656, 9541, 9703, 8853, 9945 = 7 ticks
Volumes: 2, 2, 3, 6, 2, 3, 3 25%: 0.5, 0.5, 0.75, 1.5, 0.5, 0.75, 0.75
Total: 5.25 in 56 ticks. In 30 ticks: ~2.8. So I can only buy about 2-3 units!
```
A single rollout at `-c 1` (`outputs/thinking/smoke/`) does the same: 486 seconds,
`completion_tokens` 16384, `reasoning_content` 30,121 characters, `content` empty, reward
0.000. So does the same prompt sent straight at the reference node with `curl`, no verifiers in the
path — `reasoning_tokens` 16390, `finish_reason` length, content empty. **This is not a
harness bug, and it is not fixed by raising `max_tokens`** — see the next section for why.
⚠️ **`grand-exchange` 0.0055 was a sampling artefact and 0.1667 is a truncation artefact.**
Neither is the environment's number. The number is somewhere at or above 0.1667, taken with
a budget large enough that the model finishes, and this box cannot currently run that.
### ⚠️ The harness has a hard 600-second ceiling on one model call
`verifiers/v1/clients/base.py:11`:
```python
DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=600.0, write=600.0, pool=600.0)
```
`BaseClientConfig` exposes `base_url`, `api_key_var` and `headers` and nothing else. **There
is no config key and no CLI flag for this timeout.** At the reference node's ~34 tok/s single-stream it
caps one call at roughly 20,000 completion tokens, and far fewer under concurrency.
That is not theoretical. `outputs/thinking/ge-think-n8.log` is 8 tasks at `-c 8` with
`max_tokens = 32768`:
```
16:09:27 INFO rollout start: ... (×8)
16:19:30 WARNING model call failed: id=... ProviderError: (×8)
```
Eight failures, all at 16:19:30, exactly 603 seconds after the starts, with an empty
`ProviderError` message and `traces.jsonl` left at **zero lines**. `eval` still exited 0.
The `-c 1` smoke run survived at 486 seconds; the same work at `-c 8` did not.
`-c 2` is not safe either. `ge-think-n6.log` at `max_tokens = 16384` still logged **4
`ProviderError`s in 6 rollouts**, each exactly 601603 seconds after its rollout started —
`16:36:12 → 16:46:15`, `16:46:15 → 16:56:16`. Those rollouts survived only because the null
harness's program retries the call itself (tenacity), which is why their traces carry two
`calls` entries, the first with a null `finish_reason`. A retry that also lands at 16k
tokens costs another ten minutes and scores zero anyway.
**Raising `max_tokens` therefore does not fix the truncations.** The five rollouts that ran
out at 16,384 tokens want more budget; the wall says they cannot have it, because 16,384
tokens is already 486603 seconds of wall clock on this box. The two limits point in
opposite directions and there is no setting that satisfies both.
**And the thinking block cannot be bounded from the request either.** SGLang's
`thinking_budget` is accepted (HTTP 200) and ignored by this build: the same prompt with
`{"max_tokens": 8192, "thinking_budget": 3000}` came back with `reasoning_tokens` **8195**,
`finish_reason` length, content empty. The server also reports `enable_strict_thinking:
false`. The only lever is `max_tokens`, and `max_tokens` cuts the answer, not the reasoning.
Three consequences for anyone re-running this suite against a reasoning model:
1. **Keep `-c` low and `max_tokens` under the wall**, or budget the run knowing calls will be
cut at ten minutes. The reference node is shared — another lane's eval was running against it during
this measurement, and contention alone moves a rollout across the line.
2. **A timed-out run leaves no evidence.** Zero traces, exit code 0. `count_rewards.py`
reports on a file that does not exist. This is the same trap as §3 with a bigger blast
radius: check the log for `ProviderError` and check `wc -l traces.jsonl` before believing
any run.
3. ⚠️ **CORRECTED: the 600-second ceiling is the INSTALLED WHEEL, not upstream.** The
remedy stated here first — "the 600-second default gets a config key upstream" — is
wrong, and it is wrong in the expensive direction, because it reads as blocked on someone
else. Upstream **already removed it**: `verifiers/v1/clients/base.py:12` in
`~/vendor/prime-intellect/verifiers` reads `httpx.Timeout(connect=5.0, read=None, ...)`,
commit `a298bcfe fix(v1): restore the unbounded model-call timeout (#2304)`, 2026-08-08.
Every `environments/*/.venv` here pins `read=600.0` because it resolves verifiers 0.3.0.
**The fix is a dependency bump**, and an n=32 thinking-on run is achievable today. That
makes the "grand-exchange is solvable" claim — currently resting on six rollouts — cheap
to settle properly.
**RETIRED 2026-08-21 evening.** The bump happened — `verifiers==0.3.1`, §2 — and
every venv on this box now reads `read=None`. One correction to the paragraph above, which
matters because it is the version anyone will type: the fix is **not** `verifiers==0.4.0`.
There is no such release. `a298bcfe` is `v0.3.0-8-ga298bcfe`; the vendored HEAD is
`v0.3.0-59-g4bcb48e5`; the wheel that carries them is `0.3.1`. Everything below this
line about 600 seconds describes the old pin and is kept as the record of why the bump was
worth doing, not as current behaviour. The `max_tokens` / wall-clock contradiction it
describes — "the two limits point in opposite directions and there is no setting that
satisfies both" — no longer holds: with `read=None` the only limit left is `max_tokens`,
and §6 spends it.
### Did the other six collapse too?
Only one of them, and only partly. Completion tokens per trace, and the mean weighted
reward in each half of the length distribution:
| environment | min | p25 | median | p75 | max | shape |
|---|---|---|---|---|---|---|
| `grand-exchange` | 18 | 18 | 18 | 18 | 4954 | **collapsed**; distribution `{18: 27, 19: 1, 24: 3, 4954: 1}` — 27/32 at 18 tokens, and **31/32 planned no orders at all** |
| `drop-table-inference` | 0 | 159 | 5347 | 7205 | 11034 | **bimodal** |
| `fault-localisation` | 34 | 37 | 37 | 39 | 191 | short by design |
| `schema-migration` | 105 | 145 | 168 | 271 | 1687 | unimodal |
| `redaction-pressure` | 153 | 240 | 310 | 369 | 2014 | unimodal |
| `canary-trap` | 56 | 139 | 148 | 626 | 3128 | unimodal |
| `bot-detection` | 1156 | 2508 | 3457 | 4603 | 8146 | unimodal |
⚠️ **`drop-table-inference` 0.4174 is a mixture of two regimes, not a capability estimate.**
Twelve of its 31 scored rollouts answered in 116174 tokens and averaged **0.0894**; the
other nineteen wrote 4,58511,034 tokens and averaged **0.6465**. The same prompt, the same
sampling settings, a 7× gap in outcome depending on whether the model chose to work. Report
it with that split or not at all.
The other five are unimodal and their reward does not track length — `fault-localisation`
answers three fields in 37 tokens and scores 0.9531, `canary-trap`'s shortest half scores
0.6141 against its longest half's 0.6437. Those five numbers stand as measured. They are
still **thinking-off numbers**, which is a property of the run nobody chose and nothing
recorded.
### What to do before the next run
- Put the sampling configuration in the run's own name or its notes. A number that changes
by two orders of magnitude with one server-side default is not a capability score unless
the configuration travels with it.
- Ask the reference node what it defaults to (`/get_server_info`) rather than assuming a model's own
default applies.
- If `reasoning_tokens` matters to you, read it off the provider — the trace drops it.
---
## 6. The thinking-ON re-measurement, n=32
Run 2026-08-21 evening, after the `verifiers==0.3.1` bump of §2. This section
exists because two of §4's seven numbers were artefacts of a sampling configuration
nobody chose and nothing recorded, and because §5's remedy — "an n=32 thinking-on run is
achievable today" — was owed a run.
> **Sampling configuration for every number in this section.** `verifiers==0.3.1`
> (`read=None`); `chat_template_kwargs = {enable_thinking = true}`; `max_tokens = 49152`;
> `temperature`, `top_p`, `reasoning_effort` unset; `-c 8`; harness `null`; agent runtime
> `subprocess`; model `brain-qwen38-dspark` on the reference node. The configs are
> `outputs/thinking-n32/*.toml`, the sweep is `outputs/thinking-n32/run_all.sh`, the runs
> are `outputs/thinking-n32/run/<env>/`.
**Thinking is verified from the traces, not from the flag.** `traces_with_reasoning` is
**32 of 32** for `grand-exchange`, mean 56,560 characters of `reasoning_content` per
trace, against 0 of 224 for `run-20260821-1401`.
### `grand-exchange` — 0.0055 → **0.4338**
| | thinking off (§4) | 6 rollouts, 16k (§5) | **thinking on, n=32** |
|---|---|---|---|
| `reward_mean_attempted` | 0.0055 | 0.1667 | **0.4338** |
| traces scored | 32/32 | 6/6 | **32/32** |
| traces with reasoning | 0/32 | 6/6 | **32/32** |
| `finish_reason=length` | 0 | 5/6 | **2/32** |
| median completion tokens | 18 | 16,384 | **29,376** |
| `gate` mean (max) | 0.0000 (0.000) | — | **0.2188 (1.000)** |
`probe.py` rates the crude maximiser at 0.082 and the plausible strategy at 0.608. **0.4338
sits between them**, which is the first time this environment has produced a number that
means anything: 0.0055 was below crude, and 0.1667 was one perfect run and five
truncations.
Wall clock 18:28 → 20:33, 2 h 5 min, 965,217 completion tokens for 32 rollouts. Zero
`ProviderError`s. **Under the old pin every one of these rollouts would have died**: the
median call ran ~20 minutes against a 600-second ceiling, and the median completion is
29,376 tokens against the 16,384 that section 5 could afford.
The distribution is properly graded — not the two-spike shape thinking-off produced:
```
total gate profit disc tokens finish
0.0 0.0 0.0 0.0 20305 stop <- 7 genuine zeros: finished, planned nothing usable
...
0.0 0.0 0.0 0.0 49152 length <- 2 truncations, both scored 0
0.102 0.0 0.203 0.038 38913 stop
0.162 0.0 0.302 0.087 30042 stop
0.413 0.0 0.653 0.397 37978 stop
0.56 0.0 0.746 0.746 22000 stop
0.683 0.0 0.91 0.91 20886 stop
0.747 0.0 0.996 0.996 32545 stop
0.993 1.0 1.0 0.977 23531 stop
1.0 1.0 1.0 1.0 20579 stop <- 6 perfect rollouts
```
Nine zeros, six 1.000s, seventeen strictly between. Median 0.4165. Dropping the two
truncations gives 0.4627 over the thirty that finished — quote 0.4338 and say why, rather
than quoting the higher number.
⚠️ **The dead `gate` in `grand-exchange` was partly a sampling artefact.** `docs/GATE_DIAGNOSIS.md`
records it at exactly 0.000, max 0.000, across all 32 thinking-off rollouts. With thinking
on it fires on **7 of 32**, mean 0.2188, max 1.000. That does not repair the shape — it is
still exact equality, still all-or-nothing, and it still only opens for a rollout that is
already at `profit` ≈ 1.0, so it adds no gradient anywhere below the top. But the
diagnosis's evidence for *this* environment was measured under a configuration in which
the model answered in 18 tokens, and the three other dead gates were measured under the
same one. **Re-read GATE_DIAGNOSIS against this column before acting on it.**
### `drop-table-inference` — thinking on does not fit in 49,152 tokens
The question §5 left was whether thinking on collapses this environment's two regimes
(twelve short rollouts at 0.0894, nineteen long ones at 0.6465). **It does not collapse
them; it pushes the whole distribution off the end of the token budget.** The first eight
rollouts of the n=32 run, all eight carrying `reasoning_content`:
```
weighted fit gate rare tokens finish
0.4082 0.692 0.0 0.277 31649 stop
0.4082 0.692 0.0 0.277 33803 stop
0.0000 0.0 0.0 0.0 49152 length
0.0000 0.0 0.0 0.0 49152 length
0.0000 0.0 0.0 0.0 49152 length
0.0000 0.0 0.0 0.0 49152 length
0.0000 0.0 0.0 0.0 49152 length
0.0000 0.0 0.0 0.0 49152 length
```
**Six of eight hit `finish_reason=length` at 49,152 completion tokens** — three times the
budget §5 could afford, on a model whose reasoning here runs 7486 thousand characters.
The two that finished scored 0.408 each, above §4's short regime (0.0894) and below its
long one (0.6465).
⚠️ **Whatever mean this run finishes with is a truncation artefact, exactly like
`grand-exchange`'s 0.1667 was.** Do not publish it as a capability score, and do not read
it as evidence about the mixture. The comparison `grand-exchange` earned — a distribution
where 30 of 32 finish — this environment has not: it needs a larger `max_tokens` (the reference node's
context is 262,144, so there is room) and a correspondingly larger wall-clock budget. One
49,152-token rollout costs roughly 25 minutes at `-c 8` on this box.
### What is still running, and how to pick it up
`outputs/thinking-n32/run_all.sh` was launched detached (`setsid`) and works down the
priority order in §6: grand-exchange (done), drop-table-inference, bot-detection,
redaction-pressure, schema-migration, canary-trap, fault-localisation. It holds a `flock`
on `outputs/thinking-n32/.lock`, so a second launch refuses rather than writing two evals
into one `traces.jsonl`.
```bash
tail -f outputs/thinking-n32/sweep.log # stage transitions + per-rollout
grep 'exit=' outputs/thinking-n32/sweep.log # which stages finished, with counts
uv run --project environments/<pkg> python outputs/summarise_run.py \
outputs/thinking-n32/run/<env> # the numbers, with the sampling config
pkill -f 'thinking-n32/run_all.sh' # stop it after the current stage,
pkill -f 'bin/eval @ outputs/thinking-n32' # then kill the eval in flight
```
⚠️ **Read every stage's `finish_reasons` before its mean.** `length` in that histogram
means the model was cut off mid-answer, and a cut-off answer scores what an empty one
scores. A stage whose histogram is mostly `length` has measured `max_tokens`, not the
model — that is the single mistake this section exists to stop repeating.
### Every number here is comparable only to the thinking-on column
`grand-exchange` moves by 79× between the two sampling configurations. Nothing in §4 and
nothing in §6 may be tabulated beside the other without the configuration attached, and no
number from either belongs on `/evals` or in `eval-results.json` without it. That is the
whole lesson of the last two phases, stated as a rule:
**A reward mean is not a number. It is a number plus a sampling configuration, and the
pair travels or neither does.**
+364
View File
@@ -0,0 +1,364 @@
# Why `gate` scored zero in four environments
Written 2026-08-21, against `outputs/run-20260821-1401` — seven environments, 32 rollouts
each, `brain-qwen38-dspark`.
In four of the seven, the `gate` reward was **0.000 mean and 0.000 max** across all 32
rollouts: `bot-detection` (weight 0.25), `grand-exchange` (0.25), `redaction-pressure`
(0.30) and `schema-migration` (0.25). It moved in the other three — `canary-trap` 0.2188,
`drop-table-inference` 0.2258, `fault-localisation` 0.9062.
`probe.py` prints 1.000 for every one of those four, and 1.000 requires `gate = 1`, so
nothing here is unreachable and house rule 3 is not violated. The question this file
answers is the one after that: **is the gate (a) a threshold set beyond what a real model
reaches, (b) a parse or format precondition the model never satisfies, or (c) genuinely
hard?** They have different consequences and they need different fixes.
---
## The answer, in one table
`outputs/gate_probe.py` runs each environment's shipped scorer over the probe ladder and
prints the gate **on its own**, as a pass rate over 24 tasks (12 for grand-exchange).
`probe.py` blends the gate into one number per rung, which is exactly why this was
invisible for a month.
```
#### redaction-pressure
inaction gate 0/24 = 0.000
crude gate 0/24 = 0.000
plausible gate 0/24 = 0.000
six-of-seven gate 0/24 = 0.000
oracle gate 24/24 = 1.000
#### schema-migration
inaction gate 0/24 = 0.000
crude gate 0/24 = 0.000
plausible gate 0/24 = 0.000
oracle gate 24/24 = 1.000
#### bot-detection
inaction gate 0/24 = 0.000
crude gate 0/24 = 0.000
oracle-minus-one gate 0/24 = 0.000
oracle gate 24/24 = 1.000
#### grand-exchange
inaction gate 0/12 = 0.000
oracle gate 12/12 = 1.000
```
**In three of the four the gate is a step function whose only step is the oracle.** Six of
redaction's seven rules scores zero. The oracle bot list minus a single account scores
zero. There is no rung anywhere on the ladder — inaction, crude, plausible, near-oracle —
that earns a fraction of it.
The three that fired are not perfection-free; they are perfection over a *small* object.
`canary-trap`'s gate wants every contaminated state caught and no probe firing on the clean
model, over 212 probes. `fault-localisation`'s wants service, fault and evidence all
correct — three fields. `drop-table-inference` is the only gate in the repository with a
**margin**: `GATE_MARGIN = 0.05`, three sub-scores each within 5% of the reference, and it
fired on 22.6% of rollouts. `grand-exchange` has one too — `TARGET_SHARE = 0.90` — and its
zero has a different cause entirely (below).
So the verdict is **(a) for three environments and neither for the fourth**. It is not (b)
anywhere: parsing works. `bot-detection` recorded `unknown_ids` 0.000 on all 32 rollouts,
`redaction-pressure` `validity` 1.0 on 30 of 32, `schema-migration` produced a valid target
schema on 25 of 32. The models are submitting well-formed work and being scored zero on it
because the work is not perfect.
---
## Per environment
### `bot-detection` — (a), and the binding clause is `detection == 1.0`
```python
# scan.py
@property
def gate(self) -> bool:
return (
self.reference_caught > 0
and self.true_positives >= self.reference_caught
and self.false_positives == 0
)
```
Over 32 rollouts:
| clause | satisfied |
|---|---|
| `false_positives == 0` | **24 / 32** |
| `true_positives >= reference_caught` | **0 / 32** |
`detection` — true positives over what the reference caught — had mean 0.5135 and **max
0.8333**. It never reached 1.0 once. The restraint clause is doing its job and is not the
problem; the catch-everything clause is a ceiling test.
The environment's own docstring explains why it has to be: each cloaked account leaks on
exactly one of three behavioural channels, so "a discriminator built on any single channel
ceilings at roughly a third of them". The reference reads all three. The gate therefore
pays only a model that found all three tells, in a batch where the reference caught 46
bots and the model accused 2.9 on average.
**Consequence.** 0.25 of bot-detection's reward mass is a constant. The measured 0.3488 is
entirely `caught` (0.35) and `spared` (0.40), and against a maximum of 0.75 it is 0.465 of
what was actually on offer.
### `redaction-pressure` — (a), and the binding clause is total recall
```python
# scan.py
@property
def clean(self) -> bool:
return (
self.removed_secrets == self.secrets_total
and self.partial_secrets == 0
and self.collateral_hits == 0
and self.innocent_lost == 0
and self.secrets_total > 0
and self.validity == 1.0
)
```
Six clauses, and they are satisfied at wildly different rates:
| clause | satisfied |
|---|---|
| `partial_secrets == 0` | 28 / 32 |
| `validity == 1.0` | 30 / 32 |
| `collateral_hits == 0` | 6 / 32 |
| `innocent_lost == 0` | 6 / 32 |
| `residual_hits == 0` (every secret removed) | **0 / 32** |
`recall` had max **0.852** across 32 rollouts and `residual_hits` a **minimum of 4** — there
was always at least one secret left standing. Two clauses never fired together either:
`innocent_lost` averaged 140 characters.
This is the widest gate in the repository — six simultaneous conditions, three of them
counting to zero over a twelve-document held-out slice — and the ladder shows six of the
oracle's seven rules do not clear it.
**Consequence.** 0.30 of the mass — the largest gate weight of the four — is a constant.
0.4040 against a real maximum of 0.70 is 0.577 of what was on offer.
### `schema-migration` — (a), and the binding clause is per-row fidelity
```python
# run.py
@property
def clean(self) -> bool:
return (self.schema_ok and self.graded > 0
and self.matched == self.graded
and self.rows_after == self.rows_before)
```
| clause | satisfied |
|---|---|
| `schema_ok` | 25 / 32 |
| `rows_after == rows_before` | 25 / 32 |
| `matched == graded` (fidelity 1.0) | **0 / 32** |
`fidelity` distribution over the 32 rollouts:
```
0.0 ×10, 0.10, 0.15, 0.375, 0.40, 0.575, 0.65, 0.675, 0.725, 0.725, 0.75,
0.775 ×4, 0.80 ×4, 0.85 ×3, 0.875
```
Max 0.875. Forty awkward held-out rows must every one recompose; the best migration
anybody wrote missed five of them. The docstring is explicit that this is `forge verify`'s
exit code — a migration is correct or it is not — and as a shipping check that is right.
As a *reward component* it is a constant at this capability level.
### `grand-exchange` — not a gate defect. Nothing was measured
```python
# book.py — a bar, not a knife-edge
return (self.fills.realised >= TARGET_SHARE * self.reference.realised
and self.fills.roc >= TARGET_SHARE * self.reference.roc)
```
`TARGET_SHARE = 0.90`. This gate already has the margin the other three lack, and it is
written that way deliberately — the commit message is in the docstring ("demanding the
reference's exact result made a wasted unit of capital cost 0.146 with byte-identical
profit").
It scored zero because **31 of 32 rollouts placed no orders at all**: 18 completion tokens,
`{"expected_profit": 0, "orders": []}`, `finish_reason=stop`. `planned` had mean 65.6 and
median 0 — one rollout in 32 planned anything at all; `profit_ratio` maxed at 0.323, so even
that one reached 29% of the reference. Nothing in this environment was measured. See
`FIRST_EVAL.md` §5 — the reference node serves with
`default_chat_template_kwargs = {"enable_thinking": false}`, and this environment is the one
that cannot be answered without arithmetic.
**Re-measured with thinking on, this gate fires.** `outputs/thinking/ge-think-n6/`, 6
rollouts: one of them finished inside its token budget and scored `profit` 1.000,
`discipline` 1.000 and **`gate` 1.000**, realising 40,081 gp against a reference of 36,157.
The other five were cut off mid-reasoning at `max_tokens`. So this gate is not merely
oracle-reachable in a probe — it is reachable by the model under evaluation, in a real
rollout, at a 90% bar.
**Do not touch this gate.** It is the one that works. It is also the shape the other three
should be argued against: a band at 0.90 of the reference, cleared by a run that beat the
reference by its own route rather than by copying it.
---
## What follows
1. **Every ranking over those four environments is a ranking over their soft components.**
Report `bot-detection` against 0.75, `redaction-pressure` against 0.70,
`schema-migration` against 0.75 — or say plainly that a quarter to a third of the scale
is unreachable. A leaderboard that prints 0.4040 out of 1.000 for redaction-pressure is
overstating the headroom by a factor it never names.
2. **The gradient problem is worse than the scale problem.** A constant term contributes
nothing to a policy gradient. Training against these four trains against
`caught`/`spared`, `recall`/`precision`, `schema`/`integrity` — and the gate only starts
paying once the model is already essentially perfect, which is the point at which it
stops needing the signal. This is not what a gate is for.
3. **The repository already contains the fix and applies it in exactly one place.**
`drop-table-inference` gates on three sub-scores each within `GATE_MARGIN = 0.05` of the
reference and fires 22.6% of the time; `grand-exchange` gates at `TARGET_SHARE = 0.90`.
The three dead gates are the three written as exact equality. A margin on each — every
secret bar one, 39 rows of 40, the reference's bots less one — would restore a gradient
without paying for inaction, because the ladder above shows `inaction` and `crude` score
zero on every one of these with or without a margin.
4. **Whatever is decided, `probe.py` should print the gate separately.** Four dead
components hid for a month behind a blended `oracle 1.000`. `outputs/gate_probe.py` is
the throwaway version of that check; the real one belongs next to the ladder, and it is
a one-line consequence of house rule 3 that a component nothing below the oracle can
earn is a component that does not discriminate.
⚠️ **Superseded by the addendum below.** As first written this file changed nothing in any
environment — four packages' reward code was out of that lane, and it was a diagnosis and a
recommendation rather than a patch. Two of the four have since been patched and one has been
measured and deliberately left alone. Read the addendum before acting on §3 above: its
recommendation "a margin on each" turned out to be right about two of the three and wrong
about `redaction-pressure`.
---
## Addendum, same day: what was actually changed
⚠️ The line above — "Nothing in this file has been changed in any environment" — no longer
holds. Two of the four gates were given margins and one was deliberately not. Everything
below was measured, not argued, and the measurements are reproducible from the repository
without touching a model:
```bash
uv run --with regex python tools/regate.py # replay the 32 real rollouts per env
uv run --with regex python tools/regate_ladder.py # the same predicates over the ladder
uv run --with regex python probe.py # floors and ceilings, per component
```
`tools/regate.py` rebuilds each task from the seed in its trace, re-runs the shipped scorer
over the model's own reply, and asks a candidate predicate directly. That is a better
measurement than a fresh sample would have been: it holds the model's behaviour fixed and
varies only the reward, and it costs nothing on the reference node. Its `recorded_mean` reproduces
0.3488, 0.4432 and 0.4040 exactly, which is the cross-check that it is replaying the same
population `eval.log` scored.
### `bot-detection` — fixed. `GATE_SLACK = 1`
The reference's bots bar one, and still not one person.
| gate | fires on 32 real rollouts |
|---|---|
| exact (as shipped) | 0/32 = 0.000 |
| **slack 1** | **5/32 = 0.156** |
| slack 2 | 15/32 = 0.469 |
| share 0.90 of the reference | 0/32 = 0.000 |
Slack 2 is what the ladder rules out. At slack 1 the oracle's list minus one account clears
the gate and minus two does not, so it still separates a near miss from a half-right answer;
at slack 2 both clear and it separates nothing. `inaction` and `crude` score 0.000 at every
slack, and `probe.py` asserts all three of those facts rather than reporting them.
`max(1, reference_caught - GATE_SLACK)` is load-bearing: without it a batch where the
reference catches exactly one bot would let an **empty** accusation list clear the gate.
Observed `reference_caught` is 46, so nothing would have caught that in a sample.
The restraint clause was not given a margin and should not be. A single wrongful ban still
shuts the gate outright, because that asymmetry is the environment.
### `schema-migration` — fixed. `GATE_MARGIN = 0.15`
Thirty-four of forty rows recompose. `schema_ok` and the row count stay exact.
| gate | fires on 32 real rollouts | `plausible` on the ladder |
|---|---|---|
| exact (as shipped) | 0/32 = 0.000 | 0.000 |
| margin 0.05 | 0/32 = 0.000 | 0.000 |
| margin 0.10 | 0/32 = 0.000 | 0.000 |
| **margin 0.15** | **4/32 = 0.125** | 0.042 |
| margin 0.20 | 8/32 = 0.250 | 0.292 |
0.20 is out: it pays the naive split — the parser fitted to the five tidy rows on screen,
which is precisely what this environment exists to punish — on 29% of seeds. 0.15 pays it on
one seed in twenty-four, because its luckiest draw of forty awkward rows recomposes exactly
34 of them and 34/40 is the threshold to the digit. That knife edge is named in the source
and `probe.py` asserts it stays under a tenth.
What 0.15 buys is the rung above it. A migration that strips the thousands separator — the
inference this environment is about — and still mishandles the row with no unit at all goes
from clearing the gate on 8% of seeds to clearing it on 96%.
The two format clauses keep no margin. `wipe` — the oracle followed by `DELETE FROM
readings`, which recomposes perfectly over nothing — is now a probe rung asserted at 0.000,
so the hole the row clause was added to close cannot quietly reopen.
### `redaction-pressure` — NOT fixed, and that is the finding
| gate | fires on 32 real rollouts | `plausible` (4 of 7 rules) |
|---|---|---|
| exact (as shipped) | 0/32 = 0.000 | 0.000 |
| recall margin 0.10, collateral 0, innocent 0 | 0/32 = 0.000 | 0.000 |
| recall margin 0.20, collateral 1, innocent 32 | 0/32 = 0.000 | 0.000 |
| recall margin 0.50, collateral 2, innocent 160 | 3/32 = 0.094 | **0.833** |
There is no setting that both fires on the measured population and keeps `plausible` at
zero. Everything loose enough to fire pays a ruleset that never found three of the seven
secret types, on five seeds in six; everything tight enough to hold that at zero fires on
none of the 32.
The reason is that the failure is **joint**, not a threshold. Recall maxed at 0.852 with a
minimum of four secrets left standing; `collateral_hits == 0` held on 6 of 32; and no
rollout managed high recall and zero collateral in the same episode. So the verdict for this
one is **(c) genuinely hard**, not (a) mis-thresholded — which is a different answer from
the one this file gave in §3 above, and it is the answer the measurement gives.
Half the secrets left in the document is not a near miss. The fix, when it comes, is a
better model or an easier corpus, not a looser gate. The sweep is recorded in
`Outcome.clean`'s docstring so it is not redone from scratch.
### `grand-exchange` — untouched, as §3 said
Its 0.0055 measured a sampling config and not a gate. Left alone.
## And the blindness that let all four hide
`probe.py` returned one blended float per rung. It now returns the **weighted components**,
prints each one's floor, its best rung below the oracle and its ceiling, and draws two lines:
- **flat** — the same value on every rung including the oracle — is fatal. That is a constant
added to every policy's score and there is no legitimate reward of that shape.
- **step@oracle** — nothing below the oracle earns a fraction — warns and names the
component.
The second is deliberately not fatal, and the reason is the whole difficulty of this file: it
is the shape of a dead gate AND the shape of an honest binary check, and the two are not
distinguishable from a ladder. Making it fatal would forbid every binary gate; making it
silent is what cost a month. So it is an instruction — go and replay the traces — and
`tests/test_probe.py::STEP_AT_ORACLE` is the allowlist that keeps the instruction from
becoming wallpaper. Two names are on it, `redaction-pressure/gate` (0/32, hard) and
`fault-localisation/gate` (29/32, healthy — the ladder just has no two-of-three rung). It
only ever shrinks, and a name may only leave it alongside the measurement that justifies it.
Four environments also gained a `near-oracle` rung, which is what makes the check say
anything: a gate carrying a real band now demonstrates it — `grand-exchange`'s 0.90 bar reads
0.244 of 0.250 one unit below the reference, and `bot-detection`'s new slack reads the full
0.250 at the oracle's list minus one.
+24
View File
@@ -0,0 +1,24 @@
# Prime-first boundary
Arena publishes Lumbridge environments; it does not implement another generic
harness, trainer, runtime marketplace, or environment hub.
- Every environment pins stable `verifiers==0.3.1`.
- Pure-trace environments select Verifiers' upstream `null` harness.
- Local model-free and the reference node runs name the `subprocess` runtime explicitly so
tests never provision a paid Prime sandbox.
- Prime Hub is the public distribution target for approved environment wheels.
- Prime-RL consumes the same taskset packages for training; Forge owns the
explicit training handoff and artifact rendering.
Lumbridge's unique work is the environment: task distributions, interactive
state, rewards, probes, held-out gates, and result interpretation. Tera bridges
are developed with Tera because they package proprietary simulator source; they
use the same upstream Verifiers contract without living in this public repo.
Generic rollout and training machinery stays upstream. A Hub publication must
exclude secrets, private task splits, customer data, and proprietary Tera source.
The preferred future compute path is Prime's spot market. GH200/GB300 names in
runbooks are desired hardware profiles, not a promise that inventory exists and
not authorization to rent it. An operator checks current Prime availability and
price before launching any paid run.
+21
View File
@@ -0,0 +1,21 @@
# Tera source boundary
Arena is public Apache-2.0 environment work. Tera is Lumbridge's proprietary
world simulator and is developed in the private `lumbridge-private/tera`
repository.
The `tera_spatial` bridge and `tera-crow-nav` taskset were previously committed
to Arena. Those historical revisions remain available under the license granted
when they were published; deleting current files does not revoke that grant or
rewrite git history. As of 2026-08-25, maintained bridge code, vendored simulator
closures, Tera tasksets, and new Tera releases live only with private Tera.
The boundary is about source ownership, not rebuilding upstream machinery:
- Arena and private Tera both depend on Prime Intellect Verifiers for the
environment/harness contract.
- Forge delegates training to Prime-RL across a subprocess boundary.
- No Lumbridge repository should implement its own generic rollout harness,
trainer, environment hub, or GPU marketplace.
- Publishing an Arena environment to Prime Hub is a separate owner-approved
action. Nothing in this repository publishes automatically.
@@ -0,0 +1,3 @@
from bot_detection.taskset import BotTaskset
__all__ = ["BotTaskset"]
@@ -0,0 +1,367 @@
"""Generating one batch of accounts: a cover drawn before anybody knows who is a bot.
The naive discriminator for this task is "low timing variance means bot". An environment
that only generates metronomic scripts teaches that rule and nothing else, so the cover
statistics — how fast an account clicks, how long it plays, how far it wanders — are drawn
BEFORE the population is assigned, and the assignment is a uniform coin the style never
sees. That ordering is the whole design. The first version of this file drew the cover per
population instead, and gave a grinder `sigma ~ U(0.30, 0.45)` where a cloaked bot got
`U(0.65, 0.90)`; the two ranges do not touch, so `sd(log click_dt_ms)` cut cleanly at 0.52
and a two-line rule that never opened `misclick_next`, `break_s` or `login_gap_min` scored
0.953 against the oracle's 1.000. Session length and route count were worse than that: they
were EXACT labels — every account with four or more routes was a casual human, all 7200 of
them — so half the humans in every batch were free and the false positive the environment
is built around cost nothing to avoid.
Drawing the style first makes P(bot | any statistic of the style) exactly the batch's bot
rate, for every statistic, with no distributional argument required and nothing to drift.
There are three populations:
human a person. Long-tailed click latency, breaks that cluster into micro-pauses and
long absences, corrects most misclicks, logs in when they are free.
scripted a naive bot. Near-constant delays, no breaks at all, one route, never corrects
anything. Caught by everything, and meant to be — it is the floor of the task,
not the task. A click-variance rule finds these and nothing else, which is
worth about a quarter of the ceiling.
cloaked a sophisticated bot wearing a style drawn from the same pool a human's came
from. It leaks on exactly ONE of three behavioural channels, chosen per
account:
uncorrected it never corrects a misclick. A person does.
memoryless its breaks are exponential, so they land in the 3-8 minute dead
band that a human's bimodal breaks never occupy.
metronomic it logs in on a fixed period, so every gap between session
starts is the same to within a couple of minutes.
Exactly one leak per cloaked account is the difficulty: a discriminator built on any single
channel catches a third of them, and the labelled slice shows one of each so the three are
learnable rather than guessable.
Every separation is guaranteed at generation time rather than hoped for — the resample
loops below are why — and so is every NON-separation, which is what the style-first
ordering buys. A ceiling that is unreachable on one seed in two hundred is still an
unreachable ceiling, and house rule 3 is measured, not assumed.
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass
CLICKS = 32
BREAKS = 12
SESSIONS = 4
MISCLICKS = 8
TRAVERSALS = 20
# Seconds. Human breaks are bimodal — a micro-pause or a real absence — so nothing of
# theirs lands in here. An exponential break generator fills it, which is the whole tell.
DEAD_BAND = (180, 480)
BOT_POPULATIONS = ("scripted", "cloaked")
TELLS = ("uncorrected", "memoryless", "metronomic")
@dataclass(frozen=True)
class Style:
"""The cover an account wears, drawn before it is decided whether it is a person.
`efficient` is the corner every naive flagger points at: tight fast clicking, one or
two memorised routes, sessions measured in hours. Half the styles in a batch are drawn
there and the population coin does not know which, so an efficient account is exactly
as likely to be a bot as a rambling one. The two corners OVERLAP on all three
statistics as well, so there is not even a clean latent to recover.
"""
efficient: bool
click_median: int
click_sigma: float
session_min: list[int]
routes: int
@dataclass(frozen=True)
class Account:
"""One account's activity log. Every field is raw per-event data rather than a summary,
because the summary IS the answer — handing over a variance or a correction rate would
leave nothing to find."""
acc_id: str
population: str
tell: str
"""Which channel a cloaked account leaks on; empty for everyone else."""
style: str
"""`efficient`, `casual`, or `constant` for a naive script. Never rendered — it is here
so the probe can assert it carries no information about `is_bot`."""
click_dt_ms: list[int]
break_s: list[int]
session_min: list[int]
login_gap_min: list[int]
"""Minutes between consecutive session starts."""
misclick_next: list[str]
"""What the account did after each misclick: `fix` or `ignore`."""
route_ids: list[str]
@property
def is_bot(self) -> bool:
return self.population in BOT_POPULATIONS
def _style(rng: random.Random, efficient: bool) -> Style:
"""One cover. The ranges deliberately overlap between the two corners — 520-620 ms of
median, 0.50-0.60 of sigma, 210-260 minutes of session, two or three routes — so that
even the corner itself is not cleanly recoverable, let alone the population behind it."""
if efficient:
return Style(
True,
rng.randint(360, 620),
rng.uniform(0.28, 0.60),
[rng.randint(210, 520) for _ in range(SESSIONS)],
rng.randint(1, 3),
)
return Style(
False,
rng.randint(520, 1000),
rng.uniform(0.50, 0.95),
[rng.randint(45, 260) for _ in range(SESSIONS)],
rng.randint(2, 9),
)
def _lognormal_clicks(rng: random.Random, style: Style) -> list[int]:
"""Human reaction time is lognormal: a hard floor, a dense bulk, and a long right tail
of hesitations. Humans and cloaked bots draw from this with the SAME parameters, drawn
from the same pool, so neither the tail nor its absence is a tell."""
return [
max(40, int(style.click_median * math.exp(rng.gauss(0.0, style.click_sigma))))
for _ in range(CLICKS)
]
def _clustered_breaks(rng: random.Random, n: int) -> list[int]:
"""Either a sip of coffee or a trip out of the house. Nothing in between, and the gap
between the two modes is what an exponential generator cannot reproduce."""
return [
rng.randint(25, 140) if rng.random() < 0.6 else rng.randint(600, 2700)
for _ in range(n)
]
def _memoryless_breaks(rng: random.Random, n: int) -> list[int]:
"""Exponential breaks: a five-minute pause is as likely as any other, which is exactly
what a human never does. Resampled until at least two land in the dead band, so the
tell is present in every log rather than in most of them."""
while True:
out = [max(15, int(rng.expovariate(1 / 300.0))) for _ in range(n)]
if sum(1 for b in out if DEAD_BAND[0] <= b < DEAD_BAND[1]) >= 2:
return out
def _irregular_gaps(rng: random.Random, n: int) -> list[int]:
"""A person logs in when they are free. Resampled for a spread of at least two hours so
that a human is never mistaken for a cron job."""
while True:
out = [rng.randint(240, 2400) for _ in range(n)]
if max(out) - min(out) >= 120:
return out
def _metronomic_gaps(rng: random.Random, n: int) -> list[int]:
"""A fixed period with a couple of minutes of slop — a scheduler, not a schedule."""
period = rng.randrange(300, 481, 15)
return [period + rng.randint(-2, 2) for _ in range(n)]
def _human_misclicks(rng: random.Random, n: int) -> list[str]:
"""A person who fat-fingers a target notices and puts it right. Resampled to at least
five corrections in eight so the rate is unambiguously above any bot's zero."""
while True:
out = ["fix" if rng.random() < 0.8 else "ignore" for _ in range(n)]
if out.count("fix") >= 5:
return out
def _never_corrects(n: int) -> list[str]:
"""A script has no idea it missed. It has no model of what it meant to click."""
return ["ignore"] * n
def _routes(rng: random.Random, distinct: int, n: int) -> list[str]:
"""Resampled until all `distinct` routes actually appear, so the rendered log shows the
variety the style was supposed to have."""
pool = [f"R{i + 1}" for i in range(distinct)]
while True:
out = [rng.choice(pool) for _ in range(n)]
if len(set(out)) == distinct:
return out
def _wearing(rng: random.Random, acc_id: str, style: Style, tell: str) -> Account:
"""A human (`tell` empty) or a cloaked bot, built from a style that was drawn before
this call knew which it was making.
Every channel not named by `tell` goes through the identical generator with identical
parameters, so a cloaked account and a person differ on exactly one column of the log
and are the same distribution on the other five. That is what stops any statistic of
the cover — click variance, session length, route count, or any function of them —
from carrying information the reward would then pay for.
"""
return Account(
acc_id,
"cloaked" if tell else "human",
tell,
"efficient" if style.efficient else "casual",
_lognormal_clicks(rng, style),
_memoryless_breaks(rng, BREAKS) if tell == "memoryless" else _clustered_breaks(rng, BREAKS),
list(style.session_min),
_metronomic_gaps(rng, SESSIONS - 1) if tell == "metronomic" else _irregular_gaps(rng, SESSIONS - 1),
_never_corrects(MISCLICKS) if tell == "uncorrected" else _human_misclicks(rng, MISCLICKS),
_routes(rng, style.routes, TRAVERSALS),
)
def _scripted(rng: random.Random, acc_id: str) -> Account:
"""The naive bot, and the only account whose cover is its own population. It leaks on
all three behavioural channels at once AND on click variance, so a timing rule finds
it; that is the point of keeping it. It is roughly a fifth of the batch, so a timing
rule that finds nothing else ceilings around a quarter of the reward."""
base = rng.randint(300, 450)
return Account(
acc_id, "scripted", "constant", "constant",
[base + rng.randint(-12, 12) for _ in range(CLICKS)],
[], # it does not stop, so there is nothing to log
[rng.randint(900, 1400) for _ in range(SESSIONS)],
_metronomic_gaps(rng, SESSIONS - 1),
_never_corrects(MISCLICKS),
_routes(rng, 1, TRAVERSALS),
)
def _ids(rng: random.Random, n: int) -> list[str]:
seen: set[str] = set()
out: list[str] = []
while len(out) < n:
candidate = f"ACC-{rng.randrange(0x10000):04X}"
if candidate not in seen:
seen.add(candidate)
out.append(candidate)
return out
def _plan(rng: random.Random, graded: int) -> tuple[int, int, int]:
"""How many scripted, cloaked and human accounts this batch holds.
Drawn per seed rather than fixed. A constant class balance is itself a decoy: with
"always six bots in twelve" a model can rank the batch by any weak suspicion score and
take the top six, which recovers most of the ceiling without a rule. Measured on the
fixed-balance version that ranking scored 0.974.
Two invariants: at least three cloaked accounts, so all three tells fit and a
single-channel discriminator visibly ceilings; and at least two more humans than
cloaked accounts, which is what leaves room for `_cover` to guarantee an efficient
human without letting the batch's parity say anything.
"""
scripted = rng.randint(1, max(1, graded // 6))
ceiling = min((graded - scripted - 2) // 2, graded // 3 + 1)
cloaked = rng.randint(3, max(3, ceiling))
humans = graded - scripted - cloaked
while humans < cloaked + 2:
cloaked -= 1
humans += 1
return scripted, cloaked, humans
def _cover(rng: random.Random, n: int, cloaked: int) -> tuple[list[Style], set[int]]:
"""`n` styles, then a uniform random choice of which of them belong to bots.
Half the styles are efficient — the corner a naive flagger points at — and there are
strictly more of them than there are cloaked accounts, so at least one efficient HUMAN
is in every batch by construction. That account is the expensive false positive the
whole environment is built around; leaving its presence to chance would leave the cost
asymmetry absent from some batches entirely.
The population subset is drawn AFTER the styles and independently of them, so
P(bot | style) is `cloaked / n` for every style there is. No threshold, band, ranking
or joint rule over the cover columns beats the batch's base rate, and that is a
property of the draw order rather than of the numbers, so it cannot drift.
An odd count gives the spare style to a corner chosen by a coin, so the expected share
is exactly one half for every batch shape there is. Rounding it up instead made the
share 6/11 in a batch with one naive script and 5/10 in a batch with two — and the
script count moves the bot rate as well, so pooling batches would tie the corner to
`is_bot` even though within any single batch it cannot. Adding the coin unconditionally
is the same bug wearing the other sign: it would make the share 5.5/10 on even counts.
"""
efficient = n // 2 + (rng.randint(0, 1) if n % 2 else 0)
assert efficient > cloaked, "not enough efficient styles to guarantee an efficient human"
corners = [True] * efficient + [False] * (n - efficient)
rng.shuffle(corners)
styles = [_style(rng, corner) for corner in corners]
return styles, set(rng.sample(range(n), cloaked))
def _tells(rng: random.Random, cloaked: int) -> list[str]:
"""One tell per cloaked account, all three present, and the spare slots decided by the
seed: cycling in a fixed order would make the mix identical in every batch, and a model
can fit a constant."""
order = list(TELLS) + [rng.choice(TELLS) for _ in range(cloaked - len(TELLS))]
rng.shuffle(order)
return order
def build_slices(seed: int, graded: int) -> tuple[list[Account], list[Account]]:
"""The labelled examples, and the unlabelled batch the answer is graded on.
The labelled slice is a scripted bot, two humans and one cloaked account per tell. Both
corners appear on both sides of the label — an efficient human and an efficient bot, a
casual human and a casual bot — so the six examples teach that the cover is worthless
and the behaviour is not. A slice where every bot was efficient would teach the
shortcut instead, which is a subtler version of the defect that killed the first draft.
Nothing about the graded batch is derivable from the labelled one except the rule,
which is the point: the accounts differ, the generator does not.
"""
rng = random.Random(seed)
scripted, cloaked, humans = _plan(rng, graded)
ids = _ids(rng, 6 + graded)
# Two efficient and two casual styles, dealt one of each to a human and to a bot.
demo = [_style(rng, True), _style(rng, True), _style(rng, False), _style(rng, False)]
demo_tells = _tells(rng, 3)
labelled = [
_scripted(rng, ids[0]),
_wearing(rng, ids[1], demo[0], ""),
_wearing(rng, ids[2], demo[2], ""),
_wearing(rng, ids[3], demo[1], demo_tells[0]),
_wearing(rng, ids[4], demo[3], demo_tells[1]),
_wearing(rng, ids[5], _style(rng, rng.random() < 0.5), demo_tells[2]),
]
rng.shuffle(labelled)
styles, bot_slots = _cover(rng, cloaked + humans, cloaked)
tells = iter(_tells(rng, cloaked))
batch = [_scripted(rng, ids[6 + i]) for i in range(scripted)]
for i, style in enumerate(styles):
batch.append(
_wearing(rng, ids[6 + scripted + i], style, next(tells) if i in bot_slots else "")
)
rng.shuffle(batch)
return labelled, batch
def render(account: Account, *, label: bool) -> str:
"""One account as the log the agent reads. `break_s` can be empty — an account that
never pauses has nothing to write there, and the blank line is itself the observation."""
head = f"[{account.acc_id}]"
if label:
head += f" label: {'BOT' if account.is_bot else 'PLAYER'}"
return "\n".join([
head,
f" click_dt_ms {' '.join(str(v) for v in account.click_dt_ms)}",
f" break_s {' '.join(str(v) for v in account.break_s) or '(no breaks logged)'}",
f" session_min {' '.join(str(v) for v in account.session_min)}",
f" login_gap_min {' '.join(str(v) for v in account.login_gap_min)}",
f" misclick_next {' '.join(account.misclick_next)}",
f" route_ids {' '.join(account.route_ids)}",
])
@@ -0,0 +1,241 @@
"""Scoring an accusation list, against a reference discriminator rather than against truth.
The score is normalised to what a discriminator built only from the labelled examples
catches on this batch — `reference_bots` below, three threshold tests on three columns of
the log. That indirection is deliberate and it is what makes 1.000 reachable BY
CONSTRUCTION: if the generator ever drifts so that one cloaked account stops leaking, the
reference misses it too and the ceiling stays at 1.000 instead of quietly moving out of
reach. Grading against ground truth would have made that drift look like a model failure.
The reference is only legitimate because it reads the same columns the agent reads. It has
no access to `Account.population`; the probe asserts it is nonetheless perfect, which is
the "the signal exists" check — a discriminator that cannot beat guessing means the batch
is noise and a model's score on it means nothing.
Four quantities come out of one pass:
detection bots caught, over what the reference caught. Clipped, so beating the
reference is never punished.
restraint people left alone, SQUARED. Linear, a wrongful ban and a missed bot cost
almost exactly the same (0.42 against 0.38 on a twelve-account batch) and
the environment stops teaching the asymmetry it exists to teach. A wrongful
ban is a support ticket, an appeal, and a player who does not come back; the
second one costs more than the first, and squaring is that shape.
purity true positives over the accusations made, floored at the reference's count.
The floor is what stops one confident accusation scoring like a full sweep:
without it, naming the single most obvious script earns perfect precision.
gate the reference's bots bar one, and not one person. Binary. It demanded ALL of
them until 2026-08-21, and measured over 32 rollouts it fired zero times —
0.25 of the reward mass with no gradient in it at all. The slack is on the
catching clause only; a single wrongful ban still shuts the gate, because
that is the asymmetry the environment exists to teach. See `GATE_SLACK`.
"""
from __future__ import annotations
import json
import math
import re
import statistics
from dataclasses import dataclass
from bot_detection.accounts import DEAD_BAND, Account
MAX_ACCUSATIONS = 64
# The thresholds sit in the middle of gaps that are visible in the labelled slice: humans
# correct at least five misclicks in eight and bots that leak here correct none; human
# login gaps spread by hours and a scheduler's by minutes. Anything an agent could not read
# off the examples would not be a fair reference.
CORRECTION_FLOOR = 0.35
LOGIN_SPREAD_FLOOR_MIN = 30
# How many of the reference's bots the gate forgives. It used to forgive none, and over the
# 32 rollouts in `outputs/run-20260821-1401` it fired 0 times with a maximum of 0.000: a
# quarter of this environment's reward mass was a constant, and a constant contributes
# nothing to a policy gradient. `detection` never reached 1.0 in 32 attempts — its maximum
# was 0.833 — so the gate only ever paid a model that was already perfect, which is the
# point at which it has stopped needing the signal.
#
# One is measured, not chosen. Replaying those same 32 rollouts through this scorer
# (`tools/regate.py`): exact 0/32, slack 1 **5/32 = 0.156**, slack 2 15/32 = 0.469. The
# ladder is what rules slack 2 out (`tools/regate_ladder.py`): at slack 1 the oracle's list
# minus one account clears the gate and minus two does not, so the gate still separates a
# near miss from a half-right answer; at slack 2 both clear and it stops separating anything.
# Inaction and accusing everybody score 0.000 at every slack.
#
# The restraint clause is deliberately NOT given a margin. A single wrongful ban still shuts
# the gate outright, because that asymmetry is the whole environment — a missed bot is
# another day of farming, a banned player is a player who does not come back.
GATE_SLACK = 1
def correction_share(account: Account) -> float:
if not account.misclick_next:
return 1.0
return account.misclick_next.count("fix") / len(account.misclick_next)
def deadband_breaks(account: Account) -> int:
return sum(1 for b in account.break_s if DEAD_BAND[0] <= b < DEAD_BAND[1])
def login_spread_min(account: Account) -> int:
"""Range of the gaps between session starts. A person's varies by hours."""
if len(account.login_gap_min) < 2:
return 10_000
return max(account.login_gap_min) - min(account.login_gap_min)
def click_cv(account: Account) -> float:
"""The naive statistic, exported so the probe can play the strategy it represents.
It finds the naive scripts, whose delays are near-constant, and nothing else: a cloaked
bot and a person draw their click latency from one pool of lognormal parameters that
was sampled before either was assigned a population, so no threshold on this column
tells them apart. It is here to be measured failing, not to be used.
"""
dts = account.click_dt_ms
if len(dts) < 2:
return 0.0
mean = statistics.fmean(dts)
return statistics.stdev(dts) / mean if mean else 0.0
def log_click_sd(account: Account) -> float:
"""The sharpest form of the naive statistic — the sigma of the lognormal, recovered.
Exported because it is the attack that broke the first version of this environment: the
populations were drawn from disjoint sigma ranges, so a cut at 0.52 separated the
sophisticated bot from the efficient human and a rule reading only this column and
`route_ids` scored 0.953. The probe asserts, over thousands of accounts, that no cut
and no two-sided band on it now beats the base rate.
"""
dts = [d for d in account.click_dt_ms if d > 0]
if len(dts) < 2:
return 0.0
return statistics.stdev(math.log(d) for d in dts)
def session_mean(account: Account) -> float:
"""Hours at the keyboard. The other column that used to be an identity: humans were the
only population whose sessions ran under 200 minutes, in all 7200 accounts checked."""
return statistics.fmean(account.session_min) if account.session_min else 0.0
def distinct_routes(account: Account) -> int:
"""The third decoy: an account that found the fast route never leaves it. Drawn from
the style, and the style is drawn before anybody is a bot."""
return len(set(account.route_ids))
def reference_bots(accounts: list[Account]) -> set[str]:
"""The strategy the reward is normalised against: three tests on three channels.
Any one of them alone catches roughly a third of the cloaked accounts, because each
cloaked account leaks on exactly one. Together they are exhaustive. Nothing here reads
a label — this runs on the same rendered columns the agent is given.
"""
accused = set()
for account in accounts:
if correction_share(account) < CORRECTION_FLOOR:
accused.add(account.acc_id)
elif deadband_breaks(account) > 0:
accused.add(account.acc_id)
elif login_spread_min(account) < LOGIN_SPREAD_FLOOR_MIN:
accused.add(account.acc_id)
return accused
@dataclass
class Outcome:
"""What one accusation list did to one batch."""
true_positives: int
false_positives: int
bots_total: int
humans_total: int
reference_caught: int
accused_total: int
unknown_ids: int
"""Ids that are not in the graded batch. Ignored rather than counted against the agent:
naming an account that does not exist is a formatting slip, not a wrongful ban."""
@property
def detection(self) -> float:
if not self.reference_caught:
return 0.0
return min(1.0, self.true_positives / self.reference_caught)
@property
def restraint(self) -> float:
if not self.humans_total:
return 1.0
return max(0.0, 1.0 - self.false_positives / self.humans_total) ** 2
@property
def purity(self) -> float:
"""Zero for an empty accusation list. An empty list has no false positives and
also no evidence; paying it for restraint would make silence the safe play, which
is the same defect that put redaction-pressure's floor above zero."""
made = self.true_positives + self.false_positives
denominator = max(made, self.reference_caught)
return self.true_positives / denominator if denominator else 0.0
@property
def gate(self) -> bool:
"""The reference's bots bar `GATE_SLACK`, and not one person.
`max(1, ...)` is load-bearing and not defensive dressing. Without it, a batch in
which the reference catches exactly one bot would let an EMPTY accusation list
clear the gate — house rule 3 failing at the one seed nobody thinks to sample.
"""
if self.reference_caught <= 0:
return False
needed = max(1, self.reference_caught - GATE_SLACK)
return self.true_positives >= needed and self.false_positives == 0
def measure(accounts: list[Account], accused: list[str]) -> Outcome:
by_id = {a.acc_id: a for a in accounts}
# Deduplicated: naming the same account twice is one accusation, and counting it twice
# would let a model inflate its own precision denominator into a rounding error.
named = []
for raw in accused[:MAX_ACCUSATIONS]:
key = str(raw).strip().upper()
if key not in named:
named.append(key)
unknown = sum(1 for key in named if key not in by_id)
hits = [by_id[key] for key in named if key in by_id]
return Outcome(
true_positives=sum(1 for a in hits if a.is_bot),
false_positives=sum(1 for a in hits if not a.is_bot),
bots_total=sum(1 for a in accounts if a.is_bot),
humans_total=sum(1 for a in accounts if not a.is_bot),
reference_caught=len(reference_bots(accounts) & {a.acc_id for a in accounts if a.is_bot}),
accused_total=len(hits),
unknown_ids=unknown,
)
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
def parse_accusations(reply: str) -> list[str]:
"""The last JSON block in the reply, as either `{"bots": [...]}` or a bare array.
A reply that will not parse is an empty accusation list, not an error — it scores what
accusing nobody scores, which is zero.
"""
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except json.JSONDecodeError:
return []
if isinstance(parsed, dict):
parsed = parsed.get("bots", [])
if not isinstance(parsed, list):
return []
return [item for item in parsed if isinstance(item, str)]
@@ -0,0 +1,147 @@
"""bot-detection: name the scripts without banning the people who play like one.
Six labelled accounts, then a batch of unlabelled ones from the same generator. The labels
on the batch are never in the prompt and never in the task data — only the seed is, and
they are rebuilt at scoring time. What the agent is graded on is whether the rule it drew
from six examples holds on accounts it has not been told the answer to.
The naive rule is "low timing variance means bot", and here it finds the naive scripts and
stops. Every other account's cover — click latency, session length, route count — is drawn
before the generator decides whether that account is a person, and the decision is a uniform
coin over the styles it just drew. So P(bot | anything in those three columns) is the
batch's bot rate exactly, and the efficient human who clicks tighter than half the bots is
in every batch by construction rather than by luck.
What IS separable is behaviour: a bot that never corrects a misclick, one whose breaks are
exponential and so land in a dead band a human's bimodal breaks never occupy, one that logs
in on a fixed period. Each cloaked account leaks on exactly one of the three, so a
discriminator built on any single channel ceilings at roughly a third of them. The class
balance varies with the seed too — a fixed "six bots in twelve" lets a model rank by any
weak score and take the top six, which was worth 0.974 of the ceiling when measured.
The reward is normalised against `reference_bots`, a discriminator that reads the same
columns the agent reads. That is what makes 1.000 reachable by construction rather than by
assumption: nothing here is scored against a ground truth the agent cannot get to.
Banning a real player is the expensive error, so restraint is weighted more heavily than
detection and is squared — and neither reward can be collected without the other, because
each is multiplied by the thing that qualifies it. Accusing nobody has no true positives,
so it has no purity, so its perfect restraint is worth nothing.
"""
from __future__ import annotations
from typing import ClassVar
from pydantic import Field
import verifiers.v1 as vf
from bot_detection.accounts import build_slices, render
from bot_detection.scan import measure, parse_accusations
SYSTEM = """You are reviewing activity logs for a game's anti-cheat team.
Each account log has six columns: the delay between consecutive clicks in milliseconds,
the length of each logged break in seconds, the length of each play session in minutes,
the minutes between consecutive session starts, what the account did after each misclick,
and the route it took on each of twenty traversals of the same stretch of map.
You are given labelled examples first, then a batch to classify. Return the account ids you
believe are bots, as one JSON object in a ```json code block:
```json
{"bots": ["ACC-0000", "ACC-1111"]}
```
Some of the people in this batch play more efficiently than some of the bots. Banning one
of them costs more than missing a bot, and an accusation list you cannot defend is worse
than a short one. Accounts you do not name are left alone."""
class BotData(vf.TaskData):
seed: int
"""Rebuilds both slices exactly; no label ever reaches the task data."""
graded: int
class BotTask(vf.Task[BotData]):
@vf.stop
async def single_turn(self, trace: vf.Trace) -> bool:
return trace.num_turns >= 1
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
"""One pass over the graded batch; every reward reads this. Recomputing per reward
is how the components drift apart when the scorer changes."""
_, batch = build_slices(self.data.seed, self.data.graded)
outcome = measure(batch, parse_accusations(trace.last_reply))
return {
"detection": outcome.detection,
"restraint": outcome.restraint,
"purity": outcome.purity,
"clean": float(outcome.gate),
"true_positives": float(outcome.true_positives),
"false_positives": float(outcome.false_positives),
"accused": float(outcome.accused_total),
"unknown_ids": float(outcome.unknown_ids),
# Reported separately because it is the finding the environment exists to
# teach: a list can be free of false positives and still blind to the bots
# that bothered to jitter.
"reference_caught": float(outcome.reference_caught),
}
@vf.reward(weight=0.35)
async def caught(self, trace: vf.Trace) -> float:
"""Bots found, voided by the players banned to find them."""
return trace.metrics.get("detection", 0.0) * trace.metrics.get("restraint", 0.0)
@vf.reward(weight=0.40)
async def spared(self, trace: vf.Trace) -> float:
"""Players left alone — but only counted for a list that accused something real.
Weighted above detection because that is the true cost ordering, and multiplied by
purity rather than added beside it: a counterweight added as its own term is free
points, which is how schema-migration paid 0.15 for touching nothing."""
return trace.metrics.get("restraint", 0.0) * trace.metrics.get("purity", 0.0)
@vf.reward(weight=0.25)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
class BotConfig(vf.TasksetConfig):
num_tasks: int = Field(64, ge=1)
graded: int = Field(12, ge=9)
"""Unlabelled accounts in the batch. Nine is the floor: three cloaked accounts so all
three tells fit, strictly more humans than that so an efficient human is guaranteed to
be present to wrongly accuse, and at least one naive script."""
class BotTaskset(vf.Taskset[BotTask, BotConfig]):
SEED_BASE: ClassVar[int] = 80_000
def load(self) -> list[BotTask]:
tasks = []
for i in range(self.config.num_tasks):
seed = self.SEED_BASE + i
labelled, batch = build_slices(seed, self.config.graded)
examples = "\n\n".join(render(a, label=True) for a in labelled)
unlabelled = "\n\n".join(render(a, label=False) for a in batch)
tasks.append(
BotTask(
BotData(
idx=i,
name=f"batch-{seed}",
prompt=(
f"Labelled examples:\n\n{examples}\n\n"
f"Classify these {len(batch)} accounts:\n\n{unlabelled}\n\n"
"Return the bots."
),
system_prompt=SYSTEM,
seed=seed,
graded=self.config.graded,
),
self.config.task,
)
)
return tasks
+13
View File
@@ -0,0 +1,13 @@
[project]
name = "bot-detection"
version = "0.1.0"
description = "bot-detection — find the scripts in an activity log without banning the players who grind like one."
requires-python = ">=3.11"
dependencies = ["verifiers==0.3.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["bot_detection"]
@@ -0,0 +1,3 @@
from canary_trap.taskset import CanaryTaskset
__all__ = ["CanaryTaskset"]
@@ -0,0 +1,121 @@
"""The three knowledge states a probe has to tell apart.
A contamination probe is only useful if it separates a model that has seen your eval set
from one that has not. The trap is that the obvious probe — carry a GUID into the corpus
and ask for it back — separates only the *verbatim* case. Training data gets reworded,
deduplicated and paraphrased on the way into a model, and a model that learned every fact
in your eval set from a reworded copy answers no GUID and is contaminated anyway.
So a task builds three knowledge states from one seed:
clean public facts only. Answering from here is not evidence of anything.
verbatim public + the corpus exactly as written, GUIDs and all.
paraphrase public + the same FACTS, reworded, with every GUID regenerated.
A probe set is scored on catching both contaminated states without firing on the clean
one. Verbatim-only probes cap at half the detection score, and that ceiling is the whole
lesson of the environment.
"""
from __future__ import annotations
import random
import string
from dataclasses import dataclass
@dataclass(frozen=True)
class Fact:
"""One fact in three parts.
`canonical` is how the corpus states it. `alias` is how the paraphrase states it, and
it shares no wording with `canonical` beyond `core`. `core` is the token a probe has to
key on to see BOTH — and it is deliberately absent from public knowledge, so keying on
it is also sound. Every fact therefore has an answer that scores 1.0, and none of them
is the sentence the corpus actually contains.
"""
subject: str
attribute: str
canonical: str
alias: str
core: str
SUBJECTS = [
Fact("the Lisbon relay", "throughput", "412 requests per second", "412 rps", "412"),
Fact("the Osaka mirror", "operator", "Ines Baptiste", "I. Baptiste", "Baptiste"),
Fact("the Tallinn cache", "eviction policy", "least-recently-used (LRU)", "LRU ordering", "LRU"),
Fact("the Bogota gateway", "timeout", "1400 milliseconds", "1400 ms", "1400"),
Fact("the Leeds shard", "replica count", "seven replicas", "seven copies", "seven"),
Fact("the Dakar queue", "retry budget", "nine retries", "nine attempts", "nine"),
]
# Facts a model could know without ever seeing our corpus. A probe whose answer lives here
# fires on a clean model, which is what makes it a false positive rather than a probe.
PUBLIC = [
"TCP uses a three-way handshake.",
"Postgres defaults to port 5432.",
"HTTP 429 means too many requests.",
"UTC does not observe daylight saving.",
"SHA-256 emits 32 bytes.",
"The capital of Portugal is Lisbon.",
]
@dataclass(frozen=True)
class Knowledge:
"""What one model can answer from. Membership is a substring test on `text`."""
name: str
text: str
def _check_facts() -> None:
"""The environment's own floor-and-ceiling guarantee, asserted at import.
Every fact must have an answer that a probe can key on to see both contaminated states
(`core` in canonical AND in alias) without accusing a clean model (`core` absent from
public). Without this the ceiling is unreachable for some seeds and the reward quietly
stops being solvable — which is exactly the failure the house rules exist to catch.
"""
public = " ".join(PUBLIC).lower()
for fact in SUBJECTS:
assert fact.core.lower() in fact.canonical.lower(), fact
assert fact.core.lower() in fact.alias.lower(), fact
assert fact.core.lower() not in public, f"{fact.core} collides with public knowledge"
# And the paraphrase must not simply restate the corpus, or a verbatim probe would
# catch it for free and the environment would teach nothing.
assert fact.alias.lower() != fact.canonical.lower(), fact
_check_facts()
def _guid(rng: random.Random) -> str:
hexd = "0123456789abcdef"
return "-".join("".join(rng.choice(hexd) for _ in range(n)) for n in (8, 4, 4, 12))
def build(seed: int, facts: int) -> tuple[str, dict[str, Knowledge]]:
"""One corpus, and the three knowledge states built from it."""
rng = random.Random(seed)
picked = rng.sample(SUBJECTS, min(facts, len(SUBJECTS)))
public_text = " ".join(PUBLIC)
verbatim_lines, paraphrase_lines = [], []
for fact in picked:
verbatim_lines.append(
f"[{_guid(rng)}] Record for {fact.subject}: {fact.attribute} is {fact.canonical}."
)
# Same fact, different sentence, and a REGENERATED tag — the paraphrase remembers
# what is true and has forgotten every string that carried it.
paraphrase_lines.append(
f"[{_guid(rng)}] Concerning {fact.subject}, its {fact.attribute} sits at {fact.alias}."
)
corpus = "\n".join(verbatim_lines)
return corpus, {
"clean": Knowledge("clean", public_text),
"verbatim": Knowledge("verbatim", public_text + "\n" + corpus),
"paraphrase": Knowledge("paraphrase", public_text + "\n" + "\n".join(paraphrase_lines)),
}
@@ -0,0 +1,105 @@
"""Scoring a probe set against the three knowledge states.
Two properties, and they pull against each other:
sound the probe does NOT answer on the clean model. An unsound probe accuses a model
that has seen nothing, which is worse than no probe at all — Bench VOIDS a
score on a contamination verdict, so a false positive destroys a real result.
catching a sound probe answers on a contaminated model. Both contaminated states have
to be caught, and the paraphrase is the one that costs: it kept every fact and
not one of the strings, so a GUID-recall probe cannot see it.
Detection is scored per state rather than per probe, because catching the same state with
six probes is not six times the evidence. Specificity is per probe, so shotgunning a
hundred guesses to land one is paid for.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from canary_trap.corpus import Knowledge
MAX_PROBES = 12
MIN_ANSWER_CHARS = 3
CONTAMINATED = ("verbatim", "paraphrase")
@dataclass(frozen=True)
class Probe:
question: str
answer: str
@dataclass
class Outcome:
sound: int = 0
total: int = 0
caught: set[str] = field(default_factory=set)
rejected: list[str] = field(default_factory=list)
@property
def specificity(self) -> float:
"""Share of probes that do not accuse a clean model. Zero probes scores zero: an
empty set has no false positives and also no evidence, and rewarding it would make
returning nothing the safe play."""
return self.sound / self.total if self.total else 0.0
@property
def detection(self) -> float:
return len(self.caught) / len(CONTAMINATED)
@property
def clean_gate(self) -> bool:
"""Every contaminated state caught, and not one probe firing on the clean model."""
return self.detection == 1.0 and self.total > 0 and self.sound == self.total
def _answers(knowledge: Knowledge, probe: Probe) -> bool:
"""The model stands in for itself: it can produce an answer exactly when that answer is
present in what it knows. Crude, and the crudeness is load-bearing — it makes the whole
environment deterministic and free, and it still distinguishes recall of a string from
recall of a fact, which is the only distinction being taught."""
return probe.answer.lower() in knowledge.text.lower()
def measure(states: dict[str, Knowledge], probes: list[Probe]) -> Outcome:
outcome = Outcome()
for probe in probes[:MAX_PROBES]:
# A blank or near-blank answer is a substring of everything, which would read as a
# probe that catches all three states. Rejected rather than scored as unsound, so
# the reason shows up in the trace.
if len(probe.answer.strip()) < MIN_ANSWER_CHARS:
outcome.rejected.append(probe.answer[:24])
outcome.total += 1
continue
outcome.total += 1
if _answers(states["clean"], probe):
continue # unsound: fires on a model that has seen nothing
outcome.sound += 1
for name in CONTAMINATED:
if _answers(states[name], probe):
outcome.caught.add(name)
return outcome
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
def parse_probes(reply: str) -> list[Probe]:
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except json.JSONDecodeError:
return []
if not isinstance(parsed, list):
return []
return [
Probe(str(p.get("question", "")), p["answer"])
for p in parsed
if isinstance(p, dict) and isinstance(p.get("answer"), str)
]
@@ -0,0 +1,110 @@
"""canary-trap: write contamination probes that survive a paraphrase.
The agent reads a corpus destined to become an eval set and returns probes that would
reveal a model trained on it. Probes are then run against three stand-in models: one that
has seen only public facts, one that memorised the corpus verbatim, and one that learned
the same facts from a reworded copy with every identifier regenerated.
The obvious probe — ask for a GUID that was planted in the corpus — is sound and catches
exactly one of the two contaminated models, so it ceilings at 0.5 detection. The probe that
scores is one keyed on a FACT the paraphrase kept, whose answer does not also appear in
public knowledge. Learning that difference is the environment.
This is Bench's contamination gate as a task. There, a canary hit does not annotate a score,
it voids it — which is why a probe that fires on a clean model is the expensive failure here
and specificity carries as much weight as detection.
"""
from __future__ import annotations
from pydantic import Field
import verifiers.v1 as vf
from canary_trap.corpus import build
from canary_trap.scan import MAX_PROBES, Probe, measure, parse_probes
SYSTEM = f"""You are designing contamination probes for an evaluation corpus.
Return a JSON array of at most {MAX_PROBES} probes in one ```json code block. Each probe is
an object with "question" (what you would ask a model) and "answer" (the exact string a
model that has seen this corpus would produce).
Your probes are run against three models: one that has seen only public knowledge, one that
memorised this corpus word for word, and one that learned the same facts from a reworded
copy in which every bracketed identifier was regenerated.
A probe that the public-knowledge model can answer is worse than no probe — it accuses an
innocent model. A probe only the word-for-word model can answer catches half of what is
there.
Example shape:
```json
[{{"question": "What is the retry budget for the Dakar queue?", "answer": "three retries"}}]
```"""
class CanaryData(vf.TaskData):
seed: int
facts: int
class CanaryTask(vf.Task[CanaryData]):
@vf.stop
async def single_turn(self, trace: vf.Trace) -> bool:
return trace.num_turns >= 1
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
_, states = build(self.data.seed, self.data.facts)
outcome = measure(states, parse_probes(trace.last_reply))
return {
"detection": outcome.detection,
"specificity": outcome.specificity,
"clean": float(outcome.clean_gate),
"probes": float(outcome.total),
"rejected": float(len(outcome.rejected)),
# Reported separately because it is the finding the environment exists to
# teach: a probe set can be perfectly sound and still blind to a paraphrase.
"caught_paraphrase": float("paraphrase" in outcome.caught),
}
@vf.reward(weight=0.35)
async def detection(self, trace: vf.Trace) -> float:
return trace.metrics.get("detection", 0.0)
@vf.reward(weight=0.35)
async def specificity(self, trace: vf.Trace) -> float:
return trace.metrics.get("specificity", 0.0)
@vf.reward(weight=0.30)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
class CanaryConfig(vf.TasksetConfig):
num_tasks: int = Field(48, ge=1)
facts: int = Field(4, ge=1, le=6)
class CanaryTaskset(vf.Taskset[CanaryTask, CanaryConfig]):
SEED_BASE = 70_000
def load(self) -> list[CanaryTask]:
tasks = []
for i in range(self.config.num_tasks):
seed = self.SEED_BASE + i
corpus, _ = build(seed, self.config.facts)
tasks.append(
CanaryTask(
CanaryData(
idx=i,
name=f"corpus-{seed}",
prompt=f"The corpus:\n\n{corpus}\n\nWrite the probes.",
system_prompt=SYSTEM,
seed=seed,
facts=self.config.facts,
),
self.config.task,
)
)
return tasks
+13
View File
@@ -0,0 +1,13 @@
[project]
name = "canary-trap"
version = "0.1.0"
description = "canary-trap — write contamination probes that survive a paraphrase, without accusing a clean model."
requires-python = ">=3.11"
dependencies = ["verifiers==0.3.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["canary_trap"]
@@ -0,0 +1,3 @@
from drop_table_inference.taskset import DropTableTaskset
__all__ = ["DropTableTaskset"]
@@ -0,0 +1,498 @@
"""Parsing an estimated drop table, and scoring it against the held-out kills.
Every number here is normalised against a REFERENCE STRATEGY rather than an absolute,
because the absolute is unreachable and rule 3 forbids dead components. Nobody recovers
1/24576 exactly from 1,200 kills; sampling error is irreducible and a model reasoning
perfectly still cannot beat it. So the ceiling is what good statistics allows on the SAME
log the agent read.
WHICH reference is the whole design, and the first version got it wrong. It used the
posterior mean under a Jeffreys prior — a closed form, which meant two things. Every
zero-count item got `0.5/(kills + 0.5(n+1))` regardless of anything else in the log, so the
tail term was maxed by a memorised constant that never opened the kill counts: measured over
96 tasks, a blind reply assigning one number to every item scored `rare` 1.000 on every
single one. And the argmax of the reward was "replicate a formula", not "do the inference
the generator was built to make learnable" — a real grid posterior scored BELOW an exact
formula copy. Both are the same defect: a reference that is a function of (count, kills)
cannot reward reading the rest of the table.
The reference here is the posterior over the generator's OWN hypothesis space — every
assignment of items to distinct rungs of the published ladder, weighted by the multinomial
likelihood of the visible counts and by each item's PRICE, filtered by the published mass
window, and divided by the probability that the assignment would have produced a silent item
at all (the stream is rejected until one does, so that conditioning is evidence and ignoring
it leaves information in the prompt). Everything it uses is in the prompt; none of it is the
answer. Because rungs are not reused, what an unobserved item can be depends on what the
observed ones already are, and because prices are lognormal in the rate, two items that are
both silent are no longer interchangeable. Measured, that is the difference between a
reference that beats the best memorised constant on the tail by 8% and one that beats it by
4.75x.
Three terms, one counterweight, and the counterweight multiplies:
fit forward divergence ratio, KL(held-out || reference) / KL(held-out || estimate).
Dominated by the common items, because forward KL weights every term by the
truth's own mass. Graded against the held-out EMPIRICAL distribution, not the true
table: the environment grades what the continuation showed.
rare mean squared LOG-ratio on the items that dropped ZERO times, normalised by the
reference's own error. Graded against the TRUE rate, because forty thousand kills
cannot resolve 1/24576 either and grading the tail on the continuation would grade
the sample. Log-ratio, not probability difference, because the Bayes act for
squared log error is exp(E[ln p]) — exactly what `_posterior` returns — so no
point estimate whatsoever beats the reference in expectation. That is the property
the first version lacked, where a flat constant beat the reference's own tail
accuracy by 19% and the clip reported both as 1.000.
gate binary: valid, and as good as the reference on all three, less a small margin.
restraint the counterweight, and it MULTIPLIES into `rare` rather than sitting beside it
— the schema-migration lesson is that a counterweight scored separately is free
points, and this one is maxed trivially by asserting that nothing unobserved
exists. It is the reverse divergence over the unobserved items, positive part
only: the nats an estimate spends claiming a silent item drops more often than the
continuation showed. Reverse because reverse KL weights by the ESTIMATE's mass and
is therefore the only term that can see a tail worth two parts in a thousand.
One-sided because under-claiming is what `rare` already prices, and a two-sided
reverse divergence turned out to be head accuracy measured a second time: its
ratio correlates 0.98 with the forward one over 240 scored estimates, so
multiplying the two together squared the same error.
Validity is also a multiplier. An estimate whose probabilities sum past 1 is not a
distribution, and inflating everything zeroes the episode rather than paying for reach.
MIN_PROBABILITY is the other thing that has to be explicit. KL punishes an asserted zero
without bound, so one zero against an item that appears in the graded slice makes every
score infinite and every comparison meaningless. Clamping puts a price on it — ln(p/eps)
per unit of the truth's mass — instead of an exception.
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from functools import lru_cache
from itertools import combinations
from drop_table_inference.table import (
COMMON_DENOMINATOR,
COMMON_NUMERATORS,
LADDER_DENOMINATORS,
MAX_ITEM_MASS,
MIN_ITEM_MASS,
N_COMMON,
N_LADDER,
VALUE_EXPONENT,
VALUE_LOG_SD,
VALUE_SCALE,
Stream,
)
# The floor below which "vanishingly rare" and "impossible" stop being distinguished.
MIN_PROBABILITY = 1e-6
# Slack for an estimate written to four decimal places. Anything past it is an agent
# claiming more than one drop per kill.
SUM_TOLERANCE = 1e-3
# The handicap the reference concedes to the gate. Without one the verdict turned on the
# fifth decimal place — the reference rounded to six decimals failed its own gate on five
# tasks in eight, which measures float noise. Five percent is wide enough that it does not,
# and narrow enough to be a gate: sweeping it from 0.005 to 0.20 moves the best non-oracle
# strategy's pass rate from 0.062 to 0.083, because the gap it guards is not a rounding gap.
GATE_MARGIN = 0.05
# Guards for the ratios. Both denominators are strictly positive in practice — the
# divergences carry the held slice's own sampling error and the tail is never resolved
# exactly — so these only ever protect against a degenerate seed, and they are three orders
# of magnitude below the typical value of the quantity they are added to.
KL_EPSILON = 1e-9
TAIL_EPSILON = 1e-6
# Over-claim below this many nats is free. A good estimate's whole forward divergence is
# around 1e-3, and an unobserved item priced at twice what the continuation showed costs
# about 7e-5 — so this is the line between "rounded the tail up" and "bought coverage".
# Without a floor the term would divide by zero on the tasks where the reference happens to
# under-claim everywhere, and a counterweight that fires on float noise is not one.
OVERCLAIM_FLOOR = 1e-4
# Hypotheses this many log units below an item's best rung are dropped before the
# enumeration. e^-32 is 1e-14 of the leading term: it changes the posterior mean in the
# fourteenth decimal place and cuts the search by two orders of magnitude.
PRUNE_LOG = 32.0
SLOT_RATES = tuple(
[n / COMMON_DENOMINATOR for n in COMMON_NUMERATORS] + [1.0 / d for d in LADDER_DENOMINATORS]
)
COMMON_SLOTS = len(COMMON_NUMERATORS)
@dataclass
class Outcome:
"""What one estimate did against one held-out continuation."""
valid: bool
kl_reference: float
kl_estimate: float
over_reference: float
over_estimate: float
tail_reference: float
tail_estimate: float
rare_items: int
@property
def restraint(self) -> float:
"""The price of tail coverage: reverse divergence over the unobserved items, kept
only where the estimate claims MORE than the continuation produced.
Reverse rather than forward because reverse KL weights by the ESTIMATE's own mass,
which is the only way a term ever notices the tail — the tail is two parts in a
thousand of the distribution and forward KL, weighted by the truth, cannot see it.
One-sided because under-claiming is already what `rare` is for, and a two-sided
reverse divergence is head accuracy measured a second time — across five strategies
and 48 tasks its ratio correlates 0.98 with the forward one, so multiplying the two
together squared the same error and left the mid-range with no gradient.
1.0 for an estimate that claims no more than the reference does. It is a multiplier
on `rare` rather than a reward beside it — the schema-migration lesson is that a
counterweight scored separately is free points, and this one in particular is
maxed, trivially, by asserting that nothing unobserved exists.
"""
if not self.valid:
return 0.0
return min(1.0, (self.over_reference + OVERCLAIM_FLOOR)
/ (self.over_estimate + OVERCLAIM_FLOOR))
@property
def raw_fit(self) -> float:
if not self.valid:
return 0.0
return min(1.0, (self.kl_reference + KL_EPSILON) / (self.kl_estimate + KL_EPSILON))
@property
def raw_rare(self) -> float:
if not self.valid or self.rare_items == 0:
return 0.0
return min(1.0, (self.tail_reference + TAIL_EPSILON) / (self.tail_estimate + TAIL_EPSILON))
@property
def fit(self) -> float:
return self.raw_fit
@property
def rare(self) -> float:
return self.raw_rare * self.restraint
@property
def clean(self) -> bool:
"""The verdict: a valid distribution, and as good as the reference on ALL THREE.
All three, not any of them. Matching the reference on divergence alone is what
frequency-copying nearly does — it is right about the common items, which is where
the divergence lives. Matching it on the tail alone is what an agent does when it
sprays a number over the unobserved items, and restraint is what prices that.
"""
return (
self.valid
and self.raw_fit >= 1.0 - GATE_MARGIN
and self.raw_rare >= 1.0 - GATE_MARGIN
and self.restraint >= 1.0 - GATE_MARGIN
)
@lru_cache(maxsize=4096)
def _split_weights(n_items: int) -> dict[tuple[int, int], float]:
"""Log prior mass per ASSIGNMENT for each tier split consistent with `n_items`.
Per assignment, not per split, because the enumeration visits assignments: a split with
more mass-valid tables spreads the same prior over more of them. The generator draws
`n_common` and `n_ladder` uniformly and independently, then samples rungs without
replacement and shuffles, so every labelled assignment inside a split is equally likely
and the count is C(n, c) * c! * l! * (mass-valid rung-set pairs).
"""
weights: dict[tuple[int, int], float] = {}
for n_common in N_COMMON:
for n_ladder in N_LADDER:
if n_common + n_ladder != n_items:
continue
valid = 0
for nums in combinations(COMMON_NUMERATORS, n_common):
head = sum(n / COMMON_DENOMINATOR for n in nums)
for dens in combinations(LADDER_DENOMINATORS, n_ladder):
total = head + sum(1.0 / d for d in dens)
if MIN_ITEM_MASS <= total <= MAX_ITEM_MASS:
valid += 1
if not valid:
continue
arrangements = math.comb(n_items, n_common) * math.factorial(n_common) * math.factorial(n_ladder)
weights[(n_common, n_ladder)] = -math.log(valid * arrangements)
return weights
LOG_SLOT_VALUE = tuple(math.log(VALUE_SCALE) + VALUE_EXPONENT * math.log(1.0 / rate)
for rate in SLOT_RATES)
@lru_cache(maxsize=4096)
def _posterior(counts: tuple[int, ...], values: tuple[int, ...], kills: int) -> tuple[float, ...]:
"""Posterior geometric-mean rate per item, in the order the counts were given.
Enumeration rather than a closed form because the hypotheses are COUPLED: no two items
share a rung, the total has to land in the published mass window, and the stream was
redrawn until something was silent. Every one of those couples an item's estimate to
the rest of the log, and a closed form has none of them — which is precisely why the
closed form's tail was a constant.
"""
n = len(counts)
splits = _split_weights(n)
if not splits:
raise ValueError(f"no tier split accounts for {n} items")
# Per-item candidate rungs, pruned on the BINOMIAL marginal but carrying the
# MULTINOMIAL term. The joint likelihood does not factorise — the no-drop term couples
# every item — so only `count * ln(rate)` belongs to the item; the marginal is used to
# prune because a rung 1e-14 down on its own item's evidence cannot be rescued by a
# no-drop term shared with every other hypothesis.
candidates: list[list[tuple[int, float]]] = []
for count, value in zip(counts, values):
log_value = math.log(value)
scores = []
for index, rate in enumerate(SLOT_RATES):
# The price term. It is the only thing that tells two silent items apart —
# nothing in a kill log does — so without it the reference beats the best
# memorised constant on the tail by 8%, which is a coin. See table.VALUE_SCALE.
price = -((log_value - LOG_SLOT_VALUE[index]) ** 2) / (2.0 * VALUE_LOG_SD ** 2)
marginal = count * math.log(rate) + (kills - count) * math.log1p(-rate) + price
scores.append((index, marginal, count * math.log(rate) + price))
best = max(marginal for _, marginal, _ in scores)
candidates.append([(index, term) for index, marginal, term in scores
if marginal >= best - PRUNE_LOG])
# Most-constrained item first. The commons have two plausible rungs each and pin the
# mass; opening them first kills whole subtrees before the tail is ever touched.
order = sorted(range(n), key=lambda i: len(candidates[i]))
max_common, max_ladder = max(N_COMMON), max(N_LADDER)
nothing = kills - sum(counts)
hypotheses: list[tuple[float, tuple[float, ...]]] = []
assignment = [0.0] * n
def walk(depth: int, used: int, n_used_common: int, mass: float, loglik: float) -> None:
if mass > MAX_ITEM_MASS:
return
if depth == n:
n_used_ladder = n - n_used_common
prior = splits.get((n_used_common, n_used_ladder))
if prior is None or mass < MIN_ITEM_MASS:
return
# P(some item is silent | this table). The stream was rejected until one was,
# so dividing it out is Bayes, not a fudge: a table of shallow rungs rarely
# produces a silent item and the fact that this one did is evidence for it.
# The items are weakly negatively correlated under the multinomial; treating
# the silences as independent is accurate to a fraction of a percent at these
# rates and is the only closed form available.
quiet = 1.0
for rate in assignment:
quiet *= 1.0 - math.exp(kills * math.log1p(-rate))
# A table under which every item was near-certain to appear cannot have
# produced a log that was accepted for containing a silent one. Float rounds
# that probability to exactly 1 for an all-common assignment, so the hypothesis
# is dropped rather than divided by zero.
if quiet >= 1.0:
return
total = loglik + nothing * math.log1p(-mass) + prior - math.log1p(-quiet)
hypotheses.append((total, tuple(assignment)))
return
item = order[depth]
for index, term in candidates[item]:
if used >> index & 1:
continue
is_common = index < COMMON_SLOTS
if is_common and n_used_common == max_common:
continue
if not is_common and depth - n_used_common == max_ladder:
continue
rate = SLOT_RATES[index]
assignment[item] = rate
walk(depth + 1, used | 1 << index, n_used_common + is_common,
mass + rate, loglik + term)
assignment[item] = 0.0
walk(0, 0, 0, 0.0, 0.0)
if not hypotheses:
# Nothing in the published structure explains this log. Cannot happen for a stream
# this module's own generator produced; falling back to the smoothed frequency
# keeps a caller who reached here with hand-built counts from getting an exception
# instead of a number.
denominator = kills + 0.5 * (n + 1)
return tuple((count + 0.5) / denominator for count in counts)
peak = max(score for score, _ in hypotheses)
weight_total = 0.0
means = [0.0] * n
for score, rates in hypotheses:
weight = math.exp(score - peak)
weight_total += weight
for i, rate in enumerate(rates):
means[i] += weight * math.log(rate)
# The GEOMETRIC posterior mean, exp(E[ln p]). Arithmetic would be the Bayes act for
# squared error in probability, and on the tail that is a loss no scoring rule should
# use: an item that could be 1/512 or 1/32768 has an arithmetic mean pinned by the top
# rung, so the estimate is wrong by a factor of thirty whenever the deep rung is the
# truth. Rates live on a log axis — a drop table is quoted as "one in n" — and the
# tail term below is squared log-ratio, whose Bayes act is exactly this. That pairing
# is what makes "some constant beats the reference" false by construction rather than
# by luck.
return tuple(math.exp(mean / weight_total) for mean in means)
def reference_estimate(items: list[str], counts: dict[str, int], values: dict[str, int],
kills: int) -> dict[str, float]:
"""The reference strategy, as an estimate an agent could have written.
Computable from the prompt alone — that is the requirement it exists to satisfy. A
reference that needed the true table would put the ceiling somewhere the agent cannot
reason its way to, which is the unreachable-ceiling failure wearing a different hat.
"""
means = _posterior(tuple(counts.get(item, 0) for item in items),
tuple(values[item] for item in items), kills)
return dict(zip(items, means))
def _distribution(items: list[str], estimate: dict[str, float]) -> list[float]:
"""The estimate as a full distribution over items plus no-drop, clamped and renormalised.
Renormalising after the clamp keeps this a probability vector so the divergence stays a
divergence; it shifts nothing meaningfully, since the clamp only ever moves mass on the
order of 1e-6.
"""
values = [max(estimate.get(item, 0.0), MIN_PROBABILITY) for item in items]
values.append(max(1.0 - sum(estimate.get(item, 0.0) for item in items), MIN_PROBABILITY))
total = sum(values)
return [value / total for value in values]
def _divergence(observed: list[float], predicted: list[float]) -> float:
return sum(p * math.log(p / q) for p, q in zip(observed, predicted) if p > 0.0)
def _overclaim(predicted: list[float], observed: list[float], tail: list[int]) -> float:
"""Reverse divergence over the unobserved items, positive part only: the nats an
estimate spends asserting that a silent item drops more often than it does."""
return sum(predicted[i] * math.log(predicted[i] / observed[i])
for i in tail if predicted[i] > observed[i])
def _tail_loss(truth: list[float], predicted: list[float]) -> float:
"""Mean squared LOG-ratio on the tail: how many factors of e the estimate is out by.
Squared log rather than squared probability, for two reasons that are the same reason.
A drop rate is a quantity on a log axis — one in five hundred against one in thirty
thousand is a factor of sixty, and a difference of 0.002 — so a linear loss would grade
the shallowest zero-count item and ignore the rest. And the Bayes act for this loss is
exp(E[ln p]), which is exactly what `_posterior` returns, so the reference cannot be
beaten in expectation by any point estimate whatsoever. That is the property the first
version lacked, where a flat 0.0002 beat the reference's own tail accuracy by 19% and
the clip reported both as 1.000.
"""
if not truth:
return 0.0
return sum(math.log(p / t) ** 2 for t, p in zip(truth, predicted)) / len(truth)
def measure(stream: Stream, estimate: dict[str, float] | None) -> Outcome:
items = stream.table.items
observed = [stream.held[item] / stream.held_kills for item in items]
observed.append(stream.held_nothing / stream.held_kills)
# Clamped for the same reason the estimate is: the reverse divergence divides by the
# continuation, and an item the held slice happened to miss would make it infinite.
observed = [max(value, MIN_PROBABILITY) for value in observed]
observed = [value / sum(observed) for value in observed]
reference = _distribution(items, reference_estimate(
items, stream.visible, stream.table.values, stream.visible_kills))
# The tail is the items that dropped ZERO times in the visible slice — the generator
# guarantees there is at least one, and it is the only place an estimate cannot be read
# off the log. Defining it by true rate instead diluted it with 1/256 items that did
# appear, where frequency-copying is fine, and the term stopped measuring anything.
tail = [i for i, item in enumerate(items) if stream.visible[item] == 0]
rates = [stream.table.rates[items[i]] for i in tail]
base = Outcome(
valid=False,
kl_reference=_divergence(observed, reference),
kl_estimate=0.0,
over_reference=_overclaim(reference, observed, tail),
over_estimate=0.0,
tail_reference=_tail_loss(rates, [reference[i] for i in tail]),
tail_estimate=0.0,
rare_items=len(tail),
)
if estimate is None:
return base
predicted = _distribution(items, estimate)
base.valid = True
base.kl_estimate = _divergence(observed, predicted)
base.over_estimate = _overclaim(predicted, observed, tail)
base.tail_estimate = _tail_loss(rates, [predicted[i] for i in tail])
return base
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
_FRACTION = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(?:/|\s+in\s+)\s*(\d+(?:\.\d+)?)\s*$")
def _rate(value: object) -> float | None:
"""A rate as a number, or as the fraction an agent that spotted the grid would write.
`1/128` and `1 in 128` are the natural way to state a drop rate and refusing them would
punish exactly the reasoning the environment is trying to reward."""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
match = _FRACTION.match(value)
if match:
numerator, denominator = float(match.group(1)), float(match.group(2))
return numerator / denominator if denominator else None
try:
return float(value.strip())
except ValueError:
return None
return None
def parse_estimate(reply: str, items: list[str]) -> dict[str, float] | None:
"""The last JSON object in the reply, keyed by item name.
None means "no usable estimate" and scores what doing nothing scores — a reply that
cannot be parsed never raises, and an estimate that is not a sub-distribution is
rejected here rather than being quietly renormalised into a valid one, because
renormalising would make inflating every rate free.
"""
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except json.JSONDecodeError:
return None
if isinstance(parsed, list):
pairs = {}
for row in parsed:
if isinstance(row, dict):
name = row.get("item", row.get("name"))
rate = row.get("rate", row.get("probability", row.get("p")))
if isinstance(name, str):
pairs[name] = rate
parsed = pairs
if not isinstance(parsed, dict):
return None
lookup = {item.lower(): item for item in items}
estimate: dict[str, float] = {}
for key, value in parsed.items():
item = lookup.get(str(key).strip().lower())
if item is None:
continue
rate = _rate(value)
if rate is None or not math.isfinite(rate) or rate < 0.0 or rate > 1.0:
return None
estimate[item] = rate
if not estimate or sum(estimate.values()) > 1.0 + SUM_TOLERANCE:
return None
return estimate
@@ -0,0 +1,252 @@
"""The monster, its drop table, and the kills that came out of it.
A task is one monster. Its table is a set of invented items, each with a rate the agent
never sees, and the log is a sample from it: a visible slice the agent reads and a held-out
continuation it is graded on. Both come out of one kill stream, so the graded kills are
literally the *next* ones the monster would have dropped not a differently-seeded
population that could differ in ways the agent could not have anticipated.
Four properties of the generator are load-bearing, and none is decoration:
A LADDER WITH NO REPEATS. Rates are not merely "structured", they are drawn WITHOUT
REPLACEMENT from two published sets: commons are distinct multiples of 4/128, and the rest
are distinct unit fractions from 1/128 down to 1/24576. Distinctness is the first half of
why the tail is learnable. Nothing in a kill log distinguishes one item that dropped zero
times from another any estimator that is a function of (count, kills) alone must give them
the same number, and if that number is also the same across TASKS then a memorised constant
is a perfect tail estimate and the term is inert. That is exactly what the first version of
this environment did: its reference gave every zero-count item (0+0.5)/(kills+0.5(n+1)), a
value with four distinct settings across ninety-six tasks, so a reply that never opened the
kill log scored 1.000 on the tail. With no repeats, the rungs the OBSERVED items occupy are
the rungs the unobserved ones cannot: a table whose counts pin 1/128 and 1/384 leaves its
silent item somewhere below, and one that pins nothing above 1/3072 leaves it somewhere much
higher.
Distinctness alone was not enough, and the number that says so is 8%: with the ladder and
the elimination but no price, the reference beat the best memorised constant on the tail
loss by eight percent, and a frequency copy with a constant floor held 0.86 of the term.
Rungs eliminate, but two SILENT items are still interchangeable, and the irreducible spread
inside that pair swamped everything the elimination revealed. That is what VALUE_SCALE
below is for.
A PRICE THAT RANKS WHAT THE LOG CANNOT. Every item carries a shop value, lognormal around
SCALE * (1/rate) ** EXPONENT rare drops are worth more, which is a fact about the genre
rather than anybody's copy. It is the only channel that separates two silent items, and
separating them is what turns the tail from a lottery into an inference: with it the
reference beats the best memorised constant on the tail by 4.75x rather than by 8%, and the
frequency-copy-with-a-floor attack falls from 0.86 of the term to 0.28. The scatter is
deliberate and it is large sigma 0.55 in log value against 3x rung spacing so the price
ranks the candidates and never decides them, and an agent that reads only the price scores
0.46 where one that reads price, counts and leftover rungs together scores 1.000.
A ZERO-COUNT ITEM, ALWAYS. The kill stream is rejected and redrawn until at least one item
on the list dropped zero times in the visible slice. That case is the entire environment:
the item list says the item exists, the log says it never appeared, and an estimate of 0 is
an infinitely strong claim about something the graded kills will demonstrate is false. A
task where every item happened to show up does not pose the question, so it is not used.
The rejection is part of the model `estimate._posterior` divides it back out,
because conditioning on "some item was silent" is evidence about the table and an estimator
that ignores it is leaving information in the prompt.
HELD-OUT KILLS UNTIL EVERY ITEM APPEARS. The item list is a promise that all of these drop;
the graded slice has to keep it, or an estimate of zero for the rarest item costs nothing.
It is also what keeps the counterweight finite: `estimate.restraint` divides by what the
continuation produced, and an item the continuation never produced would make it infinite.
No wiki content: every item, monster and word here is invented. The MECHANICS are the
borrowed part, and mechanics are facts.
"""
from __future__ import annotations
import bisect
import math
import random
from dataclasses import dataclass
ADJECTIVES = [
"Ashen", "Brackish", "Cindered", "Duskbound", "Ember", "Frostbit", "Gilded", "Hollow",
"Ivory", "Jagged", "Kelpish", "Lodestone", "Murkwrought", "Nettled", "Pitchblack",
"Quarried", "Saltworn", "Tarnished", "Umbral", "Verdigris",
]
NOUNS = [
"talon", "sigil", "vertebra", "censer", "ingot", "lantern", "chitin", "warhorn",
"tessera", "phial", "reliquary", "scale", "cog", "wick", "grimoire", "spur",
"anvil-shard", "orb", "tabard", "quill",
]
MONSTER_PREFIX = ["Marrow", "Gloom", "Rime", "Slagborn", "Fenwater", "Barrow", "Cinder", "Hush"]
MONSTER_KIND = ["Warden", "Basilisk", "Revenant", "Colossus", "Hierophant", "Drake", "Herald", "Ossifier"]
# The common grid, and it is coarse on purpose. Adjacent numerators sit 33-50% apart, so a
# few hundred kills can actually tell them apart — a continuous numerator would put the
# notches inside the sampling error, the structure would be real but unresolvable, and
# "spot the grid" would be a slogan rather than a strategy.
COMMON_DENOMINATOR = 128
COMMON_NUMERATORS = (8, 12, 16, 24, 32, 40, 48)
# The rare ladder: unit fractions spaced by roughly a factor of two. No sample of 600 kills
# resolves 1/1024 from 1/2048 on its own evidence, and that is the point — down here the
# information is in which rungs the OTHER items have already taken.
LADDER_DENOMINATORS = (128, 384, 1024, 3072, 8192, 24576)
# How many of each. Both are published to the agent, because the reference estimator uses
# them and a reference the agent cannot compute is an unreachable ceiling wearing a hat.
N_COMMON = (3, 4)
N_LADDER = (4, 5, 6)
# The price law, and it is the reason the tail is a skill rather than a lottery.
#
# Two items that both dropped zero times are EXCHANGEABLE on the kill log: no statistic of
# the counts distinguishes them, so every estimator must give them the same number, and
# the reference's advantage over a memorised constant collapses to whatever the free-rung
# set alone reveals. Measured, that was four to eight percent — the reference beat the best
# constant on the tail by less than a tenth, which is not an environment, it is a coin.
#
# A price breaks the tie, and it is the OSRS-shaped way to break it: rare drops are worth
# more. `value` is lognormal around SCALE * (1/rate) ** EXPONENT, so it ranks the silent
# items without deciding them — at 3x rung spacing and this sigma, the price alone gets an
# adjacent pair the right way round about six times in seven and gets nothing for free two
# rungs down. The agent has to combine it with the counts and the leftover rungs, which is
# the entire task.
VALUE_SCALE = 15.0
VALUE_EXPONENT = 0.8
VALUE_LOG_SD = 0.55
# The share of kills that drop something. Bounded away from 1 so "no drop" is always a
# substantial category: it is where the mass an agent does not assign to items has to go,
# and it is what makes the sum-to-at-most-one constraint bite instead of being cosmetic.
MIN_ITEM_MASS = 0.55
MAX_ITEM_MASS = 0.90
# Held-out kills stop when every item has been seen; this caps the walk in case a table
# with a 1/24576 tertiary draws a very long silence. Hitting it costs an item's evidence
# rather than anything worse, and at twelve times the requested slice it is a 1e-8 event.
HELD_OUT_CEILING = 12
@dataclass(frozen=True)
class DropTable:
"""One monster's table. `rates` is in DISPLAY order, deliberately shuffled: listing
items by rarity would hand over the ordering the agent is being asked to recover."""
monster: str
rates: dict[str, float]
values: dict[str, int]
@property
def items(self) -> list[str]:
return list(self.rates)
@property
def nothing(self) -> float:
return 1.0 - sum(self.rates.values())
@dataclass(frozen=True)
class Stream:
"""One task: the table, and both slices as tallies.
Tallies rather than kill sequences because the tally is the sufficient statistic the
order kills came in carries no information about the table, so serialising 5,000 lines
would cost tokens and teach nothing.
"""
table: DropTable
visible: dict[str, int]
visible_kills: int
held: dict[str, int]
held_kills: int
@property
def visible_nothing(self) -> int:
return self.visible_kills - sum(self.visible.values())
@property
def held_nothing(self) -> int:
return self.held_kills - sum(self.held.values())
def _names(rng: random.Random, count: int) -> list[str]:
seen: list[str] = []
while len(seen) < count:
name = f"{rng.choice(ADJECTIVES)} {rng.choice(NOUNS)}"
if name not in seen:
seen.append(name)
return seen
def build_table(seed: int) -> DropTable:
"""The table alone, from the seed. Stable across kill-stream redraws so that a task's
identity is its monster, not whichever sample happened to be accepted.
`sample` rather than `choices` in both tiers: with replacement, two items could share a
rung, the leftover-rung deduction would be unsound, and the tail would go back to being
a constant nobody has to read the log for.
"""
rng = random.Random(seed)
monster = f"{rng.choice(MONSTER_PREFIX)} {rng.choice(MONSTER_KIND)}"
n_common = rng.choice(N_COMMON)
n_ladder = rng.choice(N_LADDER)
names = _names(rng, n_common + n_ladder)
# Rejection on total mass, with the tier COUNTS fixed outside the loop: redrawing the
# shape as well would make the split's prior depend on how often that shape lands in
# the mass window, and the reference's prior — which is P(n_common) * P(n_ladder) —
# would then be subtly wrong on every task.
while True:
numerators = rng.sample(COMMON_NUMERATORS, n_common)
denominators = rng.sample(LADDER_DENOMINATORS, n_ladder)
rates = [n / COMMON_DENOMINATOR for n in numerators] + [1.0 / d for d in denominators]
if MIN_ITEM_MASS <= sum(rates) <= MAX_ITEM_MASS:
break
order = list(zip(names, rates))
rng.shuffle(order)
table = dict(order)
# Prices drawn AFTER the shuffle so the display order carries nothing, and from the
# rate rather than the tier, so a common at 8/128 and a ladder item at 1/128 are priced
# identically — the price is evidence about the RATE, never about which tier an item
# came from.
values = {
name: max(1, round(VALUE_SCALE * (1.0 / rate) ** VALUE_EXPONENT
* math.exp(VALUE_LOG_SD * rng.gauss(0.0, 1.0))))
for name, rate in table.items()
}
return DropTable(monster=monster, rates=table, values=values)
def _tally(rng: random.Random, table: DropTable, kills: int, until_covered: bool) -> tuple[dict[str, int], int]:
"""Roll kills, counting drops. With `until_covered`, keep rolling past `kills` until
every item has appeared see the module docstring for why the graded slice owes the
item list that."""
items = table.items
cumulative, running = [], 0.0
for item in items:
running += table.rates[item]
cumulative.append(running)
counts = {item: 0 for item in items}
rolled, ceiling = 0, kills * HELD_OUT_CEILING
while rolled < kills or (until_covered and not all(counts.values()) and rolled < ceiling):
index = bisect.bisect(cumulative, rng.random())
if index < len(items):
counts[items[index]] += 1
rolled += 1
return counts, rolled
def build_slices(seed: int, visible_kills: int, held_out_kills: int) -> Stream:
"""The two slices of one task, from one seed.
The kill stream is redrawn table held fixed until the visible slice has a
zero-count item, because a task where every item showed up does not pose the question
this environment exists to ask. Rejection is on the visible slice only; the held-out
continuation is whatever the accepted stream produced next.
"""
table = build_table(seed)
for attempt in range(256):
rng = random.Random((seed << 9) + attempt)
visible, _ = _tally(rng, table, visible_kills, until_covered=False)
if all(visible.values()):
continue
held, held_kills = _tally(rng, table, held_out_kills, until_covered=True)
return Stream(table, visible, visible_kills, held, held_kills)
raise RuntimeError(f"no stream with an unobserved item for seed {seed}")
@@ -0,0 +1,205 @@
"""drop-table-inference: recover a drop table from a kill log, graded on the next kills.
The agent gets a monster's item list — each item with a price and a drop count over a few
hundred kills and never gets the rates. It returns an estimate, and the estimate is scored
against the continuation of that same kill stream, thirty times as long and never shown.
The reward is normalised against a REFERENCE STRATEGY rather than an absolute, and that is
not a softening. Sampling error is irreducible: nobody recovers 1/24576 from 1,200 kills, so
a reward measured against the true table has a ceiling no amount of reasoning reaches, which
is the dead-component failure rule 3 exists to catch.
WHICH reference is the design, and the first version of this environment got it wrong. It
used a closed form the posterior mean under a Jeffreys prior and a closed form is a
function of (count, kills) alone, so every item that dropped zero times got the same number
on every task. A reply that never opened the kill log scored 1.000 on the term built to
catch frequency-copying, and frequency-copying plus that one memorised constant reached 90%
of the ceiling. The reference here is the posterior over the generator's own hypothesis
space: items assigned to distinct rungs of a published ladder, weighted by the multinomial
likelihood of the counts AND by each item's price, filtered by the published mass window,
and corrected for the fact that the log was selected to contain a silent item. Everything it
uses is in the prompt. Nothing it uses is a constant.
The structure below is published deliberately. A reference the agent cannot compute is an
unreachable ceiling wearing a hat, and rule 3 forbids those. What is not published is the
answer which item sits on which rung and that is a joint inference over every item at
once, because no two items share a rung and the leftovers are the only evidence about the
ones the log never showed.
Three terms:
fit forward divergence ratio against the held-out continuation. Dominated by the
common items, because forward KL weights every term by the truth's own mass.
rare squared log-ratio on the items that dropped ZERO times, against the true rate,
normalised by the reference's own error and multiplied by `restraint`.
gate binary: valid, and as good as the reference on all three.
and the counterweight, `restraint`, which multiplies into `rare` rather than sitting beside
it: the nats an estimate spends claiming that a silent item drops more often than the
continuation showed. Forward KL rewards covering what you cannot see; restraint prices the
covering. Measured, over a uniform hedge mixed into frequency-copying at weight a: fit rises
0.136 -> 0.186 -> 0.198 as a goes 0 -> 0.0005 -> 0.002 while restraint falls 1.000 -> 0.996
-> 0.443. There is no setting of a where both are at their best, which is what rule 2 asks
for and what the first version did not have.
Probabilities must sum to at most 1; the remainder is the chance of no drop, and it is not
small. An estimate that sums past 1 is not a distribution and scores zero.
"""
from __future__ import annotations
from pydantic import Field
import verifiers.v1 as vf
from drop_table_inference.estimate import measure, parse_estimate
from drop_table_inference.table import (
COMMON_DENOMINATOR,
COMMON_NUMERATORS,
LADDER_DENOMINATORS,
MAX_ITEM_MASS,
MIN_ITEM_MASS,
N_COMMON,
N_LADDER,
VALUE_EXPONENT,
VALUE_LOG_SD,
VALUE_SCALE,
build_slices,
)
def _english(counts: tuple[int, ...]) -> str:
"""The tier counts as a person would write them: 3 or 4, and 4, 5 or 6. Spelled out
rather than given as a range because the agent needs the exact set the reference sums
over the splits it allows, and a split it does not know about is one it cannot weigh."""
return " or ".join(filter(None, (", ".join(str(n) for n in counts[:-1]), str(counts[-1]))))
_COMMONS = ", ".join(f"{n}/{COMMON_DENOMINATOR}" for n in COMMON_NUMERATORS)
_LADDER = ", ".join(f"1/{d}" for d in LADDER_DENOMINATORS)
SYSTEM = f"""You are estimating a monster's drop table from a kill log.
You are given every item on the table, its shop value in gp, and how many times it dropped.
You are NOT given the rates. Return your estimate as one JSON object in a ```json code
block, mapping each item name to its probability per kill:
```json
{{"Ashen talon": 0.125, "Ivory sigil": "1/3072"}}
```
A value may be a decimal or a fraction like "1/3072". Give at least four significant
figures your estimate is compared against a well-reasoned one and rounding throws that
comparison away.
How these tables are built, exactly:
- {_english(N_COMMON)} COMMON items, each at one of {_COMMONS}, no two the same.
- {_english(N_LADDER)} RARE items, each at one of {_LADDER}, no two the same.
- The rates sum to between {MIN_ITEM_MASS:.2f} and {MAX_ITEM_MASS:.2f}. The remainder is
the chance a kill drops nothing, and it is not small.
- An item's value in gp is lognormal around {VALUE_SCALE:g} * (1/rate) ** {VALUE_EXPONENT:g},
with standard deviation {VALUE_LOG_SD:g} in natural log. Rarer is worth more, with real
scatter: the price ranks the items the counts cannot identify, it does not name them.
- This log was chosen because at least one item dropped ZERO times in it. Every item
listed does drop. A rate of 0 is the worst answer available for any of them.
Because no two items share a rate, the rates the observed items take are rates the silent
ones cannot. That, the price, and the count are the whole of the evidence.
You are graded on the next several tens of thousands of kills from this same monster, which
you have not seen on how well your table predicts them, and separately on how close you
got for the items this log never showed you."""
class DropTableData(vf.TaskData):
seed: int
"""Rebuilds the table and both slices exactly; no rate is ever serialized here."""
visible_kills: int
held_out_kills: int
class DropTableTask(vf.Task[DropTableData]):
@vf.stop
async def single_turn(self, trace: vf.Trace) -> bool:
return trace.num_turns >= 1
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
"""Rebuild the stream and score the estimate once; every reward reads this."""
stream = build_slices(self.data.seed, self.data.visible_kills, self.data.held_out_kills)
estimate = parse_estimate(trace.last_reply, stream.table.items)
outcome = measure(stream, estimate)
return {
"fit": outcome.fit,
"rare": outcome.rare,
"clean": float(outcome.clean),
"valid": float(outcome.valid),
"restraint": outcome.restraint,
"raw_fit": outcome.raw_fit,
"raw_rare": outcome.raw_rare,
"kl_estimate": outcome.kl_estimate,
"kl_reference": outcome.kl_reference,
"tail_estimate": outcome.tail_estimate,
"tail_reference": outcome.tail_reference,
"rare_items": float(outcome.rare_items),
}
@vf.reward(weight=0.45)
async def fit(self, trace: vf.Trace) -> float:
return trace.metrics.get("fit", 0.0)
@vf.reward(weight=0.35)
async def rare(self, trace: vf.Trace) -> float:
return trace.metrics.get("rare", 0.0)
@vf.reward(weight=0.20)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
class DropTableConfig(vf.TasksetConfig):
num_tasks: int = Field(64, ge=1)
visible_kills: int = Field(1_200, ge=50)
"""Kills the agent reads. Long enough that the common tier is resolvable against the
grid, short enough that the deep rungs show up zero times which is the case the
environment exists to pose."""
held_out_kills: int = Field(40_000, ge=200)
"""Kills it is graded on and never sees. Thirty times the visible slice, so the graded
frequencies are the estimate's problem rather than the sample's, and long enough that a
1/24576 item is expected to appear."""
class DropTableTaskset(vf.Taskset[DropTableTask, DropTableConfig]):
SEED_BASE = 20_000
def load(self) -> list[DropTableTask]:
tasks = []
for i in range(self.config.num_tasks):
seed = self.SEED_BASE + i
stream = build_slices(seed, self.config.visible_kills, self.config.held_out_kills)
width = max(len(item) for item in stream.table.items) + 2
rows = "\n".join(
f" {item:<{width}}{stream.table.values[item]:>9}{stream.visible[item]:>8}"
for item in stream.table.items
)
rows += f"\n {'(no drop)':<{width}}{'':>9}{stream.visible_nothing:>8}"
tasks.append(
DropTableTask(
DropTableData(
idx=i,
name=f"{stream.table.monster.lower().replace(' ', '-')}-{seed}",
prompt=(
f"{stream.table.monster}{stream.visible_kills} kills logged.\n\n"
f" {'item':<{width}}{'value':>9}{'drops':>8}\n{rows}\n\n"
f"Estimate the drop rate of every item."
),
system_prompt=SYSTEM,
seed=seed,
visible_kills=self.config.visible_kills,
held_out_kills=self.config.held_out_kills,
),
self.config.task,
)
)
return tasks
@@ -0,0 +1,13 @@
[project]
name = "drop-table-inference"
version = "0.1.0"
description = "drop-table-inference — estimate a drop table from a kill log, graded on the kills you never saw."
requires-python = ">=3.11"
dependencies = ["verifiers==0.3.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["drop_table_inference"]
@@ -0,0 +1,3 @@
from fault_localisation.taskset import FaultTaskset
__all__ = ["FaultTaskset"]
@@ -0,0 +1,94 @@
"""Generating one incident: a service graph, an injected fault, and the logs it produced.
The fault is placed upstream and the noise is emitted downstream, because that is how real
incidents present and it is the one thing a log-reading agent has to get right. A service
whose dependency has stalled logs timeouts, retries and 503s loudly, once per request
while the service that actually broke logs a single line about its connection pool and then
goes quiet. Ranking services by error volume therefore points at the victim every time.
Everything is derived from one seed, so a task is reproducible from its integer id and the
answer key is generated alongside the logs rather than written by hand.
"""
from __future__ import annotations
import random
from dataclasses import dataclass
SERVICES = ["edge", "checkout", "ledger", "inventory", "pricing", "vault"]
# (fault class, the single line the ROOT service emits, what its callers emit)
FAULTS = [
("pool_exhausted", "connection pool exhausted (size=16, waiters=88)", "upstream timeout after 3000ms"),
("disk_full", "write failed: no space left on device", "upstream returned 503"),
("cert_expired", "TLS handshake failed: certificate expired", "upstream connection reset"),
("deadlock", "transaction aborted: deadlock detected", "upstream timeout after 3000ms"),
("oom_kill", "worker killed: cgroup memory limit exceeded", "upstream returned 502"),
("clock_skew", "rejecting request: token nbf 41s in the future", "upstream returned 401"),
]
@dataclass
class Incident:
lines: list[str]
"""Log lines, each prefixed with a stable `L07` style id the answer can cite."""
root: str
fault: str
evidence: str
"""The id of the one line that names the cause. Every other line is a symptom."""
def build(seed: int) -> Incident:
rng = random.Random(seed)
root, *rest = rng.sample(SERVICES, 4)
# The callers: they depend on root, so they are the ones that will look broken.
callers = rest[:2]
bystander = rest[2]
fault, root_line, caller_line = rng.choice(FAULTS)
raw: list[tuple[int, str, str]] = []
clock = rng.randint(0, 40)
# Ordinary traffic first, from everyone, so "the service with any errors" is not free.
for _ in range(6):
clock += rng.randint(1, 4)
svc = rng.choice([root, *callers, bystander])
raw.append((clock, svc, rng.choice([
"handled request in 24ms", "cache hit", "healthcheck ok", "handled request in 61ms",
])))
# The cause: one line, once, and then the root goes quiet.
clock += rng.randint(1, 3)
cause_at = clock
raw.append((clock, root, root_line))
# The symptoms: loud, repeated, and in the wrong place.
for _ in range(rng.randint(9, 14)):
clock += rng.randint(1, 3)
raw.append((clock, rng.choice(callers), caller_line))
# A red herring the bystander emits on its own schedule, unrelated to the fault.
clock += rng.randint(1, 2)
raw.append((clock, bystander, "retrying scheduled job (attempt 2)"))
raw.sort(key=lambda r: r[0])
lines, evidence = [], ""
for i, (at, svc, text) in enumerate(raw):
line_id = f"L{i:02d}"
if at == cause_at and svc == root and text == root_line:
evidence = line_id
lines.append(f"{line_id} t+{at:03d}s {svc:<10} {text}")
return Incident(lines=lines, root=root, fault=fault, evidence=evidence)
def loudest(incident: Incident) -> str:
"""The service with the most error lines — what ranking by volume would answer.
Exported because the probe uses it: if this ever starts coinciding with the root, the
environment has stopped testing the thing it was built to test.
"""
counts: dict[str, int] = {}
for line in incident.lines:
svc = line.split()[2]
if "upstream" in line:
counts[svc] = counts.get(svc, 0) + 1
return max(counts, key=lambda k: counts[k]) if counts else ""
@@ -0,0 +1,119 @@
"""fault-localisation: name what broke, not what is complaining.
One incident, one injected fault, and a log the fault's victims dominate. The service that
actually failed emits a single line and falls silent; the two that depend on it emit a
dozen timeouts between them. Ranking by error volume answers the loudest service, which is
never the right one the probe in this package asserts that, so the day the generator
drifts into making them coincide the environment fails rather than quietly rewarding the
heuristic it exists to punish.
Three answers are required and they are not redundant. The service can be guessed one time
in four. The fault class can be read off a line without knowing which line matters. Citing
the evidence line is what distinguishes having found the cause from having recognised the
vocabulary, and it is why the gate needs all three rather than a majority.
"""
from __future__ import annotations
import json
import re
from pydantic import Field
import verifiers.v1 as vf
from fault_localisation.incident import FAULTS, SERVICES, build
CLASSES = sorted(f[0] for f in FAULTS)
SYSTEM = f"""You are on call. Read the log and name the ROOT CAUSE.
Return one JSON object in a ```json code block with exactly three keys:
"service" the service that actually failed, one of: {", ".join(SERVICES)}
"fault" the failure class, one of: {", ".join(CLASSES)}
"evidence" the id (e.g. "L07") of the single line that names the cause
The service that failed is not the service producing the most errors. Its dependents
produce the errors; it produces one line and then stops."""
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
def parse_answer(reply: str) -> dict[str, str]:
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except json.JSONDecodeError:
return {}
if not isinstance(parsed, dict):
return {}
return {k: str(v).strip() for k, v in parsed.items() if isinstance(v, (str, int))}
class FaultData(vf.TaskData):
seed: int
class FaultTask(vf.Task[FaultData]):
@vf.stop
async def single_turn(self, trace: vf.Trace) -> bool:
return trace.num_turns >= 1
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
incident = build(self.data.seed)
answer = parse_answer(trace.last_reply)
service = float(answer.get("service", "").lower() == incident.root)
fault = float(answer.get("fault", "").lower() == incident.fault)
evidence = float(answer.get("evidence", "").upper() == incident.evidence)
return {
"service": service,
"fault": fault,
"evidence": evidence,
"clean": float(service and fault and evidence),
}
@vf.reward(weight=0.30)
async def service(self, trace: vf.Trace) -> float:
return trace.metrics.get("service", 0.0)
@vf.reward(weight=0.25)
async def fault(self, trace: vf.Trace) -> float:
return trace.metrics.get("fault", 0.0)
@vf.reward(weight=0.25)
async def evidence(self, trace: vf.Trace) -> float:
return trace.metrics.get("evidence", 0.0)
@vf.reward(weight=0.20)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
class FaultConfig(vf.TasksetConfig):
num_tasks: int = Field(64, ge=1)
class FaultTaskset(vf.Taskset[FaultTask, FaultConfig]):
SEED_BASE = 50_000
def load(self) -> list[FaultTask]:
tasks = []
for i in range(self.config.num_tasks):
seed = self.SEED_BASE + i
incident = build(seed)
log = "\n".join(incident.lines)
tasks.append(
FaultTask(
FaultData(
idx=i,
name=f"incident-{seed}",
prompt=f"```\n{log}\n```\n\nName the root cause.",
system_prompt=SYSTEM,
seed=seed,
),
self.config.task,
)
)
return tasks
@@ -0,0 +1,13 @@
[project]
name = "fault-localisation"
version = "0.1.0"
description = "fault-localisation — name the service that broke, not the ones complaining about it."
requires-python = ">=3.11"
dependencies = ["verifiers==0.3.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["fault_localisation"]
@@ -0,0 +1,3 @@
from grand_exchange.taskset import ExchangeTaskset
__all__ = ["ExchangeTaskset"]
@@ -0,0 +1,533 @@
"""Executing the agent's orders against the held-out window, and scoring the result.
Realised profit is unbounded above and has no theoretical optimum the data supports, so
scoring it against one would put the ceiling out of reach which house rule 3 forbids.
Every ratio here is therefore taken against a REFERENCE STRATEGY (`reference_orders`)
that is computed from the visible half and nothing else, and executed through the same
engine as the agent's orders. That is what makes 1.000 reachable by construction, and it
is honest: the model is asked to match a strategy available to anything that can read the
prompt, not to beat hindsight it was never shown.
A reference in the denominator has its own failure mode, and the first cut of this file had
it. If the ceiling is the reference EXACTLY, then above the clip excess profit is worth
nothing while every deviation still costs, so the reward stops being a profit metric and
becomes an imitation score for a strategy whose constants are nowhere in the prompt. It was
measurable: a wider sell band earned sixteen percent more gp and scored 0.812, and one
wasted unit of capital with identical realised profit cost 0.146. Two changes answer it.
The reference's own constants are now the profit-maximising point of its family, swept on
seeds no task is built from. And the ceiling is a BAND TARGET_SHARE of the reference
so the whole plateau around it, and everything above it, scores 1.000.
Three numbers come out of a run:
profit_ratio clip(realised / (TARGET_SHARE x reference realised), 0, 1). No orders is
zero, and a round trip that does not clear the tax is negative and also
zero.
efficiency realised profit per coin LOCKED behind an offer, against the reference's
own. This is where the volume limit bites: a fat margin on an item
carrying three thousand gp a tick is an offer that sits there, and a
sitting offer is capital a better item did not get. It also catches the
plan that fills perfectly at prices that were never worth reaching, which
a plain fill rate scores as discipline.
clean cleared the bar on both. Binary, because a trading run either was worth
doing or was not.
`efficiency` is never a reward on its own one tiny order that traded perfectly would earn
it in full for near-inaction, which is the free-points defect schema-migration shipped with.
It multiplies into profit instead, so it can only ever qualify profit that exists.
"""
from __future__ import annotations
import json
import math
import re
import statistics
from dataclasses import dataclass
from grand_exchange.market import (
DUMP_BASE,
DUMP_CAP,
DUMP_IMPACT,
FILL_SHARE,
TAX,
Item,
Market,
build_market,
)
MAX_ORDERS = 12
BUY_BAND = 0.05
SELL_BAND = 0.05
"""The band the reference buys under its anchor and sells over it.
These two numbers are the reward's denominator, so where they sit is not a taste question.
They are the profit-maximising point of the reference's own family, found by sweeping
(buy band, sell band, crossings threshold, purse cap, size multiplier) over 160 baskets
drawn from SEED 400,000 ONWARD a range no task is ever built from and they are within
three percent of the sweep's best cell over the whole plateau.
The first cut used a sell band of 0.01, and that was the defect a reviewer found: a strategy
with a wider sell band earned sixteen percent more gp than the reference and scored 0.812,
because every ratio here divides by the reference and clips at one. When the denominator is
a strategy that leaves money on the table, the reward's argmax is the denominator's
hyperparameters rather than the profit. Tuning the reference to its own family's optimum is
half the fix; TARGET_SHARE below is the other half."""
TARGET_SHARE = 0.90
"""What counts as a full score, as a share of the reference's realised profit.
A ratio that divides by the reference EXACTLY makes the ceiling a single point, and a point
ceiling turns the reward into an imitation score: excess profit is worth nothing above the
clip while any deviation is punished, so the gradient near the top points at replicating a
strategy the prompt does not contain rather than at making money. Measured on the first cut,
one wasted unit of capital with byte-identical realised profit cost 0.146 of total reward,
and a one-gp change to every buy limit that EARNED 463 gp a basket more cost 0.195.
Normalising against nine tenths of the reference makes the ceiling a BAND. Everything from
"ten percent short of the reference" upward scores 1.000, so the whole plateau around the
reference and everything above it is the argmax, and a rounding difference costs
nothing. The reference still scores exactly 1.000, so house rule 3 is unchanged."""
MIN_CROSSINGS = 0.25
"""Below this share of ticks crossing the mean, the reference will not trade the item at
all. Over 4,000 baskets a reverting series recrosses its own mean 0.362 of the time and a
random walk 0.113, and the threshold sits in the gap: it lets 4.2% of walks through and
turns away 4.1% of reverters. This one line is the difference between a strategy and a
superstition without it the reference buys the widest-swinging line in the basket, which
is exactly the line that has no anchor to revert to."""
MAX_ITEM_SHARE = 0.40
"""No more than this share of the purse behind one name. The anchor is an ESTIMATE, and one
bad estimate carrying the whole purse is the only way this strategy loses money over a
window. Spread over the 3.4 names it trades on average it profits in 3,976 of 4,000
baskets, and `viable_market` refuses the other twenty-four."""
VIABILITY_TRIES = 64
@dataclass(frozen=True)
class Order:
item: str
quantity: int
buy: int
sell: int
@dataclass
class Fills:
"""What one plan actually did in the window."""
realised: float = 0.0
"""Closing capital minus starting capital: the only number that pays."""
paper: float = 0.0
"""What the orders CLAIM, filled in full at the limit prices. Not part of any reward —
it exists so `arith_ok` can ask whether the agent's stated expected profit matches the
sum its own orders imply."""
bought: int = 0
sold: int = 0
planned: int = 0
spent: float = 0.0
dumped: int = 0
orders: int = 0
committed: float = 0.0
"""Coins actually locked behind offers. Capital left idle earns nothing, which is the
only reason allocation is a decision."""
offered: int = 0
"""Units the purse could fund, against `planned` units asked for."""
@property
def roc(self) -> float:
"""Realised profit per coin locked behind an offer.
This is the counterweight the profit term is qualified by, and it replaced a plain
fill rate. A fill rate only asks whether an offer was reachable; it says nothing
about whether reaching it was worth doing, so a plan that bought badly at prices it
was always going to reach scored as disciplined as one that bought well. Return on
the coins actually committed asks both questions at once, and it cannot be won by
committing nothing: it multiplies into profit, which needs the coins.
"""
if self.committed <= 0.0:
return 0.0
return self.realised / self.committed
@property
def conversion(self) -> float:
"""Of the coins locked behind offers, the share that actually bought stock.
The first version of this was realised profit over CLAIMED profit, and it was
gameable in one line: set the sell limit a hair over the buy limit and the claim
goes to nearly nothing, so the ratio goes to nearly anything. Coins converted
cannot be talked down the only way to raise it is to offer a quantity the volume
supports at a price the market reaches, which is the lesson the number is for.
"""
if self.committed <= 0.0:
return 0.0
return min(1.0, self.spent / self.committed)
def paper_profit(orders: list[Order]) -> float:
"""The profit the orders assert, at their own limit prices, net of tax on the sale.
Deliberately the naive multiplication it is the sum the agent is claiming, and
`arith_ok` checks whether the agent can do it."""
return sum(o.quantity * (o.sell * (1.0 - TAX) - o.buy) for o in orders)
def execute(market: Market, orders: list[Order]) -> Fills:
"""Walk the held-out ticks once, filling what the purse and the book support.
Rules, all quoted to the agent:
- COINS ARE LOCKED WHEN THE OFFER IS PLACED, quantity times buy price, in the order
the orders were listed. This is the exchange's own behaviour and it is what makes
the task an allocation: an offer that never fills has still spent the capital a
better offer needed, so ordering the whole basket at its buy limit is not free.
It is also why a buy limit of a billion buys nothing nobody can fund that offer.
- a buy fills at the tick's price when it is at or under the limit; a sell fills at
the tick's price when it is at or over. You are never filled at your own limit
when the market is better than it, because that is not how a limit works.
- at most FILL_SHARE of a tick's volume per side, per item, and only on the ticks the
offer was eligible on flow you were not in the market for is not yours to bank.
- stock bought this tick cannot be sold this tick. Without that, a buy limit above a
sell limit is a free round trip on a single price.
- sale proceeds land in the purse but fund nothing: every offer was placed up front.
- stock still held at the close is forced out at DUMP_BASE under the last price plus
DUMP_IMPACT for every tick's worth of the item's median volume being pushed through.
Depth is taken from the window the stock is actually being sold into, which is the
window the agent estimated from the visible median.
"""
fills = Fills(orders=len(orders), planned=sum(o.quantity for o in orders))
fills.paper = paper_profit(orders)
# Placement: fund each offer in submission order out of one purse.
purse = float(market.capital)
live: list[tuple[Order, Item, int]] = []
for order in orders:
item = market.item(order.item)
if item is None or order.buy <= 0:
continue
qty = min(order.quantity, item.buy_limit, int(purse // order.buy))
if qty <= 0:
continue
purse -= qty * order.buy
live.append((order, item, qty))
fills.committed = market.capital - purse
fills.offered = sum(q for _, _, q in live)
remaining = [q for _, _, q in live]
available = [0] * len(live)
pending = [0] * len(live)
# Capacity carries between eligible ticks instead of being truncated at each one. An
# item that trades two units a tick would otherwise be untradeable rather than thin,
# because a quarter of two is zero every time — and thin is what this is about.
buy_room = [0.0] * len(live)
sell_room = [0.0] * len(live)
proceeds = 0.0
for t in range(market.visible, market.visible + market.held_out):
for i in range(len(live)):
available[i] += pending[i]
pending[i] = 0
for i, (order, item, _) in enumerate(live):
price = item.prices[t]
flow = FILL_SHARE * item.volumes[t]
# Room accrues only on the ticks the order was actually eligible on. Accruing it
# every tick banks the flow of ticks the price never reached, which would let an
# offer fill far past the volume that was ever available to it — and the volume
# limit is the whole reason allocation is a decision here.
if price <= order.buy:
buy_room[i] += flow
if price >= order.sell:
sell_room[i] += flow
if price <= order.buy and remaining[i] > 0:
qty = min(remaining[i], int(buy_room[i]))
if qty > 0:
buy_room[i] -= qty
remaining[i] -= qty
pending[i] += qty
fills.spent += qty * price
fills.bought += qty
if price >= order.sell and available[i] > 0:
qty = min(available[i], int(sell_room[i]))
if qty > 0:
sell_room[i] -= qty
proceeds += qty * price * (1.0 - TAX)
available[i] -= qty
fills.sold += qty
for i, (_, item, _) in enumerate(live):
left = available[i] + pending[i]
if left:
depth = statistics.median(item.volumes[market.visible:])
haircut = min(DUMP_CAP, DUMP_BASE + DUMP_IMPACT * (left / max(1.0, depth)))
fills.dumped += left
proceeds += left * item.prices[-1] * (1.0 - haircut) * (1.0 - TAX)
# Coins locked behind an offer that never filled come back when the window closes, so
# the only thing a wasted offer costs is the profit the capital did not make. That is
# the right price for it: an opportunity cost, not a fine.
fills.realised = proceeds - fills.spent
return fills
def crossings(prices: list[int]) -> float:
"""Share of consecutive ticks that straddle the series' own mean.
The whole discrimination, in one countable number, so it is available to anything that
can read the prompt no variance ratio, no regression, just how often the line cuts
its own average.
"""
if len(prices) < 2:
return 0.0
anchor = statistics.fmean(prices)
above = [p > anchor for p in prices]
return sum(1 for i in range(1, len(above)) if above[i] != above[i - 1]) / (len(above) - 1)
def reference_orders(market: Market) -> list[Order]:
"""The strategy the reward is normalised against, computed from the visible half only.
Per item: anchor on the mean of the visible prices, buy a band under it, sell a band
over it. Then three judgements, and each of them is a way the reward discriminates:
does it revert `crossings` over MIN_CROSSINGS, or the item is skipped. The decoy
swings widest and is worth nothing.
what it pays the average visible price BELOW the buy limit against the average
ABOVE the sell limit because a limit fills at the market, not at
the limit, so a wide reverting item pays far more than its band.
Ranking by the band alone ranks every item identically.
what it can hold a quarter of a typical tick's volume, over the ticks that touched
the buy limit. Capital committed beyond that is capital locked
behind an offer that will not fill.
Best return on capital first, until the purse is gone. Nothing here reads a held-out
tick; every input is a column the agent was shown.
"""
plans = []
for item in market.items:
prices = item.visible_prices(market.visible)
volumes = item.visible_volumes(market.visible)
if crossings(prices) < MIN_CROSSINGS:
continue
anchor = statistics.fmean(prices)
buy = max(1, round(anchor * (1.0 - BUY_BAND)))
sell = max(buy + 1, round(anchor * (1.0 + SELL_BAND)))
lows = [p for p in prices if p <= buy]
highs = [p for p in prices if p >= sell]
if not lows or not highs:
continue
entry, exit_ = statistics.fmean(lows), statistics.fmean(highs)
expected = (exit_ * (1.0 - TAX) - entry) / entry
if expected <= 0:
continue
reachable = int(
FILL_SHARE * statistics.median(volumes) * (len(lows) / len(prices)) * market.held_out
)
qty = min(item.buy_limit, reachable, int(MAX_ITEM_SHARE * market.capital // buy))
if qty <= 0:
continue
plans.append((expected, item.name, qty, buy, sell))
plans.sort(key=lambda p: -p[0])
orders, purse = [], float(market.capital)
for _, name, qty, buy, sell in plans:
qty = min(qty, int(purse // buy))
if qty <= 0:
continue
purse -= qty * buy
orders.append(Order(item=name, quantity=qty, buy=buy, sell=sell))
return orders
def viable_market(seed: int, num_items: int, visible: int, held_out: int) -> Market:
"""The next basket from `seed` onward in which the reference strategy makes money.
Every ratio in the reward divides by the reference's realised profit, so a basket where
the reference loses has no reachable ceiling and would quietly break house rule 3 for
that task the oracle would score below 1.000 and nothing would say why. About one
basket in two thousand is like that, from an anchor the visible half happened to
mis-estimate. Skipping it is a guard, not a crutch, and the seed that was used travels
on the Market so scoring rebuilds exactly the basket that was shown.
"""
for offset in range(VIABILITY_TRIES):
market = build_market(seed + offset, num_items, visible, held_out)
if execute(market, reference_orders(market)).realised > 0:
return market
return build_market(seed, num_items, visible, held_out)
@dataclass
class Outcome:
"""One run, beside the reference run it is normalised against."""
fills: Fills
reference: Fills
stated: float | None
dropped: int = 0
"""Orders that named nothing on the board, or asked for a quantity or price of zero."""
@property
def target(self) -> float:
"""The gp a full score is worth in this basket. See TARGET_SHARE."""
return TARGET_SHARE * self.reference.realised
@property
def profit_ratio(self) -> float:
if self.target <= 0.0:
return 0.0
return min(1.0, max(0.0, self.fills.realised / self.target))
@property
def efficiency(self) -> float:
"""Return on committed capital, against the reference's return on its own.
Not discounted by TARGET_SHARE: it is already a rate rather than a total, so a
strategy that trades well on a small book is not penalised for being small, and
giving it a band as well would hand out the term for free."""
if self.reference.roc <= 0.0:
return 0.0
return min(1.0, max(0.0, self.fills.roc / self.reference.roc))
@property
def conversion_ratio(self) -> float:
"""Of the coins locked, the share that bought stock, against the reference's share.
Recorded, never rewarded `efficiency` is what qualifies profit now. It stays in
the trace because it separates the two ways `efficiency` falls: offers that never
filled, and offers that filled at prices not worth reaching."""
if self.reference.conversion <= 0.0:
return 0.0
return min(1.0, self.fills.conversion / self.reference.conversion)
@property
def discipline(self) -> float:
"""Profit, qualified by what each committed coin earned. A product, not a term
beside it: a term would pay in full for one tiny order that traded perfectly, which
is inaction with a receipt the free-points defect schema-migration shipped with."""
return self.profit_ratio * self.efficiency
@property
def clean(self) -> bool:
"""A bar, not a knife-edge.
It used to require matching the reference on both ratios to within 1e-9, which is a
demand for replication rather than for a good run: a reference plan with one extra
one-unit order that never fills has identical realised profit and lost 0.146 of
total reward. The bar is now TARGET_SHARE of the reference on the money AND on the
return that money made, which is a run that was worth doing however it got there.
Guarded on the reference being profitable at all. Against a reference that LOST
money the bar is negative, so doing nothing clears it and inaction collects this
whole component the floor violation house rule 3 exists to forbid. Unreachable
while `viable_market` only admits profitable references; one relaxed filter from
reachable. `grand_exchange_live.reward.score` carries the same guard.
"""
return (
self.reference.realised > 0
and self.reference.roc > 0
and self.fills.realised >= TARGET_SHARE * self.reference.realised
and self.fills.roc >= TARGET_SHARE * self.reference.roc
)
@property
def arith_ok(self) -> bool:
"""Did the agent's stated expected profit match what its own ACCEPTED orders imply.
Not a reward a probe into the trace. The target model gets simple arithmetic wrong
with thinking off, and without this line a bad multiplication and a bad strategy are
the same low number and nothing in the trace tells them apart. Measured against the
orders that survived parsing, so claiming profit from an order for an item that is
not on the board reads as the arithmetic error it is.
"""
if self.stated is None:
return False
return abs(self.stated - self.fills.paper) <= max(50.0, 0.02 * abs(self.fills.paper))
@property
def arith_error(self) -> float:
if self.stated is None or self.fills.paper == 0.0:
return 0.0
return abs(self.stated - self.fills.paper) / abs(self.fills.paper)
def measure(market: Market, orders: list[Order], stated: float | None) -> Outcome:
"""Run the agent's plan and the reference plan through the same engine."""
clean, seen, dropped = [], set(), 0
for order in orders[:MAX_ORDERS]:
item = market.item(order.item)
# One order per item: holdings pool per item, so two orders on one name would make
# "which sell limit does this unit belong to" a question the engine has to invent an
# answer to. The first one submitted is the one that counts.
if item is None or order.quantity <= 0 or order.buy <= 0 or order.sell <= 0:
dropped += 1
continue
if item.name in seen:
dropped += 1
continue
seen.add(item.name)
clean.append(Order(item.name, order.quantity, order.buy, order.sell))
return Outcome(
fills=execute(market, clean),
reference=execute(market, reference_orders(market)),
stated=stated,
dropped=dropped,
)
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
def parse_orders(reply: str) -> tuple[list[Order], float | None]:
"""The last JSON block in the reply: an object with "orders" and, optionally,
"expected_profit". A bare array of orders is accepted too a model that answers the
question and skips the arithmetic has traded, and should be graded on the trade.
A reply that parses to nothing is an empty plan, not an error: it scores what doing
nothing scores. Raising here would turn a formatting slip into a crashed rollout and
the reward runs inside the metric, so a raise takes the whole rollout with it rather
than scoring zero.
Which is why the except clauses below are wider than they look like they need to be.
Python's `json.loads` is not strict JSON: it accepts the bare literals Infinity,
-Infinity and NaN, and it overflows 1e309 to inf rather than refusing it. RecursionError
is not a ValueError, so twenty thousand nested arrays crashed the decoder; OverflowError
is not a ValueError either, so `int(float("inf"))` crashed the row loop. Both were live
on the first cut and both were reachable from a reply a model can actually emit.
"""
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except (ValueError, RecursionError):
return [], None
stated: float | None = None
if isinstance(parsed, dict):
rows = parsed.get("orders")
value = parsed.get("expected_profit")
# Finite only. `Infinity` and `NaN` parse, and either one propagates through
# `arith_error` into the trace as a non-finite metric, which is a corrupted training
# signal rather than a bad answer. An unusable claim is no claim.
if isinstance(value, (int, float)) and not isinstance(value, bool):
try:
stated = float(value) if math.isfinite(value) else None
except OverflowError:
stated = None
else:
rows = parsed
if not isinstance(rows, list):
return [], stated
orders = []
for row in rows:
if not isinstance(row, dict) or not isinstance(row.get("item"), str):
continue
try:
orders.append(
Order(
item=row["item"],
quantity=int(row.get("quantity", 0)),
buy=int(row.get("buy", 0)),
sell=int(row.get("sell", 0)),
)
)
except (TypeError, ValueError, OverflowError):
continue
return orders, stated
@@ -0,0 +1,244 @@
"""The market generator: price and volume streams with structure a reader can find.
If prices were a random walk this environment would be worthless and would still look
fine expected profit is zero for every strategy, so the reward is noise, no oracle
exists, and the numbers coming out of it would be plausible and meaningless. So the
structure is put in deliberately and is the whole design:
price_t = fundamental_t * (1 + x_t)
`fundamental_t` drifts by FUND_DRIFT a tick, worth about one percent over a whole stream.
`x_t` is an AR(1) around zero with PHI decay and a stationary spread of NOISE_SD nine
percent on the deep items, fifteen or sixteen on the thin ones. Noise dominates drift by an
order of magnitude, which is what makes the mean of the visible prices a usable estimate of
the fundamental, and "buy under the estimate, sell over it" a real strategy rather than a
superstition. `book.reference_orders` is that strategy; `probe.py` measures how far it beats
trading at random, and refuses to pass if the margin is thin.
The visible and held-out windows are two halves of ONE stream from ONE seed: the graded
ticks are the next ticks the generator would have produced, not a differently-seeded
population that could have moved somewhere the visible half gave no warning of.
Two things are here to punish reading the price column alone. One or two items per basket do
not revert at all (see DRIFT_STEP and WALKS) and swing widest of everything on screen. And
liquidity price times volume, gp a tick is uncorrelated with price, so the fattest
visible margins sit on the items that can absorb the least of the purse.
Item names are invented. Formulas and market mechanics are facts about a kind of game;
item tables are somebody's copyrighted content, and none of it is here.
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass
# --- the rules of the exchange, quoted to the agent verbatim in the prompt --------------
TAX = 0.01
"""Charged on every sale, the GE's own. It is the counterweight that kills thin flips:
a round trip has to clear it twice over before it is worth doing, so "trade everything"
is a losing strategy rather than a neutral one."""
FILL_SHARE = 0.25
"""Share of a tick's volume one participant can take, and it accrues only on the ticks an
offer was actually eligible on. Without it a fat margin on an item carrying three thousand
gp a tick is worth as much as one carrying a hundred thousand, and the environment stops
measuring allocation."""
DUMP_BASE = 0.03
DUMP_IMPACT = 0.05
DUMP_CAP = 0.40
"""Stock still held when the window closes is forced out at the last price, minus a haircut
of DUMP_BASE plus DUMP_IMPACT for every full tick's worth of the item's median volume that
has to be pushed through, capped at DUMP_CAP.
A FLAT haircut was the first cut of this and it was the wrong shape. Under a flat five
percent, the cost of holding stock at the close was the same whether the leftovers were
twenty units of an item that trades thirty a tick or four thousand units of one that trades
six hundred so oversizing was only ever punished through the purse, and the purse punishes
it as a cliff: the first over-large offer eats the whole budget and everything after it is
never placed. A cliff is not a gradient. Scaling the haircut by position-over-depth prices
the thing that is actually true forcing size out costs you in proportion to how much of
the book you are pushing through and it makes reading the volume column pay smoothly,
which is what the column is here to teach.
It also prices a bad anchor. A buy limit set too high fills fast and leaves the sell limit
out of reach, so the position that a sloppy anchor builds is exactly the position that has
to be dumped, and now it is dumped at a price that scales with its size."""
STARTING_CAPITAL = 250_000
WALKS = (1, 2)
"""How many of the basket's items do not mean-revert, drawn uniformly from this range.
It used to be exactly one, and a fixed count is a free prior: "drop the single widest line
on the board" scores what computing `book.crossings` scores, without computing anything.
It is the same defect `bot_detection` ships an assertion against a class balance the model
can count on is a class balance it will use instead of the discriminator. With the count
unknown the shape statistic is the only thing that answers the question, and a basket can
punish both over- and under-rejection."""
SEED_BASE = 60_000
"""Where task seeds start. Lives here rather than on the taskset so `probe.py`, which
cannot import the taskset without `verifiers`, grades the baskets a run would actually
serve rather than a different set that happens to share a generator."""
# --- stream parameters -----------------------------------------------------------------
PHI = 0.45
"""AR(1) decay of the mispricing. Half-life under a tick, so a visible window holds many
independent draws around the fundamental which is what makes the mean of it an estimate
rather than a guess and a held-out window holds many excursions, so the reward is not one
lucky draw."""
FUND_DRIFT = 0.0008
"""Per-tick drift of the fundamental. Small on purpose: the fundamental has to be
ESTIMABLE from the visible half or there is nothing to learn."""
VOLUME_SD = 0.35
DRIFT_STEP = 0.055
"""Per-tick step of the items that do not mean-revert at all — their price is a pure random
walk, so the fundamental IS wherever it last was.
This is the trap the whole environment is built around, put inside the task instead of
left as a hazard the designer has to avoid. A random walk has no anchor, so buying under
its moving average is not a discount, it is a coin flip that pays the tax and the dump
slippage every time. On screen it is the widest-swinging line in the basket and therefore
the most attractive one, because amplitude is what a careless reader ranks by. The two can
only be told apart by SHAPE: a reverting series crosses its own mean constantly, a walk
wanders on one side of it for a dozen ticks at a time. `book.crossings` is that statistic
and the reference strategy will not trade an item that fails it."""
# (names, base price range, base volume range, buy limit, mispricing spread)
#
# What separates these is LIQUIDITY IN GP PER TICK — base price times base volume — and it
# is deliberately uncorrelated with the price. That is the allocation problem: the purse is
# 250,000 gp and a quarter of the flow over thirty ticks is what any one offer can absorb,
# so a deep tier can take a third of the purse and a thin one can take a twentieth of it no
# matter how good the margin looks. An earlier cut of this file made the expensive items the
# thin ones, which sounds right and is not: eight units a tick of a 46,000 gp item is 368,000
# gp of flow, the deepest thing on the board. Thin means small in coins, not small in units.
#
# The two thin tiers also carry the widest mispricing spread, so they show the fattest margin
# and can absorb the least. That is the trap, and it is the same trap either way an agent
# falls into it: ignore the volume column and either the offers sit unfilled or the purse
# sits idle.
TIERS = [
# deep, ~40k-130k gp a tick: this is where the purse actually goes
(["Thornroot poultice", "Chipped bone charm", "Bogwater draught", "Coarse fletching feather"],
(90, 170), (400, 800), 5000, 0.09),
(["Emberglass shard", "Stormrune tablet", "Marrowsteel nail", "Pale grimoire page"],
(900, 1700), (40, 90), 400, 0.09),
# thin, ~3k-15k gp a tick, and the widest swings on the board
(["Gilded harpoon head", "Cinderweave cloak", "Wyrmbone talisman", "Frostbitten ledger"],
(200, 420), (12, 30), 800, 0.15),
(["Duskforged sigil", "Heart of the sunken cairn", "Voidglass lens", "Tideworn crown"],
(6000, 14000), (1.2, 3.0), 40, 0.16),
]
@dataclass(frozen=True)
class Item:
name: str
reverting: bool
"""Whether this item has an anchor at all. Never shown to the agent — it is here so the
probe can assert that the trap is a trap, and that the reference avoids it for a reason
rather than by luck."""
buy_limit: int
"""Units per item per window, the GE's own limit. With finite capital it is what turns
the task into an allocation problem instead of a single pick."""
prices: list[int]
volumes: list[int]
def visible_prices(self, visible: int) -> list[int]:
return self.prices[:visible]
def visible_volumes(self, visible: int) -> list[int]:
return self.volumes[:visible]
@dataclass(frozen=True)
class Market:
seed: int
"""Carried on the basket so the taskset can store the seed it actually used. Baskets are
skipped when the reference strategy is not profitable in them (see `book.viable_market`),
so the seed a task was built from is not always the one it was asked for."""
items: list[Item]
visible: int
held_out: int
capital: int
def item(self, name: str) -> Item | None:
wanted = name.strip().casefold()
for item in self.items:
if item.name.casefold() == wanted:
return item
return None
def _volumes(rng: random.Random, volume: float, ticks: int) -> list[int]:
return [max(1, round(volume * math.exp(rng.gauss(0.0, VOLUME_SD)))) for _ in range(ticks)]
def _walk(rng: random.Random, base: float, ticks: int) -> list[int]:
"""The decoy: no anchor, no reversion, just a wide random walk. Whatever a moving
average says about where this price belongs is a statement about the past only."""
price = base
out = []
for _ in range(ticks):
price *= 1.0 + rng.gauss(0.0, DRIFT_STEP)
out.append(max(1, round(price)))
return out
def _stream(rng: random.Random, base: float, volume: float, noise_sd: float, ticks: int
) -> tuple[list[int], list[int]]:
"""One item's price and volume history, visible and held-out ticks together."""
# eps is scaled so the AR(1) settles at exactly noise_sd rather than drifting toward it
# over the first few ticks; the visible half would otherwise be quieter than the graded
# half and every anchor estimated from it would be too tight.
eps_sd = noise_sd * math.sqrt(1.0 - PHI * PHI)
x = rng.gauss(0.0, noise_sd)
fundamental = base
prices = []
for _ in range(ticks):
fundamental *= 1.0 + rng.gauss(0.0, FUND_DRIFT)
x = PHI * x + rng.gauss(0.0, eps_sd)
prices.append(max(1, round(fundamental * (1.0 + x))))
return prices, _volumes(rng, volume, ticks)
def build_market(seed: int, num_items: int, visible: int, held_out: int) -> Market:
"""One basket, from one seed.
Every tier is represented before any tier repeats, so a thin item and a deep one are
always both on the table: the allocation choice is the task, and a basket that happened
to be all-deep or all-thin would not pose it.
"""
rng = random.Random(seed)
ticks = visible + held_out
order = list(range(len(TIERS)))
rng.shuffle(order)
picks = [order[i % len(order)] for i in range(num_items)]
# Which slots are walks, and how many, are both drawn here. The tier is drawn
# independently of the walk flag, so a basket where the walk was always the cheap item —
# solvable by reading the price column and never the shape — cannot arise.
decoys = set(rng.sample(range(num_items), rng.randint(*WALKS)))
used: set[str] = set()
items = []
for slot, tier_idx in enumerate(picks):
names, (lo, hi), (vlo, vhi), limit, noise_sd = TIERS[tier_idx]
choices = [n for n in names if n not in used] or names
name = rng.choice(choices)
used.add(name)
base, volume = rng.uniform(lo, hi), rng.uniform(vlo, vhi)
if slot in decoys:
prices, volumes = _walk(rng, base, ticks), _volumes(rng, volume, ticks)
else:
prices, volumes = _stream(rng, base, volume, noise_sd, ticks)
items.append(Item(name=name, reverting=slot not in decoys, buy_limit=limit,
prices=prices, volumes=volumes))
items.sort(key=lambda i: i.name)
return Market(seed=seed, items=items, visible=visible, held_out=held_out,
capital=STARTING_CAPITAL)
@@ -0,0 +1,230 @@
"""grand-exchange: place orders that are executed against a window you have not seen.
The agent reads 56 ticks of price and volume for five items and submits limit orders. The
orders are then run against the NEXT 30 ticks of the same streams the ones the generator
would have produced next against that window's actual volume, with a 1% sale tax, a
per-item buy limit and one finite purse. Realised profit is what pays.
Four judgements separate a good plan from a plan, and every one of them is available from
the columns on screen:
the anchor the mean of 56 visible ticks estimates the fundamental about four times
more tightly than the last tick does, and a limit set off a bad anchor
fills fast into stock the sell limit never reaches.
the trap one or two items per basket are a random walk, not a reverting series.
They swing widest, which is what a careless reader ranks by, and they have
no anchor to revert to. `book.crossings` is the only thing that tells them
apart, and the COUNT is not fixed, so "drop the widest one" is not a
substitute for computing it.
the size a quarter of a tick's volume, over the ticks that will reach your limit —
offers beyond that are coins locked behind something that cannot fill, and
stock beyond that is stock the close-out haircut charges you for.
the allocation finite purse, funded in submission order, so the best return has to go
first and the rest has to fit.
Realised profit has no ceiling and no optimum the data supports, so scoring it against one
would put 1.000 out of reach. Every ratio is taken against a REFERENCE STRATEGY instead,
which makes those four judgements and nothing else, from the visible half only, and runs
through the same execution engine. Its constants are the profit-maximising point of its own
family, swept on seeds no task is built from a denominator that leaves money on the table
makes the reward an imitation score rather than a profit metric and the ceiling is nine
tenths of it, so the plateau around it and everything above it all score 1.000.
`probe.py` measures how far the reference beats trading at random, and refuses to pass if
that margin closes, because a reward normalised against a reference that is no better than
chance is noise wearing a number. It also refuses to pass if a plan that never estimates the
anchor and never applies the trap filter scores as well as one that does.
Three rewards:
profit clip(realised / (0.90 × reference realised), 0, 1). No orders is exactly zero,
and so is any round trip that fails to clear the tax.
discipline profit × realised-per-coin-committed, relative to the reference's. A PRODUCT:
as a term beside profit it would pay in full for one tiny order that traded
perfectly, which is inaction with a receipt.
gate cleared both bars. Binary a trading run was worth doing or it was not. A
bar, not a match: demanding the reference's exact result made a wasted unit of
capital cost 0.146 with byte-identical profit, which is a gradient pointing at
replication instead of at money.
`arith_ok` is recorded and never rewarded: whether the agent's stated expected profit
matches what its own orders imply. The target model gets simple arithmetic wrong with
thinking off, and without this line in the trace a bad multiplication and a bad strategy
are the same low score.
"""
from __future__ import annotations
from typing import ClassVar
from pydantic import Field
import verifiers.v1 as vf
from grand_exchange.book import MAX_ORDERS, TARGET_SHARE, measure, parse_orders, viable_market
from grand_exchange.market import (
DUMP_BASE,
DUMP_CAP,
DUMP_IMPACT,
FILL_SHARE,
SEED_BASE,
STARTING_CAPITAL,
TAX,
build_market,
)
SYSTEM = f"""You are trading on the Grand Exchange.
You will be shown recent price and volume history for a basket of items. You submit limit
orders. They are executed tick by tick against a FUTURE window you have not seen, drawn
from the same streams.
The rules of the exchange:
- A buy fills at the tick's price when that price is at or below your buy limit. A sell
fills at the tick's price when that price is at or above your sell limit.
- PLACING an offer locks quantity × buy price out of your purse straight away, whether
or not it ever fills. Offers are funded in the order you list them; once the purse is
gone the rest are not placed at all. You start with {STARTING_CAPITAL:,} gp and that is
the entire budget sale proceeds return to you but fund no further offers.
- You can take at most {FILL_SHARE:.0%} of a tick's traded volume, per item, per side, and only
on ticks where your limit was actually reached.
- Each item has a buy limit: the most units of it you may buy in the whole window.
- {TAX:.0%} tax is charged on every sale.
- Stock bought on a tick cannot be sold on the same tick.
- Stock you still hold when the window closes is forced out at the last price minus {DUMP_BASE:.0%},
plus a further {DUMP_IMPACT:.0%} for every full tick's worth of that item's median volume you have
to push through, capped at {DUMP_CAP:.0%}; then taxed. Buying what you cannot sell is a loss,
not a hold, and it is a bigger loss the more of it there is.
Return ONE ```json code block containing an object with:
"expected_profit": the profit you expect your orders to make, in gp, as a number
"orders": a list of at most {MAX_ORDERS} objects, each with "item" (exactly as named above),
"quantity", "buy" and "sell"
At most one order per item; later duplicates are discarded. Example shape:
```json
{{"expected_profit": 12000,
"orders": [{{"item": "Bogwater draught", "quantity": 400, "buy": 118, "sell": 129}}]}}
```"""
class ExchangeData(vf.TaskData):
seed: int
"""Rebuilds both windows exactly. The held-out ticks are never serialized here — they
are regenerated at scoring time from this integer, so the task data cannot leak them."""
num_items: int
visible: int
held_out: int
class ExchangeTask(vf.Task[ExchangeData]):
@vf.stop
async def single_turn(self, trace: vf.Trace) -> bool:
return trace.num_turns >= 1
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
"""Execute the plan and the reference once; every reward reads this. Recomputing
per reward is how two rewards end up disagreeing about the same episode."""
market = build_market(
self.data.seed, self.data.num_items, self.data.visible, self.data.held_out
)
orders, stated = parse_orders(trace.last_reply)
outcome = measure(market, orders, stated)
return {
"profit_ratio": outcome.profit_ratio,
"efficiency": outcome.efficiency,
"discipline": outcome.discipline,
"clean": float(outcome.clean),
"realised": outcome.fills.realised,
"reference_realised": outcome.reference.realised,
"target_realised": outcome.target,
"roc": outcome.fills.roc,
"reference_roc": outcome.reference.roc,
# Recorded, not rewarded. `efficiency` falls for two different reasons and this
# is what tells them apart in the trace: offers that never filled show here,
# offers that filled at prices not worth reaching do not.
"conversion_ratio": outcome.conversion_ratio,
"paper": outcome.fills.paper,
"filled": float(outcome.fills.bought),
"planned": float(outcome.fills.planned),
"dumped": float(outcome.fills.dumped),
"committed": outcome.fills.committed / max(market.capital, 1),
"conversion": outcome.fills.conversion,
"orders_dropped": float(outcome.dropped),
# Recorded, never rewarded: bad multiplication and bad strategy are different
# failures and score the same without this.
"arith_ok": float(outcome.arith_ok),
"arith_error": outcome.arith_error,
}
@vf.reward(weight=0.45)
async def profit(self, trace: vf.Trace) -> float:
return trace.metrics.get("profit_ratio", 0.0)
@vf.reward(weight=0.30)
async def discipline(self, trace: vf.Trace) -> float:
"""profit_ratio × efficiency, computed once in `scan`. Read from the metric rather
than multiplied here: two rewards that recompute the same product are two rewards
that will eventually disagree about the same episode."""
return trace.metrics.get("discipline", 0.0)
@vf.reward(weight=0.25)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
class ExchangeConfig(vf.TasksetConfig):
num_tasks: int = Field(48, ge=1)
num_items: int = Field(5, ge=1)
visible: int = Field(56, ge=8)
"""Ticks the agent reads. Enough of them that the mean is an anchor and not a rumour:
shorten this and the reference strategy stops being reliably profitable, which would
take the reward's denominator with it."""
held_out: int = Field(30, ge=1)
"""Ticks it is executed against and never sees."""
class ExchangeTaskset(vf.Taskset[ExchangeTask, ExchangeConfig]):
SEED_BASE: ClassVar[int] = SEED_BASE
def load(self) -> list[ExchangeTask]:
tasks, seed = [], self.SEED_BASE
for i in range(self.config.num_tasks):
# `viable_market` may skip a basket the reference loses money in, so the seed
# stored is the one it landed on and the next scan starts after it — otherwise
# a skip would hand two tasks the same basket.
market = viable_market(
seed, self.config.num_items, self.config.visible, self.config.held_out
)
seed = market.seed + 1
blocks = []
for item in market.items:
prices = " ".join(str(p) for p in item.visible_prices(market.visible))
volumes = " ".join(str(v) for v in item.visible_volumes(market.visible))
blocks.append(
f"{item.name} (buy limit {item.buy_limit} per window)\n"
f" price {prices}\n"
f" volume {volumes}"
)
board = "\n\n".join(blocks)
tasks.append(
ExchangeTask(
ExchangeData(
idx=i,
name=f"basket-{market.seed}",
prompt=(
f"Last {market.visible} ticks, oldest first. Your orders will be "
f"executed against the next {market.held_out}.\n\n{board}\n\n"
f"Purse: {market.capital:,} gp. Place your orders."
),
system_prompt=SYSTEM,
seed=market.seed,
num_items=self.config.num_items,
visible=self.config.visible,
held_out=self.config.held_out,
),
self.config.task,
)
)
return tasks
@@ -0,0 +1,13 @@
[project]
name = "grand-exchange"
version = "0.1.0"
description = "grand-exchange — place orders executed against a market window you never saw."
requires-python = ">=3.11"
dependencies = ["verifiers==0.3.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["grand_exchange"]
@@ -0,0 +1,35 @@
"""grand-exchange-live — the stepped form of `grand-exchange`.
The engine (`live.py`), the market it runs on (`market.py`, `book.py` copies of the
one-shot form's, held byte-identical by `tests/test_market_copy.py`), the reward
(`reward.py`), the render/parse boundary (`protocol.py`) and the taskset that wires them to
`verifiers` (`taskset.py`).
Importing this package imports `verifiers`, through `taskset`. `probe.py` deliberately
does not: it registers each package directory as a namespace and imports the leaf modules
under it, so the floor-and-ceiling gate runs with the training stack uninstalled. Nothing
below `taskset.py` may grow a `verifiers` import, or that stops being true.
"""
from grand_exchange_live.live import HORIZON, TURNS, Leg, Reference, Result, play
from grand_exchange_live.protocol import SYSTEM, Sheet, opening, parse_sheet, render
from grand_exchange_live.reward import TARGET_SHARE, score
from grand_exchange_live.taskset import LiveExchangeEnv, LiveExchangeTaskset
__all__ = [
"HORIZON",
"SYSTEM",
"TARGET_SHARE",
"TURNS",
"Leg",
"LiveExchangeEnv",
"LiveExchangeTaskset",
"Reference",
"Result",
"Sheet",
"opening",
"parse_sheet",
"play",
"render",
"score",
]
@@ -0,0 +1,532 @@
"""Executing the agent's orders against the held-out window, and scoring the result.
A COPY of `grand_exchange/book.py`, held byte-identical in behaviour by
`tests/test_market_copy.py` see the note at the top of `market.py` for why it is a copy
and not an import. Nothing in this file steps: it is the one-shot engine, kept here because
the live reference bootstraps its first quote out of `reference_orders`, `crossings` and
`MIN_CROSSINGS`, and because `execute` is the fixed point the stepped engine is checked
against (`live.py`'s `test_single_look_matches_one_shot`). The stepped engine is `live.py`.
Realised profit is unbounded above and has no theoretical optimum the data supports, so
scoring it against one would put the ceiling out of reach which house rule 3 forbids.
Every ratio here is therefore taken against a REFERENCE STRATEGY (`reference_orders`)
that is computed from the visible half and nothing else, and executed through the same
engine as the agent's orders. That is what makes 1.000 reachable by construction, and it
is honest: the model is asked to match a strategy available to anything that can read the
prompt, not to beat hindsight it was never shown.
A reference in the denominator has its own failure mode, and the first cut of this file had
it. If the ceiling is the reference EXACTLY, then above the clip excess profit is worth
nothing while every deviation still costs, so the reward stops being a profit metric and
becomes an imitation score for a strategy whose constants are nowhere in the prompt. It was
measurable: a wider sell band earned sixteen percent more gp and scored 0.812, and one
wasted unit of capital with identical realised profit cost 0.146. Two changes answer it.
The reference's own constants are now the profit-maximising point of its family, swept on
seeds no task is built from. And the ceiling is a BAND TARGET_SHARE of the reference
so the whole plateau around it, and everything above it, scores 1.000.
Three numbers come out of a run:
profit_ratio clip(realised / (TARGET_SHARE x reference realised), 0, 1). No orders is
zero, and a round trip that does not clear the tax is negative and also
zero.
efficiency realised profit per coin LOCKED behind an offer, against the reference's
own. This is where the volume limit bites: a fat margin on an item
carrying three thousand gp a tick is an offer that sits there, and a
sitting offer is capital a better item did not get. It also catches the
plan that fills perfectly at prices that were never worth reaching, which
a plain fill rate scores as discipline.
clean cleared the bar on both. Binary, because a trading run either was worth
doing or was not.
`efficiency` is never a reward on its own one tiny order that traded perfectly would earn
it in full for near-inaction, which is the free-points defect schema-migration shipped with.
It multiplies into profit instead, so it can only ever qualify profit that exists.
"""
from __future__ import annotations
import json
import math
import re
import statistics
from dataclasses import dataclass
from grand_exchange_live.market import (
DUMP_BASE,
DUMP_CAP,
DUMP_IMPACT,
FILL_SHARE,
TAX,
Item,
Market,
build_market,
)
MAX_ORDERS = 12
BUY_BAND = 0.05
SELL_BAND = 0.05
"""The band the reference buys under its anchor and sells over it.
These two numbers are the reward's denominator, so where they sit is not a taste question.
They are the profit-maximising point of the reference's own family, found by sweeping
(buy band, sell band, crossings threshold, purse cap, size multiplier) over 160 baskets
drawn from SEED 400,000 ONWARD a range no task is ever built from and they are within
three percent of the sweep's best cell over the whole plateau.
The first cut used a sell band of 0.01, and that was the defect a reviewer found: a strategy
with a wider sell band earned sixteen percent more gp than the reference and scored 0.812,
because every ratio here divides by the reference and clips at one. When the denominator is
a strategy that leaves money on the table, the reward's argmax is the denominator's
hyperparameters rather than the profit. Tuning the reference to its own family's optimum is
half the fix; TARGET_SHARE below is the other half."""
TARGET_SHARE = 0.90
"""What counts as a full score, as a share of the reference's realised profit.
A ratio that divides by the reference EXACTLY makes the ceiling a single point, and a point
ceiling turns the reward into an imitation score: excess profit is worth nothing above the
clip while any deviation is punished, so the gradient near the top points at replicating a
strategy the prompt does not contain rather than at making money. Measured on the first cut,
one wasted unit of capital with byte-identical realised profit cost 0.146 of total reward,
and a one-gp change to every buy limit that EARNED 463 gp a basket more cost 0.195.
Normalising against nine tenths of the reference makes the ceiling a BAND. Everything from
"ten percent short of the reference" upward scores 1.000, so the whole plateau around the
reference and everything above it is the argmax, and a rounding difference costs
nothing. The reference still scores exactly 1.000, so house rule 3 is unchanged."""
MIN_CROSSINGS = 0.25
"""Below this share of ticks crossing the mean, the reference will not trade the item at
all. Over 4,000 baskets a reverting series recrosses its own mean 0.362 of the time and a
random walk 0.113, and the threshold sits in the gap: it lets 4.2% of walks through and
turns away 4.1% of reverters. This one line is the difference between a strategy and a
superstition without it the reference buys the widest-swinging line in the basket, which
is exactly the line that has no anchor to revert to."""
MAX_ITEM_SHARE = 0.40
"""No more than this share of the purse behind one name. The anchor is an ESTIMATE, and one
bad estimate carrying the whole purse is the only way this strategy loses money over a
window. Spread over the 3.4 names it trades on average it profits in 3,976 of 4,000
baskets, and `viable_market` refuses the other twenty-four."""
VIABILITY_TRIES = 64
@dataclass(frozen=True)
class Order:
item: str
quantity: int
buy: int
sell: int
@dataclass
class Fills:
"""What one plan actually did in the window."""
realised: float = 0.0
"""Closing capital minus starting capital: the only number that pays."""
paper: float = 0.0
"""What the orders CLAIM, filled in full at the limit prices. Not part of any reward —
it exists so `arith_ok` can ask whether the agent's stated expected profit matches the
sum its own orders imply."""
bought: int = 0
sold: int = 0
planned: int = 0
spent: float = 0.0
dumped: int = 0
orders: int = 0
committed: float = 0.0
"""Coins actually locked behind offers. Capital left idle earns nothing, which is the
only reason allocation is a decision."""
offered: int = 0
"""Units the purse could fund, against `planned` units asked for."""
@property
def roc(self) -> float:
"""Realised profit per coin locked behind an offer.
This is the counterweight the profit term is qualified by, and it replaced a plain
fill rate. A fill rate only asks whether an offer was reachable; it says nothing
about whether reaching it was worth doing, so a plan that bought badly at prices it
was always going to reach scored as disciplined as one that bought well. Return on
the coins actually committed asks both questions at once, and it cannot be won by
committing nothing: it multiplies into profit, which needs the coins.
"""
if self.committed <= 0.0:
return 0.0
return self.realised / self.committed
@property
def conversion(self) -> float:
"""Of the coins locked behind offers, the share that actually bought stock.
The first version of this was realised profit over CLAIMED profit, and it was
gameable in one line: set the sell limit a hair over the buy limit and the claim
goes to nearly nothing, so the ratio goes to nearly anything. Coins converted
cannot be talked down the only way to raise it is to offer a quantity the volume
supports at a price the market reaches, which is the lesson the number is for.
"""
if self.committed <= 0.0:
return 0.0
return min(1.0, self.spent / self.committed)
def paper_profit(orders: list[Order]) -> float:
"""The profit the orders assert, at their own limit prices, net of tax on the sale.
Deliberately the naive multiplication it is the sum the agent is claiming, and
`arith_ok` checks whether the agent can do it."""
return sum(o.quantity * (o.sell * (1.0 - TAX) - o.buy) for o in orders)
def execute(market: Market, orders: list[Order]) -> Fills:
"""Walk the held-out ticks once, filling what the purse and the book support.
Rules, all quoted to the agent:
- COINS ARE LOCKED WHEN THE OFFER IS PLACED, quantity times buy price, in the order
the orders were listed. This is the exchange's own behaviour and it is what makes
the task an allocation: an offer that never fills has still spent the capital a
better offer needed, so ordering the whole basket at its buy limit is not free.
It is also why a buy limit of a billion buys nothing nobody can fund that offer.
- a buy fills at the tick's price when it is at or under the limit; a sell fills at
the tick's price when it is at or over. You are never filled at your own limit
when the market is better than it, because that is not how a limit works.
- at most FILL_SHARE of a tick's volume per side, per item, and only on the ticks the
offer was eligible on flow you were not in the market for is not yours to bank.
- stock bought this tick cannot be sold this tick. Without that, a buy limit above a
sell limit is a free round trip on a single price.
- sale proceeds land in the purse but fund nothing: every offer was placed up front.
- stock still held at the close is forced out at DUMP_BASE under the last price plus
DUMP_IMPACT for every tick's worth of the item's median volume being pushed through.
Depth is taken from the window the stock is actually being sold into, which is the
window the agent estimated from the visible median.
"""
fills = Fills(orders=len(orders), planned=sum(o.quantity for o in orders))
fills.paper = paper_profit(orders)
# Placement: fund each offer in submission order out of one purse.
purse = float(market.capital)
live: list[tuple[Order, Item, int]] = []
for order in orders:
item = market.item(order.item)
if item is None or order.buy <= 0:
continue
qty = min(order.quantity, item.buy_limit, int(purse // order.buy))
if qty <= 0:
continue
purse -= qty * order.buy
live.append((order, item, qty))
fills.committed = market.capital - purse
fills.offered = sum(q for _, _, q in live)
remaining = [q for _, _, q in live]
available = [0] * len(live)
pending = [0] * len(live)
# Capacity carries between eligible ticks instead of being truncated at each one. An
# item that trades two units a tick would otherwise be untradeable rather than thin,
# because a quarter of two is zero every time — and thin is what this is about.
buy_room = [0.0] * len(live)
sell_room = [0.0] * len(live)
proceeds = 0.0
for t in range(market.visible, market.visible + market.held_out):
for i in range(len(live)):
available[i] += pending[i]
pending[i] = 0
for i, (order, item, _) in enumerate(live):
price = item.prices[t]
flow = FILL_SHARE * item.volumes[t]
# Room accrues only on the ticks the order was actually eligible on. Accruing it
# every tick banks the flow of ticks the price never reached, which would let an
# offer fill far past the volume that was ever available to it — and the volume
# limit is the whole reason allocation is a decision here.
if price <= order.buy:
buy_room[i] += flow
if price >= order.sell:
sell_room[i] += flow
if price <= order.buy and remaining[i] > 0:
qty = min(remaining[i], int(buy_room[i]))
if qty > 0:
buy_room[i] -= qty
remaining[i] -= qty
pending[i] += qty
fills.spent += qty * price
fills.bought += qty
if price >= order.sell and available[i] > 0:
qty = min(available[i], int(sell_room[i]))
if qty > 0:
sell_room[i] -= qty
proceeds += qty * price * (1.0 - TAX)
available[i] -= qty
fills.sold += qty
for i, (_, item, _) in enumerate(live):
left = available[i] + pending[i]
if left:
depth = statistics.median(item.volumes[market.visible:])
haircut = min(DUMP_CAP, DUMP_BASE + DUMP_IMPACT * (left / max(1.0, depth)))
fills.dumped += left
proceeds += left * item.prices[-1] * (1.0 - haircut) * (1.0 - TAX)
# Coins locked behind an offer that never filled come back when the window closes, so
# the only thing a wasted offer costs is the profit the capital did not make. That is
# the right price for it: an opportunity cost, not a fine.
fills.realised = proceeds - fills.spent
return fills
def crossings(prices: list[int]) -> float:
"""Share of consecutive ticks that straddle the series' own mean.
The whole discrimination, in one countable number, so it is available to anything that
can read the prompt no variance ratio, no regression, just how often the line cuts
its own average.
"""
if len(prices) < 2:
return 0.0
anchor = statistics.fmean(prices)
above = [p > anchor for p in prices]
return sum(1 for i in range(1, len(above)) if above[i] != above[i - 1]) / (len(above) - 1)
def reference_orders(market: Market) -> list[Order]:
"""The strategy the reward is normalised against, computed from the visible half only.
Per item: anchor on the mean of the visible prices, buy a band under it, sell a band
over it. Then three judgements, and each of them is a way the reward discriminates:
does it revert `crossings` over MIN_CROSSINGS, or the item is skipped. The decoy
swings widest and is worth nothing.
what it pays the average visible price BELOW the buy limit against the average
ABOVE the sell limit because a limit fills at the market, not at
the limit, so a wide reverting item pays far more than its band.
Ranking by the band alone ranks every item identically.
what it can hold a quarter of a typical tick's volume, over the ticks that touched
the buy limit. Capital committed beyond that is capital locked
behind an offer that will not fill.
Best return on capital first, until the purse is gone. Nothing here reads a held-out
tick; every input is a column the agent was shown.
"""
plans = []
for item in market.items:
prices = item.visible_prices(market.visible)
volumes = item.visible_volumes(market.visible)
if crossings(prices) < MIN_CROSSINGS:
continue
anchor = statistics.fmean(prices)
buy = max(1, round(anchor * (1.0 - BUY_BAND)))
sell = max(buy + 1, round(anchor * (1.0 + SELL_BAND)))
lows = [p for p in prices if p <= buy]
highs = [p for p in prices if p >= sell]
if not lows or not highs:
continue
entry, exit_ = statistics.fmean(lows), statistics.fmean(highs)
expected = (exit_ * (1.0 - TAX) - entry) / entry
if expected <= 0:
continue
reachable = int(
FILL_SHARE * statistics.median(volumes) * (len(lows) / len(prices)) * market.held_out
)
qty = min(item.buy_limit, reachable, int(MAX_ITEM_SHARE * market.capital // buy))
if qty <= 0:
continue
plans.append((expected, item.name, qty, buy, sell))
plans.sort(key=lambda p: -p[0])
orders, purse = [], float(market.capital)
for _, name, qty, buy, sell in plans:
qty = min(qty, int(purse // buy))
if qty <= 0:
continue
purse -= qty * buy
orders.append(Order(item=name, quantity=qty, buy=buy, sell=sell))
return orders
def viable_market(seed: int, num_items: int, visible: int, held_out: int) -> Market:
"""The next basket from `seed` onward in which the reference strategy makes money.
Every ratio in the reward divides by the reference's realised profit, so a basket where
the reference loses has no reachable ceiling and would quietly break house rule 3 for
that task the oracle would score below 1.000 and nothing would say why. About one
basket in two thousand is like that, from an anchor the visible half happened to
mis-estimate. Skipping it is a guard, not a crutch, and the seed that was used travels
on the Market so scoring rebuilds exactly the basket that was shown.
"""
for offset in range(VIABILITY_TRIES):
market = build_market(seed + offset, num_items, visible, held_out)
if execute(market, reference_orders(market)).realised > 0:
return market
return build_market(seed, num_items, visible, held_out)
@dataclass
class Outcome:
"""One run, beside the reference run it is normalised against."""
fills: Fills
reference: Fills
stated: float | None
dropped: int = 0
"""Orders that named nothing on the board, or asked for a quantity or price of zero."""
@property
def target(self) -> float:
"""The gp a full score is worth in this basket. See TARGET_SHARE."""
return TARGET_SHARE * self.reference.realised
@property
def profit_ratio(self) -> float:
if self.target <= 0.0:
return 0.0
return min(1.0, max(0.0, self.fills.realised / self.target))
@property
def efficiency(self) -> float:
"""Return on committed capital, against the reference's return on its own.
Not discounted by TARGET_SHARE: it is already a rate rather than a total, so a
strategy that trades well on a small book is not penalised for being small, and
giving it a band as well would hand out the term for free."""
if self.reference.roc <= 0.0:
return 0.0
return min(1.0, max(0.0, self.fills.roc / self.reference.roc))
@property
def conversion_ratio(self) -> float:
"""Of the coins locked, the share that bought stock, against the reference's share.
Recorded, never rewarded `efficiency` is what qualifies profit now. It stays in
the trace because it separates the two ways `efficiency` falls: offers that never
filled, and offers that filled at prices not worth reaching."""
if self.reference.conversion <= 0.0:
return 0.0
return min(1.0, self.fills.conversion / self.reference.conversion)
@property
def discipline(self) -> float:
"""Profit, qualified by what each committed coin earned. A product, not a term
beside it: a term would pay in full for one tiny order that traded perfectly, which
is inaction with a receipt the free-points defect schema-migration shipped with."""
return self.profit_ratio * self.efficiency
@property
def clean(self) -> bool:
"""A bar, not a knife-edge.
It used to require matching the reference on both ratios to within 1e-9, which is a
demand for replication rather than for a good run: a reference plan with one extra
one-unit order that never fills has identical realised profit and lost 0.146 of
total reward. The bar is now TARGET_SHARE of the reference on the money AND on the
return that money made, which is a run that was worth doing however it got there.
"""
return (
self.fills.realised >= TARGET_SHARE * self.reference.realised
and self.fills.roc >= TARGET_SHARE * self.reference.roc
)
@property
def arith_ok(self) -> bool:
"""Did the agent's stated expected profit match what its own ACCEPTED orders imply.
Not a reward a probe into the trace. The target model gets simple arithmetic wrong
with thinking off, and without this line a bad multiplication and a bad strategy are
the same low number and nothing in the trace tells them apart. Measured against the
orders that survived parsing, so claiming profit from an order for an item that is
not on the board reads as the arithmetic error it is.
"""
if self.stated is None:
return False
return abs(self.stated - self.fills.paper) <= max(50.0, 0.02 * abs(self.fills.paper))
@property
def arith_error(self) -> float:
if self.stated is None or self.fills.paper == 0.0:
return 0.0
return abs(self.stated - self.fills.paper) / abs(self.fills.paper)
def measure(market: Market, orders: list[Order], stated: float | None) -> Outcome:
"""Run the agent's plan and the reference plan through the same engine."""
clean, seen, dropped = [], set(), 0
for order in orders[:MAX_ORDERS]:
item = market.item(order.item)
# One order per item: holdings pool per item, so two orders on one name would make
# "which sell limit does this unit belong to" a question the engine has to invent an
# answer to. The first one submitted is the one that counts.
if item is None or order.quantity <= 0 or order.buy <= 0 or order.sell <= 0:
dropped += 1
continue
if item.name in seen:
dropped += 1
continue
seen.add(item.name)
clean.append(Order(item.name, order.quantity, order.buy, order.sell))
return Outcome(
fills=execute(market, clean),
reference=execute(market, reference_orders(market)),
stated=stated,
dropped=dropped,
)
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
def parse_orders(reply: str) -> tuple[list[Order], float | None]:
"""The last JSON block in the reply: an object with "orders" and, optionally,
"expected_profit". A bare array of orders is accepted too a model that answers the
question and skips the arithmetic has traded, and should be graded on the trade.
A reply that parses to nothing is an empty plan, not an error: it scores what doing
nothing scores. Raising here would turn a formatting slip into a crashed rollout and
the reward runs inside the metric, so a raise takes the whole rollout with it rather
than scoring zero.
Which is why the except clauses below are wider than they look like they need to be.
Python's `json.loads` is not strict JSON: it accepts the bare literals Infinity,
-Infinity and NaN, and it overflows 1e309 to inf rather than refusing it. RecursionError
is not a ValueError, so twenty thousand nested arrays crashed the decoder; OverflowError
is not a ValueError either, so `int(float("inf"))` crashed the row loop. Both were live
on the first cut and both were reachable from a reply a model can actually emit.
"""
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except (ValueError, RecursionError):
return [], None
stated: float | None = None
if isinstance(parsed, dict):
rows = parsed.get("orders")
value = parsed.get("expected_profit")
# Finite only. `Infinity` and `NaN` parse, and either one propagates through
# `arith_error` into the trace as a non-finite metric, which is a corrupted training
# signal rather than a bad answer. An unusable claim is no claim.
if isinstance(value, (int, float)) and not isinstance(value, bool):
try:
stated = float(value) if math.isfinite(value) else None
except OverflowError:
stated = None
else:
rows = parsed
if not isinstance(rows, list):
return [], stated
orders = []
for row in rows:
if not isinstance(row, dict) or not isinstance(row.get("item"), str):
continue
try:
orders.append(
Order(
item=row["item"],
quantity=int(row.get("quantity", 0)),
buy=int(row.get("buy", 0)),
sell=int(row.get("sell", 0)),
)
)
except (TypeError, ValueError, OverflowError):
continue
return orders, stated
@@ -0,0 +1,667 @@
"""The stepped exchange: quote, watch the tape, re-quote, over a turn budget.
The one-shot environment asks for one basket of limit orders and runs it against a window
the agent never sees. This one hands the agent the SAME market and lets it act inside the
window: eight looks spread over sixty ticks, with inventory, open offers, cumulative buy
limits and one purse carrying between them. What is held out is no longer the whole window
it is the tail after the last look, plus every tick the agent chose not to spend a look
on. That is a weaker held-out claim than the one-shot form's and it is stated as one: the
agent sees 49 of the 60 graded ticks if it takes every look.
Three mechanics carry the whole design, and each of them exists to price a turn.
the freeze Any look that touches a book which is already live freezes the WHOLE book
for `market.freeze` ticks one to four, drawn per basket. Nothing fills
while frozen. This is the counterweight in the turn dimension that house
rule 2 demands under multi-turn: without it, re-quoting on every look is
free information and the optimal policy is "use every turn", which is
rule 4's definition of measuring nothing. The FIRST placement is free —
there is nothing to re-quote when the book is empty, and charging for it
would only tax entering the market at all.
the queue An amended offer goes to the back of the queue: its accrued fill room is
reset to zero. In the one-shot engine, room accrues on the ticks an offer
was eligible on and carries between them, which is what makes a thin item
tradeable at all. Re-quoting throws that accrual away, so moving a limit
by one gp is not free even when the freeze has expired.
the cumulative
buy limit The GE's per-item limit is over the WHOLE window, not per offer. Cancel a
half-filled offer and re-place it and the units already bought still
count. Without this, re-quoting resets the limit and the limit stops being
a limit.
`roc` is over PEAK capital employed, never average. Average-employed is a measured
free-points bug in this shape: a policy that commits the purse for two ticks, sells, and
sits in cash for fifty-eight has a tiny average and its realised profit divided by that
average is enormous, so the counterweight pays MORE for doing less. The peak asks the
question the counterweight is for how much of the purse did this plan need at its
fattest and it cannot be talked down by exiting early.
Everything is scored against a REFERENCE POLICY (`Reference`) that plays the same stepped
engine through the same interface the agent does, so 1.000 is reachable by construction and
by something the prompt describes rather than by hindsight. The reference re-quotes when
the anchor it can compute from the ticks it has seen has moved materially, and not
otherwise which is the judgement the freeze exists to make expensive.
"""
from __future__ import annotations
import statistics
from dataclasses import dataclass, field
from typing import Callable
from grand_exchange_live.book import (
BUY_BAND,
MAX_ITEM_SHARE,
MIN_CROSSINGS,
SELL_BAND,
Order,
crossings,
reference_orders,
)
from grand_exchange_live.market import (
DUMP_BASE,
DUMP_CAP,
DUMP_IMPACT,
FILL_SHARE,
TAX,
Item,
Market,
build_market,
)
TURNS = 8
"""Looks the agent gets. The last one is still worth taking — `TAIL_TICKS` run after it —
but it is worth taking only if there is something to change."""
TICKS_PER_TURN = 7
TAIL_TICKS = 4
HORIZON = TURNS * TICKS_PER_TURN + TAIL_TICKS
"""Graded ticks: 8 x 7 + 4 = 60. The tail is what makes the last look a decision rather
than a formality, and it is the floor on the held-out slice an agent that takes every
look still has TICKS_PER_TURN + TAIL_TICKS = 11 ticks executed after its final observation.
"""
REQUOTE_BAND = 0.025
IDLE_SHARE = 0.50
"""The two triggers that make the reference re-quote.
They are the reward's denominator, so where they sit is not a taste question: they are the
profit-maximising point of the reference's own family, swept over 120 baskets by
`measure_ladder.py --sweep-reference`. REQUOTE_BAND is how far the rolling anchor has to
have moved before chasing it is worth a freeze; IDLE_SHARE is how much of the purse has to
be sitting in cash before putting it back to work is.
The second trigger was missing from the first cut and its absence was measurable in exactly
the way `book.py` warns about. A reference that never redeployed sale proceeds was beaten by
22% on gp and 12% on return-on-capital by a policy that simply re-quoted on every look, so
the reward was an imitation score for an under-tuned strategy rather than a profit metric,
and the rule-4 gap it reported (0.152) was an artifact of the denominator rather than a
measurement of anything. Redeploying is the capability the stepped form EXISTS to reward
a one-shot plan cannot spend what its own sales earned and leaving it out meant the
environment measured the freeze instead of the trading. With it, the reference earns 76,744
gp against the every-look policy's 63,802 and dominates it on return as well, and the gap
becomes a real one.
Two measured facts kept here rather than in a commit message:
the anchor trigger is worth about nothing on its own. The sweep's argmax turns it off
entirely (band 1.0, idle 0.50: 77,599 gp) and 0.025 costs 1.1% of gp against it. It is
kept at the top of its own plateau because a reference whose only trigger is a cash
threshold cannot express "the anchor moved" at all, and 1.1% is inside the three-percent
tolerance `probe.py` already holds the one-shot bands to. The cell that earns more scores
0.976, well above the 0.90 that assertion demands.
the idle trigger is flat between 0.0 and 0.5 and falls off a cliff above it 63,472 gp
at 1.01, where it effectively never fires. There is no knife-edge here to tune."""
# --- what the agent submits ------------------------------------------------------------
@dataclass(frozen=True)
class Leg:
"""One line of a delta sheet: the offer this item should carry from now on.
A leg REPLACES whatever is live on that item there is one offer per name, exactly as
in the one-shot form, so "which sell limit does this unit belong to" is never a question
the engine has to invent an answer to. `quantity <= 0` cancels.
"""
item: str
quantity: int
buy: int = 0
sell: int = 0
# --- what the agent sees ---------------------------------------------------------------
@dataclass(frozen=True)
class OpenOrder:
item: str
quantity: int
"""Units still sought on the buy side of this offer."""
buy: int
sell: int
locked: float
@dataclass(frozen=True)
class TurnView:
"""Everything true at the moment of a look. Plain types only — this is what a protocol
layer renders and what `trace.info` would carry, and a dataclass or a numpy scalar in
there fails the trace write for the whole rollout."""
turn: int
turns_left: int
ticks_left: int
freeze: int
frozen_for: int
cash: float
market: Market
new_prices: dict[str, list[int]]
"""Ticks that elapsed since the previous look, per item. Empty on turn 1."""
new_volumes: dict[str, list[int]]
seen: int
"""How many ticks of each stream have been observed, warmup included."""
held: dict[str, int]
bought: dict[str, int]
"""Cumulative units bought per item, against the item's window limit."""
open_orders: dict[str, OpenOrder]
realised_so_far: float
# --- the engine's own state ------------------------------------------------------------
@dataclass
class _Live:
"""An offer standing in the book. Not visible to the agent in this shape — `OpenOrder`
is."""
item: Item
buy: int
sell: int
remaining: int
locked: float
buy_room: float = 0.0
sell_room: float = 0.0
@dataclass
class Ledger:
"""The purse, the stock and the cost basis, carried across every turn.
One invariant holds this together and the tests assert it after every tick: cash, plus
coins locked behind offers, plus the cost basis of stock held, plus realised profit,
equals the starting capital. Every mechanic here moves coins between those buckets and
creates none, so a bug that mints money shows up as a broken identity rather than as a
good score.
"""
capital: float
cash: float
locked: float = 0.0
held: dict[str, int] = field(default_factory=dict)
basis: dict[str, float] = field(default_factory=dict)
bought: dict[str, int] = field(default_factory=dict)
incoming: dict[str, int] = field(default_factory=dict)
"""Units bought on the current tick, not yet sellable. Settled at the top of the next
tick stock bought on a tick cannot be sold on that tick, or a buy limit above a sell
limit is a free round trip on one price."""
incoming_basis: dict[str, float] = field(default_factory=dict)
spent: float = 0.0
proceeds: float = 0.0
sold: int = 0
dumped: int = 0
peak_employed: float = 0.0
@property
def employed(self) -> float:
"""Coins not sitting idle: locked behind an offer, or sunk into stock at what it
cost. The PEAK of this is the denominator of `roc`. Average-employed is a measured
free-points bug see the module docstring."""
return self.locked + sum(self.basis.values()) + sum(self.incoming_basis.values())
@property
def realised(self) -> float:
return self.cash + self.employed - self.capital
def mark(self) -> None:
self.peak_employed = max(self.peak_employed, self.employed)
def settle(self) -> None:
for name, qty in self.incoming.items():
self.held[name] = self.held.get(name, 0) + qty
self.basis[name] = self.basis.get(name, 0.0) + self.incoming_basis.get(name, 0.0)
self.incoming.clear()
self.incoming_basis.clear()
@dataclass
class Result:
"""One episode, in the shape every reward reads."""
realised: float
peak_employed: float
looks: int
"""Looks that submitted a non-empty sheet. Recorded, never rewarded — a turn-count
reward is a second single-sided term and the freeze already prices the turn."""
amendments: int
"""Looks that touched a live book, i.e. the ones that actually paid a freeze."""
frozen_ticks: int
bought: int
bought_by_item: dict[str, int]
"""Cumulative units per name, for the cumulative-buy-limit invariant and for the trace.
A plain dict of plain ints `trace.info` fails the whole rollout on anything else."""
sold: int
dumped: int
spent: float
dropped: int
"""Legs that named nothing on the board or asked for a price of zero."""
unfunded: int
"""Legs the purse could not fund at all when they were submitted."""
seen_ticks: int
"""Graded ticks the agent observed. HORIZON minus this is the held-out slice."""
@property
def roc(self) -> float:
if self.peak_employed <= 0.0:
return 0.0
return self.realised / self.peak_employed
Policy = Callable[[TurnView], list[Leg]]
def play(market: Market, policy: Policy, *, turns: int = TURNS,
step: int = TICKS_PER_TURN, tail: int = TAIL_TICKS) -> Result:
"""Run one policy through one basket, look by look.
The tick loop is the one-shot engine's, moved inside a turn loop and given three things
it did not have: a freeze that suppresses fills, a queue reset on amendment, and a purse
that sale proceeds actually return to. With a single look on turn 1 and nothing after
it, the two engines produce byte-identical realised profit and identical committed
capital `tests/test_live.py::test_single_look_matches_one_shot` is that assertion, and
it is what says the stepped form is the same market rather than a different one.
"""
ledger = Ledger(capital=float(market.capital), cash=float(market.capital))
live: dict[str, _Live] = {}
dropped = unfunded = looks = amendments = frozen_ticks = 0
frozen_until = 0
t = market.visible
end = market.visible + market.held_out
last_seen = market.visible
ledger.mark()
for turn in range(1, turns + 1):
view = TurnView(
turn=turn,
turns_left=turns - turn,
ticks_left=end - t,
freeze=market.freeze,
frozen_for=max(0, frozen_until - t),
cash=ledger.cash,
market=market,
new_prices={i.name: i.prices[last_seen:t] for i in market.items},
new_volumes={i.name: i.volumes[last_seen:t] for i in market.items},
seen=t,
held=dict(ledger.held),
bought=dict(ledger.bought),
open_orders={
name: OpenOrder(name, o.remaining, o.buy, o.sell, o.locked)
for name, o in live.items()
},
realised_so_far=ledger.cash + ledger.employed - ledger.capital,
)
last_seen = t
sheet = policy(view) or []
touched, drop, unfund = _apply(market, ledger, live, sheet)
dropped += drop
unfunded += unfund
if touched:
looks += 1
# The first placement is free: there is nothing to re-quote when the book is
# empty, and charging for entering the market taxes acting at all rather than
# taxing acting AGAIN, which is the thing a turn budget is supposed to price.
if view.open_orders:
amendments += 1
frozen_until = max(frozen_until, t + market.freeze)
ledger.mark()
run = step if turn < turns else step + tail
for _ in range(min(run, end - t)):
frozen_ticks += _tick(market, ledger, live, t, frozen=t < frozen_until)
t += 1
while t < end:
frozen_ticks += _tick(market, ledger, live, t, frozen=t < frozen_until)
t += 1
_close(market, ledger, live)
return Result(
realised=ledger.cash - ledger.capital,
peak_employed=ledger.peak_employed,
looks=looks,
amendments=amendments,
frozen_ticks=frozen_ticks,
bought=sum(ledger.bought.values()),
bought_by_item=dict(ledger.bought),
sold=ledger.sold,
dumped=ledger.dumped,
spent=ledger.spent,
dropped=dropped,
unfunded=unfunded,
seen_ticks=last_seen - market.visible,
)
def _apply(market: Market, ledger: Ledger, live: dict[str, _Live],
sheet: list[Leg]) -> tuple[bool, int, int]:
"""Put a delta sheet into the book. Returns (touched anything, dropped, unfunded)."""
touched = dropped = unfunded = 0
seen: set[str] = set()
for leg in sheet:
item = market.item(leg.item) if isinstance(leg.item, str) else None
if item is None or item.name in seen:
dropped += 1
continue
seen.add(item.name)
existing = live.get(item.name)
if leg.quantity <= 0:
# A cancel. The coins behind the unfilled remainder come back; the stock does
# not, and with no offer on the name it can now only leave through the close-out
# haircut. Cancelling is not free — it is just not charged as a fine.
if existing is None:
dropped += 1
continue
ledger.locked -= existing.locked
ledger.cash += existing.locked
del live[item.name]
touched += 1
continue
if leg.buy <= 0 or leg.sell <= 0:
dropped += 1
continue
# Release the old offer first, so its coins are available to fund the new one. This
# is what makes an amendment a re-quote rather than a second offer.
if existing is not None:
ledger.locked -= existing.locked
ledger.cash += existing.locked
del live[item.name]
room = item.buy_limit - ledger.bought.get(item.name, 0)
qty = max(0, min(leg.quantity, room, int(ledger.cash // leg.buy)))
if qty <= 0 and not ledger.held.get(item.name, 0):
# The purse cannot fund it, or the window's buy limit is already spent, and
# there is no stock for the sell side to work on either. Recorded rather than
# silently ignored: a plan whose offers do not fit is a plan that allocated
# badly, and the trace should say which.
unfunded += 1
if existing is not None:
touched += 1
continue
# qty may be zero while stock is held — that is a SELL-ONLY offer, and it has to be
# expressible. An agent that has bought its whole window limit still needs a way to
# move the price it is asking, and "cancel" is not that way: cancelling strands the
# stock in the close-out haircut.
ledger.cash -= qty * leg.buy
ledger.locked += qty * leg.buy
# Queue room starts at zero. An amended offer is a new offer to the book.
live[item.name] = _Live(item=item, buy=leg.buy, sell=max(leg.buy + 1, leg.sell),
remaining=qty, locked=float(qty * leg.buy))
touched += 1
return bool(touched), dropped, unfunded
def _tick(market: Market, ledger: Ledger, live: dict[str, _Live], t: int,
*, frozen: bool) -> int:
"""One tick of the exchange. Returns 1 if the tick was frozen out.
Identical to `book.execute`'s inner loop, with two additions: a frozen tick fills
nothing at all, and sale proceeds land in the purse where the next look can spend them.
Settlement is NOT frozen the freeze is on the book, not on stock already paid for, and
stalling settlement too would be a second, unstated penalty on the same action.
"""
ledger.settle()
if frozen:
ledger.mark()
return 1
for name, order in live.items():
item = order.item
price = item.prices[t]
flow = FILL_SHARE * item.volumes[t]
# Room accrues only on the ticks the offer was actually eligible on — banking the
# flow of ticks the price never reached would let an offer fill far past the volume
# that was ever available to it, and the volume limit is the whole reason allocation
# is a decision here.
if price <= order.buy:
order.buy_room += flow
if price >= order.sell:
order.sell_room += flow
if price <= order.buy and order.remaining > 0:
room = item.buy_limit - ledger.bought.get(name, 0)
qty = min(order.remaining, int(order.buy_room), room)
if qty > 0:
order.buy_room -= qty
order.remaining -= qty
order.locked -= qty * order.buy
ledger.locked -= qty * order.buy
# Reserved at the limit, paid at the market: the difference returns to the
# purse, exactly as the exchange does it.
ledger.cash += qty * (order.buy - price)
ledger.spent += qty * price
ledger.bought[name] = ledger.bought.get(name, 0) + qty
ledger.incoming[name] = ledger.incoming.get(name, 0) + qty
ledger.incoming_basis[name] = (
ledger.incoming_basis.get(name, 0.0) + qty * price
)
avail = ledger.held.get(name, 0)
if price >= order.sell and avail > 0:
qty = min(avail, int(order.sell_room))
if qty > 0:
order.sell_room -= qty
ledger.basis[name] -= ledger.basis[name] * (qty / avail)
ledger.held[name] = avail - qty
ledger.cash += qty * price * (1.0 - TAX)
ledger.proceeds += qty * price * (1.0 - TAX)
ledger.sold += qty
ledger.mark()
return 0
def _close(market: Market, ledger: Ledger, live: dict[str, _Live]) -> None:
"""The window shuts. Offers are cancelled, stock is forced out."""
ledger.settle()
for order in live.values():
ledger.locked -= order.locked
ledger.cash += order.locked
live.clear()
for item in market.items:
left = ledger.held.get(item.name, 0)
if not left:
continue
depth = statistics.median(item.volumes[market.visible:])
haircut = min(DUMP_CAP, DUMP_BASE + DUMP_IMPACT * (left / max(1.0, depth)))
ledger.cash += left * item.prices[-1] * (1.0 - haircut) * (1.0 - TAX)
ledger.dumped += left
ledger.held[item.name] = 0
ledger.basis[item.name] = 0.0
# --- the strategy the reward is normalised against --------------------------------------
def desired_orders(items: list[Item], prices: dict[str, list[int]],
volumes: dict[str, list[int]], purse: float, ticks_left: int,
limits: dict[str, int]) -> list[Order]:
"""`book.reference_orders`, generalised to a moment inside the window.
Same three judgements does it revert, what does it pay, what can it hold over
whatever ticks have been observed by now, against whatever purse and whatever remaining
per-item window limits are left. On turn 1, with the whole warmup observed, the whole
purse free and no units bought, this returns exactly what `reference_orders` returns:
`tests/test_live.py::test_turn_one_matches_reference_orders` asserts that, and it is
what keeps the live reference recognisably the same strategy as the one-shot one rather
than a second, unswept family of constants.
"""
plans = []
for item in items:
window, flow = prices[item.name], volumes[item.name]
room = limits.get(item.name, item.buy_limit)
if room <= 0 or crossings(window) < MIN_CROSSINGS:
continue
anchor = statistics.fmean(window)
buy = max(1, round(anchor * (1.0 - BUY_BAND)))
sell = max(buy + 1, round(anchor * (1.0 + SELL_BAND)))
lows = [p for p in window if p <= buy]
highs = [p for p in window if p >= sell]
if not lows or not highs:
continue
entry, exit_ = statistics.fmean(lows), statistics.fmean(highs)
expected = (exit_ * (1.0 - TAX) - entry) / entry
if expected <= 0:
continue
reachable = int(
FILL_SHARE * statistics.median(flow) * (len(lows) / len(window)) * ticks_left
)
qty = min(room, reachable, int(MAX_ITEM_SHARE * purse // buy))
if qty <= 0:
continue
plans.append((expected, item.name, qty, buy, sell))
plans.sort(key=lambda p: -p[0])
orders, left = [], float(purse)
for _, name, qty, buy, sell in plans:
qty = min(qty, int(left // buy))
if qty <= 0:
continue
left -= qty * buy
orders.append(Order(item=name, quantity=qty, buy=buy, sell=sell))
return orders
class Reference:
"""The reference POLICY: quote from the warmup, then chase the anchor when it moves.
It is a policy and not a plan because the reward has to be reachable by playing the
game rather than by pre-computing it, and because a denominator that never re-quotes
would make the whole stepped form decorative every ratio would be maximised by the
one-shot answer and the turn budget would be measuring nothing.
What it does on a look:
1. Recompute each live item's anchor over every tick observed so far.
2. Mark an item for re-quote only if that anchor has moved more than REQUOTE_BAND.
This is the judgement the freeze prices: chasing a two-gp drift costs `freeze`
ticks of fills across the WHOLE book and throws away the offer's queue position,
and it is worth doing only when the old limits are on the wrong side of where the
price now lives.
3. Re-run the same three judgements over the marked items and the items carrying no
offer at all, against the cash that would be free once the marked offers are
released and the buy-limit room actually left.
Stateful, so one instance plays one episode. `play(market, Reference())`.
"""
def __init__(self, requote_band: float = REQUOTE_BAND,
idle_share: float = IDLE_SHARE) -> None:
self.requote_band = requote_band
self.idle_share = idle_share
self.prices: dict[str, list[int]] = {}
self.volumes: dict[str, list[int]] = {}
def _observe(self, view: TurnView) -> None:
if not self.prices:
for item in view.market.items:
self.prices[item.name] = list(item.visible_prices(view.market.visible))
self.volumes[item.name] = list(item.visible_volumes(view.market.visible))
for name, ticks in view.new_prices.items():
self.prices[name].extend(ticks)
self.volumes[name].extend(view.new_volumes[name])
def __call__(self, view: TurnView) -> list[Leg]:
self._observe(view)
items = view.market.items
limits = {i.name: i.buy_limit - view.bought.get(i.name, 0) for i in items}
idle = view.cash >= self.idle_share * view.market.capital
free = [i for i in items if i.name not in view.open_orders]
if not view.open_orders:
purse = view.cash
candidates = items
else:
marked = set()
for name, open_order in view.open_orders.items():
anchor = statistics.fmean(self.prices[name])
buy = max(1, round(anchor * (1.0 - BUY_BAND)))
if abs(buy - open_order.buy) / max(1, open_order.buy) > self.requote_band:
marked.add(name)
elif idle and open_order.quantity == 0 and limits[name] > 0:
# The buy side of this offer is done and the coins it earned are sitting
# in cash. Re-quoting it is not chasing a drift, it is putting the purse
# back to work, and the window limit says there is room to do so.
marked.add(name)
if not marked and not (idle and free):
# Nothing has moved far enough to be worth a freeze and there is nothing
# idle to deploy. Taking the look and doing nothing is the correct play, and
# it is the play the exhaustive policy in the ladder refuses to make.
return []
purse = view.cash + sum(
o.locked for n, o in view.open_orders.items() if n in marked
)
candidates = [i for i in items if i.name in marked or i.name not in view.open_orders]
orders = desired_orders(
candidates,
{i.name: self.prices[i.name] for i in candidates},
{i.name: self.volumes[i.name] for i in candidates},
purse,
max(1, view.ticks_left),
limits,
)
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
def live_reference(market: Market) -> Result:
"""The denominator, cached per basket.
`viable_live_market` calls this once per candidate seed and `scan()` calls it again for
every episode on the seed that was chosen, so an uncached reference is `VIABILITY_TRIES`
stepped simulations per task before the first token is emitted 3,072 of them for a
default 48-task load, on every eval AND every `--dry-run`. The cache is keyed on the
seed and the window shape, which is everything the result depends on.
"""
key = (market.seed, market.visible, market.held_out, market.freeze)
hit = _REFERENCE_CACHE.get(key)
if hit is None:
hit = _REFERENCE_CACHE[key] = play(market, Reference())
return hit
_REFERENCE_CACHE: dict[tuple[int, int, int, int], Result] = {}
LIVE_VIABILITY_TRIES = 16
"""Candidate seeds `viable_live_market` will try before giving up, against the one-shot
form's 64. A stepped reference is eight turns and sixty ticks rather than one pass, so the
worst case is the load cost, not the failure rate: at 48 tasks a 64-try cap is 3,072
simulations of a market nobody has looked at yet. Measured skip rate is low enough that 16
never binds in practice `measure_ladder.py` reports how often it does."""
def viable_live_market(seed: int, num_items: int, visible: int, held_out: int) -> Market:
"""The next basket from `seed` onward in which the LIVE reference makes money.
Retargeted from `book.viable_market`, which asks whether the ONE-SHOT reference profits.
That is the wrong question here: every ratio divides by the live reference, so a basket
where the live reference loses has no reachable ceiling and quietly breaks house rule 3
for that task. The freeze is drawn inside `build_market` off its own RNG precisely so
this function can see it it used to be drawn after viability had already answered,
which meant viability accepted a basket under one cost of re-quoting and the run played
it under another.
"""
for offset in range(LIVE_VIABILITY_TRIES):
market = build_market(seed + offset, num_items, visible, held_out)
if live_reference(market).realised > 0:
return market
return build_market(seed, num_items, visible, held_out)
@@ -0,0 +1,277 @@
"""The market generator: price and volume streams with structure a reader can find.
This file is a COPY of `grand_exchange/market.py`, not an import of it. The one-shot
environment's scores have to stay comparable across time, and an import would mean a change
made for the live form silently moved every historical `grand-exchange` number. The copy is
held to the original byte-for-byte by `tests/test_market_copy.py`, which builds the same
seeds through both packages and compares every price and volume. The ONLY intentional
divergence is the freeze draw below, and it is drawn off a separate RNG so it cannot
perturb a single tick of either stream.
If prices were a random walk this environment would be worthless and would still look
fine expected profit is zero for every strategy, so the reward is noise, no oracle
exists, and the numbers coming out of it would be plausible and meaningless. So the
structure is put in deliberately and is the whole design:
price_t = fundamental_t * (1 + x_t)
`fundamental_t` drifts by FUND_DRIFT a tick, worth about one percent over a whole stream.
`x_t` is an AR(1) around zero with PHI decay and a stationary spread of NOISE_SD nine
percent on the deep items, fifteen or sixteen on the thin ones. Noise dominates drift by an
order of magnitude, which is what makes the mean of the visible prices a usable estimate of
the fundamental, and "buy under the estimate, sell over it" a real strategy rather than a
superstition. `book.reference_orders` is that strategy; `probe.py` measures how far it beats
trading at random, and refuses to pass if the margin is thin.
The visible and held-out windows are two halves of ONE stream from ONE seed: the graded
ticks are the next ticks the generator would have produced, not a differently-seeded
population that could have moved somewhere the visible half gave no warning of.
Two things are here to punish reading the price column alone. One or two items per basket do
not revert at all (see DRIFT_STEP and WALKS) and swing widest of everything on screen. And
liquidity price times volume, gp a tick is uncorrelated with price, so the fattest
visible margins sit on the items that can absorb the least of the purse.
Item names are invented. Formulas and market mechanics are facts about a kind of game;
item tables are somebody's copyrighted content, and none of it is here.
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass
# --- the rules of the exchange, quoted to the agent verbatim in the prompt --------------
TAX = 0.01
"""Charged on every sale, the GE's own. It is the counterweight that kills thin flips:
a round trip has to clear it twice over before it is worth doing, so "trade everything"
is a losing strategy rather than a neutral one."""
FILL_SHARE = 0.25
"""Share of a tick's volume one participant can take, and it accrues only on the ticks an
offer was actually eligible on. Without it a fat margin on an item carrying three thousand
gp a tick is worth as much as one carrying a hundred thousand, and the environment stops
measuring allocation."""
DUMP_BASE = 0.03
DUMP_IMPACT = 0.05
DUMP_CAP = 0.40
"""Stock still held when the window closes is forced out at the last price, minus a haircut
of DUMP_BASE plus DUMP_IMPACT for every full tick's worth of the item's median volume that
has to be pushed through, capped at DUMP_CAP.
A FLAT haircut was the first cut of this and it was the wrong shape. Under a flat five
percent, the cost of holding stock at the close was the same whether the leftovers were
twenty units of an item that trades thirty a tick or four thousand units of one that trades
six hundred so oversizing was only ever punished through the purse, and the purse punishes
it as a cliff: the first over-large offer eats the whole budget and everything after it is
never placed. A cliff is not a gradient. Scaling the haircut by position-over-depth prices
the thing that is actually true forcing size out costs you in proportion to how much of
the book you are pushing through and it makes reading the volume column pay smoothly,
which is what the column is here to teach.
It also prices a bad anchor. A buy limit set too high fills fast and leaves the sell limit
out of reach, so the position that a sloppy anchor builds is exactly the position that has
to be dumped, and now it is dumped at a price that scales with its size."""
STARTING_CAPITAL = 250_000
WALKS = (1, 2)
"""How many of the basket's items do not mean-revert, drawn uniformly from this range.
It used to be exactly one, and a fixed count is a free prior: "drop the single widest line
on the board" scores what computing `book.crossings` scores, without computing anything.
It is the same defect `bot_detection` ships an assertion against a class balance the model
can count on is a class balance it will use instead of the discriminator. With the count
unknown the shape statistic is the only thing that answers the question, and a basket can
punish both over- and under-rejection."""
SEED_BASE = 60_000
"""Where task seeds start. Lives here rather than on the taskset so `probe.py`, which
cannot import the taskset without `verifiers`, grades the baskets a run would actually
serve rather than a different set that happens to share a generator."""
FREEZE_RANGE = (1, 4)
FREEZE_SALT = 0x5F3E_11CE
"""How long the book is frozen after a re-quote, in ticks, drawn per basket.
Drawn HERE, off `random.Random(seed ^ FREEZE_SALT)`, and carried on the Market. It used to
be drawn in the taskset after the basket was chosen, which is circular: `viable_market`
decides whether the reference makes money in a basket, the reference's profit depends on
what re-quoting costs, and the cost was not drawn yet. Viability would then have accepted a
basket under one freeze and the run would have played it under another, so the denominator
of every reward would be a strategy the model never played against. A separate RNG keyed off
the same seed makes the draw deterministic, available before the first simulation, and
provably free of any effect on the price and volume streams which is what lets the copy
assertion in `tests/` be an equality rather than an approximation.
The value is a per-look cost paid by the WHOLE book: see `live.freeze`. Flat, not
proportional to the number of items re-quoted the settled decision. Per-item is the
better mechanism and is the identified strengthening, not a thing to build unmeasured on
the critical path."""
# --- stream parameters -----------------------------------------------------------------
PHI = 0.45
"""AR(1) decay of the mispricing. Half-life under a tick, so a visible window holds many
independent draws around the fundamental which is what makes the mean of it an estimate
rather than a guess and a held-out window holds many excursions, so the reward is not one
lucky draw."""
FUND_DRIFT = 0.0008
"""Per-tick drift of the fundamental. Small on purpose: the fundamental has to be
ESTIMABLE from the visible half or there is nothing to learn."""
VOLUME_SD = 0.35
DRIFT_STEP = 0.055
"""Per-tick step of the items that do not mean-revert at all — their price is a pure random
walk, so the fundamental IS wherever it last was.
This is the trap the whole environment is built around, put inside the task instead of
left as a hazard the designer has to avoid. A random walk has no anchor, so buying under
its moving average is not a discount, it is a coin flip that pays the tax and the dump
slippage every time. On screen it is the widest-swinging line in the basket and therefore
the most attractive one, because amplitude is what a careless reader ranks by. The two can
only be told apart by SHAPE: a reverting series crosses its own mean constantly, a walk
wanders on one side of it for a dozen ticks at a time. `book.crossings` is that statistic
and the reference strategy will not trade an item that fails it."""
# (names, base price range, base volume range, buy limit, mispricing spread)
#
# What separates these is LIQUIDITY IN GP PER TICK — base price times base volume — and it
# is deliberately uncorrelated with the price. That is the allocation problem: the purse is
# 250,000 gp and a quarter of the flow over thirty ticks is what any one offer can absorb,
# so a deep tier can take a third of the purse and a thin one can take a twentieth of it no
# matter how good the margin looks. An earlier cut of this file made the expensive items the
# thin ones, which sounds right and is not: eight units a tick of a 46,000 gp item is 368,000
# gp of flow, the deepest thing on the board. Thin means small in coins, not small in units.
#
# The two thin tiers also carry the widest mispricing spread, so they show the fattest margin
# and can absorb the least. That is the trap, and it is the same trap either way an agent
# falls into it: ignore the volume column and either the offers sit unfilled or the purse
# sits idle.
TIERS = [
# deep, ~40k-130k gp a tick: this is where the purse actually goes
(["Thornroot poultice", "Chipped bone charm", "Bogwater draught", "Coarse fletching feather"],
(90, 170), (400, 800), 5000, 0.09),
(["Emberglass shard", "Stormrune tablet", "Marrowsteel nail", "Pale grimoire page"],
(900, 1700), (40, 90), 400, 0.09),
# thin, ~3k-15k gp a tick, and the widest swings on the board
(["Gilded harpoon head", "Cinderweave cloak", "Wyrmbone talisman", "Frostbitten ledger"],
(200, 420), (12, 30), 800, 0.15),
(["Duskforged sigil", "Heart of the sunken cairn", "Voidglass lens", "Tideworn crown"],
(6000, 14000), (1.2, 3.0), 40, 0.16),
]
@dataclass(frozen=True)
class Item:
name: str
reverting: bool
"""Whether this item has an anchor at all. Never shown to the agent — it is here so the
probe can assert that the trap is a trap, and that the reference avoids it for a reason
rather than by luck."""
buy_limit: int
"""Units per item per window, the GE's own limit. With finite capital it is what turns
the task into an allocation problem instead of a single pick."""
prices: list[int]
volumes: list[int]
def visible_prices(self, visible: int) -> list[int]:
return self.prices[:visible]
def visible_volumes(self, visible: int) -> list[int]:
return self.volumes[:visible]
@dataclass(frozen=True)
class Market:
seed: int
"""Carried on the basket so the taskset can store the seed it actually used. Baskets are
skipped when the reference strategy is not profitable in them (see `book.viable_market`),
so the seed a task was built from is not always the one it was asked for."""
items: list[Item]
visible: int
held_out: int
capital: int
freeze: int = 0
"""Ticks the book is frozen for after any re-quote. Zero on a market built by the
one-shot generator; drawn from FREEZE_RANGE here. See FREEZE_SALT."""
def item(self, name: str) -> Item | None:
wanted = name.strip().casefold()
for item in self.items:
if item.name.casefold() == wanted:
return item
return None
def _volumes(rng: random.Random, volume: float, ticks: int) -> list[int]:
return [max(1, round(volume * math.exp(rng.gauss(0.0, VOLUME_SD)))) for _ in range(ticks)]
def _walk(rng: random.Random, base: float, ticks: int) -> list[int]:
"""The decoy: no anchor, no reversion, just a wide random walk. Whatever a moving
average says about where this price belongs is a statement about the past only."""
price = base
out = []
for _ in range(ticks):
price *= 1.0 + rng.gauss(0.0, DRIFT_STEP)
out.append(max(1, round(price)))
return out
def _stream(rng: random.Random, base: float, volume: float, noise_sd: float, ticks: int
) -> tuple[list[int], list[int]]:
"""One item's price and volume history, visible and held-out ticks together."""
# eps is scaled so the AR(1) settles at exactly noise_sd rather than drifting toward it
# over the first few ticks; the visible half would otherwise be quieter than the graded
# half and every anchor estimated from it would be too tight.
eps_sd = noise_sd * math.sqrt(1.0 - PHI * PHI)
x = rng.gauss(0.0, noise_sd)
fundamental = base
prices = []
for _ in range(ticks):
fundamental *= 1.0 + rng.gauss(0.0, FUND_DRIFT)
x = PHI * x + rng.gauss(0.0, eps_sd)
prices.append(max(1, round(fundamental * (1.0 + x))))
return prices, _volumes(rng, volume, ticks)
def build_market(seed: int, num_items: int, visible: int, held_out: int) -> Market:
"""One basket, from one seed.
Every tier is represented before any tier repeats, so a thin item and a deep one are
always both on the table: the allocation choice is the task, and a basket that happened
to be all-deep or all-thin would not pose it.
"""
rng = random.Random(seed)
ticks = visible + held_out
order = list(range(len(TIERS)))
rng.shuffle(order)
picks = [order[i % len(order)] for i in range(num_items)]
# Which slots are walks, and how many, are both drawn here. The tier is drawn
# independently of the walk flag, so a basket where the walk was always the cheap item —
# solvable by reading the price column and never the shape — cannot arise.
decoys = set(rng.sample(range(num_items), rng.randint(*WALKS)))
used: set[str] = set()
items = []
for slot, tier_idx in enumerate(picks):
names, (lo, hi), (vlo, vhi), limit, noise_sd = TIERS[tier_idx]
choices = [n for n in names if n not in used] or names
name = rng.choice(choices)
used.add(name)
base, volume = rng.uniform(lo, hi), rng.uniform(vlo, vhi)
if slot in decoys:
prices, volumes = _walk(rng, base, ticks), _volumes(rng, volume, ticks)
else:
prices, volumes = _stream(rng, base, volume, noise_sd, ticks)
items.append(Item(name=name, reverting=slot not in decoys, buy_limit=limit,
prices=prices, volumes=volumes))
items.sort(key=lambda i: i.name)
# Off its own RNG, so `rng` above is left exactly where the one-shot generator leaves
# it and every stream is byte-identical to the original's.
freeze = random.Random(seed ^ FREEZE_SALT).randint(*FREEZE_RANGE)
return Market(seed=seed, items=items, visible=visible, held_out=held_out,
capital=STARTING_CAPITAL, freeze=freeze)
@@ -0,0 +1,305 @@
"""The render/parse boundary — and the security boundary.
`live.play` takes a `Policy = Callable[[TurnView], list[Leg]]`. A model is not that, so this
file is the adapter: `render` turns a `TurnView` into the text of one user turn, and
`parse_sheet` turns the reply back into legs. `taskset.py` is the only caller and does
nothing else with the view.
THE LEAK THIS FILE EXISTS TO CLOSE. `TurnView.market` is the live `Market` object, so
`view.market.items[i].prices` is the WHOLE stream every graded tick, including the ones
the agent is being graded on not having seen. A verifier wrote a policy that reads it and
earns 9.4% more than the oracle. House rule 1 is enforced here and nowhere else, by two
rules this module holds to absolutely:
1. Nothing is rendered from `item.prices` or `item.volumes` past `view.seen`. The warmup
is `[:view.seen]` on the opening turn and every turn after it renders `view.new_prices`
/ `view.new_volumes`, which the engine slices as `[last_seen:t]`. `tests/test_protocol.py`
builds a market whose held-out ticks are impossible sentinel values and asserts none of
them appears in any rendered turn, and separately asserts that what was rendered
reassembles into exactly `prices[:seen]`.
2. `view.market` never leaves this module not into a return value, not into `trace.info`,
not into a metric. The taskset writes plain ints and floats and the same test asserts
that too.
The reply grammar is the one-shot form's, minus the arithmetic and plus a delta:
```json
{"orders": [{"item": "Bogwater draught", "quantity": 400, "buy": 118, "sell": 129}]}
```
A DELTA SHEET, not a book: a line replaces whatever offer stands on that item, an item not
named keeps its offer untouched, `quantity: 0` cancels, and `{"orders": []}` holds. It is
the one-shot form's object so a model that can play `grand-exchange` can play this without
learning a second syntax, and because the alternative a bespoke amend/cancel verb set
would put a second thing in front of the model to fail at, and the failure mode measured on
the one-shot form was not syntax.
MALFORMED REPLIES ARE COERCED TO A HOLD, AND THAT IS THE WHOLE POLICY. Not rejected, not
fined, never raised. Three reasons, in order of how much they cost:
a raise takes the rollout, not the turn. Parsing runs inside the env's `run()`, so an
exception there fails the whole episode and the trace lands with `rewards: {}` a
formatting slip would score as an infrastructure error rather than as a bad turn, and
a run's mean would silently be a mean over the model's better-formatted episodes.
a hold is already a real cost. The look is spent: the book stands as it was, the freeze
it might have wanted is not paid, and seven more ticks execute against yesterday's limits.
There is no need to invent a fine, and a fine would be a second single-sided term (house
rule 2) sitting beside a reward that already prices doing nothing at zero.
degenerate replies are the measured failure. In 31 of 32 rollouts of the one-shot form the
model returned `{"expected_profit": 0, "orders": []}` in 18 tokens. That is a WELL-FORMED
hold, and it must score what holding scores rather than what a parse error scores, or the
environment reports a parser bug where a sampling problem is. `malformed` is recorded as a
metric so the two are separable in the trace.
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass, field
from grand_exchange_live.book import MAX_ORDERS
from grand_exchange_live.live import TURNS, Leg, TurnView
from grand_exchange_live.market import (
DUMP_BASE,
DUMP_CAP,
DUMP_IMPACT,
FILL_SHARE,
STARTING_CAPITAL,
TAX,
Market,
)
MAX_LEGS = MAX_ORDERS
"""Lines one sheet may carry, the one-shot form's cap. A basket holds five items and a leg
replaces the offer on its item, so this only ever bites a reply that is repeating itself."""
SYSTEM = f"""You are trading on the Grand Exchange, live, over a window that is running.
You get {TURNS} looks at the market. Between one look and the next, ticks execute against
whatever offers you have standing, and after your last look more ticks still run. You will
never see those final ticks before they are graded.
The rules of the exchange:
- A buy fills at the tick's price when that price is at or below your buy limit. A sell
fills at the tick's price when that price is at or above your sell limit.
- PLACING an offer locks quantity x buy price out of your purse straight away, whether or
not it ever fills. You start with {STARTING_CAPITAL:,} gp. Sale proceeds DO return to the
purse and you can spend them at your next look that is the whole reason to look again.
- You can take at most {FILL_SHARE:.0%} of a tick's traded volume, per item, per side, and only on
ticks where your limit was actually reached.
- Each item has a buy limit: the most units of it you may buy in the WHOLE window. It is
cumulative cancelling an offer does not give the units back.
- {TAX:.0%} tax is charged on every sale.
- Stock bought on a tick cannot be sold on the same tick.
- Stock you still hold when the window closes is forced out at the last price minus
{DUMP_BASE:.0%}, plus a further {DUMP_IMPACT:.0%} for every full tick's worth of that item's median volume
you have to push through, capped at {DUMP_CAP:.0%}; then taxed. Buying what you cannot sell is a
loss, not a hold, and it is a bigger loss the more of it there is.
What a look costs:
- Touching a book that already has an offer standing FREEZES THE WHOLE BOOK for a few
ticks. Nothing fills while it is frozen not the item you touched, all of them. The
number of ticks is stated on every turn.
- An amended offer goes to the BACK OF THE QUEUE: the fill room it had accrued is reset to
zero, so moving a limit by one gp costs you even after the freeze expires.
- Your first placement, into an empty book, is free of both.
- Doing nothing on a look costs nothing at all. Holding a good book is a real move.
Reply with ONE ```json code block containing an object with "orders": a list of at most
{MAX_LEGS} objects, each with "item" (exactly as named), "quantity", "buy" and "sell".
It is a DELTA SHEET, not your whole book:
- a line REPLACES the offer standing on that item;
- an item you do not name keeps the offer it has, untouched and unfrozen;
- "quantity": 0 cancels the offer on that item and returns its locked coins;
- {{"orders": []}} changes nothing the correct reply when your book is where you want it.
Example:
```json
{{"orders": [{{"item": "Bogwater draught", "quantity": 400, "buy": 118, "sell": 129}}]}}
```"""
# --- rendering --------------------------------------------------------------------------
def _series(values: list[int]) -> str:
return " ".join(str(v) for v in values)
def render(view: TurnView) -> str:
"""One turn of the exchange, as the text the model reads.
Reads `view.new_prices` / `view.new_volumes` and, on the opening turn only, the warmup
slice `[:view.seen]`. Never anything else off the market see the module docstring.
"""
lines: list[str] = []
lines.append(
f"Look {view.turn} of {view.turn + view.turns_left}. "
f"{view.ticks_left} ticks left in the window; the next ones execute as soon as you "
f"answer, and {view.turns_left} more looks follow this one."
)
lines.append(
f"Re-quoting an item that already has an offer freezes the whole book for "
f"{view.freeze} tick{'s' if view.freeze != 1 else ''}."
)
if view.frozen_for:
lines.append(f"The book is frozen for {view.frozen_for} more tick"
f"{'s' if view.frozen_for != 1 else ''}: nothing is filling.")
lines.append("")
if view.turn == 1:
lines.append(f"The last {view.seen} ticks, oldest first. Nothing of yours was "
"standing for any of them.")
lines.append("")
for item in view.market.items:
lines.append(f"{item.name} (buy limit {item.buy_limit} per window)")
lines.append(f" price {_series(item.prices[:view.seen])}")
lines.append(f" volume {_series(item.volumes[:view.seen])}")
lines.append("")
else:
ticks = len(next(iter(view.new_prices.values()), []))
lines.append(f"{ticks} tick{'s' if ticks != 1 else ''} have executed since your last "
"look, oldest first:")
lines.append("")
for name in sorted(view.new_prices):
lines.append(name)
lines.append(f" price {_series(view.new_prices[name])}")
lines.append(f" volume {_series(view.new_volumes[name])}")
lines.append("")
lines.append(f"Purse: {view.cash:,.0f} gp free. "
f"Realised so far: {view.realised_so_far:+,.0f} gp.")
if view.open_orders:
lines.append("Offers standing:")
for name in sorted(view.open_orders):
order = view.open_orders[name]
lines.append(f" {name} buy {order.buy} sell {order.sell} "
f"{order.quantity} units still sought "
f"{order.locked:,.0f} gp locked behind it")
else:
lines.append("No offers standing.")
held = {n: q for n, q in view.held.items() if q}
if held:
lines.append("Stock held:")
for name in sorted(held):
lines.append(f" {name} {held[name]} units")
else:
lines.append("You hold no stock.")
bought = {n: q for n, q in view.bought.items() if q}
if bought:
lines.append("Bought so far, against each item's window limit:")
for name in sorted(bought):
item = view.market.item(name)
limit = item.buy_limit if item is not None else 0
lines.append(f" {name} {bought[name]} of {limit}")
lines.append("")
lines.append('Your order sheet. One ```json block, {"orders": [...]}; items you do not '
'name keep their offer; {"orders": []} holds.')
return "\n".join(lines)
def opening(market: Market, turns: int = TURNS) -> str:
"""The first turn's text, built before the engine has run.
The task's prompt has to exist at `load()` time — a prompted task speaks first, and the
board is what the model reads before it says anything but the first `TurnView` does
not exist until `play` builds it. So this reconstructs that view, and
`tests/test_protocol.py::test_the_opening_prompt_is_the_engines_own_first_turn` asserts
the two are byte-identical over twenty-four baskets. Without that assertion this is a
second, quietly drifting description of the state the engine is actually in.
"""
return render(TurnView(
turn=1,
turns_left=turns - 1,
ticks_left=market.held_out,
freeze=market.freeze,
frozen_for=0,
cash=float(market.capital),
market=market,
new_prices={},
new_volumes={},
seen=market.visible,
held={},
bought={},
open_orders={},
realised_so_far=0.0,
))
# --- parsing ----------------------------------------------------------------------------
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
@dataclass(frozen=True)
class Sheet:
"""One parsed reply. `legs` is what the engine gets, the rest is trace material."""
legs: list[Leg] = field(default_factory=list)
malformed: bool = False
"""No sheet could be read out of the reply at all. Coerced to a hold; recorded."""
dropped_rows: int = 0
"""Rows that were not usable as a leg — not an object, no item name, a quantity that is
not a number. Distinct from the legs the ENGINE drops (a name not on the board, a price
of zero), which it counts itself."""
def _int(value) -> int | None:
"""A JSON number as an int, or None. `json.loads` is not strict JSON — it accepts bare
NaN, Infinity and -Infinity, and overflows 1e309 to inf and `int(inf)` raises
OverflowError, which inside `run()` would take the whole rollout with it."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
try:
if not math.isfinite(value):
return None
return int(value)
except (OverflowError, ValueError):
return None
def parse_sheet(reply: str) -> Sheet:
"""The last JSON block in the reply, as a delta sheet. Never raises.
A bare array of order objects is accepted as well as `{"orders": [...]}` a model that
answers with the list and skips the wrapper has stated a sheet, and should be graded on
the sheet. Anything else is a hold, flagged `malformed`; see the module docstring for
why that is the policy and what it costs.
"""
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except (ValueError, RecursionError):
return Sheet(malformed=True)
if isinstance(parsed, dict):
rows = parsed.get("orders", parsed.get("legs"))
if rows is None and not parsed:
rows = [] # `{}` is a hold, stated in the shape the grammar asks for
else:
rows = parsed
if not isinstance(rows, list):
return Sheet(malformed=True)
legs, dropped = [], 0
for row in rows:
if len(legs) >= MAX_LEGS:
dropped += 1
continue
if not isinstance(row, dict) or not isinstance(row.get("item"), str):
dropped += 1
continue
quantity = _int(row.get("quantity", 0))
buy, sell = _int(row.get("buy", 0)), _int(row.get("sell", 0))
if quantity is None or buy is None or sell is None:
dropped += 1
continue
legs.append(Leg(item=row["item"], quantity=quantity, buy=buy, sell=sell))
return Sheet(legs=legs, dropped_rows=dropped)
@@ -0,0 +1,66 @@
"""The reward, in one place, so no two readers can disagree about the same episode.
`taskset.py` scores a rollout with this, `measure_ladder.py` scores its ladder with it and
`probe.py` gates the environment through the ladder one function, three callers. It lived
in `measure_ladder.py` while the constants were being measured, which was fine while nothing
else read it and would not have survived the taskset landing: a reward the probe computes
and a reward the run computes are two rewards, and the day they drift the probe is gating
something the model is not being paid for.
Nothing here imports `verifiers`, for the same reason the rest of the package does not: the
probe has to grade this environment with the training stack uninstalled.
"""
from __future__ import annotations
from grand_exchange_live.live import Result
TARGET_SHARE = 0.90
"""What counts as a full score, as a share of the LIVE reference's realised profit and of
its return on peak capital. Chosen by `measure_ladder.py --sweep-target`, not carried over
from the spec, which proposed 0.85 against an engine that no longer exists.
The two things it trades off are both measured over 120 baskets. Raising it widens the
rule-4 gap (exhaustive scores 0.723 at 0.80 and 0.574 at 1.00) and narrows the ceiling
plateau and the plateau is what stops the reward being an imitation score for the
reference's own constants. At 0.90 the best-earning member of the reference's family that
is NOT the reference earns 1.1% more gp and still scores 0.976, far above the 0.90 bar
`probe.py` holds the one-shot form to, and the gap to exhaustive is 0.369. It is also the
one-shot environment's value, which is one fewer constant that differs between two forms of
the same market for no measured reason."""
def score(result: Result, reference: Result,
target_share: float = TARGET_SHARE) -> dict[str, float]:
"""Three numbers and the two ratios behind them, for one played episode.
profit realised, against nine tenths of what the reference earned in the same
basket. Zero for doing nothing, and zero for a round trip that fails to
clear the tax.
discipline profit x return-on-peak-capital, relative to the reference's. A PRODUCT,
never a term beside profit: as a separate weighted term it pays in full
for one tiny position that traded perfectly, which is inaction with a
receipt. `roc` is over PEAK employed capital rather than average see
`live.Ledger.employed` for the measured free-points bug that is.
clean cleared both bars. Binary: the window was worth trading or it was not.
"""
target = target_share * reference.realised
profit = 0.0 if target <= 0 else min(1.0, max(0.0, result.realised / target))
efficiency = 0.0 if reference.roc <= 0 else min(1.0, max(0.0, result.roc / reference.roc))
discipline = profit * efficiency
# Guarded on the reference the same way `profit` and `efficiency` are. Against a reference
# that LOST money, `target_share * reference.realised` is negative and doing nothing clears
# it — inaction scored 0.25 here, the whole weight of `clean`. Unreachable as shipped, since
# `viable_live_market` only admits profitable references and `run()` rebuilds from the
# stored viable seed, but it is one relaxed filter away from being reachable and it is
# exactly the shape house rule 3 exists to forbid.
clean = float(reference.realised > 0 and reference.roc > 0
and result.realised >= target_share * reference.realised
and result.roc >= target_share * reference.roc)
return {
"total": 0.45 * profit + 0.30 * discipline + 0.25 * clean,
"profit_ratio": profit,
"efficiency": efficiency,
"discipline": discipline,
"clean": clean,
}
@@ -0,0 +1,218 @@
"""grand-exchange-live: trade a window while it is running, eight looks at a time.
The one-shot `grand-exchange` asks for one basket of limit orders against thirty ticks the
agent never sees. This asks for a plan that survives contact with the tape: the same market,
sixty graded ticks, and eight looks spread through them. Between looks, ticks execute buys
fill, sales pay into the purse, an anchor moves and after the last look eleven ticks still
run. What is held out is the tail plus every tick the agent chose not to spend a look on.
Three mechanics price a look, and `live.py` documents each at length: re-quoting a live book
freezes the WHOLE book for a few ticks, an amended offer restarts at the back of the queue,
and the per-item buy limit is cumulative over the window rather than per offer. Together
they are what makes house rule 4 hold here as a measurement rather than as a hope the
policy that spends every look scores 0.631 against the reference's 1.000, gap 0.369, stdev
0.054 over five blocks of twenty-four, worst block 0.316 (`measure_ladder.py`).
The env drives the engine host-side. `run()` opens ONE interaction and walks `live.play`
through it: the engine asks its policy for a sheet, the policy renders the turn, waits on
the model, parses the reply, and hands back legs. The engine is a synchronous loop and the
model call is not, so `play` runs in a worker thread and each policy call hops back onto the
event loop the alternative is a second copy of the tick loop written in async, which is a
second engine to keep in step with the one the ladder and the probe measure.
Three rewards, the one-shot form's, over `reward.score`:
profit realised gp against nine tenths of what the reference earned in this basket.
discipline profit x return on PEAK employed capital, relative to the reference's. A
product, never a term beside profit.
gate cleared both bars. Binary.
`TurnView` carries the live `Market`, and `view.market.items[i].prices` is the full
held-out series. Nothing in this file touches it: the observation comes from
`protocol.render` and every metric below is a plain float computed from `Result`. See
`protocol.py`'s docstring for the leak and the tests that hold it shut.
"""
from __future__ import annotations
import asyncio
from typing import ClassVar
from pydantic import Field
import verifiers.v1 as vf
from grand_exchange_live.live import (
HORIZON,
TURNS,
Leg,
TurnView,
live_reference,
play,
viable_live_market,
)
from grand_exchange_live.market import SEED_BASE, build_market
from grand_exchange_live.protocol import SYSTEM, opening, parse_sheet, render
from grand_exchange_live.reward import TARGET_SHARE, score
class LiveExchangeData(vf.TaskData):
seed: int
"""Rebuilds the whole basket exactly, both halves. The graded ticks are never serialized
they are regenerated from this integer at scoring time, so the task data cannot leak
what the agent is graded on."""
num_items: int
visible: int
turns: int
class LiveExchangeTask(vf.Task[LiveExchangeData]):
@vf.stop
async def budget(self, trace: vf.Trace) -> bool:
"""The turn budget, as a backstop. `run()` asks for exactly `turns` sheets and stops,
so this only fires if the exchange is driven from somewhere else but a multi-turn
task with no stop condition is a task whose budget is a convention, and the budget is
the thing this environment measures."""
return trace.num_turns >= self.data.turns
class LiveExchangeConfig(vf.TasksetConfig):
num_tasks: int = Field(48, ge=1)
num_items: int = Field(5, ge=1)
visible: int = Field(56, ge=8)
"""Warmup ticks, read before the first look. Enough that the mean is an anchor and not a
rumour: shorten it and the reference stops being reliably profitable, which takes the
reward's denominator with it."""
class LiveExchangeTaskset(vf.Taskset[LiveExchangeTask, LiveExchangeConfig]):
SEED_BASE: ClassVar[int] = SEED_BASE
def load(self) -> list[LiveExchangeTask]:
tasks, seed = [], self.SEED_BASE
for i in range(self.config.num_tasks):
# `viable_live_market` skips a basket the LIVE reference loses money in — every
# ratio divides by it, so a basket where it loses has no reachable ceiling — and
# the seed stored is the one it landed on, so the next scan starts after it. A
# contiguous range would hand two tasks the same basket and would grade a
# different set from the one `measure_ladder.baskets` reports on.
market = viable_live_market(
seed, self.config.num_items, self.config.visible, HORIZON
)
seed = market.seed + 1
tasks.append(
LiveExchangeTask(
LiveExchangeData(
idx=i,
name=f"basket-{market.seed}",
prompt=opening(market, TURNS),
system_prompt=SYSTEM,
seed=market.seed,
num_items=self.config.num_items,
visible=self.config.visible,
turns=TURNS,
),
self.config.task,
)
)
return tasks
class LiveExchangeEnvConfig(vf.EnvConfig):
agent: vf.AgentConfig = vf.AgentConfig()
"""The one seat, named to match `SingleAgentEnvConfig` so `--env.agent.*` addresses it
exactly as it does every other environment here. A different name parses fine and
silently leaves the seat empty."""
class LiveExchangeEnv(vf.Env[LiveExchangeEnvConfig]):
async def run(self, task: vf.Task, agents: vf.Agents) -> None:
data = task.data
market = build_market(data.seed, data.num_items, data.visible, HORIZON)
loop = asyncio.get_running_loop()
state = {"malformed": 0, "holds": 0, "dropped_rows": 0, "turns": 0, "over": False}
async with agents.agent.interaction(task) as interaction:
async def ask(view: TurnView) -> list[Leg]:
# A terminated segment means the run ended instead of answering — a token
# limit, the @stop, a harness that stopped. The naive loop calls turn()
# again and gets RuntimeError('the exchange is over'), which fails the
# episode over a model that simply stopped talking. The window keeps
# running instead: every remaining look holds, and the offers standing at
# that moment are graded against the ticks that follow.
if state["over"]:
return []
# The task is prompted, so the model speaks first: turn 1 is a BARE turn().
# Passing the opening text here raises ValueError — the prompt already
# opened the exchange. `opening()` built that prompt from this same view.
segment = await interaction.turn(None if view.turn == 1 else render(view))
if segment.terminated:
state["over"] = True
return []
state["turns"] += 1
sheet = parse_sheet(segment.last_reply)
state["malformed"] += int(sheet.malformed)
state["dropped_rows"] += sheet.dropped_rows
state["holds"] += int(not sheet.legs)
return sheet.legs
def policy(view: TurnView) -> list[Leg]:
return asyncio.run_coroutine_threadsafe(ask(view), loop).result()
# `play` is the engine the ladder and the probe measure, run unmodified. The
# thread blocks on the model; the loop stays free for every other rollout.
result = await asyncio.to_thread(play, market, policy, turns=data.turns)
# Scored INSIDE the interaction, before it closes. `Rollout.close` logs the
# rollout's reward as it finishes, so rewards recorded after the `async with`
# exits land in `traces.jsonl` and NOT in the `rollout done` line — and the
# runbook's instruction to cross-check the log against the traces would be
# checking a run's real scores against a column of zeroes. Per-trace scoring
# runs at close and only fills in decorated rewards, so these survive it.
reference = live_reference(market)
parts = score(result, reference, TARGET_SHARE)
trace = interaction.trace
trace.record_reward("profit", parts["profit_ratio"], 0.45)
trace.record_reward("discipline", parts["discipline"], 0.30)
trace.record_reward("gate", parts["clean"], 0.25)
trace.record_metrics({
"profit_ratio": parts["profit_ratio"],
"efficiency": parts["efficiency"],
"discipline": parts["discipline"],
"clean": parts["clean"],
"realised": result.realised,
"reference_realised": reference.realised,
"target_realised": TARGET_SHARE * reference.realised,
"roc": result.roc,
"reference_roc": reference.roc,
"peak_employed": result.peak_employed / max(market.capital, 1),
# What the turn budget was spent on. Recorded, never rewarded: a turn-count
# reward would be a second single-sided term, and the freeze already prices the
# look. `amendments` is the count that actually paid one.
"looks": float(result.looks),
"amendments": float(result.amendments),
"frozen_ticks": float(result.frozen_ticks),
"reference_looks": float(reference.looks),
"seen_ticks": float(result.seen_ticks),
"held_out_ticks": float(HORIZON - result.seen_ticks),
"bought": float(result.bought),
"sold": float(result.sold),
"dumped": float(result.dumped),
"spent": result.spent,
"legs_dropped": float(result.dropped),
"legs_unfunded": float(result.unfunded),
# The two ways a reply can say nothing, kept apart. A degenerate but well-formed
# `{"orders": []}` is a HOLD and scores what holding scores; a reply no sheet
# could be read out of is `malformed`. Reporting one as the other is how a
# sampling problem gets written up as a parser bug.
"malformed_replies": float(state["malformed"]),
"held_replies": float(state["holds"]),
"rows_dropped": float(state["dropped_rows"]),
"answered_turns": float(state["turns"]),
"ended_early": float(state["over"]),
})
# Plain ints only. `trace.info` takes the seed the basket was built from and the cost
# of a look in it — never the Market, which carries every held-out tick.
trace.info["basket"] = int(market.seed)
trace.info["freeze"] = int(market.freeze)
@@ -0,0 +1,530 @@
"""Measure the policy ladder in the SHIPPED engine, before any constant is locked.
The spec this environment came from locked `horizon`, `FREEZE_RANGE`, `TARGET_SHARE` and
`TURNS` against arithmetic from a throwaway engine that no longer exists, and its ladder did
not reconcile with its own reward impatient computes to 0.612 where 0.623 was reported.
Nothing here is taken from that document. Every row below is produced by `live.play`, the
engine the taskset will actually run, over the baskets a run will actually serve.
Two questions this file exists to answer, in this order:
1. What does each policy score? Nine of them, from doing nothing to the reference itself,
each differing from its neighbour by one judgement.
2. **Does the turn budget bind?** Every reward in Arena saturates `profit_ratio` and
`efficiency` clip at 1.0 and `clean` is a boolean band so an exhaustive policy that
clears the band scores 1.000 and house rule 4 is structurally unreachable. That is
risk #2 in the programme's register, it is answered as a NUMBER, and if the number is
zero that is a finding about the reward shape rather than a failure of this lane.
Read block-wise, never as one mean: realised P&L over sixty ticks is the noisiest thing in
the repository, and `probe.py` already documents this generator swinging 0.48 against 0.18
between blocks of twenty-four. A ladder read off one block is a ladder read off the seed.
uv run --no-project python measure_ladder.py
uv run --no-project python measure_ladder.py --sweep-target
uv run --no-project python measure_ladder.py --sweep-requote
uv run --no-project python measure_ladder.py --sweep-reference
uv run --no-project python measure_ladder.py --sweep-freeze
uv run --no-project python measure_ladder.py --sweep-shape
--- what it measured, 2026-08-21, amd-server, 120 baskets from SEED_BASE ------------------
TURNS=8, TICKS_PER_TURN=7, TAIL_TICKS=4, HORIZON=60, FREEZE_RANGE=(1,4), TARGET_SHARE=0.90.
policy total profit effcy discip clean gp looks
inaction 0.000 0.000 0.000 0.000 0.000 0 0.00
staller 0.000 0.000 0.000 0.000 0.000 0 0.00
crude (market orders, one look) 0.126 0.207 0.186 0.095 0.017 -2,684 1.00
spammer 0.158 0.262 0.219 0.114 0.025 13,938 7.89
restate (re-quote the same book) 0.219 0.375 0.362 0.168 0.000 25,244 7.90
plausible (mean anchor, no filter) 0.406 0.563 0.524 0.391 0.142 37,084 1.00
churn (re-place at full size) 0.447 0.684 0.550 0.430 0.042 49,926 8.00
impatient (one-shot ref, one look) 0.538 0.739 0.722 0.560 0.150 50,172 1.00
hindsight (whole-window anchor) 0.598 0.792 0.763 0.630 0.208 54,599 1.00
exhaustive (re-quote every look) 0.631 0.849 0.749 0.669 0.192 63,802 7.97
oracle (the live reference) 1.000 1.000 1.000 1.000 1.000 76,744 3.99
Every row reconciles with 0.45*profit + 0.30*discipline + 0.25*clean to three decimals,
which the spec's ladder did not.
**The budget binds. Oracle - exhaustive = 0.369, stdev 0.054 over five blocks of 24, worst
block 0.316.** That is fifteen times the 0.02 margin B2 proposed and it clears the 0.25
quantum of the binary `gate` term outright, so the comparison is not a cliff. It is also
not an artifact of the denominator: the reference earns 76,744 gp against exhaustive's
63,802 and out-returns it on peak capital, 0.302 against 0.219.
`hindsight` is the row that says the stepped form measures something the one-shot form
cannot. It quotes off the mean of the WHOLE window, held-out ticks included, and still
scores 0.598 perfect price foresight in a single plan is worth less than playing the
window with none.
Three things this measurement overturned:
the first engine's rule-4 gap was fake. With a reference that never redeployed sale
proceeds, exhaustive earned 22% MORE gp than the reference and scored 0.848: the gap was
the denominator being under-tuned, exactly the imitation-score defect `book.py` documents
paying for once already. Fixed by giving the reference the idle-cash trigger.
the freeze alone is not what binds. At freeze 0 the gap is still 0.180 the queue reset
and the capital churn carry it. The freeze sets the slope: 0.238 at 1 tick, 0.505 at 4,
0.902 at 8. FREEZE_RANGE=(1,4) is kept because 8 turns a look into a catastrophe rather
than a cost, and because flat-per-look is the settled decision.
more turns bind harder, monotonically: 4x14+4 gives 0.155, 8x7+4 gives 0.369, 12x4+12
gives 0.517. Eight is chosen for the token cost of a rollout, not because the gap needs
it. If the gap ever needs widening, this is the lever, and it is measured.
"""
from __future__ import annotations
import random
import statistics
from dataclasses import replace
import sys
import time
import types
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
try:
# The installed package, when there is one — `taskset.py` reads `__all__` off whatever
# `sys.modules` holds, and a namespace shim has none, so an unconditional shim here
# would break every taskset lookup in any process that also imports this file.
import grand_exchange_live # noqa: F401
except ImportError:
# Nothing installed: register the package as a namespace pointing at the source and let
# the leaf modules import under it, so `__init__` — and with it `verifiers` — never runs.
# This is the path `probe.py` takes, and it is the reason this file can be graded with
# the training stack absent.
_shim = types.ModuleType("grand_exchange_live")
_shim.__path__ = [str(HERE / "grand_exchange_live")]
sys.modules["grand_exchange_live"] = _shim
from grand_exchange_live.book import ( # noqa: E402
BUY_BAND,
FILL_SHARE,
MAX_ITEM_SHARE,
MIN_CROSSINGS,
SELL_BAND,
TAX,
Order,
crossings,
reference_orders,
)
from grand_exchange_live.live import ( # noqa: E402
HORIZON,
IDLE_SHARE,
LIVE_VIABILITY_TRIES,
REQUOTE_BAND,
TAIL_TICKS,
TICKS_PER_TURN,
TURNS,
Leg,
Reference,
desired_orders,
live_reference,
play,
viable_live_market,
)
from grand_exchange_live.market import SEED_BASE, build_market # noqa: E402
import grand_exchange_live.reward as reward # noqa: E402
HEADER = "requote / idle"
BLOCK = 24
BLOCKS = 5
TARGET_SHARE = reward.TARGET_SHARE
"""The ceiling band, imported rather than restated. It was defined here while it was being
measured `--sweep-target` is what chose 0.90 and it moved into the shipped package the
day the taskset landed: a reward the probe computes and a reward the run computes are two
rewards, and the day they drift the probe is gating something the model is not paid for.
`reward.TARGET_SHARE` carries the measurement that chose it."""
score = reward.score
"""Likewise. One definition, three callers: the run, this ladder, and `probe.py` through it."""
# --- the baskets a run actually serves --------------------------------------------------
def baskets(count: int, base: int = SEED_BASE):
"""Skip-aware, exactly as `taskset.LiveExchangeTaskset.load` is: `viable_live_market` may pass over a
seed the LIVE reference loses money in, and the next task starts after the seed it
landed on. Grading a contiguous `range` instead would grade a different set of baskets
from the one a model is served."""
out, seed = [], base
for _ in range(count):
market = viable_live_market(seed, 5, 56, HORIZON)
seed = market.seed + 1
out.append(market)
return out
# --- the ladder, as policies rather than as adjectives ----------------------------------
def inaction(view):
"""Never submits a sheet. House rule 3's floor."""
return []
def staller(view):
"""Takes every look and does nothing with any of them. Scores what inaction scores —
the point of the row is that BURNING the budget is not itself worth anything, which is
the degenerate strategy a time-averaged reward would pay for."""
return []
def spammer(seed: int):
"""Random legs on every look. Acts constantly, reads nothing."""
rng = random.Random(seed)
def policy(view):
items = view.market.items
return [
Leg(i.name, rng.randint(1, i.buy_limit),
round(i.prices[view.seen - 1] * rng.uniform(0.85, 1.15)),
round(i.prices[view.seen - 1] * rng.uniform(0.85, 1.15)))
for i in items if rng.random() < 0.6
]
return policy
def one_look(plan_of):
"""Submit a plan on turn 1, never look again. The one-shot form, played live."""
def make(market):
state = {"done": False}
orders = plan_of(market)
def policy(view):
if state["done"]:
return []
state["done"] = True
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
return policy
return make
def band(market, items, anchor, sizer, filtered=False, ranked=False):
"""`probe.py`'s strategy family, unchanged, so the live ladder's crude and plausible
rungs are the same strategies the one-shot ladder grades."""
plans, out, purse = [], [], float(market.capital)
for item in items:
prices = item.visible_prices(market.visible)
volumes = item.visible_volumes(market.visible)
if filtered and crossings(prices) < MIN_CROSSINGS:
continue
a = anchor(prices)
buy = max(1, round(a * (1.0 - BUY_BAND)))
sell = max(buy + 1, round(a * (1.0 + SELL_BAND)))
lows = [p for p in prices if p <= buy]
highs = [p for p in prices if p >= sell]
qty = sizer(item, market, buy, prices, volumes)
if qty <= 0:
continue
s = 0.0
if ranked:
if not lows or not highs:
continue
entry, exit_ = statistics.fmean(lows), statistics.fmean(highs)
s = (exit_ * (1.0 - TAX) - entry) / entry
if s <= 0:
continue
plans.append((s, item.name, qty, buy, sell))
plans.sort(key=lambda p: -p[0])
for _, name, qty, buy, sell in plans:
qty = min(qty, int(purse // buy))
if qty <= 0:
continue
purse -= qty * buy
out.append(Order(name, qty, buy, sell))
return out
def by_volume(item, market, buy, prices, volumes):
lows = [p for p in prices if p <= buy]
reach = int(FILL_SHARE * statistics.median(volumes) * (len(lows) / len(prices))
* market.held_out)
return min(item.buy_limit, reach, int(MAX_ITEM_SHARE * market.capital // buy))
def market_orders(market):
"""Buy ten percent over the last price and sell ten under: every offer fills and every
round trip pays the tax. The crude maximiser."""
return [
Order(i.name, i.buy_limit, round(i.prices[market.visible - 1] * 1.10),
round(i.prices[market.visible - 1] * 0.90))
for i in market.items
]
def churn(market):
"""Re-place the ORIGINAL plan at full size on every look.
Not the same policy as re-stating the book: the units already bought release their
reservation and sale proceeds have returned to the purse, so asking for the full size
again spends them. It earns roughly what a single look earns off materially more peak
capital, which is precisely the shape `efficiency` exists to catch.
"""
orders = reference_orders(market)
return lambda view: [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
def restate(market):
"""Place the one-shot reference on turn 1, then re-submit the book exactly as it stands
on every look after it.
The pure cost of looking, isolated: same names, same prices, same units still sought,
nothing learned, one freeze and one queue reset per turn. Its gap below `impatient` an
identical plan that simply stops looking is the price of a look with no information in
it, in reward units.
"""
orders = reference_orders(market)
def policy(view):
if view.turn == 1:
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in view.open_orders.values()]
return policy
def exhaustive(market):
"""Spend the entire budget: re-quote on EVERY look, off everything observed so far.
This is the rule-4 policy. It makes every judgement the reference makes and one more
look than the reference thinks is worth paying for, so if it scores at the oracle the
budget is free and the environment measures nothing the one-shot form did not.
"""
inner = Reference(requote_band=0.0)
return inner
def perfect_hindsight(market):
"""Not shipped, not a rung: a sanity ceiling. Quotes off the mean of the WHOLE window,
held-out ticks included. If the oracle were close to this, the visible half would not be
the thing being read."""
prices = {i.name: list(i.prices) for i in market.items}
volumes = {i.name: list(i.volumes) for i in market.items}
orders = desired_orders(market.items, prices, volumes, float(market.capital),
market.held_out, {i.name: i.buy_limit for i in market.items})
return one_look(lambda m: orders)(market)
LADDER = [
("inaction", lambda m: inaction),
("staller", lambda m: staller),
("spammer", lambda m: spammer(m.seed)),
("crude (market orders, one look)", one_look(market_orders)),
("plausible (mean anchor, no filter)",
one_look(lambda m: band(m, m.items, statistics.fmean, by_volume))),
("impatient (one-shot reference, one look)", one_look(reference_orders)),
("restate (re-quote the same book every look)", restate),
("churn (re-place at full size every look)", churn),
("exhaustive (re-quote every look)", exhaustive),
("oracle (the live reference)", lambda m: Reference()),
("hindsight (not a rung: whole-window anchor)", perfect_hindsight),
]
def ladder(markets, target_share: float = TARGET_SHARE) -> dict[str, dict[str, float]]:
rows: dict[str, dict[str, float]] = {}
for name, make in LADDER:
totals: dict[str, float] = {}
gp = looks = frozen = 0.0
peak = 0.0
for market in markets:
result = play(market, make(market))
for key, value in score(result, live_reference(market), target_share).items():
totals[key] = totals.get(key, 0.0) + value
gp += result.realised
looks += result.looks
frozen += result.frozen_ticks
peak += result.peak_employed
n = len(markets)
rows[name] = {k: v / n for k, v in totals.items()}
rows[name].update(gp=gp / n, looks=looks / n, frozen=frozen / n, peak=peak / n)
return rows
def table(markets, target_share: float = TARGET_SHARE) -> None:
rows = ladder(markets, target_share)
print(f"{'policy':<46}{'total':>8}{'profit':>8}{'effcy':>8}{'discip':>8}{'clean':>7}"
f"{'gp':>12}{'peak gp':>10}{'looks':>7}{'frozen':>7}")
for name, row in rows.items():
print(f"{name:<46}{row['total']:>8.3f}{row['profit_ratio']:>8.3f}"
f"{row['efficiency']:>8.3f}{row['discipline']:>8.3f}{row['clean']:>7.3f}"
f"{row['gp']:>12,.0f}{row['peak']:>10,.0f}{row['looks']:>7.2f}"
f"{row['frozen']:>7.2f}")
def main() -> None:
flags = set(sys.argv[1:])
started = time.monotonic()
seeds = baskets(BLOCK * BLOCKS)
build = time.monotonic() - started
blocks = [seeds[i:i + BLOCK] for i in range(0, len(seeds), BLOCK)]
skipped = sum(1 for i, m in enumerate(seeds)
if m.seed != (seeds[i - 1].seed + 1 if i else SEED_BASE))
print(f"engine TURNS={TURNS} TICKS_PER_TURN={TICKS_PER_TURN} TAIL={TAIL_TICKS} "
f"HORIZON={HORIZON} REQUOTE_BAND={REQUOTE_BAND:.4f} TARGET_SHARE={TARGET_SHARE}")
print(f"baskets {len(seeds)} over {BLOCKS} blocks of {BLOCK}, "
f"{skipped} seeds skipped by viability (cap {LIVE_VIABILITY_TRIES}), "
f"built in {build:.2f}s")
print(f"freeze {statistics.fmean([m.freeze for m in seeds]):.2f} ticks mean, "
f"distribution {sorted({f: [m.freeze for m in seeds].count(f) for f in {m.freeze for m in seeds}}.items())}")
print()
print("=== the ladder, all 120 baskets ===")
table(seeds)
print()
print("=== does the budget bind? exhaustive against oracle, block by block ===")
print(f"{'block':<22}{'exhaustive':>12}{'oracle':>10}{'gap':>10}"
f"{'exh gp':>12}{'ora gp':>12}{'exh looks':>11}{'ora looks':>11}")
gaps = []
for i, block in enumerate(blocks):
rows = ladder(block)
e, o = rows["exhaustive (re-quote every look)"], rows["oracle (the live reference)"]
gaps.append(o["total"] - e["total"])
print(f"{f'{block[0].seed}-{block[-1].seed}':<22}{e['total']:>12.3f}{o['total']:>10.3f}"
f"{o['total'] - e['total']:>10.3f}{e['gp']:>12,.0f}{o['gp']:>12,.0f}"
f"{e['looks']:>11.2f}{o['looks']:>11.2f}")
print(f"{'mean':<22}{'':>12}{'':>10}{statistics.fmean(gaps):>10.3f}")
print(f"{'stdev':<22}{'':>12}{'':>10}{statistics.stdev(gaps):>10.3f}")
print(f"{'worst block':<22}{'':>12}{'':>10}{min(gaps):>10.3f}")
print()
print("=== every rung, block by block (total only) ===")
per_block = [ladder(b) for b in blocks]
print(f"{'policy':<46}" + "".join(f"{f'b{i}':>9}" for i in range(len(blocks)))
+ f"{'spread':>9}")
for name, _ in LADDER:
values = [rows[name]["total"] for rows in per_block]
print(f"{name:<46}" + "".join(f"{v:>9.3f}" for v in values)
+ f"{max(values) - min(values):>9.3f}")
print()
if "--sweep-target" in flags:
print("=== TARGET_SHARE sweep (the ceiling band) ===")
names = [n for n, _ in LADDER]
print(f"{'share':<8}" + "".join(f"{n.split(' ')[0]:>12}" for n in names))
for share in (0.75, 0.80, 0.85, 0.90, 0.95, 1.00):
rows = ladder(seeds, share)
print(f"{share:<8.2f}" + "".join(f"{rows[n]['total']:>12.3f}" for n in names))
print()
if "--sweep-requote" in flags:
print("=== REQUOTE_BAND sweep (what the reference should pay a freeze for) ===")
print(f"{'band':<8}{'gp':>12}{'roc':>10}{'looks':>8}{'frozen':>8}")
for band_value in (0.0, 0.005, 0.01, 0.015, 0.025, 0.04, 0.06, 0.10, 1.0):
gp = roc = looks = frozen = 0.0
for market in seeds:
result = play(market, Reference(requote_band=band_value))
gp += result.realised
roc += result.roc
looks += result.looks
frozen += result.frozen_ticks
n = len(seeds)
print(f"{band_value:<8.3f}{gp / n:>12,.0f}{roc / n:>10.4f}"
f"{looks / n:>8.2f}{frozen / n:>8.2f}")
print()
if "--sweep-reference" in flags:
# The denominator has to be the profit-maximising point of its OWN family, or some
# other member earns more gp and scores less, and the reward's argmax is a pair of
# constants rather than a strategy. This is the same sweep `book.py` documents for
# the one-shot bands, over the two triggers that make the live reference act.
print("=== reference family sweep: gp per basket ===")
idles = (0.0, 0.05, 0.10, 0.15, 0.20, 0.30, 0.50, 1.01)
print(f"{HEADER:<16}" + "".join(f"{i:>10.2f}" for i in idles))
best = (0.0, None)
for band_value in (0.0, 0.005, 0.010, 0.015, 0.025, 0.040, 0.060, 1.0):
row = []
for idle in idles:
gp = sum(play(m, Reference(band_value, idle)).realised for m in seeds)
row.append(gp / len(seeds))
if row[-1] > best[0]:
best = (row[-1], (band_value, idle))
print(f"{band_value:<16.3f}" + "".join(f"{v:>10,.0f}" for v in row))
print(f"argmax {best[1]} at {best[0]:,.0f} gp per basket; shipped "
f"({REQUOTE_BAND}, {IDLE_SHARE}) is "
f"{sum(play(m, Reference()).realised for m in seeds) / len(seeds):,.0f}")
print()
print("=== the same family, in return on peak capital ===")
print(f"{HEADER:<16}" + "".join(f"{i:>10.2f}" for i in idles))
for band_value in (0.0, 0.005, 0.010, 0.015, 0.025, 0.040, 0.060, 1.0):
row = []
for idle in idles:
roc = sum(play(m, Reference(band_value, idle)).roc for m in seeds)
row.append(roc / len(seeds))
print(f"{band_value:<16.3f}" + "".join(f"{v:>10.4f}" for v in row))
print()
print("=== and what each family member SCORES against the shipped reference ===")
print(f"{HEADER:<16}" + "".join(f"{i:>10.2f}" for i in idles))
for band_value in (0.0, 0.005, 0.010, 0.015, 0.025, 0.040, 0.060, 1.0):
row = []
for idle in idles:
total = sum(score(play(m, Reference(band_value, idle)), live_reference(m))["total"]
for m in seeds)
row.append(total / len(seeds))
print(f"{band_value:<16.3f}" + "".join(f"{v:>10.3f}" for v in row))
print()
if "--sweep-freeze" in flags:
# If no freeze makes looking expensive enough for the optimum to be interior, the
# freeze is decoration and rule 4 is being carried by the denominator alone.
print("=== freeze sweep: what a look has to cost before it is worth declining ===")
print(f"{'freeze':<10}{'exhaust gp':>12}{'oracle gp':>12}{'exh score':>11}"
f"{'gap':>8}{'ora looks':>11}{'ora frozen':>12}")
for forced in (0, 1, 2, 3, 4, 6, 8, 12):
shaped = [replace(m, freeze=forced) for m in seeds]
e = g = 0.0
egp = ogp = looks = frozen = 0.0
for m in shaped:
ref = play(m, Reference())
exh = play(m, Reference(requote_band=0.0, idle_share=0.0))
if ref.realised <= 0:
continue
e += score(exh, ref)["total"]
g += 1.0 - score(exh, ref)["total"]
egp += exh.realised
ogp += ref.realised
looks += ref.looks
frozen += ref.frozen_ticks
n = len(shaped)
print(f"{forced:<10}{egp / n:>12,.0f}{ogp / n:>12,.0f}{e / n:>11.3f}"
f"{g / n:>8.3f}{looks / n:>11.2f}{frozen / n:>12.2f}")
print()
if "--sweep-shape" in flags:
print("=== window shape sweep: does a different budget bind harder? ===")
print(f"{'turns x step + tail':<22}{'horizon':>9}{'exhaust':>9}{'oracle':>9}"
f"{'gap':>9}{'ora looks':>11}{'held out':>10}")
for turns, step, tail in ((4, 14, 4), (6, 9, 6), (8, 7, 4), (8, 5, 20),
(10, 5, 10), (12, 4, 12)):
horizon = turns * step + tail
shaped = [build_market(m.seed, 5, 56, horizon) for m in seeds]
e = o = 0.0
looks = 0.0
for market in shaped:
ref = play(market, Reference(), turns=turns, step=step, tail=tail)
exh = play(market, Reference(requote_band=0.0), turns=turns, step=step,
tail=tail)
if ref.realised <= 0:
continue
e += score(exh, ref)["total"]
o += score(ref, ref)["total"]
looks += ref.looks
n = len(shaped)
print(f"{f'{turns} x {step} + {tail}':<22}{horizon:>9}{e / n:>9.3f}{o / n:>9.3f}"
f"{(o - e) / n:>9.3f}{looks / n:>11.2f}{step + tail:>10}")
print()
print(f"total wall clock {time.monotonic() - started:.1f}s")
if __name__ == "__main__":
main()
@@ -0,0 +1,13 @@
[project]
name = "grand-exchange-live"
version = "0.1.0"
description = "grand-exchange-live — quote, watch the tape, re-quote, over a turn budget."
requires-python = ">=3.11"
dependencies = ["verifiers==0.3.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["grand_exchange_live"]
@@ -0,0 +1,40 @@
"""Import the package under test, installed or not — and get the same one every time.
Two facts collide here. `probe.py` and half of this suite must run with NOTHING installed,
which is why the package is registered as a namespace pointing at the source directory and
its leaf modules are imported under it: `__init__` never executes, so `verifiers` is never
required. And `test_taskset.py` needs the opposite `verifiers` present and
`grand_exchange_live` resolving to the REAL package, because `import_taskset` reads
`__all__` off whatever is in `sys.modules` and a namespace shim has none.
A shim installed by whichever test module imported first would decide that for the whole
process. So the decision is made once, here, and every test module imports this before it
imports anything else: the real package if it is importable, the shim if it is not.
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def shim(package: Path) -> None:
"""Register `package` as a namespace so its leaf modules import without its `__init__`."""
module = types.ModuleType(package.name)
module.__path__ = [str(package)]
sys.modules[package.name] = module
try:
import grand_exchange_live
INSTALLED = getattr(grand_exchange_live, "__all__", None) is not None
except ImportError:
# The package is there and `verifiers` is not, which is the no-dependency path.
INSTALLED = False
if not INSTALLED:
shim(ROOT / ROOT.name)
@@ -0,0 +1,556 @@
"""The stepped engine's invariants, as tests rather than as docstrings.
Five claims are load-bearing and every one of them is a way the environment could be quietly
wrong rather than loudly broken:
the fixed point one look on turn 1 and nothing after it must reproduce the one-shot
engine EXACTLY same realised profit, same capital committed. If it
does not, the live form is a different market wearing the same
generator, and no number measured here is comparable to a `grand-exchange`
number.
the ledger cash, coins locked, cost basis and realised profit must sum to the
starting capital after every single tick. A bug that mints money reads
as a good score, not as a crash.
the buy limit cumulative across the whole window, so cancel-and-re-place cannot buy
the same item twice over. Without it, re-quoting resets the limit and
the limit stops being a limit.
the freeze a look that touches a live book stops the WHOLE book for `market.freeze`
ticks, and the first placement is free.
the queue an amended offer starts again at zero accrued fill room, so moving a
limit costs even after the freeze has expired.
"""
from __future__ import annotations
import random
import statistics
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import context # noqa: E402 - resolves the package before anything imports it
HERE = context.ROOT
# The one-shot environment is a sibling checkout rather than a dependency, and the fixed-point
# assertion below needs both engines in one process. It is always a shim.
context.shim(HERE.parent / "grand_exchange" / "grand_exchange")
from grand_exchange_live.book import Order, execute, reference_orders # noqa: E402
from grand_exchange_live.live import ( # noqa: E402
HORIZON,
IDLE_SHARE,
REQUOTE_BAND,
TAIL_TICKS,
TICKS_PER_TURN,
TURNS,
Leg,
Ledger,
Reference,
desired_orders,
play,
)
from grand_exchange_live.market import build_market # noqa: E402
SEEDS = range(60_000, 60_024)
def market(seed: int, held_out: int = HORIZON):
return build_market(seed, 5, 56, held_out)
def once(orders):
"""Submit a plan on turn 1 and never look again — the one-shot strategy, played live."""
state = {"done": False}
def policy(view):
if state["done"]:
return []
state["done"] = True
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in orders]
return policy
def restate(view):
"""Re-submit the book exactly as it already stands, on every look.
The true no-op re-quote, and the only honest way to price the freeze and the queue
reset on their own: same names, same prices, same units still sought. Nothing about the
plan changes, so any difference in what it buys or earns IS the cost of looking.
Note that re-submitting the ORIGINAL quantities instead is a different policy and a
bigger one the units already bought free up their reservation, sale proceeds return
to the purse, and asking for the full size again spends them. That policy is `churn` in
the ladder; it earns about the same gp off a third more peak capital, which is what
`efficiency` is for.
"""
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in view.open_orders.values()]
class FixedPointTests(unittest.TestCase):
def test_single_look_matches_one_shot(self) -> None:
"""The stepped engine, used exactly the way the one-shot engine is used, IS the
one-shot engine. This is the assertion that makes the two forms comparable."""
for seed in SEEDS:
m = market(seed)
plan = reference_orders(m)
stepped = play(m, once(plan))
flat = execute(m, plan)
self.assertAlmostEqual(stepped.realised, flat.realised, places=6, msg=f"seed {seed}")
self.assertAlmostEqual(stepped.peak_employed, flat.committed, places=6,
msg=f"seed {seed}")
self.assertEqual(stepped.bought, flat.bought, f"seed {seed}")
self.assertEqual(stepped.sold, flat.sold, f"seed {seed}")
self.assertEqual(stepped.dumped, flat.dumped, f"seed {seed}")
self.assertEqual(stepped.frozen_ticks, 0, "the first placement is not free")
def test_turn_one_matches_reference_orders(self) -> None:
"""`desired_orders` is `book.reference_orders` generalised to a moment in the
window, and on turn 1 the moment is the one the one-shot form scores."""
for seed in SEEDS:
m = market(seed)
wanted = desired_orders(
m.items,
{i.name: list(i.visible_prices(m.visible)) for i in m.items},
{i.name: list(i.visible_volumes(m.visible)) for i in m.items},
float(m.capital),
m.held_out,
{i.name: i.buy_limit for i in m.items},
)
self.assertEqual(
[(o.item, o.quantity, o.buy, o.sell) for o in wanted],
[(o.item, o.quantity, o.buy, o.sell) for o in reference_orders(m)],
f"seed {seed}",
)
class LedgerTests(unittest.TestCase):
def test_nothing_mints_coins(self) -> None:
"""Cash + locked + cost basis + realised == capital, checked after every tick of
every turn by instrumenting `Ledger.mark`, which the engine calls on each one."""
broken: list[str] = []
original = Ledger.mark
def checked(self) -> None:
total = self.cash + self.locked + sum(self.basis.values()) \
+ sum(self.incoming_basis.values())
if abs((total - self.realised) - self.capital) > 1e-6:
broken.append(f"identity: {total:.6f} against {self.capital:.6f}")
# The purse is the binding constraint on everything: an offer is funded out of
# it or it is not placed. A negative purse is the engine having spent coins that
# were not there, which reads as a good score rather than as a crash.
if self.cash < -1e-9:
broken.append(f"purse: {self.cash:.6f}")
original(self)
Ledger.mark = checked
try:
for seed in SEEDS:
m = market(seed)
for policy in (Reference(), once(reference_orders(m)), restate):
play(m, policy)
finally:
Ledger.mark = original
self.assertEqual(broken, [], "the ledger identity broke")
def test_inaction_is_exactly_zero(self) -> None:
for seed in SEEDS:
result = play(market(seed), lambda view: [])
self.assertEqual(result.realised, 0.0)
self.assertEqual(result.peak_employed, 0.0)
self.assertEqual(result.roc, 0.0)
self.assertEqual(result.looks, 0)
def test_capital_employed_can_exceed_the_starting_purse(self) -> None:
"""A policy that recycles sale proceeds can employ more than the starting capital,
and that is the whole point of a purse the proceeds return to a one-shot plan
cannot spend what its own sales earned. What it can never do is employ coins that
do not exist, and that is `test_nothing_mints_coins`'s negative-purse check, not a
bound on the final realised profit: gains made mid-window and given back later are
real capital while they are employed."""
peaks = [play(market(s), Reference()).peak_employed for s in SEEDS]
self.assertTrue(all(p > 0.0 for p in peaks))
self.assertTrue(any(p > 250_000 for p in peaks),
"no basket ever employs more than the starting purse — sale "
"proceeds are not being redeployed and the live form is a "
"one-shot form with extra steps")
class BuyLimitTests(unittest.TestCase):
def test_the_window_limit_is_cumulative_across_requotes(self) -> None:
"""Ask for the whole limit on every one of the eight looks. The window limit is the
window's, not the offer's, so the total bought can never exceed it."""
greedy = lambda view: [
Leg(i.name, i.buy_limit, round(i.prices[view.seen - 1] * 1.20),
round(i.prices[view.seen - 1] * 0.80))
for i in view.market.items
]
seen_a_cap = False
for seed in SEEDS:
m = market(seed)
result = play(m, greedy)
for item in m.items:
got = result.bought_by_item.get(item.name, 0)
self.assertLessEqual(got, item.buy_limit,
f"seed {seed}: bought {got} of {item.name} against a "
f"window limit of {item.buy_limit}")
seen_a_cap = seen_a_cap or got == item.buy_limit
self.assertTrue(seen_a_cap, "no basket ever reached a buy limit — the invariant is "
"untested by this policy rather than upheld by the engine")
class FreezeTests(unittest.TestCase):
def test_the_first_placement_is_free_and_the_second_is_not(self) -> None:
for seed in SEEDS:
m = market(seed)
plan = reference_orders(m)
if not plan:
continue
self.assertEqual(play(m, once(plan)).frozen_ticks, 0)
state = {"n": 0}
def twice(view, plan=plan, state=state):
state["n"] += 1
if state["n"] <= 2:
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in plan]
return []
result = play(m, twice)
self.assertEqual(result.frozen_ticks, m.freeze,
f"seed {seed}: one re-quote should freeze {m.freeze} ticks")
self.assertEqual(result.amendments, 1)
self.assertEqual(result.looks, 2)
def test_a_frozen_tick_fills_nothing(self) -> None:
"""The freeze has to bite on the tape, not only on a counter. Re-quoting the
identical plan changes nothing about what is wanted, so any difference in units
bought is the freeze and the queue reset and it must never be an increase."""
churned = fresh = 0
for seed in SEEDS:
m = market(seed)
plan = reference_orders(m)
fresh += play(m, once(plan)).bought
churned += play(m, restate).bought
self.assertLess(churned, fresh,
f"churning the identical plan bought {churned} units against "
f"{fresh} — re-quoting costs nothing")
def test_churn_earns_less_than_standing_still(self) -> None:
"""The same claim in gp, which is what the reward actually reads."""
churned = fresh = 0.0
for seed in SEEDS:
m = market(seed)
plan = reference_orders(m)
fresh += play(m, once(plan)).realised
churned += play(m, restate).realised
self.assertLess(churned, fresh, f"churn earned {churned:,.0f} gp against {fresh:,.0f}")
class QueueTests(unittest.TestCase):
def test_an_amendment_resets_accrued_fill_room(self) -> None:
"""A thin item accrues a quarter of a tick's volume per eligible tick and carries it
between ticks that carry is the only reason an item trading two units a tick is
tradeable rather than untradeable. Throwing it away on every look must show up as
fewer units bought even when the freeze is one tick, so the two mechanics are
separable rather than one mechanic counted twice."""
thin = [s for s in SEEDS if build_market(s, 5, 56, HORIZON).freeze == 1]
self.assertTrue(thin, "no one-tick-freeze basket in the block — test proves nothing")
worse = 0
for seed in thin:
m = market(seed)
plan = reference_orders(m)
if play(m, restate).bought < play(m, once(plan)).bought:
worse += 1
self.assertGreater(worse, 0, "with a one-tick freeze, re-quoting cost nothing at all")
class HeldOutTests(unittest.TestCase):
def test_the_tail_after_the_last_look_is_never_observed(self) -> None:
"""House rule 1, in its WEAKER live form, asserted on the size of the slice rather
than on the frontier. An agent that takes every look still has TICKS_PER_TURN +
TAIL_TICKS ticks executed after its final observation."""
seen: list[int] = []
def watcher(view):
seen.append(view.seen - view.market.visible)
return []
for seed in SEEDS:
m = market(seed)
seen.clear()
play(m, watcher)
self.assertEqual(len(seen), TURNS)
self.assertEqual(seen[0], 0, "the first look sees a graded tick")
self.assertEqual(seen[-1], (TURNS - 1) * TICKS_PER_TURN)
self.assertEqual(HORIZON - seen[-1], TICKS_PER_TURN + TAIL_TICKS)
self.assertGreaterEqual(HORIZON - seen[-1], 4)
def test_a_view_never_carries_a_graded_tick_early(self) -> None:
"""The observation handed over on a look must contain only ticks that have already
been executed against a one-tick lookahead here would hand the agent the answer."""
for seed in (60_000, 60_007, 60_013):
m = market(seed)
def strict(view, m=m):
for item in m.items:
got = view.new_prices[item.name]
start = view.seen - len(got)
self.assertEqual(got, item.prices[start:view.seen])
self.assertLessEqual(view.seen, m.visible + m.held_out)
return []
play(m, strict)
class SheetTests(unittest.TestCase):
def test_a_cancel_returns_the_coins_and_stops_the_buying(self) -> None:
m = market(60_000)
plan = reference_orders(m)
name = plan[0].item
def cancel_after_one(view, plan=plan, name=name):
if view.turn == 1:
return [Leg(o.item, o.quantity, o.buy, o.sell) for o in plan]
if view.turn == 2:
return [Leg(name, 0)]
return []
held = play(m, once(plan)).bought_by_item.get(name, 0)
cut = play(m, cancel_after_one).bought_by_item.get(name, 0)
self.assertLess(cut, held, "cancelling an offer did not stop it buying")
def test_a_leg_naming_nothing_on_the_board_is_dropped_not_fatal(self) -> None:
"""A malformed sheet scores what doing nothing scores. It must never raise: the
reward runs inside the metric, so a raise takes the whole rollout with it rather
than scoring zero."""
junk = lambda view: [
Leg("Nonexistent bauble", 10, 5, 6),
Leg("", 1, 1, 2),
Leg(view.market.items[0].name, 5, 0, 0),
Leg(view.market.items[0].name, -3),
]
result = play(market(60_000), junk)
self.assertEqual(result.realised, 0.0)
self.assertGreaterEqual(result.dropped, 4)
def test_two_legs_on_one_name_keep_the_first(self) -> None:
m = market(60_000)
name = m.items[0].name
def duplicated(view, name=name):
if view.turn > 1:
return []
price = view.market.item(name).prices[view.seen - 1]
return [Leg(name, 10, round(price * 0.95), round(price * 1.05)),
Leg(name, 10_000, round(price * 1.50), round(price * 1.60))]
result = play(m, duplicated)
self.assertEqual(result.dropped, 1)
class ReferencePolicyTests(unittest.TestCase):
def test_the_reference_does_not_requote_every_turn(self) -> None:
"""If it did, the freeze would be a constant cost and the environment would be
measuring the one-shot answer with extra steps."""
amendments = [play(market(s), Reference()).amendments for s in SEEDS]
# Per-basket, the reference will sometimes want every look it is given — a basket
# where the purse keeps freeing up is a basket where redeploying it keeps paying.
# The claim that matters is the average, and it is that a look is declined often
# enough for declining to be a judgement rather than an accident.
self.assertLess(statistics.fmean(amendments), TURNS - 2,
"the reference re-quotes on nearly every look — the budget is a "
"formality")
self.assertGreater(sum(amendments), 0,
"the reference never re-quotes — the stepped form is decorative")
def test_the_reference_makes_money_in_most_baskets(self) -> None:
earned = [play(market(s), Reference()).realised for s in SEEDS]
won = sum(1 for e in earned if e > 0)
self.assertGreaterEqual(won, len(earned) - 2,
f"the reference profits in only {won}/{len(earned)} baskets "
"— the reward's denominator is not reliable")
def test_it_is_deterministic(self) -> None:
for seed in (60_000, 60_011):
m = market(seed)
a, b = play(m, Reference()), play(m, Reference())
self.assertEqual(a, b)
class RandomSheetTests(unittest.TestCase):
def test_random_legs_never_break_the_engine(self) -> None:
"""The hostile battery, cheaply: random garbage on every look, replayed per turn,
must never raise and must never mint a coin."""
rng = random.Random(7)
for seed in SEEDS:
m = market(seed)
def noise(view, rng=rng):
return [
Leg(rng.choice([i.name for i in view.market.items] + ["", "nope"]),
rng.choice([-5, 0, 1, 10 ** 9]),
rng.choice([0, -1, 1, 10 ** 9]),
rng.choice([0, 1, 10 ** 9]))
for _ in range(rng.randint(0, 8))
]
result = play(m, noise)
self.assertLessEqual(result.peak_employed, m.capital + 1e-6)
self.assertGreaterEqual(result.realised, -float(m.capital))
class LadderGateTests(unittest.TestCase):
"""House rules 3 and 4, executable, in this package rather than in `probe.py`.
`probe.py` is B1's file and five workstreams are queued behind it; the gate this
environment lives or dies on should not wait on a merge, and it should fail here first
if the engine drifts. When A's probe row lands it asserts the same three things over the
same helper.
"""
def setUp(self) -> None:
sys.path.insert(0, str(HERE))
import measure_ladder
self.ladder = measure_ladder
self.markets = measure_ladder.baskets(measure_ladder.BLOCK * measure_ladder.BLOCKS)
self.blocks = [self.markets[i:i + measure_ladder.BLOCK]
for i in range(0, len(self.markets), measure_ladder.BLOCK)]
def test_the_floor_is_exactly_zero_and_the_ceiling_is_reachable(self) -> None:
rows = self.ladder.ladder(self.markets)
self.assertEqual(rows["inaction"]["total"], 0.0)
self.assertEqual(rows["staller"]["total"], 0.0)
self.assertEqual(rows["oracle (the live reference)"]["total"], 1.0)
def test_the_budget_binds_in_every_block(self) -> None:
"""Rule 4. Not on the 120-basket mean — `probe.py` documents this generator swinging
0.48 against 0.18 between blocks of twenty-four, and a mean that holds while one
block of the size a run actually grades goes the other way is a mean measured off
the seed."""
gaps = []
for block in self.blocks:
rows = self.ladder.ladder(block)
gap = (rows["oracle (the live reference)"]["total"]
- rows["exhaustive (re-quote every look)"]["total"])
gaps.append(gap)
self.assertGreater(gap, 0.10,
f"baskets {block[0].seed}-{block[-1].seed}: spending every "
f"look scores {gap:.3f} below the reference — the turn "
"budget is a formality")
self.assertGreater(statistics.fmean(gaps), 0.25)
def test_the_reference_is_not_merely_the_denominator(self) -> None:
"""The gap has to be a fact about money, not about normalisation. If a policy EARNED
more than the reference and scored below it, the reward would be an imitation score
for the reference's constants — which is precisely what the first cut of this engine
did, at 63,974 gp against the reference's 51,520.
Two halves, because there are two ways to be the wrong denominator. The rungs are
different strategies; the sweep below is the reference's OWN family, and that is the
half this test used to skip.
"""
rows = self.ladder.ladder(self.markets)
oracle = rows["oracle (the live reference)"]
for name in ("exhaustive (re-quote every look)", "churn (re-place at full size every look)",
"impatient (one-shot reference, one look)",
"restate (re-quote the same book every look)"):
self.assertLess(rows[name]["gp"], oracle["gp"],
f"{name} earns more gp than the reference it is scored against")
def test_no_member_of_the_references_own_family_beats_it_and_scores_below_it(self) -> None:
"""The whole sixty-four-cell family, not four named rungs.
The shipped reference is not the family's argmax and this is the assertion that says
how far from it that is allowed to be. Measured over 120 baskets, 2026-08-21:
shipped (0.025, 0.50) 76,744 gp rule-4 gap 0.369 mean, 0.316 worst block
argmax (1.000, 0.50) 77,599 gp rule-4 gap 0.372 mean, 0.299 worst block
so the best member earns 1.0111x the reference, and the worst-scoring member that
out-earns it (0.040, 0.50) still scores 0.972. Moving the denominator to the
argmax would make the invariant exact and was measured before this test was written:
it is not taken, because the argmax's requote band is 1.0, i.e. the anchor trigger
never fires, so the denominator would be a strategy that cannot express "the anchor
moved" at all — and the freeze, the mechanic the whole environment uses to make the
turn budget bind, exists to price exactly that judgement. It also does not buy a
wider gap: 0.372 against 0.369 on the mean, and a WORSE worst block. Paying 1.1% of
gp to keep the denominator a strategy rather than a cash-threshold reflex is the
trade, and the tolerances below are the measurement, not a taste.
"""
bands = (0.0, 0.005, 0.010, 0.015, 0.025, 0.040, 0.060, 1.0)
idles = (0.0, 0.05, 0.10, 0.15, 0.20, 0.30, 0.50, 1.01)
shipped = statistics.fmean(
self.ladder.live_reference(m).realised for m in self.markets
)
for band in bands:
for idle in idles:
if (band, idle) == (REQUOTE_BAND, IDLE_SHARE):
continue
earned = statistics.fmean(
play(m, Reference(band, idle)).realised for m in self.markets
)
self.assertLessEqual(
earned, 1.02 * shipped,
f"reference family member ({band}, {idle}) earns {earned:,.0f} gp "
f"against the shipped reference's {shipped:,.0f} — the denominator is "
"leaving too much money on the table, so the reward's argmax is its "
"constants rather than the profit",
)
if earned <= shipped:
continue
got = statistics.fmean(
self.ladder.score(play(m, Reference(band, idle)),
self.ladder.live_reference(m))["total"]
for m in self.markets
)
self.assertGreaterEqual(
got, 0.90,
f"reference family member ({band}, {idle}) earns {earned:,.0f} gp "
f"against {shipped:,.0f} and scores {got:.3f} — the reward is an "
"imitation score, not a profit metric",
)
def test_looking_costs_and_a_wasted_look_costs_more(self) -> None:
rows = self.ladder.ladder(self.markets)
self.assertLess(rows["restate (re-quote the same book every look)"]["total"],
rows["impatient (one-shot reference, one look)"]["total"],
"re-quoting the identical book every turn is free")
self.assertLess(rows["crude (market orders, one look)"]["total"],
rows["plausible (mean anchor, no filter)"]["total"])
self.assertLess(rows["plausible (mean anchor, no filter)"]["total"],
rows["impatient (one-shot reference, one look)"]["total"])
class LoadCostTests(unittest.TestCase):
def test_a_default_taskset_load_is_not_three_thousand_simulations(self) -> None:
"""`num_tasks` defaults to 48 and viability retries; a live `viable_market` running
a full stepped reference per candidate is thousands of simulations before the first
token, on every eval AND every `--dry-run`. The reference is cached per seed and the
try cap is 16 rather than 64, and this is the assertion that says so."""
import time
sys.path.insert(0, str(HERE))
import measure_ladder
from grand_exchange_live import live
live._REFERENCE_CACHE.clear()
started = time.monotonic()
markets = measure_ladder.baskets(48)
cold = time.monotonic() - started
self.assertEqual(len(markets), 48)
self.assertLess(cold, 2.0, f"a 48-task load took {cold:.2f}s")
hits = len(live._REFERENCE_CACHE)
started = time.monotonic()
for market in markets:
live.live_reference(market)
self.assertEqual(len(live._REFERENCE_CACHE), hits, "scoring re-simulated the "
"reference the model played against")
self.assertLess(time.monotonic() - started, 0.05)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,106 @@
"""The vendored generator is a COPY, and this is the assertion that keeps it one.
`grand_exchange_live/market.py` and `book.py` are copies of the one-shot environment's, not
imports of it. The reason is that the one-shot scores have to stay comparable across time:
an import would mean any change made for the live form silently moved every historical
`grand-exchange` number, and nothing would say so. The cost of a copy is drift, and drift is
what this file refuses to allow every price and every volume of every item, over a block
of seeds, through both packages, compared exactly.
The freeze draw is the one intentional divergence. It is drawn off `random.Random(seed ^
FREEZE_SALT)` rather than off the generator's own `rng`, so it cannot consume a draw and
cannot perturb a single tick. That claim is not an argument here, it is the test below.
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import context # noqa: E402 - resolves the package before anything imports it
HERE = context.ROOT
# The one-shot environment is a sibling checkout rather than a dependency, so it is always
# `probe.py`'s shim, for `probe.py`'s reason: its `__init__` imports its taskset, which
# imports verifiers, and a self-check that only runs once the training stack is installed is
# a self-check nobody runs. Registering the package name as a namespace pointing at the
# source directory keeps intra-package imports resolving while `__init__` never executes.
context.shim(HERE.parent / "grand_exchange" / "grand_exchange")
import grand_exchange.book as one_shot_book # noqa: E402
import grand_exchange.market as one_shot # noqa: E402
import grand_exchange_live.book as live_book # noqa: E402
import grand_exchange_live.market as copy # noqa: E402
SEEDS = range(60_000, 60_048)
SHAPES = ((5, 56, 30), (5, 56, 60), (3, 24, 12))
class GeneratorCopyTests(unittest.TestCase):
def test_every_stream_is_byte_identical(self) -> None:
for num_items, visible, held_out in SHAPES:
for seed in SEEDS:
a = one_shot.build_market(seed, num_items, visible, held_out)
b = copy.build_market(seed, num_items, visible, held_out)
self.assertEqual(a.seed, b.seed)
self.assertEqual(a.capital, b.capital)
self.assertEqual(a.visible, b.visible)
self.assertEqual(a.held_out, b.held_out)
self.assertEqual([i.name for i in a.items], [i.name for i in b.items])
for x, y in zip(a.items, b.items):
self.assertEqual(x.reverting, y.reverting)
self.assertEqual(x.buy_limit, y.buy_limit)
self.assertEqual(x.prices, y.prices, f"prices diverge on {seed}/{x.name}")
self.assertEqual(x.volumes, y.volumes, f"volumes diverge on {seed}/{x.name}")
def test_the_constants_did_not_drift(self) -> None:
"""A copy that produces the same streams from a different tax rate is still a
broken copy the streams would match and every execution would not."""
for name in ("TAX", "FILL_SHARE", "DUMP_BASE", "DUMP_IMPACT", "DUMP_CAP",
"STARTING_CAPITAL", "WALKS", "SEED_BASE", "PHI", "FUND_DRIFT",
"VOLUME_SD", "DRIFT_STEP", "TIERS"):
self.assertEqual(getattr(one_shot, name), getattr(copy, name), name)
for name in ("MAX_ORDERS", "BUY_BAND", "SELL_BAND", "TARGET_SHARE", "MIN_CROSSINGS",
"MAX_ITEM_SHARE", "VIABILITY_TRIES"):
self.assertEqual(getattr(one_shot_book, name), getattr(live_book, name), name)
def test_the_execution_engine_agrees_order_for_order(self) -> None:
"""The one-shot engine is copied too, and `live.play` is checked against it. If the
copy of `execute` drifted, that check would be comparing the stepped engine to the
wrong fixed point and would still pass."""
for seed in SEEDS:
a = one_shot.build_market(seed, 5, 56, 60)
b = copy.build_market(seed, 5, 56, 60)
plan_a = one_shot_book.reference_orders(a)
plan_b = live_book.reference_orders(b)
self.assertEqual(
[(o.item, o.quantity, o.buy, o.sell) for o in plan_a],
[(o.item, o.quantity, o.buy, o.sell) for o in plan_b],
)
fa = one_shot_book.execute(a, plan_a)
fb = live_book.execute(b, plan_b)
self.assertEqual(fa.realised, fb.realised)
self.assertEqual(fa.committed, fb.committed)
self.assertEqual(fa.bought, fb.bought)
self.assertEqual(fa.sold, fb.sold)
self.assertEqual(fa.dumped, fb.dumped)
def test_the_freeze_is_drawn_without_touching_the_streams(self) -> None:
"""The divergence is bounded to one integer, and that integer moves."""
freezes = {copy.build_market(s, 5, 56, 60).freeze for s in SEEDS}
self.assertEqual(freezes - set(range(copy.FREEZE_RANGE[0], copy.FREEZE_RANGE[1] + 1)),
set())
self.assertGreater(len(freezes), 1, "every basket freezes for the same number of "
"ticks — the cost of a look is a free prior")
# Deterministic in the seed alone: viability and scoring must see the same value,
# which is the whole reason it is not drawn in `Taskset.load()`.
self.assertEqual(copy.build_market(60_000, 5, 56, 60).freeze,
copy.build_market(60_000, 5, 56, 60).freeze)
self.assertEqual(one_shot.build_market(60_000, 5, 56, 60).items[0].prices,
copy.build_market(60_000, 5, 56, 60).items[0].prices)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,234 @@
"""The render/parse boundary, and the leak it exists to close.
`TurnView` carries the live `Market`, so `view.market.items[i].prices` is every graded tick
including the ones the agent is scored on not having seen. A verifier wrote a policy that
reads it and earned 9.4% more than the oracle. House rule 1 is enforced by `protocol.render`
and by nothing else, so it is enforced here:
the sentinel every graded tick of a purpose-built basket is a value no real price or
volume can take, encoding its own index. Every rendered turn is scanned for
them, and any sentinel it carries must be a tick the view had already
observed. A render that reached one tick past `view.seen` fails this.
the prefix what was rendered, reassembled across all eight turns, must be exactly
`prices[:seen]` not a subset of it, not a superset. The sentinel test says
nothing forbidden got out; this one says nothing was quietly mangled on the
way, which is the other way a boundary stops being a boundary.
And the parser, against replies a model actually emits. `json.loads` is not strict JSON: it
takes bare NaN and Infinity and overflows 1e309 to inf, and `int(inf)` raises OverflowError
inside `run()`, which fails the whole episode over a formatting slip.
"""
from __future__ import annotations
import re
import sys
import unittest
from dataclasses import replace
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import context # noqa: E402 - resolves the package before anything imports it
from grand_exchange_live.live import HORIZON, TURNS, Leg, Reference, play # noqa: E402
from grand_exchange_live.market import build_market # noqa: E402
from grand_exchange_live.protocol import ( # noqa: E402
MAX_LEGS,
opening,
parse_sheet,
render,
)
SEEDS = range(60_000, 60_024)
PRICE_SENTINEL = 9_000_000
VOLUME_SENTINEL = 8_000_000
NUMBER = re.compile(r"-?\d+")
def market(seed: int):
return build_market(seed, 5, 56, HORIZON)
def sentinel_market(seed: int):
"""The same basket with every GRADED tick replaced by a value that encodes its index and
that no real stream can produce prices here run to five figures and volumes to three.
The visible warmup is left alone, so the render still has something true to show."""
items = []
for item in market(seed).items:
visible = item.prices[:56]
prices = visible + [PRICE_SENTINEL + t for t in range(56, len(item.prices))]
volumes = (item.volumes[:56]
+ [VOLUME_SENTINEL + t for t in range(56, len(item.volumes))])
items.append(replace(item, prices=prices, volumes=volumes))
return replace(market(seed), items=items)
def walk(basket, policy=None):
"""Play a basket, keeping (view, rendered text) for every look."""
seen = []
def spy(view):
seen.append((view, render(view)))
return policy(view) if policy else []
play(basket, spy)
return seen
class HeldOutLeakTests(unittest.TestCase):
def test_no_rendered_turn_carries_a_tick_the_view_had_not_observed(self) -> None:
found = 0
for seed in SEEDS:
basket = sentinel_market(seed)
for view, text in walk(basket, Reference()):
for token in NUMBER.findall(text):
value = int(token)
if value >= PRICE_SENTINEL:
tick = value - PRICE_SENTINEL
elif value >= VOLUME_SENTINEL:
tick = value - VOLUME_SENTINEL
else:
continue
found += 1
self.assertLess(
tick, view.seen,
f"basket {basket.seed} look {view.turn} rendered tick {tick} "
f"with only {view.seen} observed — the held-out window is on screen",
)
# Not vacuous: the ticks that HAVE executed are graded ticks and are rendered, so a
# basket that produced no sentinel at all would mean the scan found nothing to check.
self.assertGreater(found, 0)
def test_the_rendered_ticks_reassemble_into_exactly_the_observed_prefix(self) -> None:
for seed in SEEDS:
basket = market(seed)
got: dict[str, list[int]] = {i.name: [] for i in basket.items}
last_view = None
for view, text in walk(basket, Reference()):
last_view = view
name = None
for line in text.splitlines():
stripped = line.strip()
if stripped in got:
name = stripped
elif stripped.startswith("price ") and name:
got[name].extend(int(t) for t in stripped.split()[1:])
elif line.startswith(tuple(f"{i.name} (" for i in basket.items)):
name = line.split(" (")[0]
for item in basket.items:
self.assertEqual(got[item.name], item.prices[:last_view.seen],
f"{item.name} in basket {basket.seed}")
def test_the_last_look_never_sees_the_tail(self) -> None:
"""The floor on the held-out slice: even an agent that takes every look is graded on
ticks that ran after its final observation."""
for seed in SEEDS:
views = [v for v, _ in walk(market(seed), Reference())]
self.assertEqual(len(views), TURNS)
self.assertLessEqual(views[-1].seen, 56 + HORIZON - 11)
class OpeningTests(unittest.TestCase):
def test_the_opening_prompt_is_the_engines_own_first_turn(self) -> None:
"""`opening()` reconstructs the turn-1 view at `load()` time, before the engine has
run. If the two ever drift, the model reads a header describing a state the engine
is not in and nothing else in the repository would notice."""
for seed in SEEDS:
basket = market(seed)
first = walk(basket)[0][1]
self.assertEqual(opening(basket, TURNS), first, f"basket {seed}")
def test_the_board_is_on_the_opening_turn_and_not_repeated(self) -> None:
seen = walk(market(60_000), Reference())
self.assertIn("buy limit", seen[0][1])
for _, text in seen[1:]:
self.assertNotIn("buy limit", text)
class ParseTests(unittest.TestCase):
def test_a_well_formed_sheet_parses(self) -> None:
sheet = parse_sheet(
'here you go\n```json\n{"orders": [{"item": "Bogwater draught", '
'"quantity": 400, "buy": 118, "sell": 129}]}\n```'
)
self.assertFalse(sheet.malformed)
self.assertEqual(sheet.legs, [Leg("Bogwater draught", 400, 118, 129)])
def test_an_explicit_hold_is_a_hold_and_not_a_parse_failure(self) -> None:
"""The measured failure of the one-shot form was a well-formed empty answer in 31 of
32 rollouts. It must score what holding scores, and it must not be reported as a
parser bug."""
for reply in ('```json\n{"orders": []}\n```', "```json\n[]\n```",
'```json\n{}\n```',
'```json\n{"expected_profit": 0, "orders": []}\n```'):
sheet = parse_sheet(reply)
self.assertEqual(sheet.legs, [])
self.assertFalse(sheet.malformed, reply)
def test_a_bare_array_is_a_sheet(self) -> None:
sheet = parse_sheet('```json\n[{"item": "x", "quantity": 1, "buy": 2, "sell": 3}]\n```')
self.assertEqual(sheet.legs, [Leg("x", 1, 2, 3)])
self.assertFalse(sheet.malformed)
def test_the_last_block_wins(self) -> None:
sheet = parse_sheet('```json\n{"orders": [{"item": "a", "quantity": 1, "buy": 1, '
'"sell": 2}]}\n```\non reflection\n```json\n{"orders": []}\n```')
self.assertEqual(sheet.legs, [])
def test_hostile_replies_never_raise_and_never_invent_a_leg(self) -> None:
for hostile in (
"", "no trades today", "```json\n{not json}\n```",
'```json\n{"orders": {"item": "x"}}\n```',
'```json\n{"orders": [{"item": "x", "quantity": NaN, "buy": 1, "sell": 2}]}\n```',
'```json\n{"orders": [{"item": "x", "quantity": Infinity, "buy": 1, "sell": 2}]}\n```',
'```json\n{"orders": [{"item": "x", "quantity": 1e309, "buy": 1, "sell": 2}]}\n```',
'```json\n{"orders": [{"item": "x", "quantity": 1e400, "buy": 1, "sell": 2}]}\n```',
'```json\n{"orders": [{"item": "x", "quantity": 1, "buy": -Infinity, "sell": 2}]}\n```',
'```json\n{"orders": [{"item": 7, "quantity": 1, "buy": 1, "sell": 2}]}\n```',
'```json\n{"orders": [null, 3, "x"]}\n```',
"```json\n" + "[" * 20_000 + "]" * 20_000 + "\n```",
):
sheet = parse_sheet(hostile)
self.assertEqual(sheet.legs, [], hostile[:40])
def test_a_malformed_reply_is_a_hold_and_is_flagged_as_one(self) -> None:
for hostile in ("no trades today", "```json\n{not json}\n```",
'```json\n{"orders": {"item": "x"}}\n```'):
self.assertTrue(parse_sheet(hostile).malformed, hostile[:40])
def test_a_sheet_is_capped(self) -> None:
rows = ",".join('{"item": "x%d", "quantity": 1, "buy": 1, "sell": 2}' % i
for i in range(MAX_LEGS + 5))
sheet = parse_sheet('```json\n{"orders": [%s]}\n```' % rows)
self.assertEqual(len(sheet.legs), MAX_LEGS)
self.assertEqual(sheet.dropped_rows, 5)
def test_hostile_replies_played_through_the_engine_never_break_it(self) -> None:
"""The parser guarantees nothing on its own — the legs it returns go into the book.
A leg naming nothing, a cancel of an offer that is not there and a price of zero are
all reachable from a reply, and each must be dropped rather than fatal."""
replies = [
"", "no trades today",
'```json\n{"orders": [{"item": "nothing here", "quantity": 5, "buy": 1, "sell": 2}]}\n```',
'```json\n{"orders": [{"item": "Bogwater draught", "quantity": 0, "buy": 0, "sell": 0}]}\n```',
'```json\n{"orders": [{"item": "Bogwater draught", "quantity": -5, "buy": 1, "sell": 2}]}\n```',
'```json\n{"orders": [{"item": "Bogwater draught", "quantity": 999999999, "buy": 1, "sell": 2}]}\n```',
'```json\n{"orders": []}\n```',
'```json\n{"orders": [{"item": "Bogwater draught", "quantity": 5, "buy": 1000000000, "sell": 1}]}\n```',
"```json\n{not json}\n```",
]
for seed in SEEDS:
turn = {"i": 0}
def policy(view, turn=turn):
sheet = parse_sheet(replies[turn["i"] % len(replies)])
turn["i"] += 1
return sheet.legs
result = play(market(seed), policy)
self.assertGreaterEqual(result.realised, -float(market(seed).capital))
self.assertGreaterEqual(result.peak_employed, 0.0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,224 @@
"""The taskset and the env: the loop, the seat, and what reaches the trace.
Everything else in this directory runs with nothing installed. This file needs `verifiers`,
so it skips when it is absent `uv sync --project environments/grand_exchange_live` first,
exactly as the README's release gate does.
The env is driven against a stub agent rather than a model. What is being tested is the
control flow that only exists here: a synchronous engine walked through an asynchronous
interaction, a prompted task whose first turn must be BARE, a `terminated` segment arriving
mid-window, and the rule that `TurnView.market` never reaches the trace.
"""
from __future__ import annotations
import asyncio
import sys
import types
import unittest
from dataclasses import dataclass, field
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import context # noqa: E402 - resolves the package before anything imports it
try:
import verifiers.v1 as vf # noqa: F401
except ImportError: # pragma: no cover - the no-dependency path
vf = None
from grand_exchange_live.live import HORIZON, TURNS # noqa: E402
from grand_exchange_live.market import build_market # noqa: E402
from grand_exchange_live.protocol import opening # noqa: E402
PRICE_SENTINEL = 9_000_000
@dataclass
class StubTrace:
rewards: dict = field(default_factory=dict)
metrics: dict = field(default_factory=dict)
info: dict = field(default_factory=dict)
def record_reward(self, name, value, weight=1.0):
self.rewards[name] = (float(value), float(weight))
def record_metrics(self, values):
self.metrics.update({k: float(v) for k, v in values.items()})
@dataclass
class StubSegment:
last_reply: str = ""
terminated: bool = False
class StubInteraction:
"""One rollout, scripted. Records what each turn was asked with."""
def __init__(self, replies, terminate_at=None):
self.replies = replies
self.terminate_at = terminate_at
self.asked: list = []
self.trace = StubTrace()
async def turn(self, message=None):
self.asked.append(message)
n = len(self.asked)
if self.terminate_at is not None and n >= self.terminate_at:
return StubSegment(terminated=True)
return StubSegment(last_reply=self.replies[(n - 1) % len(self.replies)])
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class StubAgent:
def __init__(self, interaction):
self._interaction = interaction
def interaction(self, task):
return self._interaction
class StubAgents:
def __init__(self, interaction):
self.agent = StubAgent(interaction)
HOLD = '```json\n{"orders": []}\n```'
@unittest.skipIf(vf is None, "verifiers is not installed")
class TasksetTests(unittest.TestCase):
def taskset(self, num_tasks=4):
from grand_exchange_live.taskset import LiveExchangeConfig, LiveExchangeTaskset
return LiveExchangeTaskset(
LiveExchangeConfig(id="grand-exchange-live", num_tasks=num_tasks)
)
def test_the_seat_is_named_agent(self) -> None:
"""A wrong name parses fine and silently leaves the seat empty, so it is asserted
rather than read off the file."""
from grand_exchange_live.taskset import LiveExchangeEnvConfig
self.assertIn("agent", LiveExchangeEnvConfig.model_fields)
def test_tasks_carry_the_seed_and_the_opening_board(self) -> None:
tasks = self.taskset().load()
self.assertEqual(len(tasks), 4)
for task in tasks:
market = build_market(task.data.seed, task.data.num_items, task.data.visible,
HORIZON)
self.assertEqual(task.data.prompt, opening(market, TURNS))
self.assertEqual(task.data.turns, TURNS)
self.assertIn("Grand Exchange", task.data.system_prompt)
def test_two_tasks_never_share_a_basket(self) -> None:
"""`viable_live_market` skips baskets the reference loses money in; a load that did
not advance past the seed it landed on would serve the same basket twice."""
seeds = [t.data.seed for t in self.taskset(24).load()]
self.assertEqual(len(set(seeds)), len(seeds))
def test_the_task_stops_at_the_turn_budget(self) -> None:
task = self.taskset(1).load()[0]
trace = types.SimpleNamespace(num_turns=TURNS - 1)
self.assertFalse(asyncio.run(task.budget(trace)))
trace.num_turns = TURNS
self.assertTrue(asyncio.run(task.budget(trace)))
@unittest.skipIf(vf is None, "verifiers is not installed")
class EnvRunTests(unittest.TestCase):
def env_and_task(self):
from grand_exchange_live.taskset import (
LiveExchangeConfig,
LiveExchangeEnv,
LiveExchangeEnvConfig,
LiveExchangeTaskset,
)
config = LiveExchangeEnvConfig(
taskset=LiveExchangeConfig(id="grand-exchange-live", num_tasks=1)
)
# No harness, no model, no runtime: `run()` is the whole subject here, and building
# the env through its constructor would stand up an agent to reach it.
env = LiveExchangeEnv.__new__(LiveExchangeEnv)
env.config = config
return env, LiveExchangeTaskset(config.taskset).load()[0]
def run_env(self, replies, terminate_at=None, task=None, env=None):
from grand_exchange_live.taskset import LiveExchangeEnv
if env is None:
env, task = self.env_and_task()
interaction = StubInteraction(replies, terminate_at)
asyncio.run(LiveExchangeEnv.run(env, task, StubAgents(interaction)))
return interaction
def test_the_first_turn_is_bare_and_every_later_turn_carries_the_view(self) -> None:
"""The task is prompted, so the model speaks first. Passing the opening text on turn
one raises ValueError inside `verifiers` the prompt already opened the exchange."""
interaction = self.run_env([HOLD])
self.assertEqual(len(interaction.asked), TURNS)
self.assertIsNone(interaction.asked[0])
for message in interaction.asked[1:]:
self.assertIsInstance(message, str)
self.assertIn("Look ", message)
def test_a_terminated_segment_mid_window_is_not_an_error(self) -> None:
"""The naive loop calls turn() again and gets RuntimeError('the exchange is over'),
which fails the episode over a model that stopped talking. The window runs on."""
interaction = self.run_env([HOLD], terminate_at=4)
self.assertEqual(interaction.trace.metrics["ended_early"], 1.0)
self.assertEqual(interaction.trace.metrics["answered_turns"], 3.0)
self.assertEqual(set(interaction.trace.rewards), {"profit", "discipline", "gate"})
def test_a_hold_every_turn_scores_exactly_zero(self) -> None:
"""House rule 3's floor, through the whole stack rather than through the engine —
including the degenerate reply that 31 of 32 one-shot rollouts produced."""
interaction = self.run_env([HOLD])
self.assertEqual(interaction.trace.metrics["held_replies"], float(TURNS))
self.assertEqual(interaction.trace.metrics["malformed_replies"], 0.0)
for name, (score, _) in interaction.trace.rewards.items():
self.assertEqual(score, 0.0, name)
def test_a_malformed_reply_is_a_hold_and_never_fails_the_episode(self) -> None:
interaction = self.run_env(["I would rather not."])
self.assertEqual(interaction.trace.metrics["malformed_replies"], float(TURNS))
self.assertEqual(interaction.trace.rewards["profit"][0], 0.0)
def test_a_real_sheet_trades(self) -> None:
import json
from grand_exchange_live.book import reference_orders
env, task = self.env_and_task()
market = build_market(task.data.seed, task.data.num_items, task.data.visible,
HORIZON)
sheet = "```json\n" + json.dumps({"orders": [
{"item": o.item, "quantity": o.quantity, "buy": o.buy, "sell": o.sell}
for o in reference_orders(market)
]}) + "\n```"
interaction = self.run_env([sheet, HOLD], env=env, task=task)
self.assertGreater(interaction.trace.metrics["bought"], 0.0)
self.assertGreater(interaction.trace.rewards["profit"][0], 0.0)
def test_the_trace_carries_no_held_out_tick_and_no_market(self) -> None:
"""House rule 1's other half. The observation is `protocol.render`'s problem; this is
the one that would put the whole series in the training data through `trace.info`."""
interaction = self.run_env([HOLD])
for key, value in interaction.trace.info.items():
self.assertIsInstance(value, (int, float, str), key)
for key, value in interaction.trace.metrics.items():
self.assertIsInstance(value, float, key)
self.assertLess(abs(value), PRICE_SENTINEL, key)
self.assertEqual(set(interaction.trace.info), {"basket", "freeze"})
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
# redaction-pressure v0.2
A one-turn Verifiers v1 environment. The model returns regex rules; the scanner runs
them against later records from the same deterministic corpus stream.
## Reward contract
- **Recall (0.35):** a labelled secret counts only when every original character in
its span is gone. Partial edits earn zero span recall.
- **Precision (0.35):** source characters belonging to fully removed secret spans,
divided by all removed source characters. Partial damage stays in the denominator.
- **Clean gate (0.30):** every secret fully removed, every non-secret character and
decoy preserved, and no validation or timeout errors.
- **Validity:** malformed JSON, bad entries, more than 24 rules, unsafe replacements,
invalid regexes, excessive matches, timeouts, or an exhausted episode budget multiply
every reward by zero.
Replacement is intentionally limited to `"[REDACTED]"` or `""`. That prevents a rule
from matching a complete secret and then leaking it through a capture expansion.
## Splits
`train` and `dev` have separate, deterministic public seed ranges. `private_eval` derives
seeds with HMAC from `REDACTION_PRESSURE_PRIVATE_SEED`, which must contain at least 32
characters. The secret is never task data; traces carry its SHA-256 commitment and reveal
the derived task seed only in the completed trace so the episode can be reproduced by an
authorized evaluator.
Never present public `dev` scores as private evaluation.
## Trace identity
Each task records the environment, corpus, and reward-schema versions, split commitment,
and a SHA-256 digest of the behavior-bearing installed Python sources. Scoring repeats
the identity under `trace.info.environment` and records validation failures under
`trace.info.redaction_errors`.
## Checks
From the Arena repository root:
```bash
uv sync --project environments/redaction_pressure
uv run --project environments/redaction_pressure \
python -m unittest discover -s environments/redaction_pressure/tests -v
uv run --with regex python probe.py
```
Arena's environments are independently published libraries, so their generated
`uv.lock` files are intentionally not committed. The runtime contract above is pinned in
this environment's `pyproject.toml` and exercised on Python 3.11 and 3.12 in CI.
The committed tests demonstrate oracle 1.0 and inaction 0.0, reject the historical
first-character exploit across all 64 public development tasks, exercise parser and
rule validation, prove deterministic/private split behavior, and interrupt hostile
regexes under the shared episode budget.
@@ -1,12 +1,16 @@
[project]
name = "redaction-pressure"
version = "0.1.0"
description = "redaction-pressure — write redaction rules graded on records the author never saw."
version = "0.2.0"
description = "Provenance-safe redaction rules graded on records the author never saw."
requires-python = ">=3.11"
dependencies = ["verifiers", "regex"]
dependencies = [
"pydantic==2.13.4",
"regex==2026.7.19",
"verifiers==0.3.1",
]
[build-system]
requires = ["hatchling"]
requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
@@ -1,3 +1,4 @@
from redaction_pressure.provenance import ENVIRONMENT_VERSION
from redaction_pressure.taskset import RedactionTaskset
__all__ = ["RedactionTaskset"]
__all__ = ["ENVIRONMENT_VERSION", "RedactionTaskset"]
@@ -0,0 +1,37 @@
"""Stable environment identity recorded with every task and trace."""
from __future__ import annotations
import hashlib
from functools import lru_cache
from pathlib import Path
ENVIRONMENT_VERSION = "0.2.0"
CORPUS_VERSION = "redaction-pressure-corpus-v2"
REWARD_SCHEMA_VERSION = "redaction-pressure-reward-v2"
SOURCE_FILES = ("corpus.py", "provenance.py", "scan.py", "taskset.py")
@lru_cache(maxsize=1)
def environment_source_sha256() -> str:
"""Hash the behavior-bearing installed sources, independent of checkout paths."""
root = Path(__file__).resolve().parent
digest = hashlib.sha256()
for name in SOURCE_FILES:
payload = (root / name).read_bytes()
digest.update(name.encode("utf-8"))
digest.update(b"\0")
digest.update(len(payload).to_bytes(8, "big"))
digest.update(payload)
return digest.hexdigest()
def trace_provenance(*, split: str, seed_commitment: str) -> dict[str, str]:
return {
"environment_version": ENVIRONMENT_VERSION,
"corpus_version": CORPUS_VERSION,
"reward_schema_version": REWARD_SCHEMA_VERSION,
"source_sha256": environment_source_sha256(),
"split": split,
"seed_commitment": seed_commitment,
}
@@ -1,37 +1,35 @@
"""Applying the agent's rules, and measuring what they did.
"""Apply hostile redaction rules while preserving source-character provenance.
Everything is counted as *removals*, and that choice is the whole reward.
The scorer never infers redaction from an output substring disappearing. Every
character starts with its source offset, replacements carry no source offsets, and a
secret counts as removed only when *all* offsets in its labelled span are gone. This
makes deleting or changing one byte of a secret worth zero recall. The same provenance
requires every ordinary and decoy character to survive for the clean gate.
The obvious framing secrets caught, decoys survived, prose kept pays a model that
returns no rules at all: it destroys nothing, so it keeps everything. Measured that way
an empty ruleset and a ruleset that shreds the corpus scored identically, and neither
told a trainer anything. Counting removals fixes it, because a model that removes nothing
has no true positives and therefore no precision.
removed_secrets true positives a seeded secret gone from the held-out text.
residual_hits the ones still there. Forge's rule applies to the gate: a residual
hit is exit 1, not a deduction.
collateral_hits decoys destroyed. Each is shape-matched to a secret class, so this is
what a rule keyed on shape pays and one keyed on meaning does not.
innocent_lost ordinary words destroyed. The floor that stops `\\S+ -> [X]`, which
otherwise has perfect recall.
The agent supplies the patterns, so they are hostile input: `regex` is used rather than
`re` for its `timeout=`, since `re` cannot be interrupted and one nested quantifier would
otherwise hang a rollout until the harness killed it.
Agent regexes run under one wall-clock budget shared by compilation and every record.
Any invalid, excessive, unsafe, over-broad, or timed-out rule invalidates the complete
ruleset; valid rules are still measured so the trace explains what happened, but reward
methods apply ``validity`` and therefore pay zero for an invalid submission.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import re
import time
from dataclasses import dataclass, field
from typing import Callable, Iterable
import regex
from redaction_pressure.corpus import Record
from redaction_pressure.corpus import Record, Span
MAX_RULES = 24
MAX_PATTERN_CHARS = 200
PATTERN_TIMEOUT_S = 0.25
MAX_MATCHES_PER_RULE_RECORD = 128
PER_OPERATION_TIMEOUT_S = 0.025
EPISODE_REGEX_BUDGET_S = 0.75
ALLOWED_REPLACEMENTS = frozenset({"", "[REDACTED]"})
@dataclass(frozen=True)
@@ -40,97 +38,358 @@ class Rule:
replacement: str
@dataclass(frozen=True)
class ParsedRules:
rules: tuple[Rule, ...]
errors: tuple[str, ...] = ()
submitted: int = 0
@dataclass(frozen=True)
class LocatedSpan:
kind: str
text: str
start: int
end: int
@property
def offsets(self) -> range:
return range(self.start, self.end)
@dataclass(frozen=True)
class TrackedText:
text: str
origins: tuple[int | None, ...]
@classmethod
def original(cls, text: str) -> "TrackedText":
return cls(text=text, origins=tuple(range(len(text))))
@dataclass(frozen=True)
class CompiledRule:
index: int
pattern: regex.Pattern
replacement: str
@dataclass
class RegexBudget:
seconds: float = EPISODE_REGEX_BUDGET_S
clock: Callable[[], float] = time.monotonic
started: float = field(init=False)
def __post_init__(self) -> None:
self.started = self.clock()
@property
def elapsed(self) -> float:
return max(0.0, self.clock() - self.started)
@property
def remaining(self) -> float:
return max(0.0, self.seconds - self.elapsed)
def operation_timeout(self) -> float:
remaining = self.remaining
if remaining <= 0:
raise RegexBudgetExceeded("episode regex budget exhausted")
return min(PER_OPERATION_TIMEOUT_S, remaining)
class RegexBudgetExceeded(RuntimeError):
pass
@dataclass(frozen=True)
class ApplyResult:
tracked: TrackedText
errors: tuple[str, ...]
@dataclass
class Outcome:
"""What one ruleset did to one slice."""
"""Measurements for one submitted ruleset over one held-out slice."""
removed_secrets: int
partial_secrets: int
residual_hits: int
secrets_total: int
credited_secret_chars: int
removed_secret_chars: int
secret_chars_total: int
collateral_hits: int
decoys_total: int
innocent_lost: int
innocent_total: int
invalid_rules: list[str]
@property
def removed_secrets(self) -> int:
return self.secrets_total - self.residual_hits
budget_seconds: float
budget_elapsed_seconds: float
@property
def recall(self) -> float:
"""All-or-nothing span recall; partial secret edits earn no recall."""
return self.removed_secrets / max(self.secrets_total, 1)
@property
def precision(self) -> float:
"""Of everything this ruleset destroyed, how much of it should have been.
def character_coverage(self) -> float:
"""Diagnostic only: never used as reward because partial deletion is unsafe."""
return self.removed_secret_chars / max(self.secret_chars_total, 1)
Zero for an empty ruleset it destroyed nothing, so none of what it destroyed
was a secret. That is the case the survivor-counting version got wrong.
@property
def precision(self) -> float:
"""Of removed source characters, the share in fully removed secret spans.
Characters removed from a merely damaged secret stay in the denominator but are
not true positives. This keeps the historical one-byte exploit at precision zero.
"""
destroyed = self.removed_secrets + self.collateral_hits + self.innocent_lost
return self.removed_secrets / destroyed if destroyed else 0.0
destroyed = self.removed_secret_chars + self.innocent_lost
return self.credited_secret_chars / destroyed if destroyed else 0.0
@property
def ordinary_preservation(self) -> float:
return 1.0 - self.innocent_lost / max(self.innocent_total, 1)
@property
def validity(self) -> float:
"""Binary by design: invalid hostile input is never a partially paid policy."""
return float(not self.invalid_rules)
@property
def clean(self) -> bool:
"""The gate: every secret gone AND no decoy taken with it — the ruleset you could
actually ship. `forge redact`'s exit 1, with the collateral clause it implies."""
"""Every secret gone, nothing else touched. Six clauses, all exact, deliberately.
This gate scored 0.000 mean and 0.000 max over the 32 rollouts in
`outputs/run-20260821-1401`, and unlike `bot-detection`'s and `schema-migration`'s
it was swept and **left alone**. The sweep is here so it is not redone:
gate fires on 32 real rollouts `plausible`
exact (shipped) 0/32 = 0.000 0.000
recall margin 0.10, collateral 0, innocent 0 0/32 = 0.000 0.000
recall margin 0.20, collateral 1, innocent 32 0/32 = 0.000 0.000
recall margin 0.50, collateral 2, innocent 160 3/32 = 0.094 0.833
Every margin loose enough to fire on the measured population pays `plausible` a
ruleset carrying four of the seven rules, which is to say one that never found three
of the secret types on five seeds in six. Every margin tight enough to keep
`plausible` at zero fires on none of the 32. There is no setting that does both,
because the failure is joint and not a threshold: recall maxed at 0.852 with a
minimum of four secrets left standing, `collateral_hits == 0` held on 6 of 32, and
no rollout managed high recall and zero collateral at the same time.
So this one is (c) genuinely hard, not (a) mis-thresholded, and a margin here would
buy nothing measurable in exchange for redefining what "redacted" means in a
redaction environment. Half the secrets left in the document is not a near miss.
`probe.py` reports the component as a step at the oracle and names it; that is the
honest state, and the fix when it comes is a harder model or an easier corpus, not
a looser gate. Measured by `tools/regate.py` and `tools/regate_ladder.py`.
"""
return (
self.residual_hits == 0
self.removed_secrets == self.secrets_total
and self.partial_secrets == 0
and self.collateral_hits == 0
and self.innocent_lost == 0
and self.secrets_total > 0
and self.validity == 1.0
)
def compile_rules(rules: list[Rule]) -> tuple[list[tuple[regex.Pattern, str]], list[str]]:
"""Compile what compiles; report the rest rather than failing the rollout.
A rollout that dies on one bad pattern teaches nothing the agent's score should
reflect that the rule did no work, which is what dropping it does.
"""
compiled, invalid = [], []
for rule in rules[:MAX_RULES]:
def compile_rules(
parsed: ParsedRules,
budget: RegexBudget,
) -> tuple[list[CompiledRule], list[str]]:
"""Compile bounded rules and retain every validation error."""
errors = list(parsed.errors)
rules = list(parsed.rules)
if len(rules) > MAX_RULES:
errors.append(f"too many rules: received {len(rules)}, maximum is {MAX_RULES}")
compiled: list[CompiledRule] = []
for index, rule in enumerate(rules[:MAX_RULES]):
label = f"rule[{index}]"
if not rule.pattern:
errors.append(f"{label}: pattern must not be empty")
continue
if len(rule.pattern) > MAX_PATTERN_CHARS:
invalid.append(f"{rule.pattern[:40]}: over {MAX_PATTERN_CHARS} characters")
errors.append(f"{label}: pattern exceeds {MAX_PATTERN_CHARS} characters")
continue
if rule.replacement not in ALLOWED_REPLACEMENTS:
errors.append(
f"{label}: replacement must be empty or [REDACTED]; captures and arbitrary text are unsafe"
)
continue
try:
compiled.append((regex.compile(rule.pattern), rule.replacement))
budget.operation_timeout()
pattern = regex.compile(rule.pattern)
budget.operation_timeout()
except RegexBudgetExceeded as exc:
errors.append(f"{label}: {exc}")
break
except regex.error as exc:
invalid.append(f"{rule.pattern[:40]}: {exc}")
return compiled, invalid
def apply_rules(body: str, compiled: list[tuple[regex.Pattern, str]]) -> str:
"""Rules in the order given, each over the whole text. A pattern that times out is
skipped for that record rather than aborting the measurement."""
text = body
for pattern, replacement in compiled:
try:
text = pattern.sub(replacement, text, timeout=PATTERN_TIMEOUT_S)
except (TimeoutError, regex.error):
errors.append(f"{label}: invalid regex: {exc}")
continue
return text
compiled.append(CompiledRule(index=index, pattern=pattern, replacement=rule.replacement))
return compiled, errors
def _innocent_text(record: Record) -> str:
"""The record with every secret and decoy cut out — the part no rule should touch."""
text = record.body
for span in [*record.secrets, *record.decoys]:
text = text.replace(span.text, " ")
return text
def apply_rules(
body: str,
compiled: Iterable[CompiledRule],
budget: RegexBudget,
) -> ApplyResult:
"""Apply rules atomically per pattern and carry source offsets through unchanged text."""
tracked = TrackedText.original(body)
errors: list[str] = []
for rule in compiled:
label = f"rule[{rule.index}]"
try:
timeout = budget.operation_timeout()
matches = list(rule.pattern.finditer(tracked.text, timeout=timeout))
budget.operation_timeout()
except (TimeoutError, regex.error):
errors.append(f"{label}: regex timed out")
continue
except RegexBudgetExceeded as exc:
errors.append(f"{label}: {exc}")
break
if len(matches) > MAX_MATCHES_PER_RULE_RECORD:
errors.append(
f"{label}: produced {len(matches)} matches in one record; maximum is "
f"{MAX_MATCHES_PER_RULE_RECORD}"
)
continue
text_parts: list[str] = []
origins: list[int | None] = []
cursor = 0
for match in matches:
start, end = match.span()
text_parts.append(tracked.text[cursor:start])
origins.extend(tracked.origins[cursor:start])
text_parts.append(rule.replacement)
origins.extend([None] * len(rule.replacement))
cursor = end
text_parts.append(tracked.text[cursor:])
origins.extend(tracked.origins[cursor:])
tracked = TrackedText(text="".join(text_parts), origins=tuple(origins))
return ApplyResult(tracked=tracked, errors=tuple(errors))
def measure(records: list[Record], rules: list[Rule]) -> Outcome:
compiled, invalid = compile_rules(rules)
residual = collateral = secrets = decoys = lost = total = 0
def _locate(body: str, span: Span) -> LocatedSpan:
start = body.find(span.text)
if start < 0 or body.find(span.text, start + 1) >= 0:
raise ValueError(f"ground-truth span {span.kind!r} must occur exactly once")
return LocatedSpan(span.kind, span.text, start, start + len(span.text))
def _removed_count(span: LocatedSpan, retained: set[int]) -> int:
return sum(1 for offset in span.offsets if offset not in retained)
def measure(
records: list[Record],
parsed: ParsedRules | list[Rule],
*,
budget_seconds: float = EPISODE_REGEX_BUDGET_S,
clock: Callable[[], float] = time.monotonic,
) -> Outcome:
"""Measure one ruleset under one episode-wide hostile-regex budget."""
if isinstance(parsed, list):
parsed = ParsedRules(tuple(parsed), submitted=len(parsed))
budget = RegexBudget(seconds=budget_seconds, clock=clock)
compiled, errors = compile_rules(parsed, budget)
removed_secrets = partial_secrets = residual = 0
secrets_total = credited_secret_chars = removed_secret_chars = secret_chars_total = 0
collateral = decoys_total = innocent_lost = innocent_total = 0
for record in records:
redacted = apply_rules(record.body, compiled)
secrets += len(record.secrets)
decoys += len(record.decoys)
residual += sum(1 for s in record.secrets if s.text in redacted)
collateral += sum(1 for d in record.decoys if d.text not in redacted)
# Fidelity is counted over whole words rather than characters: a rule that eats
# every space would otherwise keep most of its characters and look harmless.
innocent = [w for w in _innocent_text(record).split() if w]
total += len(innocent)
lost += sum(1 for w in innocent if w not in redacted)
return Outcome(residual, secrets, collateral, decoys, lost, total, invalid)
result = apply_rules(record.body, compiled, budget)
errors.extend(result.errors)
retained = {offset for offset in result.tracked.origins if offset is not None}
secrets = [_locate(record.body, span) for span in record.secrets]
decoys = [_locate(record.body, span) for span in record.decoys]
secret_offsets = {offset for span in secrets for offset in span.offsets}
decoy_offsets = {offset for span in decoys for offset in span.offsets}
for span in secrets:
removed = _removed_count(span, retained)
removed_secret_chars += removed
secret_chars_total += span.end - span.start
secrets_total += 1
if removed == span.end - span.start:
removed_secrets += 1
credited_secret_chars += removed
elif removed > 0:
partial_secrets += 1
residual += 1
else:
residual += 1
for span in decoys:
decoys_total += 1
if _removed_count(span, retained) > 0:
collateral += 1
# Every non-secret source character is protected. Decoys are included here as well
# as in their span-level gate, so one altered byte is both visible and costly.
protected = set(range(len(record.body))) - secret_offsets
innocent_total += len(protected)
innocent_lost += len(protected - retained)
# Keep this assertion close to the accounting: every decoy must be protected.
if not decoy_offsets <= protected:
raise AssertionError("decoy offsets must be non-secret protected content")
unique_errors = list(dict.fromkeys(errors))
return Outcome(
removed_secrets=removed_secrets,
partial_secrets=partial_secrets,
residual_hits=residual,
secrets_total=secrets_total,
credited_secret_chars=credited_secret_chars,
removed_secret_chars=removed_secret_chars,
secret_chars_total=secret_chars_total,
collateral_hits=collateral,
decoys_total=decoys_total,
innocent_lost=innocent_lost,
innocent_total=innocent_total,
invalid_rules=unique_errors,
budget_seconds=budget.seconds,
budget_elapsed_seconds=budget.elapsed,
)
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
def parse_rules(reply: str) -> ParsedRules:
"""Parse the last fenced JSON block and retain every structural error.
This parser deliberately lives in the scanner rather than the Verifiers taskset so
the repository-level four-environment probe stays lightweight.
"""
candidates = _BLOCK.findall(reply or "")
raw = candidates[-1] if candidates else (reply or "")
try:
payload = json.loads(raw.strip())
except json.JSONDecodeError as exc:
return ParsedRules((), (f"response is not valid JSON: {exc.msg}",), 0)
if not isinstance(payload, list):
return ParsedRules((), ("response JSON must be an array",), 1)
rules: list[Rule] = []
errors: list[str] = []
for index, item in enumerate(payload):
if not isinstance(item, dict):
errors.append(f"item[{index}] must be an object")
continue
pattern = item.get("pattern")
replacement = item.get("replacement", "")
if not isinstance(pattern, str):
errors.append(f"item[{index}].pattern must be a string")
continue
if not isinstance(replacement, str):
errors.append(f"item[{index}].replacement must be a string")
continue
rules.append(Rule(pattern=pattern, replacement=replacement))
return ParsedRules(tuple(rules), tuple(errors), len(payload))
@@ -1,79 +1,90 @@
"""redaction-pressure: write redaction rules that survive records you never read.
The agent reads a handful of support tickets seeded with secrets, and returns a ruleset.
The ruleset is then run against the *next* records the same generator would have produced.
That gap is the environment: rules fitted to the visible examples score well on the
visible examples and are worth nothing, and only the held-out slice can tell the two apart.
Three rewards, all counted over what the ruleset *destroyed*:
recall seeded secrets removed from the held-out text.
precision of everything destroyed, the share that was a secret. An empty ruleset
destroys nothing and therefore scores zero here which is the point. A
first pass scored survivors instead, and an empty ruleset tied the best
real attempt at 0.500 because it had kept every decoy and every word.
gate binary: every secret gone and no decoy taken with it. `forge redact`'s
exit 1, which is a verdict and not a deduction.
No judge model and no runtime: the reward is a scanner, so a rollout costs one completion
and grades in milliseconds. Deterministic from the task seed.
"""
"""Verifiers v1 taskset for provenance-safe redaction rules."""
from __future__ import annotations
import json
import re
from typing import ClassVar
import hashlib
import hmac
import os
from typing import Literal
from pydantic import Field
from pydantic import Field, model_validator
import verifiers.v1 as vf
from redaction_pressure.corpus import build_slices
from redaction_pressure.scan import MAX_RULES, Rule, measure
from redaction_pressure.provenance import (
CORPUS_VERSION,
ENVIRONMENT_VERSION,
REWARD_SCHEMA_VERSION,
environment_source_sha256,
trace_provenance,
)
from redaction_pressure.scan import MAX_RULES, measure, parse_rules
Split = Literal["train", "dev", "private_eval"]
PUBLIC_SPLIT_BASES = {"train": 100_000, "dev": 200_000}
PRIVATE_SEED_ENV_DEFAULT = "REDACTION_PRESSURE_PRIVATE_SEED"
MAX_TASKS_PER_SPLIT = 10_000
SYSTEM = f"""You are writing redaction rules for a support-ticket corpus.
Return a JSON array of at most {MAX_RULES} rules in one ```json code block. Each rule is
an object with "pattern" (a Python regular expression) and "replacement" (the text that
replaces every match).
an object with "pattern" (a Python regex) and "replacement". For safety, replacement
must be exactly "[REDACTED]" or the empty string; capture expansion and arbitrary
replacement text invalidate the complete ruleset.
Your rules will NOT be graded on the tickets below. They will be run against different
tickets from the same source, containing the same kinds of secret and the same kinds of
lookalike. Removing every secret matters most; destroying text that only resembles a
secret, or shredding ordinary prose, costs you.
Your rules are graded on different tickets from the same source. A secret earns credit
only when every source character in its labelled span is removed. Changing one byte is
still a residual secret. Every non-secret source character, including public lookalikes,
must survive for the clean gate. Invalid, excessive, over-broad, or timed-out rules make
the rollout invalid and receive zero reward.
Example shape:
Example:
```json
[{{"pattern": "\\\\bfoo-\\\\d+\\\\b", "replacement": "[REF]"}}]
[{{"pattern": "\\\\bfoo-\\\\d+\\\\b", "replacement": "[REDACTED]"}}]
```"""
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
def _private_key(env_name: str) -> bytes:
value = os.environ.get(env_name, "")
if len(value) < 32:
raise ValueError(
f"private_eval requires at least 32 characters in environment variable {env_name}"
)
return value.encode("utf-8")
def parse_rules(reply: str) -> list[Rule]:
"""The last JSON array in the reply. A reply with no parsable ruleset is an empty
ruleset, not an error it scores what doing nothing scores."""
candidates = _BLOCK.findall(reply or "")
raw = candidates[-1] if candidates else (reply or "")
try:
parsed = json.loads(raw.strip())
except json.JSONDecodeError:
return []
if not isinstance(parsed, list):
return []
rules = []
for item in parsed:
if isinstance(item, dict) and isinstance(item.get("pattern"), str):
rules.append(Rule(item["pattern"], str(item.get("replacement", ""))))
return rules
def seed_for(split: Split, index: int, private_seed_env: str = PRIVATE_SEED_ENV_DEFAULT) -> int:
if split in PUBLIC_SPLIT_BASES:
return PUBLIC_SPLIT_BASES[split] + index
key = _private_key(private_seed_env)
digest = hmac.new(
key,
f"redaction-pressure/v2/private_eval/{index}".encode("utf-8"),
hashlib.sha256,
).digest()
return int.from_bytes(digest[:8], "big") & ((1 << 63) - 1)
def seed_commitment(split: Split, private_seed_env: str = PRIVATE_SEED_ENV_DEFAULT) -> str:
if split in PUBLIC_SPLIT_BASES:
material = f"redaction-pressure/v2/{split}/{PUBLIC_SPLIT_BASES[split]}".encode()
else:
material = _private_key(private_seed_env)
return hashlib.sha256(material).hexdigest()
class RedactionData(vf.TaskData):
seed: int
"""Rebuilds both slices exactly; the held-out records are never serialized here."""
"""Rebuilds both slices exactly; held-out records are generated only for scoring."""
visible: int
held_out: int
split: Split
seed_commitment: str
environment_version: str
corpus_version: str
reward_schema_version: str
source_sha256: str
class RedactionTask(vf.Task[RedactionData]):
@@ -83,59 +94,81 @@ class RedactionTask(vf.Task[RedactionData]):
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
"""Run the ruleset over the held-out slice once; every reward reads this."""
"""Rebuild the held-out slice once and expose raw diagnostics in the trace."""
_, held_out = build_slices(self.data.seed, self.data.visible, self.data.held_out)
outcome = measure(held_out, parse_rules(trace.last_reply))
trace.info["environment"] = trace_provenance(
split=self.data.split,
seed_commitment=self.data.seed_commitment,
)
trace.info["redaction_errors"] = outcome.invalid_rules
return {
"recall": outcome.recall,
"precision": outcome.precision,
"clean": float(outcome.clean),
"validity": outcome.validity,
"character_coverage": outcome.character_coverage,
"ordinary_preservation": outcome.ordinary_preservation,
"residual_hits": float(outcome.residual_hits),
"partial_secrets": float(outcome.partial_secrets),
"collateral_hits": float(outcome.collateral_hits),
"innocent_lost": float(outcome.innocent_lost),
"invalid_rules": float(len(outcome.invalid_rules)),
"regex_budget_elapsed_seconds": outcome.budget_elapsed_seconds,
}
@vf.reward(weight=0.35)
async def recall(self, trace: vf.Trace) -> float:
return trace.metrics.get("recall", 0.0)
return trace.metrics.get("recall", 0.0) * trace.metrics.get("validity", 0.0)
@vf.reward(weight=0.35)
async def precision(self, trace: vf.Trace) -> float:
return trace.metrics.get("precision", 0.0)
return trace.metrics.get("precision", 0.0) * trace.metrics.get("validity", 0.0)
@vf.reward(weight=0.30)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
return trace.metrics.get("clean", 0.0) * trace.metrics.get("validity", 0.0)
class RedactionConfig(vf.TasksetConfig):
num_tasks: int = Field(64, ge=1)
visible: int = Field(4, ge=1)
"""Records the agent reads."""
held_out: int = Field(12, ge=1)
"""Records it is graded on and never sees."""
num_tasks: int = Field(64, ge=1, le=MAX_TASKS_PER_SPLIT)
visible: int = Field(4, ge=1, le=64)
held_out: int = Field(12, ge=1, le=256)
split: Split = "dev"
private_seed_env: str = PRIVATE_SEED_ENV_DEFAULT
@model_validator(mode="after")
def validate_private_split(self) -> "RedactionConfig":
if self.split == "private_eval":
_private_key(self.private_seed_env)
return self
class RedactionTaskset(vf.Taskset[RedactionTask, RedactionConfig]):
SEED_BASE: ClassVar[int] = 90_000
def load(self) -> list[RedactionTask]:
commitment = seed_commitment(self.config.split, self.config.private_seed_env)
source_hash = environment_source_sha256()
tasks = []
for i in range(self.config.num_tasks):
seed = self.SEED_BASE + i
for index in range(self.config.num_tasks):
seed = seed_for(self.config.split, index, self.config.private_seed_env)
visible, _ = build_slices(seed, self.config.visible, self.config.held_out)
tickets = "\n\n".join(record.body for record in visible)
tasks.append(
RedactionTask(
RedactionData(
idx=i,
name=f"stream-{seed}",
idx=index,
name=f"{self.config.split}-{index:05d}",
prompt=f"Tickets from the stream:\n\n{tickets}\n\nWrite the ruleset.",
system_prompt=SYSTEM,
seed=seed,
visible=self.config.visible,
held_out=self.config.held_out,
split=self.config.split,
seed_commitment=commitment,
environment_version=ENVIRONMENT_VERSION,
corpus_version=CORPUS_VERSION,
reward_schema_version=REWARD_SCHEMA_VERSION,
source_sha256=source_hash,
),
self.config.task,
)
@@ -0,0 +1 @@
"""Tests for redaction-pressure."""
@@ -0,0 +1,36 @@
from __future__ import annotations
import json
from redaction_pressure.scan import Rule
ORACLE_RULES = [
Rule(r"(?<=Customer pasted their key )sk-live-[a-z0-9]{24}", "[REDACTED]"),
Rule(r"(?<=Reply-to was )[a-z]+[.][a-z]+@[a-z]{6}[.]com", "[REDACTED]"),
Rule(r"(?<=Callback number )[+]1-[0-9]{3}-[0-9]{3}-[0-9]{4}", "[REDACTED]"),
Rule(r"(?<=They read the card )[0-9]{4}(?:-[0-9]{4}){3}", "[REDACTED]"),
Rule(r"(?<=Trace pointed at )[a-z]{5}-db-[1-9][.]internal[.]lumbridge", "[REDACTED]"),
Rule(
r"(?<=Escalated by )(?:Dana|Priya|Marcus|Ines|Tomas|Rui|Nadia|Owen) "
r"(?:Okafor|Lindqvist|Baptiste|Moreau|Sato|Ahmed|Vance|Duarte)",
"[REDACTED]",
),
Rule(r"(?<=Session )[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", "[REDACTED]"),
]
FIRST_CHARACTER_HACK = [
Rule(r"(?<=Customer pasted their key )s", ""),
Rule(r"(?<=Reply-to was )[a-z]", ""),
Rule(r"(?<=Callback number )[+]", ""),
Rule(r"(?<=They read the card )[0-9]", ""),
Rule(r"(?<=Trace pointed at )[a-z]", ""),
Rule(r"(?<=Escalated by )[A-Z]", ""),
Rule(r"(?<=Session )[0-9a-f]", ""),
]
def reply_for(rules: list[Rule]) -> str:
return "```json\n" + json.dumps(
[{"pattern": rule.pattern, "replacement": rule.replacement} for rule in rules]
) + "\n```"
@@ -0,0 +1,116 @@
from __future__ import annotations
import unittest
import regex
from redaction_pressure.corpus import build_slices
from redaction_pressure.scan import (
CompiledRule,
ParsedRules,
RegexBudget,
Rule,
apply_rules,
measure,
)
from tests.helpers import FIRST_CHARACTER_HACK, ORACLE_RULES
def held_out(seed: int = 200_000):
return build_slices(seed, 4, 12)[1]
class RewardContractTests(unittest.TestCase):
def test_oracle_is_one_for_every_public_dev_task(self) -> None:
for seed in range(200_000, 200_064):
with self.subTest(seed=seed):
outcome = measure(build_slices(seed, 4, 12)[1], ORACLE_RULES)
self.assertEqual(outcome.recall, 1.0)
self.assertEqual(outcome.precision, 1.0)
self.assertTrue(outcome.clean)
self.assertEqual(outcome.validity, 1.0)
def test_inaction_is_zero(self) -> None:
outcome = measure(held_out(), [])
self.assertEqual(outcome.recall, 0.0)
self.assertEqual(outcome.precision, 0.0)
self.assertFalse(outcome.clean)
self.assertEqual(outcome.validity, 1.0)
def test_first_character_exploit_earns_zero_recall(self) -> None:
for seed in range(200_000, 200_064):
with self.subTest(seed=seed):
outcome = measure(build_slices(seed, 4, 12)[1], FIRST_CHARACTER_HACK)
self.assertEqual(outcome.recall, 0.0)
self.assertEqual(outcome.precision, 0.0)
self.assertEqual(outcome.partial_secrets, outcome.secrets_total)
self.assertEqual(outcome.residual_hits, outcome.secrets_total)
self.assertFalse(outcome.clean)
def test_ordinary_content_loss_breaks_clean_gate_and_precision(self) -> None:
rules = [*ORACLE_RULES, Rule(r"Customer", "")]
outcome = measure(held_out(), rules)
self.assertEqual(outcome.recall, 1.0)
self.assertGreater(outcome.innocent_lost, 0)
self.assertLess(outcome.precision, 1.0)
self.assertFalse(outcome.clean)
def test_one_decoy_character_is_collateral(self) -> None:
rules = [*ORACLE_RULES, Rule(r"(?<=our published sample is )s", "")]
outcome = measure(held_out(), rules)
self.assertGreater(outcome.collateral_hits, 0)
self.assertGreater(outcome.innocent_lost, 0)
self.assertFalse(outcome.clean)
def test_arbitrary_replacement_invalidates_complete_ruleset(self) -> None:
parsed = ParsedRules(tuple([*ORACLE_RULES, Rule(r"never-matches", "[EMAIL]")]), submitted=8)
outcome = measure(held_out(), parsed)
self.assertEqual(outcome.validity, 0.0)
self.assertFalse(outcome.clean)
self.assertTrue(any("replacement" in error for error in outcome.invalid_rules))
def test_excess_rules_are_explicitly_invalid(self) -> None:
parsed = ParsedRules(tuple(Rule(r"(?!)", "") for _ in range(25)), submitted=25)
outcome = measure(held_out(), parsed)
self.assertEqual(outcome.validity, 0.0)
self.assertTrue(any("too many rules" in error for error in outcome.invalid_rules))
def test_broad_many_match_rule_is_invalid(self) -> None:
outcome = measure(held_out(), [Rule(r"(?=.)", "")])
self.assertEqual(outcome.validity, 0.0)
self.assertTrue(any("matches in one record" in error for error in outcome.invalid_rules))
def test_shred_everything_cannot_pass_preservation_gate(self) -> None:
outcome = measure(held_out(), [Rule(r"\S+", "[REDACTED]")])
self.assertGreater(outcome.innocent_lost, 0)
self.assertGreater(outcome.collateral_hits, 0)
self.assertFalse(outcome.clean)
self.assertLess(outcome.precision, 1.0)
def test_real_catastrophic_regex_is_interrupted(self) -> None:
compiled = [CompiledRule(0, regex.compile(r"(?:a|aa)+$"), "")]
result = apply_rules("a" * 20_000 + "!", compiled, RegexBudget(seconds=0.05))
self.assertTrue(any("timed out" in error or "budget" in error for error in result.errors))
self.assertEqual(result.tracked.text, "a" * 20_000 + "!")
def test_episode_budget_is_shared_and_bounded(self) -> None:
class Clock:
def __init__(self) -> None:
self.value = 0.0
def __call__(self) -> float:
self.value += 0.2
return self.value
outcome = measure(held_out(), ORACLE_RULES, budget_seconds=0.5, clock=Clock())
self.equal_zero_reward_invalid(outcome)
self.assertGreaterEqual(outcome.budget_elapsed_seconds, outcome.budget_seconds)
self.assertTrue(any("budget exhausted" in error for error in outcome.invalid_rules))
def equal_zero_reward_invalid(self, outcome) -> None:
self.assertEqual(outcome.validity, 0.0)
self.assertFalse(outcome.clean)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,120 @@
from __future__ import annotations
import asyncio
import os
import string
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from pydantic import ValidationError
from redaction_pressure.corpus import build_slices
from redaction_pressure.provenance import environment_source_sha256
from redaction_pressure.taskset import (
RedactionConfig,
RedactionTaskset,
SYSTEM,
parse_rules,
seed_commitment,
seed_for,
)
from tests.helpers import ORACLE_RULES, reply_for
class ParserTests(unittest.TestCase):
def test_system_prompt_example_is_valid_json(self) -> None:
parsed = parse_rules(SYSTEM)
self.assertEqual(parsed.errors, ())
self.assertEqual(len(parsed.rules), 1)
def test_last_json_block_wins(self) -> None:
parsed = parse_rules("```json\n[]\n```\nthen\n" + reply_for(ORACLE_RULES[:1]))
self.assertEqual(parsed.rules, tuple(ORACLE_RULES[:1]))
self.assertEqual(parsed.errors, ())
self.assertEqual(parsed.submitted, 1)
def test_valid_empty_array_is_inaction_not_invalid(self) -> None:
parsed = parse_rules("[]")
self.assertEqual(parsed.rules, ())
self.assertEqual(parsed.errors, ())
self.assertEqual(parsed.submitted, 0)
def test_malformed_json_is_explicitly_invalid(self) -> None:
parsed = parse_rules("not json")
self.assertFalse(parsed.rules)
self.assertTrue(parsed.errors)
def test_non_array_and_bad_items_are_explicitly_invalid(self) -> None:
self.assertTrue(parse_rules('{"pattern": "x"}').errors)
parsed = parse_rules('[1, {"pattern": 2}, {"pattern": "x", "replacement": 4}]')
self.assertEqual(len(parsed.errors), 3)
self.assertEqual(parsed.submitted, 3)
class SeedAndProvenanceTests(unittest.TestCase):
def test_corpus_is_deterministic(self) -> None:
self.assertEqual(build_slices(200_123, 4, 12), build_slices(200_123, 4, 12))
def test_train_and_dev_seed_ranges_are_disjoint(self) -> None:
train = {seed_for("train", index) for index in range(10_000)}
dev = {seed_for("dev", index) for index in range(10_000)}
self.assertTrue(train.isdisjoint(dev))
def test_private_split_requires_external_secret(self) -> None:
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(ValidationError):
RedactionConfig(split="private_eval")
def test_private_seeds_are_deterministic_keyed_and_committed(self) -> None:
key_a = "a" * 32
key_b = "b" * 32
with patch.dict(os.environ, {"REDACTION_PRESSURE_PRIVATE_SEED": key_a}):
first = seed_for("private_eval", 7)
again = seed_for("private_eval", 7)
commitment = seed_commitment("private_eval")
with patch.dict(os.environ, {"REDACTION_PRESSURE_PRIVATE_SEED": key_b}):
other = seed_for("private_eval", 7)
self.assertEqual(first, again)
self.assertNotEqual(first, other)
self.assertNotIn(key_a, commitment)
self.assertEqual(len(commitment), 64)
def test_source_hash_is_stable_hex(self) -> None:
first = environment_source_sha256()
self.assertEqual(first, environment_source_sha256())
self.assertEqual(len(first), 64)
self.assertTrue(set(first) <= set(string.hexdigits.lower()))
def test_task_data_carries_replay_identity_without_secret_key(self) -> None:
taskset = RedactionTaskset(RedactionConfig(num_tasks=2, split="dev"))
tasks = taskset.load()
self.assertEqual([task.data.name for task in tasks], ["dev-00000", "dev-00001"])
self.assertNotEqual(tasks[0].data.seed, tasks[1].data.seed)
self.assertEqual(tasks[0].data.source_sha256, environment_source_sha256())
self.assertEqual(len(tasks[0].data.seed_commitment), 64)
self.assertEqual(tasks[0].data.environment_version, "0.2.0")
def test_scoring_records_provenance_and_oracle_metrics(self) -> None:
task = RedactionTaskset(RedactionConfig(num_tasks=1, split="dev")).load()[0]
trace = SimpleNamespace(last_reply=reply_for(ORACLE_RULES), info={})
metrics = asyncio.run(task.scan(trace))
self.assertEqual(metrics["recall"], 1.0)
self.assertEqual(metrics["precision"], 1.0)
self.assertEqual(metrics["clean"], 1.0)
self.assertEqual(metrics["validity"], 1.0)
self.assertEqual(trace.info["environment"]["source_sha256"], environment_source_sha256())
self.assertEqual(trace.info["redaction_errors"], [])
def test_invalidity_multiplies_all_rewards_to_zero(self) -> None:
task = RedactionTaskset(RedactionConfig(num_tasks=1, split="dev")).load()[0]
trace = SimpleNamespace(
metrics={"recall": 1.0, "precision": 1.0, "clean": 1.0, "validity": 0.0}
)
self.assertEqual(asyncio.run(task.recall(trace)), 0.0)
self.assertEqual(asyncio.run(task.precision(trace)), 0.0)
self.assertEqual(asyncio.run(task.gate(trace)), 0.0)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
[project]
name = "schema-migration"
version = "0.1.0"
description = "schema-migration — split a text column into a number and a unit, graded on rows you never saw."
requires-python = ">=3.11"
dependencies = ["verifiers==0.3.1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["schema_migration"]
@@ -0,0 +1,3 @@
from schema_migration.taskset import MigrationTaskset
__all__ = ["MigrationTaskset"]
@@ -0,0 +1,69 @@
"""The table before the migration, and the rows the migration is graded on.
One `readings` table carries a `value_text` column that should never have been text:
"1400 ms", "412 rps", "-3.5 degC". The migration has to split it into a real number and a
unit and drop the original column, without losing a row or rounding a value.
The visible rows are deliberately tidy. The held-out rows are not: they carry thousands
separators, negatives, decimals, a unit with a digit in it, a value with no unit at all,
and one with extra internal whitespace. A migration written by pattern-matching the five
rows on screen parses about half of them, which is the entire point of grading on rows the
author never read.
"""
from __future__ import annotations
import random
import sqlite3
TIDY = [
("1400 ms", 1400.0, "ms"),
("412 rps", 412.0, "rps"),
("16 conns", 16.0, "conns"),
("88 waiters", 88.0, "waiters"),
("7 replicas", 7.0, "replicas"),
]
# Every awkward shape a real column of this kind contains.
AWKWARD = [
("1,400 ms", 1400.0, "ms"), # thousands separator
("-3.5 degC", -3.5, "degC"), # negative, decimal
("0.25 s", 0.25, "s"), # leading zero decimal
("64 KiB/s", 64.0, "KiB/s"), # unit containing a digit and a slash
("120", 120.0, ""), # no unit at all
("55 rps", 55.0, "rps"), # doubled internal space
("2,048 MiB", 2048.0, "MiB"),
("-12 ms", -12.0, "ms"),
("3.0 x", 3.0, "x"),
("1,000,000 rows", 1000000.0, "rows"),
]
SOURCE_DDL = """
CREATE TABLE readings (
id INTEGER PRIMARY KEY,
probe TEXT NOT NULL,
value_text TEXT NOT NULL
);
"""
TARGET_COLUMNS = {"id", "probe", "value_num", "unit"}
def rows(seed: int, count: int, awkward: bool) -> list[tuple[str, float, str]]:
rng = random.Random(seed)
pool = AWKWARD + TIDY if awkward else TIDY
return [pool[rng.randrange(len(pool))] for _ in range(count)]
def connect(seed: int, count: int, awkward: bool) -> tuple[sqlite3.Connection, list[tuple]]:
"""A fresh in-memory database holding one slice. Nothing touches disk, so an agent's
SQL cannot reach anything the environment is the sandbox."""
con = sqlite3.connect(":memory:")
con.executescript(SOURCE_DDL)
data = rows(seed, count, awkward)
con.executemany(
"INSERT INTO readings (id, probe, value_text) VALUES (?, ?, ?)",
[(i + 1, f"probe_{i:03d}", text) for i, (text, _, _) in enumerate(data)],
)
con.commit()
return con, data
@@ -0,0 +1,133 @@
"""Running the agent's migration, and measuring what survived it.
Four things are checked, and they are in tension by construction:
schema the target columns exist and `value_text` is gone. A migration that adds the
new columns and leaves the old one is not a migration, it is a copy.
fidelity per row, does (value_num, unit) recompose to the original value. This is the
expensive one and the only one that punishes a parser fitted to the visible
rows.
rows the row count is unchanged. Without it, `DELETE FROM readings` scores perfect
fidelity over an empty table vacuously, since there is nothing left to be
wrong about.
gate schema and rows exactly, fidelity within `GATE_MARGIN`. It demanded all three
exactly until 2026-08-21 and measured over 32 rollouts it fired zero times
0.25 of the reward mass with no gradient in it. The two format clauses are
still exact: a migration that leaves `value_text` standing, or that loses a
row, is not a migration and no margin applies to it.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from schema_migration.fixture import TARGET_COLUMNS, connect
STATEMENT_LIMIT = 40
# ATTACH would reach outside the in-memory database; the rest cannot, but there is no
# reason for a schema migration to use any of them.
FORBIDDEN = ("attach", "pragma", "vacuum")
TOLERANCE = 1e-9
# How far below perfect per-row fidelity the gate still fires. Six rows of forty.
#
# It was zero, and over the 32 rollouts in `outputs/run-20260821-1401` `matched == graded`
# happened not once: fidelity maxed at 0.875, so a quarter of this environment's reward was
# a constant. The other two clauses were not the blocker — `schema_ok` held on 25 of 32 and
# the row count on the same 25.
#
# Swept over those 32 rollouts by replaying them (`tools/regate.py`): exact 0/32,
# margin 0.05 0/32, 0.10 0/32, **0.15 4/32 = 0.125**, 0.20 8/32 = 0.250. And over the probe
# ladder (`tools/regate_ladder.py`), which is what rules 0.20 out: the naive split — the
# parser fitted to the five tidy rows on screen, which is precisely the strategy this
# environment exists to punish — clears a 0.20 gate on 29% of seeds. At 0.15 it clears on
# one seed in twenty-four, because its luckiest draw of forty awkward rows recomposes 34 of
# them and 34/40 is 0.850 exactly. That is a knife edge and it is named here so nobody has
# to rediscover it; `probe.py` asserts the plausible rung stays under a tenth.
#
# What 0.15 buys is the rung above: a migration that strips the thousands separator and
# still mishandles the row with no unit at all — right about the inference, wrong about one
# shape — goes from clearing the gate on 8% of seeds to clearing it on 96%.
GATE_MARGIN = 0.15
@dataclass
class Outcome:
schema_ok: bool = False
matched: int = 0
graded: int = 0
rows_before: int = 0
rows_after: int = 0
error: str = ""
@property
def fidelity(self) -> float:
return self.matched / self.graded if self.graded else 0.0
@property
def rows_kept(self) -> float:
if not self.rows_before:
return 0.0
# Inserting extra rows is as wrong as dropping them, so this is a distance, not a ratio.
return max(0.0, 1.0 - abs(self.rows_after - self.rows_before) / self.rows_before)
@property
def clean(self) -> bool:
"""Schema and row count exactly; fidelity within `GATE_MARGIN` of perfect.
The margin is on fidelity alone. `schema_ok` is a format precondition and
`rows_after == rows_before` is what stops `DELETE FROM readings` scoring perfect
fidelity over an empty table neither is a matter of degree, and giving either of
them slack would reopen a hole this environment has already paid for once.
"""
return (self.schema_ok and self.graded > 0
and self.fidelity >= 1.0 - GATE_MARGIN
and self.rows_after == self.rows_before)
def _columns(con: sqlite3.Connection) -> set[str]:
return {r[1] for r in con.execute("PRAGMA table_info(readings)")}
def measure(seed: int, count: int, migration: str) -> Outcome:
con, data = connect(seed, count, awkward=True)
outcome = Outcome(rows_before=len(data), graded=len(data))
lowered = (migration or "").lower()
if any(word in lowered for word in FORBIDDEN):
outcome.error = "forbidden statement"
return outcome
if lowered.count(";") > STATEMENT_LIMIT:
outcome.error = "too many statements"
return outcome
if not lowered.strip():
outcome.error = "empty migration"
return outcome
try:
con.executescript(migration)
except sqlite3.Error as exc:
# A migration that does not run scores what doing nothing scores. It is not an
# error for the rollout — the reward is the signal.
outcome.error = str(exc)[:120]
return outcome
columns = _columns(con)
outcome.schema_ok = TARGET_COLUMNS.issubset(columns) and "value_text" not in columns
try:
outcome.rows_after = con.execute("SELECT count(*) FROM readings").fetchone()[0]
if outcome.schema_ok:
got = {
r[0]: (r[1], r[2])
for r in con.execute("SELECT id, value_num, unit FROM readings")
}
for i, (_, num, unit) in enumerate(data):
have = got.get(i + 1)
if not have or have[0] is None:
continue
if abs(float(have[0]) - num) < TOLERANCE and (have[1] or "").strip() == unit:
outcome.matched += 1
except (sqlite3.Error, TypeError, ValueError) as exc:
outcome.error = str(exc)[:120]
return outcome
@@ -0,0 +1,121 @@
"""schema-migration: a migration is judged on the rows you did not look at.
`readings.value_text` holds numbers and units as one string. The migration has to become
`value_num REAL` plus `unit TEXT`, drop the original column, and lose nothing. The agent
sees five tidy rows; it is graded on forty it never sees, which carry thousands separators,
negatives, decimals, a unit containing a digit and a slash, a value with no unit, and
doubled internal whitespace.
The reward is the migration actually executing against a real database, so there is no
judge and no rubric SQLite either recomposes the value or it does not. Row count is
scored separately from fidelity because otherwise `DELETE FROM readings` is a perfect
migration: nothing left to be wrong about.
"""
from __future__ import annotations
import re
from pydantic import Field
import verifiers.v1 as vf
from schema_migration.fixture import connect
from schema_migration.run import measure
SYSTEM = """You are writing a SQLite migration.
The table is:
CREATE TABLE readings (id INTEGER PRIMARY KEY, probe TEXT NOT NULL, value_text TEXT NOT NULL);
Migrate it so that `readings` has columns id, probe, value_num (REAL) and unit (TEXT), and
no longer has value_text. `value_num` is the number in value_text; `unit` is what follows
it, trimmed, or an empty string when there is no unit.
Return ONLY SQL in one ```sql code block. It is run with executescript(), so several
statements are fine. Preserve every row and every id.
You are graded on rows you have not seen, from the same column. They are messier than the
ones below."""
_BLOCK = re.compile(r"```(?:sql)?\s*\n(.*?)```", re.DOTALL)
def parse_sql(reply: str) -> str:
blocks = _BLOCK.findall(reply or "")
return (blocks[-1] if blocks else (reply or "")).strip()
class MigrationData(vf.TaskData):
seed: int
held_out: int
class MigrationTask(vf.Task[MigrationData]):
@vf.stop
async def single_turn(self, trace: vf.Trace) -> bool:
return trace.num_turns >= 1
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
outcome = measure(self.data.seed, self.data.held_out, parse_sql(trace.last_reply))
return {
"schema": float(outcome.schema_ok),
"fidelity": outcome.fidelity,
"rows_kept": outcome.rows_kept,
# Rows are a MULTIPLIER on fidelity, not a reward beside it. Scored separately
# they paid 0.15 for adding two empty columns and touching nothing — a floor
# above zero for near-inaction, which the house rules forbid. As a multiplier,
# losing rows still zeroes an otherwise perfect migration and preserving them
# on its own earns nothing.
"integrity": outcome.fidelity * outcome.rows_kept,
"clean": float(outcome.clean),
"errored": float(bool(outcome.error)),
}
@vf.reward(weight=0.30)
async def schema(self, trace: vf.Trace) -> float:
return trace.metrics.get("schema", 0.0)
@vf.reward(weight=0.45)
async def integrity(self, trace: vf.Trace) -> float:
return trace.metrics.get("integrity", 0.0)
@vf.reward(weight=0.25)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
class MigrationConfig(vf.TasksetConfig):
num_tasks: int = Field(48, ge=1)
visible: int = Field(5, ge=1)
held_out: int = Field(40, ge=1)
class MigrationTaskset(vf.Taskset[MigrationTask, MigrationConfig]):
SEED_BASE = 30_000
def load(self) -> list[MigrationTask]:
tasks = []
for i in range(self.config.num_tasks):
seed = self.SEED_BASE + i
# The visible slice is tidy on purpose; the graded one is not.
con, data = connect(seed, self.config.visible, awkward=False)
sample = "\n".join(
f" {i + 1} | probe_{i:03d} | {text}" for i, (text, _, _) in enumerate(data)
)
con.close()
tasks.append(
MigrationTask(
MigrationData(
idx=i,
name=f"readings-{seed}",
prompt=f"Rows in the table:\n\n id | probe | value_text\n{sample}\n\nWrite the migration.",
system_prompt=SYSTEM,
seed=seed,
held_out=self.config.held_out,
),
self.config.task,
)
)
return tasks
+1265
View File
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import re
import subprocess
import sys
import tomllib
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ROW = re.compile(r"^(?P<name>\S(?:.*\S)?)\s{2,}(?:-?\d+\.\d+\s*){4}\s+(?P<verdict>.+?)$")
# The per-component block, which is indented by two spaces precisely so that ROW — anchored
# at `^\S` — cannot swallow it. Four numbers there, five here.
PART = re.compile(
r"^ (?P<env>\S+)\s+(?P<component>\S+)\s+"
r"(?P<floor>-?\d+\.\d+)\s+(?P<below>-?\d+\.\d+)\s+"
r"(?P<oracle>-?\d+\.\d+)\s+(?P<ceiling>-?\d+\.\d+)\s+(?P<verdict>.+?)$"
)
# Reward components that nothing below the oracle earns a fraction of. `probe.py` warns and
# names them; this is what stops the warning becoming wallpaper.
#
# Both of these were investigated by replaying `outputs/run-20260821-1401` through a
# candidate gate (`tools/regate.py`), which is the only measurement that separates a dead
# gate from a merely binary one, and both were left as they are on purpose:
#
# redaction-pressure/gate fired 0/32. Every margin loose enough to fire on the measured
# population also pays a four-rule ruleset on five seeds in six.
# Genuinely hard, not mis-thresholded. See `Outcome.clean`.
# fault-localisation/gate fired 29/32 — 0.9062, the healthiest gate in the repository.
# The ladder simply has no rung that gets two of three fields
# right and clears it, because the gate wants all three.
#
# `bot-detection/gate` and `schema-migration/gate` were on this list and are not any more:
# they came back 0/32 and were given margins. It only ever shrinks, and a name may only be
# deleted alongside the measurement that justifies it.
STEP_AT_ORACLE = frozenset({
("redaction-pressure", "gate"),
("fault-localisation", "gate"),
})
# The environments that are allowed to carry no probe yet. Everything discovered and NOT in
# here MUST be gated.
#
# This list is the floor, and it is why it exists rather than a bare count: without it, both
# loops below are vacuous when nothing is gated, and CI goes green while gating zero
# environments. Commenting out a single `@probes(...)` decorator used to pass — the degated
# name simply moved into the warning line, which already legitimately carries five names.
#
# It only ever shrinks. The former Tera entries moved to the private Tera repository with their
# simulator source, so every environment remaining in public Arena is gated today.
PENDING_PROBES: frozenset[str] = frozenset()
def discovered_tasksets() -> set[str]:
"""The same denominator `probe.py:discover()` uses, derived independently of it.
Deriving it a second time here rather than importing `probe` is the point: if discovery
and the manifests ever disagree, this test is what says so. A directory with no
`pyproject.toml` is not an environment to either of us.
"""
names: set[str] = set()
for manifest in sorted((ROOT / "environments").glob("*/pyproject.toml")):
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
declared = data.get("tool", {}).get("arena", {}).get("tasksets")
names.update(declared or [data["project"]["name"]])
return names
class RootProbeIntegrationTests(unittest.TestCase):
def test_prime_verifiers_boundary_is_pinned_and_upstream_owned(self) -> None:
manifests = sorted((ROOT / "environments").glob("*/pyproject.toml"))
configs = sorted((ROOT / "configs").glob("*.toml"))
self.assertTrue(manifests)
self.assertTrue(configs)
for manifest in manifests:
with self.subTest(manifest=manifest.parent.name):
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
self.assertIn("verifiers==0.3.1", data["project"]["dependencies"])
for config in configs:
with self.subTest(config=config.stem):
data = tomllib.loads(config.read_text(encoding="utf-8"))
agent = data["env"]["agent"]
self.assertEqual(agent["harness"]["id"], "null")
self.assertEqual(agent["runtime"]["type"], "subprocess")
tracked = subprocess.run(
["git", "ls-files", "environments"],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
).stdout.splitlines()
self.assertEqual(
[path for path in tracked if path.endswith("/harness.py")],
[],
msg="Arena environments must use Verifiers harnesses, not ship a parallel one",
)
def test_public_root_probe_gates_every_environment(self) -> None:
completed = subprocess.run(
[sys.executable, "probe.py"],
cwd=ROOT,
text=True,
capture_output=True,
# The probe sweeps constants and runs multi-block ladders, and the interactive
# environments will make it dearer still. A tight timeout here only ever fails a
# slow machine, never a wrong reward.
timeout=180,
check=False,
)
detail = f"\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
self.assertEqual(completed.returncode, 0, msg=f"root probe failed:{detail}")
discovered = discovered_tasksets()
self.assertTrue(discovered, msg="no environment manifests found — the scan is empty")
rows = {}
for line in completed.stdout.splitlines()[1:]:
match = ROW.match(line.rstrip())
if match:
rows[match["name"]] = match["verdict"].strip()
for name, verdict in rows.items():
with self.subTest(environment=name):
self.assertIn(name, discovered, msg=f"probe gated {name}, no manifest declares it")
self.assertEqual(verdict, "ok", msg=f"{name} is not ok:{detail}")
# Anything discovered and not gated has to be named in the warning. That warning is
# temporary — it becomes a hard exit 1 in probe.py once the last unregistered
# environment lands its probe — and this assertion holds either way.
ungated = discovered - set(rows)
for name in sorted(ungated):
with self.subTest(environment=name):
self.assertIn(name, completed.stderr,
msg=f"{name} is neither gated nor reported as ungated:{detail}")
# The floor. Being named in the warning is not a licence to stop being gated: an
# environment may only be ungated if it is on the pending list. Without this, removing
# a `@probes(...)` decorator moves the name from the table into the warning and every
# assertion above stays vacuously true.
self.assertLessEqual(
ungated, PENDING_PROBES,
msg=f"these were gated and no longer are: {sorted(ungated - PENDING_PROBES)}{detail}",
)
self.assertTrue(rows, msg=f"the probe gated nothing at all:{detail}")
# --- and the same again, one reward component at a time -------------------------
# Everything above passed for a month while four `gate` components scored exactly
# 0.000 mean and 0.000 max over 32 real rollouts, because a blended `oracle 1.000`
# is exactly the thing that cannot show a constant inside it.
parts = {}
for line in completed.stdout.splitlines():
match = PART.match(line.rstrip())
if match:
parts[(match["env"], match["component"])] = match
self.assertTrue(parts, msg=f"the probe printed no per-component block:{detail}")
for name in rows:
with self.subTest(environment=name):
self.assertTrue(
any(env == name for env, _ in parts),
msg=f"{name} is gated but no component of it is reported:{detail}",
)
for (env, component), match in sorted(parts.items()):
with self.subTest(environment=env, component=component):
verdict = match["verdict"].strip()
self.assertFalse(
verdict.startswith("FLAT"),
msg=f"{env}/{component} is constant across the ladder:{detail}",
)
# House rule 3, per term rather than per environment. A blended floor of
# 0.000 is compatible with one component paying for inaction and another
# going negative to cancel it; this is the form that is not.
self.assertLessEqual(
float(match["floor"]), 1e-9,
msg=f"{env}/{component} pays {match['floor']} for the worst rung on the "
f"ladder — inaction is being paid for a component:{detail}",
)
stepped = verdict.startswith("step@oracle")
if (env, component) in STEP_AT_ORACLE:
self.assertTrue(
stepped,
msg=f"{env}/{component} now carries a gradient below the oracle — "
f"delete it from STEP_AT_ORACLE:{detail}",
)
else:
self.assertFalse(
stepped,
msg=f"{env}/{component} earns nothing below the oracle and is not "
f"on the STEP_AT_ORACLE list. Replay the traces through it with "
f"tools/regate.py before adding it:{detail}",
)
if __name__ == "__main__":
unittest.main()
+163
View File
@@ -0,0 +1,163 @@
"""What the `gate` component pays the probe ladder, rung by rung. SUPERSEDED — see below.
`probe.py` does this properly now, for every component of every environment rather than
the gate of four, and it exits 1 on a component that never moves. Run that instead. This
file is kept only because `docs/GATE_DIAGNOSIS.md` quotes its output verbatim as the
evidence for the diagnosis, and a quoted table whose generator is gone is not evidence. To
sweep a CANDIDATE gate rather than the shipped one, use `tools/regate_ladder.py`, and to
measure one against real rollouts rather than against the ladder, `tools/regate.py`.
`probe.py` reports one blended number per rung, so a gate that only ever fires for
the oracle is invisible in its table. This prints the gate on its own the share of
tasks each rung clears it on for the four environments where the first eval measured
`gate` 0.000 mean, 0.000 max over 32 rollouts.
Read-only: it imports the shipped scorers and changes nothing.
uv run --project environments/grand_exchange python outputs/gate_probe.py
"""
from __future__ import annotations
import json
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ENVS = ROOT / "environments"
for pkg in ("redaction_pressure", "schema_migration", "bot_detection", "grand_exchange"):
shim = types.ModuleType(pkg)
shim.__path__ = [str(ENVS / pkg / pkg)]
sys.modules[pkg] = shim
TASKS = 24
def rate(name: str, rows: dict[str, list[bool]]) -> None:
print(f"\n#### {name}")
for rung, hits in rows.items():
print(f" {rung:12s} gate {sum(hits)}/{len(hits)} = {sum(hits) / len(hits):.3f}")
def redaction() -> None:
from redaction_pressure.corpus import build_slices
from redaction_pressure.scan import measure, parse_rules
def run(rules: list[dict]) -> list[bool]:
out = []
for i in range(TASKS):
_, held = build_slices(200_000 + i, 4, 12)
o = measure(held, parse_rules("```json\n" + json.dumps(rules) + "\n```"))
out.append(bool(o.clean) and o.validity == 1.0)
return out
oracle = [
{"pattern": r"(?<=Customer pasted their key )sk-live-[a-z0-9]{24}", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Reply-to was )[a-z]+[.][a-z]+@[a-z]{6}[.]com", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Callback number )[+]1-[0-9]{3}-[0-9]{3}-[0-9]{4}", "replacement": "[REDACTED]"},
{"pattern": r"(?<=They read the card )[0-9]{4}(?:-[0-9]{4}){3}", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Trace pointed at )[a-z]{5}-db-[1-9][.]internal[.]lumbridge", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Session )[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Escalated by )(?:Dana|Priya|Marcus|Ines|Tomas|Rui|Nadia|Owen) (?:Okafor|Lindqvist|Baptiste|Moreau|Sato|Ahmed|Vance|Duarte)", "replacement": "[REDACTED]"},
]
rate("redaction-pressure", {
"inaction": run([]),
"crude": run([{"pattern": r"\S+", "replacement": "[REDACTED]"}]),
"plausible": run(oracle[:4]),
"six-of-seven": run(oracle[:6]),
"oracle": run(oracle),
})
def schema() -> None:
from schema_migration.run import measure
def run(sql: str) -> list[bool]:
return [bool(measure(30_000 + i, 40, sql).clean) for i in range(TASKS)]
naive = ("ALTER TABLE readings ADD COLUMN value_num REAL;"
"ALTER TABLE readings ADD COLUMN unit TEXT;"
"UPDATE readings SET value_num=CAST(substr(value_text,1,instr(value_text,' ')-1) AS REAL),"
" unit=substr(value_text,instr(value_text,' ')+1);"
"ALTER TABLE readings DROP COLUMN value_text;")
clean = "replace(value_text,',','')"
oracle = (f"ALTER TABLE readings ADD COLUMN value_num REAL;"
f"ALTER TABLE readings ADD COLUMN unit TEXT;"
f"UPDATE readings SET"
f" value_num=CAST(CASE WHEN instr({clean},' ')>0"
f" THEN substr({clean},1,instr({clean},' ')-1) ELSE {clean} END AS REAL),"
f" unit=CASE WHEN instr({clean},' ')>0"
f" THEN trim(substr({clean},instr({clean},' ')+1)) ELSE '' END;"
f"ALTER TABLE readings DROP COLUMN value_text;")
rate("schema-migration", {
"inaction": run(""),
"crude": run("ALTER TABLE readings ADD COLUMN value_num REAL; ALTER TABLE readings ADD COLUMN unit TEXT;"),
"plausible": run(naive),
"oracle": run(oracle),
})
def bots() -> None:
from bot_detection.accounts import build_slices
from bot_detection.scan import measure, parse_accusations, reference_bots
def run(fn) -> list[bool]:
out = []
for i in range(TASKS):
_, batch = build_slices(80_000 + i, 12)
names = fn(batch)
o = measure(batch, parse_accusations("```json\n" + json.dumps(names) + "\n```"))
out.append(bool(o.gate))
return out
def drop_one(batch):
found = sorted(reference_bots(batch))
return found[:-1]
rate("bot-detection", {
"inaction": run(lambda b: []),
"crude": run(lambda b: [a.acc_id for a in b]),
"oracle-minus-one": run(drop_one),
"oracle": run(lambda b: sorted(reference_bots(b))),
})
def exchange() -> None:
from grand_exchange.book import measure, parse_orders, reference_orders, viable_market
from grand_exchange.market import SEED_BASE
markets, seed = [], SEED_BASE
while len(markets) < 12:
m = viable_market(seed, 5, 56, 30)
markets.append(m)
seed = m.seed + 1
def reply(orders) -> str:
return "```json\n" + json.dumps({
"expected_profit": 0,
"orders": [
{"item": o.item, "quantity": o.quantity, "buy": o.buy, "sell": o.sell}
for o in orders
],
}) + "\n```"
def run(fn) -> list[bool]:
out = []
for m in markets:
orders, stated = parse_orders(fn(m))
out.append(bool(measure(m, orders, stated).clean))
return out
rate("grand-exchange", {
"inaction": run(lambda m: "No trades today."),
"oracle": run(lambda m: reply(reference_orders(m))),
})
if __name__ == "__main__":
redaction()
schema()
bots()
exchange()
+204
View File
@@ -0,0 +1,204 @@
"""Re-score the traces that are already on disk under a proposed gate, and print the fire rate.
A margin argued from a distribution is a guess. This replays `outputs/run-20260821-1401`
32 real rollouts per environment, `brain-qwen38-dspark`, thinking off through the
shipped scorer, rebuilds each task from its seed, and asks the candidate predicate
directly. The model's behaviour is held fixed and only the reward varies, which is a
cleaner measurement than a fresh sample would be and does not touch the reference node.
It reads traces and environment sources. It writes nothing.
uv run --with regex python tools/regate.py [run-directory]
"""
from __future__ import annotations
import json
import re
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ENVS = ROOT / "environments"
for _pkg in ("redaction_pressure", "schema_migration", "bot_detection"):
_shim = types.ModuleType(_pkg)
_shim.__path__ = [str(ENVS / _pkg / _pkg)]
sys.modules[_pkg] = _shim
RUN = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "outputs" / "run-20260821-1401"
def traces(env: str) -> list[dict]:
"""Every trace in one environment's `traces.jsonl`, errored rollouts included.
An errored rollout has `rewards: {}` and is still a rollout the model produced; it is
counted in the denominator, because a gate that fires only on the traces that happened
to succeed is a fire rate over a filtered population.
"""
out = []
for line in (RUN / env / "traces.jsonl").read_text().splitlines():
if line.strip():
out.extend(json.loads(line)["traces"])
return out
def last_reply(trace: dict) -> str:
for node in reversed(trace["nodes"]):
if node["message"]["role"] == "assistant":
return node["message"].get("content") or ""
return ""
def recorded_mean(env: str) -> tuple[float, int]:
"""The blended reward as `eval` recorded it, and how many traces carried one.
`eval` exits 0 even when every rollout errors, and an errored trace lands with
`rewards: {}`. Counting those as zero would understate the mean; counting them out of
the denominator would overstate the fire rates above. Both numbers are printed.
"""
scored = [t for t in traces(env) if t["rewards"]]
if not scored:
return 0.0, 0
total = sum(sum(r["score"] * r["weight"] for r in t["rewards"].values()) for t in scored)
return total / len(scored), len(scored)
def shifted(env: str, weight: float, fired: list[bool]) -> None:
"""What the gate change does to the environment's headline number."""
before, n = recorded_mean(env)
rate = sum(fired) / len(fired)
print(f" reward mean: recorded {before:.4f} over {n} scored traces"
f" -> {before + weight * rate:.4f} with gate at {rate:.3f} x weight {weight}")
def report(name: str, rows: list[tuple[str, list[bool]]]) -> None:
print(f"\n#### {name} n={len(rows[0][1])} real rollouts")
for label, hits in rows:
print(f" {label:44s} {sum(hits):2d}/{len(hits)} = {sum(hits) / len(hits):.3f}")
def bots() -> None:
from bot_detection.accounts import build_slices
from bot_detection.scan import measure, parse_accusations
outcomes = []
for trace in traces("bot-detection"):
data = trace["task"]["data"]
_, batch = build_slices(data["seed"], data["graded"])
outcomes.append(measure(batch, parse_accusations(last_reply(trace))))
def fires(slack: int) -> list[bool]:
return [
o.reference_caught > 0
and o.true_positives >= o.reference_caught - slack
and o.false_positives == 0
for o in outcomes
]
def share(target: float) -> list[bool]:
return [
o.reference_caught > 0
and o.true_positives >= target * o.reference_caught
and o.false_positives == 0
for o in outcomes
]
print("\n reference_caught:",
sorted({o.reference_caught for o in outcomes}),
" detection max", f"{max(o.detection for o in outcomes):.3f}")
report("bot-detection", [
("SHIPPED Outcome.gate", [bool(o.gate) for o in outcomes]),
("exact: every bot the reference caught", fires(0)),
("slack 1 (all but one)", fires(1)),
("slack 2 (all but two)", fires(2)),
("share 0.90 of the reference", share(0.90)),
("share 0.75 of the reference", share(0.75)),
("clause: false_positives == 0 alone", [o.false_positives == 0 for o in outcomes]),
])
shifted("bot-detection", 0.25, [bool(o.gate) for o in outcomes])
def migration() -> None:
from schema_migration.run import measure
# `schema_migration.taskset` imports verifiers and pydantic; the two lines of parsing
# this needs are copied rather than dragging the training stack into a read-only tool.
block = re.compile(r"```(?:sql)?\s*\n(.*?)```", re.DOTALL)
def parse_sql(reply: str) -> str:
blocks = block.findall(reply or "")
return (blocks[-1] if blocks else (reply or "")).strip()
outcomes = []
for trace in traces("schema-migration"):
data = trace["task"]["data"]
outcomes.append(measure(data["seed"], data["held_out"], parse_sql(last_reply(trace))))
def fires(margin: float) -> list[bool]:
return [
o.schema_ok and o.graded > 0
and o.fidelity >= 1.0 - margin
and o.rows_after == o.rows_before
for o in outcomes
]
print("\n fidelity:", " ".join(f"{o.fidelity:.3f}" for o in sorted(outcomes, key=lambda o: -o.fidelity)[:8]),
"... max", f"{max(o.fidelity for o in outcomes):.3f}")
report("schema-migration", [
("SHIPPED Outcome.clean", [bool(o.clean) for o in outcomes]),
("exact: 40 of 40 rows recompose", fires(0.0)),
("margin 0.05 (38 of 40)", fires(0.05)),
("margin 0.10 (36 of 40)", fires(0.10)),
("margin 0.15 (34 of 40)", fires(0.15)),
("margin 0.20 (32 of 40)", fires(0.20)),
("clauses: schema_ok and rows kept, fidelity free", fires(1.0)),
])
shifted("schema-migration", 0.25, [bool(o.clean) for o in outcomes])
def redaction() -> None:
from redaction_pressure.corpus import build_slices
from redaction_pressure.scan import measure, parse_rules
outcomes = []
for trace in traces("redaction-pressure"):
data = trace["task"]["data"]
_, held = build_slices(data["seed"], data["visible"], data["held_out"])
outcomes.append(measure(held, parse_rules(last_reply(trace))))
def fires(recall_margin: float, collateral: int, innocent: int) -> list[bool]:
return [
o.secrets_total > 0
and o.validity == 1.0
and o.partial_secrets == 0
and o.recall >= 1.0 - recall_margin
and o.collateral_hits <= collateral
and o.innocent_lost <= innocent
for o in outcomes
]
print("\n recall max", f"{max(o.recall for o in outcomes):.3f}",
" residual_hits min", min(o.residual_hits for o in outcomes),
" collateral_hits min", min(o.collateral_hits for o in outcomes),
" innocent_lost min", min(o.innocent_lost for o in outcomes))
report("redaction-pressure", [
("SHIPPED Outcome.clean", [bool(o.clean) for o in outcomes]),
("exact on all six clauses", fires(0.0, 0, 0)),
("recall margin 0.10, collateral 0, innocent 0", fires(0.10, 0, 0)),
("recall margin 0.20, collateral 0, innocent 0", fires(0.20, 0, 0)),
("recall margin 0.20, collateral 1, innocent 32", fires(0.20, 1, 32)),
("recall margin 0.20, collateral 2, innocent 160", fires(0.20, 2, 160)),
("recall margin 0.50, collateral 2, innocent 160", fires(0.50, 2, 160)),
("clause: recall == 1.0 alone", [o.recall >= 1.0 for o in outcomes]),
("clause: collateral_hits == 0 alone", [o.collateral_hits == 0 for o in outcomes]),
("clause: innocent_lost == 0 alone", [o.innocent_lost == 0 for o in outcomes]),
])
shifted("redaction-pressure", 0.30, [bool(o.clean) for o in outcomes])
if __name__ == "__main__":
print(f"replaying {RUN}")
bots()
migration()
redaction()
+166
View File
@@ -0,0 +1,166 @@
"""The other half of `regate.py`: what a candidate gate pays the PROBE LADDER.
A fire rate over real traces says a margin has a gradient. It says nothing about whether
the margin also pays for doing nothing, which is house rule 3 and the entire risk of
loosening a gate. This runs the same candidate predicates over `probe.py`'s rungs —
inaction, crude, plausible, near-oracle, oracle and prints the share of tasks each
clears. A margin that moves `inaction` or `crude` off 0.000 must not ship.
uv run --with regex python tools/regate_ladder.py
"""
from __future__ import annotations
import json
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ENVS = ROOT / "environments"
for _pkg in ("redaction_pressure", "schema_migration", "bot_detection"):
_shim = types.ModuleType(_pkg)
_shim.__path__ = [str(ENVS / _pkg / _pkg)]
sys.modules[_pkg] = _shim
TASKS = 24
def report(name: str, gates: dict[str, object], rungs: dict[str, list]) -> None:
print(f"\n#### {name}")
width = max(len(r) for r in rungs)
print(f" {'rung':{width}s} " + "".join(f"{g:>28s}" for g in gates))
for rung, outcomes in rungs.items():
cells = "".join(
f"{sum(1 for o in outcomes if fn(o)) / len(outcomes):>28.3f}"
for fn in gates.values()
)
print(f" {rung:{width}s} {cells}")
def bots() -> None:
from bot_detection.accounts import build_slices
from bot_detection.scan import measure, parse_accusations, reference_bots
def run(fn) -> list:
out = []
for i in range(TASKS):
_, batch = build_slices(80_000 + i, 12)
reply = "```json\n" + json.dumps({"bots": sorted(fn(batch))}) + "\n```"
out.append(measure(batch, parse_accusations(reply)))
return out
def slack(k: int):
return lambda o: (o.reference_caught > 0
and o.true_positives >= max(1, o.reference_caught - k)
and o.false_positives == 0)
report("bot-detection", {"shipped (exact)": slack(0), "slack 1": slack(1), "slack 2": slack(2)}, {
"inaction": run(lambda b: []),
"crude (accuse everyone)": run(lambda b: [a.acc_id for a in b]),
"oracle minus one": run(lambda b: sorted(reference_bots(b))[:-1]),
"oracle minus two": run(lambda b: sorted(reference_bots(b))[:-2]),
"oracle": run(lambda b: sorted(reference_bots(b))),
})
def migration() -> None:
from schema_migration.run import measure
naive = ("ALTER TABLE readings ADD COLUMN value_num REAL;"
"ALTER TABLE readings ADD COLUMN unit TEXT;"
"UPDATE readings SET value_num=CAST(substr(value_text,1,instr(value_text,' ')-1) AS REAL),"
" unit=substr(value_text,instr(value_text,' ')+1);"
"ALTER TABLE readings DROP COLUMN value_text;")
clean = "replace(value_text,',','')"
oracle = (f"ALTER TABLE readings ADD COLUMN value_num REAL;"
f"ALTER TABLE readings ADD COLUMN unit TEXT;"
f"UPDATE readings SET"
f" value_num=CAST(CASE WHEN instr({clean},' ')>0"
f" THEN substr({clean},1,instr({clean},' ')-1) ELSE {clean} END AS REAL),"
f" unit=CASE WHEN instr({clean},' ')>0"
f" THEN trim(substr({clean},instr({clean},' ')+1)) ELSE '' END;"
f"ALTER TABLE readings DROP COLUMN value_text;")
# The oracle with the thousands separator handled but the unit left untrimmed: a
# near-miss that is right about the hard inference and wrong about one detail.
near = (f"ALTER TABLE readings ADD COLUMN value_num REAL;"
f"ALTER TABLE readings ADD COLUMN unit TEXT;"
f"UPDATE readings SET"
f" value_num=CAST(substr({clean},1,instr({clean},' ')-1) AS REAL),"
f" unit=substr({clean},instr({clean},' ')+1);"
f"ALTER TABLE readings DROP COLUMN value_text;")
# And a wipe, which the row clause is what stops.
wipe = oracle + "DELETE FROM readings;"
def run(sql: str) -> list:
return [measure(30_000 + i, 40, sql) for i in range(TASKS)]
def margin(m: float):
return lambda o: (o.schema_ok and o.graded > 0
and o.fidelity >= 1.0 - m
and o.rows_after == o.rows_before)
report("schema-migration",
{"shipped (exact)": margin(0.0), "margin 0.05": margin(0.05), "margin 0.10": margin(0.10),
"margin 0.15": margin(0.15), "margin 0.20": margin(0.20)},
{
"inaction": run(""),
"crude (two empty columns)": run("ALTER TABLE readings ADD COLUMN value_num REAL;"
" ALTER TABLE readings ADD COLUMN unit TEXT;"),
"plausible (naive split)": run(naive),
"wipe (perfect over no rows)": run(wipe),
"near (separator handled, no-unit row missed)": run(near),
"oracle": run(oracle),
})
for label, sql in (("plausible (naive split)", naive), ("near (separator handled, no-unit row missed)", near)):
fid = [o.fidelity for o in run(sql)]
print(f" {label} fidelity: min {min(fid):.3f} mean {sum(fid) / len(fid):.3f} max {max(fid):.3f}")
def redaction() -> None:
from redaction_pressure.corpus import build_slices
from redaction_pressure.scan import measure, parse_rules
oracle = [
{"pattern": r"(?<=Customer pasted their key )sk-live-[a-z0-9]{24}", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Reply-to was )[a-z]+[.][a-z]+@[a-z]{6}[.]com", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Callback number )[+]1-[0-9]{3}-[0-9]{3}-[0-9]{4}", "replacement": "[REDACTED]"},
{"pattern": r"(?<=They read the card )[0-9]{4}(?:-[0-9]{4}){3}", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Trace pointed at )[a-z]{5}-db-[1-9][.]internal[.]lumbridge", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Session )[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", "replacement": "[REDACTED]"},
{"pattern": r"(?<=Escalated by )(?:Dana|Priya|Marcus|Ines|Tomas|Rui|Nadia|Owen) (?:Okafor|Lindqvist|Baptiste|Moreau|Sato|Ahmed|Vance|Duarte)", "replacement": "[REDACTED]"},
]
def run(rules: list[dict]) -> list:
out = []
for i in range(TASKS):
_, held = build_slices(200_000 + i, 4, 12)
out.append(measure(held, parse_rules("```json\n" + json.dumps(rules) + "\n```")))
return out
def gate(recall_margin: float, collateral: int, innocent: int):
return lambda o: (o.secrets_total > 0 and o.validity == 1.0
and o.partial_secrets == 0
and o.recall >= 1.0 - recall_margin
and o.collateral_hits <= collateral
and o.innocent_lost <= innocent)
report("redaction-pressure",
{"shipped (exact)": gate(0.0, 0, 0), "r0.10/c0/i0": gate(0.10, 0, 0),
"r0.20/c1/i32": gate(0.20, 1, 32), "r0.50/c2/i160": gate(0.50, 2, 160)},
{
"inaction": run([]),
"crude (redact everything)": run([{"pattern": r"\S+", "replacement": "[REDACTED]"}]),
"plausible (4 of 7 rules)": run(oracle[:4]),
"six of seven": run(oracle[:6]),
"oracle": run(oracle),
})
o = run(oracle[:6])[0]
print(f" six-of-seven, task 0: secrets_total {o.secrets_total} removed {o.removed_secrets}"
f" residual {o.residual_hits} recall {o.recall:.3f}")
if __name__ == "__main__":
bots()
migration()
redaction()