Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions pyrit/score/float_scale/float_scale_score_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ def _create_aggregator(
"""

def aggregator(scores: Iterable[Score]) -> list[ScoreAggregatorResult]:
# Validate types and normalize input
for s in scores:
# Materialize before validating: `scores` is an Iterable, so validating by
# iterating it first would exhaust a generator and leave nothing to aggregate.
scores_list = list(scores)
for s in scores_list:
if s.score_type != "float_scale":
raise ValueError("All scores must be of type 'float_scale'.")

scores_list = list(scores)
if not scores_list:
if raise_on_empty:
raise ValueError("No scores available for aggregation")
Expand Down Expand Up @@ -182,12 +183,13 @@ def _create_aggregator_by_category(
"""

def aggregator(scores: Iterable[Score]) -> list[ScoreAggregatorResult]:
# Validate types and normalize input
for s in scores:
# Materialize before validating: `scores` is an Iterable, so validating by
# iterating it first would exhaust a generator and leave nothing to aggregate.
scores_list = list(scores)
for s in scores_list:
if s.score_type != "float_scale":
raise ValueError("All scores must be of type 'float_scale'.")

scores_list = list(scores)
if not scores_list:
# No scores; return a neutral result
return [
Expand Down
7 changes: 4 additions & 3 deletions pyrit/score/true_false/true_false_score_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,13 @@ def _create_aggregator(
"""

def aggregator(scores: Iterable[Score]) -> ScoreAggregatorResult:
# Validate types and normalize input
for s in scores:
# Materialize before validating: `scores` is an Iterable, so validating by
# iterating it first would exhaust a generator and leave nothing to aggregate.
scores_list = list(scores)
for s in scores_list:
if s.score_type != "true_false":
raise ValueError("All scores must be of type 'true_false'.")

scores_list = list(scores)
if not scores_list:
# No scores; return a neutral result
return ScoreAggregatorResult(
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/score/test_float_scale_score_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# Licensed under the MIT license.


import pytest

from pyrit.models import ComponentIdentifier, Score
from pyrit.score.float_scale.float_scale_score_aggregator import (
FloatScaleScoreAggregator,
Expand Down Expand Up @@ -365,3 +367,48 @@ def test_average_raise_on_empty_with_no_scores():

with pytest.raises(ValueError, match="No scores available for aggregation"):
FloatScaleScoreAggregator.AVERAGE_RAISE_ON_EMPTY([])


def test_aggregators_accept_generators():
"""
Aggregators are typed to take an Iterable, so a generator must aggregate the same
as the equivalent list. Validating by iterating before materializing exhausted the
generator and silently produced the empty-input result (0.0).
"""
values = [0.3, 0.9, 0.5]

aggregators = [
FloatScaleScoreAggregator.MAX,
FloatScaleScoreAggregator.MIN,
FloatScaleScoreAggregator.AVERAGE,
FloatScaleScorerByCategory.MAX,
FloatScaleScorerAllCategories.MAX,
]
for aggregator in aggregators:
from_list = aggregator([_mk_score(v, category=["harm"]) for v in values])
from_generator = aggregator(_mk_score(v, category=["harm"]) for v in values)
assert [r.value for r in from_generator] == [r.value for r in from_list]


def test_raise_on_empty_aggregator_accepts_generators():
"""A generator with scores must not trip the empty-input guard."""
values = [0.3, 0.9, 0.5]
results = FloatScaleScoreAggregator.MAX_RAISE_ON_EMPTY(_mk_score(v) for v in values)
assert results[0].value == 0.9


def test_generator_of_wrong_type_still_raises():
"""Materializing first must not weaken type validation."""
bad = Score(
score_value="true",
score_value_description="",
score_type="true_false",
score_category=["test"],
score_rationale="",
score_metadata=None,
message_piece_id="1",
scorer_class_identifier=_TEST_SCORER_ID,
objective=None,
)
with pytest.raises(ValueError, match="must be of type 'float_scale'"):
FloatScaleScoreAggregator.MAX(s for s in [bad])
34 changes: 34 additions & 0 deletions tests/unit/score/test_true_false_score_aggregator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import pytest

from pyrit.models import ComponentIdentifier, Score
from pyrit.score import TrueFalseScoreAggregator

Expand Down Expand Up @@ -236,3 +238,35 @@ def test_aggregator_single_score():
res = TrueFalseScoreAggregator.OR(scores)
assert res.value is True
assert res.rationale == "Single score rationale"


def test_aggregators_accept_generators():
"""
Aggregators are typed to take an Iterable, so a generator must aggregate the same
as the equivalent list. Validating by iterating before materializing exhausted the
generator and silently produced the empty-input result (False).
"""
values = [False, True, False]

for aggregator in (TrueFalseScoreAggregator.OR, TrueFalseScoreAggregator.AND):
from_list = aggregator([_mk_score(v, prr_id="1") for v in values])
from_generator = aggregator(_mk_score(v, prr_id="1") for v in values)
assert from_generator.value == from_list.value
assert from_generator.description == from_list.description


def test_generator_of_wrong_type_still_raises():
"""Materializing first must not weaken type validation."""
bad = Score(
score_value="0.5",
score_value_description="",
score_type="float_scale",
score_category=["test"],
score_rationale="",
score_metadata=None,
message_piece_id="1",
scorer_class_identifier=_TEST_SCORER_ID,
objective=None,
)
with pytest.raises(ValueError, match="must be of type 'true_false'"):
TrueFalseScoreAggregator.OR(s for s in [bad])
Loading