evalkeep 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.
Files changed (75) hide show
  1. evalkeep/__init__.py +12 -0
  2. evalkeep/__main__.py +6 -0
  3. evalkeep/adapters/__init__.py +45 -0
  4. evalkeep/adapters/base.py +92 -0
  5. evalkeep/adapters/jsonl.py +164 -0
  6. evalkeep/adapters/langsmith.py +436 -0
  7. evalkeep/adapters/otlp.py +442 -0
  8. evalkeep/adapters/semconv.py +208 -0
  9. evalkeep/analysis.py +174 -0
  10. evalkeep/analysis_run.py +160 -0
  11. evalkeep/analyzers/__init__.py +52 -0
  12. evalkeep/analyzers/anthropic.py +145 -0
  13. evalkeep/analyzers/stub.py +34 -0
  14. evalkeep/cache.py +122 -0
  15. evalkeep/cli.py +1933 -0
  16. evalkeep/clustering.py +383 -0
  17. evalkeep/clusters.py +101 -0
  18. evalkeep/commands/__init__.py +1 -0
  19. evalkeep/commands/analyze_cmd.py +100 -0
  20. evalkeep/commands/compare_cmd.py +169 -0
  21. evalkeep/commands/dataset_cmd.py +182 -0
  22. evalkeep/commands/detect_cmd.py +154 -0
  23. evalkeep/commands/discover_cmd.py +274 -0
  24. evalkeep/commands/ingest_cmd.py +50 -0
  25. evalkeep/commands/init_cmd.py +151 -0
  26. evalkeep/commands/pipeline_cmd.py +156 -0
  27. evalkeep/commands/review_cmd.py +141 -0
  28. evalkeep/commands/run_cmd.py +131 -0
  29. evalkeep/commands/target_cmd.py +109 -0
  30. evalkeep/commands/trace_cmd.py +58 -0
  31. evalkeep/comparison.py +432 -0
  32. evalkeep/config.py +209 -0
  33. evalkeep/detection.py +94 -0
  34. evalkeep/detectors.py +182 -0
  35. evalkeep/discovery.py +208 -0
  36. evalkeep/embeddings/__init__.py +31 -0
  37. evalkeep/embeddings/base.py +32 -0
  38. evalkeep/embeddings/hashing.py +98 -0
  39. evalkeep/errors.py +42 -0
  40. evalkeep/examples/__init__.py +37 -0
  41. evalkeep/examples/langsmith/runs.jsonl +18 -0
  42. evalkeep/examples/opentelemetry/spans.json +898 -0
  43. evalkeep/examples/refund-agent/agents/baseline.py +66 -0
  44. evalkeep/examples/refund-agent/agents/candidate.py +66 -0
  45. evalkeep/examples/refund-agent/traces.jsonl +5 -0
  46. evalkeep/examples/tau-bench/prepare.py +230 -0
  47. evalkeep/exporters/__init__.py +45 -0
  48. evalkeep/exporters/generic.py +31 -0
  49. evalkeep/exporters/promptfoo.py +219 -0
  50. evalkeep/failures.py +95 -0
  51. evalkeep/generation.py +303 -0
  52. evalkeep/hashing.py +56 -0
  53. evalkeep/ingest.py +257 -0
  54. evalkeep/prompts.py +127 -0
  55. evalkeep/pseudonyms.py +82 -0
  56. evalkeep/py.typed +0 -0
  57. evalkeep/redaction.py +333 -0
  58. evalkeep/regression.py +409 -0
  59. evalkeep/review.py +309 -0
  60. evalkeep/runner.py +302 -0
  61. evalkeep/runs.py +185 -0
  62. evalkeep/storage/__init__.py +37 -0
  63. evalkeep/storage/clusters.py +163 -0
  64. evalkeep/storage/failures.py +254 -0
  65. evalkeep/storage/migrations.py +370 -0
  66. evalkeep/storage/regression.py +136 -0
  67. evalkeep/storage/runs.py +223 -0
  68. evalkeep/storage/store.py +429 -0
  69. evalkeep/targets.py +205 -0
  70. evalkeep/trace.py +238 -0
  71. evalkeep-0.1.0.dist-info/METADATA +221 -0
  72. evalkeep-0.1.0.dist-info/RECORD +75 -0
  73. evalkeep-0.1.0.dist-info/WHEEL +4 -0
  74. evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
  75. evalkeep-0.1.0.dist-info/licenses/LICENSE +202 -0
evalkeep/runs.py ADDED
@@ -0,0 +1,185 @@
1
+ """Evaluation runs and their per-test results.
2
+
3
+ The distinction that matters here is between a test that *failed* and a test
4
+ that never got to run. An assertion failure is information about the agent; a
5
+ timeout or a crashed provider is information about the harness. Conflating them
6
+ would let an outage look like a regression, which is exactly the mistake the
7
+ comparison stage exists to avoid.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC, datetime
15
+ from enum import StrEnum
16
+ from typing import Any
17
+
18
+
19
+ class Outcome(StrEnum):
20
+ PASS = "pass"
21
+ FAIL = "fail"
22
+ #: Never evaluated: the harness or the target failed, not the agent's answer.
23
+ ERROR = "error"
24
+
25
+
26
+ class ErrorKind(StrEnum):
27
+ TIMEOUT = "timeout"
28
+ EXECUTION_ERROR = "execution_error"
29
+
30
+
31
+ class RunStatus(StrEnum):
32
+ COMPLETED = "completed"
33
+ FAILED = "failed"
34
+
35
+
36
+ class Verdict(StrEnum):
37
+ """What a whole set of repetitions says about one case.
38
+
39
+ The third value is the point of repeating at all: a case that sometimes
40
+ passes has told you something a single execution cannot.
41
+ """
42
+
43
+ PASS = "pass"
44
+ FAIL = "fail"
45
+ #: Passed some repetitions and failed others.
46
+ FLAKY = "flaky"
47
+ #: Never actually evaluated.
48
+ ERROR = "error"
49
+
50
+
51
+ @dataclass
52
+ class CaseResult:
53
+ test_id: str
54
+ outcome: Outcome
55
+ error_kind: ErrorKind | None = None
56
+ error: str | None = None
57
+ latency_ms: int | None = None
58
+ #: What the agent actually produced, redacted before storage.
59
+ observation: str | None = None
60
+ failed_assertions: list[str] = field(default_factory=list)
61
+ #: Which execution of this case this was, zero-based.
62
+ repetition: int = 0
63
+
64
+ @property
65
+ def comparable(self) -> bool:
66
+ """Errors are reported separately, never counted as pass or fail."""
67
+ return self.outcome is not Outcome.ERROR
68
+
69
+
70
+ @dataclass
71
+ class CaseSummary:
72
+ """Every execution of one case in one run, taken together."""
73
+
74
+ test_id: str
75
+ passed: int = 0
76
+ failed: int = 0
77
+ errored: int = 0
78
+
79
+ @property
80
+ def repetitions(self) -> int:
81
+ return self.passed + self.failed + self.errored
82
+
83
+ @property
84
+ def evaluated(self) -> int:
85
+ """Repetitions that actually ran. Errors are not evidence either way."""
86
+ return self.passed + self.failed
87
+
88
+ @property
89
+ def pass_rate(self) -> float | None:
90
+ return self.passed / self.evaluated if self.evaluated else None
91
+
92
+ @property
93
+ def verdict(self) -> Verdict:
94
+ if not self.evaluated:
95
+ return Verdict.ERROR
96
+ if self.passed == self.evaluated:
97
+ return Verdict.PASS
98
+ if self.passed == 0:
99
+ return Verdict.FAIL
100
+ return Verdict.FLAKY
101
+
102
+ @property
103
+ def flaky(self) -> bool:
104
+ return self.verdict is Verdict.FLAKY
105
+
106
+ @property
107
+ def confidence(self) -> tuple[float, float] | None:
108
+ """A Wilson interval on the pass rate, or ``None`` with nothing to go on.
109
+
110
+ Wilson rather than the normal approximation because these counts are
111
+ small and one-sided -- 20 passes out of 20 would otherwise produce an
112
+ interval of zero width, which claims certainty no sample can give.
113
+ """
114
+ return wilson_interval(self.passed, self.evaluated)
115
+
116
+
117
+ @dataclass
118
+ class EvaluationRun:
119
+ run_id: str
120
+ target_id: str
121
+ #: Identifies the exact set of approved tests that ran, so two runs are only
122
+ #: compared when they answered the same questions.
123
+ suite_hash: str
124
+ tests: int = 0
125
+ #: How many times each test was executed.
126
+ repetitions: int = 1
127
+ status: RunStatus = RunStatus.COMPLETED
128
+ runner: str | None = None
129
+ environment: dict[str, Any] = field(default_factory=dict)
130
+ started_at: datetime = field(default_factory=lambda: datetime.now(UTC))
131
+ finished_at: datetime | None = None
132
+ output_dir: str | None = None
133
+
134
+
135
+ @dataclass
136
+ class BaselinePromotion:
137
+ """A recorded decision that one run is now the reference point."""
138
+
139
+ promotion_id: str
140
+ run_id: str
141
+ target_id: str
142
+ reviewer: str
143
+ reason: str | None = None
144
+ promoted_at: datetime = field(default_factory=lambda: datetime.now(UTC))
145
+
146
+
147
+ def suite_hash(test_ids: list[str]) -> str:
148
+ """A fingerprint of which tests a run covered."""
149
+ material = "\n".join(sorted(test_ids))
150
+ return "sha256:" + hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
151
+
152
+
153
+ #: 95% two-sided normal quantile, for the Wilson interval.
154
+ _Z = 1.959963984540054
155
+
156
+
157
+ def wilson_interval(successes: int, trials: int) -> tuple[float, float] | None:
158
+ """A Wilson score interval for a proportion.
159
+
160
+ Chosen over the Wald interval because it behaves at the edges, where these
161
+ measurements live: an all-pass or all-fail case still gets an interval that
162
+ reflects how few times it was tried.
163
+ """
164
+ if trials <= 0:
165
+ return None
166
+ rate = successes / trials
167
+ denominator = 1 + _Z**2 / trials
168
+ centre = (rate + _Z**2 / (2 * trials)) / denominator
169
+ spread = _Z * ((rate * (1 - rate) / trials + _Z**2 / (4 * trials**2)) ** 0.5) / denominator
170
+ return max(0.0, centre - spread), min(1.0, centre + spread)
171
+
172
+
173
+ def summarize(results: list[CaseResult]) -> dict[str, CaseSummary]:
174
+ """Group repeated executions by case."""
175
+ summaries: dict[str, CaseSummary] = {}
176
+ for result in results:
177
+ summary = summaries.setdefault(result.test_id, CaseSummary(test_id=result.test_id))
178
+ match result.outcome:
179
+ case Outcome.PASS:
180
+ summary.passed += 1
181
+ case Outcome.FAIL:
182
+ summary.failed += 1
183
+ case Outcome.ERROR:
184
+ summary.errored += 1
185
+ return summaries
@@ -0,0 +1,37 @@
1
+ """Redacted trace storage: SQLite schema, migrations and the trace store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from evalkeep.storage.clusters import ClusterStore
6
+ from evalkeep.storage.failures import FailureStore, FailureSummary
7
+ from evalkeep.storage.migrations import LATEST_VERSION, MIGRATIONS, Migration, apply_migrations
8
+ from evalkeep.storage.regression import RegressionStore
9
+ from evalkeep.storage.runs import RunStore
10
+ from evalkeep.storage.store import (
11
+ OccurrenceStats,
12
+ StoredTrace,
13
+ StoreOutcome,
14
+ StoreResult,
15
+ TraceOccurrence,
16
+ TraceStore,
17
+ TraceSummary,
18
+ )
19
+
20
+ __all__ = [
21
+ "LATEST_VERSION",
22
+ "MIGRATIONS",
23
+ "ClusterStore",
24
+ "FailureStore",
25
+ "FailureSummary",
26
+ "Migration",
27
+ "OccurrenceStats",
28
+ "RegressionStore",
29
+ "RunStore",
30
+ "StoreOutcome",
31
+ "StoreResult",
32
+ "StoredTrace",
33
+ "TraceOccurrence",
34
+ "TraceStore",
35
+ "TraceSummary",
36
+ "apply_migrations",
37
+ ]
@@ -0,0 +1,163 @@
1
+ """Persistence for clusterings, clusters and their members."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from evalkeep.clusters import Cluster, ClusteringRun, ClusterMember, MemberRole
11
+
12
+
13
+ class ClusterStore:
14
+ """Read/write access to the clustering tables.
15
+
16
+ Only one clustering is current at a time: ``discover`` writes a new run and
17
+ the previous one is replaced. Keeping several would mean every downstream
18
+ command had to ask which grouping it meant.
19
+ """
20
+
21
+ def __init__(self, connection: sqlite3.Connection) -> None:
22
+ self._connection = connection
23
+
24
+ def replace_run(self, run: ClusteringRun, clusters: list[Cluster]) -> None:
25
+ """Install a clustering, atomically, discarding any previous one."""
26
+ with self._connection:
27
+ self._connection.execute("DELETE FROM clustering_runs")
28
+ self._connection.execute(
29
+ """
30
+ INSERT INTO clustering_runs (
31
+ run_id, created_at, embedder, dimensions, parameters, failures
32
+ ) VALUES (?, ?, ?, ?, ?, ?)
33
+ """,
34
+ (
35
+ run.run_id,
36
+ run.created_at.isoformat(),
37
+ run.embedder,
38
+ run.dimensions,
39
+ json.dumps(run.parameters, sort_keys=True),
40
+ run.failures,
41
+ ),
42
+ )
43
+ for cluster in clusters:
44
+ self._insert_cluster(cluster, run.run_id)
45
+
46
+ def current_run(self) -> ClusteringRun | None:
47
+ row = self._connection.execute(
48
+ "SELECT * FROM clustering_runs ORDER BY created_at DESC LIMIT 1"
49
+ ).fetchone()
50
+ if row is None:
51
+ return None
52
+ return ClusteringRun(
53
+ run_id=row["run_id"],
54
+ embedder=row["embedder"],
55
+ dimensions=row["dimensions"],
56
+ parameters=json.loads(row["parameters"]),
57
+ failures=row["failures"],
58
+ created_at=datetime.fromisoformat(row["created_at"]),
59
+ )
60
+
61
+ def save(self, cluster: Cluster) -> None:
62
+ """Update one cluster in place, replacing its membership."""
63
+ run = self.current_run()
64
+ if run is None: # pragma: no cover - callers check first
65
+ raise ValueError("no clustering to update")
66
+ with self._connection:
67
+ self._connection.execute(
68
+ "DELETE FROM clusters WHERE cluster_id = ?", (cluster.cluster_id,)
69
+ )
70
+ self._insert_cluster(cluster, run.run_id)
71
+
72
+ def delete(self, cluster_id: str) -> None:
73
+ with self._connection:
74
+ self._connection.execute("DELETE FROM clusters WHERE cluster_id = ?", (cluster_id,))
75
+
76
+ def get(self, cluster_id: str) -> Cluster | None:
77
+ row = self._connection.execute(
78
+ "SELECT * FROM clusters WHERE cluster_id = ?", (cluster_id.strip(),)
79
+ ).fetchone()
80
+ return self._build(row) if row is not None else None
81
+
82
+ def list(self, *, include_dismissed: bool = True) -> list[Cluster]:
83
+ query = "SELECT * FROM clusters"
84
+ if not include_dismissed:
85
+ query += " WHERE dismissed = 0"
86
+ rows = self._connection.execute(query).fetchall()
87
+ clusters = [self._build(row) for row in rows]
88
+ clusters.sort(key=lambda cluster: (-cluster.size, cluster.cluster_id))
89
+ return clusters
90
+
91
+ def count(self) -> int:
92
+ return int(self._connection.execute("SELECT COUNT(*) AS n FROM clusters").fetchone()["n"])
93
+
94
+ def find_by_failure(self, failure_id: str) -> Cluster | None:
95
+ row = self._connection.execute(
96
+ """
97
+ SELECT c.* FROM clusters c
98
+ JOIN cluster_members m ON m.cluster_id = c.cluster_id
99
+ WHERE m.failure_id = ?
100
+ """,
101
+ (failure_id,),
102
+ ).fetchone()
103
+ return self._build(row) if row is not None else None
104
+
105
+ def _insert_cluster(self, cluster: Cluster, run_id: str) -> None:
106
+ self._connection.execute(
107
+ """
108
+ INSERT INTO clusters (
109
+ cluster_id, run_id, label, labelled_by, dismissed, created_at
110
+ ) VALUES (?, ?, ?, ?, ?, ?)
111
+ """,
112
+ (
113
+ cluster.cluster_id,
114
+ run_id,
115
+ cluster.label,
116
+ cluster.labelled_by,
117
+ int(cluster.dismissed),
118
+ cluster.created_at.isoformat(),
119
+ ),
120
+ )
121
+ self._connection.executemany(
122
+ """
123
+ INSERT INTO cluster_members (cluster_id, failure_id, distance, roles)
124
+ VALUES (?, ?, ?, ?)
125
+ """,
126
+ [
127
+ (
128
+ cluster.cluster_id,
129
+ member.failure_id,
130
+ member.distance,
131
+ json.dumps([role.value for role in member.roles]),
132
+ )
133
+ for member in cluster.members
134
+ ],
135
+ )
136
+
137
+ def _build(self, row: sqlite3.Row) -> Cluster:
138
+ return Cluster(
139
+ cluster_id=row["cluster_id"],
140
+ label=row["label"],
141
+ members=_load_members(self._connection, row["cluster_id"]),
142
+ labelled_by=row["labelled_by"],
143
+ dismissed=bool(row["dismissed"]),
144
+ created_at=datetime.fromisoformat(row["created_at"]),
145
+ )
146
+
147
+
148
+ def _load_members(connection: sqlite3.Connection, cluster_id: str) -> list[ClusterMember]:
149
+ rows = connection.execute(
150
+ "SELECT * FROM cluster_members WHERE cluster_id = ? ORDER BY distance, failure_id",
151
+ (cluster_id,),
152
+ ).fetchall()
153
+ members: list[ClusterMember] = []
154
+ for row in rows:
155
+ raw: Any = json.loads(row["roles"])
156
+ members.append(
157
+ ClusterMember(
158
+ failure_id=row["failure_id"],
159
+ distance=row["distance"],
160
+ roles=[MemberRole(value) for value in raw],
161
+ )
162
+ )
163
+ return members
@@ -0,0 +1,254 @@
1
+ """Persistence for failure candidates and their signals.
2
+
3
+ Shares the trace store's connection, so a failure and its signals are always
4
+ written in the same transaction as each other and under the same foreign keys
5
+ as the trace they describe.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from collections.abc import Iterator
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+ from evalkeep.analysis import FailureAnalysis
18
+ from evalkeep.detectors import Signal
19
+ from evalkeep.failures import Failure, FailureOrigin, FailureStatus
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class FailureSummary:
24
+ """One row of ``evalkeep failures list``."""
25
+
26
+ failure_id: str
27
+ trace_id: str
28
+ status: FailureStatus
29
+ origin: FailureOrigin
30
+ signals: int
31
+ kinds: list[str]
32
+ reviewer: str | None
33
+ failure_type: str | None = None
34
+ severity: str | None = None
35
+
36
+
37
+ class FailureStore:
38
+ """Read/write access to the failure tables."""
39
+
40
+ def __init__(self, connection: sqlite3.Connection) -> None:
41
+ self._connection = connection
42
+
43
+ def save(self, failure: Failure) -> None:
44
+ """Write a failure and replace its signals, in one transaction."""
45
+ with self._connection:
46
+ self._connection.execute(
47
+ """
48
+ INSERT INTO failures (
49
+ failure_id, trace_id, status, origin, detected_at,
50
+ updated_at, reviewer, reason
51
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
52
+ ON CONFLICT(failure_id) DO UPDATE SET
53
+ status = excluded.status,
54
+ origin = excluded.origin,
55
+ updated_at = excluded.updated_at,
56
+ reviewer = excluded.reviewer,
57
+ reason = excluded.reason
58
+ """,
59
+ (
60
+ failure.failure_id,
61
+ failure.trace_id,
62
+ failure.status.value,
63
+ failure.origin.value,
64
+ failure.detected_at.isoformat(),
65
+ failure.updated_at.isoformat(),
66
+ failure.reviewer,
67
+ failure.reason,
68
+ ),
69
+ )
70
+ self._connection.execute(
71
+ "DELETE FROM failure_signals WHERE failure_id = ?", (failure.failure_id,)
72
+ )
73
+ self._connection.executemany(
74
+ """
75
+ INSERT INTO failure_signals (
76
+ failure_id, position, detector, kind, source, summary, evidence
77
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
78
+ """,
79
+ [
80
+ (
81
+ failure.failure_id,
82
+ position,
83
+ signal.detector,
84
+ signal.kind.value,
85
+ signal.source,
86
+ signal.summary,
87
+ json.dumps(signal.evidence, sort_keys=True),
88
+ )
89
+ for position, signal in enumerate(failure.signals)
90
+ ],
91
+ )
92
+
93
+ def save_analysis(self, failure_id: str, analysis: FailureAnalysis) -> None:
94
+ """Store the latest analysis for a failure, replacing any previous one."""
95
+ with self._connection:
96
+ self._connection.execute(
97
+ """
98
+ INSERT INTO failure_analyses (
99
+ failure_id, failure_type, component, severity, summary,
100
+ analyzer, prompt_version, analyzed_at, labeler, raw_response
101
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
102
+ ON CONFLICT(failure_id) DO UPDATE SET
103
+ failure_type = excluded.failure_type,
104
+ component = excluded.component,
105
+ severity = excluded.severity,
106
+ summary = excluded.summary,
107
+ analyzer = excluded.analyzer,
108
+ prompt_version = excluded.prompt_version,
109
+ analyzed_at = excluded.analyzed_at,
110
+ labeler = excluded.labeler,
111
+ raw_response = excluded.raw_response
112
+ """,
113
+ (
114
+ failure_id,
115
+ analysis.failure_type.value,
116
+ analysis.component.value,
117
+ analysis.severity.value,
118
+ analysis.summary,
119
+ analysis.analyzer,
120
+ analysis.prompt_version,
121
+ analysis.analyzed_at.isoformat(),
122
+ analysis.labeler,
123
+ analysis.raw_response,
124
+ ),
125
+ )
126
+
127
+ def get_analysis(self, failure_id: str) -> FailureAnalysis | None:
128
+ row = self._connection.execute(
129
+ "SELECT * FROM failure_analyses WHERE failure_id = ?", (failure_id,)
130
+ ).fetchone()
131
+ if row is None:
132
+ return None
133
+ return FailureAnalysis.from_dict(
134
+ {
135
+ "failure_type": row["failure_type"],
136
+ "component": row["component"],
137
+ "severity": row["severity"],
138
+ "summary": row["summary"],
139
+ "analyzer": row["analyzer"],
140
+ "prompt_version": row["prompt_version"],
141
+ "analyzed_at": row["analyzed_at"],
142
+ "labeler": row["labeler"],
143
+ "raw_response": row["raw_response"],
144
+ }
145
+ )
146
+
147
+ def counts_by_type(self) -> dict[str, int]:
148
+ rows = self._connection.execute(
149
+ "SELECT failure_type, COUNT(*) AS n FROM failure_analyses GROUP BY failure_type"
150
+ ).fetchall()
151
+ return {row["failure_type"]: int(row["n"]) for row in rows}
152
+
153
+ def delete(self, failure_id: str) -> None:
154
+ with self._connection:
155
+ self._connection.execute("DELETE FROM failures WHERE failure_id = ?", (failure_id,))
156
+
157
+ def get(self, failure_id: str) -> Failure | None:
158
+ row = self._connection.execute(
159
+ "SELECT * FROM failures WHERE failure_id = ?", (failure_id.strip(),)
160
+ ).fetchone()
161
+ return self._build(row) if row is not None else None
162
+
163
+ def get_by_trace(self, trace_id: str) -> Failure | None:
164
+ row = self._connection.execute(
165
+ "SELECT * FROM failures WHERE trace_id = ?", (trace_id.strip(),)
166
+ ).fetchone()
167
+ return self._build(row) if row is not None else None
168
+
169
+ def list(
170
+ self, *, status: FailureStatus | None = None, limit: int = 50, offset: int = 0
171
+ ) -> list[FailureSummary]:
172
+ query = """
173
+ SELECT f.*, a.failure_type AS analysis_type, a.severity AS analysis_severity
174
+ FROM failures f
175
+ LEFT JOIN failure_analyses a ON a.failure_id = f.failure_id
176
+ """
177
+ parameters: list[Any] = []
178
+ if status is not None:
179
+ query += " WHERE f.status = ?"
180
+ parameters.append(status.value)
181
+ query += " ORDER BY f.detected_at, f.failure_id LIMIT ? OFFSET ?"
182
+ parameters += [limit, offset]
183
+
184
+ summaries: list[FailureSummary] = []
185
+ for row in self._connection.execute(query, parameters).fetchall():
186
+ signals = _load_signals(self._connection, row["failure_id"])
187
+ kinds: dict[str, None] = {}
188
+ for signal in signals:
189
+ kinds.setdefault(signal.kind.value, None)
190
+ summaries.append(
191
+ FailureSummary(
192
+ failure_id=row["failure_id"],
193
+ trace_id=row["trace_id"],
194
+ status=FailureStatus(row["status"]),
195
+ origin=FailureOrigin(row["origin"]),
196
+ signals=len(signals),
197
+ kinds=list(kinds),
198
+ reviewer=row["reviewer"],
199
+ failure_type=row["analysis_type"],
200
+ severity=row["analysis_severity"],
201
+ )
202
+ )
203
+ return summaries
204
+
205
+ def count(self, *, status: FailureStatus | None = None) -> int:
206
+ if status is None:
207
+ row = self._connection.execute("SELECT COUNT(*) AS n FROM failures").fetchone()
208
+ else:
209
+ row = self._connection.execute(
210
+ "SELECT COUNT(*) AS n FROM failures WHERE status = ?", (status.value,)
211
+ ).fetchone()
212
+ return int(row["n"])
213
+
214
+ def counts_by_status(self) -> dict[FailureStatus, int]:
215
+ rows = self._connection.execute(
216
+ "SELECT status, COUNT(*) AS n FROM failures GROUP BY status"
217
+ ).fetchall()
218
+ return {FailureStatus(row["status"]): int(row["n"]) for row in rows}
219
+
220
+ def iter_all(self) -> Iterator[Failure]:
221
+ for row in self._connection.execute("SELECT * FROM failures ORDER BY failure_id"):
222
+ yield self._build(row)
223
+
224
+ def _build(self, row: sqlite3.Row) -> Failure:
225
+ return Failure(
226
+ failure_id=row["failure_id"],
227
+ trace_id=row["trace_id"],
228
+ status=FailureStatus(row["status"]),
229
+ origin=FailureOrigin(row["origin"]),
230
+ signals=_load_signals(self._connection, row["failure_id"]),
231
+ detected_at=datetime.fromisoformat(row["detected_at"]),
232
+ updated_at=datetime.fromisoformat(row["updated_at"]),
233
+ reviewer=row["reviewer"],
234
+ reason=row["reason"],
235
+ )
236
+
237
+
238
+ def _load_signals(connection: sqlite3.Connection, failure_id: str) -> list[Signal]:
239
+ rows = connection.execute(
240
+ "SELECT * FROM failure_signals WHERE failure_id = ? ORDER BY position",
241
+ (failure_id,),
242
+ ).fetchall()
243
+ return [
244
+ Signal.from_dict(
245
+ {
246
+ "detector": row["detector"],
247
+ "kind": row["kind"],
248
+ "source": row["source"],
249
+ "summary": row["summary"],
250
+ "evidence": json.loads(row["evidence"]),
251
+ }
252
+ )
253
+ for row in rows
254
+ ]