autofte 0.2.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.
- autofte/__init__.py +3 -0
- autofte/bench.py +427 -0
- autofte/binary_analysis.py +354 -0
- autofte/cli.py +825 -0
- autofte/config.py +89 -0
- autofte/crash_display.py +39 -0
- autofte/dashboard.py +247 -0
- autofte/dedup.py +256 -0
- autofte/demo_assets/vuln-demo/Makefile +23 -0
- autofte/demo_assets/vuln-demo/README.md +36 -0
- autofte/demo_assets/vuln-demo/crashes/crash-heap-000-len65 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-heap-001-len79 +3 -0
- autofte/demo_assets/vuln-demo/crashes/crash-heap-002-len93 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-null-000-len0 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-null-001-len2 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-null-002-len4 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-stack-000-len65 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-stack-002-len101 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-stack-003-len119 +0 -0
- autofte/demo_assets/vuln-demo/crashes/crash-uaf-000-len1 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-uaf-001-len3 +1 -0
- autofte/demo_assets/vuln-demo/crashes/crash-uaf-002-len5 +1 -0
- autofte/demo_assets/vuln-demo/in/seed.txt +1 -0
- autofte/demo_assets/vuln-demo/in/seed1 +1 -0
- autofte/demo_assets/vuln-demo/in/seed1.txt +1 -0
- autofte/demo_assets/vuln-demo/in/seed2 +2 -0
- autofte/demo_assets/vuln-demo/in/seed2.txt +1 -0
- autofte/demo_assets/vuln-demo/in/seed3 +1 -0
- autofte/demo_assets/vuln-demo/in/seed3.txt +1 -0
- autofte/demo_assets/vuln-demo/in/seed4 +1 -0
- autofte/demo_assets/vuln-demo/in/seed4.txt +1 -0
- autofte/demo_assets/vuln-demo/in/seed5 +1 -0
- autofte/demo_assets/vuln-demo/in/seed6 +1 -0
- autofte/demo_assets/vuln-demo/vuln.c +160 -0
- autofte/doctor.py +64 -0
- autofte/io_utils.py +17 -0
- autofte/llm.py +1134 -0
- autofte/metrics.py +163 -0
- autofte/paths.py +25 -0
- autofte/report.py +149 -0
- autofte/sanitizers.py +247 -0
- autofte/sarif.py +221 -0
- autofte/severity.py +368 -0
- autofte/triage.py +516 -0
- autofte/vendored_ignore_lists.py +523 -0
- autofte-0.2.0.dist-info/METADATA +378 -0
- autofte-0.2.0.dist-info/RECORD +51 -0
- autofte-0.2.0.dist-info/WHEEL +5 -0
- autofte-0.2.0.dist-info/entry_points.txt +2 -0
- autofte-0.2.0.dist-info/licenses/LICENSE +21 -0
- autofte-0.2.0.dist-info/top_level.txt +1 -0
autofte/__init__.py
ADDED
autofte/bench.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""`autofte bench` -- score AutoFTE's dedup bucketing against a labeled
|
|
2
|
+
corpus of captured sanitizer reports.
|
|
3
|
+
|
|
4
|
+
This is the "ground truth" harness described in
|
|
5
|
+
`planning/research/05-accuracy-and-ground-truth.md` SS6 and
|
|
6
|
+
`planning/HARDENING.md` Part 2: it never re-fuzzes or re-runs a target.
|
|
7
|
+
Each item in a corpus is *already-captured report text* paired with a
|
|
8
|
+
ground-truth bug label; the pipeline here is exactly parse -> hash ->
|
|
9
|
+
score, independent of the live gdb/subprocess triage flow in `triage.py`.
|
|
10
|
+
|
|
11
|
+
Two corpus shapes are supported:
|
|
12
|
+
|
|
13
|
+
- **micro** -- the small, checked-in, hand-labeled corpus at
|
|
14
|
+
`tests/fixtures/bench_micro_corpus/<label>/<case>.txt`. One directory
|
|
15
|
+
per ground-truth label, one report per file. Fast, no network, this is
|
|
16
|
+
what runs on every PR.
|
|
17
|
+
- **igor** -- the GPTrace/Igor `data_sources.tar.gz` corpus (Apache-2.0,
|
|
18
|
+
Zenodo record 10.5281/zenodo.18708473), fetched by
|
|
19
|
+
`scripts/fetch_bench_corpus.sh` into `~/.cache/autofte/bench/`. Its real
|
|
20
|
+
on-disk shape, inspected directly rather than assumed from the paper, is
|
|
21
|
+
|
|
22
|
+
data_sources/<vendor>__<target>/asan_logs/poc_<LABEL>_raw/<poc-file>
|
|
23
|
+
|
|
24
|
+
where `<poc-file>` is plain ASan report text saved under the original
|
|
25
|
+
fuzzer testcase's filename (so it may have a misleading extension like
|
|
26
|
+
`.ttf` or `.pdf` -- it is text, not the testcase itself; the testcase
|
|
27
|
+
bytes live in the sibling `traces/` and `crashwalk/` directories
|
|
28
|
+
instead). `<LABEL>` is the ground-truth bug id Igor/GPTrace assigned
|
|
29
|
+
(a single letter for Igor's own SCIs, or a Magma-style id like `AAH010`
|
|
30
|
+
for the forward-ported CVEs). There is no separate label-mapping file
|
|
31
|
+
inside `data_sources.tar.gz` -- the label is the directory name itself.
|
|
32
|
+
Labels are namespaced by target (`target::LABEL`) when scoring, since
|
|
33
|
+
the same letter (e.g. `A`) is reused across unrelated targets.
|
|
34
|
+
|
|
35
|
+
For each report: `sanitizers.parse_sanitizer_output` -> (on success)
|
|
36
|
+
`dedup.stack_hashes(record["crash_stack"], extra_context=[record["bug_class"]])`.
|
|
37
|
+
A report that fails to parse is counted in `parse_failures` and excluded
|
|
38
|
+
from scoring rather than aborting the run. A report that parses but has
|
|
39
|
+
no hashable frames (e.g. a genuinely frameless allocator-out-of-memory
|
|
40
|
+
report -- V1-RELEASE.md W2 closed the far larger, and far more common,
|
|
41
|
+
case where `sanitizers.py` simply failed to recognize the crash-stack
|
|
42
|
+
boundary) still gets a bucket, keyed on its bug class, so it isn't
|
|
43
|
+
silently dropped from the corpus; this mirrors the same "fall back to a
|
|
44
|
+
raw label when there's nothing to hash" property `triage.py` relies on,
|
|
45
|
+
without importing anything from `triage.py`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
import re
|
|
50
|
+
from collections import defaultdict
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
from .dedup import stack_hashes
|
|
54
|
+
from .metrics import compute_metrics
|
|
55
|
+
from .sanitizers import parse_sanitizer_output
|
|
56
|
+
|
|
57
|
+
MICRO_CORPUS_DIR = (
|
|
58
|
+
Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "bench_micro_corpus"
|
|
59
|
+
)
|
|
60
|
+
IGOR_CACHE_DIR = Path.home() / ".cache" / "autofte" / "bench"
|
|
61
|
+
IGOR_DATA_DIR_NAME = "data_sources"
|
|
62
|
+
|
|
63
|
+
_POC_LABEL_RE = re.compile(r"^poc_(.+)_raw$")
|
|
64
|
+
|
|
65
|
+
METRIC_KEYS = (
|
|
66
|
+
"purity",
|
|
67
|
+
"inverse_purity",
|
|
68
|
+
"f_measure",
|
|
69
|
+
"overcounting_mean",
|
|
70
|
+
"undercounting_mean",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
MACRO_METRIC_KEYS = ("purity", "inverse_purity", "f_measure")
|
|
74
|
+
|
|
75
|
+
# TASK 1 (planning/V1-RELEASE.md E2/E3 aggregation question, resolved
|
|
76
|
+
# 2026-08-08, see benchmarks/results.md "TASK 1 -- aggregation methodology
|
|
77
|
+
# settled"): the GPTrace ICSE'26 paper (arXiv 2512.01609) defines
|
|
78
|
+
# purity/inverse-purity/F "for a fixed target program" (N scoped per
|
|
79
|
+
# target, not to the pooled corpus) and its Table 3 reports one row per
|
|
80
|
+
# target plus an unweighted "Average" row -- i.e. MACRO aggregation
|
|
81
|
+
# (per-target metric, then averaged across targets), not micro/pooled.
|
|
82
|
+
# Confirmed independently by a fresh adversarial review; the one open
|
|
83
|
+
# caveat is that the paper never states in prose whether its own Average
|
|
84
|
+
# row is a weighted or unweighted mean -- that is inferred, not quoted.
|
|
85
|
+
# AutoFTE's E2/E3 exit criteria never specified an aggregation (a real
|
|
86
|
+
# spec bug, fixed in planning/V1-RELEASE.md alongside this change) --
|
|
87
|
+
# report both so nobody has to guess which one a number means.
|
|
88
|
+
AGGREGATION_NOTE = (
|
|
89
|
+
"Micro = pooled over all reports (one purity/IP/F computed on the whole "
|
|
90
|
+
"corpus at once). Macro = per-target mean (metric computed separately "
|
|
91
|
+
"per target, then averaged across targets, each target weighted "
|
|
92
|
+
"equally regardless of size). The GPTrace ICSE'26 paper (arXiv "
|
|
93
|
+
"2512.01609) scopes its purity/inverse-purity/F formulas 'for a fixed "
|
|
94
|
+
"target program' and reports one row per target plus an Average row -- "
|
|
95
|
+
"i.e. MACRO. Its own prose never states whether that Average row is a "
|
|
96
|
+
"weighted or unweighted mean, so treat that one detail as inferred, "
|
|
97
|
+
"not quoted -- see benchmarks/results.md 'TASK 1' for the full citation "
|
|
98
|
+
"and the adversarial review that checked it."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CorpusNotFoundError(Exception):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_corpus(spec):
|
|
107
|
+
if spec == "micro":
|
|
108
|
+
if not MICRO_CORPUS_DIR.is_dir():
|
|
109
|
+
raise CorpusNotFoundError(f"micro corpus not found at {MICRO_CORPUS_DIR}")
|
|
110
|
+
return MICRO_CORPUS_DIR, "micro"
|
|
111
|
+
if spec == "igor":
|
|
112
|
+
data_dir = IGOR_CACHE_DIR / IGOR_DATA_DIR_NAME
|
|
113
|
+
if not data_dir.is_dir():
|
|
114
|
+
raise CorpusNotFoundError(
|
|
115
|
+
f"Igor/GPTrace corpus not found at {data_dir}. "
|
|
116
|
+
"Run scripts/fetch_bench_corpus.sh first."
|
|
117
|
+
)
|
|
118
|
+
return data_dir, "igor"
|
|
119
|
+
path = Path(spec)
|
|
120
|
+
if not path.is_dir():
|
|
121
|
+
raise CorpusNotFoundError(f"corpus path not found: {path}")
|
|
122
|
+
return path, "auto"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def detect_corpus_kind(path):
|
|
126
|
+
path = Path(path)
|
|
127
|
+
if next(path.rglob("asan_logs"), None) is not None:
|
|
128
|
+
return "igor"
|
|
129
|
+
return "micro"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def iter_micro_corpus(path):
|
|
133
|
+
path = Path(path)
|
|
134
|
+
if not path.is_dir():
|
|
135
|
+
raise CorpusNotFoundError(f"micro corpus path not found: {path}")
|
|
136
|
+
for label_dir in sorted(p for p in path.iterdir() if p.is_dir()):
|
|
137
|
+
label = label_dir.name
|
|
138
|
+
for report_path in sorted(label_dir.glob("*.txt")):
|
|
139
|
+
item_id = f"{label}/{report_path.name}"
|
|
140
|
+
text = report_path.read_text(encoding="utf-8", errors="replace")
|
|
141
|
+
yield item_id, label, text
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def iter_igor_corpus(path):
|
|
145
|
+
path = Path(path)
|
|
146
|
+
if not path.is_dir():
|
|
147
|
+
raise CorpusNotFoundError(f"Igor corpus path not found: {path}")
|
|
148
|
+
for asan_logs_dir in sorted(path.rglob("asan_logs")):
|
|
149
|
+
target = asan_logs_dir.parent.name
|
|
150
|
+
for label_dir in sorted(p for p in asan_logs_dir.iterdir() if p.is_dir()):
|
|
151
|
+
match = _POC_LABEL_RE.match(label_dir.name)
|
|
152
|
+
if not match:
|
|
153
|
+
continue
|
|
154
|
+
label = f"{target}::{match.group(1)}"
|
|
155
|
+
for report_path in sorted(label_dir.iterdir()):
|
|
156
|
+
if not report_path.is_file():
|
|
157
|
+
continue
|
|
158
|
+
item_id = f"{target}/{label_dir.name}/{report_path.name}"
|
|
159
|
+
text = report_path.read_text(encoding="utf-8", errors="replace")
|
|
160
|
+
yield item_id, label, text
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _fallback_bucket_key(record):
|
|
164
|
+
parts = [record["bug_class"]]
|
|
165
|
+
if record["access_type"]:
|
|
166
|
+
size = f" {record['access_size']}" if record["access_size"] is not None else ""
|
|
167
|
+
parts.append(f"({record['access_type']}{size})")
|
|
168
|
+
crash_stack = record["crash_stack"]
|
|
169
|
+
if crash_stack:
|
|
170
|
+
top = crash_stack[0]
|
|
171
|
+
ident = top.get("func") or top.get("addr")
|
|
172
|
+
if ident:
|
|
173
|
+
parts.append(f"in {ident}")
|
|
174
|
+
return " ".join(parts)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_corpus(corpus_path, corpus_kind):
|
|
178
|
+
kind = corpus_kind
|
|
179
|
+
if kind == "auto":
|
|
180
|
+
kind = detect_corpus_kind(corpus_path)
|
|
181
|
+
if kind == "micro":
|
|
182
|
+
return kind, list(iter_micro_corpus(corpus_path))
|
|
183
|
+
if kind == "igor":
|
|
184
|
+
return kind, list(iter_igor_corpus(corpus_path))
|
|
185
|
+
raise ValueError(f"unknown corpus kind: {corpus_kind}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_bench(corpus_path, corpus_kind="auto"):
|
|
189
|
+
resolved_kind, items = load_corpus(corpus_path, corpus_kind)
|
|
190
|
+
|
|
191
|
+
assignments = []
|
|
192
|
+
parse_failures = 0
|
|
193
|
+
nohash_count = 0
|
|
194
|
+
for item_id, label, text in items:
|
|
195
|
+
record = parse_sanitizer_output(text)
|
|
196
|
+
if record is None:
|
|
197
|
+
parse_failures += 1
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
major_hash, _minor_hash = stack_hashes(
|
|
201
|
+
record["crash_stack"], extra_context=[record["bug_class"]]
|
|
202
|
+
)
|
|
203
|
+
if major_hash is not None:
|
|
204
|
+
bucket = f"hash:{major_hash}"
|
|
205
|
+
else:
|
|
206
|
+
bucket = f"nohash:{_fallback_bucket_key(record)}"
|
|
207
|
+
nohash_count += 1
|
|
208
|
+
|
|
209
|
+
if resolved_kind == "igor":
|
|
210
|
+
# Bucket ids are namespaced by target: a real triage run only ever
|
|
211
|
+
# dedups crashes from one target's crash directory at a time, so
|
|
212
|
+
# pooling all 14 targets into one bench run must not let an
|
|
213
|
+
# unrelated bug in a different target collide into the same
|
|
214
|
+
# bucket just because it shares a generic bug_class.
|
|
215
|
+
target = label.split("::", 1)[0]
|
|
216
|
+
bucket = f"{target}::{bucket}"
|
|
217
|
+
assignments.append((item_id, label, bucket))
|
|
218
|
+
|
|
219
|
+
result = {
|
|
220
|
+
"corpus_path": str(corpus_path),
|
|
221
|
+
"corpus_kind": resolved_kind,
|
|
222
|
+
"n_reports": len(items),
|
|
223
|
+
"parse_failures": parse_failures,
|
|
224
|
+
"nohash_count": nohash_count,
|
|
225
|
+
"metrics": None,
|
|
226
|
+
}
|
|
227
|
+
if not assignments:
|
|
228
|
+
result["error"] = "no reports could be parsed and hashed; nothing to score"
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
result["metrics"] = compute_metrics(assignments)
|
|
232
|
+
if resolved_kind == "igor":
|
|
233
|
+
result["per_target"] = _per_target_metrics(assignments)
|
|
234
|
+
result["macro_metrics"] = _macro_metrics(result["per_target"])
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _per_target_metrics(assignments):
|
|
239
|
+
"""Split igor-corpus assignments by target (the `target::LABEL` namespace
|
|
240
|
+
`iter_igor_corpus` already applies to labels) and run `compute_metrics`
|
|
241
|
+
separately on each target's subset, so a target that is hiding a
|
|
242
|
+
disaster behind a healthy pooled/aggregate score (V1-RELEASE.md W3)
|
|
243
|
+
shows up on its own.
|
|
244
|
+
"""
|
|
245
|
+
by_target = defaultdict(list)
|
|
246
|
+
for item_id, label, bucket in assignments:
|
|
247
|
+
target = label.split("::", 1)[0]
|
|
248
|
+
by_target[target].append((item_id, label, bucket))
|
|
249
|
+
return {target: compute_metrics(items) for target, items in sorted(by_target.items())}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _macro_metrics(per_target):
|
|
253
|
+
"""Unweighted mean of purity/inverse_purity/f_measure across targets --
|
|
254
|
+
the macro aggregation the GPTrace ICSE'26 paper's own Table 3 uses (see
|
|
255
|
+
AGGREGATION_NOTE and benchmarks/results.md 'TASK 1'). Each target counts
|
|
256
|
+
once regardless of its report volume, unlike the pooled/micro metric in
|
|
257
|
+
`result["metrics"]` where a large target (e.g. xmllint at 58% of the
|
|
258
|
+
igor corpus by item count) dominates the number.
|
|
259
|
+
"""
|
|
260
|
+
targets = sorted(per_target)
|
|
261
|
+
n = len(targets)
|
|
262
|
+
if n == 0:
|
|
263
|
+
return None
|
|
264
|
+
macro = {"n_targets": n}
|
|
265
|
+
for key in MACRO_METRIC_KEYS:
|
|
266
|
+
macro[key] = sum(per_target[t][key] for t in targets) / n
|
|
267
|
+
return macro
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def render_table(result):
|
|
271
|
+
lines = []
|
|
272
|
+
lines.append(f"Corpus: {result['corpus_path']} ({result['corpus_kind']})")
|
|
273
|
+
lines.append(
|
|
274
|
+
f"Reports: {result['n_reports']} "
|
|
275
|
+
f"Parse failures: {result['parse_failures']} "
|
|
276
|
+
f"No-hash fallbacks: {result['nohash_count']}"
|
|
277
|
+
)
|
|
278
|
+
metrics = result.get("metrics")
|
|
279
|
+
if metrics is None:
|
|
280
|
+
lines.append(f"No metrics: {result.get('error', 'unknown error')}")
|
|
281
|
+
return "\n".join(lines)
|
|
282
|
+
|
|
283
|
+
lines.append("")
|
|
284
|
+
lines.append(f"{'metric':<20}{'value':>10}")
|
|
285
|
+
lines.append(f"{'-' * 30}")
|
|
286
|
+
lines.append(f"{'n_items':<20}{metrics['n_items']:>10}")
|
|
287
|
+
lines.append(f"{'n_labels':<20}{metrics['n_labels']:>10}")
|
|
288
|
+
lines.append(f"{'n_buckets':<20}{metrics['n_buckets']:>10}")
|
|
289
|
+
lines.append(f"{'purity':<20}{metrics['purity']:>10.4f}")
|
|
290
|
+
lines.append(f"{'inverse_purity':<20}{metrics['inverse_purity']:>10.4f}")
|
|
291
|
+
lines.append(f"{'f_measure':<20}{metrics['f_measure']:>10.4f}")
|
|
292
|
+
lines.append(
|
|
293
|
+
f"{'overcounting':<20}"
|
|
294
|
+
f"{metrics['overcounting_mean']:>10.4f} (std {metrics['overcounting_std']:.4f})"
|
|
295
|
+
)
|
|
296
|
+
lines.append(
|
|
297
|
+
f"{'undercounting':<20}"
|
|
298
|
+
f"{metrics['undercounting_mean']:>10.4f} (std {metrics['undercounting_std']:.4f})"
|
|
299
|
+
)
|
|
300
|
+
return "\n".join(lines)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def render_per_target_table(result):
|
|
304
|
+
"""Render the igor corpus's per-target breakdown, worst f_measure first.
|
|
305
|
+
|
|
306
|
+
Sorted on f_measure rather than purity or inverse_purity alone: F is
|
|
307
|
+
the only column here that is low whenever *either* purity or inverse
|
|
308
|
+
purity is bad for that target, so it surfaces both failure shapes at
|
|
309
|
+
the top in one pass -- the literature's cited "825 buckets for 8
|
|
310
|
+
bugs" disaster (an inverse-purity failure, one bug shattered across
|
|
311
|
+
hundreds of buckets) and a silent-bug-merging purity failure would
|
|
312
|
+
both rise to the top under this ordering, whereas sorting on inverse
|
|
313
|
+
purity alone could leave a target whose real problem is purity
|
|
314
|
+
buried further down the table.
|
|
315
|
+
"""
|
|
316
|
+
per_target = result.get("per_target")
|
|
317
|
+
if not per_target:
|
|
318
|
+
return "No per-target metrics (per-target breakdown is igor-corpus only)."
|
|
319
|
+
|
|
320
|
+
lines = []
|
|
321
|
+
lines.append("Per-target breakdown, worst f_measure first:")
|
|
322
|
+
lines.append("")
|
|
323
|
+
header = (
|
|
324
|
+
f"{'target':<45}{'n_items':>9}{'n_labels':>9}{'n_buckets':>10}"
|
|
325
|
+
f"{'purity':>9}{'inv_purity':>11}{'f_measure':>10}"
|
|
326
|
+
)
|
|
327
|
+
lines.append(header)
|
|
328
|
+
lines.append("-" * len(header))
|
|
329
|
+
for target, metrics in sorted(per_target.items(), key=lambda kv: kv[1]["f_measure"]):
|
|
330
|
+
lines.append(
|
|
331
|
+
f"{target:<45}{metrics['n_items']:>9}{metrics['n_labels']:>9}"
|
|
332
|
+
f"{metrics['n_buckets']:>10}{metrics['purity']:>9.4f}"
|
|
333
|
+
f"{metrics['inverse_purity']:>11.4f}{metrics['f_measure']:>10.4f}"
|
|
334
|
+
)
|
|
335
|
+
return "\n".join(lines)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def render_aggregation_table(result):
|
|
339
|
+
"""Print micro (pooled) and macro (per-target mean) purity/IP/F side by
|
|
340
|
+
side, labelled, with a one-line note on which one the published
|
|
341
|
+
baselines use. Igor-corpus only -- the micro corpus has no per-target
|
|
342
|
+
axis to average over. See planning/V1-RELEASE.md E2/E3 and
|
|
343
|
+
benchmarks/results.md 'TASK 1'/'TASK 2'.
|
|
344
|
+
"""
|
|
345
|
+
metrics = result.get("metrics")
|
|
346
|
+
macro = result.get("macro_metrics")
|
|
347
|
+
if metrics is None or macro is None:
|
|
348
|
+
return "No micro/macro comparison (igor corpus only)."
|
|
349
|
+
|
|
350
|
+
lines = []
|
|
351
|
+
lines.append(f"Aggregation comparison ({macro['n_targets']} targets):")
|
|
352
|
+
lines.append("")
|
|
353
|
+
header = f"{'aggregation':<28}{'purity':>10}{'inv_purity':>12}{'f_measure':>11}"
|
|
354
|
+
lines.append(header)
|
|
355
|
+
lines.append("-" * len(header))
|
|
356
|
+
lines.append(
|
|
357
|
+
f"{'micro (pooled)':<28}{metrics['purity']:>10.4f}"
|
|
358
|
+
f"{metrics['inverse_purity']:>12.4f}{metrics['f_measure']:>11.4f}"
|
|
359
|
+
)
|
|
360
|
+
lines.append(
|
|
361
|
+
f"{'macro (per-target mean)':<28}{macro['purity']:>10.4f}"
|
|
362
|
+
f"{macro['inverse_purity']:>12.4f}{macro['f_measure']:>11.4f}"
|
|
363
|
+
)
|
|
364
|
+
lines.append("")
|
|
365
|
+
lines.append(AGGREGATION_NOTE)
|
|
366
|
+
return "\n".join(lines)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def diff_against_baseline(result, baseline):
|
|
370
|
+
result_metrics = result.get("metrics")
|
|
371
|
+
baseline_metrics = baseline.get("metrics")
|
|
372
|
+
if result_metrics is None or baseline_metrics is None:
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
lines = []
|
|
376
|
+
for key in METRIC_KEYS:
|
|
377
|
+
current = result_metrics.get(key)
|
|
378
|
+
previous = baseline_metrics.get(key)
|
|
379
|
+
if current is None or previous is None:
|
|
380
|
+
continue
|
|
381
|
+
delta = current - previous
|
|
382
|
+
sign = "+" if delta >= 0 else ""
|
|
383
|
+
lines.append(f"{key:<20}{previous:>10.4f} -> {current:>10.4f} (delta {sign}{delta:.4f})")
|
|
384
|
+
return lines
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def check_regression(result, baseline, fail_purity_drop_points, fail_under_f):
|
|
388
|
+
result_metrics = result.get("metrics")
|
|
389
|
+
reasons = []
|
|
390
|
+
|
|
391
|
+
if fail_under_f is not None and result_metrics is not None:
|
|
392
|
+
if result_metrics["f_measure"] < fail_under_f:
|
|
393
|
+
reasons.append(
|
|
394
|
+
f"f_measure {result_metrics['f_measure']:.4f} is below "
|
|
395
|
+
f"--fail-under-f {fail_under_f:.4f}"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if baseline is not None and result_metrics is not None:
|
|
399
|
+
baseline_metrics = baseline.get("metrics")
|
|
400
|
+
if baseline_metrics is not None:
|
|
401
|
+
purity_drop = baseline_metrics["purity"] - result_metrics["purity"]
|
|
402
|
+
threshold = fail_purity_drop_points / 100.0
|
|
403
|
+
if purity_drop > threshold:
|
|
404
|
+
reasons.append(
|
|
405
|
+
f"purity dropped {purity_drop * 100:.2f} points "
|
|
406
|
+
f"(baseline {baseline_metrics['purity']:.4f} -> "
|
|
407
|
+
f"{result_metrics['purity']:.4f}), exceeds "
|
|
408
|
+
f"--fail-purity-drop {fail_purity_drop_points:.2f}"
|
|
409
|
+
)
|
|
410
|
+
f_delta = result_metrics["f_measure"] - baseline_metrics["f_measure"]
|
|
411
|
+
if f_delta < 0:
|
|
412
|
+
reasons.append(
|
|
413
|
+
f"f_measure dropped from {baseline_metrics['f_measure']:.4f} "
|
|
414
|
+
f"to {result_metrics['f_measure']:.4f}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return reasons
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def write_results(path, result):
|
|
421
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
422
|
+
json.dump(result, handle, indent=2, sort_keys=True)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def load_results(path):
|
|
426
|
+
with open(path, encoding="utf-8") as handle:
|
|
427
|
+
return json.load(handle)
|