-
Notifications
You must be signed in to change notification settings - Fork 48
[FEAT]: Add execution-layer trial populations and threshold verdicts #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d2998ff
47d77ea
1f4dc78
2b89256
387e7e5
29baf51
cd279f8
2315a01
091e9cb
8341ee5
62d06b9
dde74d8
440d975
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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( | ||
|
|
@@ -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, | ||
| ) | ||
| await self._fire( | ||
| ExecutionEvent.ON_POST_EXECUTE, | ||
| adapter=adapter, | ||
|
|
@@ -272,6 +293,69 @@ async def execute_async(self, *, adapter: AgentAdapter) -> Result: | |
| ) | ||
| return result | ||
|
|
||
| async def execute_trials_async( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there is a problem we need to address because only the child
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we could do something like this: import uuid
...
population_id = uuid.uuid4().hex
results: list[Result] = []
for index in range(n):
result = await self.execute_async(adapter=adapter)
result.metadata.setdefault(
"_rampart_population",
{
"id": population_id,
"threshold": threshold,
"n": n,
"index": index,
},
)
results.append(result)
return PopulationResult(results=results, threshold=threshold)Although we need to think about this a bit more. uuid avoids population conflict but varies run to run..
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bashirpartovi good callout. I liked your metadata suggestion and implemented it with a minor tweak: execute_async() now accepts additional result metadata and attaches it before ON_POST_EXECUTE fires. This ensures every event handler, including the built-in result collector, observes the final annotated Result, and the metadata flows through to reporting sinks by default. let me know what you think
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also about the uuid - I think it's actuallya good think to use new ones per population, because it only groups results from one execute_trials_async() invocation; stable pytest context such as _pytest_nodeid and _rampart_result_index is persisted separately for cross-run identification and ordering. |
||
| 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 | ||
| 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]. | ||
| """ | ||
|
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 on lines
+340
to
+352
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we wait to await all here?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or as an opt-in option? |
||
|
|
||
| return PopulationResult( | ||
| results=results, | ||
| threshold=threshold, | ||
| ) | ||
|
|
||
| @abstractmethod | ||
| async def _execute_async(self, *, adapter: AgentAdapter) -> Result: | ||
| """Core execution logic implemented by each strategy. | ||
|
|
@@ -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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -168,6 +168,98 @@ def __repr__(self) -> str: | |
| ) | ||
|
|
||
|
|
||
| @dataclass(kw_only=True) | ||
| class PopulationResult: | ||
| """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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just verifying this is equivalent to |
||
| 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. | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.