From 82a680a90ae576fd8a605106603fdcf166deed40 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Mon, 31 Aug 2026 11:35:35 -0700 Subject: [PATCH 1/6] FEAT: Scoring policy becomes a scorer capability (phase 2.5) Retire `role_filter` and `skip_on_error_result` as per-call scoring parameters. Role policy is now a declared scorer capability (`ScorerPromptValidator.supported_roles`), and readability policy runs after a scorer acquires its evidence instead of before dispatch. `MessageScorer._score_response_with_scorer_async` no longer inspects the response to decide whether each scorer runs. That pre-dispatch filter skipped every scorer in the tree, including scorers whose evidence never came from the response. This unblocks trace and tool-call scoring. An unreadable message now yields an undetermined score rather than no score. A fully blocked response with no content stays false-safe. The retired parameters keep their shape on released 1.0.x entry points and emit a DeprecationWarning, but they are ignored. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9cd5233d-054f-4496-8237-4dd41b2ed544 --- doc/code/framework.md | 4 +- doc/code/scoring/0_scoring.ipynb | 9 +- doc/code/scoring/0_scoring.py | 7 + pyrit/executor/attack/multi_turn/crescendo.py | 2 - .../attack/multi_turn/multi_prompt_sending.py | 2 - .../attack/multi_turn/tree_of_attacks.py | 2 - .../attack/single_turn/prompt_sending.py | 2 - pyrit/score/__init__.py | 3 +- pyrit/score/conversation_scorer.py | 4 +- pyrit/score/message_scorer.py | 228 +++++++--------- pyrit/score/scorer.py | 22 +- pyrit/score/scorer_prompt_validator.py | 31 ++- .../attack/multi_turn/test_crescendo.py | 29 +-- .../attack/multi_turn/test_tree_of_attacks.py | 37 +++ .../attack/single_turn/test_prompt_sending.py | 6 - .../attack/test_error_response_scoring.py | 230 ++++++++++++++++ .../attack/test_error_skip_scoring.py | 245 ------------------ .../score/test_conversation_history_scorer.py | 22 +- tests/unit/score/test_message_scorer.py | 151 ++++++----- tests/unit/score/test_scorer.py | 206 ++++++++++----- 20 files changed, 666 insertions(+), 576 deletions(-) create mode 100644 tests/unit/executor/attack/test_error_response_scoring.py delete mode 100644 tests/unit/executor/attack/test_error_skip_scoring.py diff --git a/doc/code/framework.md b/doc/code/framework.md index 079052bb25..cff53a0248 100644 --- a/doc/code/framework.md +++ b/doc/code/framework.md @@ -258,7 +258,9 @@ If you are contributing to PyRIT, that work will most likely land in one of the - Any decision an attack makes should be based on a scorer result - A scorer is not limited to a message, it could be anything (e.g. was this tool called or was this file written). It receives a `Scorable`, which identifies that evidence, and an optional `ScoringExpectation`. - `TrueFalseScorer` and `FloatScaleScorer` define result families. `MessageScorer` adds message resolution and message-only policy on top of them. -- `Score.status` marks a verdict complete or undetermined, and the attack decides how to branch on it. +- A scorer declares which evidence it reads, rather than the caller filtering evidence for it. A `MessageScorer` states the conversation roles it reads on its `ScorerPromptValidator`, and stays silent when the evidence carries no role it reads. +- Readability is judged after the scorer acquires its own evidence, not before it is called. A scorer that never reads the response must still run when the response failed. +- `Score.status` marks a verdict complete or undetermined, and the attack decides how to branch on it. Evidence a scorer cannot read reports undetermined, so "no verdict was reachable" is never confused with a negative verdict. - **Does not own**: acting on its own result. A scorer evaluates a response and returns a score; branching on that score is the attack's job, and aggregating scores across runs is analytics'. It may call a target to evaluate, but it doesn't send the attack's objective prompt or manage the conversation. **Framework Plans**: diff --git a/doc/code/scoring/0_scoring.ipynb b/doc/code/scoring/0_scoring.ipynb index 404018dd1d..87b0d51029 100644 --- a/doc/code/scoring/0_scoring.ipynb +++ b/doc/code/scoring/0_scoring.ipynb @@ -223,7 +223,14 @@ "`status=\"undetermined\"` and no value. A fully blocked response is a complete negative result\n", "by default: `False` for message true/false scorers and `0.0` for message float-scale scorers.\n", "`SelfAskRefusalScorer` is the intentional exception because a content-filter block is a\n", - "refusal, so it returns `True`. Other response errors remain undetermined." + "refusal, so it returns `True`. Other response errors remain undetermined.\n", + "\n", + "A scorer declares which evidence it reads; the caller does not filter evidence on its behalf.\n", + "A message scorer names the conversation roles it reads with `supported_roles` on its\n", + "`ScorerPromptValidator`, and returns no score when the message carries no role it reads.\n", + "Prepended (`simulated_assistant`) turns are fabricated history, so a scorer must opt in to\n", + "read them. Every scorer still receives a failed response, because a scorer whose evidence\n", + "never came from the response must run even when the response failed." ] }, { diff --git a/doc/code/scoring/0_scoring.py b/doc/code/scoring/0_scoring.py index 60c3100925..3eb426670a 100644 --- a/doc/code/scoring/0_scoring.py +++ b/doc/code/scoring/0_scoring.py @@ -126,6 +126,13 @@ # by default: `False` for message true/false scorers and `0.0` for message float-scale scorers. # `SelfAskRefusalScorer` is the intentional exception because a content-filter block is a # refusal, so it returns `True`. Other response errors remain undetermined. +# +# A scorer declares which evidence it reads; the caller does not filter evidence on its behalf. +# A message scorer names the conversation roles it reads with `supported_roles` on its +# `ScorerPromptValidator`, and returns no score when the message carries no role it reads. +# Prepended (`simulated_assistant`) turns are fabricated history, so a scorer must opt in to +# read them. Every scorer still receives a failed response, because a scorer whose evidence +# never came from the response must run even when the response failed. # %% [markdown] # ## Scoring directly # diff --git a/pyrit/executor/attack/multi_turn/crescendo.py b/pyrit/executor/attack/multi_turn/crescendo.py index db4d01b61b..5e04cf7a54 100644 --- a/pyrit/executor/attack/multi_turn/crescendo.py +++ b/pyrit/executor/attack/multi_turn/crescendo.py @@ -713,9 +713,7 @@ async def _score_response_async(self, *, context: CrescendoAttackContext) -> Sco response=context.last_response, objective_scorer=self._objective_scorer, auxiliary_scorers=self._auxiliary_scorers, - role_filter="assistant", objective=context.objective, - skip_on_error_result=False, ) objective_score = scoring_results["objective_scores"] diff --git a/pyrit/executor/attack/multi_turn/multi_prompt_sending.py b/pyrit/executor/attack/multi_turn/multi_prompt_sending.py index c8abfd8973..35a8312756 100644 --- a/pyrit/executor/attack/multi_turn/multi_prompt_sending.py +++ b/pyrit/executor/attack/multi_turn/multi_prompt_sending.py @@ -408,9 +408,7 @@ async def _evaluate_response_async(self, *, response: Message, objective: str) - response=response, auxiliary_scorers=self._auxiliary_scorers, objective_scorer=self._objective_scorer if self._objective_scorer else None, - role_filter="assistant", objective=objective, - skip_on_error_result=True, ) objective_scores = scoring_results["objective_scores"] diff --git a/pyrit/executor/attack/multi_turn/tree_of_attacks.py b/pyrit/executor/attack/multi_turn/tree_of_attacks.py index 6182aa144d..0853d7c638 100644 --- a/pyrit/executor/attack/multi_turn/tree_of_attacks.py +++ b/pyrit/executor/attack/multi_turn/tree_of_attacks.py @@ -841,9 +841,7 @@ async def _score_response_async(self, *, response: Message, objective: str) -> N response=response, objective_scorer=self._objective_scorer, auxiliary_scorers=self._auxiliary_scorers, - role_filter="assistant", objective=objective, - skip_on_error_result=False, ) # Extract objective score diff --git a/pyrit/executor/attack/single_turn/prompt_sending.py b/pyrit/executor/attack/single_turn/prompt_sending.py index e639046fb0..41a4b44813 100644 --- a/pyrit/executor/attack/single_turn/prompt_sending.py +++ b/pyrit/executor/attack/single_turn/prompt_sending.py @@ -367,9 +367,7 @@ async def _evaluate_response_async( response=response, objective_scorer=self._objective_scorer, auxiliary_scorers=self._auxiliary_scorers, - role_filter="assistant", objective=objective, - skip_on_error_result=True, ) if not self._objective_scorer: diff --git a/pyrit/score/__init__.py b/pyrit/score/__init__.py index 3294a0ef38..af0ecd59c9 100644 --- a/pyrit/score/__init__.py +++ b/pyrit/score/__init__.py @@ -39,7 +39,7 @@ from pyrit.score.float_scale.system_prompt_extraction_scorer import SystemPromptExtractionScorer from pyrit.score.float_scale.video_float_scale_scorer import VideoFloatScaleScorer from pyrit.score.message_scorable_resolver import MessageScorableResolver - from pyrit.score.message_scorer import MessageScorer, MessageScoringOptions + from pyrit.score.message_scorer import MessageScorer from pyrit.score.response_handler import CallableResponseHandler, JsonSchemaResponseHandler, ResponseHandler from pyrit.score.scorable import ContentScorable, MessageScorable, Scorable from pyrit.score.scorer import Scorer @@ -176,7 +176,6 @@ "MessageScorableResolver": "pyrit.score.message_scorable_resolver", "MessageScorable": "pyrit.score.scorable", "MessageScorer": "pyrit.score.message_scorer", - "MessageScoringOptions": "pyrit.score.message_scorer", "MethKeywordScorer": "pyrit.score.true_false.regex.meth_keyword_scorer", "MetricsType": "pyrit.score.scorer_evaluation.metrics_type", "NerveAgentKeywordScorer": "pyrit.score.true_false.regex.nerve_agent_keyword_scorer", diff --git a/pyrit/score/conversation_scorer.py b/pyrit/score/conversation_scorer.py index b1b6178b09..2127e2173b 100644 --- a/pyrit/score/conversation_scorer.py +++ b/pyrit/score/conversation_scorer.py @@ -60,8 +60,8 @@ def _build_scoring_message(self, *, message: Message) -> Message | None: Keep the trigger that identifies the conversation to acquire. The trigger content is not sent to the child scorer. ``_score_prepared_message_async`` - replaces it with a text view of the full conversation. The base class applies - ``skip_on_error_result`` before this hook. + replaces it with a text view of the full conversation. Overriding this hook keeps an + unreadable trigger, because the conversation behind it is still there to read. Returns: Message | None: The trigger message, or None if it has no pieces. diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index 042d54fb56..d3634ecae2 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -5,8 +5,8 @@ import asyncio import logging +import warnings from abc import abstractmethod -from dataclasses import dataclass from typing import TYPE_CHECKING, cast from pyrit.common.deprecation import print_deprecation_message @@ -43,14 +43,6 @@ MESSAGE_BATCH_REMOVED_IN = "1.3.0" -@dataclass(frozen=True, kw_only=True) -class MessageScoringOptions: - """Message-only scoring policy that is not part of evidence identity.""" - - role_filter: ChatMessageRole | None = None - skip_on_error_result: bool = False - - def extract_objective_from_previous_turn(*, message: Message, memory: MemoryInterface) -> str: """ Read the text of the turn before an assistant message and use it as the objective. @@ -116,25 +108,6 @@ def _readable_pieces(*, message: Message, should_score_blocked_content: bool) -> ] -def message_has_readable_content(*, message: Message, should_score_blocked_content: bool) -> bool: - """ - Decide whether a message carries anything a scorer can read. - - Message error state is message-family policy, so it is stated here rather than on the - generic scorer base. An error is not automatically noise, and a single bad piece does not - discard the pieces that came through beside it, so a message is unreadable only when every - one of its pieces is. - - Args: - message (Message): The message to judge. - should_score_blocked_content (bool): Whether content emitted before a block counts as readable. - - Returns: - bool: True when at least one piece is worth scoring. - """ - return bool(_readable_pieces(message=message, should_score_blocked_content=should_score_blocked_content)) - - def _piece_has_readable_content(*, piece: MessagePiece, should_score_blocked_content: bool) -> bool: """ Decide whether a single piece carries anything a scorer can read. @@ -151,16 +124,45 @@ def _piece_has_readable_content(*, piece: MessagePiece, should_score_blocked_con return should_score_blocked_content and piece.is_blocked() and bool(piece.prompt_metadata.get("partial_content")) +def _warn_retired_message_policy( + *, + role_filter: ChatMessageRole | None, + skip_on_error_result: bool | None, +) -> None: + """ + Warn that per-call message policy is retired and no longer applied. + + Both settled before the scorer acquired its evidence, which decided for scorers whose + evidence is not the response and for scorers that widen a message into the conversation + behind it. Role is now declared on the scorer's validator, and an unreadable message + reaches the scorer, which reports what it could not determine. + """ + if role_filter is None and skip_on_error_result is None: + return + warnings.warn( + "'role_filter' and 'skip_on_error_result' are retired and are ignored. Declare " + "'supported_roles' on the scorer's ScorerPromptValidator instead; an unreadable " + "message now produces an undetermined score rather than being skipped.", + DeprecationWarning, + stacklevel=3, + ) + + class MessageScorer(Scorer): """ Base class for scorers whose evidence is a single message. Every message-shaped concern lives here: substituting refusal and blocked content, - validating pieces, applying the role and error filters, and falling back to a neutral + validating pieces, deciding which roles the scorer reads, and falling back to a neutral score. ``Scorer`` stays agnostic about what a scorable is, so scorers over other kinds of evidence can sit beside this one. A ``MessageScorableResolver`` acquires the message; the scorable remains inert. + Message policy applies only after the scorer acquires its own evidence. Nothing filters a + response on a scorer's behalf, because a scorer that widens a message into the + conversation behind it, or that reads evidence the response never held, would lose the + evidence it was going to judge. + Subclasses implement ``_score_async``, which still receives a ``Message``. """ @@ -257,7 +259,6 @@ async def score_async( *, scorable: Scorable | None = None, expectation: ScoringExpectation | None = None, - message_options: MessageScoringOptions | None = None, objective: str | None = None, role_filter: ChatMessageRole | None = None, skip_on_error_result: bool | None = None, @@ -270,20 +271,21 @@ async def score_async( message (Message | None): Deprecated in-hand message. scorable (Scorable | None): Message-shaped evidence to acquire. expectation (ScoringExpectation | None): What to look for. - message_options (MessageScoringOptions | None): Message-family policy. objective (str | None): Deprecated objective string. - role_filter (ChatMessageRole | None): Deprecated role policy. - skip_on_error_result (bool | None): Deprecated error policy. ``None`` means omitted. + role_filter (ChatMessageRole | None): Deprecated and ignored. Declare + ``supported_roles`` on the scorer's validator instead. + skip_on_error_result (bool | None): Deprecated and ignored. An unreadable message + now scores undetermined rather than being skipped before it is acquired. infer_objective_from_request (bool | None): Deprecated inference policy. Returns: - list[Score]: The persisted scores, or an empty list when policy skips the message. + list[Score]: The persisted scores, or an empty list when the scorer does not read + this message's role. """ - resolved_expectation, options, infer_objective = self._consolidate_message_inputs( + resolved_expectation, infer_objective = self._consolidate_message_inputs( message=message, scorable=scorable, expectation=expectation, - message_options=message_options, objective=objective, role_filter=role_filter, skip_on_error_result=skip_on_error_result, @@ -298,14 +300,12 @@ async def score_async( scores = await self._score_resolved_message_async( message=message, expectation=resolved_expectation, - options=options, infer_objective_from_request=infer_objective, ) else: scores = await self._score_message_scorable_async( scorable=cast("Scorable", scorable), expectation=resolved_expectation, - options=options, infer_objective_from_request=infer_objective, ) return await self._validate_and_persist_scores_async(scores=scores) @@ -315,7 +315,6 @@ async def score_message_async( *, message: Message, expectation: ScoringExpectation | None = None, - message_options: MessageScoringOptions | None = None, ) -> list[Score]: """ Score a message that is already in hand. @@ -328,16 +327,15 @@ async def score_message_async( Args: message (Message): The message to score. expectation (ScoringExpectation | None): What to look for. Defaults to None. - message_options (MessageScoringOptions | None): Message-family policy. Defaults to None. Returns: - list[Score]: The persisted scores, or an empty list when policy skips the message. + list[Score]: The persisted scores, or an empty list when the scorer does not read + this message's role. """ self._validate_expectation(expectation=expectation) scores = await self._score_resolved_message_async( message=message, expectation=expectation, - options=message_options or MessageScoringOptions(), infer_objective_from_request=False, ) return await self._validate_and_persist_scores_async(scores=scores) @@ -349,7 +347,7 @@ async def score_prompts_batch_async( objectives: Sequence[str] | None = None, batch_size: int = 10, role_filter: ChatMessageRole | None = None, - skip_on_error_result: bool = False, + skip_on_error_result: bool | None = None, infer_objective_from_request: bool = False, ) -> list[Score]: """ @@ -365,9 +363,10 @@ async def score_prompts_batch_async( objectives (Sequence[str]): The objectives/tasks based on which the prompts should be scored. Must have the same length as messages. batch_size (int): The maximum batch size for processing prompts. Defaults to 10. - role_filter (ChatMessageRole | None): If provided, only score pieces with this role. - Defaults to None (no filtering). - skip_on_error_result (bool): If True, skip scoring pieces that have errors. Defaults to False. + role_filter (ChatMessageRole | None): Deprecated and ignored. Declare + ``supported_roles`` on the scorer's validator instead. + skip_on_error_result (bool | None): Deprecated and ignored. An unreadable message now + scores undetermined rather than being skipped before it is acquired. infer_objective_from_request (bool): If True and objective is empty, attempt to infer the objective from the request. Defaults to False. @@ -383,6 +382,7 @@ async def score_prompts_batch_async( new_item="Scorer.score_batch_async with MessageScorable evidence", removed_in=MESSAGE_BATCH_REMOVED_IN, ) + _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) if objectives is None: resolved_objectives = [""] * len(messages) @@ -404,10 +404,6 @@ async def score_prompts_batch_async( scorables=[MessageScorable.from_message(message) for message in messages], expectations=[ScoringExpectation(objective=objective) for objective in resolved_objectives], batch_size=batch_size, - message_options=MessageScoringOptions( - role_filter=role_filter, - skip_on_error_result=skip_on_error_result, - ), ) @staticmethod @@ -416,21 +412,21 @@ async def score_response_async( response: Message, objective_scorer: Scorer | None = None, auxiliary_scorers: list[Scorer] | None = None, - role_filter: ChatMessageRole = "assistant", objective: str | None = None, - skip_on_error_result: bool = True, ) -> dict[str, list[Score]]: """ Score a response using an objective scorer and optional auxiliary scorers. + Every scorer receives the response as it arrived. Which roles a scorer reads, and + what an unreadable message produces, are the scorer's own declarations, applied after + it acquires its evidence. Filtering here would decide for scorers whose evidence never + came from the response at all. + Args: response (Message): Response containing pieces to score. objective_scorer (Scorer | None): The main scorer to determine success. Defaults to None. auxiliary_scorers (list[Scorer] | None): List of auxiliary scorers to apply. Defaults to None. - role_filter (ChatMessageRole): Only score pieces with this exact stored role. - Defaults to "assistant" (real responses only, not simulated). objective (str | None): Task/objective for scoring context. Defaults to None. - skip_on_error_result (bool): If True, skip scoring pieces that have errors. Defaults to True. Returns: dict[str, list[Score]]: Dictionary with keys `auxiliary_scores` and `objective_scores` @@ -450,9 +446,7 @@ async def score_response_async( aux_scores = await MessageScorer.score_response_multiple_scorers_async( response=response, scorers=auxiliary_scorers, - role_filter=role_filter, objective=objective, - skip_on_error_result=skip_on_error_result, ) result["auxiliary_scores"] = aux_scores # objective_scores remains empty @@ -463,16 +457,12 @@ async def score_response_async( aux_task = MessageScorer.score_response_multiple_scorers_async( response=response, scorers=auxiliary_scorers, - role_filter=role_filter, objective=objective, - skip_on_error_result=skip_on_error_result, ) obj_task = MessageScorer._score_response_with_scorer_async( scorer=objective_scorer, response=response, expectation=ScoringExpectation(objective=objective), - role_filter=role_filter, - skip_on_error_result=skip_on_error_result, ) aux_scores, obj_scores = await asyncio.gather(aux_task, obj_task) result["auxiliary_scores"] = aux_scores @@ -482,8 +472,6 @@ async def score_response_async( scorer=objective_scorer, response=response, expectation=ScoringExpectation(objective=objective), - role_filter=role_filter, - skip_on_error_result=skip_on_error_result, ) result["objective_scores"] = obj_scores return result @@ -493,23 +481,18 @@ async def score_response_multiple_scorers_async( *, response: Message, scorers: list[Scorer], - role_filter: ChatMessageRole = "assistant", objective: str | None = None, - skip_on_error_result: bool = True, ) -> list[Score]: """ Score a response using multiple scorers in parallel. - This method applies each scorer to the first scorable response piece (filtered by role and error), - and returns all scores. This is typically used for auxiliary scoring where all results are needed. + This method applies each scorer to the response and returns all scores. This is + typically used for auxiliary scoring where all results are needed. Args: response (Message): The response containing pieces to score. scorers (list[Scorer]): List of scorers to apply. - role_filter (ChatMessageRole): Only score pieces with this exact stored role. - Defaults to "assistant" (real responses only, not simulated). objective (str | None): Optional objective description for scoring context. - skip_on_error_result (bool): If True, skip scoring pieces that have errors (default: True). Returns: list[Score]: All scores from all scorers @@ -523,8 +506,6 @@ async def score_response_multiple_scorers_async( scorer=scorer, response=response, expectation=expectation, - role_filter=role_filter, - skip_on_error_result=skip_on_error_result, ) for scorer in scorers ] @@ -541,31 +522,13 @@ async def _score_response_with_scorer_async( scorer: Scorer, response: Message, expectation: ScoringExpectation, - role_filter: ChatMessageRole, - skip_on_error_result: bool, ) -> list[Score]: """ - Apply response-scoring policy without storing policy on the scorable. - - Role and error policy decide whether this response is scored at all, so they are - settled here in the message family rather than inside a scorer that may not be - message-shaped. + Name the response as evidence and hand it to the scorer. Returns: - list[Score]: Scores from the scorer, or an empty list when policy skips the response. + list[Score]: Scores from the scorer. """ - if response.get_piece().role != role_filter: - logger.debug("Skipping scoring due to role filter mismatch.") - return [] - if ( - isinstance(scorer, MessageScorer) - and skip_on_error_result - and not message_has_readable_content( - message=response, - should_score_blocked_content=scorer.should_score_blocked_content, - ) - ): - return [] return await scorer.score_async( scorable=MessageScorable.from_message(response), expectation=expectation, @@ -577,42 +540,31 @@ def _consolidate_message_inputs( message: Message | None, scorable: Scorable | None, expectation: ScoringExpectation | None, - message_options: MessageScoringOptions | None, objective: str | None, role_filter: ChatMessageRole | None, skip_on_error_result: bool | None, infer_objective_from_request: bool | None, - ) -> tuple[ScoringExpectation | None, MessageScoringOptions, bool]: + ) -> tuple[ScoringExpectation | None, bool]: if message is not None and scorable is not None: raise ValueError("Pass either 'message' or 'scorable', not both.") if message is None and scorable is None: raise ValueError("Either 'message' or 'scorable' must be provided.") if objective is not None and expectation is not None: raise ValueError("Pass either 'objective' or 'expectation', not both.") - if message_options is not None and (role_filter is not None or skip_on_error_result is not None): - raise ValueError("Pass either 'message_options' or legacy message policy arguments, not both.") uses_legacy_parameters = ( - message is not None - or objective is not None - or role_filter is not None - or skip_on_error_result is not None - or infer_objective_from_request is not None + message is not None or objective is not None or infer_objective_from_request is not None ) if uses_legacy_parameters: print_deprecation_message( - old_item="Scorer.score_async(message=..., objective=..., role_filter=..., " - "skip_on_error_result=..., infer_objective_from_request=...)", - new_item="Scorer.score_async(scorable=..., expectation=..., message_options=...)", + old_item="Scorer.score_async(message=..., objective=..., infer_objective_from_request=...)", + new_item="Scorer.score_async(scorable=..., expectation=...)", removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, ) + _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) resolved_expectation = ScoringExpectation(objective=objective) if objective is not None else expectation - options = message_options or MessageScoringOptions( - role_filter=role_filter, - skip_on_error_result=skip_on_error_result or False, - ) - return resolved_expectation, options, bool(infer_objective_from_request) + return resolved_expectation, bool(infer_objective_from_request) async def _score_scorable_async( self, @@ -621,7 +573,7 @@ async def _score_scorable_async( expectation: ScoringExpectation | None, ) -> list[Score]: """ - Score message-shaped evidence with default message policy. + Score message-shaped evidence. Returns: list[Score]: The scores produced from the resolved message. @@ -629,7 +581,6 @@ async def _score_scorable_async( return await self._score_message_scorable_async( scorable=scorable, expectation=expectation, - options=MessageScoringOptions(), infer_objective_from_request=False, ) @@ -638,7 +589,6 @@ async def _score_message_scorable_async( *, scorable: Scorable, expectation: ScoringExpectation | None, - options: MessageScoringOptions, infer_objective_from_request: bool, ) -> list[Score]: """ @@ -647,12 +597,11 @@ async def _score_message_scorable_async( Args: scorable (Scorable): A ``MessageScorable`` or a ``ContentScorable``. expectation (ScoringExpectation | None): What to look for. - options (MessageScoringOptions): Message-only scoring policy. infer_objective_from_request (bool): Deprecated; read the objective from the previous turn when the expectation carries none. Returns: - list[Score]: The scores, or an empty list when a filter skipped the message. + list[Score]: The scores, or an empty list when the scorer does not read this role. Raises: TypeError: If the scorable is not message-shaped. @@ -661,7 +610,6 @@ async def _score_message_scorable_async( return await self._score_resolved_message_async( message=message, expectation=expectation, - options=options, infer_objective_from_request=infer_objective_from_request, anchor=scorable, ) @@ -671,7 +619,6 @@ async def _score_resolved_message_async( *, message: Message, expectation: ScoringExpectation | None, - options: MessageScoringOptions, infer_objective_from_request: bool, anchor: Scorable | None = None, ) -> list[Score]: @@ -681,14 +628,13 @@ async def _score_resolved_message_async( Args: message (Message): The acquired message. expectation (ScoringExpectation | None): What to look for. - options (MessageScoringOptions): Message-only scoring policy. infer_objective_from_request (bool): Deprecated; read the objective from the previous turn when the expectation carries none. anchor (Scorable | None): The scorable the caller named, when the message was acquired from one. Scores anchor on it rather than on the acquired message. Returns: - list[Score]: The scores, or an empty list when a filter skipped the message. + list[Score]: The scores, or an empty list when the scorer does not read this role. Raises: ScorerLLMResponseBlockedException: If the scorer's own LLM response is blocked by @@ -698,17 +644,10 @@ async def _score_resolved_message_async( """ objective = expectation.objective if expectation else None - if options.role_filter is not None and message.message_pieces[0].role != options.role_filter: - logger.debug("Skipping scoring due to role filter mismatch.") - return [] - - # This gate runs before _build_scoring_message because that method is an override hook: - # a wrapper may keep a piece the filter would drop. Both use _readable_pieces, so the - # gate and the filter agree on what "readable" means. - if options.skip_on_error_result and not message_has_readable_content( - message=message, - should_score_blocked_content=self.should_score_blocked_content, - ): + # A role this scorer does not read means the evidence is not its to judge, which is + # neither a verdict nor a failed acquisition. The scorer says nothing at all. + if not self._reads_any_role(message=message, anchor=anchor): + logger.debug("Skipping scoring: the scorer does not read this message's role.") return [] scoring_message = self._build_scoring_message(message=message) @@ -866,10 +805,10 @@ def _build_scoring_message(self, *, message: Message) -> Message | None: blocked, so refusal scorers keep their deterministic path; content emitted before a block becomes ordinary text once the scorer opts into reading it. - This shares ``_readable_pieces`` with the caller's ``skip_on_error_result`` gate, so the - two agree on what is readable. It stays a separate step because it is an override hook: - a wrapper that uses the message only to locate wider evidence keeps a piece this filter - would drop (see ``ConversationScorer``), and the gate still runs before that override. + This is where readability is decided, and it runs only after the scorer has acquired + its evidence. It stays a separate step because it is an override hook: a wrapper that + uses the message only to locate wider evidence keeps a piece this filter would drop + (see ``ConversationScorer``). Args: message (Message): The acquired message. @@ -891,6 +830,27 @@ def _build_scoring_message(self, *, message: Message) -> Message | None: scoring_message = self._apply_blocked_content_substitution(scoring_message) return scoring_message + def _reads_any_role(self, *, message: Message, anchor: Scorable | None) -> bool: + """ + Decide whether this scorer reads any piece of a message, by role alone. + + Role is asked separately from the rest of the validator because the two answer + different questions. An unread role means the evidence belongs to another scorer, so + this one stays silent; an unsupported data type means this scorer was handed evidence + it should have judged but cannot read, which its family reports as a fallback score. + + Args: + message (Message): The acquired message. + anchor (Scorable | None): The scorable the caller named, when there was one. + + Returns: + bool: True when at least one piece carries a role this scorer reads. + """ + # Loose content is not a conversation turn, so it carries no role to judge. + if anchor is not None and not isinstance(anchor, MessageScorable): + return True + return any(self._validator.is_role_supported(message_piece=piece) for piece in message.message_pieces) + def _get_supported_pieces(self, message: Message) -> list[MessagePiece]: """ Get a list of supported message pieces for this scorer. @@ -1157,7 +1117,7 @@ def _build_neutral_fallback_score( if first_piece.is_blocked(): rationale = f"The response was blocked with no content to score; returning {neutral_value}." description = f"Blocked response; returning {neutral_value}." - elif first_piece.has_error(): + elif first_piece.has_error() or first_piece.converted_value_data_type == "error": # A transport or protocol failure is not the target's answer, so there is no verdict. return [ self._build_undetermined_score( diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index 1fa12afa55..7cbd1a07f0 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -621,33 +621,34 @@ async def score_response_async( response: Message, objective_scorer: Scorer | None = None, auxiliary_scorers: list[Scorer] | None = None, - role_filter: ChatMessageRole = "assistant", + role_filter: ChatMessageRole | None = None, objective: str | None = None, - skip_on_error_result: bool = True, + skip_on_error_result: bool | None = None, ) -> dict[str, list[Score]]: """ Score a response through the message family. Deprecated. Response scoring is message-only policy, so it moved to ``MessageScorer``. + ``role_filter`` and ``skip_on_error_result`` are retired and ignored; a scorer + declares the roles it reads, and applies that after acquiring its evidence. Returns: dict[str, list[Score]]: Auxiliary and objective scores, keyed by ``auxiliary_scores`` and ``objective_scores``. """ - from pyrit.score.message_scorer import MessageScorer + from pyrit.score.message_scorer import MessageScorer, _warn_retired_message_policy print_deprecation_message( old_item="Scorer.score_response_async", new_item="MessageScorer.score_response_async", removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, ) + _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) return await MessageScorer.score_response_async( response=response, objective_scorer=objective_scorer, auxiliary_scorers=auxiliary_scorers, - role_filter=role_filter, objective=objective, - skip_on_error_result=skip_on_error_result, ) @staticmethod @@ -655,29 +656,30 @@ async def score_response_multiple_scorers_async( *, response: Message, scorers: list[Scorer], - role_filter: ChatMessageRole = "assistant", + role_filter: ChatMessageRole | None = None, objective: str | None = None, - skip_on_error_result: bool = True, + skip_on_error_result: bool | None = None, ) -> list[Score]: """ Score a response with several scorers through the message family. Deprecated. + ``role_filter`` and ``skip_on_error_result`` are retired and ignored. + Returns: list[Score]: Every score the scorers produced. """ - from pyrit.score.message_scorer import MessageScorer + from pyrit.score.message_scorer import MessageScorer, _warn_retired_message_policy print_deprecation_message( old_item="Scorer.score_response_multiple_scorers_async", new_item="MessageScorer.score_response_multiple_scorers_async", removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, ) + _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) return await MessageScorer.score_response_multiple_scorers_async( response=response, scorers=scorers, - role_filter=role_filter, objective=objective, - skip_on_error_result=skip_on_error_result, ) async def score_batch_async( diff --git a/pyrit/score/scorer_prompt_validator.py b/pyrit/score/scorer_prompt_validator.py index cfb4c8ed77..cce5950da2 100644 --- a/pyrit/score/scorer_prompt_validator.py +++ b/pyrit/score/scorer_prompt_validator.py @@ -6,6 +6,13 @@ from pyrit.models import ChatMessageRole, Message, MessagePiece, PromptDataType +#: Roles a scorer reads unless it declares otherwise. ``simulated_assistant`` is opt-in +#: because a prepended turn is fabricated history rather than something the target said, +#: and a scorer that judges the target must not mistake one for the other. +DEFAULT_SUPPORTED_ROLES: tuple[ChatMessageRole, ...] = tuple( + role for role in get_args(ChatMessageRole) if role != "simulated_assistant" +) + class ScorerPromptValidator: """ @@ -35,8 +42,9 @@ def __init__( Defaults to all data types if not provided. required_metadata (Sequence[str] | None): Metadata keys that must be present in message pieces. Defaults to empty list. - supported_roles (Sequence[ChatMessageRole] | None): Message roles that the scorer supports. - Defaults to all roles if not provided. + supported_roles (Sequence[ChatMessageRole] | None): Message roles that the scorer reads. Roles are + compared against the stored role, so ``simulated_assistant`` must be listed to read prepended + turns. Defaults to every role except ``simulated_assistant``. max_pieces_in_response (int | None): Maximum number of pieces allowed in a response. Defaults to None (no limit). max_text_length (int | None): Maximum character length for text data type pieces. @@ -56,7 +64,7 @@ def __init__( if supported_roles: self._supported_roles = supported_roles else: - self._supported_roles = get_args(ChatMessageRole) + self._supported_roles = DEFAULT_SUPPORTED_ROLES self._required_metadata = required_metadata or [] @@ -114,6 +122,21 @@ def validate(self, message: Message, objective: str | None) -> None: if self._is_objective_required and not objective: raise ValueError("Objective is required but not provided.") + def is_role_supported(self, message_piece: MessagePiece) -> bool: + """ + Check whether this scorer reads pieces in the given piece's role. + + The stored role is compared rather than ``api_role``, so a prepended + ``simulated_assistant`` turn stays distinguishable from a real response. + + Args: + message_piece (MessagePiece): The message piece to check. + + Returns: + bool: True if the scorer reads this role. + """ + return message_piece.role in self._supported_roles + def is_message_piece_supported(self, message_piece: MessagePiece) -> bool: """ Check if a message piece is supported by this validator. @@ -131,7 +154,7 @@ def is_message_piece_supported(self, message_piece: MessagePiece) -> bool: if metadata not in message_piece.prompt_metadata: return False - if message_piece.api_role not in self._supported_roles: + if not self.is_role_supported(message_piece): return False # Check text length limit for text data types diff --git a/tests/unit/executor/attack/multi_turn/test_crescendo.py b/tests/unit/executor/attack/multi_turn/test_crescendo.py index ff3d611b0e..2a4bac2ecd 100644 --- a/tests/unit/executor/attack/multi_turn/test_crescendo.py +++ b/tests/unit/executor/attack/multi_turn/test_crescendo.py @@ -1122,7 +1122,7 @@ async def test_score_response_raises_when_no_response( with pytest.raises(ValueError, match="No response available in context to score"): await attack._score_response_async(context=basic_context) - async def test_score_response_does_not_skip_on_error_result( + async def test_score_response_scores_error_responses( self, mock_objective_target: MagicMock, mock_adversarial_chat: MagicMock, @@ -1131,14 +1131,12 @@ async def test_score_response_does_not_skip_on_error_result( sample_response: Message, success_objective_score: Score, ): - """Test that _score_response_async does not skip scoring on error responses. + """Test that _score_response_async carries no skip policy. - When the target returns an error response (e.g., blocked by content filter), - the objective scorer should still be called with skip_on_error_result=False - so that error responses get scored (as false/not achieved) rather than - raising RuntimeError due to empty score list. - - This allows Crescendo to gracefully handle all-rejection scenarios. + When the target returns an error response (e.g., blocked by a content filter), + the objective scorer still receives the response and reports an undetermined + verdict rather than no verdict, so Crescendo never raises RuntimeError on an + empty score list. This lets Crescendo handle all-rejection scenarios. """ adversarial_config = AttackAdversarialConfig(target=mock_adversarial_chat) scoring_config = AttackScoringConfig(objective_scorer=mock_objective_scorer) @@ -1151,7 +1149,6 @@ async def test_score_response_does_not_skip_on_error_result( basic_context.last_response = sample_response - # Mock MessageScorer.score_response_async to capture the call arguments with patch( "pyrit.score.MessageScorer.score_response_async", new_callable=AsyncMock, @@ -1159,14 +1156,10 @@ async def test_score_response_does_not_skip_on_error_result( ) as mock_score_response: await attack._score_response_async(context=basic_context) - # Verify score_response_async was called with skip_on_error_result=False mock_score_response.assert_called_once() call_kwargs = mock_score_response.call_args.kwargs - assert call_kwargs.get("skip_on_error_result") is False, ( - "MessageScorer.score_response_async must be called with skip_on_error_result=False " - "to ensure error responses are scored rather than skipped, " - "allowing Crescendo to handle all-rejection scenarios gracefully" - ) + assert "skip_on_error_result" not in call_kwargs + assert "role_filter" not in call_kwargs async def test_check_refusal_detects_refusal( self, @@ -1195,7 +1188,7 @@ async def test_check_refusal_detects_refusal( assert result == refusal_score mock_refusal_scorer.score_async.assert_called_once() - async def test_check_refusal_does_not_skip_on_error_result( + async def test_check_refusal_scores_error_responses( self, mock_objective_target: MagicMock, mock_adversarial_chat: MagicMock, @@ -1229,7 +1222,9 @@ async def test_check_refusal_does_not_skip_on_error_result( # Refusal scoring carries no skip policy, so an error response is still scored # rather than skipped, which would leave scores[0] to raise IndexError. mock_refusal_scorer.score_async.assert_called_once() - assert "message_options" not in mock_refusal_scorer.score_async.call_args.kwargs + call_kwargs = mock_refusal_scorer.score_async.call_args.kwargs + assert "skip_on_error_result" not in call_kwargs + assert "role_filter" not in call_kwargs @pytest.mark.usefixtures("patch_central_database") diff --git a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py index d34c8a0f57..ad88a9346b 100644 --- a/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py +++ b/tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py @@ -1947,6 +1947,43 @@ async def test_node_later_scorer_failure_clears_previous_turn_outcome(self, node assert node.error_message == "Execution error: scorer unavailable" assert basic_attack._get_completed_nodes_sorted_by_score([node]) == [] + async def test_node_scores_error_response_without_retired_policy(self, node_components): + """An error response reaches the scorer, and the node names no per-call scoring policy.""" + node = _TreeOfAttacksNode(**node_components) + error_response = Message( + message_pieces=[ + MessagePiece( + role="assistant", + original_value="Content filter error", + response_error="blocked", + converted_value_data_type="error", + ) + ] + ) + + undetermined = Score( + score_value=None, + status=ScoreStatus.UNDETERMINED, + score_value_description="Error response; no verdict was reachable.", + score_type="float_scale", + score_rationale="Response had an error: blocked; no verdict was reachable.", + message_piece_id=error_response.message_pieces[0].id, + scorer_class_identifier=node._objective_scorer.get_identifier(), + objective="Test objective", + ) + + with patch( + "pyrit.score.message_scorer.MessageScorer.score_response_async", + new_callable=AsyncMock, + return_value={"objective_scores": [undetermined], "auxiliary_scores": []}, + ) as mock_score: + await node._score_response_async(response=error_response, objective="Test objective") + + call_kwargs = mock_score.await_args.kwargs + assert call_kwargs["response"] is error_response + assert "skip_on_error_result" not in call_kwargs + assert "role_filter" not in call_kwargs + async def test_node_empty_objective_scores_marks_turn_incomplete(self, node_components): """A scorer response without an objective score must prune the branch.""" node = _TreeOfAttacksNode(**node_components) diff --git a/tests/unit/executor/attack/single_turn/test_prompt_sending.py b/tests/unit/executor/attack/single_turn/test_prompt_sending.py index 1be2cd9fe8..50c821707c 100644 --- a/tests/unit/executor/attack/single_turn/test_prompt_sending.py +++ b/tests/unit/executor/attack/single_turn/test_prompt_sending.py @@ -654,9 +654,7 @@ async def test_evaluate_response_with_objective_scorer_returns_score( response=sample_response, auxiliary_scorers=attack._auxiliary_scorers, objective_scorer=mock_true_false_scorer, - role_filter="assistant", objective="Test objective", - skip_on_error_result=True, ) async def test_evaluate_response_without_objective_scorer_returns_none(self, mock_target, sample_response): @@ -676,9 +674,7 @@ async def test_evaluate_response_without_objective_scorer_returns_none(self, moc response=sample_response, auxiliary_scorers=attack._auxiliary_scorers, objective_scorer=None, - role_filter="assistant", objective="Test objective", - skip_on_error_result=True, ) async def test_evaluate_response_with_auxiliary_scorers( @@ -718,9 +714,7 @@ async def test_evaluate_response_with_auxiliary_scorers( response=sample_response, auxiliary_scorers=[auxiliary_scorer], objective_scorer=mock_true_false_scorer, - role_filter="assistant", objective="Test objective", - skip_on_error_result=True, ) diff --git a/tests/unit/executor/attack/test_error_response_scoring.py b/tests/unit/executor/attack/test_error_response_scoring.py new file mode 100644 index 0000000000..f9efcaf23e --- /dev/null +++ b/tests/unit/executor/attack/test_error_response_scoring.py @@ -0,0 +1,230 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Unit tests for what attack executors do when a target returns an error response. + +Scoring policy is a scorer capability, so no executor filters a response before scoring. +An unreadable response reaches every scorer, and the message family reports an undetermined +score rather than staying silent. +""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pyrit.executor.attack import ( + AttackAdversarialConfig, + AttackParameters, + AttackScoringConfig, + ConversationSession, + CrescendoAttack, + CrescendoAttackContext, + MultiPromptSendingAttack, + PromptSendingAttack, +) +from pyrit.models import ( + AttackOutcome, + ComponentIdentifier, + Message, + MessagePiece, + Score, + ScoreStatus, +) +from pyrit.prompt_target import PromptTarget +from pyrit.score import TrueFalseScorer + +OBJECTIVE = "test objective" + + +def _mock_target_id(name: str = "MockTarget") -> ComponentIdentifier: + return ComponentIdentifier(class_name=name, class_module="test_module") + + +def _mock_scorer_id(name: str = "MockScorer") -> ComponentIdentifier: + return ComponentIdentifier(class_name=name, class_module="test_module") + + +@pytest.fixture +def mock_target(): + target = MagicMock(spec=PromptTarget) + target.send_prompt_async = AsyncMock() + target.get_identifier.return_value = _mock_target_id("MockTarget") + return target + + +@pytest.fixture +def mock_memory(): + memory = MagicMock() + memory.get_conversation_messages.return_value = [] + memory.add_message_to_memory = MagicMock() + return memory + + +def create_error_response(conversation_id: str) -> Message: + """ + Build a response that carries a transport error rather than the target's answer. + + Returns: + Message: A single-piece assistant message flagged as an error. + """ + return Message( + message_pieces=[ + MessagePiece( + role="assistant", + original_value="Content filter error", + conversation_id=conversation_id, + response_error="blocked", + converted_value_data_type="error", + ) + ] + ) + + +def _adversarial_config() -> AttackAdversarialConfig: + adversarial_target = MagicMock(spec=PromptTarget) + adversarial_target.send_prompt_async = AsyncMock() + adversarial_target.get_identifier.return_value = _mock_target_id("AdversarialTarget") + return AttackAdversarialConfig(target=adversarial_target) + + +def _build_prompt_sending(target, scorer): + attack = PromptSendingAttack( + objective_target=target, + attack_scoring_config=AttackScoringConfig(objective_scorer=scorer, use_score_as_feedback=False), + ) + return attack, lambda response: attack._evaluate_response_async(response=response, objective=OBJECTIVE) + + +def _build_multi_prompt_sending(target, scorer): + attack = MultiPromptSendingAttack( + objective_target=target, + attack_scoring_config=AttackScoringConfig(objective_scorer=scorer, use_score_as_feedback=False), + ) + return attack, lambda response: attack._evaluate_response_async(response=response, objective=OBJECTIVE) + + +def _build_crescendo(target, scorer): + refusal_scorer = MagicMock(spec=TrueFalseScorer) + refusal_scorer.score_async = AsyncMock(return_value=[]) + refusal_scorer.get_identifier.return_value = _mock_scorer_id("RefusalScorer") + scoring_config = AttackScoringConfig(objective_scorer=scorer, use_score_as_feedback=False) + scoring_config.refusal_scorer = refusal_scorer + attack = CrescendoAttack( + objective_target=target, + attack_adversarial_config=_adversarial_config(), + attack_scoring_config=scoring_config, + ) + + def invoke(response): + context = CrescendoAttackContext( + params=AttackParameters(objective=OBJECTIVE), + session=ConversationSession(), + last_response=response, + ) + return attack._score_response_async(context=context) + + return attack, invoke + + +ATTACK_BUILDERS = [ + _build_prompt_sending, + _build_multi_prompt_sending, + _build_crescendo, +] + + +@pytest.mark.parametrize( + "build_attack", + ATTACK_BUILDERS, + ids=["PromptSending", "MultiPromptSending", "Crescendo"], +) +@patch("pyrit.memory.CentralMemory.get_memory_instance") +async def test_attack_executor_does_not_filter_error_response( + mock_memory_instance, mock_target, mock_memory, build_attack +): + """ + Test that no executor decides scoring policy for the scorers it holds. + + An executor that dropped an error response before scoring would also drop a scorer whose + evidence never came from that response, so each executor passes the response as it arrived. + """ + mock_memory_instance.return_value = mock_memory + + scorer = MagicMock(spec=TrueFalseScorer) + scorer.score_async = AsyncMock(return_value=[]) + scorer.get_identifier.return_value = _mock_scorer_id("MockScorer") + + attack, invoke_scoring = build_attack(mock_target, scorer) + error_response = create_error_response(str(uuid.uuid4())) + + undetermined = _undetermined_score(error_response) + with patch( + "pyrit.score.message_scorer.MessageScorer.score_response_async", + new=AsyncMock(return_value={"objective_scores": [undetermined], "auxiliary_scores": []}), + ) as mock_score: + await invoke_scoring(error_response) + + assert mock_score.await_count == 1, f"{type(attack).__name__} did not score the error response" + call_kwargs = mock_score.await_args.kwargs + for retired in ("skip_on_error_result", "role_filter"): + assert retired not in call_kwargs, f"{type(attack).__name__} still passes the retired '{retired}' parameter" + assert call_kwargs["response"] is error_response, ( + f"{type(attack).__name__} did not pass the response as it arrived" + ) + + +def _undetermined_score(response: Message) -> Score: + """ + Build the score the message family reports when it cannot read the response. + + Returns: + Score: An undetermined true/false score anchored on the response. + """ + return Score( + score_value=None, + status=ScoreStatus.UNDETERMINED, + score_value_description="Error response; no verdict was reachable.", + score_type="true_false", + score_category=None, + score_metadata=None, + score_rationale="Response had an error: blocked; no verdict was reachable.", + scorer_class_identifier=_mock_scorer_id("MockScorer"), + message_piece_id=response.message_pieces[0].id, + objective=OBJECTIVE, + ) + + +@patch("pyrit.memory.CentralMemory.get_memory_instance") +async def test_error_response_produces_undetermined_outcome(mock_memory_instance, mock_target, mock_memory): + """ + Test that an error response ends a single-turn attack as undetermined, not as a failure. + + The scorer could not reach a verdict, so the attack reports what it could not determine + instead of reporting that the objective was not met. + """ + mock_memory_instance.return_value = mock_memory + + scorer = MagicMock(spec=TrueFalseScorer) + scorer.get_identifier.return_value = _mock_scorer_id("MockScorer") + + attack = PromptSendingAttack( + objective_target=mock_target, + attack_scoring_config=AttackScoringConfig(objective_scorer=scorer, use_score_as_feedback=False), + max_attempts_on_failure=0, + ) + + error_response = create_error_response(str(uuid.uuid4())) + + with patch.object(attack, "_prompt_normalizer") as mock_normalizer: + mock_normalizer.send_prompt_async = AsyncMock(return_value=error_response) + with patch( + "pyrit.score.message_scorer.MessageScorer.score_response_async", + new=AsyncMock( + return_value={"objective_scores": [_undetermined_score(error_response)], "auxiliary_scores": []} + ), + ): + result = await attack.execute_async(objective=OBJECTIVE) + + assert result.outcome == AttackOutcome.UNDETERMINED diff --git a/tests/unit/executor/attack/test_error_skip_scoring.py b/tests/unit/executor/attack/test_error_skip_scoring.py deleted file mode 100644 index d79a9d565b..0000000000 --- a/tests/unit/executor/attack/test_error_skip_scoring.py +++ /dev/null @@ -1,245 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -""" -Unit tests to verify that all attack executors skip scoring when error responses are returned. -This ensures consistent error handling across all attack strategies. -""" - -import uuid -from contextlib import suppress -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from pyrit.executor.attack import ( - CrescendoAttack, - MultiPromptSendingAttack, - PromptSendingAttack, - RedTeamingAttack, - TreeOfAttacksWithPruningAttack, -) -from pyrit.executor.attack.core import AttackAdversarialConfig, AttackScoringConfig -from pyrit.executor.attack.multi_turn.tree_of_attacks import TAPAttackScoringConfig -from pyrit.models import ComponentIdentifier, Message, MessagePiece, SeedGroup, SeedPrompt -from pyrit.prompt_target import PromptTarget -from pyrit.score import FloatScaleThresholdScorer, TrueFalseScorer - - -def _mock_target_id(name: str = "MockTarget") -> ComponentIdentifier: - """Helper to create ComponentIdentifier for tests.""" - return ComponentIdentifier( - class_name=name, - class_module="test_module", - ) - - -def _mock_scorer_id(name: str = "MockScorer") -> ComponentIdentifier: - """Helper to create ComponentIdentifier for tests.""" - return ComponentIdentifier( - class_name=name, - class_module="test_module", - ) - - -@pytest.fixture -def mock_target(): - """Create a mock prompt target for testing""" - target = MagicMock(spec=PromptTarget) - target.send_prompt_async = AsyncMock() - target.get_identifier.return_value = _mock_target_id("MockTarget") - return target - - -@pytest.fixture -def mock_scorer(): - """Create a mock scorer for testing""" - scorer = MagicMock(spec=TrueFalseScorer) - scorer.score_async = AsyncMock() - scorer.get_identifier.return_value = _mock_scorer_id("MockScorer") - return scorer - - -@pytest.fixture -def mock_memory(): - """Create a mock memory instance""" - memory = MagicMock() - memory.get_conversation_messages.return_value = [] - memory.add_message_to_memory = MagicMock() - return memory - - -def create_error_response(conversation_id: str) -> Message: - """Helper to create an error response message""" - return Message( - message_pieces=[ - MessagePiece( - role="assistant", - original_value="Content filter error", - conversation_id=conversation_id, - response_error="blocked", - converted_value_data_type="error", - ) - ] - ) - - -# Test parameters: (attack_class, attack_kwargs, execute_kwargs, scorer_patch_path) -ATTACK_TEST_PARAMS = [ - ( - PromptSendingAttack, - {}, # No extra kwargs needed - lambda: { - "objective": "test objective", - "seed_group": SeedGroup(seeds=[SeedPrompt(value="test", data_type="text")]), - }, - "pyrit.score.message_scorer.MessageScorer.score_response_async", - ), - ( - MultiPromptSendingAttack, - {}, - lambda: {"objective": "test objective", "prompt_sequence": [SeedPrompt(value="test", data_type="text")]}, - "pyrit.score.message_scorer.MessageScorer.score_response_async", - ), - ( - RedTeamingAttack, - {}, - lambda: { - "objective": "test objective", - "seed_prompt": SeedPrompt(value="test", data_type="text"), - "max_turns": 1, - }, - "pyrit.score.message_scorer.MessageScorer.score_response_async", - ), - ( - CrescendoAttack, - {}, - lambda: { - "objective": "test objective", - "seed_prompt": SeedPrompt(value="test", data_type="text"), - "max_turns": 1, - }, - "pyrit.score.message_scorer.MessageScorer.score_response_async", - ), - ( - TreeOfAttacksWithPruningAttack, - {"tree_width": 2}, - lambda: { - "objective": "test objective", - "seed_prompt": SeedPrompt(value="test", data_type="text"), - "max_iterations": 1, - }, - "pyrit.score.message_scorer.MessageScorer.score_response_async", - ), -] - - -@pytest.mark.parametrize( - "attack_class,attack_extra_kwargs,execute_kwargs_func,scorer_patch_path", - ATTACK_TEST_PARAMS, - ids=["PromptSending", "MultiPromptSending", "RedTeaming", "Crescendo", "TreeOfAttacks"], -) -@patch("pyrit.memory.CentralMemory.get_memory_instance") -async def test_attack_executor_skips_scoring_on_error( - mock_memory_instance, - mock_target, - mock_scorer, - mock_memory, - attack_class, - attack_extra_kwargs, - execute_kwargs_func, - scorer_patch_path, -): - """ - Test that all attack executors skip scoring when target returns an error response. - - This parametrized test verifies that each executor: - 1. Calls MessageScorer.score_response_async with skip_on_error_result=True - 2. Handles error responses appropriately without attempting to score them - """ - # Setup memory mock - mock_memory_instance.return_value = mock_memory - - # Setup scoring config with objective scorer - # TAP requires FloatScaleThresholdScorer, so use TAPAttackScoringConfig for it - if attack_class == TreeOfAttacksWithPruningAttack: - tap_scorer = MagicMock(spec=FloatScaleThresholdScorer) - tap_scorer.score_async = AsyncMock() - tap_scorer.get_identifier.return_value = _mock_scorer_id("MockTapScorer") - tap_scorer.threshold = 0.7 - attack_scoring_config = TAPAttackScoringConfig( - objective_scorer=tap_scorer, - use_score_as_feedback=False, - ) - else: - attack_scoring_config = AttackScoringConfig( - objective_scorer=mock_scorer, - use_score_as_feedback=False, - ) - - # Setup additional configs for multi-turn attacks that need adversarial config - if attack_class in [RedTeamingAttack, CrescendoAttack, TreeOfAttacksWithPruningAttack]: - # TreeOfAttacks requires PromptTarget, others can use PromptTarget - if attack_class == TreeOfAttacksWithPruningAttack: - adversarial_target = MagicMock(spec=PromptTarget) - else: - adversarial_target = MagicMock(spec=PromptTarget) - - adversarial_target.send_prompt_async = AsyncMock() - adversarial_target.get_identifier.return_value = _mock_target_id("AdversarialTarget") - - attack_adversarial_config = AttackAdversarialConfig( - target=adversarial_target, - ) - attack_extra_kwargs["attack_adversarial_config"] = attack_adversarial_config - - # Setup refusal scorer for Crescendo - if attack_class == CrescendoAttack: - refusal_scorer = MagicMock(spec=TrueFalseScorer) - refusal_scorer.score_async = AsyncMock(return_value=[]) - refusal_scorer.get_identifier.return_value = _mock_scorer_id("RefusalScorer") - attack_scoring_config.refusal_scorer = refusal_scorer - - # Create attack with proper configuration - attack = attack_class( - objective_target=mock_target, attack_scoring_config=attack_scoring_config, **attack_extra_kwargs - ) - - # Create error response - conversation_id = str(uuid.uuid4()) - error_response = create_error_response(conversation_id) - - # Mock normalizer to return error response - with patch.object(attack, "_prompt_normalizer") as mock_normalizer: - # For RedTeaming, we need adversarial response first, then error - if attack_class == RedTeamingAttack: - adversarial_response = Message( - message_pieces=[ - MessagePiece( - role="assistant", - original_value="adversarial prompt", - conversation_id=str(uuid.uuid4()), - ) - ] - ) - mock_normalizer.send_prompt_async = AsyncMock(side_effect=[adversarial_response, error_response]) - else: - mock_normalizer.send_prompt_async = AsyncMock(return_value=error_response) - - # Mock the MessageScorer.score_response_async to track if it's called - with patch(scorer_patch_path) as mock_score: - mock_score.return_value = {"objective_scores": [], "auxiliary_scores": []} - - # Execute attack - with suppress(Exception): - await attack.execute_async(**execute_kwargs_func()) - - # Verify scoring was called with skip_on_error_result=True if it was called - if mock_score.called: - call_kwargs = mock_score.call_args.kwargs - assert "skip_on_error_result" in call_kwargs, ( - f"{attack_class.__name__} did not pass skip_on_error_result parameter" - ) - assert call_kwargs["skip_on_error_result"] is True, ( - f"{attack_class.__name__} did not set skip_on_error_result=True" - ) diff --git a/tests/unit/score/test_conversation_history_scorer.py b/tests/unit/score/test_conversation_history_scorer.py index 5fdb0ee955..82e62e22e5 100644 --- a/tests/unit/score/test_conversation_history_scorer.py +++ b/tests/unit/score/test_conversation_history_scorer.py @@ -14,7 +14,6 @@ FloatScaleThresholdScorer, MessageScorable, MessageScorer, - MessageScoringOptions, Scorer, SelfAskGeneralFloatScaleScorer, TrueFalseCompositeScorer, @@ -668,30 +667,33 @@ async def test_conversation_scorer_blocked_input_message_does_not_raise(patch_ce mock_scorer._score_nested_async.assert_awaited_once() -async def test_conversation_scorer_skip_on_error_omits_blocked_trigger(patch_central_database): +async def test_conversation_scorer_errored_trigger_still_reads_the_conversation(patch_central_database): + """A ConversationScorer reads the conversation, so an errored trigger must not silence it.""" memory = CentralMemory.get_memory_instance() conversation_id = str(uuid.uuid4()) + prior_piece = MessagePiece( + role="assistant", + original_value="an earlier answer", + conversation_id=conversation_id, + sequence=1, + ) blocked_piece = MessagePiece( role="assistant", original_value='{"message": "content_filter"}', original_value_data_type="error", converted_value_data_type="error", conversation_id=conversation_id, + sequence=2, response_error="blocked", ) - memory.add_message_pieces_to_memory(message_pieces=[blocked_piece]) + memory.add_message_pieces_to_memory(message_pieces=[prior_piece, blocked_piece]) wrapped_scorer = MockFloatScaleScorer() - wrapped_scorer._score_nested_async = AsyncMock() scorer = create_conversation_scorer(scorer=wrapped_scorer) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(blocked_piece.to_message()), - message_options=MessageScoringOptions(skip_on_error_result=True), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(blocked_piece.to_message())) - assert scores == [] - wrapped_scorer._score_nested_async.assert_not_awaited() + assert len(scores) == 1 async def test_conversation_scorer_blocked_trigger_preserves_prior_turn_scoring(patch_central_database): diff --git a/tests/unit/score/test_message_scorer.py b/tests/unit/score/test_message_scorer.py index 1abc91f668..d269d6e4dc 100644 --- a/tests/unit/score/test_message_scorer.py +++ b/tests/unit/score/test_message_scorer.py @@ -20,6 +20,7 @@ Message, MessagePiece, Score, + ScoreStatus, ScoringExpectation, ) from pyrit.score import ( @@ -32,7 +33,7 @@ ScorerPromptValidator, ) from pyrit.score.message_scorable_resolver import MessageScorableResolver -from pyrit.score.message_scorer import MessageScoringOptions, extract_objective_from_previous_turn +from pyrit.score.message_scorer import extract_objective_from_previous_turn class UnsupportedScorable(Scorable): @@ -42,8 +43,8 @@ class UnsupportedScorable(Scorable): class PermissiveValidator(ScorerPromptValidator): - def __init__(self, *, is_objective_required: bool = False) -> None: - super().__init__(is_objective_required=is_objective_required) + def __init__(self, *, is_objective_required: bool = False, supported_roles=None) -> None: + super().__init__(is_objective_required=is_objective_required, supported_roles=supported_roles) def validate(self, message, objective=None): pass @@ -60,9 +61,13 @@ def __init__( *, message_resolver: MessageScorableResolver | None = None, is_objective_required: bool = False, + supported_roles=None, ) -> None: super().__init__( - validator=PermissiveValidator(is_objective_required=is_objective_required), + validator=PermissiveValidator( + is_objective_required=is_objective_required, + supported_roles=supported_roles, + ), message_resolver=message_resolver, ) self.scored_messages: list[Message] = [] @@ -287,45 +292,70 @@ def test_message_dependencies_live_on_message_scorer(self): @pytest.mark.usefixtures("patch_central_database") class TestScorableFilters: - """Message policy is separate from the scorable's evidence identity.""" + """Role policy is a scorer capability, applied after the scorer acquires its evidence.""" - async def test_role_filter_mismatch_skips_scoring(self): - scorer = RecordingScorer() + async def test_unread_role_produces_no_score(self): + scorer = RecordingScorer(supported_roles=["user"]) message = _assistant_message() - scores = await scorer.score_async( - scorable=MessageScorable.from_message(message), - message_options=MessageScoringOptions(role_filter="user"), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) assert scores == [] assert scorer.scored_messages == [] - async def test_role_filter_match_scores(self): - scorer = RecordingScorer() + async def test_read_role_scores(self): + scorer = RecordingScorer(supported_roles=["assistant"]) message = _assistant_message() - scores = await scorer.score_async( - scorable=MessageScorable.from_message(message), - message_options=MessageScoringOptions(role_filter="assistant"), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) assert len(scores) == 1 - async def test_skip_on_error_result_skips_error_message(self): + async def test_simulated_assistant_is_unread_by_default(self): + """A prepended turn is fabricated history, so a scorer must opt in to read it.""" scorer = RecordingScorer() - message = _error_message() + message = MessagePiece( + role="simulated_assistant", + original_value="prepended text", + conversation_id=str(uuid.uuid4()), + ).to_message() + CentralMemory.get_memory_instance().add_message_to_memory(request=message) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(message), - message_options=MessageScoringOptions(skip_on_error_result=True), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) assert scores == [] assert scorer.scored_messages == [] - @pytest.mark.parametrize("skip_on_error_result", [True, False]) - async def test_partly_errored_message_scores_only_readable_pieces(self, skip_on_error_result): + async def test_transport_error_message_produces_undetermined_score(self): + """An error is not the target's answer, so no verdict was reachable.""" + scorer = RecordingScorer() + message = MessagePiece( + role="assistant", + original_value="connection reset", + original_value_data_type="error", + response_error="processing", + conversation_id=str(uuid.uuid4()), + ).to_message() + CentralMemory.get_memory_instance().add_message_to_memory(request=message) + + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) + + assert len(scores) == 1 + assert scores[0].status == ScoreStatus.UNDETERMINED + assert scorer.scored_messages == [] + + async def test_blocked_message_stays_false_safe(self): + """A fully blocked response reaches the scorer's family and keeps its neutral verdict.""" + scorer = RecordingScorer() + message = _error_message() + + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) + + assert len(scores) == 1 + assert scores[0].score_value == "false" + assert scorer.scored_messages == [] + + async def test_partly_errored_message_scores_only_readable_pieces(self): """One bad piece must neither discard nor reach the scorer beside readable content.""" scorer = RecordingScorer() conversation_id = str(uuid.uuid4()) @@ -343,24 +373,21 @@ async def test_partly_errored_message_scores_only_readable_pieces(self, skip_on_ ) CentralMemory.get_memory_instance().add_message_to_memory(request=message) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(message), - message_options=MessageScoringOptions(skip_on_error_result=skip_on_error_result), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) assert len(scores) == 1 assert len(scorer.scored_messages) == 1 assert [piece.original_value for piece in scorer.scored_messages[0].message_pieces] == ["usable text"] - async def test_error_message_uses_fallback_when_not_skipping(self): + @pytest.mark.parametrize("kwargs", [{"role_filter": "user"}, {"skip_on_error_result": True}]) + async def test_retired_policy_parameters_warn_and_are_ignored(self, kwargs): scorer = RecordingScorer() - message = _error_message() + message = _assistant_message() - scores = await scorer.score_async(scorable=MessageScorable.from_message(message)) + with pytest.warns(DeprecationWarning, match="retired"): + scores = await scorer.score_async(scorable=MessageScorable.from_message(message), **kwargs) assert len(scores) == 1 - assert scores[0].score_value == "false" - assert scorer.scored_messages == [] @pytest.mark.usefixtures("patch_central_database") @@ -453,37 +480,37 @@ async def test_objective_maps_to_expectation(self): assert scorer.scored_objectives == ["legacy objective"] - async def test_legacy_role_filter_maps_to_message_options(self): + async def test_retired_role_filter_is_ignored(self): + """Role policy now lives on the validator, so the per-call filter must not skip anything.""" scorer = RecordingScorer() - with pytest.warns(DeprecationWarning, match="Scorer.score_async"): - scores = await scorer.score_async(_assistant_message(), role_filter="user") + with pytest.warns(DeprecationWarning, match="retired"): + scores = await scorer.score_async( + scorable=MessageScorable.from_message(_assistant_message()), + role_filter="user", + ) - assert scores == [] + assert len(scores) == 1 - async def test_legacy_skip_on_error_result_maps_to_message_options(self): + async def test_retired_skip_on_error_result_is_ignored(self): + """An unreadable message now reports an undetermined verdict instead of being skipped.""" scorer = RecordingScorer() - message = _error_message() - with pytest.warns(DeprecationWarning, match="Scorer.score_async"): - scores = await scorer.score_async(message, skip_on_error_result=True) + with pytest.warns(DeprecationWarning, match="retired"): + scores = await scorer.score_async( + scorable=MessageScorable.from_message(_error_message()), + skip_on_error_result=True, + ) - assert scores == [] + assert len(scores) == 1 - @pytest.mark.parametrize( - "kwargs", - [ - {"skip_on_error_result": False}, - {"infer_objective_from_request": False}, - ], - ) - async def test_explicit_false_legacy_boolean_emits_warning(self, kwargs): + async def test_explicit_false_legacy_boolean_emits_warning(self): scorer = RecordingScorer() with pytest.warns(DeprecationWarning, match="Scorer.score_async"): await scorer.score_async( scorable=MessageScorable.from_message(_assistant_message()), - **kwargs, + infer_objective_from_request=False, ) async def test_infer_objective_from_request_reads_the_previous_turn(self, sqlite_instance: MemoryInterface): @@ -542,17 +569,6 @@ async def test_objective_and_expectation_together_raises(self): expectation=ScoringExpectation(objective="two"), ) - @pytest.mark.parametrize("kwargs", [{"role_filter": "assistant"}, {"skip_on_error_result": True}]) - async def test_message_options_and_legacy_policy_raise(self, kwargs): - scorer = RecordingScorer() - - with pytest.raises(ValueError, match="either 'message_options' or legacy"): - await scorer.score_async( - scorable=MessageScorable.from_message(_assistant_message()), - message_options=MessageScoringOptions(), - **kwargs, - ) - @pytest.mark.usefixtures("patch_central_database") class TestExtractObjectiveFromPreviousTurn: @@ -656,13 +672,10 @@ async def test_score_message_async_does_not_anchor_unmarked_ephemeral_multipart_ assert score.message_piece_id is None assert score.scorable is None - async def test_score_message_async_applies_message_options(self): - scorer = RecordingScorer() + async def test_score_message_async_applies_declared_roles(self): + scorer = RecordingScorer(supported_roles=["user"]) - scores = await scorer.score_message_async( - message=_assistant_message(), - message_options=MessageScoringOptions(role_filter="user"), - ) + scores = await scorer.score_message_async(message=_assistant_message()) assert scores == [] diff --git a/tests/unit/score/test_scorer.py b/tests/unit/score/test_scorer.py index ca5d517178..e6b1a0711d 100644 --- a/tests/unit/score/test_scorer.py +++ b/tests/unit/score/test_scorer.py @@ -11,7 +11,16 @@ from pyrit.exceptions import InvalidJsonException, remove_markdown_json from pyrit.memory import CentralMemory, MemoryInterface -from pyrit.models import ComponentIdentifier, ContentScorable, Message, MessagePiece, Score, ScoringExpectation +from pyrit.models import ( + ComponentIdentifier, + ContentScorable, + Message, + MessagePiece, + Scorable, + Score, + ScoreStatus, + ScoringExpectation, +) from pyrit.prompt_target import PromptTarget from pyrit.score import ( FloatScaleScorer, @@ -28,7 +37,7 @@ ) from pyrit.score.llm_scoring import _run_llm_scoring_async from pyrit.score.message_scorable_resolver import MessageScorableResolver -from pyrit.score.message_scorer import MessageScoringOptions, extract_objective_from_previous_turn +from pyrit.score.message_scorer import extract_objective_from_previous_turn @pytest.fixture @@ -108,7 +117,7 @@ async def _score_piece_async(self, message_piece: MessagePiece, *, objective: st ] def validate_return_scores(self, scores: list[Score]): - assert all(s.score_value in ["true", "false"] for s in scores) + assert all(s.score_value in ["true", "false"] for s in scores if s.status != ScoreStatus.UNDETERMINED) class SelectiveValidator(ScorerPromptValidator): @@ -463,7 +472,6 @@ async def test_scorer_score_responses_batch_async(patch_central_database): assert first_call_kwargs["scorable"] == MessageScorable.from_message(user_req) assert first_call_kwargs["expectation"] == ScoringExpectation(objective="") - assert first_call_kwargs["message_options"] == MessageScoringOptions() assert fake_scores[0] in results assert len(fake_scores) == 2 @@ -609,7 +617,7 @@ async def test_score_response_async_empty_scorers(patch_central_database): async def test_score_response_async_no_matching_role(patch_central_database): - """Test that score_response_async returns empty list when no pieces match role filter.""" + """A scorer that declares only assistant roles stays silent on a user-only response.""" response = Message( message_pieces=[ MessagePiece(role="user", original_value="test1", conversation_id="test-convo"), @@ -618,18 +626,18 @@ async def test_score_response_async_no_matching_role(patch_central_database): ) scorer = MockScorer() - scorer.score_async = AsyncMock(return_value=[]) + scorer._validator = ScorerPromptValidator(supported_roles=["assistant"]) + scorer._score_async = AsyncMock(return_value=[]) result = await MessageScorer.score_response_async( response=store_message(response), objective_scorer=scorer, auxiliary_scorers=[scorer], - role_filter="assistant", objective="test task", ) assert result == {"auxiliary_scores": [], "objective_scores": []} - # Role policy settles before dispatch, so the scorer is never asked. - scorer.score_async.assert_not_called() + # Role policy is a declared capability, so the scorer never reads the evidence. + scorer._score_async.assert_not_called() async def test_score_response_async_parallel_execution(patch_central_database): @@ -654,13 +662,13 @@ async def test_score_response_async_parallel_execution(patch_central_database): scorer2.score_async = AsyncMock(side_effect=[[score2_1], [score2_2]]) result = await MessageScorer.score_response_async( - response=response, auxiliary_scorers=[scorer1, scorer2], role_filter="assistant", objective="test task" + response=response, auxiliary_scorers=[scorer1, scorer2], objective="test task" ) assert score1_1 in result["auxiliary_scores"] assert score2_1 in result["auxiliary_scores"] expected_scorable = MessageScorable.from_message(store_message(response)) - # Role and error policy settle before dispatch, so scorers receive evidence only. + # Every scorer receives the response as it arrived; policy belongs to the scorer. scorer1.score_async.assert_any_call( scorable=expected_scorable, expectation=ScoringExpectation(objective="test task"), @@ -685,13 +693,13 @@ async def test_score_response_select_first_success_async_empty_scorers(patch_cen async def test_score_async_no_matching_role(patch_central_database): - """Test that score_response_select_first_success_async returns None when no pieces match role filter.""" + """A scorer returns no scores when it declares none of the roles in the message.""" response = Message(message_pieces=[MessagePiece(role="user", original_value="test", conversation_id="test-convo")]) scorer = MockScorer() + scorer._validator = ScorerPromptValidator(supported_roles=["assistant"]) result = await scorer.score_async( scorable=MessageScorable.from_message(store_message(response)), expectation=ScoringExpectation(objective="test task"), - message_options=MessageScoringOptions(role_filter="assistant"), ) assert result == [] @@ -955,8 +963,8 @@ async def test_score_response_async_multiple_pieces(patch_central_database): assert result["objective_scores"][0] == obj_score -async def test_score_response_async_skip_on_error_true(patch_central_database): - """Test score_response_async skips an errored response when skip_on_error_result=True.""" +async def test_score_response_async_dispatches_on_errored_response(patch_central_database): + """Every scorer still receives an errored response; only the scorer decides what it means.""" piece1 = MessagePiece( role="assistant", original_value="error", response_error="blocked", conversation_id="test-convo" ) @@ -982,18 +990,98 @@ async def test_score_response_async_skip_on_error_true(patch_central_database): auxiliary_scorers=[aux_scorer], objective_scorer=obj_scorer, objective="test task", - skip_on_error_result=True, ) - # Every piece errored with nothing readable behind it, so nothing is scored. - assert result == {"auxiliary_scores": [], "objective_scores": []} + assert result == {"auxiliary_scores": [aux_score], "objective_scores": [obj_score]} + + aux_scorer.score_async.assert_called_once() + obj_scorer.score_async.assert_called_once() + + +async def test_score_response_async_errored_response_is_undetermined(patch_central_database): + """A response with nothing readable reports an undetermined verdict instead of no verdict.""" + piece = MessagePiece( + role="assistant", + original_value="transport failed", + original_value_data_type="error", + response_error="processing", + conversation_id="test-convo", + ) + response = Message(message_pieces=[piece]) + + obj_scorer = MockScorer() + + result = await MessageScorer.score_response_async( + response=store_message(response), + objective_scorer=obj_scorer, + objective="test task", + ) + + assert len(result["objective_scores"]) == 1 + assert result["objective_scores"][0].status == ScoreStatus.UNDETERMINED + + +async def test_score_response_async_dispatches_to_a_non_message_scorer_on_error(patch_central_database): + """A scorer whose evidence is not the response must still run when the response failed. + + This is the contract that unblocks trace and tool-call scoring: a scorer that reads + evidence the response never held (for example, whether a tool was called) is asked + even when the target itself errored. + """ + + class ToolCallScorer(Scorer): + """A scorer whose evidence never comes from the response.""" + + def __init__(self) -> None: + super().__init__() + self.seen_scorables: list[Scorable] = [] + + def _build_identifier(self) -> ComponentIdentifier: + return self._create_identifier() + + async def _score_scorable_async(self, *, scorable, expectation=None) -> list[Score]: + self.seen_scorables.append(scorable) + return [ + Score( + score_value="true", + score_value_description="tool call observed", + score_type="true_false", + score_category=None, + score_metadata=None, + score_rationale="the agent called the tool before the target errored", + scorer_class_identifier=self.get_identifier(), + message_piece_id=uuid.uuid4(), + objective=expectation.objective if expectation else None, + ) + ] - aux_scorer.score_async.assert_not_called() - obj_scorer.score_async.assert_not_called() + def validate_return_scores(self, scores: list[Score]) -> None: + pass + def get_scorer_metrics(self): + return None -async def test_score_response_async_skip_on_error_scores_partly_errored_response(patch_central_database): - """A response is only skipped when no piece came through; one bad piece is not enough.""" + piece = MessagePiece( + role="assistant", + original_value="transport failed", + original_value_data_type="error", + response_error="processing", + conversation_id="test-convo", + ) + scorer = ToolCallScorer() + + scores = await MessageScorer.score_response_multiple_scorers_async( + response=store_message(Message(message_pieces=[piece])), + scorers=[scorer], + objective="test task", + ) + + assert len(scores) == 1 + assert len(scorer.seen_scorables) == 1 + + +async def test_score_response_async_scores_partly_errored_response(patch_central_database): + """A response is scored on the pieces that came through; one bad piece is not enough to stop it.""" piece1 = MessagePiece(role="assistant", original_value="good response", conversation_id="test-convo") piece2 = MessagePiece( role="assistant", original_value="error", response_error="blocked", conversation_id="test-convo" @@ -1010,15 +1098,14 @@ async def test_score_response_async_skip_on_error_scores_partly_errored_response response=store_message(response), objective_scorer=obj_scorer, objective="test task", - skip_on_error_result=True, ) assert result["objective_scores"] == [obj_score] obj_scorer.score_async.assert_called_once() -async def test_score_response_async_skip_on_error_false(patch_central_database): - """Test score_response_async includes error pieces when skip_on_error_result=False.""" +async def test_score_response_async_includes_error_pieces(patch_central_database): + """Test score_response_async includes error pieces.""" piece1 = MessagePiece(role="assistant", original_value="good response", conversation_id="test-convo") piece2 = MessagePiece( role="assistant", original_value="error", response_error="blocked", conversation_id="test-convo" @@ -1042,7 +1129,6 @@ async def test_score_response_async_skip_on_error_false(patch_central_database): auxiliary_scorers=[aux_scorer], objective_scorer=obj_scorer, objective="test task", - skip_on_error_result=False, ) # Temporary fix means there should only be 1 auxiliary score (first piece) @@ -2506,44 +2592,42 @@ async def test_mixed_pieces_only_blocked_substituted(self): assert scorer.scored_pieces[1].response_error == "none" -# ── skip_on_error_result interaction tests ─────────────────────────────────── +# ── unreadable evidence interaction tests ──────────────────────────────────── @pytest.mark.usefixtures("patch_central_database") -class TestSkipOnErrorWithBlockedContent: - async def test_skip_on_error_true_with_flag_disabled_skips_blocked(self): +class TestUnreadableEvidenceWithBlockedContent: + async def test_blocked_content_disabled_reports_neutral_verdict(self): scorer = _BlockedContentScorer() scorer.should_score_blocked_content = False msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(store_message(msg)), - message_options=MessageScoringOptions(skip_on_error_result=True), - ) - assert scores == [] + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) - async def test_skip_on_error_true_does_not_skip_when_partial_content(self): + assert len(scores) == 1 + assert scores[0].score_value == "false" + assert scorer.scored_pieces == [] + + async def test_partial_content_behind_a_block_is_scored(self): scorer = _BlockedContentScorer() msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(store_message(msg)), - message_options=MessageScoringOptions(skip_on_error_result=True), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) + assert len(scores) == 1 assert scores[0].score_value == "true" - async def test_skip_on_error_true_still_skips_when_no_partial_content(self): + async def test_block_without_partial_content_reports_neutral_verdict(self): scorer = _BlockedContentScorer() msg = Message(message_pieces=[_make_blocked_piece()]) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(store_message(msg)), - message_options=MessageScoringOptions(skip_on_error_result=True), - ) - assert scores == [] + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) + + assert len(scores) == 1 + assert scores[0].score_value == "false" + assert scorer.scored_pieces == [] - async def test_skip_on_error_skips_error_type_without_response_error_flag(self): + async def test_error_type_without_response_error_flag_is_undetermined(self): scorer = _BlockedContentScorer() msg = Message( message_pieces=[ @@ -2557,12 +2641,10 @@ async def test_skip_on_error_skips_error_type_without_response_error_flag(self): ] ) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(store_message(msg)), - message_options=MessageScoringOptions(skip_on_error_result=True), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) - assert scores == [] + assert len(scores) == 1 + assert scores[0].status == ScoreStatus.UNDETERMINED assert scorer.scored_pieces == [] @pytest.mark.parametrize( @@ -2572,16 +2654,13 @@ async def test_skip_on_error_skips_error_type_without_response_error_flag(self): SelectiveValidator(raise_on_no_valid_pieces=True), ], ) - async def test_skip_on_error_scores_structured_refusal_as_text(self, validator: ScorerPromptValidator): + async def test_structured_refusal_is_scored_as_text(self, validator: ScorerPromptValidator): scorer = _BlockedContentScorer(validator=validator) refusal = "I cannot assist with that request." piece = _make_blocked_piece(structured_refusal=refusal) msg = Message(message_pieces=[piece]) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(store_message(msg)), - message_options=MessageScoringOptions(skip_on_error_result=True), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert len(scores) == 1 assert scorer.scored_pieces[0].id == piece.id @@ -2589,7 +2668,7 @@ async def test_skip_on_error_scores_structured_refusal_as_text(self, validator: assert scorer.scored_pieces[0].converted_value_data_type == "text" assert scorer.scored_pieces[0].response_error == "blocked" - async def test_skip_on_error_scores_readable_piece_beside_a_runtime_error(self): + async def test_readable_piece_beside_a_runtime_error_is_scored(self): scorer = _BlockedContentScorer() msg = Message( message_pieces=[ @@ -2608,10 +2687,7 @@ async def test_skip_on_error_scores_readable_piece_beside_a_runtime_error(self): ] ) - scores = await scorer.score_async( - scorable=MessageScorable.from_message(store_message(msg)), - message_options=MessageScoringOptions(skip_on_error_result=True), - ) + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(msg))) assert len(scores) == 1 assert [piece.converted_value for piece in scorer.scored_pieces] == ["Partial content"] @@ -2622,7 +2698,7 @@ async def test_skip_on_error_scores_readable_piece_beside_a_runtime_error(self): @pytest.mark.usefixtures("patch_central_database") class TestScoreResponseAsyncBlockedContent: - async def test_score_response_async_passes_flag_to_scorers(self): + async def test_score_response_async_scores_partial_content(self): obj_scorer = _BlockedContentScorer() msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) @@ -2630,7 +2706,6 @@ async def test_score_response_async_passes_flag_to_scorers(self): response=store_message(msg), objective_scorer=obj_scorer, objective="test", - skip_on_error_result=False, ) assert len(result["objective_scores"]) == 1 @@ -2646,13 +2721,12 @@ async def test_score_response_async_disabled_does_not_substitute(self): response=store_message(msg), objective_scorer=obj_scorer, objective="test", - skip_on_error_result=False, ) assert result["objective_scores"][0].score_value == "false" assert len(obj_scorer.scored_pieces) == 0 - async def test_score_response_multiple_scorers_passes_flag(self): + async def test_score_response_multiple_scorers_scores_partial_content(self): scorer1 = _BlockedContentScorer() scorer2 = _BlockedContentScorer() msg = Message(message_pieces=[_make_blocked_piece(partial_content="harmful text")]) @@ -2661,7 +2735,6 @@ async def test_score_response_multiple_scorers_passes_flag(self): response=store_message(msg), scorers=[scorer1, scorer2], objective="test", - skip_on_error_result=False, ) assert len(scores) == 2 @@ -2677,7 +2750,6 @@ async def test_score_response_async_does_not_filter_generic_wrapper_content(self response=store_message(msg), objective_scorer=objective_scorer, objective="test", - skip_on_error_result=True, ) assert len(result["objective_scores"]) == 1 From c444f767b701bfc085db873a62567102523932c6 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Mon, 31 Aug 2026 16:04:12 -0700 Subject: [PATCH 2/6] FIX: Preserve scorer capability semantics Propagate silent child scorers through wrappers, apply conversation role policy to acquired history, make multipart fallback classification order-independent, and use the shared deprecation helper. Replace the mocked error-outcome assertion with a real message-family fallback test and remove the implementation-specific readability detail from framework.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9cd5233d-054f-4496-8237-4dd41b2ed544 --- doc/code/framework.md | 1 - pyrit/score/conversation_scorer.py | 27 ++++++- pyrit/score/message_scorer.py | 57 +++++++++----- .../float_scale_threshold_scorer.py | 2 + .../true_false/true_false_composite_scorer.py | 2 + .../true_false/true_false_inverter_scorer.py | 2 + .../attack/test_error_response_scoring.py | 55 +++++++------ .../score/test_conversation_history_scorer.py | 78 +++++++++++++++++++ .../test_float_scale_threshold_scorer.py | 34 +++----- tests/unit/score/test_message_scorer.py | 6 +- tests/unit/score/test_scorer.py | 59 ++++++++++++++ .../score/test_true_false_composite_scorer.py | 10 +++ tests/unit/score/test_true_false_inverter.py | 13 ++++ 13 files changed, 276 insertions(+), 70 deletions(-) diff --git a/doc/code/framework.md b/doc/code/framework.md index cff53a0248..7fe5565f37 100644 --- a/doc/code/framework.md +++ b/doc/code/framework.md @@ -259,7 +259,6 @@ If you are contributing to PyRIT, that work will most likely land in one of the - A scorer is not limited to a message, it could be anything (e.g. was this tool called or was this file written). It receives a `Scorable`, which identifies that evidence, and an optional `ScoringExpectation`. - `TrueFalseScorer` and `FloatScaleScorer` define result families. `MessageScorer` adds message resolution and message-only policy on top of them. - A scorer declares which evidence it reads, rather than the caller filtering evidence for it. A `MessageScorer` states the conversation roles it reads on its `ScorerPromptValidator`, and stays silent when the evidence carries no role it reads. -- Readability is judged after the scorer acquires its own evidence, not before it is called. A scorer that never reads the response must still run when the response failed. - `Score.status` marks a verdict complete or undetermined, and the attack decides how to branch on it. Evidence a scorer cannot read reports undetermined, so "no verdict was reachable" is never confused with a negative verdict. - **Does not own**: acting on its own result. A scorer evaluates a response and returns a score; branching on that score is the attack's job, and aggregating scores across runs is analytics'. It may call a target to evaluate, but it doesn't send the attack's objective prompt or manage the conversation. diff --git a/pyrit/score/conversation_scorer.py b/pyrit/score/conversation_scorer.py index 2127e2173b..73c4bd1318 100644 --- a/pyrit/score/conversation_scorer.py +++ b/pyrit/score/conversation_scorer.py @@ -9,6 +9,7 @@ ContentScorable, Message, MessagePiece, + Scorable, Score, ScoringExpectation, ) @@ -68,6 +69,27 @@ def _build_scoring_message(self, *, message: Message) -> Message | None: """ return message if message.message_pieces else None + def _reads_any_role(self, *, message: Message, anchor: Scorable | None) -> bool: + """ + Defer role policy until the conversation locator has acquired its evidence. + + Returns: + bool: True because the trigger identifies history; it is not the evidence itself. + """ + return True + + def _build_fallback_score(self, *, message: Message, objective: str | None) -> list[Score]: + """ + Preserve silence when the acquired conversation or wrapped scorer has no verdict. + + Returns: + list[Score]: An empty list. + """ + return [] + + def _validate_scoring_message(self, *, message: Message, objective: str | None) -> None: + """Skip message validation because the trigger is only a conversation locator.""" + async def _score_prepared_message_async( self, *, @@ -123,7 +145,7 @@ async def _score_prepared_message_async( for conv_message in conversation: for piece in conv_message.message_pieces: # Only include user and assistant messages in the conversation text - if piece.api_role in ["user", "assistant", "tool"]: + if piece.api_role in ["user", "assistant", "tool"] and self._validator.is_role_supported(piece): role_display = "Assistant (simulated)" if piece.is_simulated else piece.api_role.capitalize() # For blocked pieces with partial content, use the partial content # instead of the error JSON when should_score_blocked_content is enabled @@ -137,6 +159,9 @@ async def _score_prepared_message_async( text = piece.converted_value conversation_text += f"{role_display}: {text}\n" + if not conversation_text: + return [] + wrapped_scorer = self._get_wrapped_scorer() scores = await wrapped_scorer._score_nested_async( scorable=ContentScorable(value=conversation_text), diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index d3634ecae2..4fa5fc96d6 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -5,7 +5,6 @@ import asyncio import logging -import warnings from abc import abstractmethod from typing import TYPE_CHECKING, cast @@ -137,15 +136,18 @@ def _warn_retired_message_policy( behind it. Role is now declared on the scorer's validator, and an unreadable message reaches the scorer, which reports what it could not determine. """ - if role_filter is None and skip_on_error_result is None: - return - warnings.warn( - "'role_filter' and 'skip_on_error_result' are retired and are ignored. Declare " - "'supported_roles' on the scorer's ScorerPromptValidator instead; an unreadable " - "message now produces an undetermined score rather than being skipped.", - DeprecationWarning, - stacklevel=3, - ) + if role_filter is not None: + print_deprecation_message( + old_item="role_filter scoring argument", + new_item="ScorerPromptValidator(supported_roles=...)", + removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, + ) + if skip_on_error_result is not None: + print_deprecation_message( + old_item="skip_on_error_result scoring argument", + new_item="the scorer's unreadable-evidence fallback", + removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, + ) class MessageScorer(Scorer): @@ -669,7 +671,7 @@ async def _score_resolved_message_async( self._finalize_message_scores(message=message, scores=scores, anchor=anchor) return scores - self._validator.validate(scoring_message, objective=objective) + self._validate_scoring_message(message=scoring_message, objective=objective) try: scores = await self._score_prepared_message_async( @@ -712,12 +714,22 @@ async def _score_resolved_message_async( raise RuntimeError(f"Error in scorer {self.__class__.__name__}: {str(e)}") from e if not scores and scoring_message.message_pieces: - scores = self._build_fallback_score(message=scoring_message, objective=objective) + scores = self._build_fallback_score(message=message, objective=objective) self._finalize_message_scores(message=scoring_message, scores=scores, anchor=anchor) return scores + def _validate_scoring_message(self, *, message: Message, objective: str | None) -> None: + """ + Validate the acquired message before it reaches the leaf scorer. + + Args: + message (Message): The acquired message. + objective (str | None): The objective supplied for scoring. + """ + self._validator.validate(message, objective=objective) + def _finalize_message_scores( self, *, @@ -1098,7 +1110,7 @@ def _build_neutral_fallback_score( neutral value. Args: - message (Message): The message whose first piece tells why nothing was scored. + message (Message): The message that carries no readable pieces. objective (str | None): The objective associated with this scoring call. neutral_value (str): The family's neutral score value, such as "false" or "0.0". @@ -1114,19 +1126,28 @@ def _build_neutral_fallback_score( if piece_id is None: raise ValueError("Cannot create score: message piece has no id or original_prompt_id") - if first_piece.is_blocked(): - rationale = f"The response was blocked with no content to score; returning {neutral_value}." - description = f"Blocked response; returning {neutral_value}." - elif first_piece.has_error() or first_piece.converted_value_data_type == "error": + error_piece = next( + ( + piece + for piece in message.message_pieces + if not piece.is_blocked() and (piece.has_error() or piece.converted_value_data_type == "error") + ), + None, + ) + + if error_piece is not None: # A transport or protocol failure is not the target's answer, so there is no verdict. return [ self._build_undetermined_score( - rationale=f"Response had an error: {first_piece.response_error}; no verdict was reachable.", + rationale=f"Response had an error: {error_piece.response_error}; no verdict was reachable.", description="Error response; no verdict was reachable.", message_piece_id=piece_id, objective=objective, ) ] + if all(piece.is_blocked() for piece in message.message_pieces): + rationale = f"The response was blocked with no content to score; returning {neutral_value}." + description = f"Blocked response; returning {neutral_value}." else: # this can happen with multi-modal responses if no supported pieces are present rationale = f"No supported pieces to score after filtering; returning {neutral_value}." diff --git a/pyrit/score/true_false/float_scale_threshold_scorer.py b/pyrit/score/true_false/float_scale_threshold_scorer.py index 13287bd928..825cd94c73 100644 --- a/pyrit/score/true_false/float_scale_threshold_scorer.py +++ b/pyrit/score/true_false/float_scale_threshold_scorer.py @@ -120,6 +120,8 @@ async def _score_scorable_async( list[Score]: A list containing a single true/false Score based on the threshold comparison. """ scores = await self._scorer._score_nested_async(scorable=scorable, expectation=expectation) + if not scores: + return [] return self._apply_threshold( scores=scores, expectation=expectation, diff --git a/pyrit/score/true_false/true_false_composite_scorer.py b/pyrit/score/true_false/true_false_composite_scorer.py index 4acab50b35..3166dfa8d3 100644 --- a/pyrit/score/true_false/true_false_composite_scorer.py +++ b/pyrit/score/true_false/true_false_composite_scorer.py @@ -132,6 +132,8 @@ async def _score_scorable_async( score_list_results = await asyncio.gather( *(scorer._score_nested_async(scorable=scorable, expectation=expectation) for scorer in self._scorers) ) + if any(not scores for scores in score_list_results): + return [] return [ self._build_aggregate_score( score_list_results=list(score_list_results), diff --git a/pyrit/score/true_false/true_false_inverter_scorer.py b/pyrit/score/true_false/true_false_inverter_scorer.py index 106eafd569..a0cdb52f00 100644 --- a/pyrit/score/true_false/true_false_inverter_scorer.py +++ b/pyrit/score/true_false/true_false_inverter_scorer.py @@ -95,6 +95,8 @@ async def _score_scorable_async( list[Score]: A list containing a single Score object with the inverted true/false value. """ scores = await self._scorer._score_nested_async(scorable=scorable, expectation=expectation) + if not scores: + return [] return self._invert(scores) def _invert(self, scores: list[Score]) -> list[Score]: diff --git a/tests/unit/executor/attack/test_error_response_scoring.py b/tests/unit/executor/attack/test_error_response_scoring.py index f9efcaf23e..c6ffa4acbd 100644 --- a/tests/unit/executor/attack/test_error_response_scoring.py +++ b/tests/unit/executor/attack/test_error_response_scoring.py @@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from unit.mocks import store_message from pyrit.executor.attack import ( AttackAdversarialConfig, @@ -23,6 +24,7 @@ CrescendoAttackContext, MultiPromptSendingAttack, PromptSendingAttack, + SingleTurnAttackContext, ) from pyrit.models import ( AttackOutcome, @@ -33,7 +35,7 @@ ScoreStatus, ) from pyrit.prompt_target import PromptTarget -from pyrit.score import TrueFalseScorer +from pyrit.score import MessageTrueFalseScorer, ScorerPromptValidator, TrueFalseScorer OBJECTIVE = "test objective" @@ -73,9 +75,9 @@ def create_error_response(conversation_id: str) -> Message: message_pieces=[ MessagePiece( role="assistant", - original_value="Content filter error", + original_value="Transport error", conversation_id=conversation_id, - response_error="blocked", + response_error="processing", converted_value_data_type="error", ) ] @@ -189,42 +191,49 @@ def _undetermined_score(response: Message) -> Score: score_type="true_false", score_category=None, score_metadata=None, - score_rationale="Response had an error: blocked; no verdict was reachable.", + score_rationale="Response had an error: processing; no verdict was reachable.", scorer_class_identifier=_mock_scorer_id("MockScorer"), message_piece_id=response.message_pieces[0].id, objective=OBJECTIVE, ) -@patch("pyrit.memory.CentralMemory.get_memory_instance") -async def test_error_response_produces_undetermined_outcome(mock_memory_instance, mock_target, mock_memory): +class _FallbackTrueFalseScorer(MessageTrueFalseScorer): + """A scorer that relies on message-family fallback for unreadable evidence.""" + + def __init__(self) -> None: + super().__init__(validator=ScorerPromptValidator(supported_data_types=["text"])) + + def _build_identifier(self) -> ComponentIdentifier: + return self._create_identifier() + + async def _score_piece_async(self, message_piece: MessagePiece, *, objective: str | None = None) -> list[Score]: + raise AssertionError("Unreadable error evidence must not reach the leaf scorer.") + + +async def test_error_response_produces_undetermined_outcome(mock_target, patch_central_database): """ Test that an error response ends a single-turn attack as undetermined, not as a failure. The scorer could not reach a verdict, so the attack reports what it could not determine instead of reporting that the objective was not met. """ - mock_memory_instance.return_value = mock_memory - - scorer = MagicMock(spec=TrueFalseScorer) - scorer.get_identifier.return_value = _mock_scorer_id("MockScorer") - + scorer = _FallbackTrueFalseScorer() attack = PromptSendingAttack( objective_target=mock_target, attack_scoring_config=AttackScoringConfig(objective_scorer=scorer, use_score_as_feedback=False), max_attempts_on_failure=0, ) + conversation_id = str(uuid.uuid4()) + error_response = store_message(create_error_response(conversation_id)) - error_response = create_error_response(str(uuid.uuid4())) + score = await attack._evaluate_response_async(response=error_response, objective=OBJECTIVE) + context = SingleTurnAttackContext( + params=AttackParameters(objective=OBJECTIVE), + conversation_id=conversation_id, + ) + outcome, _ = attack._determine_attack_outcome(response=error_response, score=score, context=context) - with patch.object(attack, "_prompt_normalizer") as mock_normalizer: - mock_normalizer.send_prompt_async = AsyncMock(return_value=error_response) - with patch( - "pyrit.score.message_scorer.MessageScorer.score_response_async", - new=AsyncMock( - return_value={"objective_scores": [_undetermined_score(error_response)], "auxiliary_scores": []} - ), - ): - result = await attack.execute_async(objective=OBJECTIVE) - - assert result.outcome == AttackOutcome.UNDETERMINED + assert score is not None + assert score.status == ScoreStatus.UNDETERMINED + assert outcome == AttackOutcome.UNDETERMINED diff --git a/tests/unit/score/test_conversation_history_scorer.py b/tests/unit/score/test_conversation_history_scorer.py index 82e62e22e5..395fe5333e 100644 --- a/tests/unit/score/test_conversation_history_scorer.py +++ b/tests/unit/score/test_conversation_history_scorer.py @@ -696,6 +696,84 @@ async def test_conversation_scorer_errored_trigger_still_reads_the_conversation( assert len(scores) == 1 +async def test_conversation_scorer_excludes_simulated_history_by_default(patch_central_database): + """Conversation role policy checks stored history roles, not API role aliases.""" + memory = CentralMemory.get_memory_instance() + conversation_id = str(uuid.uuid4()) + pieces = [ + MessagePiece(role="user", original_value="real request", conversation_id=conversation_id, sequence=1), + MessagePiece( + role="simulated_assistant", + original_value="fabricated answer", + conversation_id=conversation_id, + sequence=2, + ), + MessagePiece(role="assistant", original_value="real answer", conversation_id=conversation_id, sequence=3), + ] + memory.add_message_pieces_to_memory(message_pieces=pieces) + wrapped_scorer = MagicMock(spec=SelfAskGeneralFloatScaleScorer) + wrapped_scorer._score_nested_async = AsyncMock(return_value=[]) + wrapped_scorer.get_identifier.return_value = _make_scorer_id() + scorer = create_conversation_scorer(scorer=wrapped_scorer) + + await scorer.score_async(scorable=MessageScorable.from_message(pieces[-1].to_message())) + + rendered = wrapped_scorer._score_nested_async.await_args.kwargs["scorable"].value + assert "real request" in rendered + assert "real answer" in rendered + assert "fabricated answer" not in rendered + + +async def test_conversation_scorer_does_not_apply_role_policy_to_trigger(patch_central_database): + """A simulated trigger is only a locator, so valid stored history can still be scored.""" + memory = CentralMemory.get_memory_instance() + conversation_id = str(uuid.uuid4()) + user_piece = MessagePiece(role="user", original_value="real request", conversation_id=conversation_id, sequence=1) + trigger = MessagePiece( + role="simulated_assistant", + original_value="fabricated locator", + conversation_id=conversation_id, + sequence=2, + ) + memory.add_message_pieces_to_memory(message_pieces=[user_piece, trigger]) + wrapped_scorer = MagicMock(spec=SelfAskGeneralFloatScaleScorer) + wrapped_scorer._score_nested_async = AsyncMock(return_value=[]) + wrapped_scorer.get_identifier.return_value = _make_scorer_id() + validator = ScorerPromptValidator( + supported_roles=["user"], + enforce_all_pieces_valid=True, + raise_on_no_valid_pieces=True, + ) + scorer = create_conversation_scorer(scorer=wrapped_scorer, validator=validator) + + await scorer.score_async(scorable=MessageScorable.from_message(trigger.to_message())) + + rendered = wrapped_scorer._score_nested_async.await_args.kwargs["scorable"].value + assert "real request" in rendered + assert "fabricated locator" not in rendered + + +async def test_conversation_scorer_is_silent_when_all_history_roles_are_excluded(patch_central_database): + """No role-supported history means the conversation scorer makes no verdict.""" + memory = CentralMemory.get_memory_instance() + conversation_id = str(uuid.uuid4()) + trigger = MessagePiece( + role="simulated_assistant", + original_value="fabricated locator", + conversation_id=conversation_id, + sequence=1, + ) + memory.add_message_pieces_to_memory(message_pieces=[trigger]) + wrapped_scorer = MagicMock(spec=SelfAskGeneralFloatScaleScorer) + wrapped_scorer.get_identifier.return_value = _make_scorer_id() + scorer = create_conversation_scorer(scorer=wrapped_scorer) + + scores = await scorer.score_async(scorable=MessageScorable.from_message(trigger.to_message())) + + assert scores == [] + wrapped_scorer._score_nested_async.assert_not_awaited() + + async def test_conversation_scorer_blocked_trigger_preserves_prior_turn_scoring(patch_central_database): """When the triggering piece is blocked, the synthetic conversation Message must still be built as plain text so the wrapped text-only scorer accepts it and scores the rendered diff --git a/tests/unit/score/test_float_scale_threshold_scorer.py b/tests/unit/score/test_float_scale_threshold_scorer.py index 61ef469867..587a0d421a 100644 --- a/tests/unit/score/test_float_scale_threshold_scorer.py +++ b/tests/unit/score/test_float_scale_threshold_scorer.py @@ -210,11 +210,8 @@ async def test_float_scale_threshold_scorer_single_score_attribution_unchanged() assert score.score_metadata["original_float_value"] == pytest.approx(0.9) -async def test_float_scale_threshold_scorer_handles_empty_scores(): - """ - Test that FloatScaleThresholdScorer gracefully handles when the underlying scorer - returns no scores (e.g., all messages filtered due to length limits). - """ +async def test_float_scale_threshold_scorer_propagates_empty_scores(): + """A threshold cannot classify a verdict that its child did not make.""" memory = MagicMock(MemoryInterface) # Mock a scorer that returns empty list (all pieces filtered) @@ -232,22 +229,12 @@ async def test_float_scale_threshold_scorer_handles_empty_scores(): result_scores = await float_scale_threshold_scorer.score_text_async(text="mock example") - # Should return exactly one score with False value (default aggregator returns 0.0) - assert len(result_scores) == 1 - binary_score = result_scores[0] - assert binary_score.get_value() is False # 0.0 < 0.5 threshold - assert binary_score.score_type == "true_false" - assert "Normalized scale score: 0.0" in binary_score.score_rationale + assert result_scores == [] + memory.add_scores_to_memory.assert_not_called() - # Verify memory was called once - memory.add_scores_to_memory.assert_called_once() - -async def test_float_scale_threshold_scorer_with_raise_on_empty_aggregator(): - """ - Test that FloatScaleThresholdScorer raises ValueError when using RAISE_ON_EMPTY aggregator - and the underlying scorer returns no scores. - """ +async def test_float_scale_threshold_scorer_does_not_aggregate_empty_scores(): + """Child silence is policy, so it bypasses the configured value aggregator.""" from pyrit.score.float_scale.float_scale_score_aggregator import FloatScaleScoreAggregator memory = MagicMock(MemoryInterface) @@ -267,11 +254,10 @@ async def test_float_scale_threshold_scorer_with_raise_on_empty_aggregator(): scorer=scorer, threshold=0.5, float_scale_aggregator=FloatScaleScoreAggregator.MAX_RAISE_ON_EMPTY ) - # Should raise RuntimeError wrapping ValueError when aggregator encounters empty list - with pytest.raises( - RuntimeError, match="Error in scorer FloatScaleThresholdScorer.*No scores available for aggregation" - ): - await float_scale_threshold_scorer.score_text_async(text="mock example") + result_scores = await float_scale_threshold_scorer.score_text_async(text="mock example") + + assert result_scores == [] + memory.add_scores_to_memory.assert_not_called() def test_get_chat_target_delegates_to_wrapped_scorer(): diff --git a/tests/unit/score/test_message_scorer.py b/tests/unit/score/test_message_scorer.py index d269d6e4dc..582943d912 100644 --- a/tests/unit/score/test_message_scorer.py +++ b/tests/unit/score/test_message_scorer.py @@ -384,7 +384,7 @@ async def test_retired_policy_parameters_warn_and_are_ignored(self, kwargs): scorer = RecordingScorer() message = _assistant_message() - with pytest.warns(DeprecationWarning, match="retired"): + with pytest.warns(DeprecationWarning, match="deprecated"): scores = await scorer.score_async(scorable=MessageScorable.from_message(message), **kwargs) assert len(scores) == 1 @@ -484,7 +484,7 @@ async def test_retired_role_filter_is_ignored(self): """Role policy now lives on the validator, so the per-call filter must not skip anything.""" scorer = RecordingScorer() - with pytest.warns(DeprecationWarning, match="retired"): + with pytest.warns(DeprecationWarning, match="deprecated"): scores = await scorer.score_async( scorable=MessageScorable.from_message(_assistant_message()), role_filter="user", @@ -496,7 +496,7 @@ async def test_retired_skip_on_error_result_is_ignored(self): """An unreadable message now reports an undetermined verdict instead of being skipped.""" scorer = RecordingScorer() - with pytest.warns(DeprecationWarning, match="retired"): + with pytest.warns(DeprecationWarning, match="deprecated"): scores = await scorer.score_async( scorable=MessageScorable.from_message(_error_message()), skip_on_error_result=True, diff --git a/tests/unit/score/test_scorer.py b/tests/unit/score/test_scorer.py index e6b1a0711d..be19cc5884 100644 --- a/tests/unit/score/test_scorer.py +++ b/tests/unit/score/test_scorer.py @@ -1848,6 +1848,65 @@ async def test_blocked_takes_precedence_over_generic_error( # The description should also mention blocked, not just "error" assert "blocked" in scores[0].score_value_description.lower() + @pytest.mark.parametrize("error_first", [False, True]) + async def test_non_blocking_error_takes_precedence_across_all_pieces( + self, true_false_scorer_returns_empty, patch_central_database, error_first + ): + """A transport error makes the result undetermined in either piece order.""" + blocked_piece = MessagePiece( + role="assistant", + original_value="blocked", + converted_value_data_type="error", + conversation_id="test-convo", + response_error="blocked", + ) + error_piece = MessagePiece( + role="assistant", + original_value="transport failed", + converted_value_data_type="error", + conversation_id="test-convo", + response_error="processing", + ) + pieces = [error_piece, blocked_piece] if error_first else [blocked_piece, error_piece] + + scores = await true_false_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(Message(message_pieces=pieces))) + ) + + assert len(scores) == 1 + assert scores[0].status == ScoreStatus.UNDETERMINED + assert "processing" in scores[0].score_rationale + + async def test_filtered_readable_piece_does_not_hide_transport_error( + self, true_false_scorer_returns_empty, patch_central_database + ): + """Fallback classification uses the original multipart response.""" + response = Message( + message_pieces=[ + MessagePiece( + role="assistant", + original_value="transport failed", + converted_value_data_type="error", + conversation_id="test-convo", + response_error="processing", + ), + MessagePiece( + role="assistant", + original_value="unsupported", + converted_value_data_type="image_path", + conversation_id="test-convo", + ), + ] + ) + + scores = await true_false_scorer_returns_empty.score_async( + scorable=MessageScorable.from_message(store_message(response)) + ) + + assert len(scores) == 1 + assert scores[0].status == ScoreStatus.UNDETERMINED + assert "processing" in scores[0].score_rationale + class TestFloatScaleScorerEmptyScoreListRationale: """Tests for FloatScaleScorer's unified no-pieces fallback that returns Score(0.0). diff --git a/tests/unit/score/test_true_false_composite_scorer.py b/tests/unit/score/test_true_false_composite_scorer.py index 438dcd9b58..c64383d900 100644 --- a/tests/unit/score/test_true_false_composite_scorer.py +++ b/tests/unit/score/test_true_false_composite_scorer.py @@ -202,6 +202,16 @@ async def test_composite_scorer_with_task(mock_request, true_scorer): assert scores[0].objective == task +async def test_composite_scorer_propagates_silent_child(mock_request, true_scorer): + """A composite cannot aggregate when any required child made no verdict.""" + true_scorer._validator = ScorerPromptValidator(supported_roles=["assistant"]) + scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.AND, scorers=[true_scorer]) + + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) + + assert scores == [] + + async def test_composite_routes_full_expectation_to_matching_and_nonmatching_leaves(mock_request): objective_scorer = MockScorer( score_value=True, diff --git a/tests/unit/score/test_true_false_inverter.py b/tests/unit/score/test_true_false_inverter.py index 2963fb9933..9a764d284b 100644 --- a/tests/unit/score/test_true_false_inverter.py +++ b/tests/unit/score/test_true_false_inverter.py @@ -12,6 +12,7 @@ from pyrit.models import MessagePiece from pyrit.score import ( MessageScorable, + ScorerPromptValidator, SubStringScorer, TrueFalseInverterScorer, ) @@ -65,3 +66,15 @@ async def test_substring_scorer_adds_to_memory(): await scorer.score_text_async(text="string") memory.add_scores_to_memory.assert_called_once() + + +async def test_inverter_propagates_silent_child(patch_central_database): + """An inverter cannot invert a verdict that its child did not make.""" + sub_scorer = SubStringScorer(substring="test", categories=["new_category"]) + sub_scorer._validator = ScorerPromptValidator(supported_roles=["assistant"]) + scorer = TrueFalseInverterScorer(scorer=sub_scorer) + message = MessagePiece(role="user", original_value="test").to_message() + + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(message))) + + assert scores == [] From 7a7d8b6e378811267784a3abdabb02e056dbb8eb Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Mon, 31 Aug 2026 16:10:10 -0700 Subject: [PATCH 3/6] FIX: Keep response scorer signatures compatible Retain the retired response policy arguments on MessageScorer so its static methods remain compatible with the Scorer forwarders. Apply the formatter update from pre-commit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9cd5233d-054f-4496-8237-4dd41b2ed544 --- pyrit/score/message_scorer.py | 10 ++++++++++ .../executor/attack/test_error_response_scoring.py | 4 +--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index 4fa5fc96d6..88125349c2 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -414,7 +414,9 @@ async def score_response_async( response: Message, objective_scorer: Scorer | None = None, auxiliary_scorers: list[Scorer] | None = None, + role_filter: ChatMessageRole | None = None, objective: str | None = None, + skip_on_error_result: bool | None = None, ) -> dict[str, list[Score]]: """ Score a response using an objective scorer and optional auxiliary scorers. @@ -428,7 +430,9 @@ async def score_response_async( response (Message): Response containing pieces to score. objective_scorer (Scorer | None): The main scorer to determine success. Defaults to None. auxiliary_scorers (list[Scorer] | None): List of auxiliary scorers to apply. Defaults to None. + role_filter (ChatMessageRole | None): Deprecated and ignored. objective (str | None): Task/objective for scoring context. Defaults to None. + skip_on_error_result (bool | None): Deprecated and ignored. Returns: dict[str, list[Score]]: Dictionary with keys `auxiliary_scores` and `objective_scores` @@ -437,6 +441,7 @@ async def score_response_async( Raises: ValueError: If response is not provided. """ + _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) result: dict[str, list[Score]] = {"auxiliary_scores": [], "objective_scores": []} if not response: @@ -483,7 +488,9 @@ async def score_response_multiple_scorers_async( *, response: Message, scorers: list[Scorer], + role_filter: ChatMessageRole | None = None, objective: str | None = None, + skip_on_error_result: bool | None = None, ) -> list[Score]: """ Score a response using multiple scorers in parallel. @@ -494,11 +501,14 @@ async def score_response_multiple_scorers_async( Args: response (Message): The response containing pieces to score. scorers (list[Scorer]): List of scorers to apply. + role_filter (ChatMessageRole | None): Deprecated and ignored. objective (str | None): Optional objective description for scoring context. + skip_on_error_result (bool | None): Deprecated and ignored. Returns: list[Score]: All scores from all scorers """ + _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) if not scorers: return [] diff --git a/tests/unit/executor/attack/test_error_response_scoring.py b/tests/unit/executor/attack/test_error_response_scoring.py index c6ffa4acbd..ad1e8e4d6a 100644 --- a/tests/unit/executor/attack/test_error_response_scoring.py +++ b/tests/unit/executor/attack/test_error_response_scoring.py @@ -172,9 +172,7 @@ async def test_attack_executor_does_not_filter_error_response( call_kwargs = mock_score.await_args.kwargs for retired in ("skip_on_error_result", "role_filter"): assert retired not in call_kwargs, f"{type(attack).__name__} still passes the retired '{retired}' parameter" - assert call_kwargs["response"] is error_response, ( - f"{type(attack).__name__} did not pass the response as it arrived" - ) + assert call_kwargs["response"] is error_response, f"{type(attack).__name__} did not pass the response as it arrived" def _undetermined_score(response: Message) -> Score: From 4ad1337f184c3066ed93a17fc376d5ce08339b25 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Tue, 1 Sep 2026 13:24:40 -0700 Subject: [PATCH 4/6] FIX: Address scoring policy review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e2a5ec1-82af-4b4b-a69c-3306c1507164 --- doc/code/framework.md | 2 +- doc/code/scoring/0_scoring.ipynb | 4 +- doc/code/scoring/0_scoring.py | 4 +- pyrit/score/conversation_scorer.py | 2 +- pyrit/score/message_scorer.py | 200 ++++++++++++++---- pyrit/score/scorer.py | 16 +- .../float_scale_threshold_scorer.py | 2 - .../true_false/true_false_composite_scorer.py | 11 +- .../test_float_scale_threshold_scorer.py | 17 +- tests/unit/score/test_message_scorer.py | 31 ++- tests/unit/score/test_scorer.py | 24 ++- .../score/test_true_false_composite_scorer.py | 16 +- 12 files changed, 245 insertions(+), 84 deletions(-) diff --git a/doc/code/framework.md b/doc/code/framework.md index 7fe5565f37..7a4a126dcd 100644 --- a/doc/code/framework.md +++ b/doc/code/framework.md @@ -259,7 +259,7 @@ If you are contributing to PyRIT, that work will most likely land in one of the - A scorer is not limited to a message, it could be anything (e.g. was this tool called or was this file written). It receives a `Scorable`, which identifies that evidence, and an optional `ScoringExpectation`. - `TrueFalseScorer` and `FloatScaleScorer` define result families. `MessageScorer` adds message resolution and message-only policy on top of them. - A scorer declares which evidence it reads, rather than the caller filtering evidence for it. A `MessageScorer` states the conversation roles it reads on its `ScorerPromptValidator`, and stays silent when the evidence carries no role it reads. -- `Score.status` marks a verdict complete or undetermined, and the attack decides how to branch on it. Evidence a scorer cannot read reports undetermined, so "no verdict was reachable" is never confused with a negative verdict. +- `Score.status` marks a verdict complete or undetermined, and the attack decides how to branch on it. A negative verdict is a completed result that does not satisfy the scoring criterion, such as `False` or a value below a threshold. Evidence a scorer cannot read reports undetermined, so "no verdict was reachable" is not confused with a completed negative result. - **Does not own**: acting on its own result. A scorer evaluates a response and returns a score; branching on that score is the attack's job, and aggregating scores across runs is analytics'. It may call a target to evaluate, but it doesn't send the attack's objective prompt or manage the conversation. **Framework Plans**: diff --git a/doc/code/scoring/0_scoring.ipynb b/doc/code/scoring/0_scoring.ipynb index 87b0d51029..046b1097ec 100644 --- a/doc/code/scoring/0_scoring.ipynb +++ b/doc/code/scoring/0_scoring.ipynb @@ -230,7 +230,9 @@ "`ScorerPromptValidator`, and returns no score when the message carries no role it reads.\n", "Prepended (`simulated_assistant`) turns are fabricated history, so a scorer must opt in to\n", "read them. Every scorer still receives a failed response, because a scorer whose evidence\n", - "never came from the response must run even when the response failed." + "never came from the response must run even when the response failed. The deprecated\n", + "`role_filter` and `skip_on_error_result` arguments remain supported until removal, but new\n", + "code should use `supported_roles` and the scorer's unreadable-evidence fallback instead." ] }, { diff --git a/doc/code/scoring/0_scoring.py b/doc/code/scoring/0_scoring.py index 3eb426670a..5c748de992 100644 --- a/doc/code/scoring/0_scoring.py +++ b/doc/code/scoring/0_scoring.py @@ -132,7 +132,9 @@ # `ScorerPromptValidator`, and returns no score when the message carries no role it reads. # Prepended (`simulated_assistant`) turns are fabricated history, so a scorer must opt in to # read them. Every scorer still receives a failed response, because a scorer whose evidence -# never came from the response must run even when the response failed. +# never came from the response must run even when the response failed. The deprecated +# `role_filter` and `skip_on_error_result` arguments remain supported until removal, but new +# code should use `supported_roles` and the scorer's unreadable-evidence fallback instead. # %% [markdown] # ## Scoring directly # diff --git a/pyrit/score/conversation_scorer.py b/pyrit/score/conversation_scorer.py index 73c4bd1318..fdb069040d 100644 --- a/pyrit/score/conversation_scorer.py +++ b/pyrit/score/conversation_scorer.py @@ -218,7 +218,7 @@ def create_conversation_scorer( scorer (Scorer): The true/false or float-scale scorer to wrap for conversation-level evaluation. It must support text ``ContentScorable`` evidence. validator (ScorerPromptValidator | None): Optional validator override. - If not provided, uses the wrapped scorer's validator. + If not provided, uses the conversation scorer's default text validator. Returns: Scorer: A ConversationScorer instance that is also an instance of the wrapped scorer's type. diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index 88125349c2..80eda4e63a 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -129,12 +129,10 @@ def _warn_retired_message_policy( skip_on_error_result: bool | None, ) -> None: """ - Warn that per-call message policy is retired and no longer applied. + Warn that per-call message policy is retired. - Both settled before the scorer acquired its evidence, which decided for scorers whose - evidence is not the response and for scorers that widen a message into the conversation - behind it. Role is now declared on the scorer's validator, and an unreadable message - reaches the scorer, which reports what it could not determine. + The compatibility arguments continue to apply until removal. New code declares role + support on the scorer's validator and lets unreadable evidence reach the scorer fallback. """ if role_filter is not None: print_deprecation_message( @@ -150,6 +148,37 @@ def _warn_retired_message_policy( ) +def _legacy_policy_allows_message( + *, + message: Message, + role_filter: ChatMessageRole | None, + skip_on_error_result: bool, + should_score_blocked_content: bool, +) -> bool: + """ + Apply the deprecated per-call message filters. + + Args: + message (Message): The message to filter. + role_filter (ChatMessageRole | None): Required role, when supplied. + skip_on_error_result (bool): Whether to skip unreadable error results. + should_score_blocked_content (bool): Whether partial blocked content is readable. + + Returns: + bool: True when the message passes the compatibility filters. + """ + if role_filter is not None and (not message.message_pieces or message.message_pieces[0].role != role_filter): + logger.debug("Skipping scoring due to legacy role filter mismatch.") + return False + if skip_on_error_result and not _readable_pieces( + message=message, + should_score_blocked_content=should_score_blocked_content, + ): + logger.debug("Skipping scoring due to legacy error-result policy.") + return False + return True + + class MessageScorer(Scorer): """ Base class for scorers whose evidence is a single message. @@ -274,24 +303,26 @@ async def score_async( scorable (Scorable | None): Message-shaped evidence to acquire. expectation (ScoringExpectation | None): What to look for. objective (str | None): Deprecated objective string. - role_filter (ChatMessageRole | None): Deprecated and ignored. Declare - ``supported_roles`` on the scorer's validator instead. - skip_on_error_result (bool | None): Deprecated and ignored. An unreadable message - now scores undetermined rather than being skipped before it is acquired. + role_filter (ChatMessageRole | None): Deprecated compatibility filter. Declare + ``supported_roles`` on the scorer's validator for new code. + skip_on_error_result (bool | None): Deprecated compatibility policy. If True, + unreadable error results are skipped. infer_objective_from_request (bool | None): Deprecated inference policy. Returns: list[Score]: The persisted scores, or an empty list when the scorer does not read this message's role. """ - resolved_expectation, infer_objective = self._consolidate_message_inputs( - message=message, - scorable=scorable, - expectation=expectation, - objective=objective, - role_filter=role_filter, - skip_on_error_result=skip_on_error_result, - infer_objective_from_request=infer_objective_from_request, + resolved_expectation, infer_objective, legacy_role_filter, legacy_skip_on_error = ( + self._consolidate_message_inputs( + message=message, + scorable=scorable, + expectation=expectation, + objective=objective, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, + infer_objective_from_request=infer_objective_from_request, + ) ) self._validate_expectation(expectation=resolved_expectation) @@ -303,12 +334,16 @@ async def score_async( message=message, expectation=resolved_expectation, infer_objective_from_request=infer_objective, + role_filter=legacy_role_filter, + skip_on_error_result=legacy_skip_on_error, ) else: scores = await self._score_message_scorable_async( scorable=cast("Scorable", scorable), expectation=resolved_expectation, infer_objective_from_request=infer_objective, + role_filter=legacy_role_filter, + skip_on_error_result=legacy_skip_on_error, ) return await self._validate_and_persist_scores_async(scores=scores) @@ -365,10 +400,8 @@ async def score_prompts_batch_async( objectives (Sequence[str]): The objectives/tasks based on which the prompts should be scored. Must have the same length as messages. batch_size (int): The maximum batch size for processing prompts. Defaults to 10. - role_filter (ChatMessageRole | None): Deprecated and ignored. Declare - ``supported_roles`` on the scorer's validator instead. - skip_on_error_result (bool | None): Deprecated and ignored. An unreadable message now - scores undetermined rather than being skipped before it is acquired. + role_filter (ChatMessageRole | None): Deprecated compatibility filter. + skip_on_error_result (bool | None): Deprecated compatibility policy. infer_objective_from_request (bool): If True and objective is empty, attempt to infer the objective from the request. Defaults to False. @@ -402,9 +435,22 @@ async def score_prompts_batch_async( for message, objective in zip(messages, resolved_objectives, strict=True) ] + filtered_pairs = [ + (message, objective) + for message, objective in zip(messages, resolved_objectives, strict=True) + if _legacy_policy_allows_message( + message=message, + role_filter=role_filter, + skip_on_error_result=bool(skip_on_error_result), + should_score_blocked_content=self.should_score_blocked_content, + ) + ] + if not filtered_pairs: + return [] + return await self.score_batch_async( - scorables=[MessageScorable.from_message(message) for message in messages], - expectations=[ScoringExpectation(objective=objective) for objective in resolved_objectives], + scorables=[MessageScorable.from_message(message) for message, _ in filtered_pairs], + expectations=[ScoringExpectation(objective=objective) for _, objective in filtered_pairs], batch_size=batch_size, ) @@ -421,18 +467,17 @@ async def score_response_async( """ Score a response using an objective scorer and optional auxiliary scorers. - Every scorer receives the response as it arrived. Which roles a scorer reads, and - what an unreadable message produces, are the scorer's own declarations, applied after - it acquires its evidence. Filtering here would decide for scorers whose evidence never - came from the response at all. + Every scorer receives the response as it arrived unless a deprecated compatibility + filter skips it. Otherwise, which roles a scorer reads and what an unreadable message + produces are the scorer's own declarations. Args: response (Message): Response containing pieces to score. objective_scorer (Scorer | None): The main scorer to determine success. Defaults to None. auxiliary_scorers (list[Scorer] | None): List of auxiliary scorers to apply. Defaults to None. - role_filter (ChatMessageRole | None): Deprecated and ignored. + role_filter (ChatMessageRole | None): Deprecated compatibility filter. objective (str | None): Task/objective for scoring context. Defaults to None. - skip_on_error_result (bool | None): Deprecated and ignored. + skip_on_error_result (bool | None): Deprecated compatibility policy. Returns: dict[str, list[Score]]: Dictionary with keys `auxiliary_scores` and `objective_scores` @@ -443,6 +488,7 @@ async def score_response_async( """ _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) result: dict[str, list[Score]] = {"auxiliary_scores": [], "objective_scores": []} + expectation = ScoringExpectation(objective=objective) if not response: raise ValueError("Response must be provided for scoring.") @@ -450,10 +496,12 @@ async def score_response_async( # If no objective_scorer is provided, only run auxiliary_scorers if present if objective_scorer is None: if auxiliary_scorers: - aux_scores = await MessageScorer.score_response_multiple_scorers_async( + aux_scores = await MessageScorer._score_response_multiple_scorers_async( response=response, scorers=auxiliary_scorers, - objective=objective, + expectation=expectation, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, ) result["auxiliary_scores"] = aux_scores # objective_scores remains empty @@ -461,15 +509,19 @@ async def score_response_async( # Run auxiliary and objective scoring in parallel if auxiliary_scorers is provided if auxiliary_scorers: - aux_task = MessageScorer.score_response_multiple_scorers_async( + aux_task = MessageScorer._score_response_multiple_scorers_async( response=response, scorers=auxiliary_scorers, - objective=objective, + expectation=expectation, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, ) obj_task = MessageScorer._score_response_with_scorer_async( scorer=objective_scorer, response=response, - expectation=ScoringExpectation(objective=objective), + expectation=expectation, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, ) aux_scores, obj_scores = await asyncio.gather(aux_task, obj_task) result["auxiliary_scores"] = aux_scores @@ -478,7 +530,9 @@ async def score_response_async( obj_scores = await MessageScorer._score_response_with_scorer_async( scorer=objective_scorer, response=response, - expectation=ScoringExpectation(objective=objective), + expectation=expectation, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, ) result["objective_scores"] = obj_scores return result @@ -501,23 +555,47 @@ async def score_response_multiple_scorers_async( Args: response (Message): The response containing pieces to score. scorers (list[Scorer]): List of scorers to apply. - role_filter (ChatMessageRole | None): Deprecated and ignored. + role_filter (ChatMessageRole | None): Deprecated compatibility filter. objective (str | None): Optional objective description for scoring context. - skip_on_error_result (bool | None): Deprecated and ignored. + skip_on_error_result (bool | None): Deprecated compatibility policy. Returns: list[Score]: All scores from all scorers """ _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) + return await MessageScorer._score_response_multiple_scorers_async( + response=response, + scorers=scorers, + expectation=ScoringExpectation(objective=objective), + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, + ) + + @staticmethod + async def _score_response_multiple_scorers_async( + *, + response: Message, + scorers: list[Scorer], + expectation: ScoringExpectation, + role_filter: ChatMessageRole | None, + skip_on_error_result: bool | None, + ) -> list[Score]: + """ + Score a response with each scorer after applying compatibility filters. + + Returns: + list[Score]: All scores from applicable scorers. + """ if not scorers: return [] - expectation = ScoringExpectation(objective=objective) tasks = [ MessageScorer._score_response_with_scorer_async( scorer=scorer, response=response, expectation=expectation, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, ) for scorer in scorers ] @@ -534,6 +612,8 @@ async def _score_response_with_scorer_async( scorer: Scorer, response: Message, expectation: ScoringExpectation, + role_filter: ChatMessageRole | None = None, + skip_on_error_result: bool | None = None, ) -> list[Score]: """ Name the response as evidence and hand it to the scorer. @@ -541,6 +621,15 @@ async def _score_response_with_scorer_async( Returns: list[Score]: Scores from the scorer. """ + if not _legacy_policy_allows_message( + message=response, + role_filter=role_filter, + skip_on_error_result=bool(skip_on_error_result) and isinstance(scorer, MessageScorer), + should_score_blocked_content=( + scorer.should_score_blocked_content if isinstance(scorer, MessageScorer) else True + ), + ): + return [] return await scorer.score_async( scorable=MessageScorable.from_message(response), expectation=expectation, @@ -556,7 +645,7 @@ def _consolidate_message_inputs( role_filter: ChatMessageRole | None, skip_on_error_result: bool | None, infer_objective_from_request: bool | None, - ) -> tuple[ScoringExpectation | None, bool]: + ) -> tuple[ScoringExpectation | None, bool, ChatMessageRole | None, bool]: if message is not None and scorable is not None: raise ValueError("Pass either 'message' or 'scorable', not both.") if message is None and scorable is None: @@ -576,7 +665,7 @@ def _consolidate_message_inputs( _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) resolved_expectation = ScoringExpectation(objective=objective) if objective is not None else expectation - return resolved_expectation, bool(infer_objective_from_request) + return resolved_expectation, bool(infer_objective_from_request), role_filter, bool(skip_on_error_result) async def _score_scorable_async( self, @@ -594,6 +683,8 @@ async def _score_scorable_async( scorable=scorable, expectation=expectation, infer_objective_from_request=False, + role_filter=None, + skip_on_error_result=False, ) async def _score_message_scorable_async( @@ -602,6 +693,8 @@ async def _score_message_scorable_async( scorable: Scorable, expectation: ScoringExpectation | None, infer_objective_from_request: bool, + role_filter: ChatMessageRole | None, + skip_on_error_result: bool, ) -> list[Score]: """ Resolve a message scorable and score the message it names. @@ -611,6 +704,8 @@ async def _score_message_scorable_async( expectation (ScoringExpectation | None): What to look for. infer_objective_from_request (bool): Deprecated; read the objective from the previous turn when the expectation carries none. + role_filter (ChatMessageRole | None): Deprecated compatibility filter. + skip_on_error_result (bool): Deprecated compatibility policy. Returns: list[Score]: The scores, or an empty list when the scorer does not read this role. @@ -624,6 +719,8 @@ async def _score_message_scorable_async( expectation=expectation, infer_objective_from_request=infer_objective_from_request, anchor=scorable, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, ) async def _score_resolved_message_async( @@ -633,6 +730,8 @@ async def _score_resolved_message_async( expectation: ScoringExpectation | None, infer_objective_from_request: bool, anchor: Scorable | None = None, + role_filter: ChatMessageRole | None = None, + skip_on_error_result: bool = False, ) -> list[Score]: """ Run the message-scoring pipeline over an acquired message. @@ -644,6 +743,8 @@ async def _score_resolved_message_async( previous turn when the expectation carries none. anchor (Scorable | None): The scorable the caller named, when the message was acquired from one. Scores anchor on it rather than on the acquired message. + role_filter (ChatMessageRole | None): Deprecated compatibility filter. + skip_on_error_result (bool): Deprecated compatibility policy. Returns: list[Score]: The scores, or an empty list when the scorer does not read this role. @@ -656,6 +757,14 @@ async def _score_resolved_message_async( """ objective = expectation.objective if expectation else None + if not _legacy_policy_allows_message( + message=message, + role_filter=role_filter, + skip_on_error_result=skip_on_error_result, + should_score_blocked_content=self.should_score_blocked_content, + ): + return [] + # A role this scorer does not read means the evidence is not its to judge, which is # neither a verdict nor a failed acquisition. The scorer says nothing at all. if not self._reads_any_role(message=message, anchor=anchor): @@ -724,7 +833,7 @@ async def _score_resolved_message_async( raise RuntimeError(f"Error in scorer {self.__class__.__name__}: {str(e)}") from e if not scores and scoring_message.message_pieces: - scores = self._build_fallback_score(message=message, objective=objective) + scores = self._build_fallback_score(message=scoring_message, objective=objective) self._finalize_message_scores(message=scoring_message, scores=scores, anchor=anchor) @@ -1131,7 +1240,10 @@ def _build_neutral_fallback_score( Raises: ValueError: If the first message piece has no ``id`` or ``original_prompt_id``. """ - first_piece = message.message_pieces[0] + scorer_pieces = [ + piece for piece in message.message_pieces if self._validator.is_role_supported(message_piece=piece) + ] + first_piece = scorer_pieces[0] if scorer_pieces else message.message_pieces[0] piece_id = first_piece.id or first_piece.original_prompt_id if piece_id is None: raise ValueError("Cannot create score: message piece has no id or original_prompt_id") @@ -1139,7 +1251,7 @@ def _build_neutral_fallback_score( error_piece = next( ( piece - for piece in message.message_pieces + for piece in scorer_pieces if not piece.is_blocked() and (piece.has_error() or piece.converted_value_data_type == "error") ), None, @@ -1155,7 +1267,7 @@ def _build_neutral_fallback_score( objective=objective, ) ] - if all(piece.is_blocked() for piece in message.message_pieces): + if scorer_pieces and all(piece.is_blocked() for piece in scorer_pieces): rationale = f"The response was blocked with no content to score; returning {neutral_value}." description = f"Blocked response; returning {neutral_value}." else: diff --git a/pyrit/score/scorer.py b/pyrit/score/scorer.py index 7cbd1a07f0..ed64340fd6 100644 --- a/pyrit/score/scorer.py +++ b/pyrit/score/scorer.py @@ -629,26 +629,27 @@ async def score_response_async( Score a response through the message family. Deprecated. Response scoring is message-only policy, so it moved to ``MessageScorer``. - ``role_filter`` and ``skip_on_error_result`` are retired and ignored; a scorer - declares the roles it reads, and applies that after acquiring its evidence. + ``role_filter`` and ``skip_on_error_result`` are deprecated compatibility filters. + New code declares the roles it reads on the scorer. Returns: dict[str, list[Score]]: Auxiliary and objective scores, keyed by ``auxiliary_scores`` and ``objective_scores``. """ - from pyrit.score.message_scorer import MessageScorer, _warn_retired_message_policy + from pyrit.score.message_scorer import MessageScorer print_deprecation_message( old_item="Scorer.score_response_async", new_item="MessageScorer.score_response_async", removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, ) - _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) return await MessageScorer.score_response_async( response=response, objective_scorer=objective_scorer, auxiliary_scorers=auxiliary_scorers, + role_filter=role_filter, objective=objective, + skip_on_error_result=skip_on_error_result, ) @staticmethod @@ -663,23 +664,24 @@ async def score_response_multiple_scorers_async( """ Score a response with several scorers through the message family. Deprecated. - ``role_filter`` and ``skip_on_error_result`` are retired and ignored. + ``role_filter`` and ``skip_on_error_result`` are deprecated compatibility filters. Returns: list[Score]: Every score the scorers produced. """ - from pyrit.score.message_scorer import MessageScorer, _warn_retired_message_policy + from pyrit.score.message_scorer import MessageScorer print_deprecation_message( old_item="Scorer.score_response_multiple_scorers_async", new_item="MessageScorer.score_response_multiple_scorers_async", removed_in=LEGACY_SCORE_ASYNC_REMOVED_IN, ) - _warn_retired_message_policy(role_filter=role_filter, skip_on_error_result=skip_on_error_result) return await MessageScorer.score_response_multiple_scorers_async( response=response, scorers=scorers, + role_filter=role_filter, objective=objective, + skip_on_error_result=skip_on_error_result, ) async def score_batch_async( diff --git a/pyrit/score/true_false/float_scale_threshold_scorer.py b/pyrit/score/true_false/float_scale_threshold_scorer.py index 825cd94c73..13287bd928 100644 --- a/pyrit/score/true_false/float_scale_threshold_scorer.py +++ b/pyrit/score/true_false/float_scale_threshold_scorer.py @@ -120,8 +120,6 @@ async def _score_scorable_async( list[Score]: A list containing a single true/false Score based on the threshold comparison. """ scores = await self._scorer._score_nested_async(scorable=scorable, expectation=expectation) - if not scores: - return [] return self._apply_threshold( scores=scores, expectation=expectation, diff --git a/pyrit/score/true_false/true_false_composite_scorer.py b/pyrit/score/true_false/true_false_composite_scorer.py index 3166dfa8d3..f3e0c419e0 100644 --- a/pyrit/score/true_false/true_false_composite_scorer.py +++ b/pyrit/score/true_false/true_false_composite_scorer.py @@ -2,6 +2,7 @@ # Licensed under the MIT license. import asyncio +import logging from typing import TYPE_CHECKING, cast if TYPE_CHECKING: @@ -21,6 +22,8 @@ from pyrit.score.true_false.true_false_score_aggregator import TrueFalseAggregatorFunc from pyrit.score.true_false.true_false_scorer import TrueFalseScorer +logger = logging.getLogger(__name__) + class TrueFalseCompositeScorer(TrueFalseScorer): """ @@ -132,11 +135,15 @@ async def _score_scorable_async( score_list_results = await asyncio.gather( *(scorer._score_nested_async(scorable=scorable, expectation=expectation) for scorer in self._scorers) ) - if any(not scores for scores in score_list_results): + applicable_results = [scores for scores in score_list_results if scores] + skipped_count = len(score_list_results) - len(applicable_results) + if skipped_count: + logger.debug("Ignoring %d non-applicable child scorer result(s) in composite scoring.", skipped_count) + if not applicable_results: return [] return [ self._build_aggregate_score( - score_list_results=list(score_list_results), + score_list_results=applicable_results, expectation=expectation, # Score rejects a kind outside the union when it is constructed below. scorable=cast("ScorableUnion | None", scorable), diff --git a/tests/unit/score/test_float_scale_threshold_scorer.py b/tests/unit/score/test_float_scale_threshold_scorer.py index 587a0d421a..83a55251e8 100644 --- a/tests/unit/score/test_float_scale_threshold_scorer.py +++ b/tests/unit/score/test_float_scale_threshold_scorer.py @@ -210,8 +210,8 @@ async def test_float_scale_threshold_scorer_single_score_attribution_unchanged() assert score.score_metadata["original_float_value"] == pytest.approx(0.9) -async def test_float_scale_threshold_scorer_propagates_empty_scores(): - """A threshold cannot classify a verdict that its child did not make.""" +async def test_float_scale_threshold_scorer_aggregates_empty_scores(): + """The configured aggregator defines the empty-input value.""" memory = MagicMock(MemoryInterface) # Mock a scorer that returns empty list (all pieces filtered) @@ -229,12 +229,12 @@ async def test_float_scale_threshold_scorer_propagates_empty_scores(): result_scores = await float_scale_threshold_scorer.score_text_async(text="mock example") - assert result_scores == [] - memory.add_scores_to_memory.assert_not_called() + assert len(result_scores) == 1 + assert result_scores[0].get_value() is False + memory.add_scores_to_memory.assert_called_once() -async def test_float_scale_threshold_scorer_does_not_aggregate_empty_scores(): - """Child silence is policy, so it bypasses the configured value aggregator.""" +async def test_float_scale_threshold_scorer_raises_on_empty_when_configured(): from pyrit.score.float_scale.float_scale_score_aggregator import FloatScaleScoreAggregator memory = MagicMock(MemoryInterface) @@ -254,9 +254,8 @@ async def test_float_scale_threshold_scorer_does_not_aggregate_empty_scores(): scorer=scorer, threshold=0.5, float_scale_aggregator=FloatScaleScoreAggregator.MAX_RAISE_ON_EMPTY ) - result_scores = await float_scale_threshold_scorer.score_text_async(text="mock example") - - assert result_scores == [] + with pytest.raises(RuntimeError, match="No scores available for aggregation"): + await float_scale_threshold_scorer.score_text_async(text="mock example") memory.add_scores_to_memory.assert_not_called() diff --git a/tests/unit/score/test_message_scorer.py b/tests/unit/score/test_message_scorer.py index 582943d912..67f7c0ef29 100644 --- a/tests/unit/score/test_message_scorer.py +++ b/tests/unit/score/test_message_scorer.py @@ -379,15 +379,28 @@ async def test_partly_errored_message_scores_only_readable_pieces(self): assert len(scorer.scored_messages) == 1 assert [piece.original_value for piece in scorer.scored_messages[0].message_pieces] == ["usable text"] - @pytest.mark.parametrize("kwargs", [{"role_filter": "user"}, {"skip_on_error_result": True}]) - async def test_retired_policy_parameters_warn_and_are_ignored(self, kwargs): + async def test_explicit_legacy_role_filter_still_applies(self): scorer = RecordingScorer() message = _assistant_message() with pytest.warns(DeprecationWarning, match="deprecated"): - scores = await scorer.score_async(scorable=MessageScorable.from_message(message), **kwargs) + scores = await scorer.score_async( + scorable=MessageScorable.from_message(message), + role_filter="user", + ) - assert len(scores) == 1 + assert scores == [] + + async def test_explicit_legacy_skip_on_error_still_applies(self): + scorer = RecordingScorer() + + with pytest.warns(DeprecationWarning, match="deprecated"): + scores = await scorer.score_async( + scorable=MessageScorable.from_message(_error_message()), + skip_on_error_result=True, + ) + + assert scores == [] @pytest.mark.usefixtures("patch_central_database") @@ -480,8 +493,7 @@ async def test_objective_maps_to_expectation(self): assert scorer.scored_objectives == ["legacy objective"] - async def test_retired_role_filter_is_ignored(self): - """Role policy now lives on the validator, so the per-call filter must not skip anything.""" + async def test_retired_role_filter_is_preserved(self): scorer = RecordingScorer() with pytest.warns(DeprecationWarning, match="deprecated"): @@ -490,10 +502,9 @@ async def test_retired_role_filter_is_ignored(self): role_filter="user", ) - assert len(scores) == 1 + assert scores == [] - async def test_retired_skip_on_error_result_is_ignored(self): - """An unreadable message now reports an undetermined verdict instead of being skipped.""" + async def test_retired_skip_on_error_result_is_preserved(self): scorer = RecordingScorer() with pytest.warns(DeprecationWarning, match="deprecated"): @@ -502,7 +513,7 @@ async def test_retired_skip_on_error_result_is_ignored(self): skip_on_error_result=True, ) - assert len(scores) == 1 + assert scores == [] async def test_explicit_false_legacy_boolean_emits_warning(self): scorer = RecordingScorer() diff --git a/tests/unit/score/test_scorer.py b/tests/unit/score/test_scorer.py index be19cc5884..251f9a8418 100644 --- a/tests/unit/score/test_scorer.py +++ b/tests/unit/score/test_scorer.py @@ -1631,6 +1631,21 @@ async def test_scorer_score_response_async_still_dispatches(self, patch_central_ assert len(results["objective_scores"]) == 1 + async def test_scorer_score_response_async_preserves_role_filter(self, patch_central_database): + scorer = MockScorer() + message = store_message( + MessagePiece(role="assistant", original_value="response", conversation_id="legacy-role").to_message() + ) + + with pytest.warns(DeprecationWarning, match="role_filter"): + results = await Scorer.score_response_async( + response=message, + objective_scorer=scorer, + role_filter="user", + ) + + assert results["objective_scores"] == [] + async def test_scorer_score_response_multiple_scorers_async_still_dispatches(self, patch_central_database): scorer = MockScorer() message = store_message( @@ -1877,10 +1892,10 @@ async def test_non_blocking_error_takes_precedence_across_all_pieces( assert scores[0].status == ScoreStatus.UNDETERMINED assert "processing" in scores[0].score_rationale - async def test_filtered_readable_piece_does_not_hide_transport_error( + async def test_filtered_error_piece_does_not_control_fallback( self, true_false_scorer_returns_empty, patch_central_database ): - """Fallback classification uses the original multipart response.""" + """Fallback classification uses only the message view passed to the scorer.""" response = Message( message_pieces=[ MessagePiece( @@ -1904,8 +1919,9 @@ async def test_filtered_readable_piece_does_not_hide_transport_error( ) assert len(scores) == 1 - assert scores[0].status == ScoreStatus.UNDETERMINED - assert "processing" in scores[0].score_rationale + assert scores[0].status == ScoreStatus.COMPLETE + assert scores[0].get_value() is False + assert "processing" not in scores[0].score_rationale class TestFloatScaleScorerEmptyScoreListRationale: diff --git a/tests/unit/score/test_true_false_composite_scorer.py b/tests/unit/score/test_true_false_composite_scorer.py index c64383d900..27c1aac7bc 100644 --- a/tests/unit/score/test_true_false_composite_scorer.py +++ b/tests/unit/score/test_true_false_composite_scorer.py @@ -202,8 +202,7 @@ async def test_composite_scorer_with_task(mock_request, true_scorer): assert scores[0].objective == task -async def test_composite_scorer_propagates_silent_child(mock_request, true_scorer): - """A composite cannot aggregate when any required child made no verdict.""" +async def test_composite_scorer_is_silent_when_all_children_are_not_applicable(mock_request, true_scorer): true_scorer._validator = ScorerPromptValidator(supported_roles=["assistant"]) scorer = TrueFalseCompositeScorer(aggregator=TrueFalseScoreAggregator.AND, scorers=[true_scorer]) @@ -212,6 +211,19 @@ async def test_composite_scorer_propagates_silent_child(mock_request, true_score assert scores == [] +async def test_composite_scorer_ignores_non_applicable_child(mock_request, true_scorer, false_scorer): + false_scorer._validator = ScorerPromptValidator(supported_roles=["assistant"]) + scorer = TrueFalseCompositeScorer( + aggregator=TrueFalseScoreAggregator.OR, + scorers=[false_scorer, true_scorer], + ) + + scores = await scorer.score_async(scorable=MessageScorable.from_message(store_message(mock_request))) + + assert len(scores) == 1 + assert scores[0].get_value() is True + + async def test_composite_routes_full_expectation_to_matching_and_nonmatching_leaves(mock_request): objective_scorer = MockScorer( score_value=True, From 41f07a9ccbd5ba6b0a54c7afdfb8a886aff569a1 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Tue, 1 Sep 2026 14:08:58 -0700 Subject: [PATCH 5/6] DOC: Clarify scorer fallback behavior Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e2a5ec1-82af-4b4b-a69c-3306c1507164 --- doc/code/framework.md | 4 +-- doc/code/scoring/3_combining_scorers.ipynb | 8 ++++++ doc/code/scoring/3_combining_scorers.py | 8 ++++++ .../attack/multi_turn/tree_of_attacks.py | 11 ++++---- pyrit/score/float_scale/float_scale_scorer.py | 18 ++++++------- pyrit/score/message_scorer.py | 6 ++--- pyrit/score/true_false/true_false_scorer.py | 25 ++++++++----------- 7 files changed, 44 insertions(+), 36 deletions(-) diff --git a/doc/code/framework.md b/doc/code/framework.md index 7a4a126dcd..3416d18295 100644 --- a/doc/code/framework.md +++ b/doc/code/framework.md @@ -258,8 +258,8 @@ If you are contributing to PyRIT, that work will most likely land in one of the - Any decision an attack makes should be based on a scorer result - A scorer is not limited to a message, it could be anything (e.g. was this tool called or was this file written). It receives a `Scorable`, which identifies that evidence, and an optional `ScoringExpectation`. - `TrueFalseScorer` and `FloatScaleScorer` define result families. `MessageScorer` adds message resolution and message-only policy on top of them. -- A scorer declares which evidence it reads, rather than the caller filtering evidence for it. A `MessageScorer` states the conversation roles it reads on its `ScorerPromptValidator`, and stays silent when the evidence carries no role it reads. -- `Score.status` marks a verdict complete or undetermined, and the attack decides how to branch on it. A negative verdict is a completed result that does not satisfy the scoring criterion, such as `False` or a value below a threshold. Evidence a scorer cannot read reports undetermined, so "no verdict was reachable" is not confused with a completed negative result. +- A scorer declares which evidence it reads, rather than the caller filtering evidence for it. A `MessageScorer` states the conversation roles it reads on its `ScorerPromptValidator`. It returns no score when the evidence carries no role it reads. +- `Score.status` marks a verdict complete or undetermined, and the attack decides how to branch on it. A negative verdict is a completed result that does not satisfy the scoring criterion, such as `False` or a value below a threshold. For a role the scorer reads, an unreadable transport or protocol response produces an undetermined score. This result keeps "no verdict was reachable" separate from a completed negative result. Fully blocked responses use the scorer family's neutral fallback unless a specialized scorer overrides it. - **Does not own**: acting on its own result. A scorer evaluates a response and returns a score; branching on that score is the attack's job, and aggregating scores across runs is analytics'. It may call a target to evaluate, but it doesn't send the attack's objective prompt or manage the conversation. **Framework Plans**: diff --git a/doc/code/scoring/3_combining_scorers.ipynb b/doc/code/scoring/3_combining_scorers.ipynb index 5dfbc6e385..ca4da5f527 100644 --- a/doc/code/scoring/3_combining_scorers.ipynb +++ b/doc/code/scoring/3_combining_scorers.ipynb @@ -100,6 +100,14 @@ "text `ContentScorable` evidence. It returns a dynamic wrapper that remains the same scorer\n", "kind as its input.\n", "\n", + "An empty child result means that the scorer did not apply. A composite scorer ignores empty\n", + "child results and aggregates the remaining results. It returns an empty list if every child\n", + "result is empty. An inverter passes an empty result through unchanged. A threshold wrapper\n", + "passes an empty result to its float-scale aggregator. The standard aggregators map it to\n", + "`0.0`, while the `*_RAISE_ON_EMPTY` variants raise `ValueError`. A conversation wrapper\n", + "returns an empty result when it finds no applicable conversation evidence or its child\n", + "returns no score. Any outer wrapper then applies the rules above.\n", + "\n", "Deprecated message-shaped calls remain on `MessageScorer`, but generic wrappers do not\n", "project those APIs from their children. Score wrappers through the canonical `Scorable` API.\n", "\n", diff --git a/doc/code/scoring/3_combining_scorers.py b/doc/code/scoring/3_combining_scorers.py index 213034c99c..b463f7a2d2 100644 --- a/doc/code/scoring/3_combining_scorers.py +++ b/doc/code/scoring/3_combining_scorers.py @@ -83,6 +83,14 @@ # text `ContentScorable` evidence. It returns a dynamic wrapper that remains the same scorer # kind as its input. # +# An empty child result means that the scorer did not apply. A composite scorer ignores empty +# child results and aggregates the remaining results. It returns an empty list if every child +# result is empty. An inverter passes an empty result through unchanged. A threshold wrapper +# passes an empty result to its float-scale aggregator. The standard aggregators map it to +# `0.0`, while the `*_RAISE_ON_EMPTY` variants raise `ValueError`. A conversation wrapper +# returns an empty result when it finds no applicable conversation evidence or its child +# returns no score. Any outer wrapper then applies the rules above. +# # Deprecated message-shaped calls remain on `MessageScorer`, but generic wrappers do not # project those APIs from their children. Score wrappers through the canonical `Scorable` API. # diff --git a/pyrit/executor/attack/multi_turn/tree_of_attacks.py b/pyrit/executor/attack/multi_turn/tree_of_attacks.py index 0853d7c638..9d2b5e082f 100644 --- a/pyrit/executor/attack/multi_turn/tree_of_attacks.py +++ b/pyrit/executor/attack/multi_turn/tree_of_attacks.py @@ -804,12 +804,11 @@ async def _score_response_async(self, *, response: Message, objective: str) -> N and any auxiliary scorers (which provide additional metrics). The scoring results are used by the TAP algorithm to decide which branches to explore further. - Blocked or errored responses are scored via the scorer's unified default behavior: - ``TrueFalseScorer`` returns - ``Score(False)`` and ``FloatScaleScorer`` - returns ``Score(0.0)`` whenever no supported pieces remain after validator filtering - (the normal outcome for a blocked piece). This keeps blocked branches at the bottom - of the priority queue without needing attack-level error mapping. + Scorers apply their own unreadable-response policy. A fully blocked response uses the + scorer family's neutral fallback unless the scorer overrides it. An unreadable transport + or protocol response produces an undetermined score. A response with no role supported + by the objective scorer produces no objective score, so this method raises ``RuntimeError``. + Tree of Attacks does not map these outcomes to ``False`` or ``0.0``. Args: response (Message): The response from the objective target to evaluate. diff --git a/pyrit/score/float_scale/float_scale_scorer.py b/pyrit/score/float_scale/float_scale_scorer.py index 33a77fb751..27597c07fc 100644 --- a/pyrit/score/float_scale/float_scale_scorer.py +++ b/pyrit/score/float_scale/float_scale_scorer.py @@ -72,17 +72,13 @@ class MessageFloatScaleScorer(FloatScaleScorer, MessageScorer): to which a response exhibits certain characteristics. Each piece in a request response is scored independently, returning one score per piece. - **Default error / blocked behavior** - - When no supported pieces remain after validator filtering (e.g. the response is - blocked, has another error type, or no piece matches the scorer's supported data - types), the base ``score_async`` invokes ``_build_fallback_score`` and returns a - single ``Score`` with value ``0.0``. The rationale distinguishes blocked / error / - filtered cases. This mirrors ``MessageTrueFalseScorer``'s ``False`` default so that - downstream consumers (attack strategies, threshold wrappers) get a consistent, - "attack did not succeed" value without each call site needing special-cased error - handling. Subclasses that need different semantics (e.g. a refusal-style - "blocked = True") should override ``_score_piece_async`` or ``_build_fallback_score``. + **Default unreadable / blocked behavior** + + A message that has no role supported by this scorer produces no score. For a supported + role, an unreadable transport or protocol response produces an undetermined score. A + fully blocked response or one with no supported data type produces a completed ``0.0`` + score. Subclasses can override ``_build_fallback_score`` when they need different + semantics. """ def __init__( diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index 80eda4e63a..7e09f7a7f6 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -1224,9 +1224,9 @@ def _build_neutral_fallback_score( """ Build the family's neutral result for a message that carries nothing to score. - Blocked, error, and filtered messages read the same way whatever the score family is, - so this message-family policy is stated once here. Each family supplies only its - neutral value. + This shared message policy returns an undetermined score for an unreadable transport + or protocol response. It returns the score family's neutral value for a fully blocked + response or one with no supported data type. Args: message (Message): The message that carries no readable pieces. diff --git a/pyrit/score/true_false/true_false_scorer.py b/pyrit/score/true_false/true_false_scorer.py index 9f4f3d4f90..185199f4a4 100644 --- a/pyrit/score/true_false/true_false_scorer.py +++ b/pyrit/score/true_false/true_false_scorer.py @@ -117,17 +117,13 @@ class MessageTrueFalseScorer(TrueFalseScorer, MessageScorer): whether the response meets a specific criterion. Multiple pieces in a request response are aggregated using a TrueFalseAggregatorFunc function (default: TrueFalseScoreAggregator.OR). - **Default error / blocked behavior** - - When no supported pieces remain after validator filtering (e.g. the response is - blocked, has another error type, or no piece matches the scorer's supported data - types), the base ``score_async`` invokes ``_build_fallback_score`` and returns a - single ``Score(False)`` whose rationale distinguishes blocked / error / filtered - cases. This mirrors ``MessageFloatScaleScorer``'s ``0.0`` default so that downstream - consumers (attack strategies, threshold wrappers) get a consistent, "attack did not - succeed" value without each call site needing special-cased error handling. - Subclasses that need different semantics (e.g. ``SelfAskRefusalScorer``, which - returns ``True`` on blocked) should override ``_build_fallback_score``. + **Default unreadable / blocked behavior** + + A message that has no role supported by this scorer produces no score. For a supported + role, an unreadable transport or protocol response produces an undetermined score. A + fully blocked response or one with no supported data type produces a completed ``False`` + score. Subclasses can override ``_build_fallback_score`` when they need different + semantics. For example, ``SelfAskRefusalScorer`` returns ``True`` on a blocked response. """ def __init__( @@ -175,9 +171,10 @@ async def _score_async(self, message: Message, *, objective: str | None = None) Score the given request response asynchronously. For TrueFalseScorer, multiple piece scores are aggregated into a single true/false score. - When no supported pieces remain (e.g. the response was blocked, had an error, or no piece - type matched the validator), returns an empty list; the base ``score_async`` then invokes - ``_build_fallback_score`` to produce a single neutral ``Score(False)``. + When a supported role has no scoreable pieces, this method returns an empty list. The base + ``score_async`` then invokes ``_build_fallback_score``. That fallback is undetermined for + an unreadable transport or protocol response and ``False`` for a fully blocked or filtered + response. Args: message (Message): The message to score. From 514113f7f8cf2f1ae146a1b8782d47a4d710d9a2 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Tue, 1 Sep 2026 16:24:24 -0700 Subject: [PATCH 6/6] DOC: Clarify message role invariant Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e2a5ec1-82af-4b4b-a69c-3306c1507164 --- pyrit/score/message_scorer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyrit/score/message_scorer.py b/pyrit/score/message_scorer.py index 7e09f7a7f6..b29a9fb6bf 100644 --- a/pyrit/score/message_scorer.py +++ b/pyrit/score/message_scorer.py @@ -969,6 +969,8 @@ def _reads_any_role(self, *, message: Message, anchor: Scorable | None) -> bool: different questions. An unread role means the evidence belongs to another scorer, so this one stays silent; an unsupported data type means this scorer was handed evidence it should have judged but cannot read, which its family reports as a fallback score. + ``Message`` validation requires every piece in one message to have the same role, so + readable evidence from an unsupported role cannot mask an unreadable supported role. Args: message (Message): The acquired message.