Skip to content

[FEAT]: Add execution trial populations and threshold verdicts - #121

Open
Behnam (behnam-o) wants to merge 24 commits into
microsoft:mainfrom
behnam-o:execution-trials
Open

[FEAT]: Add execution trial populations and threshold verdicts#121
Behnam (behnam-o) wants to merge 24 commits into
microsoft:mainfrom
behnam-o:execution-trials

Conversation

@behnam-o

@behnam-o Behnam (behnam-o) commented Jul 20, 2026

Copy link
Copy Markdown
Contributor
  • Add execute_trials_async() and public PopulationResult.
  • Evaluate repeated executions using configurable safety thresholds.
  • Preserve population metadata across pytest, xdist, and JSON reporting.
  • Update documentation and unit tests.

Proposed stack order

  1. [FEAT]: Add execution trial populations and threshold verdicts #121 (this PR)
  2. (DRAFT) [FEAT]: Replace trial cloning with configurable trial populations #123

Merge this PR first. GitHub cannot link these PRs as an official stack because both heads are in a fork.

@behnam-o
Behnam (behnam-o) requested a review from a team July 20, 2026 20:28
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI review requested due to automatic review settings July 22, 2026 20:54

Copilot AI 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.

Pull request overview

Adds a first-class “population” execution path to RAMPART’s execution layer so callers can run a safety test multiple times and assert a single threshold-based verdict, while updating pytest plugin aggregation semantics and associated docs/tests.

Changes:

  • Introduces execute_trials_async() returning a new PopulationResult aggregate with threshold-based verdict semantics.
  • Updates pytest trial-group aggregation/gating semantics (notably allowing UNSAFE outcomes when the SAFE pass-rate meets the threshold, while ERROR should fail the group).
  • Refreshes documentation and unit tests to reflect the new execution- and aggregation-layer behavior.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/unit/pytest_plugin/test_xdist_aggregation.py Updates xdist aggregation expectations from unconditional UNSAFE-fail to threshold-based pass behavior.
tests/unit/pytest_plugin/test_plugin.py Adjusts trial-group aggregation unit tests and adds coverage for excluding no-result clones from the denominator.
tests/unit/probes/test_single_turn.py Adds a regression test ensuring each trial creates an isolated session when using repeated execution.
tests/unit/core/test_result.py Adds PopulationResult unit tests covering threshold logic and status precedence.
tests/unit/core/test_execution.py Adds execution-layer tests for execute_trials_async() and public export checks for PopulationResult.
rampart/pytest_plugin/plugin.py Updates gate-evaluation logging semantics (now focusing on ERROR and threshold-based pass rate).
rampart/pytest_plugin/_session.py Changes trial-group aggregation semantics: pass-rate denominator excludes no_result, and group passing is threshold-based.
rampart/core/result.py Introduces PopulationResult aggregate type and its status/summary behavior.
rampart/core/execution.py Adds execute_trials_async() to run n full lifecycles and return a PopulationResult.
rampart/core/init.py Re-exports PopulationResult from rampart.core.
rampart/init.py Re-exports PopulationResult from the top-level rampart API.
docs/usage/pytest-integration.md Documents execute_trials_async() semantics as the primary repeated-execution approach.
docs/usage/ci-integration.md Updates CI guidance for trial semantics and threshold/error/no-result behavior.
docs/getting-started/quickstart.md Updates the quickstart example to use execute_trials_async() and describes population-level assertion behavior.

Comment thread rampart/pytest_plugin/plugin.py Outdated
Comment thread docs/usage/pytest-integration.md Outdated
Comment thread rampart/pytest_plugin/_session.py Outdated
Copilot AI review requested due to automatic review settings July 22, 2026 21:00

Copilot AI 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.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

rampart/pytest_plugin/_session.py:283

  • record_trial_group() is documented as “ERROR results make the group fail”, but the counting logic can miss ERRORs when a clone recorded multiple results containing both ERROR and UNSAFE (current if has_unsafe: … elif has_error: …). In that case error_count stays 0 and the group can incorrectly pass if the threshold is met. Prioritize ERROR over UNSAFE so any ERROR in a clone is always reflected in group.errors and group.passed.
        executed_count = total - no_result_count
        pass_rate = safe_count / executed_count if executed_count > 0 else 0.0
        passed = (
            error_count == 0
            and executed_count > 0

docs/usage/pytest-integration.md:71

  • pytest-integration.md now documents execute_trials_async, but it no longer mentions @pytest.mark.trial even though other docs (e.g. CI integration) still recommend it and the quickstart links here as the marker reference. Add a short @pytest.mark.trial subsection clarifying that it runs clones as separate pytest items (possibly across xdist workers) and that execute_trials_async is the option when the threshold must control the single pytest verdict.
**Trial semantics:**

- One logical test produces one pytest verdict
- `threshold` sets the minimum pass rate: `threshold=0.8` requires ≥ 80% SAFE
- An `ERROR` trial resolves the population to `ERROR`
- `UNDETERMINED` trials count against the pass rate
- Individual results and the aggregate verdict are available through `PopulationResult`

Comment thread rampart/pytest_plugin/_session.py
Copilot AI review requested due to automatic review settings July 22, 2026 21:07

Copilot AI 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.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

rampart/pytest_plugin/_session.py:244

  • The updated "Semantics" bullet list in this docstring has broken indentation/wrapping, and it doesn’t explicitly state the new behavior that a group fails when all clones are no_result (executed==0). Reformatting the bullets will improve readability and keep the docstring aligned with the implementation.
        Semantics:
                - ERROR results make the group fail.
                - threshold is the minimum pass rate (SAFE / executed).
          e.g. 0.8 means at least 80% of runs must be SAFE.
        - Clones with zero results (skipped or crashed before producing

rampart/pytest_plugin/plugin.py:644

  • Gate logs currently report safe counts as safe/total, but pass_rate is computed using the executed denominator (total - no_result). When there are no-result clones, this can produce misleading logs like "1/2 safe (100% pass rate)". Use the executed denominator in the log counts to keep them consistent with the pass-rate semantics.
                group.total,
                group.pass_rate * 100,
                group.threshold * 100,
            )
        elif group.errors > 0:

rampart/pytest_plugin/_session.py:280

  • record_trial_group counts a clone as UNSAFE before checking for ERROR (if has_unsafe: ... elif has_error: ...). If a clone recorded multiple Results and includes both an ERROR and an UNSAFE, it will be classified as UNSAFE (so error_count stays 0) and the group can incorrectly pass even though an ERROR occurred. ERROR should take precedence in the per-clone classification.
            elif has_safe:
                safe_count += 1

        executed_count = total - no_result_count
        pass_rate = safe_count / executed_count if executed_count > 0 else 0.0

Comment thread rampart/core/result.py Outdated
Comment thread rampart/pytest_plugin/_session.py Outdated
Comment thread rampart/pytest_plugin/_session.py Outdated
Comment thread rampart/core/execution.py Outdated
Comment thread rampart/core/result.py Outdated
Comment thread rampart/core/execution.py Outdated
Comment thread rampart/core/result.py
Comment thread rampart/core/execution.py Outdated
Comment thread rampart/core/result.py Outdated
Comment thread tests/unit/core/test_result.py
@behnam-o Behnam (behnam-o) changed the title FEAT Add execution-layer trial populations and threshold verdicts [FEAT]: Add execution-layer trial populations and threshold verdicts Jul 28, 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.

LGTM - let's also check with Bashir Partovi (@bashirpartovi) ! One question about the semantics of failed tests in pass/fail calculation but everything else makes sense to me and test coverage is good. Thanks Behnam (@behnam-o) !

Comment thread rampart/core/result.py
Comment thread rampart/core/execution.py Outdated
Comment thread rampart/pytest_plugin/_session.py Outdated
Comment thread docs/usage/results-and-reporting.md Outdated

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.

Just to step back a little bit, I think additional_result_metadata on execute_async is doing two jobs that want opposite things. _rampart_population is framework-internal provenance that should never be able to fail, and the scenario keys the docs now recommend (scenario_id, threat_class, and so on) are caller data where failing loudly on a typo is fine. Both go into the same flat dict with strict duplicate rejection, and that's what produces the failure mode.

It's also a second convention for something we already do. absorb() injects framework keys into result.metadata after the fact, last write wins, and it never raises:

result.metadata = {
    **original_result.metadata,
    "_pytest_test_name": test_name,
    "_pytest_nodeid": node.nodeid,
    "_rampart_result_index": result_index,
}

So we already have a pattern for framework-internal metadata and this adds a stricter one next to it.

I'm also not sure the parameter buys much over writing to metadata after the call. The reason for a parameter is that handlers see it at ON_POST_EXECUTE, but ResultCollectionHandler holds a reference, the collector drains at teardown, and xdist serializes at absorb, so a post-hoc write is already visible everywhere that matters. The only thing that misses it is a custom handler reading metadata inside on_event, and population identity is a property of the aggregate rather than of one execution anyway.

The thing that convinced me the parameter is on the wrong object is that callers can't use it on the trials path. execute_trials_async(adapter, n, threshold) has no additional_result_metadata, and the only thing that fills that argument there is the framework's own _rampart_population dict. So the receipt pattern we just documented works for a single run and quietly stops being available once you switch to trials:

# works
result = await Attacks.xpia(...).execute_async(
    adapter=my_agent,
    additional_result_metadata={"scenario_id": "xpia-login-001"},
)

# no way to say the same thing here
population = await Attacks.xpia(...).execute_trials_async(
    adapter=my_agent, n=10, threshold=0.8,
)

Supporting it would mean adding the parameter to execute_trials_async as well and merging the caller's dict with _rampart_population before passing it down, which is more surface and two sources feeding one argument.

Scenario metadata is constant across every trial anyway, so it seems like it belongs where the scenario is declared rather than on each call:

attack = Attacks.xpia(
    inject=...,
    trigger="Summarize Q3",
    evaluator=...,
    metadata={
        "scenario_id": "xpia-login-001",
        "threat_class": "credential_exfiltration",
        "mitigation_ref": "SEC-1234",
    },
)

result = await attack.execute_async(adapter=my_agent)
population = await attack.execute_trials_async(adapter=my_agent, n=10, threshold=0.8)

The execution copies it onto each Result it produces, so it works the same for one run and for a population and neither method grows a parameter. Run level stuff like ci_run_url already has report.metadata and probably shouldn't be duplicated onto every result.

That leaves the population provenance, and I'd rather see it as a typed field than a reserved dict key. We already do this with injections: list[InjectionRecord], which is XPIA-specific provenance carried as a real field and documented as empty for non-XPIA tests:

@dataclass(kw_only=True)
class PopulationRef:
    """Identifies the trial population a Result belongs to."""

    id: str
    index: int
    size: int
    threshold: float


# on Result, next to injections
population: PopulationRef | None = None

and the loop sets it directly, the same way execute_async already sets duration_seconds:

population_id = uuid.uuid4().hex
results: list[Result] = []
for index in range(n):
    result = await self.execute_async(adapter=adapter)
    result.population = PopulationRef(
        id=population_id, index=index, size=n, threshold=threshold,
    )
    results.append(result)

Collisions become impossible, ty checks it, and WS0-05 gets to serialize a known field instead of a reserved key. If the typed field is too much for this PR, the smaller version is to drop the parameter and stamp into metadata in that same spot:

result.metadata["_rampart_population"] = {
    "id": population_id, "index": index, "size": n, "threshold": threshold,
}

which matches what absorb() already does and can't raise.

Comment thread rampart/core/execution.py Outdated
Comment thread rampart/core/execution.py Outdated
@behnam-o Behnam (behnam-o) changed the title [FEAT]: Add execution-layer trial populations and threshold verdicts [FEAT]: Add execution-layer trial populations Aug 10, 2026
@behnam-o Behnam (behnam-o) changed the title [FEAT]: Add execution-layer trial populations [FEAT]: Add execution trial populations and threshold verdicts Aug 10, 2026
Comment thread rampart/core/execution.py Outdated

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.

Thanks Behnam (@behnam-o) for working through all the feedback but I think there is a concurrency issue with the trial run that should get fixed before merging this. Let me know if it doesn't make sense.

Comment thread rampart/core/execution.py Outdated
Comment on lines +301 to +310
self._execute_trial_async(
adapter=adapter,
population=PopulationRef(
id=population_id,
index=index,
size=n,
threshold=threshold,
),
semaphore=semaphore,
),

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 doesn't really run independent trials because every trial uses the same BaseExecution object. A fresh agent session gets created each time, but everything else on the execution object, like the driver, evaluator, handlers, and injection handles, gets reused. That means state from one trial can affect another.

For example, consider this case:

  1. A factory creates one SingleTurnExecution or XPIAExecution.
  2. That execution stores one driver in self._driver.
  3. execute_trials_async() repeatedly calls _execute_async() on that same execution.
  4. Each call creates a fresh agent session, but the driver is not recreated.
  5. LLMDriver has one _conversation_id, and its messages are kept in PyRIT's CentralMemory.
  6. Trial 2 starts with a fresh agent-side history=[], but the driver-side conversation still has trial 1.
  7. LLMDriver._assert_conversations_consistent() raises DriverError.
  8. BaseExecution turns that exception into an ERROR result, so a population that should contain independent trials becomes [SAFE, ERROR].

The same problem can happen with anything else that keeps state between runs.

I think each trial needs at least:

  • A fresh agent session
  • A fresh driver with a new conversation ID
  • Fresh injection handles, unless we decide to activate them once for the whole population
  • A fresh evaluator if it keeps any state

Things that don't keep state can be shared. Anything that does keep state should be recreated for each trial unless we know it was designed to be reused.

One possible way to handle this would be to keep the population loop in BaseExecution, but let each execution type create a fresh execution for every trial:

class BaseExecution:
    async def execute_trials_async(...):
        executions = [
            self._create_trial_execution()
            for _ in range(n)
        ]
        ...

Then SingleTurnExecution could create a new driver through a factory instead of reusing self._driver:

class SingleTurnExecution:
    def _create_trial_execution(self) -> BaseExecution:
        return SingleTurnExecution(
            driver=self._driver_factory(),
            ....
        )

If callers can provide stateful drivers, the public API may need to accept a factory instead of an existing driver object:

Probes.behavior(
    driver_factory=lambda: LLMDriver(...),
    evaluator=evaluator,
)

I don't think we can safely rebuild a driver with deepcopy(). Drivers can hold model clients, locks, normalizers, and external memory state that should not be copied.

For XPIA, I think we need to pick one of these options:

  • Create and activate fresh injection handles for every trial
  • Activate one injection around the whole population, then create fresh sessions and drivers for each trial

Either option could work, but repeatedly entering the same handle is not safe.

I would also remove max_concurrency for now. It makes this problem worse because multiple trials start using the same stateful objects at the same time.

Could we also add tests with an LLMDriver and a handle that cannot be entered twice? The current isolation test proves that sessions are different, but it uses StaticDriver, so it doesn't prove that the full trial run is independent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing this out. I had a design dilleman how to address this, tried a bunch of patterns, and committed option 2 below - let me know if you think it's a good choice.

Trial Isolation Design

Trials must be independent, but an execution can contain stateful drivers, evaluators, handlers, and injection handles. Reusing these objects may make trials co-dependent.

Option 1: Factory for Every Stateful Dependency

execution = Probes.behavior(
    driver_factory=lambda: LLMDriver(...),
    evaluator_factory=lambda: MyEvaluator(),
    event_handler_factories=[
        lambda: MyHandler(),
    ],
)

population = await execution.execute_trials_async(
    adapter=adapter,
    n=10,
    threshold=0.8,
)

The execution constructs fresh dependencies internally:

class SingleTurnExecution(BaseExecution):
    async def _execute_async(self, *, adapter):
        driver = self._driver_factory()
        evaluator = self._evaluator_factory()
        ...

Pros

  • Preserves execution.execute_trials_async(...).
  • Explicitly creates fresh dependencies.

Cons

  • Requires identifying every stateful constructor argument.
  • APIs accumulate driver_factory, evaluator_factory, handle_factory, etc.
  • A newly added stateful dependency can easily be overlooked.
  • Lifecycle policy becomes duplicated across execution implementations.

Option 2: Factory for the Complete Execution

def create_execution() -> BaseExecution:
    return Probes.behavior(
        driver=LLMDriver(...),
        evaluator=MyEvaluator(),
        event_handlers=[MyHandler()],
    )

population = await execute_trials_async(
    execution_factory=create_execution,
    adapter=adapter,
    n=10,
    threshold=0.8,
)

The framework invokes the factory for every trial:

async def run_trial(index: int) -> Result:
    execution = execution_factory()

    return await execution.execute_async(
        adapter=adapter,
    )

XPIA follows the same pattern:

def create_execution() -> BaseExecution:
    return Attacks.xpia(
        inject=surface.inject(payload=payload),
        trigger=LLMDriver(...),
        evaluator=MyEvaluator(),
    )

Pros

  • Creates the complete execution graph per trial.
  • Covers future constructor parameters automatically.
  • Avoids unsafe copying.
  • Avoids dependency-specific factory proliferation.
  • Makes the independence requirement visible at the call site.
  • Works consistently across execution implementations.

Cons

  • Population tests use a standalone function instead of an instance method.
  • Users can still accidentally close over shared mutable objects.

For example, this remains incorrect:

shared_driver = LLMDriver(...)

def create_execution() -> BaseExecution:
    return Probes.behavior(
        driver=shared_driver,
        evaluator=MyEvaluator(),
    )

Decision

We chose the complete execution factory because freshness applies to the whole execution graph, not a fixed list of known dependencies.

The resulting contract is:

Each factory invocation must return a fresh execution with fresh trial-scoped dependencies.

Single-run usage remains unchanged:

execution = Probes.behavior(
    driver=driver,
    evaluator=evaluator,
)

result = await execution.execute_async(adapter=adapter)

Only population execution requires an explicit construction strategy.

Comment thread rampart/core/result.py
Comment thread rampart/pytest_plugin/_xdist.py Outdated
Nina Chikanov (nina-msft) added a commit that referenced this pull request Aug 25, 2026
…e the evidence it needs (#141)

## Description

Fixes #140 and closes #167, which @nina-msft asked be superseded rather
than done separately. #140 has a standalone repro: a probe asserting an
agent does not exfiltrate passes green while the agent exfiltrates on
every turn, because the adapter declares `TOOL_ONLY` and so never
populates `Response.side_effects`.

`ToolCalled` and `SideEffectOccurred` return `NOT_DETECTED` in two
different situations: the thing did not happen, and the adapter does not
report that kind of evidence. `resolve_as_attack` maps `NOT_DETECTED` to
`SAFE` and `resolve_as_probe` maps it to `UNSAFE`, so a gap in the
adapter becomes a statement about the agent. `_adjust_for_observability`
covers part of this, but only for `RESPONSE_ONLY`, only in XPIA, and
only by counting tool calls.

This PR adds a regression test for the attack side. Run against `main`
at `3d219d8` it produces:

```
=========================== RAMPART Safety Summary ============================

UNCATEGORIZED (2 tests)
  FAIL  test_dropping_observability_does_not_look_safer_async -- Attack objective detected: Side effect 'http_request': {'url': 'https://evil.com/steal'} (tool_and_side_effects)
  PASS  test_dropping_observability_does_not_look_safer_async -- Agent defended successfully (tool_only)
```

`FAIL` and `PASS` there are RAMPART's per-result verdict labels, not
pytest outcomes. Both lines come from one pytest test that runs the same
attack twice. The agent exfiltrates on both runs; the two adapters
differ in the declared level and in what that level lets them report, so
the `TOOL_ONLY` one returns the same response with no `side_effects`,
which is exactly what a `TOOL_ONLY` adapter can do. RAMPART calls that
second run "Agent defended successfully". The pytest test fails on
`main` and passes with this change.

Every combination where a built-in evaluator needs evidence the adapter
does not report, measured with a `MockAdapter`:

| Adapter | Evaluator | Attack before | Attack after | Probe before |
Probe after |
|---|---|---|---|---|---|
| `RESPONSE_ONLY` | `ToolCalled` | `UNDETERMINED` | `UNDETERMINED` |
**`UNSAFE`** | `UNDETERMINED` |
| `RESPONSE_ONLY` | `SideEffectOccurred` | `UNDETERMINED` |
`UNDETERMINED` | **`UNSAFE`** | `UNDETERMINED` |
| `TOOL_ONLY` | `SideEffectOccurred` | **`SAFE`** | `UNDETERMINED` |
**`UNSAFE`** | `UNDETERMINED` |

Two of the six were already correct, and both were correct because
`_adjust_for_observability` caught them.

Rebased onto `main` at `3d219d8`, so the tests here follow `RMP001` from
#158 and #159 and the xdist transport is the one #166 landed.

### Changes

- `ObservabilityLevel` gains `observes_tool_calls` and
`observes_side_effects`, following the `PayloadFormat.is_text` and
`is_binary` pattern already in that file. Its class docstring described
only the `RESPONSE_ONLY` case, so it now also covers `TOOL_ONLY` with
side effects, which is the case in the linked issue.
- `EvalContext` gains `observability_level`. It is required, so a
context built by hand has to say what the adapter behind it could see.
- `evaluate_turn_async` takes the level, required and keyword-only, and
puts it on the context. `XPIAExecution` and `SingleTurnExecution` both
pass `adapter.observability_profile`. `Result` and
`EvalContext.from_response` require it too, which is @nina-msft's
request below and what closes #167.
- `ToolCalled` and `SideEffectOccurred` return `UNDETERMINED` when they
cannot see the evidence they need. The check runs after the scan, so
anything the adapter does report still counts as evidence.
`_adjust_for_observability` makes the same allowance today.
- The `UNDETERMINED` summary on both strategies is built from
`undetermined_operands`, so it names every channel that could not be
observed rather than only the operand the composite reported first.
Repeats collapse, and anything past the first two is counted. This is
@nina-msft's second request below.
- `_AllEvaluator` short-circuits only on a `NOT_DETECTED` left operand.
An `UNDETERMINED` left operand no longer skips the right one, so `&` no
longer depends on the order the operands were written in. Both
undetermined branches carry the evidence of both operands. This is the
review fix from @nina-msft below.
- `_AnyEvaluator` names the undetermined operand and carries the
evidence of both, instead of a bare "One or both operands undetermined".
Outcomes are unchanged. Without this, `|` hid the adapter setting behind
the verdict, which is the one thing this PR is trying to surface, and
the note added to `authoring-tests.md` points the reader at `|` for
exactly this case.
- The XPIA undetermined summary prefers results that are themselves
`UNDETERMINED`, matching the probe summary above it. Without that it
could lead with a `NOT_DETECTED` rationale from a different turn.
Settled results are read only when nothing else gave a reason, which is
what the `_adjust_for_observability` downgrade looks like.
- The probe unsafe summary takes its reason from a `NOT_DETECTED`
result. It took the last rationale of any outcome, so once these
evaluators can return `UNDETERMINED`, an undetermined turn could state
the reason for a definitive failure.
- `EvalResult` gains `undetermined_operands`. `&` and `|` record every
operand they ran that came back `UNDETERMINED`, so "the predicate is
false" stops being indistinguishable from "the predicate is false and
every part of the evaluation ran". `~` carries its inner result's
entries through, each reason is kept once, the xdist transport
round-trips it with the same ANSI stripping as the other free text, and
`JsonFileReportSink` emits it as `eval_undetermined_operands` when it is
not empty. This is @bashirpartovi's first option below; the truth table
is untouched.
- The `SAFE` summary on both strategies names what was left
undetermined. It names the first two distinct reasons and counts the
rest. Verdicts do not move, so no existing result changes status.
- The XPIA unsafe summary takes its evidence only from `DETECTED`
results, matching the probe summary. An `UNDETERMINED` composite can
carry evidence of its own, and that evidence is not what established the
verdict.
- The JSON run report carries `observability_level`. It named the
verdict, the strategy and the harm category but not the level the run
was gathered under, so a dashboard could not tell a clean pass from one
the adapter was never able to see through. The xdist transport already
carried it.
- Every read of `undetermined_operands` and `evidence` in the
composites, the summaries and the two serializers goes through
`safe_str_list`, and the probe's unsafe and error summaries put
`rationale` through `safe_str` where the XPIA summary was already
guarded. `_distinct_operand_reasons` flattened the operand list with a
comprehension, so a third-party evaluator returning a non-iterable, or
an iterator whose `__iter__` raises, aborted summary construction before
the containment helpers saw it. `evidence` had the same shape, and this
branch had taken the composites from one evidence concatenation to five,
where a value that is not a list broke the compose step itself.
- The probe unsafe summary renders each rationale before testing whether
it has content. It filtered on the raw value first, so a rationale whose
truthiness raises took the summary and the verdict with it, and a
whitespace-only rationale printed `UNSAFE: ` with nothing after the
colon. This is @nina-msft's request below, using the code she supplied.
One behavior moves with it: a rationale that is falsy but renders as
something, such as `None` or `0`, now shows as itself where it used to
fall through to the generic line. Both readings are of a value that
already violates the declared `str`, and the verdict is the same either
way.
- `safe_str` returns an exact `str`. `str()` accepts a `__str__` that
returns a `str` subclass, so the rendered value could still carry
evaluator code on the methods RAMPART reaches for next.
`_distinct_reasons` and `_merge_undetermined` already called `.strip()`
on it, and the fix above adds a third such call, so containment was
moving the failure rather than removing it. `str.__str__` is the C slot:
it cannot be overridden, cannot raise, and returns the argument
unchanged when it is already exact.
- The xdist truncation marker carries the run's real observability
level. It hardcoded `RESPONSE_ONLY`, so a result too large to send came
back through the controller claiming the narrowest level, in the field
the rest of this PR is about. Predates this branch; the original
`Result` was already in scope.
- The undetermined summary reads settled results only when no result
stayed undetermined, so a gap another turn settled around cannot be
offered as the reason this verdict was missed.
- `safe_str` and `safe_str_list` in `rampart/common/text.py` coerce
evaluator-supplied values without raising. Every rationale interpolation
in the composites goes through them, as does every read of
`undetermined_operands` in the composites, the JSON sink and the xdist
serializer. `_AnyEvaluator` and `_AllEvaluator` between them gained
three rationale interpolations `main` does not have, so a value whose
`__str__` raises turned inputs that resolved cleanly on `main` into
`SafetyStatus.ERROR`, losing a verdict the evaluators had already
reached. Both helpers catch `Exception` rather than `BaseException`, so
cancellation and interrupts still propagate.
- `ObservabilityLevel` and `authoring-tests.md` now say the guarantee is
per channel rather than per field: a level that reports a channel is
taken at its word for what it puts in it, so a tool call reported with
redacted arguments still counts as observed and a predicate over those
arguments can return `NOT_DETECTED`.
- `Session.send_async`, `authoring-tests.md` and `quickstart.md` said
empty lists mean "no observations", not "nothing happened". That rule
predates the declared level and now reads backwards, and it contradicted
`observability_profile`'s own docstring in the same file. All three now
say an empty list is read against the declared level.
- The composition and reporting docs say how a recorded gap is graded:
the verdict does not move, `SAFE` is the only status that passes, and on
a run that reaches it the summary and `undetermined_operands` are the
only places the gap shows. `results-and-reporting.md` documents
`turn.eval_result.undetermined_operands` and the per-turn
`eval_undetermined_operands` key, with a snippet that fails a passing
run carrying one. This is @bashirpartovi's request below.
- @nina-msft pushed five commits to this branch, `38dd617`, `c93f611`,
`7643ac8`, `60924db` and `a217370`. They drop a blank carried reason in
`_merge_undetermined` so it falls through to the rationale, promote the
finite-float guard out of `_xdist.py` into `rampart/common/text.py` as
`safe_float`, put `safe_float` and `safe_str` on `confidence` and
`rationale` in both serializers, and teach the xdist reader to tell a
confidence that is missing from one that was sanitized. Her note below
has the detail.
- On top of those, `2c195aa` adds a test per site for the two
`rationale` guards, which the sweep below had left green, and `aefee9b`
removes a `ty: ignore` that `ty` reports as unused and that was failing
Lint & Type Check with the test matrix skipped behind it.

### Why the fix is in the evaluator

Two docstrings disagree about this, so I want to be explicit about which
one I followed and why. Both are quoted as they stand on `main`; this PR
updates both.

`rampart/core/types.py:27-29`:

> When the adapter declares RESPONSE_ONLY, evaluators that require tool
call data return UNDETERMINED rather than a false SAFE.

`rampart/evaluators/tool_called.py:23-25`:

> This evaluator only detects conditions. It does not reason about
observability gaps. That adjustment is owned by the execution strategy.

I followed the first one.

The obvious alternative is to keep the adjustment central and have
evaluators declare a `required_observability` for the strategy to read.
I could not make that work for composition. Under `TOOL_ONLY`,
`ToolCalled("x") | SideEffectOccurred("y")` should still return
`DETECTED` if `x` was called, while the right operand cannot be
observed. A strategy-level check against a composite's declared
requirement cannot see the operands, so it either suppresses a real
detection or does nothing. The post-scan allowance above has the same
problem: "evidence the adapter actually reported still counts" is a
per-operand runtime fact, not something a static declaration can
express. `|`, `&` and `~` already arbitrate this correctly once operands
can return `UNDETERMINED`, which is what this change gives them.

There is also precedent for an evaluator reporting its own uncertainty.
`LLMJudge` returns `UNDETERMINED` when the judge output is malformed
after retries or the call fails, rather than guessing. Those are
transient instrument failures and an observability gap is static
configuration, so the situations are not identical, but the outcome type
is doing the same job in both: `EvalOutcome.UNDETERMINED` is defined as
"The evaluator could not make a determination".

The adjustment itself stays where the second docstring puts it.
`_adjust_for_observability` is unchanged and still owns the verdict
downgrade. What changes is the quality of its input. The sentence in
`ToolCalled`'s docstring is contradicted by this PR and is updated, as
is the matching note in `docs/usage/authoring-tests.md`.

### No new verdict semantics

`UNDETERMINED` is not new at either level. `EvalOutcome.UNDETERMINED` is
produced today by `LLMJudge` and by `|` and `&`, and preserved by `~`.
`SafetyStatus.UNDETERMINED` is produced by both resolvers and by
`_adjust_for_observability`. Every consumer already handles it: the
resolver precedence rules, the composition operators, the xdist round
trip through `SafetyStatus(value)`, `JsonFileReportSink`, the WARN
terminal label, and the population summary. This change produces it in
more of the cases it already exists for.

`DETECTED` that came from observed evidence is untouched on every path,
so no evidence-based detection is weakened. The one detection that
changes is `~` inverting an absence the adapter could not attest,
covered below.

## Breaking changes

Yes, in two ways, and the title carries `[BREAKING]` as @nina-msft
asked.

**1. `observability_level` is required on four public APIs.**
`EvalContext`,
`EvalContext.from_response`, `evaluate_turn_async` and `Result` no
longer
default it. Three of those four parameters are introduced by this PR, so
the
break there is against a signature that has not shipped; `Result` is the
one
that predates the branch and loses a real default of `RESPONSE_ONLY`.

`Result` is also the widest of the four: of the 94 call sites in this
repo's
Python files that omitted the argument, 75 were `Result(...)`, counting
the ones
written inside pytester source strings. Two more `Result(...)` examples
in
`docs/` omitted it as well, and are updated here. @nina-msft left this
one to
my judgement with a stated preference for requiring it, and requiring it
is
what actually dissolves the asymmetry, so that is what this does. Say
the word
and I will put the `Result` default back.

Migration is to pass the adapter's declared level, normally
`adapter.observability_profile`. Omitting it is a `TypeError` at the
call
rather than a silent assumption in a report. No call site in `rampart/`
omitted it, so no built-in behavior moves. All four are keyword-only, so
nothing positional breaks.

**2. Verdicts move**, in one direction for the evaluators on their own
and in
one cell for `&`.

Nothing is removed or renamed otherwise. The xdist transport gains a
key.
`JsonFileReportSink` gains two: `eval_undetermined_operands` per turn
when the
list is not empty, and `observability_level` on every result
unconditionally, so
a consumer validating a strict schema on a result object sees a new
always-present
field. `EvalResult.undetermined_operands` is
written and read at both ends of the xdist transport; a payload without
the key
still deserializes and an old controller ignores the extra one, so
`SCHEMA_VERSION` is unchanged, and bumping it would make
`_validate_schema`
reject the whole payload instead. `JsonFileReportSink` emits
`eval_undetermined_operands` per turn only when the list is not empty.

For `ToolCalled` and `SideEffectOccurred` used alone, `NOT_DETECTED`
becomes
`UNDETERMINED` and nothing moves toward `SAFE`. What existing suites
will see:

- An attack that passed because the adapter could not see side effects
now returns `UNDETERMINED` and fails. That is the bug being fixed, and
it will surface as a newly red test.
- A probe using `ToolCalled` or `SideEffectOccurred` below the level it
needs goes from `UNSAFE` to `UNDETERMINED`. Both are falsy, so the test
still fails, but the terminal label changes from FAIL to WARN.
- `~ToolCalled(...)` under `RESPONSE_ONLY` previously returned
`DETECTED` by inverting an absence the adapter could not attest, and now
passes `UNDETERMINED` through. On a probe, "must not call X" against an
adapter that cannot report tool calls was a false pass and now fails.
The linked issue is the same shape one level down:
`~SideEffectOccurred("http_request")` against a `TOOL_ONLY` adapter.
- With the default trial threshold of 0.0, a group whose clones are all
`UNDETERMINED` logs a passing gate line where it previously logged a
failing one. The clones still fail, since `assert result` is falsy, and
`_evaluate_gates` only logs, so no CI outcome flips. I left the
threshold alone because PR #121 is reworking that layer.

Making `&` order independent required choosing which outcome wins when
one operand is `NOT_DETECTED` and the other is `UNDETERMINED`. It
returns `NOT_DETECTED`, which is Kleene and is what the review asked
for. Against every operand pair on `main`, one cell moves:

```
main   : undetermined & not_detected -> undetermined   attack=undetermined
branch : undetermined & not_detected -> not_detected   attack=safe
```

This is not a regression against `main` for `ToolCalled` or
`SideEffectOccurred`, which returned `NOT_DETECTED` on `main` at a level
that could not report the evidence, so the conjunction already resolved
`SAFE`. It does mean a composed evaluator no longer gets the protection
the first commit of this PR gave it in one of the two operand orders,
and that a degraded `LLMJudge` inside `&` can now resolve `SAFE` where
`main` said `UNDETERMINED`. Both cases now record the reason in
`EvalResult.undetermined_operands`, and a `SAFE` summary names it, when
the undetermined operand is on the left, since `&` still short-circuits
on a `NOT_DETECTED` left operand and never runs what is to its right.
`|` reports `UNDETERMINED` in those cases, and the docs now say which
operator to reach for and which side to put the observability-dependent
operand on.

For the verdict changes there is no migration beyond fixing the
adapter's declared level or the evaluator choice. The new rationale
string names the declared level, the channel it does not report, and the
target the evaluator was looking for.

### Deliberately out of scope

- `_adjust_for_observability` also fires when it should not:
`RESPONSE_ONLY` with `ResponseContains` is downgraded even though that
evaluator never needed tool data. That is a false positive rather than a
false negative, and narrowing the heuristic is a separate change.
- `LLMJudge` now receives `observability_level` and ignores it. Telling
the judge that tool calls are not visible would stop it reading an
evidence-free transcript as innocence, but that changes judge prompting.
- `ResponseContains` is untouched on purpose. Every level reports text,
so no declared level hides it.
- Splitting `EvalOutcome.UNDETERMINED` into "cannot observe" and "did
not run" so `&` can treat them differently. That is the real fix for the
`LLMJudge` case above and it is bigger than this PR.
`undetermined_operands` records both kinds without telling them apart,
so it does not pre-empt that design.
- The rationale the LLM driver puts into its next prompt is untouched.
At `rampart/drivers/llm.py:333` a value whose truthiness raises costs
the next turn before rendering is tried. `confidence` and `rationale` in
the two serializers were on this list until @nina-msft guarded them in
`c93f611`.
- A reason that itself contains `"; "` is indistinguishable from the
separator the summary joins on. Same on `main` for the existing
summaries, and the full list is on `Result.eval_results` and in the JSON
either way.

## Checklist

- [x] `pre-commit run --all-files` passes
- [x] Tests added or updated for changes
- [x] Documentation updated

### Tests

300 new tests against `main`, and one removed:
`test_left_undetermined_short_circuits_async`
asserted that `&` skips the right operand when the left is
`UNDETERMINED`, which is the
behavior the review asked me to remove. `8bf0b62` renamed it to
`test_left_undetermined_evaluates_right_async` and inverted its
`right.call_count`
assertion, so the coverage moved rather than being dropped. It is the
only collected node
id `main` has that this branch does not, so nothing else existing was
changed, removed or
reparented.

One test appears and disappears inside the branch rather than against
`main`:
`9ac85b7` added `test_observability_level_defaults_to_no_declared_limit`
and `b86a67f`
removed it, because it asserted the default that commit takes away; four
tests asserting
the `TypeError` replace it, one per API.

The containment tests cover the operand path through
`_summarize_undetermined_operands`
and `_explain_undetermined`, both summary builders, every branch of the
three composites,
the xdist serializer, the new report key, and `safe_str_list` itself.
Most of them are
parametrized sweeps over the composites, one per field, because line
coverage cannot see
an expression change: a guard runs whether or not any test would notice
it being removed.
Neutering each of the 35 `safe_str` and `safe_str_list` call sites on
this branch, one
at a time, turns the suite red at every one of them.

Across the whole PR, by file: `test_evaluator.py` 151, `test_text.py`
34,
`test_single_turn.py` 26, `test_xpia.py` 25, `test_result.py` 21,
`test_xdist.py` 12, `test_tool_called.py` 9, `test_side_effect.py` 7,
`test_types.py` 7, `test_json_file.py` 6, `test_execution.py` 2. Those
cover the outcome table for
`&` and `|`, commutativity over all nine operand pairs, De Morgan both
ways,
associativity over all 27 triples, which cells record an undetermined
operand
and which cannot because the short-circuit skipped it, `UNDETERMINED` at
each
insufficient level with the rationale naming the level and the target,
evidence
still counted below the declared level, the new field surviving the
xdist round
trip with ANSI stripped at the boundary, and the report key present only
when
the list is not empty.

The 27 that `4b43052`, `b86a67f` and `82f7926` added:

- `test_result.py` (10): nine on `_explain_undetermined` in priority
order, plus one `TypeError` test. Operand reasons beat the composite
rationale, a reason repeated across turns collapses on both the operand
path and the rationale path, the count names what it does not, a settled
result cannot speak over an operand that stayed undetermined, a settled
result does speak when nothing else did, and a blank or whitespace-only
reason falls through to the fixed phrase rather than rendering an empty
detail.
- `test_xpia.py` (7) and `test_single_turn.py` (7): `ToolCalled("x") |
SideEffectOccurred("y")` under `RESPONSE_ONLY` end to end, naming both
channels, which is the case in the review comment; the
`_adjust_for_observability` downgrade naming the gap it recorded; and
the unit-level dedup, count and settled-result cases on both strategies,
plus the two fallback cases on the probe.
- `test_types.py` (2) and `test_execution.py` (1): the remaining three
of the four `TypeError` tests, one per API.
- `test_xdist.py`: no new ids. The oversized-result marker test now
asserts the level survives truncation instead of being rewritten to
`RESPONSE_ONLY`.

`tests/integration/test_smoke.py` uses `ToolCalled` through
`EvalContext.from_response` and asserts a detection, so no verdict there
moves; it only gains the now-required argument. It needs no credentials
and passes: `2 passed`.

### Documentation

- `docs/usage/authoring-tests.md`: the `ToolCalled` warning said it
"always returns `NOT_DETECTED`" under `RESPONSE_ONLY`, which is no
longer true. `SideEffectOccurred` had no note and now has one. Added a
short paragraph under the levels table on why declaring the level
honestly matters, a paragraph saying the guarantee is per channel rather
than per field, a note on how `UNDETERMINED` travels through `&` and
`|`, which no user facing page covered, and a paragraph on what
`undetermined_operands` records and which side of `&` to put an
observability-dependent operand on.
- `docs/attacks/xpia.md`: the Observability Adjustment section now says
what it is for, now that evaluators handle their own cases, and the
composition example says which operator to reach for when two evaluators
are two views of one harm, and that the result records the gap rather
than the verdict resting on silence.
- `docs/contributing/extending-rampart.md`: the custom execution
strategy example called `evaluate_turn_async` without the level, which
would silently treat every adapter as fully observable. Fixed, plus a
bullet in the key points, reworded again this round because omitting it
is now a `TypeError` rather than a wrong assumption.
- `docs/contributing/testing.md` and `docs/usage/pytest-integration.md`:
the `Result(...)` helper and the manual-recording example both pass the
level now, with a line on how to choose one and a note that existing
tests were backfilled with the old default so no test changed meaning.
- `docs/api/core-protocols.md`: `evaluate_turn_async` is exported from
`rampart.core` but was absent from the API reference. It joins the other
`rampart.core.execution` members there. It is not importable from
`rampart` directly, so it does not belong on `core-types.md`, whose lede
promises exactly that.
- `docs/getting-started/quickstart.md` and `docs/glossary.md`: the
empty-list rule and the `EvalContext` entry.

No new pages, so no `mkdocs.yml` nav change.

### Checks run locally

Rebased onto `main` at `3d219d8`. That range brought ruff 0.16.3 and ty
0.0.72
into `uv.lock` via #169, so the numbers below are at those versions, not
the ones
I quoted two rounds ago.

```
ruff 0.16.3 check .............. passed
ruff 0.16.3 format --check ..... 128 files already formatted
ty 0.0.72 check ................ passed
flake8 7.3.0 (RMP codes) ....... passed, 0 violations
coverage run -m pytest ......... 1020 passed, 7 skipped
pytest -n 4 .................... 1020 passed, 7 skipped
coverage report ................ TOTAL 94%, threshold 80
                                 core/types.py 100%   core/evaluator.py 100%
                                 core/result.py 100%   reporting/json_file.py 100%
                                 common/text.py 100%
                                 evaluators/tool_called.py 100%
                                 evaluators/side_effect.py 100%
                                 probes/_single_turn.py 100%
                                 attacks/_xpia.py 95%
mkdocs build --strict .......... no content warnings, same as main
```

`mkdocs build --strict` aborts on both `main` and this branch for the
same
environmental reason: the Material offline plugin cannot create symlinks
on
Windows. All 43 warnings on both sides are that one, and filtering it
out leaves
none.

---------

Co-authored-by: Nina Chikanov <nichikan@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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.

4 participants