Skip to content

Commit 1cd6e68

Browse files
committed
FIX: reject a threshold aggregator that does not combine scores
1 parent 7b53310 commit 1cd6e68

2 files changed

Lines changed: 87 additions & 1 deletion

File tree

‎pyrit/score/true_false/float_scale_threshold_scorer.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ def __init__(
6060
scorer (FloatScaleScorer): The underlying float scale scorer to use.
6161
threshold (float): A finite threshold in (0, 1]. Scores >= threshold are True, otherwise False.
6262
float_scale_aggregator (FloatScaleAggregatorFunc): The aggregator function to use for combining
63-
multiple float scale scores. Defaults to FloatScaleScoreAggregator.MAX.
63+
multiple float scale scores. Defaults to FloatScaleScoreAggregator.MAX. It must combine the
64+
scores into exactly one result; a by-category aggregator is rejected when scoring.
6465
6566
Raises:
6667
ValueError: If the threshold is non-finite or not in (0, 1].
@@ -151,11 +152,24 @@ def _apply_threshold(
151152
152153
Returns:
153154
list[Score]: A list containing one completed or undetermined true/false score.
155+
156+
Raises:
157+
ValueError: If the configured aggregator does not combine the scores into exactly one result.
154158
"""
155159
objective = expectation.objective if expectation else None
156160

157161
# The wrapped scorer's non-applicable result returns before aggregation.
158162
aggregate_results = self._float_scale_aggregator(scores)
163+
if len(aggregate_results) != 1:
164+
# A by-category aggregator returns one result per category, and the
165+
# threshold can only be applied to a single value. Silently taking the
166+
# first one would decide the verdict from one category and drop the rest.
167+
raise ValueError(
168+
f"{self._float_scale_aggregator.__name__} returned {len(aggregate_results)} results. "
169+
"FloatScaleThresholdScorer requires an aggregator that combines the scores into exactly "
170+
"one result, such as FloatScaleScoreAggregator.MAX; a by-category aggregator such as "
171+
"FloatScaleScorerByCategory.MAX cannot be thresholded."
172+
)
159173
aggregate_score = aggregate_results[0]
160174
aggregate_value = aggregate_score.value
161175
scorer_type = self._scorer.get_identifier().class_name

‎tests/unit/score/test_float_scale_threshold_scorer.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,78 @@ async def test_float_scale_threshold_scorer_attributes_result_to_aggregate_not_f
264264
assert "azure_severity" not in score.score_metadata
265265

266266

267+
async def test_float_scale_threshold_scorer_rejects_aggregator_that_does_not_combine():
268+
"""
269+
A by-category aggregator returns one result per category, and the threshold can only be
270+
applied to a single value. It used to be accepted and only the first result was used, so
271+
the verdict came from whichever category sorted first and the others were dropped without
272+
a log: with Hate at 0.0 and Violence at 0.9 the caller received False.
273+
"""
274+
from pyrit.score.float_scale.float_scale_score_aggregator import FloatScaleScorerByCategory
275+
276+
memory = MagicMock(MemoryInterface)
277+
mock_identifier = ComponentIdentifier(class_name="MockScorer", class_module="test.mock")
278+
279+
prompt_id = uuid.uuid4()
280+
scorer = MagicMock(spec=MessageFloatScaleScorer)
281+
scorer._score_nested_async = AsyncMock(
282+
return_value=[
283+
Score(
284+
score_value=value,
285+
score_type="float_scale",
286+
score_category=[category],
287+
score_rationale=f"{category} rationale",
288+
score_metadata=None,
289+
message_piece_id=prompt_id,
290+
score_value_description="",
291+
scorer_class_identifier=mock_identifier,
292+
id=uuid.uuid4(),
293+
)
294+
for category, value in (("Hate", "0.0"), ("Violence", "0.9"))
295+
]
296+
)
297+
scorer.get_identifier = MagicMock(return_value=mock_identifier)
298+
299+
with patch.object(CentralMemory, "get_memory_instance", return_value=memory):
300+
threshold_scorer = FloatScaleThresholdScorer(
301+
scorer=scorer,
302+
threshold=0.5,
303+
float_scale_aggregator=FloatScaleScorerByCategory.MAX,
304+
)
305+
306+
with pytest.raises(RuntimeError, match="returned 2 results") as exc_info:
307+
await threshold_scorer.score_text_async(text="mock example")
308+
309+
# The message has to name the aggregator that cannot be thresholded, so the caller
310+
# knows to pick a combining one instead of guessing why no score came back.
311+
assert "FloatScaleScorerByCategory.MAX cannot be thresholded" in str(exc_info.value)
312+
memory.add_scores_to_memory.assert_not_called()
313+
314+
315+
async def test_float_scale_threshold_scorer_rejects_aggregator_that_returns_nothing():
316+
"""An aggregator that returns no result is a configuration error, not an IndexError."""
317+
318+
def empty_aggregator(scores):
319+
return []
320+
321+
empty_aggregator.__name__ = "empty_aggregator"
322+
323+
memory = MagicMock(MemoryInterface)
324+
scorer = create_mock_float_scorer(0.9)
325+
326+
with patch.object(CentralMemory, "get_memory_instance", return_value=memory):
327+
threshold_scorer = FloatScaleThresholdScorer(
328+
scorer=scorer,
329+
threshold=0.5,
330+
float_scale_aggregator=empty_aggregator,
331+
)
332+
333+
with pytest.raises(RuntimeError, match="empty_aggregator returned 0 results"):
334+
await threshold_scorer.score_text_async(text="mock example")
335+
336+
memory.add_scores_to_memory.assert_not_called()
337+
338+
267339
async def test_float_scale_threshold_scorer_single_score_attribution_unchanged():
268340
"""A single wrapped score must keep its own category and rationale, as before."""
269341

0 commit comments

Comments
 (0)