Skip to content

Commit 5926410

Browse files
committed
feat(audit): ContractTrendAnalyzer for cross-session pass-rate regression detection
Adds gateframe.audit.trend.ContractTrendAnalyzer which reads an existing JSONL audit log written by JsonFileExporter, groups entries by workflow_id, and computes per-contract OLS pass-rate slopes to detect reliability regressions across workflow runs. Closes: none (requested in issue #1) Changes: - gateframe/audit/trend.py -- ContractTrendAnalyzer, TrendReport, ContractTrend, WorkflowRunSummary - tests/audit/__init__.py - tests/audit/test_trend.py -- 19 tests (all passing) API: from pathlib import Path from gateframe.audit.trend import ContractTrendAnalyzer report = ContractTrendAnalyzer(Path('audit.jsonl'), window=20).analyze() if report.any_regression: # one or more contracts are degrading for ct in report.regressions: print(ct.contract_name, ct.slope) Key design decisions (per issue #1 discussion): - window=N means last N *workflow_id groups* (not raw events) - Entries without workflow_id are silently skipped - Groups ordered by earliest timestamp seen within each workflow_id - OLS via statistics.linear_regression (stdlib, zero new deps) - Strictly additive -- no changes to AuditLog, JsonFileExporter, or CLI Reference: PDR in Production v2.5 -- DOI 10.5281/zenodo.19362461
1 parent 4fa4bd4 commit 5926410

2 files changed

Lines changed: 481 additions & 0 deletions

File tree

gateframe/audit/trend.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""Cross-session contract reliability trend analysis for gateframe.
2+
3+
Provides :class:`ContractTrendAnalyzer` which reads an existing JSONL audit
4+
log produced by :class:`~gateframe.audit.exporters.JsonFileExporter`, groups
5+
entries by ``workflow_id``, and computes per-contract OLS pass-rate slopes to
6+
detect reliability regressions across workflow runs.
7+
8+
Usage::
9+
10+
from pathlib import Path
11+
from gateframe.audit.trend import ContractTrendAnalyzer
12+
13+
analyzer = ContractTrendAnalyzer(Path("audit.jsonl"), window=20)
14+
report = analyzer.analyze()
15+
if report.any_regression:
16+
print("Contracts degrading:", [r.contract_name for r in report.regressions])
17+
18+
CLI equivalent::
19+
20+
gateframe trend audit.jsonl --window 20
21+
22+
The *window* parameter specifies the number of most-recent **workflow runs**
23+
(i.e. distinct ``workflow_id`` groups) to include in the trend calculation.
24+
Entries without a ``workflow_id`` are ignored.
25+
26+
Reference: PDR in Production v2.5 — DOI 10.5281/zenodo.19362461
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import json
32+
import statistics
33+
from collections import defaultdict
34+
from dataclasses import dataclass, field
35+
from pathlib import Path
36+
from typing import Optional
37+
38+
39+
# ── Data models ───────────────────────────────────────────────────────────────
40+
41+
42+
@dataclass
43+
class WorkflowRunSummary:
44+
"""Pass-rate statistics for a single workflow run.
45+
46+
A "workflow run" is the set of all audit entries that share the same
47+
``workflow_id``.
48+
49+
Attributes:
50+
workflow_id: The workflow identifier.
51+
contract_name: Name of the :class:`~gateframe.core.contract.ValidationContract`.
52+
total: Total number of validation events in this run.
53+
passed: Number of events where ``passed == True``.
54+
pass_rate: ``passed / total`` (``NaN`` when ``total == 0``).
55+
"""
56+
57+
workflow_id: str
58+
contract_name: str
59+
total: int
60+
passed: int
61+
62+
@property
63+
def pass_rate(self) -> float:
64+
return self.passed / self.total if self.total > 0 else float("nan")
65+
66+
67+
@dataclass
68+
class ContractTrend:
69+
"""OLS trend for a single contract across ordered workflow runs.
70+
71+
Attributes:
72+
contract_name: Name of the contract.
73+
run_summaries: Ordered (oldest first) per-run statistics.
74+
slope: OLS slope of ``pass_rate`` over run index (positive = improving).
75+
direction: ``"improving"``, ``"worsening"``, or ``"stable"``.
76+
regressed: ``True`` when the slope is below ``-regression_threshold``.
77+
"""
78+
79+
contract_name: str
80+
run_summaries: list[WorkflowRunSummary]
81+
slope: float
82+
direction: str
83+
regressed: bool
84+
85+
86+
@dataclass
87+
class TrendReport:
88+
"""Aggregated trend report across all contracts in the audit log.
89+
90+
Attributes:
91+
contract_trends: Per-contract trend results, sorted by ``contract_name``.
92+
any_regression: ``True`` if at least one contract is degrading.
93+
regressions: Subset of *contract_trends* where ``regressed == True``.
94+
window: Number of workflow runs included in the analysis.
95+
regression_threshold: Threshold used to classify a slope as a regression.
96+
"""
97+
98+
contract_trends: list[ContractTrend]
99+
any_regression: bool
100+
regressions: list[ContractTrend] = field(default_factory=list)
101+
window: int = 20
102+
regression_threshold: float = 0.02
103+
104+
105+
# ── Internal helpers ──────────────────────────────────────────────────────────
106+
107+
108+
def _ols_slope(values: list[float]) -> float:
109+
"""OLS slope of *values* over index 0 … n-1. Returns 0.0 for < 2 points."""
110+
n = len(values)
111+
if n < 2:
112+
return 0.0
113+
xs = list(range(n))
114+
slope, _ = statistics.linear_regression(xs, values)
115+
return slope
116+
117+
118+
def _direction(slope: float, threshold: float = 0.001) -> str:
119+
if slope > threshold:
120+
return "improving"
121+
if slope < -threshold:
122+
return "worsening"
123+
return "stable"
124+
125+
126+
def _is_valid_float(v: float) -> bool:
127+
import math
128+
return not (math.isnan(v) or math.isinf(v))
129+
130+
131+
# ── Public API ────────────────────────────────────────────────────────────────
132+
133+
134+
class ContractTrendAnalyzer:
135+
"""Reads a gateframe JSONL audit log and computes per-contract trend slopes.
136+
137+
Only audit entries that carry a ``workflow_id`` are considered. Entries are
138+
grouped by ``workflow_id``; the groups are ordered by the **earliest
139+
timestamp** seen within each group (oldest-first), so the trend slope
140+
represents change over time.
141+
142+
Args:
143+
audit_log_path: Path to the JSONL file written by
144+
:class:`~gateframe.audit.exporters.JsonFileExporter`.
145+
window: How many most-recent workflow-ID groups to include.
146+
regression_threshold: Minimum downward slope that constitutes a
147+
regression. Defaults to 0.02 (2 percentage-point drop per run).
148+
149+
Example::
150+
151+
analyzer = ContractTrendAnalyzer(Path("audit.jsonl"), window=20)
152+
report = analyzer.analyze()
153+
for ct in report.contract_trends:
154+
print(f"{ct.contract_name}: {ct.direction} (slope={ct.slope:.4f})")
155+
"""
156+
157+
def __init__(
158+
self,
159+
audit_log_path: Path | str,
160+
*,
161+
window: int = 20,
162+
regression_threshold: float = 0.02,
163+
) -> None:
164+
self._path = Path(audit_log_path)
165+
self._window = window
166+
self._regression_threshold = regression_threshold
167+
168+
# ── private ──────────────────────────────────────────────────────────────
169+
170+
def _read_entries(self) -> list[dict]:
171+
"""Parse the JSONL file; skip malformed lines."""
172+
entries: list[dict] = []
173+
try:
174+
with self._path.open(encoding="utf-8") as fh:
175+
for line in fh:
176+
line = line.strip()
177+
if not line:
178+
continue
179+
try:
180+
entries.append(json.loads(line))
181+
except json.JSONDecodeError:
182+
continue
183+
except FileNotFoundError:
184+
pass
185+
return entries
186+
187+
def _build_run_summaries(
188+
self, entries: list[dict]
189+
) -> dict[str, list[WorkflowRunSummary]]:
190+
"""Group entries by workflow_id, compute pass rates per contract.
191+
192+
Returns a dict mapping contract_name → list of WorkflowRunSummary,
193+
ordered oldest-first by first-seen timestamp within each workflow run.
194+
"""
195+
# workflow_id → contract_name → {total, passed}
196+
# Also track earliest timestamp per workflow_id for ordering.
197+
wf_contract: dict[str, dict[str, dict]] = defaultdict(
198+
lambda: defaultdict(lambda: {"total": 0, "passed": 0})
199+
)
200+
wf_first_ts: dict[str, str] = {}
201+
202+
for entry in entries:
203+
wf_id = entry.get("workflow_id")
204+
if not wf_id:
205+
continue
206+
contract = entry.get("contract_name", "unknown")
207+
passed = bool(entry.get("passed", False))
208+
ts = entry.get("timestamp", "")
209+
210+
if wf_id not in wf_first_ts or ts < wf_first_ts[wf_id]:
211+
wf_first_ts[wf_id] = ts
212+
213+
bucket = wf_contract[wf_id][contract]
214+
bucket["total"] += 1
215+
if passed:
216+
bucket["passed"] += 1
217+
218+
# Sort workflow runs by first-seen timestamp → oldest first
219+
ordered_wf_ids = sorted(wf_first_ts, key=lambda w: wf_first_ts[w])
220+
221+
# Apply window: keep only the most-recent *window* runs
222+
if len(ordered_wf_ids) > self._window:
223+
ordered_wf_ids = ordered_wf_ids[-self._window :]
224+
225+
# Invert: contract_name → list[WorkflowRunSummary] (time-ordered)
226+
contract_runs: dict[str, list[WorkflowRunSummary]] = defaultdict(list)
227+
for wf_id in ordered_wf_ids:
228+
for contract, counts in wf_contract[wf_id].items():
229+
contract_runs[contract].append(
230+
WorkflowRunSummary(
231+
workflow_id=wf_id,
232+
contract_name=contract,
233+
total=counts["total"],
234+
passed=counts["passed"],
235+
)
236+
)
237+
238+
return dict(contract_runs)
239+
240+
# ── public ───────────────────────────────────────────────────────────────
241+
242+
def analyze(self) -> TrendReport:
243+
"""Run the trend analysis and return a :class:`TrendReport`.
244+
245+
Returns:
246+
A :class:`TrendReport` summarising per-contract slopes and
247+
regression flags. If the audit log is empty or contains no
248+
entries with ``workflow_id``, the report will have no
249+
``contract_trends``.
250+
"""
251+
entries = self._read_entries()
252+
contract_runs = self._build_run_summaries(entries)
253+
254+
contract_trends: list[ContractTrend] = []
255+
for contract_name, run_summaries in sorted(contract_runs.items()):
256+
pass_rates = [
257+
s.pass_rate for s in run_summaries if _is_valid_float(s.pass_rate)
258+
]
259+
slope = _ols_slope(pass_rates)
260+
direction = _direction(slope)
261+
regressed = slope < -self._regression_threshold
262+
263+
contract_trends.append(
264+
ContractTrend(
265+
contract_name=contract_name,
266+
run_summaries=run_summaries,
267+
slope=slope,
268+
direction=direction,
269+
regressed=regressed,
270+
)
271+
)
272+
273+
regressions = [ct for ct in contract_trends if ct.regressed]
274+
return TrendReport(
275+
contract_trends=contract_trends,
276+
any_regression=bool(regressions),
277+
regressions=regressions,
278+
window=self._window,
279+
regression_threshold=self._regression_threshold,
280+
)

0 commit comments

Comments
 (0)