Two Inspect runs. Same 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.

Free · no key required · reproduces in about a minute · 22 August 2026 · the rest of the register →

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.

The measurement

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_errorstatusaccuracystderrcompleted / total
None (default)errorno resultsCORRECT
Trueerrorno resultsCORRECT
0.10errorno resultsCORRECT
0.75success1.0000.0003 / 10OVER SURVIVORS
0.80success1.0000.0003 / 10OVER SURVIVORS
Falsesuccess1.0000.0003 / 10OVER 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.

The control that makes it a finding

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 gate

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.

The gate checks itself first

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

Run the whole thing yourself

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.py — the gate, in full (zero dependencies beyond Inspect itself)
"""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:]))
make_fixtures.py — the two runs, in full
"""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)

What this page does not claim