fix(detectors): stop RefusalOnlyAdversarial over-counting skipped multi-turn attempts - #2111
fix(detectors): stop RefusalOnlyAdversarial over-counting skipped multi-turn attempts#2111Yigtwxx wants to merge 1 commit into
Conversation
…t.outputs Detector results are expected to line up with attempt.outputs, as stated in evaluators/base.py. When RefusalOnlyAdversarial skipped a non-adversarial attempt it returned one None per assistant turn held by the attempt instead, so a multi-turn attempt was scored with more results than it has outputs. probes.fitd.FITD carries several assistant turns per attempt and names this detector as its primary detector, so every skipped intermediate attempt inflated the evaluator's None count. NVIDIA#1415 removed Attempt.all_outputs use from the detectors; this class landed in the same window and kept it. Its sibling JailbreakOnlyAdversarial already returns one None per attempt.outputs. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Yigtwxx <yigiterdogan023@gmail.com>
|
What Please amend title |
|
Sorry, that was opaque. Amended. The old title described the change; the new one describes the defect: when Happy to reword again if you had something else in mind, or to trim the description down. |
|
Re-read your comment: you were pointing at the The description now follows |
|
Confirmed on Windows 11 / Python 3.13 against Reproduced your fail-before by taking this branch and reverting only Byte-identical to what you reported on 3.11, and the test passes on the branch as written. Your completeness claim holds too, though it reads as false at a glance. Grepping |
|
Thanks for the independent repro — good to have it confirmed on 3.13 as well as 3.11. On the grep: agreed, and worth spelling out. Of the 20 I've added a note to the PR description so the next person greping this doesn't have to re-derive it. |
judge.RefusalOnlyAdversarial.detectreturns[None] * len(attempt.all_outputs)when it skips a non-adversarial attempt, but detector results are expected to line up withattempt.outputs.garak/evaluators/base.pystates this inline, next to the index it uses:all_outputscollects every assistant turn across the attempt's conversations;outputscollects the last assistant turn of each. They are equal only for single-turn attempts, so the fix is one line,all_outputstooutputs.probes.fitd.FITDsetsprimary_detector = "judge.RefusalOnlyAdversarial"and is multi-turn, so its intermediate attempts markedis_adversarial: Falseare exactly the misaligning case. Each skipped attempt contributes oneNoneper turn taken rather than one per output, inflating thenonestally_evaluate_one_detectorreports and the length ofdetector_resultswritten toreport.jsonl.attempt.pydocumentsdetector_resultsas "a list of scores corresponding to each of the generator output strings inoutputs".This looks like a missed spot rather than a deliberate choice. #1415 ("remove usage of
Attempt.all_outputs; update detectors to process only latestAttempt.outputs") swept the detectors in October 2025;RefusalOnlyAdversarialwas added on 2025-10-10 with the FITD probe, inside the same window, and keptall_outputs. The sibling classJailbreakOnlyAdversarial, added later for GOAT, does the same job and already returns oneNoneperattempt.outputs. In #1430 @leondz asked for concrete bugs in this area to be raised ahead of the wider architectural change.After this change the only remaining
all_outputsuse undergarak/detectors/is inany.AnyOutput/any.AnyNonspaceOutput, where scoring every turn is the stated purpose of the class. Those are left alone. Greppingall_outputsundergarak/detectors/returns twenty hits, so that claim reads as false at a glance: onlyany.py:21andany.py:34are reads of theattempt.all_outputsproperty. The rest (base.py,misleading.py,propile.py,snowball.py) bind a local namedall_outputsfromattempt.outputs_for(...), which already returns latest-turn outputs — the name is misleading there, the behaviour is not.Checked open PRs before opening this one: fifteen touch
garak/detectors/judge.py(#2013, #1832, #1898, #1970, #1773, #1885, #1717 and #1688 among them) and none modifiesRefusalOnlyAdversarial.detect. No open PR or open issue references this misalignment; #1430, which describes the general indexing mismatch, was closed as completed by #1415.Verification
TestRefusalOnlyAdversarial::test_non_adversarial_returns_one_result_per_output, which builds an attempt holding two assistant turns and one output, asserts that multi-turn precondition, and checks the skip path returns one result per output without calling the judge model. It mirrors the existingTestJailbreakOnlyAdversarial, whose single-turn attempts cannot catch this.python -m pytest tests/Refusal.detect; only the length of the skip branch's list changes.garak/resources/plugin_cache.jsonis not included.Environment: Windows 11, Python 3.11.
black --config pyproject.toml --checkalready flags both touched files onmain, over lines this PR does not go near; the lines added here are black-clean and the pre-existing formatting is left as it is.This change was made with AI assistance (Claude Code): it found the inconsistency, wrote the failing test first, applied the one-line fix and ran the suites quoted above. I reviewed every changed line, confirmed the
all_outputs/outputsdivergence and the #1415 history myself, and ran the tests locally.