Files
arena/environments/schema_migration/schema_migration/taskset.py
T
kartiandClaude Opus 5 5a99eb86a5 Three more environments, and probe.py to gate all four
canary-trap, fault-localisation and schema-migration, in the three domains the
Environments Hub has nothing in: contamination, operations, data engineering.
None is a port. Each is graded on a slice the model never read.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:03:05 -07:00

122 lines
4.4 KiB
Python

"""schema-migration: a migration is judged on the rows you did not look at.
`readings.value_text` holds numbers and units as one string. The migration has to become
`value_num REAL` plus `unit TEXT`, drop the original column, and lose nothing. The agent
sees five tidy rows; it is graded on forty it never sees, which carry thousands separators,
negatives, decimals, a unit containing a digit and a slash, a value with no unit, and
doubled internal whitespace.
The reward is the migration actually executing against a real database, so there is no
judge and no rubric — SQLite either recomposes the value or it does not. Row count is
scored separately from fidelity because otherwise `DELETE FROM readings` is a perfect
migration: nothing left to be wrong about.
"""
from __future__ import annotations
import re
from pydantic import Field
import verifiers.v1 as vf
from schema_migration.fixture import connect
from schema_migration.run import measure
SYSTEM = """You are writing a SQLite migration.
The table is:
CREATE TABLE readings (id INTEGER PRIMARY KEY, probe TEXT NOT NULL, value_text TEXT NOT NULL);
Migrate it so that `readings` has columns id, probe, value_num (REAL) and unit (TEXT), and
no longer has value_text. `value_num` is the number in value_text; `unit` is what follows
it, trimmed, or an empty string when there is no unit.
Return ONLY SQL in one ```sql code block. It is run with executescript(), so several
statements are fine. Preserve every row and every id.
You are graded on rows you have not seen, from the same column. They are messier than the
ones below."""
_BLOCK = re.compile(r"```(?:sql)?\s*\n(.*?)```", re.DOTALL)
def parse_sql(reply: str) -> str:
blocks = _BLOCK.findall(reply or "")
return (blocks[-1] if blocks else (reply or "")).strip()
class MigrationData(vf.TaskData):
seed: int
held_out: int
class MigrationTask(vf.Task[MigrationData]):
@vf.stop
async def single_turn(self, trace: vf.Trace) -> bool:
return trace.num_turns >= 1
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
outcome = measure(self.data.seed, self.data.held_out, parse_sql(trace.last_reply))
return {
"schema": float(outcome.schema_ok),
"fidelity": outcome.fidelity,
"rows_kept": outcome.rows_kept,
# Rows are a MULTIPLIER on fidelity, not a reward beside it. Scored separately
# they paid 0.15 for adding two empty columns and touching nothing — a floor
# above zero for near-inaction, which the house rules forbid. As a multiplier,
# losing rows still zeroes an otherwise perfect migration and preserving them
# on its own earns nothing.
"integrity": outcome.fidelity * outcome.rows_kept,
"clean": float(outcome.clean),
"errored": float(bool(outcome.error)),
}
@vf.reward(weight=0.30)
async def schema(self, trace: vf.Trace) -> float:
return trace.metrics.get("schema", 0.0)
@vf.reward(weight=0.45)
async def integrity(self, trace: vf.Trace) -> float:
return trace.metrics.get("integrity", 0.0)
@vf.reward(weight=0.25)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
class MigrationConfig(vf.TasksetConfig):
num_tasks: int = Field(48, ge=1)
visible: int = Field(5, ge=1)
held_out: int = Field(40, ge=1)
class MigrationTaskset(vf.Taskset[MigrationTask, MigrationConfig]):
SEED_BASE = 30_000
def load(self) -> list[MigrationTask]:
tasks = []
for i in range(self.config.num_tasks):
seed = self.SEED_BASE + i
# The visible slice is tidy on purpose; the graded one is not.
con, data = connect(seed, self.config.visible, awkward=False)
sample = "\n".join(
f" {i + 1} | probe_{i:03d} | {text}" for i, (text, _, _) in enumerate(data)
)
con.close()
tasks.append(
MigrationTask(
MigrationData(
idx=i,
name=f"readings-{seed}",
prompt=f"Rows in the table:\n\n id | probe | value_text\n{sample}\n\nWrite the migration.",
system_prompt=SYSTEM,
seed=seed,
held_out=self.config.held_out,
),
self.config.task,
)
)
return tasks