accuracy 1.000, same stderr 0.000, same status: success. One of them scored three of ten.Inspect is the evaluation framework UK AISI and METR build on, and it is the best-behaved of the eleven tools in my register — it is the only one that writes down what it lost. This page is about the one step where that record does not travel, the two-line demonstration, and the gate that closes it. Nothing here needs an API key.
The short version. Inspect’s default is
correct: fail_on_error=None stops the run on the first sample error and publishes
no results. But a long agent run cannot use that default — one flaky container throws away
hours — so real evals set an error tolerance. Above the default and below the tolerance, the
run succeeds, the headline metric is a mean over the survivors, and stderr is
computed over the survivors too. Inspect keeps the counts in
results.completed_samples and results.total_samples. The number that
a leaderboard row, a system card or a CI threshold consumes does not carry them.
The point is not that Inspect is careless. It is the only framework I tested that records the loss at all. The point is that recording it in the log and publishing it in the metric are different things, and only the second one reaches the person deciding whether a model ships.
Ten samples. Seven raise inside the solver, as a sample does when a container dies or an
upstream returns 503. Three complete and are scored correct. The only variable is
fail_on_error.
| fail_on_error | status | accuracy | stderr | completed / total | |
|---|---|---|---|---|---|
| None (default) | error | — | — | no results | CORRECT |
| True | error | — | — | no results | CORRECT |
| 0.10 | error | — | — | no results | CORRECT |
| 0.75 | success | 1.000 | 0.000 | 3 / 10 | OVER SURVIVORS |
| 0.80 | success | 1.000 | 0.000 | 3 / 10 | OVER SURVIVORS |
| False | success | 1.000 | 0.000 | 3 / 10 | OVER SURVIVORS |
I checked the top three rows first, and they matter more than the bottom three. A gate that only ever fires has not been tested. Inspect’s guard works; the question was only ever what happens once you turn the tolerance up to where a real harness needs it.
Inspect does print a warning to the console on the failing runs:
WARNING: 7 of 10 executed samples (70%) had errors and were not scored.
That warning is in the terminal of whoever launched the run. It is not in the
.eval log’s metric, not in the leaderboard row built from that metric, and
not in the CI comparison against last week’s number.
I ran the identical evaluation a second time with nothing failing. Ten of ten scored.
shrinking (10 samples): mockllm/model intact (10 samples): mockllm/model fail_on_error: 0.75 fail_on_error: 0.75 always_right always_right accuracy 1.000 accuracy 1.000 stderr 0.000 stderr 0.000 status success status success
Every line but the task name is identical.
One of those runs measured ten agent tasks. The other measured three and dropped seven on the floor. From the printed result there is no way to tell which is which — and that is the same seam the Berkeley RDI team walked through in April 2026 when an automated agent took near-perfect scores on eight agent benchmarks without solving a single task. Theirs was an exploit. This is an accident. From the number, a reader cannot tell those apart either, and the accident is the more expensive of the two, because an adversary has to be invited in while a flaky container arrives on its own.
The fix is not to complain about the tolerance — the tolerance is necessary. It is to stop
reporting a point estimate over a set that shrank. A mean is a sum over a count: the
survivors’ sum is fixed at reported × scored, and each lost sample
contributes somewhere in [0, 1]. That gives the interval the metric could occupy over the
set that was actually asked for, and it assumes nothing whatsoever about why a sample
died — which is the only assumption an outside checker is entitled to make.
$ python inspect_denominator.py "logs/*.eval" logs/2026-08-22T04-26-07-00-00_shrinking_Adi3nzrWQLBMoxAvGtaTQe.eval shrinking | mockllm/model | status=success always_right.accuracy reported 1.0000 denominator 3 scored of 10 asked for [OVER SURVIVORS] 7 sample(s) left the set before scoring; the reported figure divides by 3. over the set that was asked for, the same run is somewhere in [0.3000 .. 1.0000] logs/2026-08-22T04-26-08-00-00_intact_mkGwjqoSmt5q69SycyWG6X.eval intact | mockllm/model | status=success always_right.accuracy reported 1.0000 denominator 10 scored of 10 asked for [OVER THE FULL SET] EXIT=2
Exit 0 nothing lost, 2 samples left the set, 3 log
unreadable, 4 the instrument failed its own probe. There is deliberately no code
that means probably fine.
Every claim on this site carries controls that probe the instrument rather than the text,
because a control that cannot fail has told you nothing. Seven of them here, and the seventh is
the one that matters: it feeds the checker a deliberately wrong answer and demands the word
DEAD back, then confirms that saying DEAD actually condemns the run.
If any control stops working the tool gives no verdict at all — it exits 4.
$ python inspect_denominator.py --selftest controls LIVE C1 1.0000 scored over 3 of 10 must open to [0.3, 1.0] LIVE C2 nothing lost must leave the number alone LIVE C3 0.0000 over 3 of 10 must open upward only LIVE C4 the reported figure must lie inside its own interval LIVE C5 scored > intended must be refused LIVE C6 an empty set must be refused, not scored LIVE C7 the checker must report DEAD on a wrong answer, and doing so must condemn the run SELFTEST OK 7 controls live EXIT=0
And the proof that exit 4 is reachable rather than decorative — the arithmetic
broken on purpose, from outside the module:
$ python -c "import inspect_denominator as d; d.bound = lambda *a, **k: (0.0, 0.0); print(d.selftest())"
...
DEAD C1 1.0000 scored over 3 of 10 must open to [0.3, 1.0]
got (0.0, 0.0) want (0.3, 1.0)
...
SELFTEST FAILED - do not trust any verdict above
4
Two files, no API key, no account, about a minute. The fixtures are real Inspect logs
generated against mockllm/model, so the demonstration costs nothing and does not
depend on my copy of anything.
pip install inspect-ai python make_fixtures.py # writes the two .eval logs python inspect_denominator.py --selftest python inspect_denominator.py "logs/*.eval"
"""inspect_denominator — read an Inspect .eval log and say what set the score was over.
Inspect is the only evaluation framework I have tested that records the loss: an
EvalLog carries both `results.total_samples` (the set the run was asked to cover)
and `results.completed_samples` (the set that was actually scored). Its default is
also safe: `fail_on_error=None` aborts the run on the first sample error.
The gap is one step downstream. Long agent runs cannot use that default — a single
flaky container would throw away hours of work — so real evals set a tolerance,
`fail_on_error=0.2` or similar. Above the default and below the tolerance the run
reports `status: success`, the headline metric is a mean over the survivors, and
`stderr` is computed over the survivors too. The counts stay in the log; the number
that gets quoted does not carry them.
This tool replaces the point estimate with the interval the metric could occupy
over the set that was asked for.
python inspect_denominator.py logs/*.eval
python inspect_denominator.py --selftest
Exit codes: 0 nothing lost | 2 samples left the set | 3 log unreadable |
4 the instrument failed its own probe. There is no code that means "probably fine".
"""
from __future__ import annotations
import glob
import sys
from dataclasses import dataclass
EXIT_OK, EXIT_FINDING, EXIT_UNREADABLE, EXIT_INSTRUMENT = 0, 2, 3, 4
@dataclass(frozen=True)
class Reading:
task: str
model: str
status: str
intended: int # samples the run was asked to cover
scored: int # samples that produced a score
metric: str
reported: float # the number the harness prints
lo: float # the metric if every lost sample were worst-case
hi: float # the metric if every lost sample were best-case
@property
def lost(self) -> int:
return self.intended - self.scored
@property
def verdict(self) -> str:
return "OVER SURVIVORS" if self.lost else "OVER THE FULL SET"
def bound(reported: float, scored: int, intended: int,
worst: float = 0.0, best: float = 1.0) -> tuple[float, float]:
"""What the mean could be over `intended`, given `reported` over `scored`.
A mean is a sum divided by a count. The sum over the scored samples is fixed at
reported*scored; each of the lost samples contributes somewhere in [worst, best].
Nothing here assumes anything about why a sample was lost.
"""
if intended <= 0:
raise ValueError("intended denominator must be positive")
if scored > intended:
raise ValueError("more samples scored than were asked for")
total = reported * scored
lost = intended - scored
return ((total + lost * worst) / intended,
(total + lost * best) / intended)
def read(path: str) -> list[Reading]:
from inspect_ai.log import read_eval_log
log = read_eval_log(path, header_only=True)
if log.results is None:
return []
intended = log.results.total_samples
scored = log.results.completed_samples
out = []
for sc in log.results.scores:
for name, m in sc.metrics.items():
if name == "stderr":
continue
lo, hi = bound(float(m.value), scored, intended)
out.append(Reading(
task=log.eval.task, model=log.eval.model, status=log.status,
intended=intended, scored=scored, metric=f"{sc.name}.{name}",
reported=float(m.value), lo=lo, hi=hi))
return out
def render(r: Reading) -> str:
head = (f"{r.task} | {r.model} | status={r.status}\n"
f" {r.metric:<28} reported {r.reported:.4f}\n"
f" denominator {r.scored} scored of {r.intended} asked for"
f" [{r.verdict}]")
if not r.lost:
return head
return (head + "\n"
f" {r.lost} sample(s) left the set before scoring; the reported figure divides by "
f"{r.scored}.\n"
f" over the set that was asked for, the same run is somewhere in "
f"[{r.lo:.4f} .. {r.hi:.4f}]")
# --------------------------------------------------------------------------
# controls — these probe the instrument, not the text. A dead control is RED.
# --------------------------------------------------------------------------
def selftest() -> int:
ok = True
def check(label: str, got, want) -> None:
nonlocal ok
good = got == want
ok = ok and good
print(f" {'LIVE' if good else 'DEAD'} {label}\n got {got!r} want {want!r}")
print("controls")
# C1 the whole point: 1.0000 over 3 of 10 must not stay 1.0000
check("C1 1.0000 scored over 3 of 10 must open to [0.3, 1.0]",
tuple(round(x, 4) for x in bound(1.0, 3, 10)), (0.3, 1.0))
# C2 nothing lost must collapse the interval onto the point — the gate must not
# cry over an honest run
check("C2 nothing lost must leave the number alone",
tuple(round(x, 4) for x in bound(0.42, 50, 50)), (0.42, 0.42))
# C3 a zero must stay reachable from below
check("C3 0.0000 over 3 of 10 must open upward only",
tuple(round(x, 4) for x in bound(0.0, 3, 10)), (0.0, 0.7))
# C4 the interval must contain the reported figure whenever any sample survived
lo, hi = bound(0.8, 4, 9)
check("C4 the reported figure must lie inside its own interval",
lo <= 0.8 <= hi, True)
# C5 an impossible denominator must raise, not return something plausible
try:
bound(1.0, 11, 10)
check("C5 scored > intended must be refused", "returned a value", "ValueError")
except ValueError:
check("C5 scored > intended must be refused", "ValueError", "ValueError")
# C6 a total of zero must be refused rather than divided by
try:
bound(1.0, 0, 0)
check("C6 an empty set must be refused, not scored", "returned a value", "ValueError")
except ValueError:
check("C6 an empty set must be refused, not scored", "ValueError", "ValueError")
# C7 the checker itself must be able to say DEAD. A suite that can only pass is
# not a suite. This runs check() against an answer that is wrong on purpose and
# demands the word DEAD back, without letting it condemn the run.
import io
import contextlib
buf, saved = io.StringIO(), ok
with contextlib.redirect_stdout(buf):
check("negative control", tuple(round(x, 4) for x in bound(1.0, 3, 10)), (1.0, 1.0))
said_dead, condemned = "DEAD" in buf.getvalue(), not ok
ok = saved
check("C7 the checker must report DEAD on a wrong answer, and doing so must "
"condemn the run", (said_dead, condemned), (True, True))
print("\nSELFTEST " + ("OK 7 controls live" if ok
else "FAILED - do not trust any verdict above"))
return EXIT_OK if ok else EXIT_INSTRUMENT
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return EXIT_OK
if argv[0] == "--selftest":
return selftest()
paths: list[str] = []
for a in argv:
paths.extend(sorted(glob.glob(a)) or [a])
if not paths:
print("no logs matched", file=sys.stderr)
return EXIT_UNREADABLE
finding = False
for p in paths:
try:
readings = read(p)
except Exception as e: # noqa: BLE001 — reported, never swallowed
print(f"{p}\n UNREADABLE {type(e).__name__}: {e}")
return EXIT_UNREADABLE
if not readings:
print(f"{p}\n no results in this log (status only)")
continue
print(p)
for r in readings:
print(render(r))
finding = finding or bool(r.lost)
print()
return EXIT_FINDING if finding else EXIT_OK
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
"""Produce two real Inspect logs: one that loses samples, one that loses none.
No API cost — everything runs against mockllm/model. The honest run is the point:
a gate that only ever fires has not been tested. Both logs are then read back by
inspect_denominator.py, which must find the first and stay silent on the second.
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
os.environ.setdefault("INSPECT_LOG_DIR", os.path.join(HERE, "logs"))
from inspect_ai import Task, task, eval # noqa: E402
from inspect_ai.dataset import Sample # noqa: E402
from inspect_ai.solver import solver, TaskState, Generate # noqa: E402
from inspect_ai.scorer import scorer, accuracy, stderr, Score, Target # noqa: E402
N = 10
@solver
def flaky(fail_ids):
async def solve(state: TaskState, generate: Generate) -> TaskState:
if int(state.sample_id) in fail_ids:
raise RuntimeError("upstream 503 — sample never ran")
state.output.completion = "yes"
return state
return solve
@scorer(metrics=[accuracy(), stderr()])
def always_right():
async def score(state: TaskState, target: Target) -> Score:
return Score(value="C")
return score
def build(name, fail_ids):
@task
def t():
return Task(
name=name,
dataset=[Sample(id=i, input="q", target="yes") for i in range(1, N + 1)],
solver=flaky(fail_ids),
scorer=always_right(),
)
return t()
if __name__ == "__main__":
# seven of ten die inside the solver; the tolerance is set above the real error
# rate, which is what a long agent run has to do to survive one flaky container
eval(build("shrinking", set(range(1, 8))), model="mockllm/model",
fail_on_error=0.75, display="plain")
# nothing dies — the negative control, on a real log rather than an assertion
eval(build("intact", set()), model="mockllm/model",
fail_on_error=0.75, display="plain")
print("\nlogs written to", os.environ["INSPECT_LOG_DIR"], file=sys.stderr)
completed_samples beside
total_samples at all. The finding is that the record does not travel to the metric.[0.3000 .. 1.0000]
does not say the true accuracy is 0.3. It says the printed 1.0000 is consistent
with everything from 0.3 upward, which is what a reader needs and does not currently get.