grand-exchange, bot-detection and drop-table-inference. All three produce unbounded or irreducible-error scores, unlike the first four, so all three normalise against a REFERENCE STRATEGY rather than an absolute -- which is what makes 1.000 reachable rather than aspirational. drop-table-inference is the clearest case. Scoring KL against the true drop table would put the ceiling out of reach, because sampling error is irreducible: ground truth scores 0.900 against the Bayesian posterior's 1.000, and that ordering is correct. The best estimate available from 1,200 kills is not the true table, and an environment that demands it is measuring luck. grand-exchange needed the market to carry structure a model can actually infer, or profit is noise and no oracle exists. Measured over 120 baskets: the reference earns 38,821 gp and is profitable in 120/120, random orders lose 561, and trading only the random-walk items -- which look like the widest-swinging lines on the board -- loses 16,860. bot-detection generates naive bots, cloaked bots that jitter on purpose, and efficient humans who look bot-like on every naive statistic. Timing features are drawn BEFORE the generator decides who is a bot, so every latency rule sits at chance. Reference discriminator F1 1.000, random 0.430. Each was built by one agent then attacked by two independent reviewers on exploitability and soundness. They earned their keep: drop-table-inference's first reward let a memorised constant score 0.901 without opening the kill log, and its reference was 19% worse than a flat number. probe.py now carries three named fences asserting those attacks stay dead. First evaluation also lands. Nemotron 3.5 Lightning, thinking off, 32 rollouts per environment: 0.570 down to 0.059, every gate at or near zero, nothing solved and nothing unsolvable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
253 lines
12 KiB
Python
253 lines
12 KiB
Python
"""The monster, its drop table, and the kills that came out of it.
|
|
|
|
A task is one monster. Its table is a set of invented items, each with a rate the agent
|
|
never sees, and the log is a sample from it: a visible slice the agent reads and a held-out
|
|
continuation it is graded on. Both come out of one kill stream, so the graded kills are
|
|
literally the *next* ones the monster would have dropped — not a differently-seeded
|
|
population that could differ in ways the agent could not have anticipated.
|
|
|
|
Four properties of the generator are load-bearing, and none is decoration:
|
|
|
|
A LADDER WITH NO REPEATS. Rates are not merely "structured", they are drawn WITHOUT
|
|
REPLACEMENT from two published sets: commons are distinct multiples of 4/128, and the rest
|
|
are distinct unit fractions from 1/128 down to 1/24576. Distinctness is the first half of
|
|
why the tail is learnable. Nothing in a kill log distinguishes one item that dropped zero
|
|
times from another — any estimator that is a function of (count, kills) alone must give them
|
|
the same number, and if that number is also the same across TASKS then a memorised constant
|
|
is a perfect tail estimate and the term is inert. That is exactly what the first version of
|
|
this environment did: its reference gave every zero-count item (0+0.5)/(kills+0.5(n+1)), a
|
|
value with four distinct settings across ninety-six tasks, so a reply that never opened the
|
|
kill log scored 1.000 on the tail. With no repeats, the rungs the OBSERVED items occupy are
|
|
the rungs the unobserved ones cannot: a table whose counts pin 1/128 and 1/384 leaves its
|
|
silent item somewhere below, and one that pins nothing above 1/3072 leaves it somewhere much
|
|
higher.
|
|
|
|
Distinctness alone was not enough, and the number that says so is 8%: with the ladder and
|
|
the elimination but no price, the reference beat the best memorised constant on the tail
|
|
loss by eight percent, and a frequency copy with a constant floor held 0.86 of the term.
|
|
Rungs eliminate, but two SILENT items are still interchangeable, and the irreducible spread
|
|
inside that pair swamped everything the elimination revealed. That is what VALUE_SCALE
|
|
below is for.
|
|
|
|
A PRICE THAT RANKS WHAT THE LOG CANNOT. Every item carries a shop value, lognormal around
|
|
SCALE * (1/rate) ** EXPONENT — rare drops are worth more, which is a fact about the genre
|
|
rather than anybody's copy. It is the only channel that separates two silent items, and
|
|
separating them is what turns the tail from a lottery into an inference: with it the
|
|
reference beats the best memorised constant on the tail by 4.75x rather than by 8%, and the
|
|
frequency-copy-with-a-floor attack falls from 0.86 of the term to 0.28. The scatter is
|
|
deliberate and it is large — sigma 0.55 in log value against 3x rung spacing — so the price
|
|
ranks the candidates and never decides them, and an agent that reads only the price scores
|
|
0.46 where one that reads price, counts and leftover rungs together scores 1.000.
|
|
|
|
A ZERO-COUNT ITEM, ALWAYS. The kill stream is rejected and redrawn until at least one item
|
|
on the list dropped zero times in the visible slice. That case is the entire environment:
|
|
the item list says the item exists, the log says it never appeared, and an estimate of 0 is
|
|
an infinitely strong claim about something the graded kills will demonstrate is false. A
|
|
task where every item happened to show up does not pose the question, so it is not used.
|
|
The rejection is part of the model — `estimate._posterior` divides it back out,
|
|
because conditioning on "some item was silent" is evidence about the table and an estimator
|
|
that ignores it is leaving information in the prompt.
|
|
|
|
HELD-OUT KILLS UNTIL EVERY ITEM APPEARS. The item list is a promise that all of these drop;
|
|
the graded slice has to keep it, or an estimate of zero for the rarest item costs nothing.
|
|
It is also what keeps the counterweight finite: `estimate.restraint` divides by what the
|
|
continuation produced, and an item the continuation never produced would make it infinite.
|
|
|
|
No wiki content: every item, monster and word here is invented. The MECHANICS are the
|
|
borrowed part, and mechanics are facts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import bisect
|
|
import math
|
|
import random
|
|
from dataclasses import dataclass
|
|
|
|
ADJECTIVES = [
|
|
"Ashen", "Brackish", "Cindered", "Duskbound", "Ember", "Frostbit", "Gilded", "Hollow",
|
|
"Ivory", "Jagged", "Kelpish", "Lodestone", "Murkwrought", "Nettled", "Pitchblack",
|
|
"Quarried", "Saltworn", "Tarnished", "Umbral", "Verdigris",
|
|
]
|
|
NOUNS = [
|
|
"talon", "sigil", "vertebra", "censer", "ingot", "lantern", "chitin", "warhorn",
|
|
"tessera", "phial", "reliquary", "scale", "cog", "wick", "grimoire", "spur",
|
|
"anvil-shard", "orb", "tabard", "quill",
|
|
]
|
|
MONSTER_PREFIX = ["Marrow", "Gloom", "Rime", "Slagborn", "Fenwater", "Barrow", "Cinder", "Hush"]
|
|
MONSTER_KIND = ["Warden", "Basilisk", "Revenant", "Colossus", "Hierophant", "Drake", "Herald", "Ossifier"]
|
|
|
|
# The common grid, and it is coarse on purpose. Adjacent numerators sit 33-50% apart, so a
|
|
# few hundred kills can actually tell them apart — a continuous numerator would put the
|
|
# notches inside the sampling error, the structure would be real but unresolvable, and
|
|
# "spot the grid" would be a slogan rather than a strategy.
|
|
COMMON_DENOMINATOR = 128
|
|
COMMON_NUMERATORS = (8, 12, 16, 24, 32, 40, 48)
|
|
|
|
# The rare ladder: unit fractions spaced by roughly a factor of two. No sample of 600 kills
|
|
# resolves 1/1024 from 1/2048 on its own evidence, and that is the point — down here the
|
|
# information is in which rungs the OTHER items have already taken.
|
|
LADDER_DENOMINATORS = (128, 384, 1024, 3072, 8192, 24576)
|
|
|
|
# How many of each. Both are published to the agent, because the reference estimator uses
|
|
# them and a reference the agent cannot compute is an unreachable ceiling wearing a hat.
|
|
N_COMMON = (3, 4)
|
|
N_LADDER = (4, 5, 6)
|
|
|
|
# The price law, and it is the reason the tail is a skill rather than a lottery.
|
|
#
|
|
# Two items that both dropped zero times are EXCHANGEABLE on the kill log: no statistic of
|
|
# the counts distinguishes them, so every estimator must give them the same number, and
|
|
# the reference's advantage over a memorised constant collapses to whatever the free-rung
|
|
# set alone reveals. Measured, that was four to eight percent — the reference beat the best
|
|
# constant on the tail by less than a tenth, which is not an environment, it is a coin.
|
|
#
|
|
# A price breaks the tie, and it is the OSRS-shaped way to break it: rare drops are worth
|
|
# more. `value` is lognormal around SCALE * (1/rate) ** EXPONENT, so it ranks the silent
|
|
# items without deciding them — at 3x rung spacing and this sigma, the price alone gets an
|
|
# adjacent pair the right way round about six times in seven and gets nothing for free two
|
|
# rungs down. The agent has to combine it with the counts and the leftover rungs, which is
|
|
# the entire task.
|
|
VALUE_SCALE = 15.0
|
|
VALUE_EXPONENT = 0.8
|
|
VALUE_LOG_SD = 0.55
|
|
|
|
# The share of kills that drop something. Bounded away from 1 so "no drop" is always a
|
|
# substantial category: it is where the mass an agent does not assign to items has to go,
|
|
# and it is what makes the sum-to-at-most-one constraint bite instead of being cosmetic.
|
|
MIN_ITEM_MASS = 0.55
|
|
MAX_ITEM_MASS = 0.90
|
|
|
|
# Held-out kills stop when every item has been seen; this caps the walk in case a table
|
|
# with a 1/24576 tertiary draws a very long silence. Hitting it costs an item's evidence
|
|
# rather than anything worse, and at twelve times the requested slice it is a 1e-8 event.
|
|
HELD_OUT_CEILING = 12
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DropTable:
|
|
"""One monster's table. `rates` is in DISPLAY order, deliberately shuffled: listing
|
|
items by rarity would hand over the ordering the agent is being asked to recover."""
|
|
|
|
monster: str
|
|
rates: dict[str, float]
|
|
values: dict[str, int]
|
|
|
|
@property
|
|
def items(self) -> list[str]:
|
|
return list(self.rates)
|
|
|
|
@property
|
|
def nothing(self) -> float:
|
|
return 1.0 - sum(self.rates.values())
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Stream:
|
|
"""One task: the table, and both slices as tallies.
|
|
|
|
Tallies rather than kill sequences because the tally is the sufficient statistic — the
|
|
order kills came in carries no information about the table, so serialising 5,000 lines
|
|
would cost tokens and teach nothing.
|
|
"""
|
|
|
|
table: DropTable
|
|
visible: dict[str, int]
|
|
visible_kills: int
|
|
held: dict[str, int]
|
|
held_kills: int
|
|
|
|
@property
|
|
def visible_nothing(self) -> int:
|
|
return self.visible_kills - sum(self.visible.values())
|
|
|
|
@property
|
|
def held_nothing(self) -> int:
|
|
return self.held_kills - sum(self.held.values())
|
|
|
|
|
|
def _names(rng: random.Random, count: int) -> list[str]:
|
|
seen: list[str] = []
|
|
while len(seen) < count:
|
|
name = f"{rng.choice(ADJECTIVES)} {rng.choice(NOUNS)}"
|
|
if name not in seen:
|
|
seen.append(name)
|
|
return seen
|
|
|
|
|
|
def build_table(seed: int) -> DropTable:
|
|
"""The table alone, from the seed. Stable across kill-stream redraws so that a task's
|
|
identity is its monster, not whichever sample happened to be accepted.
|
|
|
|
`sample` rather than `choices` in both tiers: with replacement, two items could share a
|
|
rung, the leftover-rung deduction would be unsound, and the tail would go back to being
|
|
a constant nobody has to read the log for.
|
|
"""
|
|
rng = random.Random(seed)
|
|
monster = f"{rng.choice(MONSTER_PREFIX)} {rng.choice(MONSTER_KIND)}"
|
|
n_common = rng.choice(N_COMMON)
|
|
n_ladder = rng.choice(N_LADDER)
|
|
names = _names(rng, n_common + n_ladder)
|
|
|
|
# Rejection on total mass, with the tier COUNTS fixed outside the loop: redrawing the
|
|
# shape as well would make the split's prior depend on how often that shape lands in
|
|
# the mass window, and the reference's prior — which is P(n_common) * P(n_ladder) —
|
|
# would then be subtly wrong on every task.
|
|
while True:
|
|
numerators = rng.sample(COMMON_NUMERATORS, n_common)
|
|
denominators = rng.sample(LADDER_DENOMINATORS, n_ladder)
|
|
rates = [n / COMMON_DENOMINATOR for n in numerators] + [1.0 / d for d in denominators]
|
|
if MIN_ITEM_MASS <= sum(rates) <= MAX_ITEM_MASS:
|
|
break
|
|
order = list(zip(names, rates))
|
|
rng.shuffle(order)
|
|
table = dict(order)
|
|
# Prices drawn AFTER the shuffle so the display order carries nothing, and from the
|
|
# rate rather than the tier, so a common at 8/128 and a ladder item at 1/128 are priced
|
|
# identically — the price is evidence about the RATE, never about which tier an item
|
|
# came from.
|
|
values = {
|
|
name: max(1, round(VALUE_SCALE * (1.0 / rate) ** VALUE_EXPONENT
|
|
* math.exp(VALUE_LOG_SD * rng.gauss(0.0, 1.0))))
|
|
for name, rate in table.items()
|
|
}
|
|
return DropTable(monster=monster, rates=table, values=values)
|
|
|
|
|
|
def _tally(rng: random.Random, table: DropTable, kills: int, until_covered: bool) -> tuple[dict[str, int], int]:
|
|
"""Roll kills, counting drops. With `until_covered`, keep rolling past `kills` until
|
|
every item has appeared — see the module docstring for why the graded slice owes the
|
|
item list that."""
|
|
items = table.items
|
|
cumulative, running = [], 0.0
|
|
for item in items:
|
|
running += table.rates[item]
|
|
cumulative.append(running)
|
|
counts = {item: 0 for item in items}
|
|
rolled, ceiling = 0, kills * HELD_OUT_CEILING
|
|
while rolled < kills or (until_covered and not all(counts.values()) and rolled < ceiling):
|
|
index = bisect.bisect(cumulative, rng.random())
|
|
if index < len(items):
|
|
counts[items[index]] += 1
|
|
rolled += 1
|
|
return counts, rolled
|
|
|
|
|
|
def build_slices(seed: int, visible_kills: int, held_out_kills: int) -> Stream:
|
|
"""The two slices of one task, from one seed.
|
|
|
|
The kill stream is redrawn — table held fixed — until the visible slice has a
|
|
zero-count item, because a task where every item showed up does not pose the question
|
|
this environment exists to ask. Rejection is on the visible slice only; the held-out
|
|
continuation is whatever the accepted stream produced next.
|
|
"""
|
|
table = build_table(seed)
|
|
for attempt in range(256):
|
|
rng = random.Random((seed << 9) + attempt)
|
|
visible, _ = _tally(rng, table, visible_kills, until_covered=False)
|
|
if all(visible.values()):
|
|
continue
|
|
held, held_kills = _tally(rng, table, held_out_kills, until_covered=True)
|
|
return Stream(table, visible, visible_kills, held, held_kills)
|
|
raise RuntimeError(f"no stream with an unobserved item for seed {seed}")
|