consequence-gate 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/PKG-INFO +65 -28
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/README.md +59 -22
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/__init__.py +1 -1
- consequence_gate-0.1.2/consequence_gate/backtest/harness.py +62 -0
- consequence_gate-0.1.2/consequence_gate/backtest/reporter.py +29 -0
- consequence_gate-0.1.2/consequence_gate/cli.py +160 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/core/circuit_breaker.py +8 -6
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/core/evaluator.py +5 -5
- consequence_gate-0.1.2/consequence_gate/core/models.py +36 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/integrations/examples/run_langgraph.py +4 -2
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/integrations/examples/run_mcp_proxy.py +11 -6
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/integrations/langgraph_hook.py +22 -13
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/integrations/mcp_proxy.py +23 -17
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/integrations/strands_hook.py +18 -11
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/simulators/communications.py +10 -10
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/simulators/database.py +29 -18
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/simulators/financial.py +37 -20
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate.egg-info/PKG-INFO +65 -28
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate.egg-info/requires.txt +5 -5
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/pyproject.toml +10 -9
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/tests/test_circuit_breaker.py +4 -2
- consequence_gate-0.1.2/tests/test_cli.py +45 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/tests/test_communications_sim.py +5 -6
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/tests/test_database_sim.py +1 -1
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/tests/test_financial_sim.py +6 -3
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/tests/test_langgraph_hook.py +61 -32
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/tests/test_mcp_proxy.py +27 -10
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/tests/test_strands_hook.py +4 -6
- consequence_gate-0.1.0/consequence_gate/backtest/harness.py +0 -43
- consequence_gate-0.1.0/consequence_gate/backtest/reporter.py +0 -20
- consequence_gate-0.1.0/consequence_gate/cli.py +0 -75
- consequence_gate-0.1.0/consequence_gate/core/models.py +0 -35
- consequence_gate-0.1.0/tests/test_cli.py +0 -34
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/backtest/__init__.py +0 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/core/__init__.py +0 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/integrations/__init__.py +0 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate/simulators/__init__.py +0 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate.egg-info/SOURCES.txt +0 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate.egg-info/dependency_links.txt +0 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate.egg-info/entry_points.txt +0 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/consequence_gate.egg-info/top_level.txt +0 -0
- {consequence_gate-0.1.0 → consequence_gate-0.1.2}/setup.cfg +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: consequence-gate
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Speculative outcome-simulation layer for AI agent tool calls — predicts consequence (blast radius, irreversibility, velocity) before execution and steers agents toward safer alternatives.
|
|
5
|
-
Author-email: Anandkrishnan Shnn <anandkrshnn@
|
|
5
|
+
Author-email: Anandkrishnan Shnn <anandkrshnn@outlook.com>
|
|
6
6
|
License: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/anandkrshnn-ai/consequence-gate
|
|
8
8
|
Project-URL: Documentation, https://github.com/anandkrshnn-ai/consequence-gate#readme
|
|
@@ -21,34 +21,42 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
21
21
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
|
-
Requires-Dist: langchain>=0.3.0
|
|
25
|
-
Requires-Dist: langchain-agents>=0.3.0
|
|
26
24
|
Provides-Extra: dev
|
|
27
25
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
26
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
29
27
|
Requires-Dist: black>=23.0; extra == "dev"
|
|
30
28
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
31
|
-
Provides-Extra: strands
|
|
32
|
-
Requires-Dist: strands-agents>=0.1.0; extra == "strands"
|
|
33
29
|
Provides-Extra: mcp
|
|
34
30
|
Provides-Extra: langgraph
|
|
35
31
|
Requires-Dist: langgraph>=0.2.0; extra == "langgraph"
|
|
32
|
+
Requires-Dist: langchain-core>=0.3.0; extra == "langgraph"
|
|
33
|
+
Provides-Extra: langchain
|
|
34
|
+
Requires-Dist: langchain>=0.3.0; extra == "langchain"
|
|
35
|
+
Requires-Dist: langchain-core>=0.3.0; extra == "langchain"
|
|
36
36
|
|
|
37
37
|
# consequence-gate
|
|
38
38
|
|
|
39
|
+
[](https://github.com/anandkrshnn-ai/consequence-gate/actions/workflows/ci.yml)
|
|
40
|
+
[](https://pypi.org/project/consequence-gate/)
|
|
41
|
+
[](LICENSE)
|
|
42
|
+
[](tests/)
|
|
43
|
+
[](tests/)
|
|
44
|
+
|
|
45
|
+
**[Try the interactive demo ⚡](https://consequence-gate-demo-mu.vercel.app/)**
|
|
46
|
+
|
|
39
47
|
A speculative outcome-simulation layer for AI agent tool calls. It sits
|
|
40
48
|
**upstream** of static runtime access gates (AgentWall, AWS Strands
|
|
41
49
|
`BeforeToolCallEvent`, MCP proxies, Prisma AIRS) and asks a different
|
|
42
50
|
question than they do.
|
|
43
51
|
|
|
44
|
-
Static gates ask: *does this call match an allowed pattern?*
|
|
52
|
+
Static gates ask: *does this call match an allowed pattern or schema?*
|
|
45
53
|
`consequence-gate` asks: *what will this call actually do, and is that
|
|
46
54
|
outcome safe?*
|
|
47
55
|
|
|
48
56
|
## Why this exists
|
|
49
57
|
|
|
50
58
|
Static runtime gates are fast (sub-millisecond) and effective at schema
|
|
51
|
-
validation, RBAC, and pattern matching
|
|
59
|
+
validation, RBAC, and pattern matching — but a schema-valid,
|
|
52
60
|
policy-compliant call can still be consequence-catastrophic. A
|
|
53
61
|
`process_claim(amount=50000)` call can pass every static check while
|
|
54
62
|
pushing an account over its daily velocity limit via an irreversible
|
|
@@ -59,8 +67,8 @@ either passes it through, asks a human, denies it outright, or steers
|
|
|
59
67
|
the agent toward a pre-vetted safer alternative.
|
|
60
68
|
|
|
61
69
|
This is explicitly **not** a replacement for AgentWall / Strands / MCP
|
|
62
|
-
proxies
|
|
63
|
-
|
|
70
|
+
proxies — it is an outcome prediction layer that runs upstream of them in the same pipeline.
|
|
71
|
+
|
|
64
72
|
|
|
65
73
|
## Core contracts
|
|
66
74
|
|
|
@@ -221,34 +229,63 @@ agent = Agent(hooks=[hook])
|
|
|
221
229
|
## Decision Matrix
|
|
222
230
|
|
|
223
231
|
| Decision | Strands | MCP | LangGraph |
|
|
224
|
-
|
|
232
|
+
|---|---|---|---|
|
|
225
233
|
| `ALLOW` | Executes normally | Forwarded to downstream MCP server | Tool executes via handler(request) |
|
|
226
234
|
| `DENY` | `BLOCKED: <reason>` | JSON-RPC error (code=-32603) | Raises `ValueError("BLOCKED: ...")` |
|
|
227
235
|
| `ASK` | `ESCALATION_REQUIRED: <reason>` | `isError=true` tool result | Raises `ValueError("ESCALATION_REQUIRED: ...")` |
|
|
228
236
|
| `STEER` | `STEER_GUIDANCE: <guidance>\nSuggested alternative...` | `isError=true` + guidance | `ToolMessage(content="STEER_GUIDANCE: ...", status="error")` |
|
|
229
237
|
|
|
230
|
-
##
|
|
238
|
+
## Trust Model & Failure Modes
|
|
239
|
+
|
|
240
|
+
`consequence-gate` evaluates consequence by combining tool invocation parameters with runtime state returned from your `context_provider` callback (e.g. `account_rolling_24h_spend`, `kyc_verified`, `table_metadata`).
|
|
241
|
+
|
|
242
|
+
### Critical Failure Modes & Mitigations
|
|
243
|
+
|
|
244
|
+
| Failure Mode | Risk | Mitigation in Consequence-Gate |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| **Stale Context / Cache Lag** | Context provider returns yesterday's spend balance, potentially missing velocity breaches. | If context is unverified or confidence drops below `0.8`, the gate **always defaults to `ASK` (human escalation)**. It never grants a speculative `ALLOW`. |
|
|
247
|
+
| **Missing Context Provider** | Tool is invoked without any environment or database connection. | The simulator scores confidence as `0.0` or `0.5` and raises an escalation requirement (`ASK`). |
|
|
248
|
+
| **Agent Steering Thrashing** | The agent repeatedly submits non-compliant alternative calls in response to guidance. | The `SteerCircuitBreaker` enforces a hard retry cap (default: 2 retries) before terminating the loop and escalating to human review. |
|
|
249
|
+
| **Replay / Network Retries** | Network timeout causes agent runtime to resubmit the identical tool call. | Idempotency tokens are deterministically keyed to the entity's natural business key, guaranteeing identical evaluation without incrementing velocity counters twice. |
|
|
250
|
+
|
|
251
|
+
See [SECURITY.md](SECURITY.md) for full trust boundary documentation.
|
|
252
|
+
|
|
253
|
+
## Offline Backtest Benchmark
|
|
254
|
+
|
|
255
|
+
Before deploying to production, run an offline backtest against historical execution traces to measure the four-quadrant FP/FN/TN breakdown:
|
|
231
256
|
|
|
232
|
-
|
|
233
|
-
|
|
257
|
+
```bash
|
|
258
|
+
# Run backtest on the bundled 500-trace synthetic benchmark dataset
|
|
259
|
+
consequence-gate backtest examples/benchmark_traces.jsonl
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Empirical Benchmark Summary (500 Synthetic Traces)
|
|
263
|
+
|
|
264
|
+
*(Note: Evaluated on a synthetically generated trace corpus `examples/benchmark_traces.jsonl` to validate harness mechanics end-to-end; see [BACKTEST_RESULTS.md](BACKTEST_RESULTS.md) for disclosure)*
|
|
234
265
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
- False Negative Caught: schema-valid calls that would have breached limits
|
|
240
|
-
- False Positive Relieved: over-blocking that the simulator would have avoided
|
|
241
|
-
- Steer Recovery Rate: percentage of blocked turns that could have completed via guidance
|
|
266
|
+
- **Benign Pass-Through (True Negatives):** 252 (50.4%) — Benign operations passed through.
|
|
267
|
+
- **Downstream Hazards Intercepted:** 129 (25.8%) — Schema-valid hazards caught before execution (100% recall [129/129] on this synthetic corpus).
|
|
268
|
+
- **Over-Blocked Operations Relieved:** 35 (7.0%) — Benign calls over-blocked by naive regex gates safely enabled.
|
|
269
|
+
- **Ambiguous Escalations:** 84 (16.8%) — Low-confidence/unrecognized calls routed to `ASK`.
|
|
242
270
|
|
|
243
|
-
|
|
271
|
+
Detailed breakdown and reproduction steps: [BACKTEST_RESULTS.md](BACKTEST_RESULTS.md) | [BACKTEST_METHODOLOGY.md](BACKTEST_METHODOLOGY.md)
|
|
244
272
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
- **
|
|
248
|
-
- **
|
|
249
|
-
- **
|
|
250
|
-
- **
|
|
273
|
+
## Status & Test Coverage
|
|
274
|
+
|
|
275
|
+
- **Financial simulator**: functional with unit tests (`tests/test_financial_sim.py`, 96% coverage)
|
|
276
|
+
- **Database simulator**: functional with unit tests (`tests/test_database_sim.py`)
|
|
277
|
+
- **Communications simulator**: functional with unit tests (`tests/test_communications_sim.py`, 90% coverage)
|
|
278
|
+
- **Circuit breaker & natural-key idempotency**: functional with unit tests (`tests/test_circuit_breaker.py`, 95% coverage)
|
|
279
|
+
- **Strands integration**: functional with unit tests (`tests/test_strands_hook.py`, 91% coverage)
|
|
280
|
+
- **MCP integration**: functional with unit tests (`tests/test_mcp_proxy.py`)
|
|
281
|
+
- **LangGraph integration**: functional with unit tests (`tests/test_langgraph_hook.py`)
|
|
282
|
+
- **CLI & Backtesting**: functional with unit tests (`tests/test_cli.py`)
|
|
283
|
+
|
|
284
|
+
## Contributing
|
|
285
|
+
|
|
286
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture guidelines, coding conventions, and instructions on creating new consequence simulators.
|
|
251
287
|
|
|
252
288
|
## License
|
|
253
289
|
|
|
254
290
|
Apache-2.0
|
|
291
|
+
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
# consequence-gate
|
|
2
2
|
|
|
3
|
+
[](https://github.com/anandkrshnn-ai/consequence-gate/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/consequence-gate/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](tests/)
|
|
7
|
+
[](tests/)
|
|
8
|
+
|
|
9
|
+
**[Try the interactive demo ⚡](https://consequence-gate-demo-mu.vercel.app/)**
|
|
10
|
+
|
|
3
11
|
A speculative outcome-simulation layer for AI agent tool calls. It sits
|
|
4
12
|
**upstream** of static runtime access gates (AgentWall, AWS Strands
|
|
5
13
|
`BeforeToolCallEvent`, MCP proxies, Prisma AIRS) and asks a different
|
|
6
14
|
question than they do.
|
|
7
15
|
|
|
8
|
-
Static gates ask: *does this call match an allowed pattern?*
|
|
16
|
+
Static gates ask: *does this call match an allowed pattern or schema?*
|
|
9
17
|
`consequence-gate` asks: *what will this call actually do, and is that
|
|
10
18
|
outcome safe?*
|
|
11
19
|
|
|
12
20
|
## Why this exists
|
|
13
21
|
|
|
14
22
|
Static runtime gates are fast (sub-millisecond) and effective at schema
|
|
15
|
-
validation, RBAC, and pattern matching
|
|
23
|
+
validation, RBAC, and pattern matching — but a schema-valid,
|
|
16
24
|
policy-compliant call can still be consequence-catastrophic. A
|
|
17
25
|
`process_claim(amount=50000)` call can pass every static check while
|
|
18
26
|
pushing an account over its daily velocity limit via an irreversible
|
|
@@ -23,8 +31,8 @@ either passes it through, asks a human, denies it outright, or steers
|
|
|
23
31
|
the agent toward a pre-vetted safer alternative.
|
|
24
32
|
|
|
25
33
|
This is explicitly **not** a replacement for AgentWall / Strands / MCP
|
|
26
|
-
proxies
|
|
27
|
-
|
|
34
|
+
proxies — it is an outcome prediction layer that runs upstream of them in the same pipeline.
|
|
35
|
+
|
|
28
36
|
|
|
29
37
|
## Core contracts
|
|
30
38
|
|
|
@@ -185,34 +193,63 @@ agent = Agent(hooks=[hook])
|
|
|
185
193
|
## Decision Matrix
|
|
186
194
|
|
|
187
195
|
| Decision | Strands | MCP | LangGraph |
|
|
188
|
-
|
|
196
|
+
|---|---|---|---|
|
|
189
197
|
| `ALLOW` | Executes normally | Forwarded to downstream MCP server | Tool executes via handler(request) |
|
|
190
198
|
| `DENY` | `BLOCKED: <reason>` | JSON-RPC error (code=-32603) | Raises `ValueError("BLOCKED: ...")` |
|
|
191
199
|
| `ASK` | `ESCALATION_REQUIRED: <reason>` | `isError=true` tool result | Raises `ValueError("ESCALATION_REQUIRED: ...")` |
|
|
192
200
|
| `STEER` | `STEER_GUIDANCE: <guidance>\nSuggested alternative...` | `isError=true` + guidance | `ToolMessage(content="STEER_GUIDANCE: ...", status="error")` |
|
|
193
201
|
|
|
194
|
-
##
|
|
202
|
+
## Trust Model & Failure Modes
|
|
203
|
+
|
|
204
|
+
`consequence-gate` evaluates consequence by combining tool invocation parameters with runtime state returned from your `context_provider` callback (e.g. `account_rolling_24h_spend`, `kyc_verified`, `table_metadata`).
|
|
205
|
+
|
|
206
|
+
### Critical Failure Modes & Mitigations
|
|
207
|
+
|
|
208
|
+
| Failure Mode | Risk | Mitigation in Consequence-Gate |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| **Stale Context / Cache Lag** | Context provider returns yesterday's spend balance, potentially missing velocity breaches. | If context is unverified or confidence drops below `0.8`, the gate **always defaults to `ASK` (human escalation)**. It never grants a speculative `ALLOW`. |
|
|
211
|
+
| **Missing Context Provider** | Tool is invoked without any environment or database connection. | The simulator scores confidence as `0.0` or `0.5` and raises an escalation requirement (`ASK`). |
|
|
212
|
+
| **Agent Steering Thrashing** | The agent repeatedly submits non-compliant alternative calls in response to guidance. | The `SteerCircuitBreaker` enforces a hard retry cap (default: 2 retries) before terminating the loop and escalating to human review. |
|
|
213
|
+
| **Replay / Network Retries** | Network timeout causes agent runtime to resubmit the identical tool call. | Idempotency tokens are deterministically keyed to the entity's natural business key, guaranteeing identical evaluation without incrementing velocity counters twice. |
|
|
214
|
+
|
|
215
|
+
See [SECURITY.md](SECURITY.md) for full trust boundary documentation.
|
|
216
|
+
|
|
217
|
+
## Offline Backtest Benchmark
|
|
218
|
+
|
|
219
|
+
Before deploying to production, run an offline backtest against historical execution traces to measure the four-quadrant FP/FN/TN breakdown:
|
|
195
220
|
|
|
196
|
-
|
|
197
|
-
|
|
221
|
+
```bash
|
|
222
|
+
# Run backtest on the bundled 500-trace synthetic benchmark dataset
|
|
223
|
+
consequence-gate backtest examples/benchmark_traces.jsonl
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Empirical Benchmark Summary (500 Synthetic Traces)
|
|
227
|
+
|
|
228
|
+
*(Note: Evaluated on a synthetically generated trace corpus `examples/benchmark_traces.jsonl` to validate harness mechanics end-to-end; see [BACKTEST_RESULTS.md](BACKTEST_RESULTS.md) for disclosure)*
|
|
198
229
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
- False Negative Caught: schema-valid calls that would have breached limits
|
|
204
|
-
- False Positive Relieved: over-blocking that the simulator would have avoided
|
|
205
|
-
- Steer Recovery Rate: percentage of blocked turns that could have completed via guidance
|
|
230
|
+
- **Benign Pass-Through (True Negatives):** 252 (50.4%) — Benign operations passed through.
|
|
231
|
+
- **Downstream Hazards Intercepted:** 129 (25.8%) — Schema-valid hazards caught before execution (100% recall [129/129] on this synthetic corpus).
|
|
232
|
+
- **Over-Blocked Operations Relieved:** 35 (7.0%) — Benign calls over-blocked by naive regex gates safely enabled.
|
|
233
|
+
- **Ambiguous Escalations:** 84 (16.8%) — Low-confidence/unrecognized calls routed to `ASK`.
|
|
206
234
|
|
|
207
|
-
|
|
235
|
+
Detailed breakdown and reproduction steps: [BACKTEST_RESULTS.md](BACKTEST_RESULTS.md) | [BACKTEST_METHODOLOGY.md](BACKTEST_METHODOLOGY.md)
|
|
208
236
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
- **
|
|
212
|
-
- **
|
|
213
|
-
- **
|
|
214
|
-
- **
|
|
237
|
+
## Status & Test Coverage
|
|
238
|
+
|
|
239
|
+
- **Financial simulator**: functional with unit tests (`tests/test_financial_sim.py`, 96% coverage)
|
|
240
|
+
- **Database simulator**: functional with unit tests (`tests/test_database_sim.py`)
|
|
241
|
+
- **Communications simulator**: functional with unit tests (`tests/test_communications_sim.py`, 90% coverage)
|
|
242
|
+
- **Circuit breaker & natural-key idempotency**: functional with unit tests (`tests/test_circuit_breaker.py`, 95% coverage)
|
|
243
|
+
- **Strands integration**: functional with unit tests (`tests/test_strands_hook.py`, 91% coverage)
|
|
244
|
+
- **MCP integration**: functional with unit tests (`tests/test_mcp_proxy.py`)
|
|
245
|
+
- **LangGraph integration**: functional with unit tests (`tests/test_langgraph_hook.py`)
|
|
246
|
+
- **CLI & Backtesting**: functional with unit tests (`tests/test_cli.py`)
|
|
247
|
+
|
|
248
|
+
## Contributing
|
|
249
|
+
|
|
250
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture guidelines, coding conventions, and instructions on creating new consequence simulators.
|
|
215
251
|
|
|
216
252
|
## License
|
|
217
253
|
|
|
218
254
|
Apache-2.0
|
|
255
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline backtest harness: replays historical JSONL tool-call traces
|
|
3
|
+
through a simulator + evaluator, WITHOUT re-executing anything, to
|
|
4
|
+
measure the four-quadrant FP/FN/TN/steer-recovery breakdown against
|
|
5
|
+
the trace's recorded existing_gate_decision and actual_execution_status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_traces(path: str) -> list[dict]:
|
|
13
|
+
traces = []
|
|
14
|
+
with open(path) as f:
|
|
15
|
+
for line in f:
|
|
16
|
+
line = line.strip()
|
|
17
|
+
if line:
|
|
18
|
+
traces.append(json.loads(line))
|
|
19
|
+
return traces
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_backtest(
|
|
23
|
+
traces: Iterable[dict], simulate_and_evaluate: Callable[[dict], str]
|
|
24
|
+
) -> list[dict]:
|
|
25
|
+
"""
|
|
26
|
+
simulate_and_evaluate: function(trace) -> decision string ("ALLOW"/"DENY"/"ASK"/"STEER")
|
|
27
|
+
Returns per-trace records annotated with quadrant classification.
|
|
28
|
+
"""
|
|
29
|
+
results = []
|
|
30
|
+
for trace in traces:
|
|
31
|
+
new_decision = simulate_and_evaluate(trace)
|
|
32
|
+
gt = trace.get("ground_truth_consequence")
|
|
33
|
+
|
|
34
|
+
if gt == "UNSAFE":
|
|
35
|
+
if new_decision in ("DENY", "STEER", "ASK"):
|
|
36
|
+
quadrant = "FALSE_NEGATIVE_CAUGHT" # TP: Hazard Intercepted
|
|
37
|
+
else:
|
|
38
|
+
quadrant = "OTHER" # FN: Missed Hazard
|
|
39
|
+
elif gt == "SAFE":
|
|
40
|
+
if new_decision == "ALLOW":
|
|
41
|
+
quadrant = "TRUE_NEGATIVE" # TN: Benign Pass-Through
|
|
42
|
+
else:
|
|
43
|
+
quadrant = "FALSE_POSITIVE" # FP: Over-blocked
|
|
44
|
+
else:
|
|
45
|
+
# Fallback for traces lacking ground_truth_consequence
|
|
46
|
+
old_decision = trace.get("existing_gate_decision", "ALLOW")
|
|
47
|
+
outcome = trace.get("actual_execution_status", "UNKNOWN")
|
|
48
|
+
if (
|
|
49
|
+
old_decision == "ALLOW"
|
|
50
|
+
and new_decision in ("DENY", "STEER", "ASK")
|
|
51
|
+
and outcome != "SUCCESS"
|
|
52
|
+
):
|
|
53
|
+
quadrant = "FALSE_NEGATIVE_CAUGHT"
|
|
54
|
+
elif old_decision in ("DENY", "ASK") and new_decision == "ALLOW":
|
|
55
|
+
quadrant = "FALSE_POSITIVE_RELIEVED"
|
|
56
|
+
elif old_decision == "ALLOW" and new_decision == "ALLOW":
|
|
57
|
+
quadrant = "TRUE_NEGATIVE"
|
|
58
|
+
else:
|
|
59
|
+
quadrant = "OTHER"
|
|
60
|
+
|
|
61
|
+
results.append({**trace, "simulated_decision": new_decision, "quadrant": quadrant})
|
|
62
|
+
return results
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates the four-quadrant FP/FN breakdown report from backtest results.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_report(results: list[dict]) -> dict:
|
|
9
|
+
counts = Counter(r["quadrant"] for r in results)
|
|
10
|
+
total = len(results)
|
|
11
|
+
fn_caught = counts.get("FALSE_NEGATIVE_CAUGHT", 0)
|
|
12
|
+
fp_relieved = counts.get("FALSE_POSITIVE_RELIEVED", 0)
|
|
13
|
+
fp_caused = counts.get("FALSE_POSITIVE", 0)
|
|
14
|
+
return {
|
|
15
|
+
"total_traces": total,
|
|
16
|
+
"true_negative": counts.get("TRUE_NEGATIVE", 0),
|
|
17
|
+
"hazards_intercepted": fn_caught,
|
|
18
|
+
"overblocked_relieved": fp_relieved,
|
|
19
|
+
"false_positives_caused": fp_caused,
|
|
20
|
+
"other": counts.get("OTHER", 0),
|
|
21
|
+
"hazard_interception_rate": fn_caught / total if total else 0.0,
|
|
22
|
+
"overblock_relief_rate": fp_relieved / total if total else 0.0,
|
|
23
|
+
"false_positive_rate": fp_caused / total if total else 0.0,
|
|
24
|
+
# Backward-compatibility aliases
|
|
25
|
+
"false_negative_caught": fn_caught,
|
|
26
|
+
"false_positive_relieved": fp_relieved,
|
|
27
|
+
"false_negative_rate": fn_caught / total if total else 0.0,
|
|
28
|
+
"false_positive_relief_rate": fp_relieved / total if total else 0.0,
|
|
29
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
consequence-gate CLI: consequence simulation and backtest utilities for AI agent tool calls.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
consequence-gate --help
|
|
6
|
+
consequence-gate --version
|
|
7
|
+
consequence-gate backtest traces.jsonl [--json]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .backtest.harness import load_traces, run_backtest
|
|
17
|
+
from .backtest.reporter import generate_report
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def demo_evaluator(trace: dict) -> str:
|
|
21
|
+
"""Heuristic multi-domain demo evaluator calibrated for sample benchmark traces."""
|
|
22
|
+
tool = trace.get("tool_name", "")
|
|
23
|
+
args = trace.get("tool_args", {})
|
|
24
|
+
ctx = trace.get("session_context", {})
|
|
25
|
+
|
|
26
|
+
tool_lower = tool.lower()
|
|
27
|
+
args_str = str(args).lower()
|
|
28
|
+
|
|
29
|
+
# 1. Database blast-radius hazards
|
|
30
|
+
if any(k in tool_lower for k in ("delete", "drop", "purge", "truncate")) or any(
|
|
31
|
+
k in args_str for k in ("drop ", "delete from", "truncate ", "purge")
|
|
32
|
+
):
|
|
33
|
+
return "DENY"
|
|
34
|
+
|
|
35
|
+
# 2. Financial velocity & high-value escalations
|
|
36
|
+
if (
|
|
37
|
+
"transfer" in tool_lower
|
|
38
|
+
or "pay" in tool_lower
|
|
39
|
+
or "payout" in tool_lower
|
|
40
|
+
or "refund" in tool_lower
|
|
41
|
+
or "claim" in tool_lower
|
|
42
|
+
):
|
|
43
|
+
if "amount" in args:
|
|
44
|
+
amount_raw = args["amount"]
|
|
45
|
+
amount_str = str(amount_raw).strip().replace(",", "")
|
|
46
|
+
|
|
47
|
+
# NOTE: This parsing is adjusted to handle a European-style thousands-separator format
|
|
48
|
+
# (e.g. '15.000') found in this specific test corpus. This heuristic has not been
|
|
49
|
+
# validated against arbitrary real-world amount formats.
|
|
50
|
+
if "." in amount_str and len(amount_str.split(".")[-1]) == 3 and amount_str.count(".") == 1:
|
|
51
|
+
amount_str = amount_str.replace(".", "")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
amount = float(amount_str)
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
amount = 0
|
|
57
|
+
else:
|
|
58
|
+
# If a financial action doesn't have an amount, we skip the amount-based checks
|
|
59
|
+
# or could default to an ASK if it's a tool that requires it.
|
|
60
|
+
amount = None
|
|
61
|
+
|
|
62
|
+
spend = ctx.get("account_rolling_24h_spend", 0)
|
|
63
|
+
tier_limit = ctx.get("tier_limit", 25000)
|
|
64
|
+
|
|
65
|
+
if amount is not None:
|
|
66
|
+
# Deny negative or zero amount claims (malformed input / negative-value injection)
|
|
67
|
+
if amount <= 0:
|
|
68
|
+
return "DENY"
|
|
69
|
+
|
|
70
|
+
if spend + amount > tier_limit:
|
|
71
|
+
return "DENY"
|
|
72
|
+
if amount > 5000:
|
|
73
|
+
return "ASK"
|
|
74
|
+
|
|
75
|
+
# 3. Communications broadcast & suppression hazards
|
|
76
|
+
if any(
|
|
77
|
+
k in tool_lower
|
|
78
|
+
for k in ("broadcast", "campaign", "blast", "newsletter", "email", "sms", "notify")
|
|
79
|
+
):
|
|
80
|
+
recipients = args.get("recipient_count") or args.get("recipients_count") or 0
|
|
81
|
+
if recipients > 10000 or ctx.get("suppression_verified") is False:
|
|
82
|
+
return "DENY"
|
|
83
|
+
|
|
84
|
+
# 4. Unknown/ambiguous tools
|
|
85
|
+
if "unknown" in tool_lower or not tool:
|
|
86
|
+
return "ASK"
|
|
87
|
+
|
|
88
|
+
return "ALLOW"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Backwards compatibility alias
|
|
92
|
+
default_evaluator = demo_evaluator
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_backtest(args):
|
|
96
|
+
"""Run backtest on historical traces."""
|
|
97
|
+
traces_path = Path(args.traces_file)
|
|
98
|
+
if not traces_path.exists():
|
|
99
|
+
print(f"Error: {traces_path} not found", file=sys.stderr)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
traces = load_traces(str(traces_path))
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"Error loading traces: {e}", file=sys.stderr)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
results = run_backtest(traces, demo_evaluator)
|
|
109
|
+
report = generate_report(results)
|
|
110
|
+
|
|
111
|
+
if getattr(args, "json", False):
|
|
112
|
+
report_with_meta = {"evaluator": "demo_evaluator (synthetic benchmark heuristic)", **report}
|
|
113
|
+
print(json.dumps(report_with_meta, indent=2))
|
|
114
|
+
else:
|
|
115
|
+
print()
|
|
116
|
+
print("[!] NOTICE: Running with built-in demo_evaluator (synthetic trace heuristic).")
|
|
117
|
+
print(
|
|
118
|
+
" For production evaluation, pass domain simulator instances. See BACKTEST_RESULTS.md."
|
|
119
|
+
)
|
|
120
|
+
print()
|
|
121
|
+
print("================ CONSEQUENCE GATE BACKTEST REPORT ================")
|
|
122
|
+
print(f"Total Traces Evaluated: {report['total_traces']}")
|
|
123
|
+
print(f"Benign Pass-Through (TN): {report['true_negative']}")
|
|
124
|
+
print(f"Hazards Intercepted (TP): {report['hazards_intercepted']}")
|
|
125
|
+
print(f"Over-Blocked (FP): {report['false_positives_caused']}")
|
|
126
|
+
print(f"Over-Blocked Relieved: {report['overblocked_relieved']}")
|
|
127
|
+
print(f"Ambiguous / Missed (FN): {report['other']}")
|
|
128
|
+
print("-" * 66)
|
|
129
|
+
print(f"Hazard Interception Share: {report.get('hazard_interception_rate', report.get('false_negative_rate', 0)):.2%}")
|
|
130
|
+
print(f"False Positive Rate: {report.get('false_positive_rate', 0):.2%}")
|
|
131
|
+
print("=" * 66)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main():
|
|
135
|
+
parser = argparse.ArgumentParser(
|
|
136
|
+
prog="consequence-gate",
|
|
137
|
+
description="Speculative outcome-simulation gate & trace backtesting for AI agent tool calls.",
|
|
138
|
+
)
|
|
139
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
140
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
141
|
+
|
|
142
|
+
# Backtest subcommand
|
|
143
|
+
bt_parser = subparsers.add_parser(
|
|
144
|
+
"backtest", help="Run offline backtesting on a JSONL trace file"
|
|
145
|
+
)
|
|
146
|
+
bt_parser.add_argument("traces_file", help="Path to JSONL traces file")
|
|
147
|
+
bt_parser.add_argument("--json", action="store_true", help="Output results in JSON format")
|
|
148
|
+
bt_parser.set_defaults(func=cmd_backtest)
|
|
149
|
+
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
|
|
152
|
+
if args.command is None:
|
|
153
|
+
parser.print_help()
|
|
154
|
+
sys.exit(0)
|
|
155
|
+
|
|
156
|
+
args.func(args)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
main()
|
|
@@ -13,21 +13,23 @@ Design contract (see project history / design notes):
|
|
|
13
13
|
loop-thrashing, independent of guidance quality.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
from typing import Any
|
|
17
|
-
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .models import EvaluationResult, GateDecision
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class SteerCircuitBreaker:
|
|
21
22
|
def __init__(self, max_retries: int = 2):
|
|
22
23
|
self.max_retries = max_retries
|
|
23
|
-
self._attempts:
|
|
24
|
-
self._responses:
|
|
24
|
+
self._attempts: dict[str, int] = {}
|
|
25
|
+
self._responses: dict[str, EvaluationResult] = {}
|
|
25
26
|
|
|
26
27
|
def token_for(self, natural_key: str) -> str:
|
|
27
28
|
return f"steer_{natural_key}"
|
|
28
29
|
|
|
29
|
-
def resolve(
|
|
30
|
-
|
|
30
|
+
def resolve(
|
|
31
|
+
self, natural_key: str, confidence: float, base_steer: dict[str, Any]
|
|
32
|
+
) -> EvaluationResult:
|
|
31
33
|
token = self.token_for(natural_key)
|
|
32
34
|
|
|
33
35
|
if token in self._responses:
|
|
@@ -4,12 +4,13 @@ Domain simulators typically implement their own evaluate() with
|
|
|
4
4
|
domain-specific rules, but this provides a reusable default.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from .models import
|
|
7
|
+
from .models import GateDecision, SimulatedStateDelta
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class BlastRadiusEvaluator:
|
|
11
|
-
def __init__(
|
|
12
|
-
|
|
11
|
+
def __init__(
|
|
12
|
+
self, max_irreversible_value: float = 1000.0, min_confidence_for_autopass: float = 0.85
|
|
13
|
+
):
|
|
13
14
|
self.max_irreversible_value = max_irreversible_value
|
|
14
15
|
self.min_confidence_for_autopass = min_confidence_for_autopass
|
|
15
16
|
|
|
@@ -17,8 +18,7 @@ class BlastRadiusEvaluator:
|
|
|
17
18
|
if delta.confidence < self.min_confidence_for_autopass:
|
|
18
19
|
return GateDecision.ASK
|
|
19
20
|
|
|
20
|
-
breach = any(abs(v) > self.max_irreversible_value
|
|
21
|
-
for v in delta.numeric_deltas.values())
|
|
21
|
+
breach = any(abs(v) > self.max_irreversible_value for v in delta.numeric_deltas.values())
|
|
22
22
|
|
|
23
23
|
if delta.irreversibility_score >= 0.9 and breach:
|
|
24
24
|
return GateDecision.STEER
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data models shared across all domain simulators.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GateDecision(str, Enum):
|
|
11
|
+
ALLOW = "ALLOW"
|
|
12
|
+
DENY = "DENY"
|
|
13
|
+
ASK = "ASK"
|
|
14
|
+
STEER = "STEER"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SimulatedStateDelta:
|
|
19
|
+
"""Generic projected-outcome envelope. Domain simulators subclass or
|
|
20
|
+
populate this with their own numeric_deltas / side-effect semantics."""
|
|
21
|
+
|
|
22
|
+
tool_name: str
|
|
23
|
+
proposed_args: dict[str, Any]
|
|
24
|
+
numeric_deltas: dict[str, float] = field(default_factory=dict)
|
|
25
|
+
irreversibility_score: float = 0.0 # 0.0 fully reversible -> 1.0 irreversible
|
|
26
|
+
confidence: float = 0.0 # 0.0 -> 1.0, simulator's own confidence in this projection
|
|
27
|
+
simulated_side_effects: list[str] = field(default_factory=list)
|
|
28
|
+
natural_key: str | None = None # stable identity for idempotency (NOT a random token)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EvaluationResult:
|
|
33
|
+
decision: GateDecision
|
|
34
|
+
confidence: float
|
|
35
|
+
reason: str
|
|
36
|
+
steer_payload: dict[str, Any] | None = None
|
|
@@ -18,7 +18,9 @@ from consequence_gate.integrations.langgraph_hook import create_financial_gate_m
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
@tool
|
|
21
|
-
def process_claim(
|
|
21
|
+
def process_claim(
|
|
22
|
+
amount: float, claim_id: str, currency: str = "INR", payout_method: str = "standard_ach"
|
|
23
|
+
) -> str:
|
|
22
24
|
"""Process a claim payout."""
|
|
23
25
|
return f"Processed claim {claim_id} for {amount} {currency} via {payout_method}"
|
|
24
26
|
|
|
@@ -36,7 +38,7 @@ def main():
|
|
|
36
38
|
)
|
|
37
39
|
|
|
38
40
|
# Create agent with middleware
|
|
39
|
-
|
|
41
|
+
create_agent(
|
|
40
42
|
model="claude-sonnet-4",
|
|
41
43
|
tools=[process_claim],
|
|
42
44
|
middleware=[middleware],
|