GM-Bench is a small, deterministic sports general-manager benchmark inspired by front-office dynasty games. Agents manage a fictional hockey-style league over multiple seasons by making roster, trade, free-agency, draft, and lineup decisions through a JSON-compatible API.
Explore the live leaderboard, read the phase-one findings, or download the tagged evidence release.
Eight pre-registered model systems produced strict sota-v2 rows under one
frozen 4,096-token API lane. Every eligible model trailed the transparent
pick-trader heuristic (411.619), and all eight model uncertainty intervals
form one overlapping tier—so the evidence supports no ordinal “best model”
claim. Muse Spark 1.1 had the highest observed model mean at 231.851. Grok 4.5
and Mistral Medium 3.5 completed but were excluded from the headline table for
incomplete usage/cost coverage.
Read the phase-one findings, inspect the generated analysis, or follow the clean-clone reproduction guide.
GM-Bench includes:
- A seeded league simulator with aging, development, injuries, contracts, salary-cap pressure, free agency, trades, drafts, playoffs, and team standings.
- Lineups that matter: the players you dress set team strength, and only dressed young players develop at full speed.
- A competitive draft: opponents pick in inverse-standings order, so top prospects are gone before a strong team's turn.
- Competitive free agency: opponent front offices act after every phase, fill roster needs, and grab standout free agents (waiving their weakest player to make room), so waiting on a signing carries real risk.
- A realistic trade market: partners privately re-value players each season, accept a limited number of trades, and rosters cannot be stripped below the league minimum. At the trade deadline, opponents also make one-for-one trades among themselves, visible in the transaction feed.
- Baseline agents: random, conservative, win-now, rebuild, value,
shrewd(cap hygiene plus development-aware lineups), andstrategic, which also scouts, evaluates incoming offers, and persists a plan memo. The strongestpick-traderreference adds cap-aware future-pick acquisitions, while the red-teamexploitcanary replays known-degenerate strategies. - A scoring model that rewards wins, championships, future assets, prospects, and cap health, reported as a strategy score with protocol (invalid-action) penalties broken out separately.
- A CLI runner for single-agent runs and baseline comparisons.
- An external-process protocol for plugging in LLM agents, including a
persistent
memoscratchpad for carrying multi-season plans between decision points. - Protocol v2 (
gm-bench-v2): four phases per season (including midseason injuries and waivers), multi-round decision windows with query actions and same-turnaction_results, trade negotiation (accept_trade_offer/reject_trade_offer/counter_trade_offer), tiered observations (full/summary), and optional persistent subprocess sessions per episode.
Official results use the leaderboard preset (8 held-back seeds × 5 seasons,
full baseline panel; GM_BENCH_PRIVATE_SEEDS swaps in a private panel). Every
run records usage telemetry — tokens, dollar cost (from gm_bench/pricing.json
or adapter-reported cost), and per-decision latency — alongside scores. Results
intended for serious frontier-model comparison should pass the stricter
sota-v2 validator in docs/production_benchmark.md.
Runs also stamp a seed-panel hash so private held-out panels can be verified
locally (integrity for a known panel, not secrecy). Use redact-result before
publishing private-panel artifacts so the seed list is not committed.
OPENAI_API_KEY=... python -m gm_bench model --provider openai --model gpt-5.4 \
--preset leaderboard --repeats 3 --json > /tmp/openai-gpt-5.4.raw.json
python -m gm_bench validate-result /tmp/openai-gpt-5.4.raw.json \
--policy sota-v2
python -m gm_bench compact-result /tmp/openai-gpt-5.4.raw.json \
--output results/leaderboard/openai-gpt-5.4.json --policy sota-v2
python -m gm_bench validate-contract
python -m gm_bench calibrate-score --json
python web/scripts/build_leaderboard.py # refresh web/src/data/leaderboard.json
cd web && bun install && bun run dev # local siteThe site in web/ deploys to GitHub Pages automatically on pushes to main
(.github/workflows/pages.yml).
The frozen phase-one headline API lane uses the common 4,096-token safety ceiling in
config/sota_v2_lane.json, with native-minimum
reasoning and actual token/cost efficiency reported beside score. Reasoning is
disabled where optional and set to the lowest OpenRouter-supported effort where
mandatory. The earlier four-cap
study in config/output_budget_sweep.json is
retained for auditability but retired as a publication prerequisite. The common
smoke and eight-model minimum gates are now satisfied; generated site data
reports publishable_ranking: true. That flag permits the evidence table to be
shown, but the overlapping uncertainty tier still forbids an ordinal winner
claim. Coding-harness rows remain separate from the API headline lane.
The contract bumped to sota-v2 (actions-v2 / sim-v2, new contract
fingerprint) for two fixes and one reporting requirement:
- Scout contract fixed. The scaffold prompt has always documented
{"type":"scout","player_id":88}and{"type":"scout","prospect_id":1010001}, but thesim-v1simulator only readplayer_idand rejectedprospect_idwith "no such player or prospect to scout." Models that followed the prompt's own example got a misleading error instead of intel — 1,124 times in one 24-episode run — while scripted baselines, which always useplayer_id, scouted successfully.scoutnow accepts either key. Seeresults/leaderboard/archive-v1/README.mdfor the affected rows. failed_queriesis now first-class. Failedscout/inspect_team/inspect_player/list_free_agentscalls are non-penalized (querying is free), but undersota-v1they were also invisible: they appeared nowhere in episode or run summaries.failed_queriesis now counted alongsideillegal_actionsin every summary and comparison block, so a run full of misfired lookups is visible even though it costs no score.- Published tables must show compute alongside score. Score tracks
tokens/decision almost monotonically across published rows, so a bare score
is not a fair comparison. Any published table or claim must show the
execution lane (direct API vs. a coding-agent CLI harness — Claude Code,
Codex, Cursor, opencode), mean tokens/decision, and cost next to the score.
The leaderboard site enforces this: every model row carries
laneandtokens_per_decision.
python -m gm_bench run --agent value --seeds 1 2 3 --seasons 5
python -m gm_bench compare --agents random conservative win-now rebuild value --seeds 1 2 3 --seasons 5
python -m gm_bench evaluate --agent value --seeds 1 2 3 4 5 --seasons 5
python -m gm_bench describe --seed 42
python -m gm_bench guiThe fastest way to score an LLM with objective simulator metrics is the model
command. It wires a built-in provider adapter, runs the candidate on your seed
panel, and compares against scripted baselines with paired lift statistics.
# Quick smoke test (1 seed, 1 season, 4 LLM calls)
OPENAI_API_KEY=... python -m gm_bench model \
--provider openai \
--model gpt-5.4-mini \
--preset smoke
# Model adapters run serially by default (safe for rate-limited CLIs).
# Only raise concurrency if the provider can take it: --workers N or GM_BENCH_WORKERS=N.
# Standard panel (3 seeds, 3 seasons)
python -m gm_bench model \
--provider openai \
--model gpt-5.4-mini \
--preset standard \
--json
# Local Ollama
python -m gm_bench model \
--provider ollama \
--model gemma4:e4b \
--preset smoke \
--profile tinyReproducible JSON configs live in examples/ (the config supplies the provider,
so no extra flags are needed):
OPENAI_API_KEY=... python -m gm_bench model --config examples/benchmark.smoke.jsonList supported providers:
python -m gm_bench providersDirect OpenAI, Anthropic, Gemini, and OpenRouter setup—including reproducible
OpenRouter routing controls—is documented in
docs/api_provider_lanes.md. API lanes are
recorded separately from coding-harness and local-inference results.
Canonical GPT-5.6 Luna smoke (serial, upstream-pinned, JSON-constrained, and atomically persisted):
python3 -m gm_bench model --config examples/openrouter.luna.smoke.jsonPrecompute baseline scores so repeated model runs skip re-simulating scripted agents:
python -m gm_bench cache-baselines --preset benchmarkCached baseline episodes are keyed by a fingerprint of the simulator, scoring,
and agent source files, so any change to the simulation automatically
invalidates the cache — stale baselines can never bias lift statistics. Pass
--no-baseline-cache to force fresh baseline runs, or set
GM_BENCH_BASELINE_CACHE=/path/to/cache.json to relocate the cache file.
Presets:
| Preset | Seeds | Seasons | LLM calls per seed | Total LLM calls |
|---|---|---|---|---|
smoke |
1 | 1 | 4 | 4 |
standard |
3 | 3 | 12 | 36 |
benchmark |
5 | 5 | 20 | 100 |
Every preset pins the compact observation profile so scores from the same
preset are comparable across providers (provider defaults otherwise differ —
Ollama would see a tiny observation while OpenAI sees compact, which are
different questions). Pass --profile tiny explicitly for local models that
need the smaller observation, and treat those scores as their own track.
Result payloads from run, evaluate, and model include a run_info
provenance block recording the resolved provider, model, observation profile,
timeout, preset, benchmark version, and timestamp, so logged runs stay
attributable and comparable after the fact.
Use --verbose (or GM_BENCH_VERBOSE=1) to print per-decision progress while
model episodes run.
Add --repeats N (CLI or "repeats" in a config file) to run the candidate N
times per seed. The simulator is deterministic, so repeats isolate the model's
own sampling noise: paired lift statistics use the per-seed mean, and the
summary reports within_seed_score_stddev alongside the across-seed
score_stddev. evaluate also reports an exact sign-flip permutation p-value
on the paired lift (sign_flip_p_value), which is more trustworthy than the
bootstrap interval at 3-5 seeds — note the exact floor of 2 / 2^n means a
3-seed run can never show p below 0.25.
Start the browser GUI:
python -m gm_bench gui --port 8765Then open:
http://127.0.0.1:8765
The GUI can:
- Run built-in agents in
run,compare, andevaluatemodes. - Automatically log those runs into the SQLite database.
- Show KPI totals for runs, episodes, best score, mean score, and rejected-action rate.
- Explain the latest run with candidate-vs-baseline lift, score, wins, titles, and illegal-action totals.
- Compare aggregate agent standings by mean score, best score, score range, wins, titles, and legality.
- Browse score history, the episode leaderboard, recent runs, and transaction audit feed.
The GUI intentionally does not launch external model-backed agents yet; use the CLI for Codex, Claude, opencode, Ollama, and API-backed agents where cost and provider behavior should be explicit.
A forward-facing landing site (Vite + React + TypeScript, managed with Bun)
lives in web/. It renders reference baseline results,
the decision-loop and protocol overview, and a quickstart from a committed
snapshot of real benchmark output, and builds to a fully static bundle:
cd web
bun install
bun dev # local development
bun run build # static production build in web/dist/The default episode uses protocol v2 (benchmark: "gm-bench-v2" in every
observation). Each season has four decision phases:
preseasonmidseason— partial-season games, standings update, injuries, and a waiver wire (claim_waiver)trade_deadline— opponent trade proposals inincoming_offersdraft
By default the runner launches external agents once per decision point: one
observation JSON object on stdin, a JSON action list (or {"actions": [...], "usage": {...}} envelope) on stdout. For a persistent subprocess that stays
alive across the whole episode, use a session-capable adapter with
GM_BENCH_SESSION=1 (see examples/gm_agent_common.py); the runner exchanges
line-delimited start / observation / action_results / end events and
records usage and latency for every interaction round.
python -m gm_bench run --agent-cmd "python examples/external_agent.py" --seeds 1 --seasons 3
python -m gm_bench run --agent-cmd "python examples/openai_compatible_agent.py" \
--seeds 1 --seasons 1Within each phase the agent may take up to five interaction rounds. Query actions
return results in the same window; the runner echoes them as action_results on
the next round (with interaction_round incremented):
inspect_team— roster and cap detail for one teaminspect_player— full public card for one playerlist_free_agents— filtered free-agent listscout— spend a scouting point for a near-truetrue_potentialreading (persisted inscout_reports)
Send end_turn to stop gathering information and close the window. Core roster
moves (sign_free_agent, trade, draft, etc.) apply immediately and also
end the multi-round loop unless followed by more query actions in the same
response. Review action_results before repeating failed moves.
Observations carry observation_tier: full (default for built-in scripted
agents) or summary. Summary tier replaces long lists with compact
*_summary blocks (free_agents_summary, draft_class_summary,
waiver_wire_summary, …) and a hint to use query actions. External agents
receive the tier configured for the run; use inspect/list/scout before
committing when on summary tier.
Each action is an object. Core moves:
{"type": "sign_free_agent", "player_id": 123, "years": 2, "salary": 4.2}
{"type": "trade", "partner_team_id": 3, "give_player_ids": [11], "receive_player_ids": [87], "give_pick_seasons": [], "receive_pick_seasons": [4]}
{"type": "draft", "prospect_id": 9001}
{"type": "set_lineup", "player_ids": [1, 2, 3, 4, 5, 6]}
{"type": "claim_waiver", "player_id": 55}
{"type": "memo", "text": "rebuild through season 3, then spend cap room"}Trade negotiation (when incoming_offers is non-empty):
{"type": "accept_trade_offer", "offer_id": "offer-3-1-trade_deadline-12-34"}
{"type": "reject_trade_offer", "offer_id": "offer-3-1-trade_deadline-12-34"}
{"type": "counter_trade_offer", "offer_id": "offer-3-1-trade_deadline-12-34", "give_player_ids": [2], "receive_player_ids": [9]}accept_offer and decline_offer remain accepted aliases. Query and control:
{"type": "inspect_team", "team_id": 3}
{"type": "scout", "player_id": 88}
{"type": "end_turn"}Invalid actions are ignored and penalized. Observations intentionally include noisy public ratings, not hidden true potential, so agents must reason under uncertainty. Trade acceptance additionally uses hidden per-partner valuation noise (re-rolled each season), so offers can only be estimated, not solved.
Rejections come in two kinds, and only one is penalized:
- Protocol violations (malformed actions, invented IDs, cap or roster
violations) count as
illegal_actionsand cost score viaprotocol_penalty. - Rejected offers — a trade the partner declines as too light, or a
free-agent offer below the player's hidden reservation price — are
legitimate negotiation under hidden information. They are counted and
reported as
rejected_offersbut cost nothing. To keep free probing from solving the hidden values, a counterparty breaks off talks for the rest of the decision window afterrejected_offer_limit_per_windowdeclines.
Free agents accept salaries down to a hidden reservation fraction of their
asking price (uniform within fa_reservation_range, re-rolled each season),
so bidding is a real decision: offering the full ask always works, shading
below it saves cap but risks a decline.
Key mechanics agents should know:
set_lineupchooses the 18 players who actually dress. It drives team strength directly, and players outside the lineup develop at half speed. Stale lineups (after trades or expiring contracts) are repaired automatically at simulation time.- Midseason simulates roughly 35% of the regular season, updates standings
and player morale, generates injuries, and populates
waiver_wirewith players opponents waived.claim_waiveris only legal in that phase. - Each opponent accepts at most
trade_limit_per_partnertrades per season, and no trade may shrink a roster belowroster_min. Opponents may send incoming trade offers at the deadline; judge them with public stats before accepting — every offer looks fair to the sender's hidden valuation. - Opponents draft in inverse-standings order around your slot, so the draft class you see at your pick already reflects earlier selections.
- Opponent front offices act after every phase: they sign free agents (both
to fill needs and to poach clear upgrades) and swap players among
themselves at the trade deadline. A free agent visible now may be gone at
the next decision point, and opponent trades appear in
recent_transactionsas market signal. memostores up to 2000 characters that are echoed back in every future observation — the only cross-decision memory for stateless one-shot agents. Persistent-session adapters can also keep in-process state between rounds.textmust be a JSON string; a null or missingtext(allowed by the structured-output wrapper schema, where every field is nullable) is rejected as an invalid action.
When a model-backed adapter cannot use the model's output (timeout, crash,
unparseable JSON, missing API key), it substitutes fallback actions marked
with a model_error key (adapter-level failures from the runner carry an
error key). The runner counts each such decision as a failed decision and
reports decisions, failed_decisions, decision_failure_rate, and
memo_writes per episode and in run summaries, plus per-episode
mean_decision_seconds / max_decision_seconds wall-time latency. A model
that never produces usable output no longer silently scores like the fallback
policy — the failure rate is right next to the score.
By default the fallback still plays a safe turn (best-value draft pick plus a
legal lineup) so one flaky decision doesn't ruin an episode. Set
GM_AGENT_STRICT=1 to make the fallback a pure noop, so the score reflects
only actions the model itself produced.
JSON Schema definitions for the protocol live in schemas/:
gm_observation.schema.json— observation sent to agentsgm_action_list.schema.json— action array on stdoutgm_actions.schema.json— structured wrapper for Codex/Claude adapters
Use evaluate for benchmark-style scoring. It runs the candidate and a baseline
panel on the same seeds, then reports:
candidate_mean_score: the raw objective score.candidate_mean_strategy_score/candidate_protocol_penalty: the score split into roster-management quality and invalid-action penalties, so strategy skill is not conflated with JSON discipline.baseline_panel_mean_score: the mean score of selected scripted baselines.score_lift: candidate score minus the baseline-panel score.score_lift_pct: percent lift over the baseline panel.- illegal action counts.
Because every agent plays identical seeds, the report also includes a paired
block that differences the candidate against the baselines per seed. On a
handful of seeds this cancels most of the league-generation luck and gives a
far lower-variance read on skill than comparing unpaired means:
per_seed: candidate score, baseline-panel score, and lift for each seed.paired_lift_mean: mean of the per-seed lifts (equalsscore_lift, but with a variance you can now quantify).paired_lift_ci95: a deterministic bootstrap 95% confidence interval on the paired lift.significant_at_95: whether that interval excludes zero.candidate_seed_win_rate: fraction of seeds where the candidate beat the panel.best_baseline: the strongest single baseline (by mean score) with the candidate's paired lift and seed win rate against it — an honest bar to clear, since the panel average is dragged down by weak baselines likerandom.
This keeps league-seed luck from dominating a small benchmark run.
run, compare, and evaluate automatically log to SQLite at:
data/gm_bench.sqlite
Override the path:
GM_BENCH_DB=/tmp/gm-bench.sqlite python -m gm_bench run --agent value --seeds 1 --seasons 1Disable logging:
python -m gm_bench run --agent value --seeds 1 --seasons 1 --no-logUseful queries:
sqlite3 data/gm_bench.sqlite 'select created_at, command, agent, summary_json from runs order by created_at desc;'
sqlite3 data/gm_bench.sqlite 'select agent, seed, final_score, wins, championships, illegal_actions from episodes order by final_score desc;'
sqlite3 data/gm_bench.sqlite 'select phase, accepted, message, action_json from transactions where accepted = 0;'The Ollama adapter reads the benchmark observation from stdin, prompts a local model, parses JSON actions, and falls back to legal value-style moves if parsing fails.
Installed local models can be checked with:
ollama listExample:
python -m gm_bench run \
--agent-cmd "python examples/ollama_agent.py" \
--agent-timeout 240 \
--seeds 1 \
--seasons 1 \
--jsonChoose a model explicitly:
OLLAMA_MODEL=gemma4:e4b python -m gm_bench run \
--agent-cmd "python examples/ollama_agent.py" \
--agent-timeout 240 \
--seeds 1 \
--seasons 1Useful knobs:
OLLAMA_MODEL: local model name. In this workspace,gemma4:e4bandqwen3.5:latestwere installed.GM_AGENT_PROFILE=tiny|compact: smaller prompts are better for local models. The Ollama adapter defaults totiny.OLLAMA_TRANSPORT=cli|http: defaults tocli;httpuses the Ollama REST API.OLLAMA_THINK=0|1: the adapter defaults to disabling Ollama thinking mode so thinking-first local models return JSON actions instead of reasoning prose. SetOLLAMA_THINK=1to opt a model back into thinking.
Use the generic chat-completions adapter directly:
LLM_API_KEY=... \
LLM_MODEL=gpt-4.1-mini \
python -m gm_bench evaluate \
--agent-cmd "python examples/openai_compatible_agent.py" \
--agent-timeout 120 \
--baselines random conservative win-now rebuild \
--seeds 1 2 3 \
--seasons 3Or use the built-in provider shortcut:
OPENAI_API_KEY=... python -m gm_bench model --provider openai --model gpt-5.4-mini --preset standardFor non-OpenAI-compatible providers, set:
LLM_API_BASE=https://provider.example/v1
LLM_API_KEY=...
LLM_MODEL=provider-model-nameThe opencode adapter uses opencode run and whatever provider/model opencode is
configured to use:
OPENCODE_MODEL=opencode/deepseek-v4-flash-free python -m gm_bench run \
--agent-cmd "python examples/opencode_agent.py" \
--agent-timeout 240 \
--seeds 1 \
--seasons 1 \
--jsonIn this workspace, opencode models reported:
opencode/big-pickleopencode/deepseek-v4-flash-freeopencode/mimo-v2.5-freeopencode/nemotron-3-ultra-freeopencode/north-mini-code-free
Note: opencode-backed runs may send benchmark observations/prompts to an external model provider, depending on your opencode configuration.
The Codex adapter uses codex exec with a read-only sandbox, no approvals,
ephemeral sessions, and the shared GM action JSON schema:
python -m gm_bench run \
--agent-cmd "python examples/codex_agent.py" \
--agent-timeout 180 \
--seeds 1 \
--seasons 1 \
--jsonPick a Codex model:
CODEX_MODEL=gpt-5-mini python -m gm_bench evaluate \
--agent-cmd "python examples/codex_agent.py" \
--agent-timeout 180 \
--baselines random conservative win-now rebuild \
--seeds 1 2 3 \
--seasons 3Use Codex in local Ollama/OSS mode:
CODEX_OSS=1 CODEX_LOCAL_PROVIDER=ollama CODEX_MODEL=gemma4:e4b python -m gm_bench run \
--agent-cmd "python examples/codex_agent.py" \
--agent-timeout 240 \
--seeds 1 \
--seasons 1The Claude adapter uses claude -p with no tools, no session persistence, and
the shared GM action JSON schema.
Quota warning (2026-07-11): parallel episode fan-out burned a full Claude Pro
5h usage limit in ~5 minutes and produced an invalid leaderboard row. Model
adapters default to serial, but GM_BENCH_WORKERS / --workers can override that
— for Claude always force serial, and never raise workers for a subscription CLI:
# Smoke first (4 decisions) before any full panel
GM_BENCH_WORKERS=1 CLAUDE_EFFORT=medium python -m gm_bench model \
--provider claude --model sonnet --preset smoke --verbose --json
# Leaderboard: serial only. Budget a whole 5h window — this is hours, not minutes.
GM_BENCH_WORKERS=1 CLAUDE_EFFORT=medium python -m gm_bench model \
--provider claude --model sonnet \
--preset leaderboard --repeats 3 \
--agent-timeout 300 --verbose --json --no-logDirect adapter smoke without the model command:
GM_BENCH_WORKERS=1 CLAUDE_MODEL=sonnet python -m gm_bench run \
--agent-cmd "python examples/claude_agent.py" \
--agent-timeout 180 \
--seeds 1 \
--seasons 1 \
--jsonFor a more controlled spend during early tests:
GM_BENCH_WORKERS=1 CLAUDE_MODEL=sonnet CLAUDE_MAX_BUDGET_USD=0.25 \
python -m gm_bench evaluate \
--agent-cmd "python examples/claude_agent.py" \
--agent-timeout 180 \
--baselines random conservative win-now rebuild \
--seeds 1 2 3 \
--seasons 3The invalid parallel burn had a 0.873 decision-failure rate. Its multi-megabyte artifact is not retained; the operational lesson and serial guard remain here.
Model panels checkpoint every completed seed/repeat under
data/model_checkpoints/ and abort after two consecutive adapter failures.
Use --resume to continue the matching checkpoint, or --resume-from PATH
to reuse only zero-failure episodes from an earlier result. Resume sources must
match the current provider, model, profile, benchmark contract, and prompt
scaffold. --preflight-only checks provider authentication/tool availability
without making a model request.
Note: Codex cloud/API mode, Claude Code, and opencode-backed runs may send benchmark observations/prompts to external model providers. Local Codex OSS mode with Ollama stays local.
The Gemini adapter calls Google's native generateContent API with a
GEMINI_API_KEY (or GOOGLE_API_KEY) and records native token usage. Start with
the stable gemini-3.5-flash lane and keep the benchmark serial:
GEMINI_API_KEY=... GM_BENCH_WORKERS=1 python -m gm_bench model \
--provider gemini --model gemini-3.5-flash \
--preset smoke --verbose --json --no-log
GEMINI_API_KEY=... GM_BENCH_WORKERS=1 python -m gm_bench model \
--provider gemini --model gemini-3.5-flash \
--preset leaderboard --repeats 3 \
--agent-timeout 180 --verbose --json --no-log \
> results/leaderboard/gemini-3.5-flash.jsonThis MVP is designed to test long-horizon strategic planning, resource allocation, numeric reasoning, uncertainty management, and coherent memory over multi-season episodes. It avoids GUI automation and avoids real player names so evaluation focuses on decision quality rather than browser-control reliability or memorized sports data.
Multi-season coherence is testable in practice because external agents are
stateless between decision points by default: the memo action is the primary
channel for carrying a plan forward, so agents that form and follow multi-season
plans are distinguishable from agents that re-derive greedy moves each call.
Persistent-session adapters can additionally keep in-process memory across
rounds within an episode.
Model-backed scores are also attributed: adapters tag substituted actions
(model_error/error), and every result reports how many decision points the
model actually played versus how many fell back to the adapter's safety policy
(fallback_decisions and fallback_decision_rate). This keeps a weak model
from being silently carried by its fallback heuristics.
The strategic and pick-trader references ensure that scouting, offer
responses, pick trading, and memo persistence are exercised by accepted actions
across the official seed panel; validate-contract fails if that coverage or
their clean-play calibration disappears.
The exploit baseline exists to keep the benchmark honest. It replays the
degenerate strategies that used to dominate (trade value-pumping and free-agent
hoarding); a regression test asserts it stays below the honest value
baseline, so any rules change that re-opens an exploit fails CI instead of
silently inflating scores.
See docs/benchmark_spec.md for the MVP design, landscape notes, and suggested next steps. See docs/scoring_calibration.md for how the objective score is weighted and normalized.