Skip to content
Open
1 change: 1 addition & 0 deletions docs/api/core-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Data types shared across the entire framework. All importable from `rampart` dir
options:
members:
- Result
- PopulationResult
- SafetyStatus
- HarmCategory
- InjectionRecord
Expand Down
3 changes: 3 additions & 0 deletions docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ async def test_xpia_email_exfil(my_agent):
- **`@pytest.mark.harm(...)`** — Groups results by harm category in the terminal summary and reports.
- **`@pytest.mark.trial(n=3, threshold=0.8)`** — Runs 3 independent trials; passes if ≥ 80% are SAFE. LLM agents are non-deterministic, so a single run may not be representative.

!!! tip "Execution-level trials"
`execute_trials_async(adapter=my_agent, n=3, threshold=0.8)` runs repeated executions within one pytest item and returns a `PopulationResult`. Assert that result to apply the threshold without cloning the test. Each child remains an independently reported `Result`; its `_rampart_population` metadata records the population ID, index, size, and threshold for optional correlation.

See [pytest Markers & Fixtures](../usage/pytest-integration.md) for the full marker reference.

---
Expand Down
6 changes: 4 additions & 2 deletions docs/usage/ci-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ This runs 10 independent trials. The test group passes only if ≥ 80% of trials

- Each trial clone appears as a separate pytest item
- The aggregate verdict appears in the RAMPART terminal summary
- Any `UNSAFE` trial → the group fails
- `ERROR` trials count against the pass rate
- The aggregate passes when the SAFE pass rate meets the threshold
- Any `ERROR` trial makes the aggregate fail
- `UNSAFE` and `UNDETERMINED` trials count against the pass rate
- Clones that produce no RAMPART result are excluded from the pass-rate denominator

---

Expand Down
5 changes: 3 additions & 2 deletions docs/usage/pytest-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ async def test_with_threshold(adapter):
**Trial semantics:**

- Each trial clone runs independently as a separate pytest item
- Any `UNSAFE` result in any trial → the group **fails**
- `threshold` sets the minimum pass rate: `threshold=0.8` requires ≥ 80% SAFE
- `ERROR` results count against the pass rate (they are not `SAFE`)
- Any `ERROR` result makes the aggregate group fail
- `UNSAFE` and `UNDETERMINED` results count against the pass rate
- Clones that produce no RAMPART result are excluded from the pass-rate denominator
- The trial group aggregate appears in the terminal summary

!!! tip "Running trials in parallel"
Expand Down
24 changes: 12 additions & 12 deletions docs/usage/results-and-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,22 +129,22 @@ For CI gating, capture a curated set of facts in `result.metadata` — both scen


```python
result = await Attacks.xpia(...).execute_async(adapter=my_adapter)

# Scenario-level facts you want stable across runs — pick the keys your team needs
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", # run-level context
})
result = await Attacks.xpia(...).execute_async(
adapter=my_adapter,
additional_result_metadata={
"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`, for example, 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`.
Additional metadata is attached before `ON_POST_EXECUTE`, so event handlers and sinks see the same result state. It is strictly additive: reusing a key already produced by the execution raises `ValueError`. Keys beginning with `_rampart_` are conventionally used by the framework and should be avoided by callers. With `JsonFileReportSink`, these keys 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`.
Comment thread
spencrr marked this conversation as resolved.
Outdated

**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:

Expand Down
8 changes: 7 additions & 1 deletion rampart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@

from rampart.attacks import Attacks
from rampart.core.adapter import AgentAdapter, Session
from rampart.core.errors import DriverError, EvaluatorError, InfrastructureError
from rampart.core.errors import (
DriverError,
EvaluatorError,
InfrastructureError,
)
from rampart.core.evaluator import BaseEvaluator, Evaluator
from rampart.core.execution import (
BaseExecution,
Expand All @@ -23,6 +27,7 @@
from rampart.core.result import (
HarmCategory,
InjectionRecord,
PopulationResult,
Result,
SafetyStatus,
resolve_as_attack,
Expand Down Expand Up @@ -72,6 +77,7 @@
"Payload",
"PayloadFormat",
"Persona",
"PopulationResult",
"Probes",
"PromptDecision",
"PromptDriver",
Expand Down
2 changes: 2 additions & 0 deletions rampart/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from rampart.core.result import (
HarmCategory,
InjectionRecord,
PopulationResult,
Result,
SafetyStatus,
resolve_as_attack,
Expand Down Expand Up @@ -70,6 +71,7 @@
"PayloadConverter",
"PayloadFormat",
"Persona",
"PopulationResult",
"PromptDecision",
"PromptDriver",
"Request",
Expand Down
110 changes: 108 additions & 2 deletions rampart/core/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@

import logging
import time
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, replace
from enum import Enum
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from rampart.core.result import Result, SafetyStatus
from rampart.core.result import PopulationResult, Result, SafetyStatus
from rampart.core.types import EvalContext, Request, Response, Turn

if TYPE_CHECKING:
from collections.abc import Mapping
from typing import Any

from rampart.core.adapter import AgentAdapter
from rampart.core.evaluator import Evaluator
from rampart.core.manifest import AppManifest
Expand Down Expand Up @@ -214,7 +218,12 @@ def strategy_name(self) -> str:
"""
...

async def execute_async(self, *, adapter: AgentAdapter) -> Result:
async def execute_async(
self,
*,
adapter: AgentAdapter,
additional_result_metadata: Mapping[str, Any] | None = None,
) -> Result:
"""Execute the safety test.

Fires lifecycle events and delegates to _execute_async for
Expand All @@ -226,9 +235,17 @@ async def execute_async(self, *, adapter: AgentAdapter) -> Result:

Args:
adapter (AgentAdapter): The agent to test.
additional_result_metadata (Mapping[str, Any] | None): Metadata to
add to the result before ON_POST_EXECUTE. Existing result
metadata cannot be overwritten. Keys beginning with
``_rampart_`` are conventionally used by the framework.

Returns:
Result: Safety verdict with evidence and diagnostics.

Raises:
ValueError: If additional_result_metadata contains a key already
present in the result metadata.
"""
start = time.monotonic()
await self._fire(
Expand Down Expand Up @@ -264,6 +281,10 @@ async def execute_async(self, *, adapter: AgentAdapter) -> Result:

elapsed = time.monotonic() - start
result.duration_seconds = elapsed
self._add_result_metadata(
result=result,
additional_result_metadata=additional_result_metadata,
)
Comment thread
behnam-o marked this conversation as resolved.
Outdated
await self._fire(
ExecutionEvent.ON_POST_EXECUTE,
adapter=adapter,
Expand All @@ -272,6 +293,69 @@ async def execute_async(self, *, adapter: AgentAdapter) -> Result:
)
return result

async def execute_trials_async(
Comment thread
behnam-o marked this conversation as resolved.
self,
*,
adapter: AgentAdapter,
n: int,
threshold: float,
) -> PopulationResult:
"""Execute a population of independent trials.

Each trial uses the normal ``execute_async`` lifecycle, including
event dispatch and result collection. The returned aggregate provides
the single logical verdict that callers should assert. Execution
strategies are responsible for creating a fresh agent session during
each call to ``execute_async``.

Note: Trials are only statistically meaningful when the adapter is stateless
across sessions. a stateful adapter (e.g. memory-backed) makes pass_rate an
Comment thread
behnam-o marked this conversation as resolved.
Outdated
unreliable estimate.

Args:
adapter (AgentAdapter): The agent to test.
n (int): Number of independent trials to execute.
threshold (float): Required safe-result rate from 0.0 to 1.0.

Returns:
PopulationResult: Aggregate verdict and individual trial results.

Raises:
TypeError: If n is not a non-boolean integer.
ValueError: If n is less than 1 or threshold is outside
[0.0, 1.0].
"""
Comment thread
behnam-o marked this conversation as resolved.
if not isinstance(n, int) or isinstance(n, bool):
msg = "n must be a non-boolean integer"
raise TypeError(msg)
if n < 1:
msg = "n must be greater than or equal to 1"
raise ValueError(msg)
if not 0.0 <= threshold <= 1.0:
msg = "threshold must be between 0.0 and 1.0"
raise ValueError(msg)

population_id = uuid.uuid4().hex
results: list[Result] = []
for index in range(n):
result = await self.execute_async(
adapter=adapter,
additional_result_metadata={
"_rampart_population": {
"id": population_id,
"index": index,
"size": n,
"threshold": threshold,
},
},
)
results.append(result)
Comment thread
behnam-o marked this conversation as resolved.
Outdated

return PopulationResult(
results=results,
threshold=threshold,
)

@abstractmethod
async def _execute_async(self, *, adapter: AgentAdapter) -> Result:
"""Core execution logic implemented by each strategy.
Expand All @@ -284,6 +368,28 @@ async def _execute_async(self, *, adapter: AgentAdapter) -> Result:
"""
...

@staticmethod
def _add_result_metadata(
*,
result: Result,
additional_result_metadata: Mapping[str, Any] | None,
) -> None:
"""Add metadata without overwriting keys produced by the execution.

Raises:
ValueError: If an additional metadata key already exists.
"""
if not additional_result_metadata:
return

duplicate_keys = result.metadata.keys() & additional_result_metadata.keys()
if duplicate_keys:
formatted_keys = ", ".join(sorted(duplicate_keys))
msg = f"Result metadata already contains key(s): {formatted_keys}"
raise ValueError(msg)

result.metadata = {**result.metadata, **additional_result_metadata}

async def _fire(
self,
event: ExecutionEvent,
Expand Down
98 changes: 95 additions & 3 deletions rampart/core/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

"""Core result types for the RAMPART framework.

Defines the single Result type, SafetyStatus, HarmCategory, InjectionRecord,
and the resolve_as_attack / resolve_as_probe functions that map evaluator
outcomes to safety verdicts.
Defines single-run and population result types, SafetyStatus, HarmCategory,
InjectionRecord, and the resolve_as_attack / resolve_as_probe functions that
map evaluator outcomes to safety verdicts.
"""

from __future__ import annotations
Expand Down Expand Up @@ -168,6 +168,98 @@ def __repr__(self) -> str:
)


@dataclass(kw_only=True)
class PopulationResult:
Comment thread
behnam-o marked this conversation as resolved.
"""Aggregate verdict for repeated executions of one safety test.

``Result`` remains the verdict for one execution. This type applies a
threshold to a homogeneous population of those results and preserves the
individual results for reporting and future statistical analysis.

Args:
results (list[Result]): Results from trials that executed.
threshold (float): Required safe-result rate in the inclusive range
from 0.0 to 1.0.

Raises:
ValueError: If threshold is outside [0.0, 1.0].
"""

results: list[Result]
threshold: float

def __post_init__(self) -> None:
"""Validate population configuration.

Raises:
ValueError: If threshold is outside [0.0, 1.0].
"""
if not 0.0 <= self.threshold <= 1.0:
Comment thread
behnam-o marked this conversation as resolved.
msg = "threshold must be between 0.0 and 1.0"
raise ValueError(msg)

@property
def safe_count(self) -> int:
"""Number of safe trials."""
return sum(1 for result in self.results if result.safe)

@property
def executed_count(self) -> int:
"""Number of executed trials."""
return len(self.results)

@property
def pass_rate(self) -> float:
"""Safe-result rate across executed trials."""
if self.executed_count == 0:
return 0.0
return self.safe_count / self.executed_count

@property
def status(self) -> SafetyStatus:
"""Population status resolved using error and threshold policy."""
if any(result.status is SafetyStatus.ERROR for result in self.results):
return SafetyStatus.ERROR
if self.executed_count > 0 and self.pass_rate >= self.threshold:
return SafetyStatus.SAFE
if any(result.status is SafetyStatus.UNSAFE for result in self.results):
return SafetyStatus.UNSAFE
return SafetyStatus.UNDETERMINED

@property
def safe(self) -> bool:
"""Whether the population met its safety threshold."""
return self.status is SafetyStatus.SAFE

@property
def summary(self) -> str:
"""Concise population verdict summary."""
return (
f"{self.safe_count}/{self.executed_count} trials safe "
f"({self.pass_rate:.1%} pass rate, threshold: {self.threshold:.1%}); "
f"status: {self.status.value}"
)

def __bool__(self) -> bool:
"""Return whether the population met its safety threshold."""
return self.safe

def __repr__(self) -> str:
"""Show the aggregate verdict for quick debugging.

Returns:
str: A compact representation of the population verdict.
"""
return (
f"PopulationResult(safe={self.safe}, "
f"status={self.status.value}, "
f"safe_count={self.safe_count}, "
f"executed_count={self.executed_count}, "
f"pass_rate={self.pass_rate}, "
f"threshold={self.threshold})"
)


def resolve_as_attack(*, eval_results: list[EvalResult]) -> SafetyStatus:
"""Attack semantics: detected -> UNSAFE, not detected -> SAFE.

Expand Down
Loading