agentproof-sim 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. agentproof/__init__.py +41 -0
  2. agentproof/adapters/__init__.py +14 -0
  3. agentproof/adapters/base.py +19 -0
  4. agentproof/adapters/langchain.py +95 -0
  5. agentproof/adapters/native.py +23 -0
  6. agentproof/adapters/openai_agents.py +62 -0
  7. agentproof/api.py +99 -0
  8. agentproof/cli.py +172 -0
  9. agentproof/core/clock.py +18 -0
  10. agentproof/core/effects.py +109 -0
  11. agentproof/core/events.py +63 -0
  12. agentproof/core/faults.py +294 -0
  13. agentproof/core/invariant.py +109 -0
  14. agentproof/core/redaction.py +32 -0
  15. agentproof/core/result.py +109 -0
  16. agentproof/core/runner.py +216 -0
  17. agentproof/core/scenario.py +13 -0
  18. agentproof/core/trace.py +58 -0
  19. agentproof/core/world.py +126 -0
  20. agentproof/invariants/__init__.py +21 -0
  21. agentproof/invariants/builtin.py +96 -0
  22. agentproof/invariants/temporal.py +5 -0
  23. agentproof/mutations/__init__.py +61 -0
  24. agentproof/mutations/base.py +57 -0
  25. agentproof/mutations/duplication.py +15 -0
  26. agentproof/mutations/ordering.py +10 -0
  27. agentproof/mutations/state.py +15 -0
  28. agentproof/mutations/timing.py +9 -0
  29. agentproof/mutations/tool_faults.py +58 -0
  30. agentproof/py.typed +1 -0
  31. agentproof/pytest_plugin.py +40 -0
  32. agentproof/replay/player.py +74 -0
  33. agentproof/replay/recorder.py +29 -0
  34. agentproof/replay/schema.py +24 -0
  35. agentproof/reporting/__init__.py +7 -0
  36. agentproof/reporting/console.py +34 -0
  37. agentproof/reporting/json_report.py +47 -0
  38. agentproof/reporting/junit.py +66 -0
  39. agentproof/tools/definition.py +26 -0
  40. agentproof/tools/invocation.py +11 -0
  41. agentproof/tools/registry.py +154 -0
  42. agentproof_sim-0.1.1.dist-info/METADATA +407 -0
  43. agentproof_sim-0.1.1.dist-info/RECORD +46 -0
  44. agentproof_sim-0.1.1.dist-info/WHEEL +4 -0
  45. agentproof_sim-0.1.1.dist-info/entry_points.txt +5 -0
  46. agentproof_sim-0.1.1.dist-info/licenses/LICENSE +18 -0
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class ScheduledEvent(BaseModel):
10
+ model_config = ConfigDict(extra="forbid")
11
+
12
+ id: str
13
+ name: str
14
+ payload: dict[str, Any] = Field(default_factory=dict)
15
+ deliver_at: float
16
+ delivered: bool = False
17
+
18
+
19
+ class EventQueue:
20
+ def __init__(self, world: Any) -> None:
21
+ self._world = world
22
+ self._events: list[ScheduledEvent] = []
23
+
24
+ def schedule(self, *, name: str, payload: dict[str, Any], delay: float = 0.0) -> ScheduledEvent:
25
+ event = ScheduledEvent(
26
+ id=self._world.next_id("event"),
27
+ name=name,
28
+ payload=copy.deepcopy(payload),
29
+ deliver_at=float(self._world.clock.now()) + float(delay),
30
+ )
31
+ self._events.append(event)
32
+ self._world.trace.record(
33
+ "event_scheduled",
34
+ name,
35
+ {"event_id": event.id, "payload": payload, "deliver_at": event.deliver_at},
36
+ )
37
+ return event
38
+
39
+ def deliver_due(self) -> list[ScheduledEvent]:
40
+ delivered: list[ScheduledEvent] = []
41
+ for event in self._events:
42
+ if event.delivered or event.deliver_at > self._world.clock.now():
43
+ continue
44
+ if self._world.faults.before_event_delivery(event):
45
+ continue
46
+ event.delivered = True
47
+ for index in range(self._world.faults.duplicate_event_count(event)):
48
+ payload = copy.deepcopy(event.payload)
49
+ delivered_event = event.model_copy(deep=True)
50
+ delivered_event.id = event.id if index == 0 else f"{event.id}_dup_{index}"
51
+ delivered.append(delivered_event)
52
+ self._world.trace.record(
53
+ "event_delivered",
54
+ event.name,
55
+ {"event_id": delivered_event.id, "payload": payload},
56
+ )
57
+ return delivered
58
+
59
+ def pending(self) -> list[ScheduledEvent]:
60
+ return [event.model_copy(deep=True) for event in self._events if not event.delivered]
61
+
62
+ def snapshot(self) -> list[dict[str, Any]]:
63
+ return [event.model_dump(mode="json") for event in self._events]
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ Severity = Literal["low", "medium", "high", "critical"]
9
+
10
+
11
+ class AgentProofError(Exception):
12
+ """Base exception for AgentProof-controlled errors."""
13
+
14
+
15
+ class AgentProofToolError(AgentProofError):
16
+ code = "tool_error"
17
+
18
+ def __init__(self, tool_name: str, message: str | None = None) -> None:
19
+ self.tool_name = tool_name
20
+ super().__init__(message or f"{self.code}: {tool_name}")
21
+
22
+
23
+ class ToolTimeoutError(AgentProofToolError, TimeoutError):
24
+ code = "timeout"
25
+
26
+
27
+ class ToolRateLimitError(AgentProofToolError):
28
+ code = "rate_limited"
29
+
30
+
31
+ class ToolPermissionDeniedError(AgentProofToolError):
32
+ code = "permission_denied"
33
+
34
+
35
+ class ToolInjectedError(AgentProofToolError):
36
+ code = "tool_error"
37
+
38
+
39
+ class ToolValidationError(AgentProofToolError):
40
+ code = "validation_error"
41
+
42
+
43
+ class UnsupportedMutationError(AgentProofError):
44
+ pass
45
+
46
+
47
+ class MutationSpec(BaseModel):
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ type: str
51
+ target: str | None = None
52
+ occurrence: int | None = 1
53
+ params: dict[str, Any] = Field(default_factory=dict)
54
+ severity: Severity = "medium"
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class PreCallAction:
59
+ return_value: Any = None
60
+ exception: AgentProofToolError | None = None
61
+ handled: bool = False
62
+
63
+
64
+ class FaultController:
65
+ def __init__(self, world: Any) -> None:
66
+ self._world = world
67
+ self._mutations: list[MutationSpec] = []
68
+ self._counts: dict[tuple[str, str, str | None], int] = {}
69
+
70
+ @property
71
+ def mutations(self) -> list[MutationSpec]:
72
+ return list(self._mutations)
73
+
74
+ def install(self, spec: MutationSpec) -> None:
75
+ if spec.type == "reorder_tool_results":
76
+ raise UnsupportedMutationError(
77
+ "reorder_tool_results requires a controlled parallel tool-result scheduler and "
78
+ "is not stable in this MVP"
79
+ )
80
+ self._mutations.append(spec)
81
+
82
+ def prepare_user_inputs(self, inputs: list[str]) -> list[str]:
83
+ spec = self._match("before_agent_input", None, "duplicate_user_request")
84
+ if spec is None:
85
+ return list(inputs)
86
+ if not inputs:
87
+ return []
88
+ index = int(spec.params.get("index", 0))
89
+ index = min(max(index, 0), len(inputs) - 1)
90
+ duplicated = list(inputs)
91
+ duplicated.insert(index + 1, inputs[index])
92
+ self._world.trace.record(
93
+ "fault",
94
+ "duplicate_user_request",
95
+ {"mutation": spec.model_dump(mode="json"), "input": inputs[index]},
96
+ )
97
+ self._world.trace.record("user_input", "user", {"text": inputs[index], "duplicate": True})
98
+ return duplicated
99
+
100
+ def before_tool_call(
101
+ self,
102
+ *,
103
+ tool_name: str,
104
+ args: dict[str, Any],
105
+ invocation_id: str,
106
+ ) -> PreCallAction | None:
107
+ del args
108
+ latency = self._match("before_tool_call", tool_name, "tool_latency")
109
+ if latency is not None:
110
+ seconds = float(latency.params.get("seconds", 1.0))
111
+ self._world.clock.advance(seconds)
112
+ self._world.trace.record(
113
+ "fault",
114
+ "tool_latency",
115
+ {
116
+ "mutation": latency.model_dump(mode="json"),
117
+ "invocation_id": invocation_id,
118
+ "advanced_seconds": seconds,
119
+ },
120
+ )
121
+ return None
122
+
123
+ timeout = self._match("before_tool_call", tool_name, "tool_timeout")
124
+ if timeout is not None:
125
+ self._world.trace.record(
126
+ "fault",
127
+ "tool_timeout",
128
+ {"mutation": timeout.model_dump(mode="json"), "invocation_id": invocation_id},
129
+ )
130
+ return PreCallAction(
131
+ exception=ToolTimeoutError(tool_name, "tool timed out before commit"),
132
+ handled=True,
133
+ )
134
+
135
+ injected = self._match("before_tool_call", tool_name, "tool_error")
136
+ if injected is not None:
137
+ self._world.trace.record(
138
+ "fault",
139
+ "tool_error",
140
+ {"mutation": injected.model_dump(mode="json"), "invocation_id": invocation_id},
141
+ )
142
+ message = str(injected.params.get("message", "injected tool error"))
143
+ return PreCallAction(exception=ToolInjectedError(tool_name, message), handled=True)
144
+
145
+ rate_limited = self._match("before_tool_call", tool_name, "rate_limited")
146
+ if rate_limited is not None:
147
+ self._world.trace.record(
148
+ "fault",
149
+ "rate_limited",
150
+ {"mutation": rate_limited.model_dump(mode="json"), "invocation_id": invocation_id},
151
+ )
152
+ return PreCallAction(
153
+ exception=ToolRateLimitError(tool_name, "tool call was rate limited"),
154
+ handled=True,
155
+ )
156
+
157
+ denied = self._match("before_tool_call", tool_name, "permission_denied")
158
+ if denied is not None:
159
+ self._world.trace.record(
160
+ "fault",
161
+ "permission_denied",
162
+ {"mutation": denied.model_dump(mode="json"), "invocation_id": invocation_id},
163
+ )
164
+ return PreCallAction(
165
+ exception=ToolPermissionDeniedError(tool_name, "permission denied"),
166
+ handled=True,
167
+ )
168
+
169
+ stale = self._match("before_tool_call", tool_name, "stale_state")
170
+ if stale is not None:
171
+ if "value" not in stale.params:
172
+ raise UnsupportedMutationError("stale_state requires params['value']")
173
+ self._world.trace.record(
174
+ "fault",
175
+ "stale_state",
176
+ {"mutation": stale.model_dump(mode="json"), "invocation_id": invocation_id},
177
+ )
178
+ return PreCallAction(return_value=stale.params["value"], handled=True)
179
+
180
+ return None
181
+
182
+ def after_commit_before_response(self, *, tool_name: str, invocation_id: str) -> None:
183
+ spec = self._match("after_commit_before_response", tool_name, "timeout_after_commit")
184
+ if spec is None:
185
+ return
186
+ self._world.trace.record(
187
+ "fault",
188
+ "timeout_after_commit",
189
+ {"mutation": spec.model_dump(mode="json"), "invocation_id": invocation_id},
190
+ )
191
+ raise ToolTimeoutError(tool_name, "tool timed out after commit")
192
+
193
+ def after_tool_response(
194
+ self,
195
+ *,
196
+ tool_name: str,
197
+ invocation_id: str,
198
+ response: Any,
199
+ ) -> Any:
200
+ malformed = self._match("after_tool_response", tool_name, "malformed_response")
201
+ if malformed is not None:
202
+ self._world.trace.record(
203
+ "fault",
204
+ "malformed_response",
205
+ {"mutation": malformed.model_dump(mode="json"), "invocation_id": invocation_id},
206
+ )
207
+ return malformed.params.get("value", "<<agentproof:malformed-response>>")
208
+
209
+ missing = self._match("after_tool_response", tool_name, "missing_field")
210
+ if missing is not None:
211
+ field = missing.params.get("field")
212
+ if not isinstance(field, str):
213
+ raise UnsupportedMutationError("missing_field requires params['field']")
214
+ self._world.trace.record(
215
+ "fault",
216
+ "missing_field",
217
+ {
218
+ "mutation": missing.model_dump(mode="json"),
219
+ "invocation_id": invocation_id,
220
+ "field": field,
221
+ },
222
+ )
223
+ if isinstance(response, dict):
224
+ cloned = dict(response)
225
+ cloned.pop(field, None)
226
+ return cloned
227
+ return response
228
+
229
+ duplicate = self._match("after_tool_response", tool_name, "duplicate_tool_result")
230
+ if duplicate is not None:
231
+ self._world.trace.record(
232
+ "fault",
233
+ "duplicate_tool_result",
234
+ {"mutation": duplicate.model_dump(mode="json"), "invocation_id": invocation_id},
235
+ )
236
+ return {"agentproof_duplicate_tool_result": [response, response]}
237
+
238
+ changed = self._match("after_tool_response", tool_name, "state_changed_after_read")
239
+ if changed is not None:
240
+ path = changed.params.get("path")
241
+ if not isinstance(path, list) or not path:
242
+ raise UnsupportedMutationError("state_changed_after_read requires params['path']")
243
+ value = changed.params.get("value")
244
+ self._world.set_path(path, value)
245
+ self._world.trace.record(
246
+ "fault",
247
+ "state_changed_after_read",
248
+ {
249
+ "mutation": changed.model_dump(mode="json"),
250
+ "invocation_id": invocation_id,
251
+ "path": path,
252
+ "value": value,
253
+ },
254
+ )
255
+ return response
256
+
257
+ return response
258
+
259
+ def before_event_delivery(self, event: Any) -> bool:
260
+ delayed = self._match("before_event_delivery", event.name, "delayed_event")
261
+ if delayed is None:
262
+ return False
263
+ delay = float(delayed.params.get("seconds", 1.0))
264
+ event.deliver_at += delay
265
+ self._world.trace.record(
266
+ "fault",
267
+ "delayed_event",
268
+ {"mutation": delayed.model_dump(mode="json"), "event_id": event.id, "seconds": delay},
269
+ )
270
+ return True
271
+
272
+ def duplicate_event_count(self, event: Any) -> int:
273
+ duplicate = self._match("on_event_delivery", event.name, "duplicate_event")
274
+ if duplicate is None:
275
+ return 1
276
+ count = int(duplicate.params.get("count", 2))
277
+ self._world.trace.record(
278
+ "fault",
279
+ "duplicate_event",
280
+ {"mutation": duplicate.model_dump(mode="json"), "event_id": event.id, "count": count},
281
+ )
282
+ return max(count, 1)
283
+
284
+ def _match(self, stage: str, target: str | None, mutation_type: str) -> MutationSpec | None:
285
+ for spec in self._mutations:
286
+ if spec.type != mutation_type:
287
+ continue
288
+ if spec.target is not None and spec.target != target:
289
+ continue
290
+ key = (stage, mutation_type, spec.target)
291
+ self._counts[key] = self._counts.get(key, 0) + 1
292
+ if spec.occurrence is None or self._counts[key] == spec.occurrence:
293
+ return spec
294
+ return None
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import traceback
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from agentproof.core.result import InvariantFailure
10
+
11
+ InvariantFunc = Callable[[Any], Awaitable[None] | None]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Invariant:
16
+ name: str
17
+ func: InvariantFunc
18
+ severity: str = "high"
19
+
20
+ async def check(self, world: Any) -> InvariantFailure | None:
21
+ try:
22
+ result = self.func(world)
23
+ if inspect.isawaitable(result):
24
+ await result
25
+ except AssertionError as exc:
26
+ return InvariantFailure(
27
+ name=self.name,
28
+ message=str(exc) or "assertion failed",
29
+ exception_type=type(exc).__name__,
30
+ traceback="".join(
31
+ traceback.format_exception(type(exc), exc, exc.__traceback__, limit=6)
32
+ ),
33
+ )
34
+ return None
35
+
36
+ async def check_programming_error(self, world: Any) -> InvariantFailure | None:
37
+ try:
38
+ result = self.func(world)
39
+ if inspect.isawaitable(result):
40
+ await result
41
+ except AssertionError:
42
+ raise
43
+ except Exception as exc:
44
+ return InvariantFailure(
45
+ name=self.name,
46
+ message=str(exc),
47
+ exception_type=type(exc).__name__,
48
+ traceback="".join(
49
+ traceback.format_exception(type(exc), exc, exc.__traceback__, limit=6)
50
+ ),
51
+ )
52
+ return None
53
+
54
+
55
+ def invariant(
56
+ func: InvariantFunc | None = None, *, name: str | None = None, severity: str = "high"
57
+ ) -> Any:
58
+ def wrap(inner: InvariantFunc) -> Invariant:
59
+ return Invariant(name=name or inner.__name__, func=inner, severity=severity)
60
+
61
+ if func is None:
62
+ return wrap
63
+ return wrap(func)
64
+
65
+
66
+ async def evaluate_invariants(
67
+ invariants: list[Invariant],
68
+ world: Any,
69
+ ) -> tuple[list[InvariantFailure], list[InvariantFailure]]:
70
+ assertion_failures: list[InvariantFailure] = []
71
+ programming_errors: list[InvariantFailure] = []
72
+ for item in invariants:
73
+ try:
74
+ result = item.func(world)
75
+ if inspect.isawaitable(result):
76
+ await result
77
+ except AssertionError as exc:
78
+ failure = InvariantFailure(
79
+ name=item.name,
80
+ message=str(exc) or "assertion failed",
81
+ exception_type=type(exc).__name__,
82
+ traceback="".join(
83
+ traceback.format_exception(type(exc), exc, exc.__traceback__, limit=6)
84
+ ),
85
+ )
86
+ assertion_failures.append(failure)
87
+ world.trace.record(
88
+ "invariant_fail",
89
+ item.name,
90
+ {"message": failure.message, "exception_type": failure.exception_type},
91
+ )
92
+ except Exception as exc:
93
+ failure = InvariantFailure(
94
+ name=item.name,
95
+ message=str(exc),
96
+ exception_type=type(exc).__name__,
97
+ traceback="".join(
98
+ traceback.format_exception(type(exc), exc, exc.__traceback__, limit=6)
99
+ ),
100
+ )
101
+ programming_errors.append(failure)
102
+ world.trace.record(
103
+ "invariant_fail",
104
+ item.name,
105
+ {"message": failure.message, "exception_type": failure.exception_type},
106
+ )
107
+ else:
108
+ world.trace.record("invariant_pass", item.name, {})
109
+ return assertion_failures, programming_errors
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+ SECRET_KEY_FRAGMENTS = ("api_key", "authorization", "token", "password", "secret")
7
+ SECRET_STRING_PREFIXES = ("sk-", "Bearer ")
8
+ REDACTED = "[REDACTED]"
9
+
10
+
11
+ def redact(value: Any) -> Any:
12
+ """Return a JSON-safe copy with obvious secrets removed."""
13
+
14
+ if isinstance(value, Mapping):
15
+ redacted: dict[str, Any] = {}
16
+ for key, item in value.items():
17
+ key_text = str(key)
18
+ if _is_secret_key(key_text):
19
+ redacted[key_text] = REDACTED
20
+ else:
21
+ redacted[key_text] = redact(item)
22
+ return redacted
23
+ if isinstance(value, str):
24
+ return REDACTED if value.startswith(SECRET_STRING_PREFIXES) else value
25
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
26
+ return [redact(item) for item in value]
27
+ return value
28
+
29
+
30
+ def _is_secret_key(key: str) -> bool:
31
+ lowered = key.lower().replace("-", "_")
32
+ return any(fragment in lowered for fragment in SECRET_KEY_FRAGMENTS)
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from agentproof.core.effects import Effect
9
+ from agentproof.core.faults import MutationSpec, Severity
10
+ from agentproof.core.trace import TraceEvent
11
+
12
+ RunStatus = Literal[
13
+ "PASS",
14
+ "INVARIANT_FAILURE",
15
+ "BASELINE_FAILURE",
16
+ "AGENT_ERROR",
17
+ "TOOL_ERROR_EXPECTED",
18
+ "TEST_ERROR",
19
+ "ADAPTER_ERROR",
20
+ "UNSUPPORTED",
21
+ "SKIPPED",
22
+ ]
23
+
24
+ SEVERITY_ORDER: dict[Severity, int] = {
25
+ "low": 0,
26
+ "medium": 1,
27
+ "high": 2,
28
+ "critical": 3,
29
+ }
30
+
31
+
32
+ class InvariantFailure(BaseModel):
33
+ model_config = ConfigDict(extra="forbid")
34
+
35
+ name: str
36
+ message: str
37
+ exception_type: str
38
+ traceback: str
39
+
40
+
41
+ class RunResult(BaseModel):
42
+ model_config = ConfigDict(arbitrary_types_allowed=True)
43
+
44
+ run_id: str
45
+ scenario: str
46
+ adapter: str
47
+ seed: int
48
+ mutation: MutationSpec | None = None
49
+ severity: Severity = "medium"
50
+ status: RunStatus = "PASS"
51
+ final_output: str | None = None
52
+ trace: list[TraceEvent] = Field(default_factory=list)
53
+ effects: list[Effect] = Field(default_factory=list)
54
+ violated_invariants: list[str] = Field(default_factory=list)
55
+ invariant_failures: list[InvariantFailure] = Field(default_factory=list)
56
+ error_message: str | None = None
57
+ artifact_path: Path | None = None
58
+ initial_world_snapshot: dict[str, Any] = Field(default_factory=dict)
59
+ metadata: dict[str, Any] = Field(default_factory=dict)
60
+
61
+ @property
62
+ def failed(self) -> bool:
63
+ return self.status in {
64
+ "INVARIANT_FAILURE",
65
+ "BASELINE_FAILURE",
66
+ "AGENT_ERROR",
67
+ "TEST_ERROR",
68
+ "ADAPTER_ERROR",
69
+ "UNSUPPORTED",
70
+ }
71
+
72
+ @property
73
+ def name(self) -> str:
74
+ if self.mutation is None:
75
+ return f"{self.scenario}:baseline"
76
+ target = f":{self.mutation.target}" if self.mutation.target else ""
77
+ return f"{self.scenario}:{self.mutation.type}{target}"
78
+
79
+
80
+ class SuiteResult(BaseModel):
81
+ model_config = ConfigDict(arbitrary_types_allowed=True)
82
+
83
+ schema_version: int = 1
84
+ run_id: str
85
+ results: list[RunResult]
86
+ metadata: dict[str, Any] = Field(default_factory=dict)
87
+
88
+ @property
89
+ def failures(self) -> list[RunResult]:
90
+ return [result for result in self.results if result.failed]
91
+
92
+ @property
93
+ def failed_count(self) -> int:
94
+ return len(self.failures)
95
+
96
+ @property
97
+ def passed_count(self) -> int:
98
+ return len([result for result in self.results if result.status == "PASS"])
99
+
100
+ @property
101
+ def total_count(self) -> int:
102
+ return len(self.results)
103
+
104
+ def exit_code(self, fail_on: Severity = "high") -> int:
105
+ threshold = SEVERITY_ORDER[fail_on]
106
+ for result in self.failures:
107
+ if SEVERITY_ORDER[result.severity] >= threshold:
108
+ return 1
109
+ return 0