Skip to content

FEAT: Adding Scorables as an entry point - #2400

Open
Richard Lundeen (rlundeen2) wants to merge 13 commits into
microsoft:mainfrom
rlundeen2:rlundeen2-effective-memory
Open

FEAT: Adding Scorables as an entry point#2400
Richard Lundeen (rlundeen2) wants to merge 13 commits into
microsoft:mainfrom
rlundeen2:rlundeen2-effective-memory

Conversation

@rlundeen2

@rlundeen2 Richard Lundeen (rlundeen2) commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

Phase 1 of the scorer contract proposal: https://gist.github.com/rlundeen2/cb2e3a14ade6159258e276bcecb707d9

A scorer now takes two inputs: a scorable (what to look at) and a ScoringExpectation (what to look for). This is a signature change, not a rewrite. No scorer body changes.

Introduce Scorable and its variants; make score_async(*, scorable, expectation) the entry point. Add ScoringExpectation. Keep the Message signature as a deprecated wrapper with the mapping in §13, and add the MessageScorer intermediate base so no existing scorer body changes. Re-express score_text_async / score_image_async over ContentScorable. Migrate the in-repo call sites and make the deprecation warning an error in CI.

Scorable = MessageScorable | ContentScorable. MessageScorable names message piece ids and resolves itself through memory; ContentScorable holds a value that was never persisted. The scorable defines the scope that is scored.

Wrapper scorers (composite, inverter, threshold) still resolve a Message rather than forwarding the scorable, because forwarding would bypass the exception wrapping, fallback scores, and ephemeral-link dropping that live in MessageScorer. The gist places that work, and the composite anchor fix, in phase 2.

Tests and Documentation

tests/unit/score/test_scorable.py and test_message_scorer.py are new. test_message_scorer.py covers the deprecated path deliberately: one test per legacy input (positional message, message=, objective=, role_filter=, skip_on_error_result=, infer_objective_from_request=), plus the §13 rule that the shim scores the supplied message rather than widening to its conversation, plus the conflicting-input guards. Each opts in with pytest.warns.

Everything else is migrated. pyproject.toml turns this deprecation into an error for the test suite, so an unmigrated call fails CI. Full unit suite: 15373 passed, 6 skipped.

Docs are not updated yet. Five files under doc/ still call the legacy path and will emit deprecation warnings until a follow-up pass. JupyText has not been run for this reason. Holding that pass until the code is reviewed.

Give Scorer two inputs: a scorable (what to look at) and an expectation
(what to look for). This lets a scorer answer questions that are not about a
model response, which framework.md requires but the Message-only signature
made impossible.

This is a signature change, not a rewrite. A new MessageScorer intermediate
base owns every message-shaped concern -- resolving a scorable to a Message,
refusal and blocked-content substitution, piece validation, the role and error
filters, the neutral fallback, and the exception wrapping around _score_async.
The three direct Scorer subclasses re-parent onto it and no scorer body
changes.

- pyrit/models/score/ becomes a package holding every score type: the new
  scorables, ScoringExpectation and its conditions, ScoringScope, and the
  existing Score.
- role_filter and skip_on_error_result move onto the message scorables, since
  both answer "which pieces count".
- score_text_async and score_image_async keep their signatures and build a
  ContentScorable. Loose content stays ephemeral.
- infer_objective_from_request is retired internally. ScorerEvaluator now
  reads each objective from the previous turn itself.
- The legacy message-shaped parameters survive one release behind a
  DeprecationWarning, which the in-repo suite treats as an error.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6f9fd6d-38b7-4841-bb94-c608033c1c04
Phase 1 shipped every scorable, condition, and scope type the design proposal
names. Most of them have no consumer in this change and no phase in Part A
(gist phases 1-6) that can construct or read one. The proposal itself says
"nothing in Part A populates" a ScoringScope.

Removed, with the phase that will reintroduce each:

- SurfaceScorable and ScoringScope - phase 10, Part B, which is the first
  phase that populates a scope.
- TraceScorable - phase 5, where it arrives with TraceSource and the
  Observation model it feeds.
- ConversationScorable - never constructed in Part A. Phase 6 widens inside
  the scorer, as ConversationScorer reads conversation_id off the message
  today.
- ToolCalled, ToolSequence, OutputMatches, Condition, and
  ScoringExpectation.conditions - phase 4 transports an expectation and phase
  6 is the first reader. Until then a caller who sets conditions gets silence
  rather than an error, so ScoringExpectation holds objective alone.
- ScoringExpectation.extra - named by no phase.
- Volatility and the volatility ClassVars - nothing branches on them; their
  consumers are memoization in phase 3 and the lifecycle in phase 11.

ScoringScope also carried a defect no consumer caught: window is declared
tuple[datetime, datetime] | None and a test passed the string "last_turn". A
frozen dataclass does not validate, so the test passed.

Kept: MessageScorable, ContentScorable, MessageReferenceScorable, and
ScoringExpectation. MessageReferenceScorable has a resolver and no producer,
but phase 2 persists Score.scorable and a MessageScorable holding a live
Message cannot round-trip through a column, so an id-based form is the
persisted form.

The unsupported-scorable test now uses a module-local dataclass instead of
ConversationScorable, which proves the guard rejects anything outside
_SUPPORTED_SCORABLES rather than one known sibling.

No runtime behavior changes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6f9fd6d-38b7-4841-bb94-c608033c1c04
MessageScorer did the resolving: an isinstance chain over a union of dumb
records, plus a memory read and getattr duck-typing for the filter fields.
That put data plumbing in the scorer and did not extend to the trace and
surface scorables the design adds later.

Scorables now resolve themselves. Scorable becomes an abstract root type
instead of a union, SingleMessageScorable declares role_filter,
skip_on_error_result, and an abstract resolve_message, and each concrete
scorable says how it resolves: MessageScorable returns what it holds,
MessageReferenceScorable reads memory, ContentScorable builds the
never-persisted message. Adding a scorable is now adding a class, with no
union to edit.

Resolution needs pyrit.memory, which models.instructions.md does not allow
on a model, so the scorables move from pyrit/models/score/ to pyrit/score/.
That also clears a real cost: MessageScorable holds a Message but could not
import one at runtime, because pyrit.models.messages imports
ComponentIdentifierField back out of pyrit.models.score. ScoringExpectation
stays in pyrit.models — it has no behavior, and a later phase has SeedGroup
produce one.

Only identity resolution belongs on a scorable. A scorer that derives a
different kind of evidence from the same reference, such as trace ids or a
file write, still does that widening itself.

Removes _SUPPORTED_SCORABLES, the three-arm _resolve_message, and both
getattr(scorable, ...) calls.

This also carries three fixes from the review of the previous commit:

- Scorer no longer defines the message hooks. _score_async,
  _score_piece_async, and _get_supported_pieces move to MessageScorer, and
  Scorer._score_scorable_async becomes abstract. A scorer deriving directly
  from Scorer that implemented only _score_piece_async used to build fine
  and then fail at score time with a confusing TypeError; it now fails at
  instantiation with a clear abstract-method error.
- A partially resolvable MessageReferenceScorable raises and names only the
  ids that are missing, rather than only raising when nothing resolved.
- The scorable module no longer needs a TYPE_CHECKING workaround for
  Message, since the circular import it worked around does not reach
  pyrit.score.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6f9fd6d-38b7-4841-bb94-c608033c1c04
ContentScorable was made a SingleMessageScorable, which claimed it names
exactly one Message. It does not. It carries content, and the Message it
produced was fabricated on the spot as an adapter so message scorers could
read it. The design proposal is explicit that a scorable is a reference and
that loose content is the one exception (section 4), and that a later phase
persists the content as a row of its own and retires the fabricated,
never-persisted message (section 9.1).

Putting it under that base also handed it role_filter and
skip_on_error_result, which are meaningless for content that has no role
and no error state. That was not only untidy: ContentScorable(value=...,
role_filter="assistant") was accepted and then silently scored nothing,
because the adapted piece is always role="user". The previous getattr
duck-typing defaulted the filter to None, so this state was newly
reachable.

ContentScorable now derives from Scorable directly and exposes
to_ephemeral_message instead of resolve_message. The different verb is the
point: the message-shaped scorables return a message that already exists,
while loose content becomes one. MessageScorer bridges the two shapes in
two arms, and the content arm is marked as the transitional adapter it is.
Passing a filter to ContentScorable is now a TypeError at construction.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6f9fd6d-38b7-4841-bb94-c608033c1c04
Collapse the two message scorables into one by-id MessageScorable, per the
gist. Use the from_message classmethods instead of a free helper. Correct the
legacy removal version to 2.0.0. Rename _resolve_score_inputs to
_consolidate_legacy_inputs. Deprecate extract_objective_from_previous_turn.
Remove the dead objective ternary in Crescendo. Fail when a scorable names a
piece id that memory does not hold.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6f9fd6d-38b7-4841-bb94-c608033c1c04
The choice between loose content and a message reference was repeated in the
legacy shim and in each of the three true/false wrappers. Move it to
Scorer._scorable_for_message, which is the single bridge for the two callers
that still hold a Message rather than the scorable it came from.

The gist puts wrapper forwarding and the composite anchor fix in phase 2, and
phase 1 changes no scorer body, so the wrappers keep resolving a message for
now.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6f9fd6d-38b7-4841-bb94-c608033c1c04
ScoringExpectation.objective is already optional, and a scorer reads it as
expectation.objective if expectation else None, so an expectation holding no
objective and no expectation at all mean the same thing. The ternary said
otherwise in eight places and was dropped in a ninth, which made the callers
look like they differed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d6f9fd6d-38b7-4841-bb94-c608033c1c04
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1e5bf78a-8e94-4839-b6bf-7f29a6a19a38
@rlundeen2
Richard Lundeen (rlundeen2) marked this pull request as ready for review August 17, 2026 18:14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks great! I really like this PR, just have some questions about the implementation

if len(message.message_pieces) == 1 and message.get_piece().not_in_memory
else MessageScorable.from_message(message)
)
expectation = ScoringExpectation(objective=objective)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR introduces a ScoringExpectation that contains a single field objective. What are the pros and cons of this for composite scoring? This implementation uses the same expectation for each distinct scorer in the composite scorer. Do you think we should try to have a composite ScoringExpectation containing multiple objectives, one per subscorer, or something else?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fwiw I noticed in the gist you expanded on the concept in section 5 so I'm curious why we don't add those other fields in this PR



@dataclass(frozen=True, kw_only=True)
class ContentScorable(Scorable):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What are the pros and cons of making MessageScorable decorate or otherwise use ContentScorable as a component? I like the idea of both inheriting Scorable (here's how to evaluate this piece of information) but I'm noticing that the fields in MessageScorable are a superset of ContentScorable (since a MessagePiece contains value and data_type.)

Comment on lines +86 to +90
scorable=(
ContentScorable.from_message(message)
if len(message.message_pieces) == 1 and message.get_piece().not_in_memory
else MessageScorable.from_message(message)
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm seeing these exact lines repeated a few times in this PR and I'm unsure about them. Firstly, because it's repeating the same basic idea across several scorers (treat this as ContentScorable if it's one piece and not in memory, otherwise treat it like MessageScorable). Secondly because this seems like a responsibility for score_async to just take a Scorable and figure out how to score it unless its caller specifically overrides it. I think the basic idea is great which is to distinguish between context-based scoring and content-based scoring, but I'm not sure about this implementation

Comment on lines +10 to +19
class ScoringExpectation:
"""
What a scorer scores against.

An expectation is a single parameter that attacks forward without inspecting it,
so a question authored in a technique configuration or a seed can reach a scorer
through an attack that knows nothing about it.
"""

objective: str | None = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This may be a nit, but if ScoringExpectation exists to strongly type scoring objectives so that attacks forward them without needing to inspect them, why not just call this a ScoringObjective? Do you see this class evolving past this use (e.g. holding multiple objectives, confidence thresholds, etc.?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As I said earlier the gist expands on this significantly so I'm ok with this as-is but how do you see subsequent PRs extending this with conditions and/or other fields

Comment thread pyrit/score/message_scorer.py Outdated
else:
legacy_message = cast("Message", message)
resolved_scorable = (
ContentScorable.from_message(legacy_message)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why isn't this a message scorable ? don't we lose some attributes if we are creating content scorables from messages ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is only if the message isn't in memory (not_in_memory = True), but I don't quite remember the situations in which this happens, maybe worth a comment

Comment on lines +183 to +190
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.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: this is all validation so imo doesn't fit in a consolidate function

async def _score_message_scorable_async(
self,
*,
scorable: Scorable,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

kinda confused whether this function is just for scoring messages because it takes in a generic scorable but the comments all refer to messages. what about _score_message_compatible_scorable_async

Comment thread pyrit/score/scorer.py

enforce_keyword_only_init(cls, base_name="Scorer")

def __init__(self, *, validator: ScorerPromptValidator, chat_target: PromptTarget | None = None) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we want a deprecation warning for this right ?

@hannahwestra25

Copy link
Copy Markdown
Contributor

are you planning on updating the docs after all stages are completed ?

Comment thread pyrit/score/message_scorer.py Outdated
[
piece.original_value
for piece in conversation
if piece.sequence == last_prompt.sequence - 1 and piece.original_value_data_type == "text"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be == piece.sequence - 1 (and also check that piece.sequence>=1) in order to do what the docstring says, which is Read the text of the turn before an assistant message and use it as the objective.?

skip_on_error_result: bool = False


def extract_objective_from_previous_turn(*, message: Message, memory: MemoryInterface) -> str:

@jsong468 Justin Song (jsong468) Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

when this gets removed, how will scorer_evaluator logic change (seems to be only consumer of this function)?

from pyrit.prompt_target import CapabilityName
from pyrit.prompt_target.common.target_requirements import TargetRequirements
from pyrit.score import MessageScorable
from pyrit.score.message_scorer import MessageScoringOptions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we let MessageScoringOptions be exported from pyrit.score?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

curious why we do object validation for MessageScorable here as opposed to in the dataclass post_init? (e.g., things like >=1 piece ID, no duplicate IDs, valid UUIDs) and also similarly that the PromptDataType is valid for ContentScorable

@romanlutz Roman Lutz (romanlutz) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great start for the scorer upgrades!

scorable = (
ContentScorable.from_message(message)
if len(message.message_pieces) == 1 and message.get_piece().not_in_memory
else MessageScorable.from_message(message)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we avoid converting this prepared Message back into persisted IDs? Some callers intentionally change the message content while retaining its original ID. For example, ConversationScorer combines the whole conversation into one string, and blocked-content handling substitutes partial content. The child scorer then reloads the original stored message and silently scores that instead. I think nested scorers need a way to receive the already-resolved message directly.

ContentScorable: A scorable holding the converted message content.
"""
piece = message.get_piece()
return cls(value=piece.converted_value, data_type=piece.converted_value_data_type)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(GHCP comment) This conversion drops message-specific state such as the role and response_error; the resolver reconstructs the piece as a user message with no error. For example, an ephemeral blocked assistant response passed through the compatibility shim will no longer take SelfAskRefusalScorer's deterministic blocked-response path and can be scored as not refused. Could ephemeral messages use a lossless representation, leaving ContentScorable for genuinely loose content?

Wrapper scorers forwarded a prepared message by re-describing it as a scorable,
so children reloaded the pre-substitution pieces from memory, or lost role and
error state for a message that was never persisted. Add
MessageScorer.score_message_async as the in-hand entry point and route the
composite, inverter, threshold, and deprecated-message paths through it.

Also: adapt pre-2.0 direct Scorer subclasses so they keep working; declare and
enforce routable conditions instead of dropping them silently; read the request
for the scored turn rather than the conversation's latest; export
MessageScoringOptions and MessageScorableResolver; validate MessageScorable id
tuples; migrate the remaining documentation call sites.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7affb573-e048-445a-9152-0b46e997dd89
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7affb573-e048-445a-9152-0b46e997dd89
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants