Skip to content

FIX: stop score aggregators from discarding generator input - #2408

Merged
Roman Lutz (romanlutz) merged 2 commits into
microsoft:mainfrom
VishnuR23:fix/aggregator-generator-exhaustion
Aug 21, 2026
Merged

FIX: stop score aggregators from discarding generator input#2408
Roman Lutz (romanlutz) merged 2 commits into
microsoft:mainfrom
VishnuR23:fix/aggregator-generator-exhaustion

Conversation

@VishnuR23

Copy link
Copy Markdown
Contributor

Description

Every score aggregator is typed to accept an Iterable[Score]:

TrueFalseAggregatorFunc = Callable[[Iterable[Score]], ScoreAggregatorResult]
FloatScaleAggregatorFunc = Callable[[Iterable[Score]], list[ScoreAggregatorResult]]

but each one consumes that iterable twice — validating first, materializing second:

def aggregator(scores: Iterable[Score]) -> ScoreAggregatorResult:
    # Validate types and normalize input
    for s in scores:                      # <-- exhausts a generator
        if s.score_type != "true_false":
            raise ValueError(...)

    scores_list = list(scores)            # <-- now empty

Pass a generator and the validation loop drains it, list(scores) returns [], and the aggregator falls through to its empty-input branch. The scores are silently discarded — no error, no warning.

Reproduction on main, same three scores each time:

                                        list      generator
FloatScaleScoreAggregator.MAX           0.9   ->  0.0
FloatScaleScorerByCategory.MAX          0.9   ->  0.0
FloatScaleScorerAllCategories.MAX       0.9   ->  0.0
TrueFalseScoreAggregator.OR             True  ->  False
FloatScaleScoreAggregator.MAX_RAISE_ON_EMPTY  ->  ValueError: No scores available for aggregation

The true/false case is the one that worries me: a composite scorer that should report True (objective achieved) reports False instead. That is a silent false negative on a successful attack, which is the failure direction you least want in a red-teaming tool. The RAISE_ON_EMPTY variants are less dangerous but actively misleading — they raise "No scores available for aggregation" while holding a perfectly good set of scores.

Affects three call sites: true_false_score_aggregator.py and both aggregator factories in float_scale_score_aggregator.py.

Scope, stated honestly: every in-repo caller currently passes a list or a materialized sequence, so nothing in PyRIT is broken today — this is latent. It matters because these are public, exported API (pyrit.score.__all__ lists TrueFalseScoreAggregator, FloatScaleScoreAggregator, FloatScaleScorerByCategory, FloatScaleScorerAllCategories) and are demonstrated in doc/code/scoring/3_combining_scorers. A user writing a custom scorer that yields scores, or an internal refactor that swaps a list comprehension for a generator expression, hits it immediately and gets a wrong number rather than a traceback.

Changes

Materialize before validating, at all three sites:

scores_list = list(scores)
for s in scores_list:
    if s.score_type != "true_false":
        raise ValueError(...)

Three lines reordered per site. Behavior for list/sequence inputs is unchanged, and type validation still rejects the same inputs with the same message — the only difference is that a bad generator is now fully consumed before raising, rather than short-circuiting on the first bad element.

Tests and Documentation

Added to tests/unit/score/test_true_false_score_aggregator.py and tests/unit/score/test_float_scale_score_aggregator.py:

  • test_aggregators_accept_generators (both files) — asserts a generator aggregates identically to the equivalent list, across OR/AND and MAX/MIN/AVERAGE plus both category-aware factories.
  • test_raise_on_empty_aggregator_accepts_generators — a non-empty generator must not trip the empty-input guard.
  • test_generator_of_wrong_type_still_raises (both files) — pins that materializing first did not weaken type validation.

The three behavioral tests fail on main and pass with this change (verified by reverting only the two source files and re-running: 3 failed, 42 passed).

Verification:

  • pytest tests/unit/score/test_true_false_score_aggregator.py tests/unit/score/test_float_scale_score_aggregator.py -> 45 passed
  • pytest -n 4 --dist=loadfile tests/unit -> full suite green
  • pre-commit run --files <changed> -> all hooks pass, including ruff and ty

No documentation changes — this restores the behavior the existing type signature already advertises.

Every aggregator is typed Callable[[Iterable[Score]], ...] but consumed the
iterable twice: a validation loop ran first, then list(scores) materialized
it. A generator was drained by the validation pass, so list(scores) returned
empty and the aggregator fell through to its empty-input branch, silently
discarding the scores.

Concretely, on the same three scores: FloatScale MAX returned 0.0 instead of
0.9, TrueFalse OR returned False instead of True, and the RAISE_ON_EMPTY
variants raised "No scores available for aggregation". The true/false case is
a silent false negative on a successful attack.

Materialize before validating at all three sites. List inputs are unaffected
and type validation is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@romanlutz Roman Lutz (romanlutz) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not currently an issue we're facing because this is internal only and we can just make sure there are no generators but I suppose there's no harm in accepting the fix.

@romanlutz
Roman Lutz (romanlutz) added this pull request to the merge queue Aug 21, 2026
Merged via the queue into microsoft:main with commit 2fc9a3b Aug 21, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants