Skip to content

Latest commit

 

History

History
1639 lines (1298 loc) · 96.5 KB

experiments.md

File metadata and controls

1639 lines (1298 loc) · 96.5 KB

Experiment log

A running record of what was changed, why, how it was measured, and what happened. Kept in docs/ and not logs/ because logs/ is gitignored — this file is meant to survive for the next version.

Data source for every "field" number below: scripts/mine_daily.py over sampled Kaggle daily episode dumps. Fingerprint CSVs live in logs/ and are regenerable.


The gate problem is solved: rank against a calibrated ladder

scripts/rank_ladder.py plays the agent seat-swapped against Rayk Kretzschmar's reference ladder — ten agents whose relative strength is already measured and documented. Rungs 0-5 are vendored under opponents/ladder/ (MIT, LICENSE and NOTICE retained); rungs 6-9 embed the shared public meta line and are deliberately not vendored, because the dataset's own NOTICE asks that submissions be built on 0-5. --fetch pulls them into reference/ladder/ for measurement only.

uv run python scripts/rank_ladder.py --fetch
uv run python scripts/rank_ladder.py --episodes 6

Rungs 0-5 share a byte-identical scheduler and differ only in a POLICY dict, so a gap between them is an economic decision and nothing else. That is what none of our previous gates had.

v0.1.1 placement, 6 episodes per rung, seats alternated

tier agent result ours theirs margin
0 fallow_finn 6W-0L $84,746 $3,000 +$81,746
1 wheat_walter 6W-0L $71,871 $6,959 +$64,912
2 rotation_rosa 6W-0L $74,802 $12,998 +$61,804
3 homestead_hana 6W-0L $75,366 $13,530 +$61,836
4 melon_mateo 6W-0L $68,433 $19,899 +$48,534
5 rancher_rita 6W-0L $50,454 $22,583 +$27,871
6 broker_bea 0W-6L $41,078 $126,176 −$85,098
7 ledger_lena 0W-6L $40,711 $126,234 −$85,524
8 slotter_silas 0W-6L $40,766 $124,639 −$83,873
9 closer_cleo 0W-6L $30,168 $108,450 −$78,283

RUNG: between tier 5 and tier 6. 36/60 overall.

This is the cleanest measurement in the repo. It also reframes the plateau: our MAX_COWS = 9 / MAX_SHEEP = 6 livestock core is tier 5 (rancher_rita is 10 cows / 6 sheep and banks ~$53k against starter; we bank $55-56k), and we beat her by +$27,871 so we are genuinely above that rung. The reference league has Rita losing to broker_bea by −$128,041 where we lose by −$85,098, so the ~$56k plateau is not a bug in our agent — it is the ceiling of the whole authored-strategy band.

Why tiers 6-9 are unreachable by tuning: they are not strategies

Every rung above 5 runs the same production plan — a base85-encoded _TRACE field that the dataset's NOTICE describes as "the shared public meta line", found as identical 712-turn farmer/hand sequences across 104 distinct teams in 530 downloaded replays. The four top rungs differ only in their market layer.

The current #1 on the leaderboard is the same shape. Its notebook is 25/27 Strict-Future | v27 Midgame Meta Reset, and the published artifact decodes to 20,813 bytes with SHA-256 f48c2116… (verified). It contains:

  • _LEGACY_ACTIONS — one hardcoded 719-step action list, used in both seats, credited to team Ezzzzzekki's public replay of episode 91493566 seat 0;
  • _weed_repair_action — if a scripted PLANT/BUILD_PASTURE lands on a WEED, emit DIG instead, replay the intended action next step, then shift the next 8 steps of the trace by one;
  • _rank_sell_slots — reorder the SELL orders within their existing slots by price impact × a bounded town-demand urgency term. Nothing about production changes;
  • _align_hands — pad or truncate the hands list to the live hand count.

That is the entire agent. There is no planner, no scheduler, no opponent model. The notebook's own ablation puts most of the gain on the route and about +$819 to +$1,115 of margin on the sell layer.

So the competition above ~$56k is not a strategy competition. It is route selection plus a thin repair layer, and the ~$130k the top rungs bank against us is what a hand-selected tape achieves. That is the honest explanation for eleven consecutive failed A/Bs: we were tuning a closed-loop planner against opponents that do not plan.

Two corrections from the reference material

Engine version. The README claimed verification against kaggle-environments 1.32.3. We actually run 1.32.6, and so does the competition (Kaito's notebook records research_engine_version: 1.32.6). This matters more than it looks: the ladder's NOTICE warns that the same game on the same seed pays out completely differently across releases — Rancher Rita banks 28,370 on 1.32.3 and 9,002 on 1.32.6. All ladder numbers above are ours, measured on 1.32.6, so they are internally consistent; the dataset's own published banks are 1.32.3 and are not comparable to them.

Shop demand, not curve steepness, sets the realised price. _town_consume drains market inventory every townShopSellInterval for each unlocked shop that lists the product, which is what holds prices up all season:

product shops demanding it base shop demand/day
WHEAT 5 $25 30
STRAWBERRY 4 $120 24
MILK 3 $160 18
EGG 2 $50 12
CARROT / TOMATO 2 $35 / $60 12
WOOL 1 $200 12
MELON 0 $250 0

Melon appears in no shop at all, so nothing ever drains it and its price only falls — it is genuinely a one-shot pot, which is why melon_mateo (tier 4) meters sales into 12-unit lots and why buying more land does not help him. This is the mechanism behind our own measurement that cutting MELON_TILE_TARGET 9→3 lost 0W-30L: the first ~$26k of the pot is real money, and the tiles were collecting it.

It also independently confirms two of our results. The dataset author tested adding geese to Rita in 32 configurations and all 32 lost, matching our EGG_ENGINE ablation (−62.3%, 0/30). And Rita's documented failure mode — "with a 4-day feed reserve instead of 16 she goes bankrupt on day 3 and loses to tier 1" — is the same effect as our LAND_CASH_BUFFER 1961→400 experiment (23.3%, and 5W-25L in v0.1.0).


Measurement: what counts as evidence

--opponent baseline is not a gate. v0.0.9 reports $64,351 against starter and averaged $50,957 across its 10 real Kaggle seats, against a field median of $83,606 and a top decile of $132,689. starter and adaptive do not contest pots or force tempo, so beating them measures survival. Keep it only as a reliability smoke test (crashes / timeouts / invalid statuses / p95 compute).

A leaderboard replay is not a gate either. opponents/leaderboard_replay.py had three bugs and is now fixed, but even fixed it is open-loop: replayed against a different opponent the $187,844 winner scores $28, because the frontier strategy runs at $8 cash on day 4 and cannot absorb a $96 perturbation. See README "Opponents".

The gate that actually works — fingerprint against real opponents, within episode. Download a live submission's episodes and run scripts/mine_daily.py over them, then compare our per-seat fingerprint to the opponent we faced in that same episode. Both seats share the episode's shop draw, so the confound below cancels, and the opponent is a real strategy rather than a copy of ourselves. This is what finally explained v0.1.0.

uv run python scripts/mine_daily.py logs/live_v0_1_0 --out logs/fingerprints_live_v0_1_0.csv

Head-to-head against the frozen previous version, 30 paired seeds, seats alternated, is necessary but not sufficient. It reliably catches regressions and it correctly rejected six of the eight v0.1.0 candidates. It cannot catch a wrong strategy class, because a frozen copy of ourselves shares our blind spots — v0.1.0 won it 23W-7L and moved the live score by nothing. Use it as a veto, never as proof.

Do not compare against the field's cash-sorted top decile. That was the third gate proposed here and it is invalid; see the confound below. The "Diagnosis" table further down is preserved as a record of the mistake, not as a target list.

Kaggle itself is the third gate, with a budget of 5 submissions/day. Track a live submission with api.competition_list_episodes(sub_id); ~9-12 episodes accumulate within a few hours, which is enough to see a 2× move and nowhere near enough to see 5%.

The confound that invalidates cash-sorted cohort analysis

Read this before deriving any target from a "top decile does X" table, including the ones further down this file.

Cash rank in a Kaggriculture episode is mostly an episode-level dice roll, shared by both players. Measured on 60 episodes of the 2026-08-10 dump:

  • Within an episode, the two seats' realised strawberry price differs by a mean of $7.20/unit.
  • Between episodes, the stdev of episode-mean strawberry price is $56.80, range $25 – $227.

The driver is town["unlocked_shops"].append(rng.choice(sorted(SHOPS))) — shops are drawn at random with replacement every townShopUnlockInterval (3) days, up to MAX_SHOP_INSTANCES (8). Four of the eight shop types buy strawberry, so how much strawberry demand an episode has is a coin-flip sequence:

strawberry-buying shops unlocked strawberry $/unit mean cash
1 $37 $67,902
2 $64 $68,128
3 $82 $73,361
4 $98 $82,790
5 $144 $100,001
6 $190 $107,717

So sorting 300 seats by final cash sorts them mostly by luck, and every cohort ends up with the same mix of strategies. That is exactly what the composition tables showed — top decile and bottom quartile identical on productive ops, pastures, cows, coops, hands, tiles and FERTILIZE ops — and it was misread here as "the field has converged on one strategy". It had not; the cohorts are the same strategies under different dice.

Consistent with that, correlations against final cash over 300 seats:

variable corr with cash
attributed gross revenue +0.822
rev_STRAWBERRY +0.666
rev_MILK +0.634
hands at end +0.130
ops_productive +0.095
sold_STRAWBERRY (volume) +0.088
op_WATER +0.079
op_PASS −0.094
owned_tiles −0.139
land_buys −0.139

Revenue correlates with cash because it is cash. Volume does not. Throughput barely does. Land is mildly negative. Every "close the 2.6× gap by matching the top decile's 75 tiles and 2,600 productive ops" target in the plan below was derived from a luck-sorted ranking and does not survive this.

What this means for measurement. Because the shop draw is shared by both seats, it cancels in a pairwise comparison, and the competition ranks pairwise. So the gate has to be a within-episode margin.

Paired seeds against the frozen previous version is one such gate and it correctly rejected the changes this file was arguing with. But it is not sufficient, and v0.1.0 is the counterexample: it won that gate 23W-7L and moved the live score by nothing, because a frozen copy of ourselves shares our blind spots. See the v0.1.0 post-mortem. The gate that catches a wrong strategy class is the same fingerprint comparison run against downloaded live opponents.

What survives the confound. Compositional facts of the form "nobody in the field does this" are not cash-sorted and still hold: all 300 seats ran zero coops and zero geese, and every cohort ran ~73 FERTILIZE ops while we run 0. Those are statements about what the field believes, and they are independently confirmed below by A/B.

Correction to an earlier reading of the data

An earlier pass over 69 episodes from 2026-08-05 concluded that prices rise over an episode (MILK 160 → 329, STRAWBERRY 120 → 294) and that production should therefore be back-half weighted. On the 2026-08-10 field that is false:

product 08-05 start→peak→end 08-10 start→peak→end
MILK 160 → 329 → 329 169 → 222 → 3
STRAWBERRY 120 → 294 → 294 128 → 206 → 110
WHEAT 25 → 52 → 52 28 → 46 → 36
WOOL 200 → 247 → 247 206 → 218 → 181
MELON 250 → 281 → 269 256 → 272 → 25
EGG 50 → 69 → 69 50 → 62 → 62

Prices rise only while the field under-produces. The 08-10 field has converged and now crushes every product it touches; only EGG and CARROT hold their price, because nobody produces them. So the rule is not "sell late" — it is "sell before the field does." Town demand sets a drain rate; revenue is a race to fill it.

This is also why the tooling had to be fixed before any of it could be trusted: see "Attribution bugs found while building the gate" below.


Diagnosis: v0.0.9 versus the 2026-08-10 top decile — INVALID, kept as a record

Every ratio in this table is unsafe. The "top decile" is the lucky tail of the shop draw, not a stronger strategy, so these are not targets. Four changes were derived from it and lost; see the v0.1.0 section. It is kept because the compositional rows that do not depend on cash ranking (zero coops, zero geese, ~73 FERTILIZE ops field-wide) held up under A/B, and because the shape of the error is worth being able to re-read.

v0.0.9 = 10 real Kaggle seats (submission 55426703, live 2026-08-11). Field = 150 sampled episodes / 300 seats from kaggriculture-episodes-2026-08-10, top decile n=30.

metric v0.0.9 top decile ratio
final cash $50,957 $132,689 2.60×
productive unit-ops 1,170 2,601 2.22×
PASS unit-turns 2,201 996 0.45×
WATER 187 988 5.28×
PLANT 36 195 5.45×
HARVEST 133 386 2.90×
FERTILIZE 0 75 ∞
CARE / FEED 264 / 244 293 / 298 1.11× / 1.22×
wheat seed bought 22 143 6.61×
strawberry seed bought 11 37 3.51×
strawberry sold 13 229 17.6×
milk sold 58 113 1.95×
fertilizer sold 250 222 0.89×
coops at end 14.7 0.0 —
geese alive 0.3 0.0 —
pastures at end 22.0 14.2 0.64×
cows / sheep alive 8.6 / 6.0 9.3 / 4.1 1.09× / 0.69×
owned tiles 50 75 1.50×
hands at end 4.1 9.9 2.42×
wasted SELL order volume 73.2% 28.6% —

Revenue mix, top decile: STRAWBERRY 39.5%, MILK 25.5%, WHEAT 13.6%, FERTILIZER 11.5%, WOOL 8.5%, MELON 1.3%, EGG 0.0%. Ours: FERTILIZER 39.0%, MILK 27.3%, WOOL 10.6%, MELON 10.4%, STRAWBERRY 7.5%, WHEAT 4.0%.

The one finding that explains most of the gap

Land allocation. We hold 50 tiles, of which 22 pastures + 14.7 coops = 36.7 are animal structures, leaving ~13 for crops. The top decile holds 75 tiles with 14.2 pastures and zero coops, leaving ~60 for crops. Per crop tile our watering rate is comparable (14 vs 16 waters/tile); we simply have 4.6× fewer crop tiles. Everything downstream — PLANT 5.5×, WATER 5.3×, strawberry sold 17.6× — follows from that one allocation decision, and the allocation is driven by engine_claim funding coops first for a revenue line worth 0.0%.

Notable: cohorts inside the 08-10 field are nearly identical in composition (all ~2,600 productive ops, 14 pastures, 9.3 cows, 0 coops, 9.9 hands, 75 tiles, 73 FERTILIZE). The top decile separates from the bottom quartile almost entirely on strawberry revenue share (39.5% vs 25.0%). The field has converged on the composition; execution on strawberry is what is still being contested.

Mechanics that justify the changes

Read out of kaggle_environments/envs/kaggriculture/kaggriculture.py:

  • STRAWBERRY: seed $100, first_yield_day 10, interval 2, max_yield 4, ongoing True. It produces 4 times (planting +10, +12, +14, +16), each event adding 1 unit, or 2 if the tile was watered and fertilized that day, and HARVEST resets yield_units without killing an ongoing crop. So fertilizing every production event takes a tile from 4 units to 8.
  • FERTILIZE sets fertilized_until_day = day + 2, active for 3 days inclusive. With strawberry's interval of 2, one FERTILIZE covers two production events if applied on a production day. Two applications cover a tile's whole life: +4 strawberry for 2 fertilizer.
  • Fertilizer supply is not scarce: fertilizer_available is reset to True on every animal tile every day, so 14 pastures yield ~420 units over an episode. Applying ~75 and selling the rest is not a trade-off. Our agent applies 0.
  • FERTILIZER is excluded from TOWN_CENTER_PRODUCTS, so the town never drains it and its price only falls: linear at $0.20/unit from $100, hitting the $1 floor at ~500 units sold. It is a fixed ~$25k pot shared with the opponent — real, but capped, and we currently take 39% of our revenue from it.
  • _commit_unit fills a SELL only from private["shed"], one unit at a time, re-quoting after each unit, and aborts the order when the shed runs dry. main.py:697 sizes its orders as shed + _carried_adjacent_to_shed(...), which the interpreter never honours — hence 73.2% wasted SELL volume against a 10-orders/turn cap.
  • GOOSE $300 → ~25 eggs at $50-62 with 1 wheat/day feed; COW $400 → ~11-22 milk at $169-222. A cow is 3-5× a goose per structure and per unit-turn, and a coop occupies a tile that could carry a crop.
  • MELON above-target curve is sq (amp 0.01): dumping ~150 units past I0 takes it from $250 to $31. WOOL is also sq (amp 0.058) — even harsher. MILK and STRAWBERRY are linear above target, so they tolerate volume far better. This is why melon and wool are the two products to under-produce and milk/strawberry the two to lean on.

Attribution bugs found while building the gate

Both were in scripts/mine_daily.py and both produced confidently wrong numbers, so they are worth recording as a warning about this replay format:

  1. Ordered volume is not filled volume. Counting qty × quoted price attributed $76,652 of fertilizer revenue to an episode whose final cash was $50,957. Fills must be clamped to the seat's shed and the price integrated unit by unit.
  2. steps[t]["action"] is the action that produced steps[t], not the one taken from it. steps[0]'s action is empty for every seat and a step's money already includes that step's own sales. Pricing an action against its own step's market under-counted revenue by 7× (attributed $18,670 against $132,689 of cash). Verified on both a locally-generated and a downloaded replay. leaderboard_replay.py had the same off-by-one and is fixed.

After both fixes, attributed gross revenue lands within ~4-7% of final cash plus costs; the residual is the opponent's interleaved orders moving the price mid-fill.


v0.1.0 — what was tried and what actually held

The plan started as a "land allocation" package aimed at the top decile's 75 tiles and 2,600 productive ops. Four of its five parts lost, and the shop-draw confound above is why the targets were wrong. What shipped is the intersection of what the field unanimously does and what wins paired seeds.

All A/Bs: 30 paired seeds against opponents/v0_0_9.py, seats alternated.

change result verdict
EGG_ENGINE off — no geese, no coops, no engine_claim ON is −62.3%, better on 0/30, p~0.0 kept off
Pin animal roles to tiles that already hold a structure 23.8 pastures for 13 animals → churn removed kept
SELL sized from shed only, not shed + carried wasted SELL volume 68% → 0% kept
LAND_CASH_BUFFER 1961.9 → 400, LAND_LAST_DAY 16 → 22 $45,238 vs $54,773, 5W-25L reverted
MELON_TILE_TARGET 9 → 3 $43,059 vs $58,635, 0W-30L reverted
MAX_SHEEP 6 → 4 15W-15L vs 23W-7L at 6 reverted
Sale-rate limiting, flat floor at 1.0 × base $13,760, 0W-30L, 305 units lost to shed overflow flag off
Sale-rate limiting, 10% per-order slippage cap $45,144 vs $58,635, 5W-25L flag off

Four lessons, all of them about the measurement rather than the game:

  1. Freeing land amplifies whatever the allocator does with it. Dropping the land buffer took us from 50 to 96.9 owned tiles — the whole board, ~$7k — and there was no labour to work it. It also pushed melon to its 9-tile cap, so we sold 55 units into a sq collapse. The land package looked like it was failing on land; it was failing on everything downstream of land.
  2. A low revenue share is not evidence a product loses money. Melon is 1.3% of top-decile revenue, which read as "cut it". The field still sells 30.6 melon for $1,410; the share is low because everything else is bigger. Cutting it cost 0W-30L.
  3. --sweep reports its best by mean cash, and the objective is P(win). MAX_SHEEP=4 wins on mean ($56,531 vs $54,383) and loses on win rate (15W-15L vs 23W-7L). Read the win rate.
  4. The empty-structure bug relocates rather than disappears. 14.4 empty coops became ~11 empty pastures the moment land expanded, because roles are recomputed every turn from a shed-distance sort and new land re-sorts ahead of built structures. Neither is visible without fingerprinting the finished farm.

v0.1.0 measured result

opponent eps mean cash win rate crashes p95 turn
opponents/v0_0_9.py 30 $54,383 (opp $51,608) 76.7% (23W 7L) 0 0.33 ms
baseline 30 $72,568 100% (30W) 0 0.30 ms
opponents/adaptive.py 30 $70,554 100% (30W) 0 0.27 ms
mirror 30 $53,866 11W 10T 9L 0 0.29 ms

Every acceptance criterion passes. For reference v0.0.9 measured $73,032 vs baseline and $68,872 vs adaptive, so the scripted opponents are flat-to-better while the head-to-head is decisively better — which is the ordering to want, since the scripted numbers are not predictive.


v0.1.0 post-mortem: it passed its gate and moved nothing

v0.1.0 went live 2026-08-11 08:49 (submission 55428161) and the team score sits at 586.3, rank #2440/3892 (leader 3214.7, leaderboard median 726.5). That reads as a collapse. It is not one, and the reason it is not is the useful part.

Nothing regressed. The plateau is five versions old.

version live eps record our mean cash opp mean cash
v0.0.6 117 57W-60L (48.7%) $56,735 $68,789
v0.0.7 89 41W-48L (46.1%) $56,245 $56,218
v0.0.8 12 4W-8L (33.3%) $2,909 $12,358
v0.0.9 30 14W-16L (46.7%) $55,157 $56,275
v0.1.0 29 14W-15L (48.3%) $56,279 $54,498

Every version except the neural one lands on the same spot: ~$56k and ~48%. v0.1.0 is marginally the best of them. A score of 586.3 is what a coin-flip record produces under rating-based matchmaking — you are paired with similarly-rated agents, so 50% holds you at the 600 entry rating indefinitely. The score did not fall because of v0.1.0; it never rose, for five versions. v0.0.8 stopped being matched at 06:47 on 08-11, so it is no longer dragging the team.

The paired-seed gate could not have detected this, and this file overclaimed it

Above, the shop-draw section concludes "the only gate worth trusting is the within-episode margin against the frozen previous version." That is wrong, and v0.1.0 is the counterexample. 23W-7L against opponents/v0_0_9.py measures the margin against one agent that shares our strategy DNA. It correctly detected that v0.1.0 fixed v0.0.9's specific defects — 14.4 empty coops out of 50 tiles. It cannot detect that both agents are playing the wrong game, because both make the same mistake.

The gate that does work needs no new tooling: download the live episodes and run scripts/mine_daily.py over them. That gives a within-episode comparison against real opponents, and the shop-draw confound still cancels because both seats share the draw. It was available the whole time.

# after a submission has accumulated episodes
uv run python scripts/mine_daily.py logs/live_v0_1_0 --out logs/fingerprints_live_v0_1_0.csv

What that gate says: we are a fixed-output machine playing a different game

29 paired live episodes, us versus the actual opponents we faced.

Our own behaviour is statistically identical in wins and losses. Only the opponent varies.

us in our 13 wins us in our 16 losses
wheat sold 29.8 29.6
milk sold 159.3 158.2
wool sold 127.2 127.4
WATER 284.8 261.9
owned tiles 48.1 48.4
productive ops 1,384 1,358
opponent in our wins opponent in our losses
final cash $38,655 $71,081
wheat sold 186.4 505.2
melon sold 70.1 100.6
strawberry sold 56.1 77.9
FERTILIZE 10.9 31.8

We emit the same farm every episode and win or lose on who we are matched against. The opponents who beat us are the ones running heavy wheat rotation.

Revenue mix, same 29 episodes:

MILK WOOL FERTILIZER STRAWBERRY MELON WHEAT
us 35.0% 24.0% 19.0% 11.7% 8.4% 1.9%
them 24.3% 8.6% 9.2% 17.9% 19.2% 19.1%

Per-farm gaps against real opponents (ratio > 1 means they do more):

metric us them ratio
wheat sold 29.7 362.3 12.19×
WATER 272.2 885.9 3.25×
melon seed bought 8.3 25.9 3.12×
PLANT 70.1 142.7 2.03×
FERTILIZE 0 22.4 ∞
owned tiles 48.3 72.4 1.50×
productive ops 1,370 1,890 1.38×
PASS unit-turns 1,432 764 0.53×
sheep alive 6.0 2.9 0.49×
wool sold 127.3 56.6 0.44×
fertilizer sold 264.2 134.2 0.51×

Why wheat is the engine we skipped

Two properties, both read out of the interpreter:

  1. Cycle. WHEAT: seed $10, first_yield_day 2, max_yield 6, ongoing: False. The tile dies on HARVEST and can be replanted immediately, so it is a 2-day, $10, 6-unit loop — the shortest capital cycle in the game.
  2. Glut resistance. Its above-target curve is log with amp 0.83, the flattest in the game. Revenue for dumping N units starting from inventory I0:
product N=50 N=130 N=360 price after 360 above-curve
WHEAT $1,127 $2,823 $7,513 $20 log amp 0.83
FERTILIZER $4,755 $11,323 $23,076 $28 linear amp 0.20
MELON $12,098 $25,267 $26,687 $1 sq amp 0.01
WOOL $7,655 $7,999 $8,229 $1 sq amp 0.058
MILK $5,430 $6,235 $6,465 $1 linear amp 2.10
STRAWBERRY $3,648 $3,877 $4,107 $1 linear amp 1.92

Wheat is the only product that still pays a real price after 360 units. Everything else hits the $1 floor. (These are worst-case one-shot dumps; the town's drain means reality sits above them, which is exactly why the steep curves punish batch size.)

We take 43% of revenue from wool plus fertilizer — wool being the second-steepest curve in the game, and fertilizer a pot the town never replenishes at all — and 1.9% from the one glut-proof product.

The land experiment was not a false negative

LAND_CASH_BUFFER 1961→400 measured 5W-25L in v0.1.0. The hypothesis recorded here was that it failed only because nothing could use the land, and that land plus wheat rotation were one change rather than two. That was retested in v0.1.1 with the wheat rotation in place and it is false: buffer 400 gives 23.3% against 56.7% at 1961.9, delta −$3,049, p~0.006. Land is genuinely not the constraint, which is also what corr(cash, owned_tiles) = −0.139 said.


Plan for what remains

The throughput targets from the cash-sorted cohorts are withdrawn. What is left is supported either by an unanimous compositional fact about the field or by mechanics read out of the interpreter.

v0.1.1 — fertilize strawberry (the last unanimous field fact)

Every cohort in the 08-10 field runs ~73 FERTILIZE ops; we run 0. This is not cash-sorted, so the confound does not apply. The mechanics support it independently: FERTILIZE sets fertilized_until_day = day + 2 (3 days inclusive) and a watered+fertilized production event adds 2 units instead of 1, so with strawberry's interval of 2 one application covers two events and two applications take a tile from 4 units to 8. Supply is free — fertilizer_available resets on every animal tile every day, ~420 units an episode against ~75 needed.

Emit FERTILIZE from build_tasks on strawberry tiles that are watered and inside a production window. Gate: paired seeds, plus sold_STRAWBERRY per strawberry tile roughly doubling.

v0.1.2 — labour, diagnosed before tuned

We end on 4.1 hands against a MAX_HANDS of 12, so the ceiling is not the throttle; HIRE_CASH_FRACTION = 0.0513 interacting with low cash is. Note hires_today resets daily, so hands are re-hired every day and pausing hiring is a shutdown, not a saving (measured −65.9%). Diagnose where the hire loop actually stops before changing anything.

Not worth pursuing

  • Matching the field's 75 tiles / 2,600 productive ops. corr(cash, owned_tiles) and corr(cash, land_buys) are both −0.139; corr(cash, ops_productive) is +0.095. These were luck-sorted artefacts.
  • Sell timing or restraint. corr(mean strawberry sale step, $/unit) is −0.241 and the buckets are flat; the price spread is the shop draw. Two implementations lost.
  • Reducing variance to chase the top decile. The top decile is the lucky tail of a field whose median is $83,606. The reachable target is the median, and the way to beat a specific opponent is the within-episode margin.

Submission budget

5/day. One submission per version that clears its paired-seed gate, held for approval first, leaving spares for a revert. Do not spend a submission to measure a change the paired-seed gate already rejected — that was tried eight times above and the gate was right every time.


v0.1.1 — wheat as a cash crop, and four more refuted hypotheses

Aimed at the 12.19× wheat gap from the v0.1.0 post-mortem. One of five parts survived. All A/Bs 30 paired seeds against opponents/v0_1_0.py.

change result verdict
Wheat sized as a real crop target (WHEAT_TILE_TARGET 14, WHEAT_LAND_FRACTION 0.70) instead of ceil(n_animals × 0.4778), and the leftover-land tail switched from STRAWBERRY to WHEAT 56.7% (17W-13L), and 80.0% vs v0.0.9 kept
PRIO_PLANT 550 → 760 / 900 6.7% (2W-28L) at both, −$13.5k and −$18.9k, p~1e-05 reverted
FERTILIZE task on both crop families 36.7% at prio 660, 33.3% at 300 or 1 flag off
LAND_CASH_BUFFER 400 with the wheat rotation in place 23.3% (7W-23L), −$3,049, p~0.006 reverted

What the failures say, and they say the same thing three times

PRIO_PLANT raised above the animal chores loses 2W-28L. FERTILIZE loses even at priority 1 — and the giveaway is that priority 300 and priority 1 produce byte-identical results (delta +0, better on 0/30), so at those levels the task is never the reason a unit is chosen, yet the agent still drops 23 points. The cost is not the priority and not the fertilizer spent: it is that any additional task consumes the leftover units that _logistics needs.

That is the third independent measurement of one mechanism. The README already records it twice — the Hungarian assignment losing 31-38% because minimising travel scatters idle units off the shed, and IDLE_PREPOSITION losing 16.4% for walking them somewhere useful. Greedy's unassigned units clustering next to the shed is load-bearing, because every animal and every sack of feed enters the farm through a shed PICKUP. Anything that gives those units a job costs more than the job is worth.

Combined with the animal chores being 78% of our revenue, this is a coherent picture of a local optimum: the livestock core and the idle-unit logistics reserve are mutually load-bearing, and every attempt to reallocate unit-turns toward the crop-rotation game the field plays has now lost — nine of eleven candidates across v0.1.0 and v0.1.1.

The implication is that this is not a knob problem. Matching the field's crop rotation needs the macro and micro layers redesigned together — fewer animals, so fewer chores, so the freed unit-turns go to planting and watering, with the priority ladder and the logistics reserve re-derived for that mix. Changing one leg at a time will keep measuring the ladder rather than the idea, which is exactly what the last eleven A/Bs did. search/cem.py exists for re-tuning the vector after such a change; it cannot make the change.

v0.1.1 measured result

opponent eps mean cash win rate crashes p95 turn
opponents/v0_1_0.py 30 $52,691 (opp $52,529) 56.7% (17W 13L) 0 0.25 ms
opponents/v0_0_9.py 30 $53,152 80.0% (24W 6L) 0 0.33 ms
baseline 30 $71,885 100% (30W) 0 0.30 ms
opponents/adaptive.py 30 $71,761 100% (30W) 0 0.30 ms
mirror 30 $54,360 11W 9T 10L 0 0.30 ms

Reliability criteria all pass. Note the gate is narrower than v0.1.0's was — 56.7% is a 17W-13L edge, which at 30 seeds is not significant on its own; the 80.0% against v0.0.9 is the stronger reading. Given that v0.1.0 won its gate 23W-7L and moved the live score by nothing, this should be expected to move the live score by nothing as well. It is worth submitting only as a cheap confirmation that the plateau is architectural, not as a candidate for a real gain.


v0.2.0 — route replay. 36/60 → 60/60 on the ladder.

The ladder gate said our hand-written agent sits between tier 5 and tier 6, and that every rung above 5 is a fixed production plan with a thin repair layer. So we built one.

scripts/build_route_agent.py bakes a downloaded episode's action stream into a submittable agent: the route as base85-of-zlib JSON, plus three runtime layers written from the environment's mechanics —

  1. bounded WEED repair. Weeds are the only per-episode randomness that can silently invalidate a route: a tile the route expects empty becomes a WEED and every PLANT on it no-ops for the rest of the game. A scripted PLANT/BUILD_* onto a weed emits DIG, replays the intent next step, then shifts that unit's trace by one for 8 steps.
  2. SELL-slot ordering. The SELL orders the route already contains are reordered among their own slots by price impact (quantity × (price_now − price_after)). Quantities, items and every non-SELL order stay where the route put them.
  3. hands alignment. Pad or truncate the hands list to the live hand count; a route recorded with N hands is otherwise invalid on a turn with M.

Route selection is the whole job, and recorded cash is the wrong criterion

Candidate routes were extracted from 188 downloaded episodes (the 2026-08-10 dump plus our own live episodes), then baked and ranked against ladder tiers 6, 8 and 9 rather than trusted on the cash they happened to bank:

# by cash team episode / seat recorded cash ladder (inner) margin
1 Ezzzzzekki 91490781 / 0 $146,935 11/12 +$22,518
2 Desert Fox88 91927561 / 0 $145,279 11/12 +$23,136
3 Jince 91759414 / 0 $141,128 12/12 +$25,381
4 Dmitry Larko 91767673 / 1 $140,980 12/12 +$29,777
5 青烟 91490952 / 0 $140,633 9/12 +$16,841
6 Gould Research 91582648 / 0 $139,872 10/12 +$16,956
7 saitamad 91613045 / 1 $137,764 12/12 +$25,381
8 THUNDER THUNDER 91537598 / 1 $137,003 10/12 +$16,458

The ranking is not the cash ranking. The best-banking route is 11/12; the fourth-best is 12/12 with the highest margin. Note also that Jince and saitamad produce byte-identical results — the same shared meta line under two team names, exactly what the dataset NOTICE describes.

The three 12/12 routes were then split on an independent seed set (seed 9000, 8 episodes per rung, tiers 6-9). All three went 32/32; Dmitry Larko's carried the highest own cash ($104,344 vs $96,943) and the best margin, so that is the route that shipped.

v0.2.0 measured result

Full ladder, 6 episodes per rung, seats alternated:

tier agent v0.1.1 v0.2.0 v0.2.0 cash theirs
0-4 finn … mateo 30W-0L 30W-0L $138-155k $3-15k
5 rancher_rita 6W-0L 6W-0L $125,300 $16,208
6 broker_bea 0W-6L 6W-0L $90,488 $57,628
7 ledger_lena 0W-6L 6W-0L $90,162 $57,222
8 slotter_silas 0W-6L 6W-0L $89,738 $57,524
9 closer_cleo 0W-6L 6W-0L $86,896 $64,244
overall 36/60 60/60

RUNG: at or above tier 9. And against our own history: 30W-0L vs both v0.1.1 and v0.1.0 at $127,338 and $127,506 mean. Smoke test against baseline banks $139,804 where v0.1.1 banked $55,109. mirror is 4W-19T-7L, the expected shape for two identical deterministic agents on a shared seed.

Reliability: 0 crashes, 0 timeouts, 0 invalid statuses, p95 turn 0.0 ms — the route lookup is far cheaper than the planner it replaced.

What this costs, and what it is honest to claim

  • The production plan is not ours. It is team Dmitry Larko's observable public replay of episode 91767673, and the generated file records episode, team, seat and recorded cash in its own header so the provenance travels with the artifact. The three runtime layers are our implementation of the design published by Kaito Fukami. The dataset NOTICE's position on the shared line is that it "demonstrably belongs to none" of the 104 teams playing it and is reconstructible from public replay data by anyone.
  • It is open-loop, so it is fragile in a way the numbers above do not show. The ladder opponents are deterministic. Against an opponent that trades very differently, a BUY the route depends on can fail and the divergence compounds — that is exactly how the 2026-08-05 $187,844 route collapsed to $392 when we replayed it. The three-layer repair covers weeds and order slots; it does not cover cash divergence. Selection on the ladder is a proxy for robustness, not a proof of it.
  • The heuristic line is not deleted. opponents/v0_1_1.py is its frozen head and search/ still operates on it; search/smoke_test.py now reads its constants from there rather than from main.py.
  • Two pre-flight changes were needed. zlib was added to submit.py's import allowlist, and the check that required the planner/scheduler classes to be present was removed: it was a proxy for "self-contained" that only described one architecture, while self-containment is already enforced by the allowlist and by loading the file the way the env loads it.

Results

version submitted change local gate live episodes live mean cash live W-L verdict
v0.0.8 2026-08-10 behaviour-cloned atomic actions — 12 $2,908 3W-8L reverted, see README
v0.0.9 2026-08-11 CEM macro vector, heuristic restored $64,351 vs baseline 10 $50,957 5W-3L baseline for the work below
v0.1.0 2026-08-11 egg engine deleted, pasture role churn fixed, SELL sized from shed 23W-7L vs v0.0.9 29 $56,279 14W-15L passed its gate, moved nothing. Marginally the best of v0.0.6/7/9/0.1.0, all of which sit at ~$56k and ~48%. Score 586.3, rank #2440/3892. See post-mortem.
v0.1.1 PR #10 wheat promoted to a real crop target; leftover-land tail switched to wheat 17W-13L vs v0.1.0, 24W-6L vs v0.0.9; ladder 36/60, between tier 5 and 6 — — — last of the heuristic line; frozen at opponents/v0_1_1.py
v0.2.0 pending route replay — Dmitry Larko ep 91767673 seat 1, plus WEED repair, SELL-slot ordering, hands alignment ladder 60/60, at or above tier 9; 30W-0L vs v0.1.1 and v0.1.0; $139,804 vs baseline — — — awaiting submit

v0.2.6 — Leaderboard Gap Research (Issue #22)

The gap between our agent (v0.2.6 at 2290 rating) and the leaderboard leader (~3220) was investigated to determine if it's solvable through strategic improvements or if it's structural (matchmaking/convergence).

Findings

  1. Volume/Convergence is not the main gap driver. The top 5 teams have between 99 and 500 episodes on their active submissions. The score correlates with true skill, not purely with episode count.

  2. Matchmaking isolation is a gradient, not a closed band. Scoring every opponent recorded across the top 5 teams' last 20 episodes against the leaderboard shows isolation tracks rating and decays sharply on approach to 3000:

    Team Score opponents >3000 opponents in top 30
    カワシギ 3208.0 14/20 20/20
    Thomas Tschinkel 3131.6 12/20 19/20
    Utkarsh #2 3020.7 11/20 14/20
    ReCurSiON 3000.1 6/20 14/20
    peikopon 2987.5 0/19 1/19

    The top two are effectively sealed into a >3000 pool (カワシギ played Utkarsh #2 6 times, Thomas Tschinkel 4, ReCurSiON 4). But rank-5 peikopon, at 2987.5, matches nobody above 3000 and draws from the broad field. Isolation is therefore a consequence of crossing 3000, not a precondition for getting there — no team in this sample climbed by playing only >3000 opponents.

  3. Our 2290 rating is real. Over the last quartile (29 completed episodes) of v0.2.6's live play, it achieved a 44.83% win rate (13W 16L 0T) — 95% CI 28.4%–62.5%. The rating is not obviously still climbing, but at n=29 that interval spans everything from "clearly losing" to "clearly winning", so it cannot on its own establish convergence. Confirming the plateau needs a wider window or a per-version rating trace; we do not currently record leaderboard rating per submission, so that trace does not exist yet.

  4. Shop-draw cancellation: Because we haven't matched against a top-30 team in our recent episodes, we could not run a direct, same-episode fingerprint comparison. The matchmaking gradient and the win rate are consistent with a real, non-artefactual gap, but neither one establishes it alone — see the interval on finding 3.

Data provenance. The numbers above come from logs/leaderboard_research.json, produced by scripts/research_leaderboard.py. The committed run covers 20 of the 31 teams requested (top 30 plus us): ranks 21-30 and our own rank-498 row were lost to API errors partway through the run, which the script previously swallowed silently. Findings 1 and 2 rest only on the top 5 and are unaffected; any statement about the shape of the top 30 as a whole is not supported by this run. The script now reports coverage and exits non-zero on an incomplete sweep, so a re-run is needed before the top-30 view can be quoted.

Conclusion & Recommendation

The gap from 2290 to 3200 is real and not a convergence artefact. We are losing at a 2290 rating band because we are playing worse than the teams above us.

Recommendation: The rest of the milestone should NOT be re-scoped away from strategic improvements — the goal of breaking 2290 is valid.

The evaluation tooling should become rating-aware, sampling a band around and modestly above our own rating (roughly 2300–2600 today) and advancing that band as we climb. Calibrating against the >3000 meta is not supported by the data above: peikopon sits at rank 5 having never played a >3000 opponent, so that meta is not the ladder we are on. Sampling exclusively from it would optimize against games we are not currently matched into while discarding the opponents that actually set our score.

Two caveats on implementing this, both of which make it larger than a config change:

  • The panel does not "sample the field broadly" as originally written here. simulate_candidates.py:414 draws panel members from the top --panel-from-top screen performers, and mining/panel.py:41 then runs greedy max-min diversity over per-step action distance. It is already a strongest-first selection; the axis it lacks is rating, not strength.
  • There is no rating signal to filter on. Panel entries carry hash, route, and team only, and mined replays never record an opponent rating (no rating field exists anywhere under mining/). Making the panel rating-aware requires capturing opponent rating at mine time first.

Market layer: measured and exhausted (Issue #23)

The shipped route uses 927 of 7,190 market order slots (12.9%). 339 of 719 steps emit no market order at all, and only 6 steps hit the 10-order cap. Market orders cost no unit-turns — _process_market never touches units, and _commit_unit only checks cash and shed capacity. On paper this is a large free action channel.

It is not usable. Three variants were built and measured against panel opponent 2f741e6bd5. The variant code lived in the analysis session's scratchpad and is not committed; reproduce from the descriptions below.

variant what it does result
Defer hold sales below a price floor, drain the backlog later catastrophic — $26k / $23k / $1k at floors 0.60 / 0.85 / 1.00, against a $104k baseline on seed 2000000
Accelerate sell the turn the product lands, capped by the route's own remaining scheduled volume +$61 mean cash, +$215 mean margin over 30 paired seeds; win rate unchanged at 90% (27W-0T-3L both)
Drip replace route SELLs with a per-turn quote sized to the market broken by construction — it sells the 351 units of feed WHEAT the route buys, and the herd starves

Why deferral fails is the interesting part. The route is not a production plan with a market layer bolted on — it is a cash schedule. 277 HIRE orders and every BUY are timed against money the route expects to already have. Delay a sale and the next HIRE fails, the hands never materialise, and throughput collapses. Any future market work has to move the BUY schedule together with the sale schedule (that is #30).

Acceleration is real but sits inside the noise band; it does halve shed overflow (25 → 15 items lost). It is not worth a submission on its own — see #25.

This is consistent with Kaito Fukami's published ablation, which puts +$819 to +$1,115 on his sell layer and attributes everything else to the route.

The trap that cost two measurement rounds: obs.private.shed predates the turn

In kaggle_environments/envs/kaggriculture/kaggriculture.py (1.32.6) the interpreter runs _apply_unit_action for the farmer and every hand (L922-926), then _process_market (L928), then _town_consume (L929).

So the shed an agent observes is the shed before this turn's PLACE / DROP / end-of-day deposits land in it. It is a lower bound, not the quantity available to sell. Any adaptive market layer that writes qty = min(route_qty, observed_shed) silently truncates every sale, every turn, for every product. All three variants above shipped with this bug, and the symptom — a total collapse to near-$0 — reads as an economic result and is not one.

main.py is not affected today, because it replays recorded quantities verbatim. The moment anything sizes an order from the observation, it is. tests/test_observation_ordering.py pins the ordering so this fails loudly instead of quietly: it drives a scripted episode in which a same-turn PLACE feeds a SELL the observation says is impossible, and a same-turn PICKUP starves a SELL the observation permitted.


v0.2.7 — aiming the opponent panel at a rating band (Issue #24)

What was wrong

Two selection biases stacked. Panel members were drawn from the top --panel-from-top screen performers, and the screen ranks by win rate against the incumbent anchor — so every member beat the incumbent ~100% by construction. Underneath that, the pool they came from was whoever happened to appear in the daily replay dumps: a sample of the whole field weighted by episode volume, not by strength. We are matched by rating, so the pipeline was optimising win rate against the median of the field.

The band, and why not the top

Issue #24 as written says "top N of the ladder". That was not run, because #22 had already measured the opposite: rank-5 peikopon at 2987 matches nobody above 3000, so isolation into the >3000 pool is a consequence of crossing 3000, not the way up. Selecting against the leaders would optimise for games we are not matched into.

The panel was therefore drawn from ranks 185–469 (2300.2–2599.1 rating) — from our own rating (2294, rank 476) to ~300 points above. --panel-team-top still supports the literal top-N reading; --panel-rank-min is what makes it a window.

Result

mean win worst opp margin CVaR₅ cash mean
Winner 044a7741e9 (Ueddy), panel 93.8% 77.0% −$8,881 $93,286
Winner, held out on 100 fresh seeds 93.2% 79.0% −$9,533 $90,702
v0.2.6 incumbent, same held-out grid 83.6% 51.0% −$9,849 $90,013
Winner vs v0.2.6 head-to-head, 100 disjoint seeds 96.0% — — $87,019

79,980 sieve episodes + 1,000 held out, zero bad. Ladder 10/10. Shrinkage −0.6%.

What was actually learned, as distinct from what was shipped

1. The bias was real and is now measurable. v0.2.6's Phase 3 scored the incumbent at 0.2% against its own panel. On a ladder-band panel it scores 83.6%. The README's largest open caveat is closed — not by argument, by measurement.

2. The headline delta is one opponent. 44 of the 48 points of the +9.6% mean delta come from a single panel member (ebfc911eaa, lllleeeo, rank 435: incumbent 51%, winner 95%). Against the other four the winner is within two points of the incumbent. The gate's rule is "strong against five, weak against one is an exploit"; this is the mirror image, and the honest expectation is a wash plus one favourable matchup.

3. All 12 finalists share a worst opponent, 8f7dd57d5f (researchstudio.site, rank 466), at 77–78%. That is not a candidate's exploit — it is a weakness of the strategy class the entire 4,315-route corpus contains, and no route in it fixes it. Independent confirmation of #31's "the remaining gap is production" from the opposite direction.

4. Finalist concentration got worse. 12 finalists across 2 teams, within a 2.5% win-rate spread, against 5 teams in v0.2.6. Route selection really is exhausted.

5. corr(recorded cash, mean win rate) = −0.54 — an order of magnitude stronger than the −0.05 measured against the old panel. Filtering the pool by banked cash would have been even more damaging than previously thought.

Limits of this result

  • Ranks are a 2026-08-17 snapshot; the replays are 08-08→08-14. A team that climbed since is credited for strength its mined route did not have. Kaggle exposes no historical rank, so this is not fixable — only dated, which logs/team_ranks.json does.
  • An open-loop replay of one good episode is not that team's agent. The panel is representative of the band's routes, not of the band's strength.
  • The mid-stage pool is still cut by win rate against the incumbent (top 150 of 4,315), so a route that beats the band but loses to v0.2.6 never reaches the panel. Fixing that means screening against the panel at 6× compute.
  • Local gates remain a veto, not a forecast. Five consecutive versions have now won their local gates; four moved the live score by nothing.

v0.2.8 — sell acceleration: two bugs, then a clean negative (Issue #25, PR #35)

The fourth runtime layer #25 specified: a product already sitting in the shed is sold now rather than when the route's schedule gets round to it, capped by the volume the route itself still has scheduled for it. #23 had already measured it as +$61 and within noise, and #25 said in its title not to spend a submission on it alone.

The first cut shipped two defects and one wrong conclusion.

Bug 1: the layer sold a farm input, and the ladder did not notice

#25's second safety rule is "never sell an input", and it names WHEAT — the route buys 351 units of it as feed. WHEAT was excluded. But FERTILIZER is an input too, and it is not bought, it is produced: 306 COLLECT_FERTILIZER calls put it in a unit's carried inventory, it is deposited to the shed, and then 14 PICKUP FERTILIZER calls lift 95 units back out to feed 64 FERTILIZE calls. _inv_take reads carried inventory and PICKUP fills it from private["shed"], so draining the shed starves the fertilizer chain. Fertilizer is also 1,907 of the route's 2,772 non-wheat SELL units — 69% of the accelerated budget, not a corner case.

Measured over 30 paired seeds vs opponents/v0_2_6.py (base seed 3000000, seats alternated), against the v0.2.7 incumbent:

mean cash win rate no-op FERTILIZE
v0.2.7 incumbent $86,515 96.7% (29W-1L) 9
v0.2.8 as first written $83,146 16.7% (5W-25L) 282

scripts/rank_ladder.py --episodes 1 --require-perfect returned 10/10 on that build. The ladder cannot see an 80-point win-rate collapse against a peer, because every rung it contains is $45k weaker than we are. It is a crash test, not a strength test.

The fix generalised #25's rule instead of adding a second hardcoded item: precompute _PICKUP_SUFFIX_SUMS, the per-item suffix sum of what the route still lifts out of the shed, and reserve it before accelerating. That is derived from the route, so it would have survived the next route swap — a blanket FERTILIZER exclusion would not have. It is not in the tree, because the layer it protects was reverted; recover it from PR #35 if #30 revives the idea. What the tree keeps is tests/test_sell_layer.py, which pins the rule and the PICKUP volumes it has to respect.

Bug 2: the emitted template no longer matched the emitted agent

AGENT_TEMPLATE was missing the two blank lines PEP8 wants before a top-level def, so build_route_agent.py and encode_submission.py emitted an agent that fails ruff format --check. Repo CI passed only because main.py had been formatted by hand after generation. scripts/build_route_agent.py now round-trips to main.py byte for byte; that equality is worth re-checking whenever the template changes.

The result, with the bugs out of the way

Paired A/B of the layer ON vs OFF — same file, budget forced empty for OFF — 30 paired seeds each, base seed 3000000, seats alternated:

opponent strength ON − OFF ON better on p shed overflow ON → OFF
opponents/v0_2_6.py peer, ~$86k −$1,680 0/30 ~0.0 10 → 45
closer_cleo (tier 9) ~$26k +$382 17/30 0.011 17 → 53
slotter_silas (tier 8) ~$26k +$290 19/30 0.052 4 → 33
broker_bea (tier 6) ~$26k −$174 12/30 0.148 0 → 30

The shed-overflow saving #25 promised is real and large — it survives every opponent, and it is the only claim in #25 that does. The cash claim does not. Against the ladder the layer is a wash to +$400; against the only peer-strength opponent available it loses $1,680 on every single one of 30 seeds, t = −8.1.

Why the sign flips with opponent strength. _process_market runs both players' order i in per-unit lockstep against one shared inventory, and README's own price table says the market rises over 30 days — MILK $160 → $329, never retracing. Selling early is therefore selling cheap, and the cost is only paid when someone else is still selling into the recovered market later. A tier-6 bot banks $26k and never gets there; v0_2_6 does. The leaderboard is made of peers, not of tier-6 bots, so the peer column is the one that forecasts.

This is the same shape as #23's deferral result read backwards: the route is a cash schedule, and both directions of moving a sale off its scheduled turn cost money — a late sale starves the next HIRE, an early one sells into a market that had not risen yet. #30 (move the BUY schedule with the sale schedule) remains the only version of this idea that could work.

Verdict

Both bugs were fixed and the layer still failed #25's own gate — "not worse on win rate vs opponents/v0_2_6.py" — on 0 seeds out of 30, so it was reverted. main.py is back to v0.2.7's _rank_sells and AGENT_VERSION is back to 0.2.7; _accelerate_sells with budget forced empty reproduced v0.2.7 at $86,513 / 96.7% exactly, which is how we know the slot-handling rewrite was never the problem and the acceleration always was.

There is no v0.2.8. What survives the branch is this write-up, the argparse fix in rank_ladder.py, and two test files: tests/test_sell_layer.py for the three restraints that make the SELL layer safe, and tests/test_agent_template.py, which fails if AGENT_TEMPLATE stops regenerating main.py byte for byte.

Count for the record: this is the tenth of twelve structural changes to main.py to lose paired seeds.


Route synthesis harness (Issue #26) — built, gated, shipped no agent

The end of "route selection is exhausted" is a tool, not a route: with every one of 4,315 candidates the same strategy, the only direction with a five-figure target is optimising a route directly. search/route_search.py is that harness — a mutation-and-accept loop over the 719-step action stream, seeded from the v0.2.7 incumbent (main.py._ROUTE, hash 044a7741e9, round-trips exactly through the pool).

What it verifies before it is trusted (the "changes nothing" gates):

gate check result
1. identity zero mutations -> baked route byte-identical to the seed PASS — hash round-trips
2. round-trip the no-op shift preserves the full non-movement op signature PASS — moves move walks only
3. fidelity the identity artifact replays a full episode with zero invalid/crash/timeout PASS — vs random, $149k / $188k clean
4. budget wall-clock per candidate is printed 30 seeds × 6 panel = 180 episodes ≈ 35 min on 12 workers at ~2.4 s/ep

What was measured building it, beyond the gates. The one operator with a definable inverse is the movement shift, and it is the honest proof that the loop runs: mutating the seed's final-step movement (step 713, the tail of the route) and re-evaluating through the real baked artifact on the full v0.2.7 panel returned bit-identical cash on all six opponents — e.g. seed $142,582 vs b8b9267d1c $115,984; mutant identical, to the dollar, on every member. The no-op is a no-op empirically, not just by construction.

Design choices that were taken deliberately, and why:

  • Seed loading prefers main.py._ROUTE, hash-verified against the pool. The baked file is what ships; the pool is the identity the rest of the pipeline agrees on. A drift between them is detected and the pool wins, loudly, because a silent drift here poisons every mutation downstream.
  • Evaluation reuses simulate_candidates.run_stage verbatim and shares its resume key. A mutated route is a new hash, so the loop never re-uses results across routes; it only ever re-reads an anchor episode it already paid for. The harness cannot drift from Phase 2 because it is Phase 2.
  • swap_herd (#27) and assign_idle (#28) mutate but do not repair. The cadence compression and the choreography are the dependent issues' operators, not this harness's; a wrong repair reads as "the herd starves for no reason" and would burn an evaluation budget chasing a bug. repath (#29) and move_sell_and_buy (#30) are None placeholders until their shortest-path and joint operators land.
  • Acceptance is rank_cvar's metric — mean panel win rate, worst-opponent tiebreak — unchanged, so a route this loop accepts is one the existing Phase 3 gate would rank the same way. The carried-forward warning stands: local accept is a veto, never a forecast.

Budget for the dependent issues. At 180 episodes per accepted/rejected candidate, one mutation step is ~35 min on 12 workers; a 20-step greedy path is ~12 h. That is the number #27's herd sweep, #28's idle-turn rotation and #29's path optimisation scope against — and the reason each of those issues should batch its mutations rather than accept on single steps.

Live validation: none by design. The harness ships no agent; its cost was eight unittest runs (8 tests, all green) plus one live 6-episode smoke pass against the real panel.


[v0.3.0] Re-mix the herd from cows to sheep (Issue #27) — accepted, gated, shipped

The premise. The incumbent route (044a7741e9) kept 10 cows and 4 sheep across 14 pastures. Cows cost $400, yield MILK every 2 days (starting day 8), but flood the market with 343+ units, driving MILK realized price down to $1-$58/unit. Sheep cost $500, yield WOOL every 3 days (starting day 6), and sell for ~$60-$65/unit into a higher-value market. Converting late cows to sheep was hypothesized to capture higher unit revenue without requiring new pasture infrastructure.

The Herd Mix Sweep & Realized Unit Economics

We implemented op_swap_herd to rewrite BUY_ANIMAL COW -> SHEEP, synchronize downstream PICKUP/PLACE unit actions, and emit SELL WOOL orders alongside milk sales. We swept the herd mix across the 6-opponent panel over 30 seeds (180 episodes per candidate) and measured realized $/unit for both products:

Herd Mix Mean Panel Win Worst Win Cash Mean Margin CVaR5 MILK Realized ($/unit) WOOL Realized ($/unit) Total Animal Rev
10c / 4s (v0.2.7 incumbent) 91.1% 73.3% (8f7dd57d5f) $90,829 −$9,722 1,140 units @ $58.2 456 units @ $65.1 $96,072
9c / 5s (Cow 7 converted) 87.8% 73.3% (ebfc911eaa) $91,948 −$6,613 1,030 units @ $66.3 566 units @ $58.7 $101,546 (+$5.4k)
8c / 6s (Cows 6, 7 converted) 83.3% 63.3% (v0_2_6) $92,777 −$8,383 925 units @ $76.9 676 units @ $52.2 $106,404 (+$10.3k)
6c / 8s (Cows 6..9 converted) 73.3% 50.0% (v0_2_6) $92,651 −$17,972 745 units @ $95.4 799 units @ $44.3 $106,480
4c / 10s (Cows 4..9 converted) 56.7% 33.3% (62b81aa8a3) $89,645 −$34,674 570 units @ $118.2 921 units @ $33.7 $98,450 (collapse!)

Market Saturation Dynamics

As predicted in #27:

  1. Wool Saturation: Realized $/unit for WOOL degrades as more sheep are added: $65.1/unit (4s) $\to$ $58.7 (5s) $\to$ $52.2 (6s) $\to$ $44.3 (8s) $\to$ $33.7 (10s). At 10 sheep, total wool revenue collapses from $35.4k to $31.0k because the market is heavily flooded.
  2. Milk Spillover Effect: With fewer cows, milk supply decreases and milk $/unit rises ($58.2 $\to$ $118.2). Against peer opponents who run 10-cow routes (like v0_2_6), our un-dumped milk leaves the market clearing at high prices that directly enrich the opponent.
  3. The Pareto Frontier: 9c / 5s (converting Cow 7 on pasture (7, 4)) captures +$5,474 in animal revenue and improves cash mean ($91,948 vs $90,829) and tail risk (CVaR5 −$6,613 vs −$9,722) while preserving robust win rates against all panel opponents.

Milestone Gate Validation

  1. Direct Head-to-Head vs Incumbent (044a7741e9):
    • 100 held-out seeds (disjoint from mid/screen sets), alternating seats:
    • 62W − 38L (62.0% Win Rate), mean margin +$1,093 $\to$ PASS (Gate $\ge 55%$).
  2. Held-Out Panel Validation (100 disjoint seeds × 6 opponents = 600 episodes):
    • Mean Cash: $90,767 (+$1,279 over incumbent $89,488)
    • Margin CVaR5: −$6,094 (+$2,284 improvement over incumbent −$8,378)
    • 100% win rate vs 800dc80f5c, 100% win rate vs b8b9267d1c, 97% win rate vs 62b81aa8a3, 83% win rate vs 8f7dd57d5f $\to$ PASS.
  3. Reference Ladder Gate (scripts/rank_ladder.py --episodes 1 --require-perfect):
    • 10/10 Perfect Run across all 10 rungs, beating tier 9 closer_cleo (+$22,453 margin) $\to$ PASS.

Verdict

9c / 5s candidate (hash 9d890c9a321f2c0b5bf7ca77fc9ac930) passed all milestone gates and is baked into main.py as v0.3.0.


[v0.3.1] Spend idle unit-turns on carrot/tomato & tile optimization (Issue #28)

The premise. The incumbent route contains 667 PASS unit-turns across 719 steps. Issue #28 proposed converting these idle turns into short-cycle cash crops (CARROT: 2-day maturity, $20 seed $\to$ 4 units @ $35 = $140 revenue; TOMATO: $60 base, 8-day maturity) to capture uncontested town shop drain (~18 carrots/day = $630/day, 12 tomatoes/day = $720/day).

Idle Turn & Farm Tile Census

We analyzed the 667 PASS turns and 100 farm tiles across the 719-step route:

  1. 70 turns sit directly on planted crops (MELON, STRAWBERRY, WHEAT) that were unwatered on that day (watered_today: False).
  2. 66 turns sit on empty farm tiles ((2,4), (8,1), (9,1), (7,4), (1,1), (4,1)).
  3. 27 turns sit on WEED tiles.
  4. Tile Mapping: Every single visited tile on the 10×10 farm is already allocated to high-margin STRAWBERRY/MELON crops, WHEAT feed crops, or 14 COW/SHEEP pasture tiles.

Candidate Sweep Across 6-Opponent Panel (30 Seeds, 1,440 Episodes)

We evaluated 8 candidate variants sweeping carrot retargeting, tomato retargeting, and idle-turn water conversions:

Candidate Description Mean Win Rate Mean Cash Margin CVaR5 Hash Status
v0_3_0_incumbent Baseline 9c/5s route 90.0% $91,869 −$5,243 8c62ea6056 Incumbent
carrot_3 3 late wheat $\to$ carrot 6.7% $70,537 −$64,119 b88826635f Crashed (feed starvation)
carrot_6 6 late wheat $\to$ carrot 1.1% $55,733 −$112,936 e4e3dd4446 Crashed (feed starvation)
carrot_10 10 late wheat $\to$ carrot 0.0% $50,696 −$162,183 29585b7591 Crashed (feed starvation)
carrot_15 15 late wheat $\to$ carrot 0.0% $45,678 −$126,860 dd87319c08 Crashed (feed starvation)
tomato_4 4 wheat $\to$ tomato 42.8% $87,354 −$44,382 a714af8eb2 Crashed (feed starvation)
water_straw_only 43 strawberry PASS $\to$ WATER 90.6% $91,871 −$5,243 dcd4e8c8b9 Win rate +0.6%
water_straw_melon 54 strawberry/melon PASS $\to$ WATER 90.6% $91,871 −$5,243 e8c035f9d0 Win rate +0.6%
water_all_crops 70 all-crop PASS $\to$ WATER 86.1% $91,996 −$6,389 1925f90c23 Degradation vs ebfc911eaa

Critical Findings & Root Mechanisms

  1. Wheat is the Livestock Engine's Non-Negotiable Input:
    • In the incumbent route, WHEAT is not grown for cash sale — it is harvested into the shed and fed to the 9 cows and 5 sheep.
    • Retargeting even 3-6 wheat plantings to carrot or tomato starves the herd of wheat feed, causing cows/sheep to stop producing milk and wool. Livestock revenue collapses by $20k-$50k, crashing win rate from 90% to 0-6%.
  2. Additive Dwell Planting Causes Inventory Deadlock:
    • The empty tiles where units dwell ((2,4), (8,1), etc.) are pastures where cows are placed on Day 3 or transit paths for fertilizer/wool delivery.
    • Planting crops on dwell tiles results in carrying units holding harvested crops, which blocks hands from carrying fertilizer or milking cows on subsequent steps.
  3. In-Place Watering Optimization:
    • Converting unwatered PASS turns on standing cash crops (STRAWBERRY and MELON) is 100% legal, consumes 0 inventory slots, and lifts single-agent win rate against the 6-opponent panel to 90.6% (with a perfect 10/10 reference ladder sweep).

Verdict

water_straw_melon (54 strawberry & melon unwatered PASS $\to$ WATER turns converted, hash e8c035f9d0ce7cd865e4078e013256db) delivers 90.6% panel win rate and a 10/10 reference ladder sweep (+$22.4k margin vs tier 9 closer_cleo). Baked into main.py and opponents/v0_3_1.py as v0.3.1.


[#29] Offline path optimisation: half the route is walking, and 88% of that walking is load-bearing

The premise. A unit-op census of the shipped v0.3.1 route (719 steps, 6,999 unit-turns):

op group count share
movement (WEST 1,045 / NORTH 1,006 / EAST 809 / SOUTH 624) 3,484 49.8%
productive tile ops (WATER + HARVEST + PLANT + FERTILIZE) 1,646 23.5%
PASS 699 10.0%
logistics (FEED, CARE, COLLECT_FERTILIZER, PICKUP, PLACE, DROP, DIG, BUILD_PASTURE) 1,170 16.7%

Half of all labour is walking, at 2.12 moves per productive tile op. Issue #29's premise is that this is the one part of the game with an exact algorithm: the route is fixed and the board is deterministic, so between two consecutive position-dependent ops a unit's path is free — any walk that arrives by the turn the next op is scheduled is equivalent.

Reproduce with uv run python scripts/analyse_movement.py --verify.

Method: an exact position simulator, not a heuristic

search/board_paths.py replays the route's movement stream against the env's own position rules and returns every unit's tile at every step. Only three things move a unit and all three are in the route: a move op, the end-of-day reset, and a HIRE order's spawn. So this is a complete model, and tests/test_board_paths.py pins it against a live kaggle_environments episode — all 719 steps, all 13 unit slots, exact agreement.

Each unit's day is then cut into segments: maximal runs of MOVE/PASS bracketed by anchors (any op whose effect depends on where the unit stands, i.e. everything that is not a move and not a PASS). A segment's endpoints are fixed; everything between them is re-planned and the difference banked as PASS, front-loaded so the unit arrives early and idles on the tile its next op needs.

Two of the three constraints the issue asked us to respect turn out to be vacuous

The issue asks for a shortest path "respecting unlocked_quadrants, occupancy and the shed-access tiles". Reading _apply_unit_action in the env settles all three:

  • unlocked_quadrants does not constrain movement. A move is applied iff the destination is on the board. The env's own comment: "Movement onto LOCKED tiles is allowed: a hand can spawn on a locked shed-access tile, and blocking movement would strand it there forever." Only tile ops check the lock.
  • Occupancy does not constrain movement either. There is no collision check anywhere; any number of units may share a tile.
  • Shed-access tiles matter, but not as obstacles — see the coupling below.

So the shortest path between two tiles is any monotone staircase of length manhattan(a, b), and a BFS over the board would return the same number. There is no graph search in this problem, which is why the operator is exact rather than approximate.

Finding: the incumbent is already 98.3% Manhattan-optimal

segments moves Manhattan-required slack
interior (an anchor follows in the same day) 1,787 3,125 3,073 52 in 23 segments
terminal (the day reset lands first) 139 359 — 359, all of it
total 1,926 3,484 411

409 of those 411 turns are actually banked; the missing 2 are the one segment the schedule verifier rejects, for the reason below.

Two separate results hide inside the 50% movement share:

  1. The pathing has almost no slack. Of 3,125 moves that actually have to get a unit somewhere, 3,073 are Manhattan-required. 23 segments out of 1,787 contain a detour, and the whole recoverable total is 52 unit-turns — 1.7% of interior walking. Zero moves in the entire route are clamped at a board edge and zero are issued to a unit that has not been hired yet; the recorded route's walking is essentially clean.
  2. The dead labour is at the end of the day, not in the middle of a walk. 139 segments have no anchor after them: the unit walks somewhere and then _end_of_day teleports the farmer back to spawn and dismisses every hand, discarding the position those 359 moves bought. Nothing at end-of-day reads a unit's position (_spawn_weeds is per-tile and unconditional, _drop_inventories_to_shed ignores where the unit is standing), so every one of those moves is provably wasted.

So #29's premise is half right. Half the labour is walking, but only 11.7% of it is recoverable, and 87% of what is recoverable is end-of-day drift rather than bad pathing. The 50% movement share is a property of the task assignment — which tiles each unit is sent to, in which order — not of slack in the pathing. Cutting it further needs a different issue.

The coupling nobody expected: re-pathing one unit relocates another

_do_hire spawns a hand on the least-occupied shed-access tile in NWSE order. Where a unit idles on the turn a hire resolves therefore decides where the next hand starts its day, and one segment in the incumbent trips exactly that:

slot 6, steps 506..508: (5,5) -> (5,4), 3 moves where 1 suffices
  incumbent : slot 6 steps EAST off (5,5) at 506, so the two hires that turn
              leave hand 12 spawning on (5,4)
  re-pathed : slot 6 steps NORTH onto (5,4) at 506, so hand 12 spawns on (4,5)
              instead -- and all nine of its ops that day fire one tile off

Segments are not independent, so repath verifies. The whole batch is applied and checked once (the ordinary case); on a violation the batch is rebuilt segment by segment and only the rewrites the schedule survives are kept. That discards exactly this one segment out of 162 and is why the operator can honestly claim to be a no-op. A verifier that never fails is not a verifier — tests/test_board_paths.py::TestVerifierHasTeeth makes it fail on purpose three ways.

Gate

Step 1 — the no-op gate. Both re-pathed routes were scored against the incumbent on the same grid (30 mid-stage seeds × 6 opponents = 180 episodes each, common random numbers on both axes, seat 0 fixed). The issue asks for equal win rate; paired final cash is the sharper form of the same claim and costs the same to measure:

route hash movement PASS mean panel win worst opponent episodes differing from the incumbent
v0.3.1 incumbent e8c035f9d0 3,484 699 62.778% 20.0% —
repath_interior 497675fdd0 3,434 749 62.778% 20.0% 0 / 180
repath_full dbecd2d487 3,075 1,108 62.778% 20.0% 0 / 180
repath_spent d057e88b1f 3,075 692 62.222% 23.3% 180 / 180

The gate passes at the strongest available standard: with the recovered turns banked as PASS, every one of 360 episodes ends with cash identical to the incumbent's, to the cent — not merely an equal win rate. Movement is strictly lower (3,484 → 3,075, −11.7%) and PASS strictly higher (699 → 1,108, +58.5%), so the census part of the gate passes too.

Panel note. candidates.jsonl and logs/_mined_agents/ are gitignored and absent from this checkout, so the v0.2.7 leaderboard-band panel that produced v0.3.1's 90.6% could not be rebuilt. This run substitutes the strongest reproducible panel on disk — v0_2_6, v0_2_7, v0_3_0, v0_3_1, rancher_rita, melon_mateo — which is harder (four of the six are our own successive versions), hence 62.8% rather than 90.6%. That does not weaken the gate: the claim being tested is equality with the incumbent on an identical grid, and bit-identical cash is panel-independent.

Step 2 — spending the recovered turns. repath_spent hands all 416 recovered unit-turns to #28's only idle-turn consumer (PASS → WATER). It is a reject: mean panel win rate 62.222% against the incumbent's 62.778%, worst-opponent 23.3% vs 20.0% — a wash at best. The reason is structural and follows from where the recovered turns land. WATER is once per tile per day (if tile["watered_today"]: return), and the re-path deliberately front-loads its walk so a unit idles on the tile its next op needs, which is a tile the route is already working. 87% of the recovered turns come from terminal segments, where the unit stands where its last op of the day fired. So the turns are recovered in exactly the places that have nothing left to do — v0.3.1 already took the 54 unwatered-crop turns that did.

Ladder. scripts/rank_ladder.py --episodes 1 --require-perfect over tiers 0–9 (tiers 6–9 fetched from the reference dataset, measurement only):

agent tiers won tier 9 closer_cleo margin cash on all ten rungs
main.py (v0.3.1) 10/10 +$22,453 reference
repath_interior 10/10 +$22,453 identical on every rung
repath_full 10/10 +$22,453 identical on every rung
repath_spent 10/10 +$22,208 differs on all ten

The ladder is a third independent confirmation: banking the recovered turns as PASS reproduces the incumbent's cash rung by rung, to the dollar.

Verdict

The re-path is correct, exact and measured. It is also not worth a submission, which #29 predicted of itself: "On its own this produces an agent that walks less and does the same things." The two ways to spend what it recovers both fail —

  • on WATER, because WATER is once per tile per day and the recovered turns land on tiles the route already works;
  • on more production, because v0.3.1's sweep already established that the field is fully allocated and that retargeting any wheat starves the herd's feed chain.

So nothing ships. What ships instead is the tooling and the finding: search/board_paths.py (an env-exact position simulator, the segment decomposition, the shortest-path rewrite and the schedule verifier), scripts/analyse_movement.py, search/route_search.op_repath, and tests/test_board_paths.py. main.py stays on v0.3.1.

Two results are worth carrying into #30 and anything after it:

  1. The route's walking is not the inefficiency. 98.3% of interior movement is Manhattan-required. Any further reduction in the 50% movement share has to come from changing which unit does which task in which order, which is a different problem — and the README already records that reassigning tasks to minimise travel loses 31–38%, because idle units clustering by the shed is load-bearing. #29 was right that this is a different experiment from that one, and the re-path did not reproduce that loss; it simply had almost nothing to find.
  2. A route mutation can act at a distance. Where a unit stands when a HIRE resolves decides where the next hand spawns. Any future operator that changes a unit's position — shift_task_block included — needs board_paths.verify_schedule or an equivalent, not a local argument about the site it edited.

[#30] Co-optimising milk metering with the route's cash schedule — the envelope works, the shed is the real constraint

The premise. MILK realises $17.0/unit against a $160 base (#27's measurement; re-measured here at $21.2 on the v0.3.1 route). Three previous attempts to fix that by metering sales failed, each for a different reason, and #30's diagnosis was that all three moved one leg of a two-legged decision: "the route is not a production plan with a market layer attached — it is a cash schedule." Withhold a sale and the next HIRE fails for cash.

This issue is the first attempt with the envelope actually computed. The headline is that the diagnosis was right and the fix does not pay: the liquidity failure is completely eliminated — zero failed HIRE orders in 1,800 episodes across every metering arm — and the route still loses, because the binding constraint was never liquidity. It is the shed.

The instrument: per-fill attribution, committed this time

scripts/analyse_market_fills.py hooks _commit_unit, _do_hire and _do_buy_land and reports realised $/unit and failed orders. It attributes each one to a seat from a farm-identity map rebuilt at the top of every _process_market call, which is handed state and therefore knows the real order — rebuilt every turn because the observation is re-materialised between steps and a farm is a different object each time. (The arena's own counters were not doing that. See the measurement-bug section below.) A failed HIRE is invisible in the replay, the observation and the final cash — the hand simply never exists — so this is the only way to read #30's gate. v0.3.1 vs opponents/v0_3_1.py, seed 2000000:

product our units our $/unit base vs base
WOOL 142 $206.3 $200 103%
WHEAT 596 $41.7 $25 167%
CARROT 15 $42.3 $35 121%
FERTILIZER 242 $51.8 $100 52%
STRAWBERRY 249 $61.8 $120 52%
MELON 120 $125.6 $250 50%
MILK 254 $21.2 $160 13%

The per-fill traces are the shape #30 describes:

MILK        d8 12@$159  d11 6@$122  d12 5@$101  d13 6@$81  d14 9@$52  d15 18@$10
            d16 6@$4 ... d20 27@$1 ... d29 27@$10
STRAWBERRY  d15 6@$199  d18 13@$191  d21 28@$160  d22 31@$69  d23 33@$5  d24 18@$1 ...

The envelope, and why it is a proof rather than a heuristic

search/cash_schedule.py computes, per step, what the rest of the route still has to pay:

  • HIRE costs fib(hires so far *today*) and _end_of_day resets the counter, so the route's own order stream fixes the whole sequence — 277 hires, $5,977;
  • BUY_LAND walks [1000, 2000, 4000] in order — 2 orders, $3,000;
  • BUY_SEED and BUY_ANIMAL are catalogue prices — $12,530 over 98 and 10 orders;
  • BUY_PRODUCT is the one term that is not knowable offline (it quotes against a shared inventory both seats move), so it is carried as a unit count — 522 WHEAT — and priced from the live observation at a 1.25× safety multiple.

Exactly-priced outlay: $21,507. Money only ever leaves the farm through those orders, so

money(u) >= money(t) - spent(t..u) >= need(t) - spent(t..u) = need(u)     for all u >= t

Hold need(t) and no future order in the route can fail. That inequality is the whole safety argument, and tests/test_cash_schedule.py pins all three copies of the catalogue (env, module, baked agent) against kaggle_environments so it cannot rot.

When the envelope opens is the first real finding. Measured against the actual cash trajectory (seed 2000000), the route runs at $13–$895 of cash through day 10 — it is a razor schedule, not a comfortable one — and money >= need(t) first holds on day 17:

day cash remaining requirement headroom
8 $13 $35,771 −$35,758
12 $10,883 $24,020 −$13,137
16 $18,635 $21,400 −$2,765
17 $21,969 $20,892 +$1,077
22 $40,068 $15,467 +$24,601
29 $56,712 $54 +$56,658

MILK's price has already collapsed to $4 by day 17. So the envelope is open exactly where there is nothing left to protect, and closed exactly where the money is. That is not a defect in the envelope — it is what makes #23's flat floors catastrophic, stated precisely for the first time.

The metering layer, and the ten-arm sweep

_meter_sells in AGENT_TEMPLATE withholds units that would clear below floor × base, and releases everything on any of three conditions: solvency (money < need(step) hands the schedule straight back to the route), shed pressure (obs.private.shed is a lower bound — it predates this turn's PLACE and the end-of-day drop — so the threshold sits well under shedCapacity), and the endgame (from step 672 everything goes; a held unit at the horn scores $0). It never touches a product it is not metering, never moves a non-SELL order, and only ever appends into slots the route left free, so a turn can never be pushed past maxMarketOrdersPerTurn and drop a HIRE off its tail. tests/test_meter_layer.py pins all of it.

Ten arms, each the shipped file with those constants rewritten, on one identical grid — 30 mid seeds × 6 opponents = 180 episodes each, 1,800 total, common random numbers on both axes, seat 0 fixed (scripts/sweep_meter.py):

variant mean panel win worst opp mean cash mean margin margin CVaR₅ shed lost failed HIRE
m_off (v0.3.1) 62.8% 20.0% $106,866 $43,349 −$4,042 1,188 0
m_both_f55_h8 58.3% 13.3% $106,790 $43,086 −$4,527 1,352 0
m_milk_f40 56.1% 23.3% $106,554 $42,646 −$5,313 2,193 0
m_both_f20 55.0% 16.7% $106,634 $42,856 −$4,627 2,088 0
m_straw_f55 54.4% 13.3% $106,622 $42,624 −$5,567 1,924 0
m_milk_f55 53.3% 13.3% $106,571 $42,583 −$5,273 2,255 0
m_both_f55 52.8% 13.3% $106,478 $42,379 −$6,399 2,475 0
m_both_f55_h48 51.1% 13.3% $106,282 $41,750 −$7,158 2,756 0
m_milk_f80 49.4% 6.7% $106,534 $42,440 −$5,323 2,430 0
m_milk_unbounded 44.4% 10.0% $100,283 $32,858 −$36,207 20,248 0

Read the last two columns first. Zero failed HIRE orders in all 1,800 episodes, in every arm, including the one that holds every unit of milk it produces for eleven days. #23's deferral collapsed to $1,090 against a $104,027 baseline because hands stopped materialising; the envelope removes that failure mode completely and provably. The mechanism #30 identified was the right one.

Failed BUY is 288 in every single arm, the incumbent included — those are the route's own two BUY_PRODUCT misses per episode and no metering configuration adds one. So two thirds of #30's instrumentation gate passes everywhere and the third, "zero increase in shed overflow", fails everywhere.

And the route still loses, monotonically in how much stock is held. What replaces the liquidity failure is visible in the same table: shed overflow rises with the hold — 1,188 → 2,255 → 2,756 → 20,248 discarded items over 180 episodes each — while mean cash barely moves. The layer is not losing the metered product's revenue. It is losing everything else's.

Why: the shed is the binding constraint, and marginal revenue on a glutted product is ~$0

shedCapacity is 100 and the incumbent already peaks at 100/100 (measured, seed 2000000, day 29). The unbounded arm makes the mechanism unmistakable on a single episode:

incumbent m_milk_unbounded
MILK 254 u @ $21.2 87 u @ ~$5
STRAWBERRY 249 u @ $61.8 151 u @ $96.5
WOOL 142 u @ $206.3 104 u @ $196.2
MELON 120 u @ $125.6 103 u
final cash $63,601 $49,999

Held milk occupies shed slots, and the goods it displaces are WOOL at $206/unit and STRAWBERRY at $62–97/unit. Note what happened to strawberry's price: it went up, from $61.8 to $96.5, because we sold 98 fewer units into the same market. That is the whole economics of this route in one line — marginal revenue on a glutted product is close to zero, so a shed slot spent holding milk is a shed slot not spent on wool, and the trade is $1 against $200.

The town cannot rescue it either. MILK drains at 18/day at full unlock and the two seats supply ~500 units over 22 days; withholding ours entirely still leaves the market above I₀, so the price never comes back inside the episode. The unbounded arm's milk backlog waits eleven days and flushes on day 28 at $3.

The joint operator: a purchase is not a free variable either

Work item 3 — move a SELL and the BUY it funds together — is implemented as search/route_search.op_move_sell_and_buy, with a scope="all" bulk form because a single-site move of one sale out of 82 is far below the noise floor of a 180-episode grid.

The first bulk measurement failed in a way that is more interesting than the result. Shifting all 78 (SELL, funder) pairs by ±6 steps, seed 2000000:

route our cash opponent MILK units STRAWBERRY units WOOL units
incumbent $63,601 $63,601 254 249 142
all pairs +6 $27,239 $102,121 78 16 74
all pairs −6 $19,113 $102,966 79 4 54

Realised $/unit went up on every product in both arms. What collapsed was volume: the route stopped producing. #30's framing — "sale timing and purchase timing are one decision" — is right but incomplete. It is a three-way coupling: sale → purchase → the unit action the purchase feeds.

  • Forward: _apply_unit_action runs for every unit before _process_market, so a BUY on step t is only usable from t+1, and a PLANT whose seed has not arrived is not merely skipped — the interpreter drops every PLANT of that crop that turn. Delay a BUY_SEED past its PLANT and the field stops.
  • Backward: pulling a sale earlier does not pull the production that fills it. The sale earns nothing and the purchase it funds spends anyway — #23's failure with the sign flipped.
  • HIRE cannot move at all in either direction. A missing hand does not idle: _align_hands truncates the hands list to the live count, so every later slot in that turn's trace shifts by one and the whole hand assignment for the turn is wrong.

So the operator now clamps both directions, and the clamp census is the second real finding of this issue:

funded order orders cannot be delayed at all median slack max
HIRE 277 277 0 0
BUY_LAND 2 2 0 0
BUY_SEED 98 31 3 450
BUY_PRODUCT 71 6 13 43
BUY_ANIMAL 10 3 2 6
total 458 319 (70%)

Seven out of ten of the route's funded orders cannot be delayed by a single step, and 277 of those are hires. There is very little room to move the BUY into, which is the structural reason #30's stronger version cannot buy the sale much time.

With both clamps in place the operator is safe — no route it emits produces an invalid action, and forward moves no longer starve anything — and it is still a reject. Same grid, 30 mid seeds × 6 opponents = 180 episodes per route, 1,620 total:

route hash pairs moved mean panel win worst opp mean cash mean margin
incumbent (v0.3.1) e8c035f9d0 — 62.8% 20.0% $106,866 $43,349
joint+3 9397214e18 5 55.6% 10.0% $106,731 $43,027
joint+1 2c95a64145 11 55.0% 6.7% $106,539 $42,713
joint+6 d70b0e9abe 22 49.4% 6.7% $106,561 $42,534
joint+12 7ae5bae095 16 46.7% 0.0% $106,277 $41,838
joint−1 5fd4602efe 6 33.3% 0.0% $97,838 $31,704
joint−3 1debe2168a 45 33.3% 0.0% $66,865 −$5,684
joint−6 a310028dab 18 33.3% 0.0% $74,186 $1,052
joint−12 0cda56f600 20 33.3% 0.0% $74,662 $2,145

Eight arms, eight losses, monotone in how far the pair moves. joint+3 moves five order pairs out of 927 and costs 7.2 points of panel win rate. Backward moves are catastrophic even at one step, and even with the deposit clamp: pulling six sales one step earlier drops mean cash by $9,028. The route is not merely a cash schedule — it is a tuned one, and its sale turns are pinned on both sides at once.

A measurement bug found by writing the gate down: the arena's counters were on the wrong seat

#30's gate is stated in terms of shed_overflow_lost and failed orders, so those counters had to be trusted, and they could not be. local_arena._install_instrumentation attributed every counter by object identity, on the rule "the first distinct farm/private object seen each turn belongs to player 0", numbering entries by insertion order into one process-wide dict.

Two things are wrong with that. The dict was never cleared between episodes, and a Phase 2 worker runs hundreds — but the deeper problem is that kaggle_environments re-materialises the observation between steps, so a farm is a different object on almost every turn: 59 distinct farm ids in a 48-step episode. The map grew by two entries per turn, and the moment CPython recycled an id the seats swapped. The symptom is unambiguous once you look for it — the same episode, run twice in one process, returned shed_overflow_lost of 7 and then 1.

The fix is to stop guessing. The market-phase counters key on a seat map rebuilt from state at the top of _process_market, which is handed the players in order. The unit-phase counter cannot use identity at all — _apply_unit_action runs before _process_market, so the objects it would key on have already been replaced — but it carries idx, and interpreter calls it as (farmer, hands…) for player 0 and then again for player 1, so idx == 0 is exactly a seat boundary. tests/test_arena_attribution.py pins both halves: the same episode twice must give the same counters, and a seat driving the pass agent must report one action per turn while a seat driving the route reports many, with and without --swap.

This invalidates previously reported shed-overflow figures, including #25's "shed overflow ON → OFF, 10 → 45" table and the "25 → 15 items lost" line in #23's. Those comparisons were between two arms measured the same wrong way, so their direction is probably intact, but the magnitudes are not evidence. Win rates, cash and margins are unaffected throughout — they come from the episode reward, not from this instrumentation. The actions_total / noop_rate telemetry was affected too and is now correct for the first time (6,914 unit actions per episode against the route's 6,999 unit-turns, which is the ramp-up of the hand count).

Verdict

Nothing ships. main.py stays on v0.3.1. #30's first gate — "panel win rate strictly better than the incumbent" — is failed by all eighteen candidates measured (ten metering arms, eight joint-move arms), so no candidate reaches the held-out head-to-head, the ladder or the overflow comparison. This is the fourth attempt at this idea and the first one to fail for a reason that was not already known.

What #30 got right, and it is worth stating plainly because three write-ups now depend on it:

  • The diagnosis was correct. The route is a cash schedule, deferral does break it through liquidity, and the envelope does fix that — zero failed HIRE orders in 1,800 episodes, including in an arm that hoards eleven days of milk. #23's $1,090-against-$104,027 collapse is solved.
  • The prescription was wrong, and the sweep says why. Liquidity was never the binding constraint; it was the first one to bind. Behind it sits shedCapacity, and behind that sits a market where a glutted product's marginal revenue is approximately zero. A shed slot holding milk at $2 is a slot not holding wool at $206, and no amount of timing changes that ratio.

Four things to carry forward:

  1. Marginal revenue on MILK and STRAWBERRY is ~$0, so the pot #30 quotes is not there. Holding 98 strawberries back raised their realised price from $61.8 to $96.5 and reduced total strawberry revenue. The "$31k of foregone milk revenue" is an accounting figure, not a recoverable one: the town drains 18 MILK/day and the two seats supply ~500 over 22 days. The next attempt at this pot has to reduce production of the glutted products, not re-time their sale — which is #27's lever, and #27 already took the part of it that pays.
  2. A purchase is not a free variable. 319 of the route's 458 funded orders cannot be delayed by one step, all 277 hires among them. Any future operator that moves a market order has to own _funder_forward_slack / _sell_backward_slack or an equivalent, exactly as #29 concluded that any operator moving a unit has to own board_paths.verify_schedule.
  3. The route is far more sensitive than its size suggests. Moving five order pairs out of 927 costs 7.2 points of panel win rate. That is the strongest argument yet for the repo's standing rule that a local accept is a veto and not a forecast — and against any future change that perturbs many sites at once on the theory that each one is small.
  4. Write the gate down before you trust the instrument. The only reason the seat-attribution bug was found is that #30's gate is phrased in terms of two counters nobody had previously had to rely on. Every gate in this repo that names a number should be read as also asserting that the number is measured correctly, and that assertion had never been tested.