Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7abbd24d34 | ||
|
|
ef01bde068 | ||
|
|
3025c4cf85 | ||
|
|
4836d3e644 | ||
|
|
61939676a5 | ||
|
|
3305be4ff7 | ||
|
|
122a858b61 | ||
|
|
8048a61a1b | ||
|
|
4147de0282 | ||
|
|
94ab513eda | ||
|
|
f953c03bd1 | ||
|
|
e4cc2bf1c3 | ||
|
|
162d67a83c | ||
|
|
00d21025ff | ||
|
|
8fe348ca82 | ||
|
|
16f4ee21ae | ||
|
|
7428e91415 | ||
|
|
0defaed9c0 | ||
|
|
d691acf131 | ||
|
|
5a99eb86a5 |
+36
-8
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
redaction-pressure:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -21,11 +21,39 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
- name: Verify lockfile
|
# Asks git, not the disk. `find` was checking the working tree, where a lock file is a
|
||||||
run: uv lock --project environments/redaction_pressure --check
|
# normal by-product of `uv sync` — so the step passed in CI's clean checkout and failed
|
||||||
- name: Install locked environment
|
# on every developer machine that had ever run an eval. The policy is about what is
|
||||||
run: uv sync --project environments/redaction_pressure --frozen
|
# TRACKED: an environment is a library and must not pin its consumers' resolution.
|
||||||
- name: Run environment tests
|
- name: Enforce library lock policy
|
||||||
run: uv run --project environments/redaction_pressure python -m unittest discover -s environments/redaction_pressure/tests -v
|
run: |
|
||||||
- name: Verify documented root entrypoint
|
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
|
run: uv run --project environments/redaction_pressure eval --help >/dev/null
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
outputs/
|
outputs/
|
||||||
|
environments/*/uv.lock
|
||||||
|
|||||||
+281
@@ -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.95–1.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
@@ -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 1–3 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.
|
||||||
@@ -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
|
||||||
|
C1–C4 are built on it. This is also a compute-policy constraint: a non-binding
|
||||||
|
turn budget burns rented GPU hours measuring nothing.
|
||||||
@@ -1,30 +1,154 @@
|
|||||||
# Arena
|
# Arena
|
||||||
|
|
||||||
RL environments from Lumbridge. Apache-2.0, built to the [`verifiers`](https://github.com/PrimeIntellect-ai/verifiers)
|
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
|
An environment is an eval you take the gradient of. That is not a rename — it changes what
|
||||||
what the artifact has to carry. A benchmark's score is its output, so a benchmark can be a
|
the artifact has to carry. A benchmark's score is its output, so a benchmark can be a number
|
||||||
number in a table. An environment's score is the input to the next weight update, so it has
|
in a table. An environment's score is the input to the next weight update, so a defect in one
|
||||||
to be reproducible, gameable-proof, and traceable back to the code that produced it, or the
|
is inherited by the model rather than printed beside it.
|
||||||
model that trained on it inherits whatever was wrong with it silently.
|
|
||||||
|
|
||||||
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
|
## The house rules
|
||||||
examples pass on visible examples. If a held-out slice cannot be generated, the
|
|
||||||
|
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.
|
environment does not measure generalisation and does not ship.
|
||||||
2. **No single-sided reward.** Every reward has a counterweight in the same episode. A
|
2. **No single-sided reward.** Every reward has a counterweight in the same episode. A reward
|
||||||
reward you can max by doing the crude thing is a reward that teaches the crude thing.
|
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
|
3. **A zero floor and a reachable ceiling, demonstrated — per component, not per
|
||||||
that shows an oracle scores 1.0 and inaction scores 0.0. An unreachable component is
|
environment.** Inaction scores 0.0 and an oracle scores 1.0, measured, in `probe.py`. An
|
||||||
dead weight in the gradient; a floor above zero pays for doing nothing.
|
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.
|
||||||
|
|
||||||
| | |
|
## The environments
|
||||||
|---|---|
|
|
||||||
| [`redaction-pressure`](environments/redaction_pressure) | Write redaction rules for a ticket corpus. Source-character provenance makes partial edits residual secrets, while the clean gate requires character-for-character preservation of all non-secret source content. Hostile regexes share one episode budget. |
|
| | 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
|
## Running one
|
||||||
|
|
||||||
@@ -35,16 +159,55 @@ uv run --project environments/redaction_pressure eval @ configs/redaction_pressu
|
|||||||
Run its release gate from the repository root:
|
Run its release gate from the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv lock --project environments/redaction_pressure --check
|
uv sync --project environments/redaction_pressure
|
||||||
uv sync --project environments/redaction_pressure --frozen
|
|
||||||
uv run --project environments/redaction_pressure \
|
uv run --project environments/redaction_pressure \
|
||||||
python -m unittest discover -s environments/redaction_pressure/tests -v
|
python -m unittest discover -s environments/redaction_pressure/tests -v
|
||||||
|
uv run --with regex python probe.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why these, and not benchmark reimplementations
|
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.
|
||||||
|
|
||||||
The Hub is filling with reimplementations, which are worth doing and are not what we have.
|
## Publishing (deferred)
|
||||||
What Lumbridge has is a set of tools aimed at verifiable work. Those gates make good
|
|
||||||
rewards only after their ceiling, floor, adversarial cases, and replay identity are
|
The layout mirrors `verifiers`' own, so an environment can eventually go to the Hub without
|
||||||
committed beside the environment. `redaction-pressure` is the first small contract for
|
a fork. No current environment is published there from this repository. Hub publication is
|
||||||
that path; Bench/Forge integration remains future work rather than an implied dependency.
|
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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -12,3 +12,12 @@ held_out = 12 # records it is scored on
|
|||||||
|
|
||||||
[env.agent.harness]
|
[env.agent.harness]
|
||||||
id = "null" # a pure-trace reward: one completion, no tools, no runtime
|
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 116–174 tokens and averaged 0.0894; the other nineteen wrote
|
||||||
|
4,585–11,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 601–603 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 486–603 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 116–174 tokens and averaged **0.0894**; the
|
||||||
|
other nineteen wrote 4,585–11,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 74–86 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.**
|
||||||
@@ -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 2–12 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 4–6
|
||||||
|
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 4–6, 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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -40,12 +40,16 @@ the identity under `trace.info.environment` and records validation failures unde
|
|||||||
From the Arena repository root:
|
From the Arena repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv lock --project environments/redaction_pressure --check
|
uv sync --project environments/redaction_pressure
|
||||||
uv sync --project environments/redaction_pressure --frozen
|
|
||||||
uv run --project environments/redaction_pressure \
|
uv run --project environments/redaction_pressure \
|
||||||
python -m unittest discover -s environments/redaction_pressure/tests -v
|
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
|
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
|
first-character exploit across all 64 public development tasks, exercise parser and
|
||||||
rule validation, prove deterministic/private split behavior, and interrupt hostile
|
rule validation, prove deterministic/private split behavior, and interrupt hostile
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ requires-python = ">=3.11"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"pydantic==2.13.4",
|
"pydantic==2.13.4",
|
||||||
"regex==2026.7.19",
|
"regex==2026.7.19",
|
||||||
"verifiers==0.3.0",
|
"verifiers==0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ methods apply ``validity`` and therefore pay zero for an invalid submission.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
@@ -156,6 +158,33 @@ class Outcome:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def clean(self) -> bool:
|
def clean(self) -> bool:
|
||||||
|
"""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 (
|
return (
|
||||||
self.removed_secrets == self.secrets_total
|
self.removed_secrets == self.secrets_total
|
||||||
and self.partial_secrets == 0
|
and self.partial_secrets == 0
|
||||||
@@ -328,3 +357,39 @@ def measure(
|
|||||||
budget_seconds=budget.seconds,
|
budget_seconds=budget.seconds,
|
||||||
budget_elapsed_seconds=budget.elapsed,
|
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))
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field, model_validator
|
from pydantic import Field, model_validator
|
||||||
@@ -21,7 +19,7 @@ from redaction_pressure.provenance import (
|
|||||||
environment_source_sha256,
|
environment_source_sha256,
|
||||||
trace_provenance,
|
trace_provenance,
|
||||||
)
|
)
|
||||||
from redaction_pressure.scan import MAX_RULES, ParsedRules, Rule, measure
|
from redaction_pressure.scan import MAX_RULES, measure, parse_rules
|
||||||
|
|
||||||
Split = Literal["train", "dev", "private_eval"]
|
Split = Literal["train", "dev", "private_eval"]
|
||||||
PUBLIC_SPLIT_BASES = {"train": 100_000, "dev": 200_000}
|
PUBLIC_SPLIT_BASES = {"train": 100_000, "dev": 200_000}
|
||||||
@@ -46,37 +44,6 @@ Example:
|
|||||||
[{{"pattern": "\\\\bfoo-\\\\d+\\\\b", "replacement": "[REDACTED]"}}]
|
[{{"pattern": "\\\\bfoo-\\\\d+\\\\b", "replacement": "[REDACTED]"}}]
|
||||||
```"""
|
```"""
|
||||||
|
|
||||||
_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."""
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def _private_key(env_name: str) -> bytes:
|
def _private_key(env_name: str) -> bytes:
|
||||||
value = os.environ.get(env_name, "")
|
value = os.environ.get(env_name, "")
|
||||||
|
|||||||
Generated
-3494
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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user