Files
arena/environments/fault_localisation/fault_localisation/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

120 lines
4.1 KiB
Python

"""fault-localisation: name what broke, not what is complaining.
One incident, one injected fault, and a log the fault's victims dominate. The service that
actually failed emits a single line and falls silent; the two that depend on it emit a
dozen timeouts between them. Ranking by error volume answers the loudest service, which is
never the right one — the probe in this package asserts that, so the day the generator
drifts into making them coincide the environment fails rather than quietly rewarding the
heuristic it exists to punish.
Three answers are required and they are not redundant. The service can be guessed one time
in four. The fault class can be read off a line without knowing which line matters. Citing
the evidence line is what distinguishes having found the cause from having recognised the
vocabulary, and it is why the gate needs all three rather than a majority.
"""
from __future__ import annotations
import json
import re
from pydantic import Field
import verifiers.v1 as vf
from fault_localisation.incident import FAULTS, SERVICES, build
CLASSES = sorted(f[0] for f in FAULTS)
SYSTEM = f"""You are on call. Read the log and name the ROOT CAUSE.
Return one JSON object in a ```json code block with exactly three keys:
"service" the service that actually failed, one of: {", ".join(SERVICES)}
"fault" the failure class, one of: {", ".join(CLASSES)}
"evidence" the id (e.g. "L07") of the single line that names the cause
The service that failed is not the service producing the most errors. Its dependents
produce the errors; it produces one line and then stops."""
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
def parse_answer(reply: str) -> dict[str, str]:
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except json.JSONDecodeError:
return {}
if not isinstance(parsed, dict):
return {}
return {k: str(v).strip() for k, v in parsed.items() if isinstance(v, (str, int))}
class FaultData(vf.TaskData):
seed: int
class FaultTask(vf.Task[FaultData]):
@vf.stop
async def single_turn(self, trace: vf.Trace) -> bool:
return trace.num_turns >= 1
@vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]:
incident = build(self.data.seed)
answer = parse_answer(trace.last_reply)
service = float(answer.get("service", "").lower() == incident.root)
fault = float(answer.get("fault", "").lower() == incident.fault)
evidence = float(answer.get("evidence", "").upper() == incident.evidence)
return {
"service": service,
"fault": fault,
"evidence": evidence,
"clean": float(service and fault and evidence),
}
@vf.reward(weight=0.30)
async def service(self, trace: vf.Trace) -> float:
return trace.metrics.get("service", 0.0)
@vf.reward(weight=0.25)
async def fault(self, trace: vf.Trace) -> float:
return trace.metrics.get("fault", 0.0)
@vf.reward(weight=0.25)
async def evidence(self, trace: vf.Trace) -> float:
return trace.metrics.get("evidence", 0.0)
@vf.reward(weight=0.20)
async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0)
class FaultConfig(vf.TasksetConfig):
num_tasks: int = Field(64, ge=1)
class FaultTaskset(vf.Taskset[FaultTask, FaultConfig]):
SEED_BASE = 50_000
def load(self) -> list[FaultTask]:
tasks = []
for i in range(self.config.num_tasks):
seed = self.SEED_BASE + i
incident = build(seed)
log = "\n".join(incident.lines)
tasks.append(
FaultTask(
FaultData(
idx=i,
name=f"incident-{seed}",
prompt=f"```\n{log}\n```\n\nName the root cause.",
system_prompt=SYSTEM,
seed=seed,
),
self.config.task,
)
)
return tasks