Results and Reporting¶
Every RAMPART execution produces a Result. Results flow into reporting sinks for persistence and into the terminal summary for immediate feedback.
The Result Type¶
Result is the single output type for all tests.
result = await Attacks.xpia(...).execute_async(adapter=my_adapter)
result.safe # bool — did the agent behave safely?
result.status # SafetyStatus (SAFE, UNSAFE, UNDETERMINED, ERROR)
result.summary # str — human-readable one-liner
result.observability_level # ObservabilityLevel (what the adapter saw)
result.turns # list[Turn] — full conversation
result.duration_seconds # float — execution wall-clock time
result.harm_category # HarmCategory | str | None
result.strategy # str — "xpia", "probe", etc.
result.injections # list[InjectionRecord] — what was injected where
The Assert Pattern¶
bool(result) returns result.safe:
SafetyStatus¶
| Status | Meaning |
|---|---|
SAFE |
The agent behaved correctly |
UNSAFE |
A safety violation was detected |
UNDETERMINED |
Could not determine safety |
ERROR |
Infrastructure failure |
Turns¶
Each Turn in result.turns is one prompt-response exchange:
for turn in result.turns:
turn.request.prompt # What was sent
turn.response.text # What came back
turn.response.tool_calls # Tool invocations observed
turn.eval_result # EvalResult for this turn, or None
turn.turn_number # 0-indexed position
Observability Gaps on a Passing Run¶
A run can resolve SAFE while part of the evaluation was never observable. Such a run is graded as a pass: result.safe is True, the result line reads PASS, a trial group counts it toward the pass rate, and pytest exits zero. result.summary names the gap, and turn.eval_result.undetermined_operands carries it one reason at a time, so a caller that wants to fail on it has to say so:
gaps = [
reason
for turn in result.turns
if turn.eval_result is not None
for reason in turn.eval_result.undetermined_operands
]
assert result and not gaps, result.summary
JsonFileReportSink writes the same list as eval_undetermined_operands on each turn that has one, and omits the key otherwise. A failing run can carry the key too, so read it alongside status: together they tell a fully observed pass from one reached with a gap. No counter makes that distinction, because a qualified pass lands in safe_count like any other.
XPIA applies one further rule of its own to RESPONSE_ONLY adapters, which does move the verdict. See Observability Adjustment.
Report Sinks¶
Report sinks receive a TestRunReport at the end of the pytest session.
JsonFileReportSink (Built-in)¶
Writes timestamped JSON files:
from pathlib import Path
from rampart.reporting import JsonFileReportSink
sink = JsonFileReportSink(output_dir=Path(".report"))
Output: .report/run_report_2026-04-25T14-30-00.json
Custom Sinks¶
Implement the ReportSink protocol:
from rampart.reporting import ReportSink, TestRunReport
class MyDatabaseSink:
async def emit_async(self, *, report: TestRunReport) -> None:
for result in report.results:
await self._db.insert(
safe=result.safe,
status=result.status.value,
harm=str(result.harm_category),
)
Wiring Sinks¶
Register the pytest_rampart_sinks hook in your conftest.py. See pytest Markers & Fixtures for the setup and examples with multiple sinks.
Parallel execution
Under pytest-xdist, workers send their results to the controller, which emits sinks once with a unified TestRunReport. The pytest_rampart_sinks hook is resolved on the controller and works the same in single-process and parallel runs. See Registering Sinks for details.
TestRunReport¶
The report object passed to sinks. See TestRunReport for full API.
Grouping and Aggregation¶
# Group by harm category
by_category = report.by_harm_category()
# Population statistics
summary = report.population_summary()
summary.total_runs
summary.safe_count
summary.unsafe_count
summary.attack_success_rate # UNSAFE / non-ERROR total
summary.safety_pass_rate # SAFE / non-ERROR total
# Filter by category
exfil = report.population_summary(harm_category=HarmCategory.DATA_EXFILTRATION)
Note
ERROR results are excluded from rate calculations. A transient infrastructure failure is not a safety finding.
Portable Regression Receipt¶
For CI gating, capture a curated set of facts in result.metadata — both scenario-level facts (what should stay stable across time) and run-level context (what was tested) — to use as a regression receipt your team can diff across runs.
result = await Attacks.xpia(...).execute_async(adapter=my_adapter)
result.metadata.update({
"scenario_id": "xpia-login-001",
"threat_class": "credential_exfiltration",
"expected_safe_behavior": "never reveal a password or token",
"evaluator_version": "response_contains@1.4.2",
"mitigation_ref": "SEC-1234",
"ci_run_url": "https://ci.example.com/runs/94821",
})
assert result, result.summary
These keys live on the Result, so any sink can persist them. With JsonFileReportSink, they appear on each result's metadata object (grouped under by_harm_category in the output). A custom sink only records them if its emit_async reads result.metadata.
Only these curated keys are stable across runs. A full sink artifact like the JsonFileReportSink file is written to a timestamped path and includes inherently non-deterministic fields, so extract the metadata subset rather than diffing the whole run report:
# Read JSON report and extract only the metadata object from the result
# Outputs a clean array of curated, stable receipt fields to diff across
jq '[.by_harm_category[][] | .metadata]' run_report.json
Or, without jq, using the standard library:
import json
with open("run_report.json") as f:
report = json.load(f)
# Collect the metadata object from every result, across all harm categories
receipt = [
result["metadata"]
for results in report["by_harm_category"].values()
for result in results
]
print(json.dumps(receipt, indent=2, sort_keys=True))
Note
The framework also adds internal, underscore-namespaced keys to result.metadata, so the persisted metadata contains slightly more than the snippet sets. Ignore these _pytest_* / _rampart_* keys when diffing your receipt.