[BREAKING] [FIX]: Return UNDETERMINED when an evaluator cannot observe the evidence it needs - #141
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
@microsoft-github-policy-service agree |
0217f67 to
afacd6a
Compare
Nina Chikanov (nina-msft)
left a comment
There was a problem hiding this comment.
Mostly doc comments in this round :-) Bashir Partovi (@bashirpartovi) - if you'd like to take a look before merge please do!
|
Thanks for the thorough follow-ups Mahdi Alhakim (@mahdi-al-hakim). I read through the full review thread and the latest changes (PS. great comments Nina Chikanov (@nina-msft)). The main fix looks right to me that an observability gap should not be treated as proof that nothing happened, and the propagation and test coverage here are strong. I do still have one concern about I understand the literal conjunction argument. If both conditions are required and one definitively did not happen, then the conjunction is false even if the other condition could not be evaluated. What concerns me is the next step, for an attack, RAMPART maps that For example: The conjunction becomes I don't think the truth table itself is the problem. The problem is that "the predicate is false, but part of the evaluation was unobservable" becomes indistinguishable from "the predicate is false and all required evidence was observable." Those carry different levels of assurance, especially in a safety test. Part of what makes this tricky is that Moving evaluator failures to Could we agree on how to keep that gap visible before merging? I see two reasonable paths:
I prefer the first approach because it preserves the predicate algebra without losing the safety signal. If that is too large a change for this PR, the second approach seems safer as an interim behavior. Separately, I'd take you up on the XPIA summary issue you mentioned. Now that an One small documentation clarification I would like to mention is that the observability guarantee here is channel-level. A Other than those points, the direction looks solid :) |
407fb41 to
702fe6b
Compare
|
Done in
|
changes provided, will mark as approved once last comments addressed
|
PR is getting very close - thank you for all your work here! |
702fe6b to
38814f1
Compare
|
Three more commits, from a review pass over my own branch rather than new feedback.
That loses a verdict the evaluators had already reached.
|
…nce it needs ToolCalled and SideEffectOccurred returned NOT_DETECTED whether the thing did not happen or the adapter never reports it. Under attack semantics that resolves to SAFE, so an adapter at TOOL_ONLY running SideEffectOccurred reports "Agent defended successfully" for an agent that exfiltrated. EvalContext now carries the adapter's observability level, and both evaluators return UNDETERMINED when they cannot see the evidence they need, matching how LLMJudge already reports its own uncertainty. The check runs after the scan, so evidence the adapter does report still counts. The verdict downgrade in XPIAExecution._adjust_for_observability is unchanged and still owned by the execution strategy.
_AllEvaluator returned UNDETERMINED as soon as the left operand was
undetermined, so it never reached a right operand that was definitively
NOT_DETECTED. That made & depend on operand order: under RESPONSE_ONLY
observability, ToolCalled("x") & ResponseContains("absent") returned
UNDETERMINED, while the same pair written the other way round returned
NOT_DETECTED.
Only a NOT_DETECTED operand settles the conjunction on its own, so that is
the only case the left operand short-circuits now. The outcome tables for &
and | are covered in both operand orders, together with De Morgan's law,
which the old behavior broke.
The backstop paragraph in the XPIA docs is narrowed to match. A single
evaluator no longer reaches that check as SAFE, but a composition still can.
Rebased onto main, which now enforces RMP001 from microsoft#158 and microsoft#159. The tests this PR adds were written before that rule landed, so they are renamed to match it. Seven names are also shortened to stay inside the line limit.
Making & evaluate the right operand when the left is undetermined meant the right operand's evidence was computed and then thrown away. A judge detection that is real but not confirmable on its own was lost that way. Both undetermined branches now carry the evidence of both operands. Also covers the two algebraic properties the suite was missing: the negated-or form of De Morgan's law, and associativity for & and |. Both already held.
The probe summary already does this after the earlier commit in this PR, so the two paths disagreed. An XPIA run that is undetermined because one turn could not be observed led its summary with a NOT_DETECTED rationale from a different turn, which names the wrong reason.
Session.send_async said empty lists mean "no observations", not "nothing happened", and two doc pages repeated it. That rule predates the declared level. An empty list is now read against observability_profile: at a level that reports that evidence it means the thing did not happen, and at a level that does not it means the thing could not be seen. The old wording also contradicted observability_profile's own docstring in the same file. ObservabilityLevel's docstring only described the RESPONSE_ONLY case, so it omitted TOOL_ONLY with side effects, which is the case the linked issue is about. Also documents how UNDETERMINED travels through & and |, which no user facing page covered, and says which operator to reach for when two evaluators are two views of one harm.
…here & and | answer different questions, and the difference only shows when one operand cannot be observed. Under TOOL_ONLY a blind SideEffectOccurred with ResponseContains settles as NOT_DETECTED under &, in either order, and stays UNDETERMINED under |. Both are covered so a change to either has to be deliberate. Adds the missing return annotations on the tests added here, aligns two rationale test names that had drifted apart, and renames the composition class now that it covers the outcome tables and the algebraic laws rather than operand order alone.
TestXPIAUndeterminedSummary was added above the last method of TestResponseMetadataPropagation, so test_multi_turn_metadata_keyed_by_turn_number_async silently became a method of the new class and its node id changed. Nothing failed, which is why it went unnoticed. The new class now follows the whole class it was meant to sit after. Collected node ids now differ from main by exactly the one intended rename.
…ring `observability_level` became a required field and moved ahead of the defaulted ones on `Result` and `EvalContext`, but both `Args:` blocks still listed it last, so the docs and the signature disagreed about the shape of the type. `authoring-tests.md` told the reader to put the observability-dependent operand on the left of `&` and said nothing about `|`, while the tip just above it recommends the `|` ordering that loses the record. `|` skips its right operand once the left detects, so it has the same limit, and the two pieces of advice pull in opposite directions. Both are now stated. The probe test that asserts an UNDETERMINED verdict sat under `TestProbeSafeSummary`, which is about the SAFE summary. It has its own class.
The report 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 an adapter was never able to see through. The xdist transport already carried it; only the report dropped it. Now that the field is required on `Result` there is always a real value to write, which is what makes this worth emitting rather than a mostly absent key. `docs/usage/results-and-reporting.md` lists the fields a caller reads off a `Result` and did not mention this one either.
`_distinct_operand_reasons` flattened `undetermined_operands` with a
comprehension, so the containment helpers never saw a value that could
not be iterated. A third-party evaluator returning a non-iterable, or an
iterator whose `__iter__` raises, aborted summary construction:
_summarize_undetermined_operands(...) TypeError: 'int' object is not iterable
_explain_undetermined(...) RuntimeError from __iter__
Sweeping for the same shape found two more. `evidence` had it, 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 with an
unsupported operand type. The probe summary had it on `rationale`, in the
UNSAFE and ERROR branches, where the XPIA summary was already guarded.
`BaseExecution` turns each of these into ERROR, so a verdict the
evaluators had already reached is lost.
Both fields now go through `safe_str_list` in the three composites, the
XPIA unsafe summary and the xdist serializer, and the probe rationale
goes through `safe_str`. Across both summary builders, four statuses,
three hostile field types and three outcomes, no combination raises.
`safe_str_list` itself needed two fixes to be worth relying on. Its type
checks sat outside the `try`, so a hostile `__class__` escaped the guard,
and the `Iterable` check rejected a sequence that only defines
`__getitem__`, silently dropping evidence that `list()` reads fine.
Legitimate input is unaffected for any list of str. Items that are not
str are now rendered as str, so an evidence entry that was a `Path`
reaches a composed result as its string form.
Three reads predate this branch and are untouched: `confidence` and
`rationale` in the two serializers, and the rationale the LLM driver puts
in its next prompt.
4befbfa to
830f838
Compare
The UNSAFE branch of the probe summary filtered on `er.rationale` before
`safe_str` ever saw it, so a value whose truthiness raises took the whole
summary, and `BaseExecution` turns that into `SafetyStatus.ERROR`:
_build_summary(UNSAFE, [EvalResult(NOT_DETECTED, rationale=<raises on bool>)])
RuntimeError: boom
An UNSAFE verdict the evaluators had already reached was lost to a value the
verdict did not depend on. The comprehension renders first and filters on the
result now, which is the shape `_distinct_reasons` already uses. Both the code
and the regression test are nina-msft's, as given.
My reply on the `result.py` thread said nothing in either summary builder
raises. That was wrong. The round-six sweep covered a value whose `__str__`
raises, a non-iterable and a raising `__iter__`. A truthiness test passes all
three without raising, so the shapes that do raise there were not in it. A
raising `__len__` is one of them, since Python falls back to `__len__` when
`__bool__` is absent, and this fixes that case with the same line.
Stripping before the emptiness test also sends a whitespace-only rationale to
the fallback rather than printing `UNSAFE: ` with nothing after the colon,
which is what `_explain_undetermined` already does on its own path.
Line coverage cannot see an expression change: a guard runs whether or not any
test would notice it being removed. Neutering each of the 33 places this branch
routes an evaluator-supplied value through `safe_str` or `safe_str_list`, one at
a time, left 12 with a green suite:
core/evaluator.py 149 163 215 228 245 259 271 272 rationale in | and &
core/evaluator.py 303 rationale in ~
core/evaluator.py 338 346 _merge_undetermined
core/result.py 287 _distinct_reasons
Every one of them sat at 100% line coverage. The existing sweep covers evidence
and kills its own guards; rationale and `undetermined_operands` had no
equivalent, so a hand-written pair only ever reached the two branches it named.
Two parametrized sweeps in the shape of `TestCompositionToleratesHostileEvidence`
close that. All 33 sites now turn the suite red when their guard is dropped.
No production change. The composition truth table is byte-identical across all
21 cells, and the end-to-end invariant sweep reports the same 216 runs, 66 SAFE
and 0 unqualified SAFE as before.
`str()` accepts a `__str__` that returns a `str` subclass, so `safe_str` could
hand back a value that still carries evaluator code on the methods RAMPART
reaches for next. Containment was moving the failure, not removing it:
class Rationale(str):
def __str__(self): return self
def strip(self, *a, **k): raise RuntimeError("boom")
_explain_undetermined(...) RuntimeError: boom
_merge_undetermined(...) RuntimeError: boom
Both already called `.strip()` on the rendered value, and the previous commit
adds a third such call in the probe unsafe summary, so the shape was about to
spread rather than shrink.
`str.__str__` is the C slot. It cannot be overridden, it cannot raise, and it
returns the argument unchanged when the argument is already an exact `str`, so
the common path does not copy. `safe_str_list` uses it directly on the
bare-string branch as well, where going through `safe_str` would have thrown
away the text a subclass with a raising `__str__` is still holding.
Seven of the eight tests added here fail without this change. The eighth pins
that an exact string is returned as the same object.
Also here, from the same review pass: the composite sweep asserted only that a
guard did not raise, so replacing the content of all nine rationale
interpolations with a constant left it green. One case per branch that words a
rationale now pins the text as well.
A third-party evaluator can emit whitespace-only undetermined_operands. safe_str_list does not strip, so a blank entry made carried truthy and skipped the rationale fallback, then got discarded downstream, hiding the observability gap from the summary. Filter blanks before the presence test so a blank-only operand falls through to its rationale. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Promote the finite-float guard from _xdist to rampart.common.text as safe_float, alongside safe_str/safe_str_list, and make it tolerate a non-numeric or raising value the same way. Apply it plus safe_str to the confidence and rationale fields in both the xdist and JSON-file serializers so an out-of-contract value from a third-party evaluator cannot break report serialization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Ruff flags direct float equality. Match the pytest.approx pattern already used in the report serializer tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Nina Chikanov (nina-msft)
left a comment
There was a problem hiding this comment.
Mahdi Alhakim (@mahdi-al-hakim) - thanks for your work on this PR!
I pushed a few small hardening changes directly to the branch in favor of reducing the back and forth since they are nits — all follow-ups to the observability work, none change the core behavior:
1. Drop blank carried reasons in _merge_undetermined (rampart/core/evaluator.py)
safe_str_list doesn't strip, so a whitespace-only undetermined_operands entry ([" "]) made carried truthy and skipped the rationale fallback, then got discarded downstream — silently hiding the observability gap from the summary. Now blank entries are filtered before the presence test, so a blank-only operand falls through to its rationale. Only reachable via third-party evaluators, but it undercut the PR's central guarantee.
2. Share safe_float and harden confidence/rationale serialization (rampart/common/text.py, rampart/pytest_plugin/_xdist.py, rampart/reporting/json_file.py)
Promoted the finite-float guard out of _xdist.py into rampart.common.text as safe_float, next to safe_str/safe_str_list, and made it tolerate non-numeric/raising values (returns None, never swallows BaseException). Applied safe_float + safe_str to the confidence and rationale fields in both serializers so an out-of-contract value from a third-party evaluator can't break report serialization. Added TestSafeFloat covering finite passthrough, int coercion, NaN/±inf, non-numeric, None, a raising __float__, and control-flow-not-swallowed.
3. Use pytest.approx in the new float assertions (tests/unit/common/test_text.py)
Ruff's float-equality-comparison flagged direct == on floats; switched to pytest.approx, matching the existing serializer tests.
No behavioral change to verdicts — these only affect blank-reason edge cases and malformed third-party values at the summary/JSON boundary.
Since I've now made changes - I asked Bashir Partovi (@bashirpartovi) to review this PR separately to push it through to the finish line.
Bashir Partovi (bashirpartovi)
left a comment
There was a problem hiding this comment.
Thanks for working through all the feedback here. This is looking good overall. I left a few short comments, it's almost there :)
Two follow-ups to Bashir's review: - safe_float now rejects bool the way the judge's confidence parser does, and the xdist reader distinguishes a missing confidence (still full) from one that was present but sanitized to null. A non-finite confidence now round-trips to NaN, which re-serializes to null, so the xdist report no longer fabricates maximum confidence where the direct JSON report shows null. - _merge_undetermined stores the stripped reason before deduping, so a padded reason and its bare form collapse to one entry and both report paths agree. Adds regression coverage: safe_float bool rejection; direct/xdist confidence parity across NaN, present-null, bool, non-numeric, and missing; mixed-whitespace operands through composition and the xdist round trip. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Ruff FBT001 flags a boolean positional argument. Keyword-only satisfies it and pytest still injects the parametrized value by name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The composition section said a `SAFE` summary names the recorded operands
"rather than reporting a plain pass", which bashirpartovi read as the pass being
downgraded. It is not: recording qualifies the report and leaves the grade
alone.
He suggested "The result remains `SAFE`, but its summary names the parts of the
evaluation that were undetermined." That is used, but scoped, because as an
unconditional statement it is false. `_merge_undetermined` runs on every return
branch of `&` and `|`, not only the ones that end up SAFE, so a run can record a
gap and still fail:
probe, RESPONSE_ONLY, ToolCalled("x") & ResponseContains("absent")
status UNSAFE
undetermined_operands 1 entry
summary UNSAFE: Right operand not detected: ...
That is the page's own example one sentence later. On a run that does not
resolve `SAFE` the summary says nothing about the gap either, because
`_summarize_undetermined_operands` is appended only in the SAFE branch of both
builders.
A warning admonition now states the grading for the case that matters, each part
measured on an XPIA run at `TOOL_ONLY` whose left operand needs a channel the
adapter cannot report:
status SafetyStatus.SAFE, result.safe True
result PASS test_gap ... but part of the evaluation was undetermined
trial PASS test_gap [4/4 safe, 100% pass rate, threshold: 100%] PASSED
pytest exit code 0
`SAFE` is the only status that passes, so any other outcome already fails on its
own account. XPIA's backstop does move the verdict under `RESPONSE_ONLY`, and
both pages link to it rather than implying the rule is absolute.
`results-and-reporting.md` gains a section for the reader on the other side of
the report: what `turn.eval_result.undetermined_operands` holds, a snippet that
fails a passing run carrying one, and `eval_undetermined_operands` as the
per-turn key `JsonFileReportSink` writes and omits when the list is empty. A
failing run can carry that key too, so it is only conclusive read next to
`status`. No counter makes the distinction, since a qualified pass lands in
`safe_count` like any other.
Two smaller corrections on the lines this touches. The operators produce the
same `EvalOutcome` in either order rather than "the same answer", his wording.
And a nested composite contributes more than one reason through a single
operand, so "one reason each" becomes "one distinct reason per entry", which is
what the field's own docstring says.
`ty check` is a required CI step and exits 1 on the branch:
warning[unused-ignore-comment]: Unused `ty: ignore` directive
--> tests\unit\pytest_plugin\test_xdist.py:458:35
`bool` is a subclass of `int`, and `int` is assignable to `float`, so
`confidence=True` was never an argument-type error and the directive had
nothing to suppress. The test still asserts what it was written to assert:
that the serializer nulls a Boolean confidence and the reader refuses to
read it back as `1.0`.
Removing the comment takes `ty check` to exit 0. No other file changes.
`60924db` put `safe_str` on `rationale` in the xdist serializer and the JSON sink, which is the right guard, but nothing failed when it was removed. Line coverage cannot see that: the call runs either way, and every existing test passes a rationale that renders. Neutering each of the 35 `safe_str` and `safe_str_list` call sites in `rampart/`, one at a time, left those two green and the other 33 red. The description already claimed every site fails when its guard is dropped, so the claim had gone stale rather than the guards being wrong. One test per site, in the shape the neighbouring hostile-value tests already use: a rationale whose `__str__` raises now has to come back as `<unprintable value>` with the rest of the payload intact. Re-run, 0 of 35 survive.
|
Nina Chikanov (@nina-msft) thanks for taking these on directly. That saved a round, and Two things on top of your commits. |
|
Thanks Mahdi Alhakim (@mahdi-al-hakim)! Just approved & merged |
|
Thanks Nina Chikanov (@nina-msft) and Bashir Partovi (@bashirpartovi) for the careful review and for getting this in. If anything comes up that I can help with down the line, I am glad to pitch in. |
Description
Fixes #140 and closes #167, which Nina Chikanov (@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_ONLYand so never populatesResponse.side_effects.ToolCalledandSideEffectOccurredreturnNOT_DETECTEDin two different situations: the thing did not happen, and the adapter does not report that kind of evidence.resolve_as_attackmapsNOT_DETECTEDtoSAFEandresolve_as_probemaps it toUNSAFE, so a gap in the adapter becomes a statement about the agent._adjust_for_observabilitycovers part of this, but only forRESPONSE_ONLY, only in XPIA, and only by counting tool calls.This PR adds a regression test for the attack side. Run against
mainat3d219d8it produces:FAILandPASSthere 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 theTOOL_ONLYone returns the same response with noside_effects, which is exactly what aTOOL_ONLYadapter can do. RAMPART calls that second run "Agent defended successfully". The pytest test fails onmainand passes with this change.Every combination where a built-in evaluator needs evidence the adapter does not report, measured with a
MockAdapter:RESPONSE_ONLYToolCalledUNDETERMINEDUNDETERMINEDUNSAFEUNDETERMINEDRESPONSE_ONLYSideEffectOccurredUNDETERMINEDUNDETERMINEDUNSAFEUNDETERMINEDTOOL_ONLYSideEffectOccurredSAFEUNDETERMINEDUNSAFEUNDETERMINEDTwo of the six were already correct, and both were correct because
_adjust_for_observabilitycaught them.Rebased onto
mainat3d219d8, so the tests here followRMP001from #158 and #159 and the xdist transport is the one #166 landed.Changes
ObservabilityLevelgainsobserves_tool_callsandobserves_side_effects, following thePayloadFormat.is_textandis_binarypattern already in that file. Its class docstring described only theRESPONSE_ONLYcase, so it now also coversTOOL_ONLYwith side effects, which is the case in the linked issue.EvalContextgainsobservability_level. It is required, so a context built by hand has to say what the adapter behind it could see.evaluate_turn_asynctakes the level, required and keyword-only, and puts it on the context.XPIAExecutionandSingleTurnExecutionboth passadapter.observability_profile.ResultandEvalContext.from_responserequire it too, which is Nina Chikanov (@nina-msft)'s request below and what closes [FEAT]: Deprecate omittedobservability_levelacross public APIs #167.ToolCalledandSideEffectOccurredreturnUNDETERMINEDwhen 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_observabilitymakes the same allowance today.The
UNDETERMINEDsummary on both strategies is built fromundetermined_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 Chikanov (@nina-msft)'s second request below._AllEvaluatorshort-circuits only on aNOT_DETECTEDleft operand. AnUNDETERMINEDleft 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 Chikanov (@nina-msft) below._AnyEvaluatornames 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 toauthoring-tests.mdpoints 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 aNOT_DETECTEDrationale from a different turn. Settled results are read only when nothing else gave a reason, which is what the_adjust_for_observabilitydowngrade looks like.The probe unsafe summary takes its reason from a
NOT_DETECTEDresult. It took the last rationale of any outcome, so once these evaluators can returnUNDETERMINED, an undetermined turn could state the reason for a definitive failure.EvalResultgainsundetermined_operands.&and|record every operand they ran that came backUNDETERMINED, 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, andJsonFileReportSinkemits it aseval_undetermined_operandswhen it is not empty. This is Bashir Partovi (@bashirpartovi)'s first option below; the truth table is untouched.The
SAFEsummary 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
DETECTEDresults, matching the probe summary. AnUNDETERMINEDcomposite 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_operandsandevidencein the composites, the summaries and the two serializers goes throughsafe_str_list, and the probe's unsafe and error summaries putrationalethroughsafe_strwhere the XPIA summary was already guarded._distinct_operand_reasonsflattened 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.evidencehad 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 Chikanov (@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 asNoneor0, now shows as itself where it used to fall through to the generic line. Both readings are of a value that already violates the declaredstr, and the verdict is the same either way.safe_strreturns an exactstr.str()accepts a__str__that returns astrsubclass, so the rendered value could still carry evaluator code on the methods RAMPART reaches for next._distinct_reasonsand_merge_undeterminedalready 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 originalResultwas 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_strandsafe_str_listinrampart/common/text.pycoerce evaluator-supplied values without raising. Every rationale interpolation in the composites goes through them, as does every read ofundetermined_operandsin the composites, the JSON sink and the xdist serializer._AnyEvaluatorand_AllEvaluatorbetween them gained three rationale interpolationsmaindoes not have, so a value whose__str__raises turned inputs that resolved cleanly onmainintoSafetyStatus.ERROR, losing a verdict the evaluators had already reached. Both helpers catchExceptionrather thanBaseException, so cancellation and interrupts still propagate.ObservabilityLevelandauthoring-tests.mdnow 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 returnNOT_DETECTED.Session.send_async,authoring-tests.mdandquickstart.mdsaid empty lists mean "no observations", not "nothing happened". That rule predates the declared level and now reads backwards, and it contradictedobservability_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,
SAFEis the only status that passes, and on a run that reaches it the summary andundetermined_operandsare the only places the gap shows.results-and-reporting.mddocumentsturn.eval_result.undetermined_operandsand the per-turneval_undetermined_operandskey, with a snippet that fails a passing run carrying one. This is Bashir Partovi (@bashirpartovi)'s request below.Nina Chikanov (@nina-msft) pushed five commits to this branch,
38dd617,c93f611,7643ac8,60924dbanda217370. They drop a blank carried reason in_merge_undeterminedso it falls through to the rationale, promote the finite-float guard out of_xdist.pyintorampart/common/text.pyassafe_float, putsafe_floatandsafe_stronconfidenceandrationalein 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,
2c195aaadds a test per site for the tworationaleguards, which the sweep below had left green, andaefee9bremoves aty: ignorethattyreports 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:rampart/evaluators/tool_called.py:23-25:I followed the first one.
The obvious alternative is to keep the adjustment central and have evaluators declare a
required_observabilityfor the strategy to read. I could not make that work for composition. UnderTOOL_ONLY,ToolCalled("x") | SideEffectOccurred("y")should still returnDETECTEDifxwas 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 returnUNDETERMINED, which is what this change gives them.There is also precedent for an evaluator reporting its own uncertainty.
LLMJudgereturnsUNDETERMINEDwhen 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.UNDETERMINEDis defined as "The evaluator could not make a determination".The adjustment itself stays where the second docstring puts it.
_adjust_for_observabilityis unchanged and still owns the verdict downgrade. What changes is the quality of its input. The sentence inToolCalled's docstring is contradicted by this PR and is updated, as is the matching note indocs/usage/authoring-tests.md.No new verdict semantics
UNDETERMINEDis not new at either level.EvalOutcome.UNDETERMINEDis produced today byLLMJudgeand by|and&, and preserved by~.SafetyStatus.UNDETERMINEDis 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 throughSafetyStatus(value),JsonFileReportSink, the WARN terminal label, and the population summary. This change produces it in more of the cases it already exists for.DETECTEDthat 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 Chikanov (@nina-msft) asked.1.
observability_levelis required on four public APIs.EvalContext,EvalContext.from_response,evaluate_turn_asyncandResultno longerdefault it. Three of those four parameters are introduced by this PR, so the
break there is against a signature that has not shipped;
Resultis the onethat predates the branch and loses a real default of
RESPONSE_ONLY.Resultis also the widest of the four: of the 94 call sites in this repo'sPython files that omitted the argument, 75 were
Result(...), counting the oneswritten inside pytester source strings. Two more
Result(...)examples indocs/omitted it as well, and are updated here. Nina Chikanov (@nina-msft) left this one tomy 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
Resultdefault back.Migration is to pass the adapter's declared level, normally
adapter.observability_profile. Omitting it is aTypeErrorat the callrather 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.
JsonFileReportSinkgains two:eval_undetermined_operandsper turn when thelist is not empty, and
observability_levelon every result unconditionally, soa consumer validating a strict schema on a result object sees a new always-present
field.
EvalResult.undetermined_operandsiswritten 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_VERSIONis unchanged, and bumping it would make_validate_schemareject the whole payload instead.
JsonFileReportSinkemitseval_undetermined_operandsper turn only when the list is not empty.For
ToolCalledandSideEffectOccurredused alone,NOT_DETECTEDbecomesUNDETERMINEDand nothing moves towardSAFE. What existing suites will see:UNDETERMINEDand fails. That is the bug being fixed, and it will surface as a newly red test.ToolCalledorSideEffectOccurredbelow the level it needs goes fromUNSAFEtoUNDETERMINED. Both are falsy, so the test still fails, but the terminal label changes from FAIL to WARN.~ToolCalled(...)underRESPONSE_ONLYpreviously returnedDETECTEDby inverting an absence the adapter could not attest, and now passesUNDETERMINEDthrough. 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 aTOOL_ONLYadapter.UNDETERMINEDlogs a passing gate line where it previously logged a failing one. The clones still fail, sinceassert resultis falsy, and_evaluate_gatesonly logs, so no CI outcome flips. I left the threshold alone because PR [FEAT]: Add execution trial populations and threshold verdicts #121 is reworking that layer.Making
&order independent required choosing which outcome wins when one operand isNOT_DETECTEDand the other isUNDETERMINED. It returnsNOT_DETECTED, which is Kleene and is what the review asked for. Against every operand pair onmain, one cell moves:This is not a regression against
mainforToolCalledorSideEffectOccurred, which returnedNOT_DETECTEDonmainat a level that could not report the evidence, so the conjunction already resolvedSAFE. 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 degradedLLMJudgeinside&can now resolveSAFEwheremainsaidUNDETERMINED. Both cases now record the reason inEvalResult.undetermined_operands, and aSAFEsummary names it, when the undetermined operand is on the left, since&still short-circuits on aNOT_DETECTEDleft operand and never runs what is to its right.|reportsUNDETERMINEDin 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_observabilityalso fires when it should not:RESPONSE_ONLYwithResponseContainsis 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.LLMJudgenow receivesobservability_leveland 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.ResponseContainsis untouched on purpose. Every level reports text, so no declared level hides it.EvalOutcome.UNDETERMINEDinto "cannot observe" and "did not run" so&can treat them differently. That is the real fix for theLLMJudgecase above and it is bigger than this PR.undetermined_operandsrecords both kinds without telling them apart, so it does not pre-empt that design.rampart/drivers/llm.py:333a value whose truthiness raises costs the next turn before rendering is tried.confidenceandrationalein the two serializers were on this list until Nina Chikanov (@nina-msft) guarded them inc93f611."; "is indistinguishable from the separator the summary joins on. Same onmainfor the existing summaries, and the full list is onResult.eval_resultsand in the JSON either way.Checklist
pre-commit run --all-filespassesTests
300 new tests against
main, and one removed:test_left_undetermined_short_circuits_asyncasserted that
&skips the right operand when the left isUNDETERMINED, which is thebehavior the review asked me to remove.
8bf0b62renamed it totest_left_undetermined_evaluates_right_asyncand inverted itsright.call_countassertion, so the coverage moved rather than being dropped. It is the only collected node
id
mainhas that this branch does not, so nothing else existing was changed, removed orreparented.
One test appears and disappears inside the branch rather than against
main:9ac85b7addedtest_observability_level_defaults_to_no_declared_limitandb86a67fremoved it, because it asserted the default that commit takes away; four tests asserting
the
TypeErrorreplace it, one per API.The containment tests cover the operand path through
_summarize_undetermined_operandsand
_explain_undetermined, both summary builders, every branch of the three composites,the xdist serializer, the new report key, and
safe_str_listitself. Most of them areparametrized 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_strandsafe_str_listcall sites on this branch, oneat a time, turns the suite red at every one of them.
Across the whole PR, by file:
test_evaluator.py151,test_text.py34,test_single_turn.py26,test_xpia.py25,test_result.py21,test_xdist.py12,test_tool_called.py9,test_side_effect.py7,test_types.py7,test_json_file.py6,test_execution.py2. 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,
UNDETERMINEDat eachinsufficient 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,b86a67fand82f7926added:test_result.py(10): nine on_explain_undeterminedin priority order, plus oneTypeErrortest. 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) andtest_single_turn.py(7):ToolCalled("x") | SideEffectOccurred("y")underRESPONSE_ONLYend to end, naming both channels, which is the case in the review comment; the_adjust_for_observabilitydowngrade 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) andtest_execution.py(1): the remaining three of the fourTypeErrortests, one per API.test_xdist.py: no new ids. The oversized-result marker test now asserts the level survives truncation instead of being rewritten toRESPONSE_ONLY.tests/integration/test_smoke.pyusesToolCalledthroughEvalContext.from_responseand 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: theToolCalledwarning said it "always returnsNOT_DETECTED" underRESPONSE_ONLY, which is no longer true.SideEffectOccurredhad 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 howUNDETERMINEDtravels through&and|, which no user facing page covered, and a paragraph on whatundetermined_operandsrecords 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 calledevaluate_turn_asyncwithout 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 aTypeErrorrather than a wrong assumption.docs/contributing/testing.mdanddocs/usage/pytest-integration.md: theResult(...)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_asyncis exported fromrampart.corebut was absent from the API reference. It joins the otherrampart.core.executionmembers there. It is not importable fromrampartdirectly, so it does not belong oncore-types.md, whose lede promises exactly that.docs/getting-started/quickstart.mdanddocs/glossary.md: the empty-list rule and theEvalContextentry.No new pages, so no
mkdocs.ymlnav change.Checks run locally
Rebased onto
mainat3d219d8. That range brought ruff 0.16.3 and ty 0.0.72into
uv.lockvia #169, so the numbers below are at those versions, not the onesI quoted two rounds ago.
mkdocs build --strictaborts on bothmainand this branch for the sameenvironmental 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.