← All writing

A benchmark found a bug in my own detector — 0.750 to 1.000

21 September 2026 · 8 min read · 1,673 words

I built a labelled corpus of agent tool-boundary bugs to stop myself making claims about detectors that I had not measured. The first thing it did was find a false positive in my own published scanner, and the fix took an evening.

The numbers, because they are the point:

detector before after
mcpaudit (declaration scanner) — 14 cases P=1.000 R=1.000 P=1.000 R=1.000
agentbound (code scanner) — 5 cases P=0.750 R=1.000 P=1.000 R=1.000

Precision is where the story is. Recall was never the problem: agentbound found all three planted bugs. It also reported a fourth finding that does not exist, and a scanner that cries wolf is a scanner people turn off — which is the same outcome as one that misses everything.


What the corpus is

Eighteen cases, each a small fixture with a label, the rule that should fire, and the source of the pattern: the Google ADK pull requests, MCP annotation semantics, the Unicode tag block used for ASCII smuggling, homoglyph tool names, and one case taken from a bug in agentbound itself.

A detector is a command that prints JSON, so nothing in the harness knows about either of my tools:

python3 -m corpus.cli run --kind code --detector 'agentbound scan {input} --json'
python3 -m corpus.cli run --kind tool-list \
  --detector 'python3 -m mcpaudit.cli audit {input} --json --no-colour'

--kind is not a convenience. Scoring a declaration scanner on framework source reports recall 0 for a tool doing its job; a number is only meaningful for the inputs a detector claims to handle.

The false positive

Case code-pattern-only-in-comments is this file — line 6 is the one that got reported:

# Never do this: self.tools_dict[tool.name] = tool after a warning.
# Also never ship _RESERVED_TOOL_NAMES = frozenset() missing set_model_response.
DOCSTRING = """
    if tool.name in self.tools_dict:
        logger.warning("duplicate")
    self.tools_dict[tool.name] = tool        # <- line 6, and it does not execute
"""

The whole file is documentation. agentbound reported tool-dict-last-wins at line 6, inside the string.

Its masking module already knew this class of mistake: it blanked every comment and every bare string statement (a docstring), because the project's own CI had gone red for three commits when a rule matched the comment block that documents the idiom it detects — the two comment lines above are themselves the regression fixture for that. And it deliberately left string arguments visible, because tool-dict-last-wins reads the logging call's message text as evidence — masking all strings was the first version of that fix, and it silently disabled the rule.

A string assigned to a name fell between the two categories: not a docstring, not an argument, so it was scanned as code. The fix extends the prose classifier to a string that is the entire right-hand side of an assignment. Everything else stays visible, which three tests pin from both sides:

# still a finding: the strings are a literal the reserved-set rule reads
_RESERVED_TOOL_NAMES = {"finish", "transfer_to_agent"}

# still a finding: the message is an argument, not prose
logger.warning("duplicate tool name")
tools_dict[tool.name] = tool

# no longer a finding: a string bound to a name is documentation
DOC = """... tools_dict[tool.name] = tool ..."""

Why nothing was deleted, and why the CI went red on purpose

The tempting move with a failing case is to delete it. The second-most-tempting is to make the build green and move on. Both destroy the value of a benchmark.

So the case stayed failing, and the CI step that ran it carried:

--known-failure code-pattern-only-in-comments

That flag does three things. The named case is reported as an expected failure rather than counted. Any other failure still fails the build. And — the part that matters — a known failure that starts passing is itself an error:

corpus: code-pattern-only-in-comments now passes — remove it from --known-failure

Which is exactly what happened when agentbound v0.1.10 shipped the fix. The entry could not rot into a benchmark that quietly guards one case less: the build went red until I removed it and tightened the gate from a recall floor to precision and recall.

What the fix cost, and what it bought

agentbound's own tests                 111 -> 114   (three added: the assigned string,
                                                     the control beside it, and the
                                                     set-literal guard)
agentbound scan agentbound (contract)  exit 0 -> exit 0
repository-root scan                   25 -> 16     (the nine removed are fixture
                                                     strings inside test constants)
corpus precision                       0.750 -> 1.000

I first published that test count as 114 → 117, and it was wrong at both ends. The number came from counting the file I had just edited rather than the suite, and it survived a commit, a README table and the first draft of this post before a --collect-only disagreed with it. The fix is in the same commit that made me look.

Two of my fixtures were wrong, and I published that too

Before finding the real false positive, the corpus reported two misses. The tempting conclusion — "my detector has a recall gap" — was wrong both times.

tool-reserved-name-shadowing fires when a framework defines a tool (def set_model_response() and omits it from its reserved set. My fixture had the incomplete set but never defined the tool, so it was never an instance of the documented pattern.

confirmation-gate-fails-open needs the whole relation: a signature assignment, the parameters extracted from it, and a dict comprehension filtering arguments by them. My fixture had the first link only.

The corpus was wrong. The fixtures now encode the documented preconditions, and the investigation is written into the corpus README, because "the benchmark was wrong" is a result too.

The guard that found a third defect before it did its job

The corpus asserts a score against a release, so the test has to know which build it is scoring. I added that check — and writing it meant running agentbound --version for the first time, which printed:

0.1.9

on the v0.1.10 release. pyproject.toml carried the bump; agentbound/__init__.py did not; nothing compared them. The CLI, the tag, the release name and pip show had disagreed for a release, and any consumer pinning the project by version had no way to tell which build it got. That consumer was the benchmark, whose entire method is naming the build a number came from.

The v0.1.10 artifact is immutable, so v0.1.11 carries the fix. What carries the guard is a test that compares agentbound.__version__ to pyproject.toml: a second version string no test reads is a second version string that drifts again. Verified by mutation — setting __version__ back to 0.1.9 fails the test, and restoring it passes.

There was a false alarm on the way in, and it is the reason the check exists. Before the version check, the corpus test measured whatever agentbound was on PATH. Mine was 0.1.8, a stale local install three releases behind, and it reported:

cases:    5   passed: 4   errors: 0
precision=0.750  recall=1.000  f1=1.000

That is the exact number this repository exists to prove was fixed. The bug, not the fix — and the test could not tell the difference, because it never asked which detector it was measuring. Both detector rows now identify the build first: agentbound by its reported version, mcpaudit by the commit pip installed from, since it is pinned to a commit and its version string is not bumped per commit. A mismatch skips with a message naming both builds, and CI sets CORPUS_REQUIRE_DETECTORS=1 so the skip becomes a failure there — because a detector that fails to install would otherwise take the benchmark down to the harness's own tests and still show green.

Two tests that were green in CI and red on a clean checkout

The same round turned up a quieter one. Two tests loaded scripts/recall_audit.py with import_module("scripts.recall_audit"), which resolves only when the repository root is on sys.path:

python -m pytest tests/    # working directory on sys.path  -> 114 passed, green
pytest tests/              # not on sys.path                -> 2 failed

CI ran the first form, so the suite was green there and red for anyone who ran it the other way. The tests load the file by path now, one test pins that mechanism, and CI runs both invocations — the stricter one included — so the two cannot diverge again unnoticed.

Three defects, one shape: a number that nothing checked. The test count was wrong, the version was wrong, and the score was being measured from a build nobody had identified. Each fix is an assertion, not a correction.

The mistake that made the harness worth trusting

My harness's contract said a detector prints {"findings": [...]}. The first real detector I pointed it at prints a bare list — because I had written the contract by imagining my own tool. A benchmark that only understands its author's output format is not a benchmark; it is a very elaborate assertion.

The harness now accepts both shapes, and it is tested against fake detectors whose scores are known in advance: a perfect one, a silent one, a trigger-happy one, a crashing one, and one that prints prose instead of JSON. A harness that flatters every input fails its own suite — otherwise "precision 1.000" means nothing.

The caveat, stated where the numbers are

I wrote both the corpus and the detectors it scores. A self-authored benchmark overstates performance, and every report the tool prints says so. What partly mitigates it: the positives cite where each pattern came from in the real world, the negatives are the correct implementation of the same job — which is where precision is actually decided — and the fake detectors keep the harness honest. Treat these as a regression gate, not an independent benchmark.


The corpus: github.com/sushant-me/tool-boundary-corpus · The fix: agentbound v0.1.10 · The version fix it turned up: v0.1.11 · Every claim above is re-checked weekly against its public source by github.com/sushant-me/reputation.

The canonical copy of this post lives in the writeups repository — view the source of this one. Claims about my own work that the ledger covers are re-checked weekly against their public source by reputation — 27 claims today, 19 checked live, and it does not yet extend to every page on this site.