runflect 0.1.0__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.
- runflect/__init__.py +26 -0
- runflect/__main__.py +5 -0
- runflect/bench/__init__.py +4 -0
- runflect/bench/fixtures.py +33 -0
- runflect/bench/metrics.py +91 -0
- runflect/bench/regression.py +320 -0
- runflect/bench/report.py +76 -0
- runflect/bench/runner.py +549 -0
- runflect/bench/schema.py +244 -0
- runflect/bench/suites/v0_1.json +106 -0
- runflect/cli.py +862 -0
- runflect/integrations/__init__.py +1 -0
- runflect/integrations/langgraph.py +453 -0
- runflect/integrations/opentelemetry.py +408 -0
- runflect/policy.py +553 -0
- runflect/policy_files.py +402 -0
- runflect/pricing.py +139 -0
- runflect/recorder.py +433 -0
- runflect/security_simulation.py +714 -0
- runflect/storage.py +220 -0
- runflect/supply_chain.py +875 -0
- runflect/terminal.py +85 -0
- runflect/web_api.py +1536 -0
- runflect/web_cli.py +9 -0
- runflect-0.1.0.dist-info/METADATA +720 -0
- runflect-0.1.0.dist-info/RECORD +29 -0
- runflect-0.1.0.dist-info/WHEEL +5 -0
- runflect-0.1.0.dist-info/entry_points.txt +3 -0
- runflect-0.1.0.dist-info/top_level.txt +1 -0
runflect/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Runflect: turn agent execution into actionable evidence."""
|
|
2
|
+
|
|
3
|
+
from .recorder import Run, Span, run
|
|
4
|
+
from .policy import (
|
|
5
|
+
Approval,
|
|
6
|
+
ApprovalAuthority,
|
|
7
|
+
ApprovalRequest,
|
|
8
|
+
ApprovalRequiredError,
|
|
9
|
+
AuditWriteError,
|
|
10
|
+
InvalidApprovalError,
|
|
11
|
+
PolicyConfigurationError,
|
|
12
|
+
PolicyEvaluationError,
|
|
13
|
+
ToolDecision,
|
|
14
|
+
ToolDenied,
|
|
15
|
+
ToolGuard,
|
|
16
|
+
ToolPolicy,
|
|
17
|
+
policy_audit_context,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Run", "Span", "run", "Approval", "ApprovalAuthority", "ApprovalRequest",
|
|
22
|
+
"ApprovalRequiredError", "AuditWriteError", "InvalidApprovalError",
|
|
23
|
+
"PolicyConfigurationError", "PolicyEvaluationError", "ToolDecision",
|
|
24
|
+
"ToolDenied", "ToolGuard", "ToolPolicy", "policy_audit_context",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.1.0"
|
runflect/__main__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Harmless deterministic tool fixtures used by the Bench suite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BenchmarkToolError(RuntimeError):
|
|
10
|
+
"""Expected, controlled tool failure used to test error propagation."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SideEffectCounter:
|
|
15
|
+
calls: int = 0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sync_fixture(counter: SideEffectCounter, behavior: str):
|
|
19
|
+
def tool(**_arguments: Any) -> str:
|
|
20
|
+
counter.calls += 1
|
|
21
|
+
if behavior == "raise":
|
|
22
|
+
raise BenchmarkToolError("controlled benchmark tool failure")
|
|
23
|
+
return "fixture-ok"
|
|
24
|
+
return tool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def async_fixture(counter: SideEffectCounter, behavior: str):
|
|
28
|
+
async def tool(**_arguments: Any) -> str:
|
|
29
|
+
counter.calls += 1
|
|
30
|
+
if behavior == "raise":
|
|
31
|
+
raise BenchmarkToolError("controlled benchmark tool failure")
|
|
32
|
+
return "fixture-ok"
|
|
33
|
+
return tool
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Precisely defined aggregate metrics for benchmark outcomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from statistics import median
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ratio(numerator: float, denominator: float) -> float | None:
|
|
11
|
+
return numerator / denominator if denominator else None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def latency_summary(samples_ms: Iterable[float]) -> dict[str, Any]:
|
|
15
|
+
values = [float(value) for value in samples_ms]
|
|
16
|
+
if not values:
|
|
17
|
+
return {"sample_count": 0, "median_ms": None, "p95_ms": None, "samples_ms": []}
|
|
18
|
+
if any(not math.isfinite(value) or value < 0 for value in values):
|
|
19
|
+
raise ValueError("latency samples must be finite and non-negative")
|
|
20
|
+
ordered = sorted(values)
|
|
21
|
+
p95 = ordered[math.ceil(0.95 * len(ordered)) - 1] if len(ordered) >= 20 else None
|
|
22
|
+
return {"sample_count": len(values), "median_ms": median(values), "p95_ms": p95, "samples_ms": values}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def calculate_metrics(results: list[dict[str, Any]], mode: str) -> dict[str, Any]:
|
|
26
|
+
"""Aggregate scenario rows. Counts are scenario-action units unless specified."""
|
|
27
|
+
selected = [row for row in results if row["configuration"] == mode]
|
|
28
|
+
if not selected:
|
|
29
|
+
raise ValueError(f"no results for configuration {mode}")
|
|
30
|
+
total_rows = len(selected)
|
|
31
|
+
passed = sum(bool(row["scenario_passed"]) for row in selected)
|
|
32
|
+
enforce_rows = [row for row in selected if mode != "baseline"]
|
|
33
|
+
decision_n = len(enforce_rows)
|
|
34
|
+
decision_correct = sum(row["actual_decision"] == row["expected_decision"] for row in enforce_rows)
|
|
35
|
+
unauthorized_actions = sum(
|
|
36
|
+
row["attempts"] for row in selected if not row["expected_execution"]
|
|
37
|
+
)
|
|
38
|
+
unauthorized_executions = sum(
|
|
39
|
+
min(row["actual_executions"], row["attempts"])
|
|
40
|
+
for row in selected if not row["expected_execution"]
|
|
41
|
+
)
|
|
42
|
+
blocked_actions = unauthorized_actions - unauthorized_executions
|
|
43
|
+
permitted_actions = sum(row["attempts"] for row in selected if row["expected_execution"])
|
|
44
|
+
false_blocks = sum(
|
|
45
|
+
max(0, row["attempts"] - min(row["actual_executions"], row["attempts"]))
|
|
46
|
+
for row in selected if row["expected_execution"]
|
|
47
|
+
)
|
|
48
|
+
approved_policy_rows = [row for row in enforce_rows if row["category"] == "approval"]
|
|
49
|
+
trace_rows = [row for row in selected if row.get("trace_complete") is not None]
|
|
50
|
+
tool_failure_count = sum(row["tool_failures"] for row in selected)
|
|
51
|
+
expected_tool_failure_rows = [row for row in selected if row["behavior"] == "raise"]
|
|
52
|
+
expected_tool_failure_handled = sum(row["expected_tool_failure_handled"] for row in expected_tool_failure_rows)
|
|
53
|
+
return {
|
|
54
|
+
"scenario_count": total_rows,
|
|
55
|
+
"scenario_pass_rate": {"numerator": passed, "denominator": total_rows, "value": passed / total_rows},
|
|
56
|
+
"policy_decision_accuracy": {
|
|
57
|
+
"numerator": decision_correct, "denominator": decision_n,
|
|
58
|
+
"value": _ratio(decision_correct, decision_n),
|
|
59
|
+
"scope": "policy and recorded configurations; baseline has no decision",
|
|
60
|
+
},
|
|
61
|
+
"unauthorized_execution_rate": {
|
|
62
|
+
"numerator": unauthorized_executions, "denominator": unauthorized_actions,
|
|
63
|
+
"value": _ratio(unauthorized_executions, unauthorized_actions),
|
|
64
|
+
},
|
|
65
|
+
"correctly_blocked_action_rate": {
|
|
66
|
+
"numerator": blocked_actions, "denominator": unauthorized_actions,
|
|
67
|
+
"value": _ratio(blocked_actions, unauthorized_actions),
|
|
68
|
+
},
|
|
69
|
+
"false_block_rate": {
|
|
70
|
+
"numerator": false_blocks, "denominator": permitted_actions,
|
|
71
|
+
"value": _ratio(false_blocks, permitted_actions),
|
|
72
|
+
},
|
|
73
|
+
"approval_enforcement_accuracy": {
|
|
74
|
+
"numerator": sum(row["scenario_passed"] for row in approved_policy_rows),
|
|
75
|
+
"denominator": len(approved_policy_rows),
|
|
76
|
+
"value": _ratio(sum(row["scenario_passed"] for row in approved_policy_rows), len(approved_policy_rows)),
|
|
77
|
+
"scope": "approval category under policy-enforced configurations",
|
|
78
|
+
},
|
|
79
|
+
"trace_completeness": {
|
|
80
|
+
"numerator": sum(row["trace_complete"] is True for row in trace_rows),
|
|
81
|
+
"denominator": len(trace_rows),
|
|
82
|
+
"value": _ratio(sum(row["trace_complete"] is True for row in trace_rows), len(trace_rows)),
|
|
83
|
+
"scope": "recorded configuration only",
|
|
84
|
+
},
|
|
85
|
+
"tool_failures_observed": tool_failure_count,
|
|
86
|
+
"expected_tool_failure_handling_rate": {
|
|
87
|
+
"numerator": expected_tool_failure_handled,
|
|
88
|
+
"denominator": len(expected_tool_failure_rows),
|
|
89
|
+
"value": _ratio(expected_tool_failure_handled, len(expected_tool_failure_rows)),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Validated Bench baseline snapshots and scenario-level regression comparison."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import REPORT_SCHEMA
|
|
15
|
+
|
|
16
|
+
BASELINE_SCHEMA = "runflect-bench-baseline/v1"
|
|
17
|
+
COMPARISON_SCHEMA = "runflect-bench-comparison/v1"
|
|
18
|
+
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
|
19
|
+
_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.: -]{0,119}$")
|
|
20
|
+
_MODES = ("policy_enforcement", "recording_and_enforcement")
|
|
21
|
+
_CONFIGURATIONS = ("baseline", *_MODES)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BenchReportError(ValueError):
|
|
25
|
+
"""A Bench report or baseline is malformed or incompatible."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BenchRegressionError(OSError):
|
|
29
|
+
"""A baseline or comparison report could not be read or written."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _canonical(value: Any) -> str:
|
|
33
|
+
try:
|
|
34
|
+
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
35
|
+
except (TypeError, ValueError) as error:
|
|
36
|
+
raise BenchReportError("report must contain finite JSON values") from error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_json(path: str | Path, label: str) -> Any:
|
|
40
|
+
try:
|
|
41
|
+
source = Path(path)
|
|
42
|
+
if source.stat().st_size > 50 * 1024 * 1024:
|
|
43
|
+
raise BenchReportError(f"{label} exceeds the 50 MiB input limit")
|
|
44
|
+
return json.loads(source.read_text(encoding="utf-8"))
|
|
45
|
+
except BenchReportError:
|
|
46
|
+
raise
|
|
47
|
+
except FileNotFoundError as error:
|
|
48
|
+
raise BenchReportError(f"{label} file was not found") from error
|
|
49
|
+
except json.JSONDecodeError as error:
|
|
50
|
+
raise BenchReportError(f"{label} file is malformed JSON") from error
|
|
51
|
+
except UnicodeError as error:
|
|
52
|
+
raise BenchReportError(f"{label} file is not UTF-8 JSON") from error
|
|
53
|
+
except OSError as error:
|
|
54
|
+
raise BenchRegressionError(f"{label} file could not be read") from error
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sample_stat(value: Any) -> dict[str, Any]:
|
|
58
|
+
if not isinstance(value, dict):
|
|
59
|
+
return {"sample_count": 0, "median_ms": None, "p95_ms": None}
|
|
60
|
+
count = value.get("sample_count")
|
|
61
|
+
count = count if isinstance(count, int) and not isinstance(count, bool) and 0 <= count <= 10000 else 0
|
|
62
|
+
result = {"sample_count": count}
|
|
63
|
+
for key in ("median_ms", "p95_ms"):
|
|
64
|
+
number = value.get(key)
|
|
65
|
+
result[key] = number if isinstance(number, (int, float)) and not isinstance(number, bool) and math.isfinite(number) and number >= 0 else None
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_report(value: Any) -> dict[str, Any]:
|
|
70
|
+
if not isinstance(value, dict) or value.get("schema") != REPORT_SCHEMA:
|
|
71
|
+
raise BenchReportError("unsupported Bench report schema")
|
|
72
|
+
benchmark_version = value.get("benchmark_version")
|
|
73
|
+
suite = value.get("suite")
|
|
74
|
+
results = value.get("scenario_results")
|
|
75
|
+
if (not isinstance(benchmark_version, str) or not _ID.fullmatch(benchmark_version)
|
|
76
|
+
or not isinstance(suite, dict) or not isinstance(results, list)):
|
|
77
|
+
raise BenchReportError("Bench report metadata is incomplete")
|
|
78
|
+
suite_id, suite_version = suite.get("id"), suite.get("version")
|
|
79
|
+
digest, scenario_ids = suite.get("digest"), suite.get("scenario_ids")
|
|
80
|
+
if (not isinstance(suite_id, str) or not _ID.fullmatch(suite_id)
|
|
81
|
+
or not isinstance(suite_version, str) or not _ID.fullmatch(suite_version)
|
|
82
|
+
or not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest)
|
|
83
|
+
or not isinstance(scenario_ids, list) or not scenario_ids or len(scenario_ids) > 500
|
|
84
|
+
or any(not isinstance(item, str) or not _ID.fullmatch(item) for item in scenario_ids)
|
|
85
|
+
or len(scenario_ids) != len(set(scenario_ids))):
|
|
86
|
+
raise BenchReportError("Bench suite identity is invalid")
|
|
87
|
+
outcomes = {mode: {} for mode in _CONFIGURATIONS}
|
|
88
|
+
for row in results:
|
|
89
|
+
if not isinstance(row, dict):
|
|
90
|
+
raise BenchReportError("Bench scenario result is invalid")
|
|
91
|
+
scenario_id, mode, passed = row.get("scenario_id"), row.get("configuration"), row.get("scenario_passed")
|
|
92
|
+
if (not isinstance(scenario_id, str) or scenario_id not in scenario_ids
|
|
93
|
+
or not isinstance(mode, str) or mode not in outcomes or type(passed) is not bool):
|
|
94
|
+
raise BenchReportError("Bench scenario result contains unsupported identity or outcome")
|
|
95
|
+
if scenario_id in outcomes[mode]:
|
|
96
|
+
raise BenchReportError("Bench report contains duplicate scenario results")
|
|
97
|
+
outcomes[mode][scenario_id] = passed
|
|
98
|
+
required = set(scenario_ids)
|
|
99
|
+
if any(set(outcomes[mode]) != required for mode in _CONFIGURATIONS):
|
|
100
|
+
raise BenchReportError("Bench report is missing one or more configured scenario outcomes")
|
|
101
|
+
run = value.get("run") if isinstance(value.get("run"), dict) else {}
|
|
102
|
+
run_id = run.get("id")
|
|
103
|
+
if not isinstance(run_id, str):
|
|
104
|
+
run_id = None
|
|
105
|
+
else:
|
|
106
|
+
try:
|
|
107
|
+
run_id = str(uuid.UUID(run_id))
|
|
108
|
+
except ValueError:
|
|
109
|
+
run_id = None
|
|
110
|
+
performance = value.get("performance") if isinstance(value.get("performance"), dict) else {}
|
|
111
|
+
statistics = performance.get("statistics") if isinstance(performance.get("statistics"), dict) else {}
|
|
112
|
+
return {
|
|
113
|
+
"benchmark_version": benchmark_version,
|
|
114
|
+
"suite": {"id": suite_id, "version": suite_version, "digest": digest, "scenario_ids": list(scenario_ids)},
|
|
115
|
+
"run_id": run_id,
|
|
116
|
+
"report_digest": hashlib.sha256(_canonical(value).encode("ascii")).hexdigest(),
|
|
117
|
+
"outcomes": outcomes,
|
|
118
|
+
"performance": {mode: _sample_stat(statistics.get(mode)) for mode in _CONFIGURATIONS},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_report(path: str | Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
123
|
+
document = _read_json(path, "Bench report")
|
|
124
|
+
return document, validate_report(document)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def create_baseline(report: dict[str, Any], *, label: str | None = None) -> dict[str, Any]:
|
|
128
|
+
validated = validate_report(report)
|
|
129
|
+
if label is not None and (not isinstance(label, str) or not _LABEL.fullmatch(label.strip())):
|
|
130
|
+
raise BenchReportError("baseline label must contain 1 to 120 characters")
|
|
131
|
+
return {
|
|
132
|
+
"schema": BASELINE_SCHEMA,
|
|
133
|
+
"baseline_id": str(uuid.uuid4()),
|
|
134
|
+
"label": label.strip() if label else "baseline",
|
|
135
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
136
|
+
"benchmark_version": validated["benchmark_version"],
|
|
137
|
+
"suite": validated["suite"],
|
|
138
|
+
"source_run_id": validated["run_id"],
|
|
139
|
+
"source_report_digest": validated["report_digest"],
|
|
140
|
+
"outcomes": validated["outcomes"],
|
|
141
|
+
"performance": validated["performance"],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def validate_baseline(value: Any) -> dict[str, Any]:
|
|
146
|
+
if not isinstance(value, dict) or value.get("schema") != BASELINE_SCHEMA:
|
|
147
|
+
raise BenchReportError("unsupported Bench baseline schema")
|
|
148
|
+
required = {"baseline_id", "benchmark_version", "suite", "outcomes", "performance", "source_report_digest"}
|
|
149
|
+
if not required.issubset(value):
|
|
150
|
+
raise BenchReportError("Bench baseline metadata is incomplete")
|
|
151
|
+
try:
|
|
152
|
+
uuid.UUID(value["baseline_id"])
|
|
153
|
+
except (ValueError, TypeError, AttributeError):
|
|
154
|
+
raise BenchReportError("Bench baseline identifier is invalid") from None
|
|
155
|
+
if not isinstance(value["source_report_digest"], str) or not re.fullmatch(r"[a-f0-9]{64}", value["source_report_digest"]):
|
|
156
|
+
raise BenchReportError("Bench baseline digest is invalid")
|
|
157
|
+
label = value.get("label", "baseline")
|
|
158
|
+
if not isinstance(label, str) or not _LABEL.fullmatch(label):
|
|
159
|
+
raise BenchReportError("Bench baseline label is invalid")
|
|
160
|
+
source_run_id = value.get("source_run_id")
|
|
161
|
+
if source_run_id is not None:
|
|
162
|
+
try:
|
|
163
|
+
uuid.UUID(source_run_id)
|
|
164
|
+
except (ValueError, TypeError, AttributeError):
|
|
165
|
+
raise BenchReportError("Bench baseline source run reference is invalid") from None
|
|
166
|
+
suite = value["suite"]
|
|
167
|
+
if not isinstance(suite, dict):
|
|
168
|
+
raise BenchReportError("Bench baseline suite metadata is invalid")
|
|
169
|
+
outcomes = value.get("outcomes")
|
|
170
|
+
if not isinstance(outcomes, dict) or set(outcomes) != set(_CONFIGURATIONS):
|
|
171
|
+
raise BenchReportError("Bench baseline outcomes are invalid")
|
|
172
|
+
for mode in _CONFIGURATIONS:
|
|
173
|
+
scenarios = outcomes[mode]
|
|
174
|
+
if not isinstance(scenarios, dict) or any(not isinstance(key, str) or type(passed) is not bool
|
|
175
|
+
for key, passed in scenarios.items()):
|
|
176
|
+
raise BenchReportError("Bench baseline scenario outcomes are invalid")
|
|
177
|
+
performance = value.get("performance")
|
|
178
|
+
if not isinstance(performance, dict) or set(performance) != set(_CONFIGURATIONS):
|
|
179
|
+
raise BenchReportError("Bench baseline performance metadata is invalid")
|
|
180
|
+
for mode in _CONFIGURATIONS:
|
|
181
|
+
stat = performance[mode]
|
|
182
|
+
if not isinstance(stat, dict):
|
|
183
|
+
raise BenchReportError("Bench baseline performance metadata is invalid")
|
|
184
|
+
baseline_report = {
|
|
185
|
+
"schema": REPORT_SCHEMA,
|
|
186
|
+
"benchmark_version": value.get("benchmark_version"),
|
|
187
|
+
"suite": suite,
|
|
188
|
+
"scenario_results": [
|
|
189
|
+
{"scenario_id": scenario_id, "configuration": mode, "scenario_passed": passed}
|
|
190
|
+
for mode, scenarios in value["outcomes"].items()
|
|
191
|
+
for scenario_id, passed in scenarios.items()
|
|
192
|
+
],
|
|
193
|
+
"performance": {"statistics": performance},
|
|
194
|
+
}
|
|
195
|
+
normalized = validate_report(baseline_report)
|
|
196
|
+
return normalized
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load_baseline(path: str | Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
200
|
+
document = _read_json(path, "Bench baseline")
|
|
201
|
+
return document, validate_baseline(document)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def compare(baseline_doc: dict[str, Any], baseline: dict[str, Any],
|
|
205
|
+
current_doc: dict[str, Any], current: dict[str, Any], *,
|
|
206
|
+
max_regressions: int = 0, max_pass_rate_drop: float | None = None,
|
|
207
|
+
max_p95_regression_percent: float | None = None) -> dict[str, Any]:
|
|
208
|
+
if isinstance(max_regressions, bool) or not isinstance(max_regressions, int) or max_regressions < 0:
|
|
209
|
+
raise BenchReportError("max_regressions must be a non-negative integer")
|
|
210
|
+
for key, threshold in (("max_pass_rate_drop", max_pass_rate_drop),
|
|
211
|
+
("max_p95_regression_percent", max_p95_regression_percent)):
|
|
212
|
+
maximum = 1 if key == "max_pass_rate_drop" else 1000
|
|
213
|
+
if threshold is not None and (isinstance(threshold, bool) or not isinstance(threshold, (int, float))
|
|
214
|
+
or not math.isfinite(threshold) or threshold < 0 or threshold > maximum):
|
|
215
|
+
raise BenchReportError(f"{key} must be a finite non-negative number")
|
|
216
|
+
if isinstance(max_regressions, bool) or not isinstance(max_regressions, int) or not 0 <= max_regressions <= 1500:
|
|
217
|
+
raise BenchReportError("max_regressions must be between 0 and 1500")
|
|
218
|
+
if baseline["benchmark_version"] != current["benchmark_version"] or baseline["suite"] != current["suite"]:
|
|
219
|
+
raise BenchReportError("Bench reports are incompatible; benchmark and suite identity, version, digest, and scenario IDs must match")
|
|
220
|
+
|
|
221
|
+
config_results = {}
|
|
222
|
+
regressions = []
|
|
223
|
+
improvements = []
|
|
224
|
+
for mode in _MODES:
|
|
225
|
+
base = baseline["outcomes"][mode]
|
|
226
|
+
now = current["outcomes"][mode]
|
|
227
|
+
failures = []
|
|
228
|
+
better = []
|
|
229
|
+
for scenario_id in baseline["suite"]["scenario_ids"]:
|
|
230
|
+
if base[scenario_id] and not now[scenario_id]:
|
|
231
|
+
failures.append(scenario_id)
|
|
232
|
+
regressions.append({"configuration": mode, "scenario_id": scenario_id})
|
|
233
|
+
elif not base[scenario_id] and now[scenario_id]:
|
|
234
|
+
better.append(scenario_id)
|
|
235
|
+
improvements.append({"configuration": mode, "scenario_id": scenario_id})
|
|
236
|
+
base_rate = sum(base.values()) / len(base) if base else None
|
|
237
|
+
current_rate = sum(now.values()) / len(now) if now else None
|
|
238
|
+
config_results[mode] = {
|
|
239
|
+
"baseline_pass_rate": base_rate,
|
|
240
|
+
"current_pass_rate": current_rate,
|
|
241
|
+
"pass_rate_delta": current_rate - base_rate if current_rate is not None and base_rate is not None else None,
|
|
242
|
+
"regression_count": len(failures), "regressed_scenarios": failures,
|
|
243
|
+
"improved_scenarios": better,
|
|
244
|
+
}
|
|
245
|
+
performance_result: dict[str, Any] = {"status": "not_requested"}
|
|
246
|
+
performance_inconclusive = False
|
|
247
|
+
performance_failed = False
|
|
248
|
+
if max_p95_regression_percent is not None:
|
|
249
|
+
performance_result = {"status": "evaluated", "threshold_percent": max_p95_regression_percent, "modes": {}}
|
|
250
|
+
for mode in _MODES:
|
|
251
|
+
before = baseline["performance"][mode]
|
|
252
|
+
after = current["performance"][mode]
|
|
253
|
+
if (before["sample_count"] < 20 or after["sample_count"] < 20
|
|
254
|
+
or before["p95_ms"] is None or after["p95_ms"] is None or before["p95_ms"] == 0):
|
|
255
|
+
performance_inconclusive = True
|
|
256
|
+
performance_result["status"] = "inconclusive"
|
|
257
|
+
performance_result["modes"][mode] = {
|
|
258
|
+
"status": "insufficient_samples", "minimum_sample_count": 20,
|
|
259
|
+
"baseline_sample_count": before["sample_count"], "current_sample_count": after["sample_count"],
|
|
260
|
+
"baseline_p95_ms": before["p95_ms"], "current_p95_ms": after["p95_ms"],
|
|
261
|
+
}
|
|
262
|
+
continue
|
|
263
|
+
delta = (after["p95_ms"] - before["p95_ms"]) / before["p95_ms"] * 100
|
|
264
|
+
failed = delta > max_p95_regression_percent
|
|
265
|
+
performance_failed = performance_failed or failed
|
|
266
|
+
performance_result["modes"][mode] = {
|
|
267
|
+
"status": "failed" if failed else "passed", "baseline_sample_count": before["sample_count"],
|
|
268
|
+
"current_sample_count": after["sample_count"], "baseline_p95_ms": before["p95_ms"],
|
|
269
|
+
"current_p95_ms": after["p95_ms"], "p95_delta_percent": delta,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
pass_rate_failed = bool(max_pass_rate_drop is not None and any(
|
|
273
|
+
-item["pass_rate_delta"] > max_pass_rate_drop for item in config_results.values()
|
|
274
|
+
if item["pass_rate_delta"] is not None
|
|
275
|
+
))
|
|
276
|
+
functional_failed = len(regressions) > max_regressions or pass_rate_failed
|
|
277
|
+
status = "failed" if functional_failed or performance_failed else (
|
|
278
|
+
"inconclusive" if performance_inconclusive else "passed"
|
|
279
|
+
)
|
|
280
|
+
return {
|
|
281
|
+
"schema": COMPARISON_SCHEMA,
|
|
282
|
+
"comparison_id": str(uuid.uuid4()),
|
|
283
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
284
|
+
"status": status,
|
|
285
|
+
"baseline": {"id": baseline_doc["baseline_id"], "label": baseline_doc.get("label", "baseline"),
|
|
286
|
+
"source_run_id": baseline_doc.get("source_run_id"), "report_digest": baseline_doc["source_report_digest"]},
|
|
287
|
+
"current": {"run_id": current["run_id"], "report_digest": current["report_digest"]},
|
|
288
|
+
"suite": {"id": current["suite"]["id"], "version": current["suite"]["version"],
|
|
289
|
+
"digest": current["suite"]["digest"], "scenario_count": len(current["suite"]["scenario_ids"])},
|
|
290
|
+
"thresholds": {"max_regressions": max_regressions, "max_pass_rate_drop": max_pass_rate_drop,
|
|
291
|
+
"max_p95_regression_percent": max_p95_regression_percent},
|
|
292
|
+
"summary": {"regression_count": len(regressions), "improvement_count": len(improvements),
|
|
293
|
+
"configuration_results": config_results},
|
|
294
|
+
"regressions": regressions,
|
|
295
|
+
"improvements": improvements,
|
|
296
|
+
"performance": performance_result,
|
|
297
|
+
"limitations": [
|
|
298
|
+
"This compares deterministic Bench scenario outcomes and configured local timing observations; it does not measure general agent quality.",
|
|
299
|
+
"P95 thresholds require at least 20 samples in both reports and are host-specific observations, not a statistical significance test.",
|
|
300
|
+
"Reports are comparable only when benchmark version, suite ID/version/digest, and scenario IDs match exactly.",
|
|
301
|
+
],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def write_json(value: dict[str, Any], path: str | Path) -> None:
|
|
306
|
+
target = Path(path).expanduser()
|
|
307
|
+
try:
|
|
308
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
|
310
|
+
temporary.write_text(json.dumps(value, ensure_ascii=True, sort_keys=True, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
|
311
|
+
temporary.replace(target)
|
|
312
|
+
except OSError as error:
|
|
313
|
+
raise BenchRegressionError("comparison artifact could not be written") from error
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def save_baseline(report_path: str | Path, output_path: str | Path, *, label: str | None = None) -> dict[str, Any]:
|
|
317
|
+
document, _ = load_report(report_path)
|
|
318
|
+
baseline = create_baseline(document, label=label)
|
|
319
|
+
write_json(baseline, output_path)
|
|
320
|
+
return baseline
|
runflect/bench/report.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Stable JSON report construction and serialization for Runflect Bench."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import json
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .. import __version__
|
|
14
|
+
from . import BENCHMARK_VERSION, REPORT_SCHEMA
|
|
15
|
+
from .schema import BenchSuite
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _package_version(name: str) -> str | None:
|
|
19
|
+
try:
|
|
20
|
+
return importlib.metadata.version(name)
|
|
21
|
+
except importlib.metadata.PackageNotFoundError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_report(suite: BenchSuite, *, results: list[dict[str, Any]],
|
|
26
|
+
metrics: dict[str, Any], performance: dict[str, Any],
|
|
27
|
+
policy_summary: dict[str, Any]) -> dict[str, Any]:
|
|
28
|
+
"""Assemble the versioned, argument-free benchmark report."""
|
|
29
|
+
enforced_pass = all(row["scenario_passed"] for row in results
|
|
30
|
+
if row["configuration"] != "baseline")
|
|
31
|
+
return {
|
|
32
|
+
"schema": REPORT_SCHEMA,
|
|
33
|
+
"status": "passed" if enforced_pass else "scenario_failures",
|
|
34
|
+
"enforcement_passed": enforced_pass,
|
|
35
|
+
"benchmark_version": BENCHMARK_VERSION,
|
|
36
|
+
"suite": {"id": suite.suite_id, "version": suite.suite_version,
|
|
37
|
+
"digest": suite.digest, "source": suite.source,
|
|
38
|
+
"scenario_ids": [item.scenario_id for item in suite.scenarios]},
|
|
39
|
+
"run": {"id": str(uuid.uuid4()), "timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
|
40
|
+
"random_seed": None, "runtime": {"python": sys.version.split()[0],
|
|
41
|
+
"platform": platform.platform(), "runflect": __version__,
|
|
42
|
+
"langgraph": _package_version("langgraph"),
|
|
43
|
+
"langchain_core": _package_version("langchain-core")}},
|
|
44
|
+
"configurations": {
|
|
45
|
+
"baseline": {"label": "baseline", "includes": [],
|
|
46
|
+
"excludes": ["policy enforcement", "policy audit", "trace recording"]},
|
|
47
|
+
"policy_enforcement": {"label": "policy_enforcement",
|
|
48
|
+
"includes": ["ToolGuard policy evaluation", "durable policy audit"],
|
|
49
|
+
"excludes": ["Run/Span trace recording"]},
|
|
50
|
+
"recording_and_enforcement": {"label": "recording_and_enforcement",
|
|
51
|
+
"includes": ["ToolGuard policy evaluation", "durable policy audit",
|
|
52
|
+
"Run/Span trace recording"]},
|
|
53
|
+
},
|
|
54
|
+
"policies": policy_summary,
|
|
55
|
+
"scenario_results": results,
|
|
56
|
+
"metrics": metrics,
|
|
57
|
+
"performance": performance,
|
|
58
|
+
"measurement_limitations": [
|
|
59
|
+
"Fixtures are deterministic and local; they do not call a model or external service.",
|
|
60
|
+
"The pure scenario evaluator supplies an off-path reference decision; baseline tool calls themselves bypass policy, and reference evaluation is excluded from latency measurements.",
|
|
61
|
+
"Prompt-injection scenarios are strings passed to controlled tools, not evidence of real-world injection resistance.",
|
|
62
|
+
"The baseline intentionally bypasses policy and may execute harmless simulated unauthorized actions.",
|
|
63
|
+
"Policy enforcement includes durable SQLite policy-event writes; recording_and_enforcement adds Run/Span writes.",
|
|
64
|
+
"Database setup and policy revision import are excluded from timed observations; per-call SQLite writes are included.",
|
|
65
|
+
"Latency results depend on this host, Python, SQLite, filesystem, and concurrent system load; no performance threshold is asserted.",
|
|
66
|
+
"No memory measurement is reported because this harness does not collect a reliable comparable metric.",
|
|
67
|
+
"A local digest and report aid reproducibility but do not establish independent integrity or trustworthiness.",
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def serialize_report(report: dict[str, Any], *, pretty: bool = False) -> str:
|
|
73
|
+
"""Serialize reports deterministically, with compact and readable forms."""
|
|
74
|
+
if pretty:
|
|
75
|
+
return json.dumps(report, ensure_ascii=True, sort_keys=True, indent=2)
|
|
76
|
+
return json.dumps(report, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|