FIX: reject a non-finite threshold that silently scores every response False - #2704
Merged
Roman Lutz (romanlutz) merged 3 commits intoSep 18, 2026
Merged
Conversation
FloatScaleThresholdScorer validated the threshold range but not its finiteness: `nan <= 0 or nan > 1` is False, so a NaN threshold was accepted, and since every comparison against NaN is False the scorer recorded each response as score_value='False' with status COMPLETE - a definitive "no violation" for content it never judged. Guard finiteness with the same idiom the repo already uses for every other (0,1] value (e.g. AudioWhiteNoiseConverter), and add the constructor's first validation tests.
Aligns the arg description with the guard that now rejects non-finite values, and renames the parametrized case so it says which two reasons are covered.
Roman Lutz (romanlutz)
approved these changes
Sep 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
FIX: reject a non-finite threshold that silently scores every response False
Closes #2703
Description
FloatScaleThresholdScoreracceptedthreshold=float("nan"): the constructor guard checks the rangebut not finiteness, and
nan <= 0andnan > 1are bothFalse. The NaN then reaches the comparisonat
pyrit/score/true_false/float_scale_threshold_scorer.py:198, where everyvalue >= nanisFalse, so each response is recorded asscore_value='False'— and line 209 stamps that verdictstatus=ScoreStatus.COMPLETE.That is the bad case #2613 describes for a red-teaming tool, and worse here: the converter family
silently wrote a corrupted artifact, whereas this silently persists a definitive negative verdict. A
run configured this way reports "the target did not violate the objective" for content the threshold
never judged, and nothing in the stored score says so.
It contradicts the constructor's own contract (
:60,:65"Raises ValueError: If the threshold isnot between 0 and 1"), and the distinction the same method draws a few lines above: an aggregate with
no value returns
ScoreStatus.UNDETERMINEDrather than a negative (:174-195). An unreadablethreshold can't reach that branch, so the failure degrades into a real-looking False.
Fix: guard finiteness with the idiom this repo already applies to every other
(0, 1]parameter —audio_white_noise_converter.py:54,audio_volume_converter.py:55,audio_speed_converter.py:54,image_resizing_converter.py:50,image_color_saturation_converter.py:51,cli/pyrit_scan.py:163(the #2560 / #2566 / #2613 family).
FloatScaleThresholdScorerwas the remaining outlier. The raisedmessage is unchanged, so no previously accepted or previously rejected value changes behaviour.
No shipped default passes NaN (every in-repo call site uses a literal, e.g.
setup/initializers/scorers.py:328); the reachable sources are caller-supplied — a threshold computedfrom a calibration sample (
numpy.mean([]),violations / totalwithtotal == 0), or a hand-writtenscenario config, where
json.loads("NaN")and YAML.nanboth yield a clean float. Nothing betweenthe config and the persisted verdict objects, which is what this guard adds.
Tests and Documentation
Reproduced on both trees with the same code; the base run used a detached worktree at
66d77e4cwithsource untouched, so nothing was reverted in place. Full reproduction and measured output in #2703.
test_init_rejects_non_finite_or_outside_unit_range_threshold[nan](DID NOT RAISE ValueError);inf,-inf,0.0,-0.5,1.5already raised, so the test bites exactly this defectpytest -n 4 --dist=loadfile tests/unit, same test file present,source at
66d77e4c): 1 failed, 18061 passed, 10 skipped — that one failure is the same[nan]case and nothing else in the suite changestests/unit/score/test_float_scale_threshold_scorer.py→ 36 passedtests/unit/score→ 1917 passed;tests/unit/executor→ 1194 passed, 33 skipped114s — the same total as the base run above, with the
[nan]case the only difference--cov-fail-under=78→ coverage 95.03%;diff_cover --fail-under=90→ the two changed sourcelines at 100% (0 missing)
pre-commit run --files <both files>→ ruff format, ruff check,ty, async-suffix and the restall Passed
CI-equivalent environment. I also reproduced CI's
dev_allleg (uv sync --extra all, thenpytest -n 4 --dist=loadfile tests/unit): 18061 passed, 10 skipped, 1 failed, and the onlyfailure was
tests/unit/datasets/test_comic_jailbreak_dataset.py::test_fetch_dataset_missing_goal_raises,which is unrelated to this change. Tracking that one honestly across six full-suite runs: it failed
twice on the fix branch (default env once,
dev_allonce) and never on a detached base worktree runwith the same command and venv (2/2 clean apart from the expected
[nan]case); it passes inisolation on both trees, and
tests/unit/datasetsalone passes serially (4678) and under-n 4(4678). So it looks like an ordering-dependent flake under xdist rather than something this diff
causes — I could not prove it either way from these samples, so I am flagging it instead of
claiming it green.
The two new tests are offline and parametrized on both sides of the boundary (reject
nan,inf,-inf,0.0,-0.5,1.5; accept0.0001,1.0). This constructor had nopytest.raisescoverage at all before, so its documented
ValueErrorwas untested.Scope: one guard condition, two docstring sentences, and the tests. Four other comparisons read a
caller-supplied float the same way (
promptgen/fuzzer/fuzzer.py:1125,analytics/text_matching.py:110,analytics/conversation_analytics.py:79,output/scorer/pretty.py:58) but return an in-memory boolean or a colour instead of persisting aCOMPLETEscore, so they are a separate concern — happy to follow up. A NaN score value is not a separate leak here:Score's own validation rejects it(
Float scale scorers must have a score value between 0 and 1. Got nan, checked onmain), so thethreshold side was the only unguarded entry point in this path.
Developed with AI assistance (Claude), reviewed line by line against the code paths above; the
commands and outputs quoted are the ones I ran on the commits named.