Skip to content

FEAT: Adding Scorables as an entry point - #2400

Merged
Richard Lundeen (rlundeen2) merged 14 commits into
microsoft:mainfrom
rlundeen2:rlundeen2-effective-memory
Aug 20, 2026
Merged

FEAT: Adding Scorables as an entry point#2400
Richard Lundeen (rlundeen2) merged 14 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

Comment thread pyrit/score/true_false/true_false_composite_scorer.py Outdated
Comment thread pyrit/models/score/scorable.py
Comment thread pyrit/score/true_false/true_false_inverter_scorer.py Outdated
Comment thread pyrit/models/score/expectation.py
Comment thread pyrit/score/message_scorer.py Outdated
Comment thread pyrit/score/message_scorer.py
Comment thread pyrit/score/message_scorer.py
Comment thread pyrit/score/scorer.py
@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
Comment thread pyrit/score/message_scorer.py
Comment thread pyrit/executor/attack/multi_turn/red_teaming.py Outdated
Comment thread pyrit/score/message_scorable_resolver.py

@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!

Comment thread pyrit/score/true_false/true_false_composite_scorer.py Outdated
Comment thread pyrit/models/score/scorable.py
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
@rlundeen2

Copy link
Copy Markdown
Contributor Author

Rich agrees with this comment but it is copilot generated:

Yes. This phase updates affected documentation call sites so the examples remain compatible and runnable. I plan a dedicated conceptual and migration documentation pass after the remaining scorer-contract stages settle, so the documentation describes the final API instead of changing during each intermediate stage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7affb573-e048-445a-9152-0b46e997dd89
@rlundeen2
Richard Lundeen (rlundeen2) added this pull request to the merge queue Aug 20, 2026
Merged via the queue into microsoft:main with commit d65ed40 Aug 20, 2026
54 checks passed
@rlundeen2
Richard Lundeen (rlundeen2) deleted the rlundeen2-effective-memory branch August 20, 2026 17:49
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