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>
This commit is contained in:
2026-08-19 01:03:05 -07:00
committed by karti
co-authored by Claude Opus 5
parent c28d864766
commit 5a99eb86a5
22 changed files with 1168 additions and 3539 deletions
@@ -0,0 +1,3 @@
from fault_localisation.taskset import FaultTaskset
__all__ = ["FaultTaskset"]
@@ -0,0 +1,94 @@
"""Generating one incident: a service graph, an injected fault, and the logs it produced.
The fault is placed upstream and the noise is emitted downstream, because that is how real
incidents present and it is the one thing a log-reading agent has to get right. A service
whose dependency has stalled logs timeouts, retries and 503s — loudly, once per request —
while the service that actually broke logs a single line about its connection pool and then
goes quiet. Ranking services by error volume therefore points at the victim every time.
Everything is derived from one seed, so a task is reproducible from its integer id and the
answer key is generated alongside the logs rather than written by hand.
"""
from __future__ import annotations
import random
from dataclasses import dataclass
SERVICES = ["edge", "checkout", "ledger", "inventory", "pricing", "vault"]
# (fault class, the single line the ROOT service emits, what its callers emit)
FAULTS = [
("pool_exhausted", "connection pool exhausted (size=16, waiters=88)", "upstream timeout after 3000ms"),
("disk_full", "write failed: no space left on device", "upstream returned 503"),
("cert_expired", "TLS handshake failed: certificate expired", "upstream connection reset"),
("deadlock", "transaction aborted: deadlock detected", "upstream timeout after 3000ms"),
("oom_kill", "worker killed: cgroup memory limit exceeded", "upstream returned 502"),
("clock_skew", "rejecting request: token nbf 41s in the future", "upstream returned 401"),
]
@dataclass
class Incident:
lines: list[str]
"""Log lines, each prefixed with a stable `L07` style id the answer can cite."""
root: str
fault: str
evidence: str
"""The id of the one line that names the cause. Every other line is a symptom."""
def build(seed: int) -> Incident:
rng = random.Random(seed)
root, *rest = rng.sample(SERVICES, 4)
# The callers: they depend on root, so they are the ones that will look broken.
callers = rest[:2]
bystander = rest[2]
fault, root_line, caller_line = rng.choice(FAULTS)
raw: list[tuple[int, str, str]] = []
clock = rng.randint(0, 40)
# Ordinary traffic first, from everyone, so "the service with any errors" is not free.
for _ in range(6):
clock += rng.randint(1, 4)
svc = rng.choice([root, *callers, bystander])
raw.append((clock, svc, rng.choice([
"handled request in 24ms", "cache hit", "healthcheck ok", "handled request in 61ms",
])))
# The cause: one line, once, and then the root goes quiet.
clock += rng.randint(1, 3)
cause_at = clock
raw.append((clock, root, root_line))
# The symptoms: loud, repeated, and in the wrong place.
for _ in range(rng.randint(9, 14)):
clock += rng.randint(1, 3)
raw.append((clock, rng.choice(callers), caller_line))
# A red herring the bystander emits on its own schedule, unrelated to the fault.
clock += rng.randint(1, 2)
raw.append((clock, bystander, "retrying scheduled job (attempt 2)"))
raw.sort(key=lambda r: r[0])
lines, evidence = [], ""
for i, (at, svc, text) in enumerate(raw):
line_id = f"L{i:02d}"
if at == cause_at and svc == root and text == root_line:
evidence = line_id
lines.append(f"{line_id} t+{at:03d}s {svc:<10} {text}")
return Incident(lines=lines, root=root, fault=fault, evidence=evidence)
def loudest(incident: Incident) -> str:
"""The service with the most error lines — what ranking by volume would answer.
Exported because the probe uses it: if this ever starts coinciding with the root, the
environment has stopped testing the thing it was built to test.
"""
counts: dict[str, int] = {}
for line in incident.lines:
svc = line.split()[2]
if "upstream" in line:
counts[svc] = counts.get(svc, 0) + 1
return max(counts, key=lambda k: counts[k]) if counts else ""
@@ -0,0 +1,119 @@
"""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
@@ -0,0 +1,13 @@
[project]
name = "fault-localisation"
version = "0.1.0"
description = "fault-localisation — name the service that broke, not the ones complaining about it."
requires-python = ">=3.11"
dependencies = ["verifiers"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["fault_localisation"]