circa-ci 0.2.2__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.
- circa/__init__.py +0 -0
- circa/analyzer.py +405 -0
- circa/benchmark.py +226 -0
- circa/cli.py +985 -0
- circa/data/incidents.json +82 -0
- circa/data/retrieval_evaluation.json +285 -0
- circa/doctor.py +111 -0
- circa/evaluation.py +282 -0
- circa/fingerprint.py +82 -0
- circa/github_runs.py +281 -0
- circa/hybrid.py +35 -0
- circa/knowledge.py +117 -0
- circa/knowledge_store.py +328 -0
- circa/llm.py +194 -0
- circa/log_parser.py +119 -0
- circa/models.py +256 -0
- circa/performance.py +172 -0
- circa/pipeline.py +342 -0
- circa/prompts.py +82 -0
- circa/redaction.py +109 -0
- circa/report.py +0 -0
- circa/resources.py +13 -0
- circa/retrieval.py +74 -0
- circa/semantic.py +130 -0
- circa_ci-0.2.2.dist-info/METADATA +781 -0
- circa_ci-0.2.2.dist-info/RECORD +29 -0
- circa_ci-0.2.2.dist-info/WHEEL +4 -0
- circa_ci-0.2.2.dist-info/entry_points.txt +2 -0
- circa_ci-0.2.2.dist-info/licenses/LICENSE +201 -0
circa/__init__.py
ADDED
|
File without changes
|
circa/analyzer.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Deterministic local CI failure analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .fingerprint import failure_fingerprint, normalize_environment
|
|
12
|
+
from .knowledge import find_similar_incidents, incident_tokens, load_incidents, tokenize
|
|
13
|
+
from .models import AnalysisResult, CauseCategory, CIFailure, HistoricalIncident
|
|
14
|
+
from .resources import data_path
|
|
15
|
+
|
|
16
|
+
_OWNERS = {
|
|
17
|
+
"hardware": "hardware-ops",
|
|
18
|
+
"network": "network-ops",
|
|
19
|
+
"infrastructure": "ci-ops",
|
|
20
|
+
"software": "developer",
|
|
21
|
+
"unknown": "unknown",
|
|
22
|
+
}
|
|
23
|
+
_CATEGORY_KEYWORDS: dict[CauseCategory, set[str]] = {
|
|
24
|
+
"hardware": {"disk", "memory", "oom", "filesystem", "cpu"},
|
|
25
|
+
"software": {"assertion", "assertionerror", "modulenotfounderror", "import", "regression", "traceback"},
|
|
26
|
+
"network": {"timeout", "dns", "resolve", "connection", "hostname", "registry"},
|
|
27
|
+
"infrastructure": {"runner", "image", "provisioning", "artifact", "503", "unavailable"},
|
|
28
|
+
"unknown": set(),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Words in nearly every CI or pytest log; sharing them with an incident is not evidence.
|
|
33
|
+
_GENERIC_TOKENS = frozenset(
|
|
34
|
+
{"test", "tests", "process", "python", "module", "run", "job", "step", "exit", "code", "completed", "passed"}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class _ExplicitError:
|
|
40
|
+
"""An error that names its own cause, such as an exception type in a traceback."""
|
|
41
|
+
|
|
42
|
+
kind: str
|
|
43
|
+
pattern: re.Pattern[str]
|
|
44
|
+
# Error names, never ordinary words: an incident naming one describes this family of error.
|
|
45
|
+
# ModuleNotFoundError is a subclass of ImportError, and pytest reports both, so they share a family.
|
|
46
|
+
incident_tokens: frozenset[str]
|
|
47
|
+
# The subset whose incidents may explain this error: the same error or a more general one,
|
|
48
|
+
# never a more specific one (a ModuleNotFoundError incident does not explain "cannot import name").
|
|
49
|
+
cause_tokens: frozenset[str]
|
|
50
|
+
root_cause: str
|
|
51
|
+
recommendation: str
|
|
52
|
+
# Where and what was reported (file, line, message), appended to the root cause when found.
|
|
53
|
+
details: tuple[re.Pattern[str], ...] = ()
|
|
54
|
+
# Some tools print their failing summary at an informational level (Prettier uses "[warn]").
|
|
55
|
+
any_level: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Ordered by pipeline stage: a dependency or import problem stops the code before
|
|
59
|
+
# any test runs, so it outranks an assertion reported in the same log.
|
|
60
|
+
_EXPLICIT_ERRORS = (
|
|
61
|
+
_ExplicitError(
|
|
62
|
+
"dependency installation failure",
|
|
63
|
+
re.compile(
|
|
64
|
+
r"No matching distribution found for|Could not find a version that satisfies the requirement"
|
|
65
|
+
r"|ResolutionImpossible|npm ERR! code (?:ERESOLVE|ETARGET|E404)"
|
|
66
|
+
),
|
|
67
|
+
frozenset({"resolutionimpossible", "eresolve", "etarget"}),
|
|
68
|
+
frozenset({"resolutionimpossible", "eresolve", "etarget"}),
|
|
69
|
+
"Dependency installation failed: the package manager could not install a required package or version.",
|
|
70
|
+
"Check the requirement's name, version pin, and package index, and reproduce the install locally.",
|
|
71
|
+
),
|
|
72
|
+
_ExplicitError(
|
|
73
|
+
"compiler error",
|
|
74
|
+
re.compile(r"\S+\.(?:c|cc|cpp|cxx|h|hh|hpp|m|mm):\d+:\d+: (?:fatal )?error: "),
|
|
75
|
+
frozenset({"gcc", "clang"}),
|
|
76
|
+
frozenset({"gcc", "clang"}),
|
|
77
|
+
"Compilation failed: the compiler reported an error in the source.",
|
|
78
|
+
"Fix the first reported compiler error; later errors are often consequences of it.",
|
|
79
|
+
(re.compile(r"\S+\.(?:c|cc|cpp|cxx|h|hh|hpp|m|mm):\d+:\d+: (?:fatal )?error: [^\n]*"),),
|
|
80
|
+
),
|
|
81
|
+
_ExplicitError(
|
|
82
|
+
"syntax error (SyntaxError)",
|
|
83
|
+
re.compile(r"(?<!except )\b(?:SyntaxError|IndentationError|TabError): "),
|
|
84
|
+
frozenset({"syntaxerror", "indentationerror", "taberror"}),
|
|
85
|
+
frozenset({"syntaxerror", "indentationerror", "taberror"}),
|
|
86
|
+
"The code could not be parsed (SyntaxError).",
|
|
87
|
+
"Fix the syntax at the reported file and line, and run the code or its tests locally.",
|
|
88
|
+
# The last traceback frame is the file that failed to parse.
|
|
89
|
+
(
|
|
90
|
+
re.compile(r'File "[^"\n]+", line \d+(?![\s\S]*File ")'),
|
|
91
|
+
re.compile(r"\b(?:SyntaxError|IndentationError|TabError): [^\n]*"),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
_ExplicitError(
|
|
95
|
+
"missing module (ModuleNotFoundError)",
|
|
96
|
+
re.compile(r"(?<!except )\bModuleNotFoundError(?::| while)|\bNo module named ['\"]"),
|
|
97
|
+
frozenset({"modulenotfounderror", "importerror"}),
|
|
98
|
+
frozenset({"modulenotfounderror", "importerror"}),
|
|
99
|
+
"A required module could not be imported (ModuleNotFoundError): it is missing from the environment"
|
|
100
|
+
" or the module path is wrong.",
|
|
101
|
+
"Declare and install the missing package in the test environment, or fix the module path.",
|
|
102
|
+
),
|
|
103
|
+
_ExplicitError(
|
|
104
|
+
"import failure (ImportError)",
|
|
105
|
+
re.compile(r"(?<!except )\bImportError(?::| while)|\bcannot import name ['\"]"),
|
|
106
|
+
frozenset({"importerror", "modulenotfounderror"}),
|
|
107
|
+
frozenset({"importerror"}),
|
|
108
|
+
"An import failed (ImportError): a module, or a name inside it, could not be imported.",
|
|
109
|
+
"Check the installed version of the module and the imported name, and look for circular imports.",
|
|
110
|
+
),
|
|
111
|
+
_ExplicitError(
|
|
112
|
+
"test assertion failure (AssertionError)",
|
|
113
|
+
re.compile(r"\bAssertionError\b|(?:^|\s)E\s+assert\s|\bFAILED \S+::\S+ - assert\b|\bDID NOT RAISE\b"),
|
|
114
|
+
frozenset({"assertion", "assertionerror"}),
|
|
115
|
+
frozenset({"assertion", "assertionerror"}),
|
|
116
|
+
"A test assertion failed (AssertionError): the code under test did not produce the expected result.",
|
|
117
|
+
"Inspect the first failing test's assertion and the change under test, and reproduce it locally.",
|
|
118
|
+
),
|
|
119
|
+
# Static checks: summaries and error signatures that only these tools print, never their names alone.
|
|
120
|
+
_ExplicitError(
|
|
121
|
+
"type-check failure (mypy)",
|
|
122
|
+
re.compile(
|
|
123
|
+
r"\bFound [1-9]\d* errors? in \d+ files? \(checked \d+ source files?\)"
|
|
124
|
+
r"|\S+\.pyi?:\d+: error: .*\[[a-z][a-z-]*\]\s*$"
|
|
125
|
+
),
|
|
126
|
+
frozenset({"mypy"}),
|
|
127
|
+
frozenset({"mypy"}),
|
|
128
|
+
"Type checking failed (mypy): the code has type errors.",
|
|
129
|
+
"Fix the reported type errors and rerun mypy locally.",
|
|
130
|
+
(re.compile(r"\S+\.pyi?:\d+: error: [^\n]*"),),
|
|
131
|
+
),
|
|
132
|
+
_ExplicitError(
|
|
133
|
+
"lint failure",
|
|
134
|
+
re.compile(r"\bFound [1-9]\d* errors?\.(?:\s|$)|\[\*\] \d+ fixable with\b"),
|
|
135
|
+
frozenset({"ruff"}),
|
|
136
|
+
frozenset({"ruff"}),
|
|
137
|
+
"The linter reported rule violations.",
|
|
138
|
+
"Fix the reported violations, or apply the linter's safe fixes, and rerun it locally.",
|
|
139
|
+
(re.compile(r"\S+\.\w+:\d+:\d+: [A-Z]+\d+ [^\n]*"),),
|
|
140
|
+
),
|
|
141
|
+
_ExplicitError(
|
|
142
|
+
"formatting check failure",
|
|
143
|
+
re.compile(r"\b[1-9]\d* files? would be reformatted\b|\b[Ww]ould reformat:? \S|\bCode style issues found in\b"),
|
|
144
|
+
frozenset({"prettier", "ruff"}),
|
|
145
|
+
frozenset({"prettier", "ruff"}),
|
|
146
|
+
"A formatting check failed: files are not formatted as the project's formatter requires.",
|
|
147
|
+
"Run the project's formatter (for example `ruff format` or `prettier --write`) and commit the result.",
|
|
148
|
+
(re.compile(r"\b[1-9]\d* files? would be reformatted[^\n]*|\bCode style issues found in[^\n]*"),),
|
|
149
|
+
any_level=True,
|
|
150
|
+
),
|
|
151
|
+
_ExplicitError(
|
|
152
|
+
"security scan finding (Bandit)",
|
|
153
|
+
re.compile(r">> Issue: \[B\d{3}:\w+\]"),
|
|
154
|
+
frozenset({"bandit"}),
|
|
155
|
+
frozenset({"bandit"}),
|
|
156
|
+
"The security scanner (Bandit) reported findings that fail the build.",
|
|
157
|
+
"Review each reported issue: fix it, or add a narrow, justified suppression.",
|
|
158
|
+
(re.compile(r"\[B\d{3}:\w+\][^\n]*"), re.compile(r"Location: \S+:\d+:\d+")),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
# Pipeline stages a historical root cause can place a failure in. A root cause claiming a stage the
|
|
162
|
+
# log does not show (dependency installation, for a Git connection timeout) is not reused.
|
|
163
|
+
_STAGE_TOKENS = {
|
|
164
|
+
"dependency installation": frozenset(
|
|
165
|
+
{"dependency", "dependencies", "install", "installation", "requirement", "requirements"}
|
|
166
|
+
),
|
|
167
|
+
}
|
|
168
|
+
# An explicit error counts like three matching keywords toward confidence.
|
|
169
|
+
_EXPLICIT_ERROR_WEIGHT = 3.0
|
|
170
|
+
_ALL_ERROR_TOKENS = frozenset().union(*(error.incident_tokens for error in _EXPLICIT_ERRORS))
|
|
171
|
+
# Explicit environment errors often cause a software error downstream (a full disk fails a test,
|
|
172
|
+
# a registry timeout fails an install), so when one is present the explicit software error is
|
|
173
|
+
# treated as a symptom and the general evidence decides.
|
|
174
|
+
_ENVIRONMENT_ERROR = re.compile(
|
|
175
|
+
r"no space left on device|out of memory|\boom(?:killed)?\b|\bMemoryError\b|timed out|\btimeout\b"
|
|
176
|
+
r"|could not resolve host|name resolution|name or service not known|connection (?:refused|reset)"
|
|
177
|
+
r"|\b503\b|service unavailable",
|
|
178
|
+
re.IGNORECASE,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Informational log levels report conditions, not failures (for example an optional import that is skipped).
|
|
183
|
+
_NON_FAILURE_LEVEL = re.compile(
|
|
184
|
+
r"^(?:[^\t]*\t[^\t]*\t)?\ufeff?(?:\S*\d{2}:\d{2}:\d{2}\S*\s+)?(?:##)?\[?(?:WARNING|WARN|INFO|DEBUG|NOTICE)\b",
|
|
185
|
+
re.IGNORECASE,
|
|
186
|
+
)
|
|
187
|
+
_TEST_FAILURE = next(error for error in _EXPLICIT_ERRORS if error.kind.startswith("test assertion"))
|
|
188
|
+
_DETAIL_LIMIT = 160
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _explicit_error(failure_text: str) -> _ExplicitError | None:
|
|
192
|
+
"""Return the earliest-stage explicit error actually reported, judging each log line on its own.
|
|
193
|
+
|
|
194
|
+
A test-failure line counts only as a test failure: an exception named in its message (``expected
|
|
195
|
+
ImportError``, ``DID NOT RAISE ModuleNotFoundError``) is what the test checked, not what was raised.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
found: set[str] = set()
|
|
199
|
+
for line in failure_text.splitlines():
|
|
200
|
+
if _NON_FAILURE_LEVEL.match(line):
|
|
201
|
+
found.update(error.kind for error in _EXPLICIT_ERRORS if error.any_level and error.pattern.search(line))
|
|
202
|
+
continue
|
|
203
|
+
if _TEST_FAILURE.pattern.search(line):
|
|
204
|
+
found.add(_TEST_FAILURE.kind)
|
|
205
|
+
continue
|
|
206
|
+
error = next((error for error in _EXPLICIT_ERRORS if error.pattern.search(line)), None)
|
|
207
|
+
if error is not None:
|
|
208
|
+
found.add(error.kind)
|
|
209
|
+
return next((error for error in _EXPLICIT_ERRORS if error.kind in found), None)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _reported_details(failure_text: str, error: _ExplicitError) -> str:
|
|
213
|
+
"""The first file, line, and message the tool reported, as one clipped string ("" if none)."""
|
|
214
|
+
|
|
215
|
+
found = (pattern.search(failure_text) for pattern in error.details)
|
|
216
|
+
return "; ".join(match.group(0).strip()[:_DETAIL_LIMIT] for match in found if match)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _contradicts(incident: HistoricalIncident, error: _ExplicitError) -> bool:
|
|
220
|
+
"""Whether the incident describes a different explicit error than the one observed."""
|
|
221
|
+
|
|
222
|
+
tokens = incident_tokens(incident)
|
|
223
|
+
return bool(tokens & _ALL_ERROR_TOKENS) and not tokens & error.incident_tokens
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _environment_matches(failure: CIFailure, incident: HistoricalIncident) -> int:
|
|
227
|
+
failure_environment = dict(normalize_environment(failure.environment))
|
|
228
|
+
incident_environment = dict(normalize_environment(incident.environment))
|
|
229
|
+
return sum(incident_environment.get(key) == value for key, value in failure_environment.items())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _data_path() -> Path:
|
|
233
|
+
return data_path("incidents.json")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def analyze_failure(
|
|
237
|
+
failure: CIFailure,
|
|
238
|
+
incidents: Iterable[HistoricalIncident] | None = None,
|
|
239
|
+
) -> AnalysisResult:
|
|
240
|
+
"""Analyze one failure using local keyword and environment evidence only.
|
|
241
|
+
|
|
242
|
+
An explicit error (dependency installation failure, ModuleNotFoundError,
|
|
243
|
+
ImportError, or a test assertion) identifies a software failure unless the
|
|
244
|
+
log also states an explicit environment error, such as a full disk that
|
|
245
|
+
made a test fail. Otherwise a category requires at least two matching
|
|
246
|
+
incident keywords beyond generic CI words, or two direct category keywords.
|
|
247
|
+
This favors ``unknown`` when the evidence is sparse.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
available_incidents = list(incidents) if incidents is not None else load_incidents(_data_path())
|
|
251
|
+
matches = find_similar_incidents(failure, available_incidents)
|
|
252
|
+
failure_tokens = tokenize(failure.failure_text)
|
|
253
|
+
explicit = _explicit_error(failure.failure_text)
|
|
254
|
+
if explicit is not None and not _ENVIRONMENT_ERROR.search(failure.failure_text):
|
|
255
|
+
return _explicit_result(failure, explicit, matches, failure_tokens)
|
|
256
|
+
|
|
257
|
+
category_scores: dict[CauseCategory, float] = defaultdict(float)
|
|
258
|
+
incident_overlap: dict[str, int] = {}
|
|
259
|
+
environment_overlap: dict[str, int] = {}
|
|
260
|
+
for incident in matches:
|
|
261
|
+
keyword_overlap = len(failure_tokens & incident_tokens(incident))
|
|
262
|
+
environment_matches = _environment_matches(failure, incident)
|
|
263
|
+
incident_overlap[incident.id] = keyword_overlap
|
|
264
|
+
environment_overlap[incident.id] = environment_matches
|
|
265
|
+
category_scores[incident.category] += keyword_overlap + (0.5 * environment_matches)
|
|
266
|
+
|
|
267
|
+
direct_signals = {
|
|
268
|
+
category: failure_tokens & keywords
|
|
269
|
+
for category, keywords in _CATEGORY_KEYWORDS.items()
|
|
270
|
+
if category != "unknown"
|
|
271
|
+
}
|
|
272
|
+
for category, signals in direct_signals.items():
|
|
273
|
+
category_scores[category] += 0.25 * len(signals)
|
|
274
|
+
|
|
275
|
+
candidate = max(
|
|
276
|
+
(category for category in _CATEGORY_KEYWORDS if category != "unknown"),
|
|
277
|
+
key=lambda category: category_scores[category],
|
|
278
|
+
)
|
|
279
|
+
strongest_match = next((match for match in matches if match.category == candidate), None)
|
|
280
|
+
strong_incident_evidence = (
|
|
281
|
+
strongest_match is not None and len((failure_tokens & incident_tokens(strongest_match)) - _GENERIC_TOKENS) >= 2
|
|
282
|
+
)
|
|
283
|
+
strong_direct_evidence = len(direct_signals[candidate]) >= 2
|
|
284
|
+
|
|
285
|
+
if not (strong_incident_evidence or strong_direct_evidence):
|
|
286
|
+
return AnalysisResult(
|
|
287
|
+
fingerprint=failure_fingerprint(failure),
|
|
288
|
+
category="unknown",
|
|
289
|
+
confidence=0.0,
|
|
290
|
+
root_cause="Insufficient deterministic evidence to identify a root cause.",
|
|
291
|
+
evidence=[],
|
|
292
|
+
recommendation="Collect additional failure output and environment details, then retry analysis.",
|
|
293
|
+
owner=_OWNERS["unknown"],
|
|
294
|
+
matched_incident_ids=[],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
evidence: list[str] = []
|
|
298
|
+
for incident in matches:
|
|
299
|
+
if incident.category != candidate:
|
|
300
|
+
continue
|
|
301
|
+
evidence.append(
|
|
302
|
+
f"Historical incident {incident.id} shares {incident_overlap[incident.id]} keyword(s)"
|
|
303
|
+
f" and {environment_overlap[incident.id]} environment value(s)."
|
|
304
|
+
)
|
|
305
|
+
if direct_signals[candidate]:
|
|
306
|
+
evidence.append(f"Failure text contains {', '.join(sorted(direct_signals[candidate]))}.")
|
|
307
|
+
|
|
308
|
+
confidence = min(0.95, 0.35 + (0.1 * category_scores[candidate]))
|
|
309
|
+
if strongest_match is not None and _root_cause_applies(failure_tokens, explicit, strongest_match):
|
|
310
|
+
# Packaged incidents have no root_cause, so their summary remains the output.
|
|
311
|
+
root_cause = strongest_match.root_cause or strongest_match.summary
|
|
312
|
+
recommendation = strongest_match.recommendation
|
|
313
|
+
else:
|
|
314
|
+
root_cause = f"Failure text indicates a likely {candidate} issue."
|
|
315
|
+
recommendation = f"Investigate the {candidate} condition reported in the failure output."
|
|
316
|
+
|
|
317
|
+
relevant_matches = [match.id for match in matches if match.category == candidate]
|
|
318
|
+
return AnalysisResult(
|
|
319
|
+
fingerprint=failure_fingerprint(failure),
|
|
320
|
+
category=candidate,
|
|
321
|
+
confidence=confidence,
|
|
322
|
+
root_cause=root_cause,
|
|
323
|
+
evidence=evidence,
|
|
324
|
+
recommendation=recommendation,
|
|
325
|
+
owner=_OWNERS[candidate],
|
|
326
|
+
matched_incident_ids=relevant_matches,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _root_cause_applies(
|
|
331
|
+
failure_tokens: set[str], explicit: _ExplicitError | None, incident: HistoricalIncident
|
|
332
|
+
) -> bool:
|
|
333
|
+
"""Whether every pipeline stage the incident's root cause claims is shown by the log's words or explicit error.
|
|
334
|
+
|
|
335
|
+
Matching is unaffected: an incident can be a relevant match without its root cause applying.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
claimed = {stage for stage, words in _STAGE_TOKENS.items() if incident_tokens(incident) & words}
|
|
339
|
+
shown = {
|
|
340
|
+
stage
|
|
341
|
+
for stage, words in _STAGE_TOKENS.items()
|
|
342
|
+
if failure_tokens & words or (explicit is not None and explicit.kind.startswith(stage))
|
|
343
|
+
}
|
|
344
|
+
return claimed <= shown
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _explicit_result(
|
|
348
|
+
failure: CIFailure, error: _ExplicitError, matches: list[HistoricalIncident], failure_tokens: set[str]
|
|
349
|
+
) -> AnalysisResult:
|
|
350
|
+
"""Classify a failure from its explicit error, keeping only incidents consistent with it."""
|
|
351
|
+
|
|
352
|
+
# Support: distinctive terms shared with the log, plus one when the incident names this error or a more
|
|
353
|
+
# general one.
|
|
354
|
+
# An incident is used only with some support, and its root cause only with at least two.
|
|
355
|
+
support = {
|
|
356
|
+
match.id: len((failure_tokens & incident_tokens(match)) - _GENERIC_TOKENS - _ALL_ERROR_TOKENS)
|
|
357
|
+
+ bool(incident_tokens(match) & error.cause_tokens)
|
|
358
|
+
for match in matches
|
|
359
|
+
}
|
|
360
|
+
consistent = [
|
|
361
|
+
match
|
|
362
|
+
for match in matches
|
|
363
|
+
if match.category == "software"
|
|
364
|
+
and not _contradicts(match, error)
|
|
365
|
+
and (incident_tokens(match) & error.incident_tokens or support[match.id] >= 1)
|
|
366
|
+
]
|
|
367
|
+
overlap = {match.id: len(failure_tokens & incident_tokens(match)) for match in consistent}
|
|
368
|
+
evidence = [
|
|
369
|
+
f"Historical incident {match.id} shares {overlap[match.id]} keyword(s)"
|
|
370
|
+
f" and {_environment_matches(failure, match)} environment value(s)."
|
|
371
|
+
for match in consistent
|
|
372
|
+
]
|
|
373
|
+
evidence.append(f"Failure text contains an explicit {error.kind}.")
|
|
374
|
+
details = _reported_details(failure.failure_text, error)
|
|
375
|
+
if details:
|
|
376
|
+
evidence.append(f"Reported: {details}")
|
|
377
|
+
direct = failure_tokens & _CATEGORY_KEYWORDS["software"]
|
|
378
|
+
if direct:
|
|
379
|
+
evidence.append(f"Failure text contains {', '.join(sorted(direct))}.")
|
|
380
|
+
|
|
381
|
+
# An incident naming only a more specific error (ModuleNotFoundError for an ImportError) is related,
|
|
382
|
+
# but its root cause is not established by this log, however many other terms it shares.
|
|
383
|
+
explaining = [
|
|
384
|
+
match
|
|
385
|
+
for match in consistent
|
|
386
|
+
if not incident_tokens(match) & _ALL_ERROR_TOKENS or incident_tokens(match) & error.cause_tokens
|
|
387
|
+
]
|
|
388
|
+
source = max(explaining, key=lambda match: support[match.id], default=None)
|
|
389
|
+
if source is not None and support[source.id] >= 2:
|
|
390
|
+
root_cause, recommendation = source.root_cause or source.summary, source.recommendation
|
|
391
|
+
else:
|
|
392
|
+
root_cause, recommendation = error.root_cause, error.recommendation
|
|
393
|
+
if details:
|
|
394
|
+
root_cause = f"{root_cause} First reported: {details}"
|
|
395
|
+
score = _EXPLICIT_ERROR_WEIGHT + sum(overlap.values()) + 0.25 * len(direct)
|
|
396
|
+
return AnalysisResult(
|
|
397
|
+
fingerprint=failure_fingerprint(failure),
|
|
398
|
+
category="software",
|
|
399
|
+
confidence=min(0.95, 0.35 + (0.1 * score)),
|
|
400
|
+
root_cause=root_cause,
|
|
401
|
+
evidence=evidence,
|
|
402
|
+
recommendation=recommendation,
|
|
403
|
+
owner=_OWNERS["software"],
|
|
404
|
+
matched_incident_ids=[match.id for match in consistent],
|
|
405
|
+
)
|
circa/benchmark.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Reproducible evaluation of local historical-incident retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
11
|
+
|
|
12
|
+
from .knowledge import find_similar_incidents, load_incidents
|
|
13
|
+
from .models import CauseCategory, CIFailure, HistoricalIncident, SemanticIncidentMatch
|
|
14
|
+
from .resources import data_path
|
|
15
|
+
from .retrieval import retrieve_historical_candidates
|
|
16
|
+
from .semantic import SemanticIncidentRetriever
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EvaluationCase(BaseModel):
|
|
20
|
+
"""One synthetic retrieval query with zero or more supported relevance labels."""
|
|
21
|
+
|
|
22
|
+
id: str = Field(min_length=1)
|
|
23
|
+
failure_text: str = Field(min_length=1)
|
|
24
|
+
expected_category: CauseCategory
|
|
25
|
+
relevant_incident_ids: list[str] = Field(default_factory=list)
|
|
26
|
+
relevance_note: str | None = None
|
|
27
|
+
synthetic: Literal[True] = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RetrievalMetrics(BaseModel):
|
|
31
|
+
"""Metrics with explicit denominators for incident-retrieval evaluation."""
|
|
32
|
+
|
|
33
|
+
evaluated_cases: int = Field(ge=0)
|
|
34
|
+
skipped_cases: int = Field(ge=0)
|
|
35
|
+
relevant_cases: int = Field(ge=0)
|
|
36
|
+
no_known_relevance_cases: int = Field(ge=0)
|
|
37
|
+
top_1_accuracy: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
38
|
+
recall_at_k: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
39
|
+
mean_reciprocal_rank: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
40
|
+
no_result_rate: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
41
|
+
evaluation_coverage: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BenchmarkMethodResult(BaseModel):
|
|
45
|
+
"""Metrics and availability state for one retrieval method."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
status: str
|
|
49
|
+
metrics: RetrievalMetrics
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BenchmarkReport(BaseModel):
|
|
53
|
+
"""All retrieval benchmark results for one fixed dataset and top-K value."""
|
|
54
|
+
|
|
55
|
+
dataset_size: int = Field(ge=0)
|
|
56
|
+
top_k: int = Field(ge=1)
|
|
57
|
+
methods: list[BenchmarkMethodResult]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_evaluation_cases(
|
|
61
|
+
path: str | Path,
|
|
62
|
+
incidents: Iterable[HistoricalIncident] | None = None,
|
|
63
|
+
) -> list[EvaluationCase]:
|
|
64
|
+
"""Load and validate synthetic cases against the available incident IDs."""
|
|
65
|
+
|
|
66
|
+
source = Path(path)
|
|
67
|
+
try:
|
|
68
|
+
payload = json.loads(source.read_text(encoding="utf-8"))
|
|
69
|
+
except json.JSONDecodeError as error:
|
|
70
|
+
raise ValueError(f"Invalid evaluation dataset JSON in {source}: {error.msg}") from error
|
|
71
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("cases"), list):
|
|
72
|
+
raise TypeError(f"Invalid evaluation dataset {source}: expected an object with a 'cases' list.")
|
|
73
|
+
|
|
74
|
+
available_incidents = incidents if incidents is not None else load_incidents(data_path("incidents.json"))
|
|
75
|
+
known_ids = {incident.id for incident in available_incidents}
|
|
76
|
+
cases: list[EvaluationCase] = []
|
|
77
|
+
seen_ids: set[str] = set()
|
|
78
|
+
for index, record in enumerate(payload["cases"]):
|
|
79
|
+
try:
|
|
80
|
+
case = EvaluationCase.model_validate(record)
|
|
81
|
+
except ValidationError as error:
|
|
82
|
+
raise ValueError(f"Invalid evaluation case at index {index} in {source}: {error}") from error
|
|
83
|
+
if case.id in seen_ids:
|
|
84
|
+
raise ValueError(f"Duplicate evaluation case ID '{case.id}' in {source}.")
|
|
85
|
+
unknown_ids = sorted(set(case.relevant_incident_ids) - known_ids)
|
|
86
|
+
if unknown_ids:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Evaluation case '{case.id}' references unknown historical incident ID(s): {', '.join(unknown_ids)}."
|
|
89
|
+
)
|
|
90
|
+
seen_ids.add(case.id)
|
|
91
|
+
cases.append(case)
|
|
92
|
+
return cases
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def calculate_retrieval_metrics(
|
|
96
|
+
cases: Sequence[EvaluationCase],
|
|
97
|
+
retrieved_ids: Sequence[Sequence[str] | None],
|
|
98
|
+
top_k: int,
|
|
99
|
+
) -> RetrievalMetrics:
|
|
100
|
+
"""Calculate retrieval metrics without treating unlabeled cases as misses.
|
|
101
|
+
|
|
102
|
+
Top-1, Recall@K, and MRR use only cases with one or more known relevant
|
|
103
|
+
incident IDs. No-result rate uses all successfully evaluated cases.
|
|
104
|
+
Coverage uses every dataset case, including deliberately ambiguous cases.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
if top_k < 1:
|
|
108
|
+
raise ValueError("top_k must be at least 1")
|
|
109
|
+
if len(cases) != len(retrieved_ids):
|
|
110
|
+
raise ValueError("cases and retrieved_ids must have the same length")
|
|
111
|
+
|
|
112
|
+
evaluated = [(case, ids[:top_k]) for case, ids in zip(cases, retrieved_ids, strict=True) if ids is not None]
|
|
113
|
+
eligible = [(case, ids) for case, ids in evaluated if case.relevant_incident_ids]
|
|
114
|
+
no_results = sum(not ids for _, ids in evaluated)
|
|
115
|
+
top_1_hits = sum(bool(ids) and ids[0] in case.relevant_incident_ids for case, ids in eligible)
|
|
116
|
+
recall_hits = sum(bool(set(ids) & set(case.relevant_incident_ids)) for case, ids in eligible)
|
|
117
|
+
reciprocal_ranks = [
|
|
118
|
+
next(
|
|
119
|
+
(1 / rank for rank, incident_id in enumerate(ids, start=1) if incident_id in case.relevant_incident_ids),
|
|
120
|
+
0.0,
|
|
121
|
+
)
|
|
122
|
+
for case, ids in eligible
|
|
123
|
+
]
|
|
124
|
+
eligible_count = len(eligible)
|
|
125
|
+
evaluated_count = len(evaluated)
|
|
126
|
+
dataset_size = len(cases)
|
|
127
|
+
return RetrievalMetrics(
|
|
128
|
+
evaluated_cases=evaluated_count,
|
|
129
|
+
skipped_cases=dataset_size - evaluated_count,
|
|
130
|
+
relevant_cases=eligible_count,
|
|
131
|
+
no_known_relevance_cases=evaluated_count - eligible_count,
|
|
132
|
+
top_1_accuracy=top_1_hits / eligible_count if eligible_count else None,
|
|
133
|
+
recall_at_k=recall_hits / eligible_count if eligible_count else None,
|
|
134
|
+
mean_reciprocal_rank=sum(reciprocal_ranks) / eligible_count if eligible_count else None,
|
|
135
|
+
no_result_rate=no_results / evaluated_count if evaluated_count else None,
|
|
136
|
+
evaluation_coverage=evaluated_count / dataset_size if dataset_size else None,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _failure(case: EvaluationCase) -> CIFailure:
|
|
141
|
+
return CIFailure(failure_text=case.failure_text)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class _PrecomputedSemanticRetriever:
|
|
145
|
+
"""Replay one semantic result so combined retrieval cannot query the backend again."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, matches: list[SemanticIncidentMatch]) -> None:
|
|
148
|
+
self.matches = matches
|
|
149
|
+
|
|
150
|
+
def retrieve(
|
|
151
|
+
self, failure: CIFailure, incidents: Iterable[HistoricalIncident], limit: int = 3
|
|
152
|
+
) -> list[SemanticIncidentMatch]:
|
|
153
|
+
return self.matches
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run_retrieval_benchmark(
|
|
157
|
+
cases: Sequence[EvaluationCase],
|
|
158
|
+
incidents: Iterable[HistoricalIncident],
|
|
159
|
+
*,
|
|
160
|
+
top_k: int = 3,
|
|
161
|
+
semantic_retriever: SemanticIncidentRetriever | None = None,
|
|
162
|
+
) -> BenchmarkReport:
|
|
163
|
+
"""Run existing keyword, semantic, and combined retrieval on labeled cases.
|
|
164
|
+
|
|
165
|
+
A semantic ``None`` result means its embedding backend was unavailable for
|
|
166
|
+
that case. Combined retrieval still returns its documented keyword fallback
|
|
167
|
+
and labels that condition in its method status.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
if top_k < 1:
|
|
171
|
+
raise ValueError("top_k must be at least 1")
|
|
172
|
+
available_incidents = list(incidents)
|
|
173
|
+
keyword_ids: list[list[str]] = []
|
|
174
|
+
semantic_ids: list[list[str] | None] = []
|
|
175
|
+
combined_ids: list[list[str]] = []
|
|
176
|
+
semantic_unavailable = 0
|
|
177
|
+
active_semantic_retriever = semantic_retriever
|
|
178
|
+
|
|
179
|
+
for case in cases:
|
|
180
|
+
failure = _failure(case)
|
|
181
|
+
keyword_ids.append([incident.id for incident in find_similar_incidents(failure, available_incidents, top_k)])
|
|
182
|
+
semantic_matches = (
|
|
183
|
+
active_semantic_retriever.retrieve(failure, available_incidents, top_k)
|
|
184
|
+
if active_semantic_retriever
|
|
185
|
+
else None
|
|
186
|
+
)
|
|
187
|
+
if semantic_matches is None:
|
|
188
|
+
semantic_ids.append(None)
|
|
189
|
+
semantic_unavailable += 1
|
|
190
|
+
# Avoid retrying an unavailable local backend for every case.
|
|
191
|
+
active_semantic_retriever = None
|
|
192
|
+
else:
|
|
193
|
+
semantic_ids.append([match.incident.id for match in semantic_matches])
|
|
194
|
+
combined = retrieve_historical_candidates(
|
|
195
|
+
failure,
|
|
196
|
+
available_incidents,
|
|
197
|
+
semantic_retriever=_PrecomputedSemanticRetriever(semantic_matches)
|
|
198
|
+
if semantic_matches is not None
|
|
199
|
+
else None,
|
|
200
|
+
keyword_limit=top_k,
|
|
201
|
+
semantic_limit=top_k,
|
|
202
|
+
limit=top_k,
|
|
203
|
+
)
|
|
204
|
+
combined_ids.append([candidate.incident.id for candidate in combined])
|
|
205
|
+
|
|
206
|
+
semantic_status = (
|
|
207
|
+
"available" if semantic_unavailable == 0 else "unavailable" if semantic_unavailable == len(cases) else "partial"
|
|
208
|
+
)
|
|
209
|
+
combined_status = (
|
|
210
|
+
"combined" if semantic_unavailable == 0 else f"keyword fallback for {semantic_unavailable} case(s)"
|
|
211
|
+
)
|
|
212
|
+
return BenchmarkReport(
|
|
213
|
+
dataset_size=len(cases),
|
|
214
|
+
top_k=top_k,
|
|
215
|
+
methods=[
|
|
216
|
+
BenchmarkMethodResult(
|
|
217
|
+
name="keyword", status="available", metrics=calculate_retrieval_metrics(cases, keyword_ids, top_k)
|
|
218
|
+
),
|
|
219
|
+
BenchmarkMethodResult(
|
|
220
|
+
name="semantic", status=semantic_status, metrics=calculate_retrieval_metrics(cases, semantic_ids, top_k)
|
|
221
|
+
),
|
|
222
|
+
BenchmarkMethodResult(
|
|
223
|
+
name="combined", status=combined_status, metrics=calculate_retrieval_metrics(cases, combined_ids, top_k)
|
|
224
|
+
),
|
|
225
|
+
],
|
|
226
|
+
)
|