Skip to content

FIX: threshold scorer attributes result to first score, not the aggregate - #2451

Merged
Richard Lundeen (rlundeen2) merged 2 commits into
microsoft:mainfrom
WatchTree-19:fix-threshold-scorer-aggregate-attribution
Aug 22, 2026
Merged

FIX: threshold scorer attributes result to first score, not the aggregate#2451
Richard Lundeen (rlundeen2) merged 2 commits into
microsoft:mainfrom
WatchTree-19:fix-threshold-scorer-aggregate-attribution

Conversation

@WatchTree-19

Copy link
Copy Markdown
Contributor

Description

FloatScaleThresholdScorer computes its true/false decision from the aggregate of the wrapped scorer's outputs, but when that scorer returns more than one score it reuses scores[0] verbatim for everything except the value. The category, rationale and metadata therefore describe the first constituent score rather than the one that actually crossed the threshold.

AzureContentFilterScorer returns one float_scale score per harm category, ordered by category name, so this is reachable in normal use.

Reproduction

Wrapped scorer returns Hate 0.0, SelfHarm 0.0, Sexual 0.0, Violence 0.857. Threshold 0.5, default MAX aggregator. On main:

score_value       : True
score_category    : ['Hate']
score_metadata    : {'azure_severity': 0, 'original_float_value': 0.857}
rationale         : ... Rationale for scale score: Hate rationale, severity 0

A content-safety flag that fired on Violence, reported as Hate at severity 0, with an original_float_value from a different category sitting next to it. The value is correct and everything explaining it is wrong, so the score reads as a false positive on a category that scored zero. Anyone filtering or reporting by score_category gets the wrong attribution silently.

Fix

The else branch of the same method, taken when every piece is filtered out, already builds the score from aggregate_score.category and aggregate_score.metadata. This makes the populated branch consistent with it, so category, metadata and rationale all come from the aggregate.

For a single wrapped score the aggregator returns that score's own category, metadata and rationale, so that path is unchanged.

Tests

  • test_float_scale_threshold_scorer_attributes_result_to_aggregate_not_first_score fails on main with assert 'Violence' in (['Hate']) and passes with the fix.
  • test_float_scale_threshold_scorer_single_score_attribution_unchanged pins the single-score path so this doesn't quietly change common usage.

The existing test_float_scale_threshold_scorer_returns_single_score_with_multi_category_scorer covers this exact code path but only asserts the value and the number of scores returned, which is why it went unnoticed. Its own fixture data (Hate 0.2, Violence 0.0, Sexual 0.8) would have been mis-attributed to Hate.

All 1628 tests in tests/unit/score pass. ruff format --check and ruff check clean.

Note for reviewers

combine_metadata_and_categories merges constituent metadata with dict.update, so where several scores share a metadata key (azure_severity) the last one wins by iteration order rather than by which score drove the aggregate. That is pre-existing aggregator behaviour and I have not changed it here. If you would prefer the threshold score to attribute to the argmax constituent specifically, rather than to the aggregate as a whole, that is a slightly larger design change and I am happy to do it that way instead.

…gate

FloatScaleThresholdScorer computes its true/false decision from the aggregate of
the wrapped scorer's outputs, but when that scorer returned more than one score it
reused scores[0] verbatim for everything except the value. The category, rationale
and metadata therefore described the first constituent score rather than the one
that actually crossed the threshold.

AzureContentFilterScorer returns one float_scale score per harm category, ordered
by category name, so this is reachable in normal use. For Hate 0.0, SelfHarm 0.0,
Sexual 0.0, Violence 0.857 at threshold 0.5 the scorer produced:

    score_value    True
    score_category ['Hate']
    score_metadata {'azure_severity': 0, 'original_float_value': 0.857}
    rationale      ... Rationale for scale score: Hate rationale, severity 0

A content-safety flag that fires on Violence, reported as Hate at severity 0, with
an original_float_value from a different category sitting beside it. The value is
right and everything explaining it is wrong, so the score reads as a false positive
on a category that scored zero.

The else branch of the same method, taken when every piece is filtered out, already
builds the score from aggregate_score.category and aggregate_score.metadata. This
makes the populated branch consistent with it: category, metadata and rationale now
come from the aggregate. For a single wrapped score the aggregator returns that
score's own category, metadata and rationale, so that path is unchanged.

Adds test_float_scale_threshold_scorer_attributes_result_to_aggregate_not_first_score,
which fails on main with 'Violence' not in ['Hate'], and
test_float_scale_threshold_scorer_single_score_attribution_unchanged to pin the
single-score behaviour. The existing multi-category test asserted only the value and
the number of scores returned, which is why this went unnoticed.

All 1628 tests in tests/unit/score pass.

Signed-off-by: WatchTree-19 <119982314+WatchTree-19@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bdc32b5f-3091-4f95-a8cc-6a4ff10479a1
@rlundeen2
Richard Lundeen (rlundeen2) added this pull request to the merge queue Aug 22, 2026
Merged via the queue into microsoft:main with commit c8b5faa Aug 22, 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