equiframe-benchmark 0.12.4__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.
- equiframe_benchmark/PROTOCOL_V2.md +172 -0
- equiframe_benchmark/__init__.py +284 -0
- equiframe_benchmark/__main__.py +3 -0
- equiframe_benchmark/_scale_workload.py +375 -0
- equiframe_benchmark/calibration_types.py +112 -0
- equiframe_benchmark/cli.py +196 -0
- equiframe_benchmark/commands.py +149 -0
- equiframe_benchmark/commands_scale.py +38 -0
- equiframe_benchmark/commands_step10.py +275 -0
- equiframe_benchmark/commands_v2.py +206 -0
- equiframe_benchmark/cost_table.py +62 -0
- equiframe_benchmark/costs.py +788 -0
- equiframe_benchmark/distribution.py +932 -0
- equiframe_benchmark/evaluation.py +614 -0
- equiframe_benchmark/evaluation_v2.py +428 -0
- equiframe_benchmark/fixtures.py +299 -0
- equiframe_benchmark/freeze.py +869 -0
- equiframe_benchmark/freeze_v2.py +412 -0
- equiframe_benchmark/integration.py +655 -0
- equiframe_benchmark/io.py +29 -0
- equiframe_benchmark/models.py +313 -0
- equiframe_benchmark/models_v2.py +13 -0
- equiframe_benchmark/mutations.py +34 -0
- equiframe_benchmark/oracle.py +264 -0
- equiframe_benchmark/pairwise.py +248 -0
- equiframe_benchmark/pilot.py +254 -0
- equiframe_benchmark/py.typed +0 -0
- equiframe_benchmark/reconciliation.py +294 -0
- equiframe_benchmark/reproduction.py +542 -0
- equiframe_benchmark/scale_io.py +19 -0
- equiframe_benchmark/scale_process.py +1077 -0
- equiframe_benchmark/scale_types.py +120 -0
- equiframe_benchmark/scaling.py +529 -0
- equiframe_benchmark/schemas/protocol-v2/CommonSupportV2-2.0.schema.json +327 -0
- equiframe_benchmark/schemas/protocol-v2/ContrastSpecV2-2.0.schema.json +37 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationConfigV2-2.0.schema.json +184 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationReportV2-2.0.schema.json +1237 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationStratumV2-2.0.schema.json +747 -0
- equiframe_benchmark/schemas/protocol-v2/FreezeDefinitionV2-2.0.schema.json +5951 -0
- equiframe_benchmark/schemas/protocol-v2/FreezeManifestV2-2.0.schema.json +6222 -0
- equiframe_benchmark/schemas/protocol-v2/PrincipalContrastV2-2.0.schema.json +578 -0
- equiframe_benchmark/schemas/protocol-v2/ProtocolReviewV2-2.0.schema.json +56 -0
- equiframe_benchmark/schemas/protocol-v2/SignedMetricV2-2.0.schema.json +51 -0
- equiframe_benchmark/schemas/protocol-v2/StudyCaseResultV2-2.0.schema.json +4881 -0
- equiframe_benchmark/schemas/protocol-v2/StudyResultV2-2.0.schema.json +8370 -0
- equiframe_benchmark/schemas/protocol-v2/TableExportV2-2.0.schema.json +75 -0
- equiframe_benchmark/selection.py +178 -0
- equiframe_benchmark/snapshot.py +24 -0
- equiframe_benchmark/splits.py +92 -0
- equiframe_benchmark/study.py +526 -0
- equiframe_benchmark/study_bundle.py +379 -0
- equiframe_benchmark/study_bundle_v2.py +388 -0
- equiframe_benchmark/study_v2.py +544 -0
- equiframe_benchmark/tables.py +347 -0
- equiframe_benchmark/tables_v2.py +340 -0
- equiframe_benchmark/training_types.py +52 -0
- equiframe_benchmark-0.12.4.dist-info/METADATA +343 -0
- equiframe_benchmark-0.12.4.dist-info/RECORD +62 -0
- equiframe_benchmark-0.12.4.dist-info/WHEEL +4 -0
- equiframe_benchmark-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe_benchmark-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe_benchmark-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +8 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""Pure, paired evaluation of evidence verified by the experimental producer.
|
|
2
|
+
|
|
3
|
+
This module neither trains detectors nor authenticates review identities. A final
|
|
4
|
+
hash binding records the producer's intent, not independent scientific approval.
|
|
5
|
+
All intervals resample whole dependency groups and estimate execution aggregates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import random
|
|
11
|
+
import re
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from fractions import Fraction
|
|
14
|
+
from math import ceil
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
from equiframe import ComponentDecision, Family
|
|
18
|
+
from equiframe.detector_types import ScoreValue, numeric_score
|
|
19
|
+
from pydantic import Field, model_validator
|
|
20
|
+
from typing_extensions import Self
|
|
21
|
+
|
|
22
|
+
from equiframe_benchmark.models import Digest, Document, Text, digest
|
|
23
|
+
|
|
24
|
+
Method = Literal["R", "M", "B", "H"]
|
|
25
|
+
Engine = Literal["pandas", "polars", "duckdb"]
|
|
26
|
+
NonNegativeInt = Annotated[int, Field(ge=0)]
|
|
27
|
+
PositiveInt = Annotated[int, Field(ge=1)]
|
|
28
|
+
METHODS: tuple[Method, ...] = ("R", "M", "B", "H")
|
|
29
|
+
SCORE_METHODS: tuple[Literal["M", "B"], ...] = ("M", "B")
|
|
30
|
+
FAMILIES: tuple[Family, ...] = ("nulls_types", "joins", "duplicates_order", "aggregations", "dates")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EvaluationRecord(Document):
|
|
34
|
+
"""One unique native execution; migration is declared, never guessed from IDs."""
|
|
35
|
+
|
|
36
|
+
case_id: Text
|
|
37
|
+
scenario_id: Text
|
|
38
|
+
group_id: Text
|
|
39
|
+
family: Family
|
|
40
|
+
migration: Text
|
|
41
|
+
engine: Engine
|
|
42
|
+
candidate_sha256: Digest
|
|
43
|
+
pipeline_sha256: Digest | None
|
|
44
|
+
oracle_sha256: Digest | None
|
|
45
|
+
oracle_state: Literal["pending", "disagreement", "confirmed", "not_evaluable"]
|
|
46
|
+
oracle_label: Literal["normal", "divergent"] | None
|
|
47
|
+
r: ComponentDecision
|
|
48
|
+
m: ComponentDecision
|
|
49
|
+
b: ComponentDecision
|
|
50
|
+
h: ComponentDecision
|
|
51
|
+
m_score: Annotated[float, Field(gt=0, le=1)] | None
|
|
52
|
+
b_score: ScoreValue | None
|
|
53
|
+
detector_bundle_sha256: Digest | None
|
|
54
|
+
freeze_sha256: Digest | None
|
|
55
|
+
failure_reason: Text | None = None
|
|
56
|
+
|
|
57
|
+
@model_validator(mode="after")
|
|
58
|
+
def consistent(self) -> Self:
|
|
59
|
+
confirmed = self.oracle_state == "confirmed"
|
|
60
|
+
if confirmed != (self.oracle_label is not None):
|
|
61
|
+
raise ValueError("only a confirmed independent label can enter evaluation")
|
|
62
|
+
if confirmed and self.oracle_sha256 is None:
|
|
63
|
+
raise ValueError("confirmed labels require their evidence fingerprint")
|
|
64
|
+
for decision, score in ((self.m, self.m_score), (self.b, self.b_score)):
|
|
65
|
+
if decision.complete and (score is None or self.detector_bundle_sha256 is None):
|
|
66
|
+
raise ValueError("complete statistical decisions require score and bundle")
|
|
67
|
+
if not decision.complete and decision.decision != "not_evaluable":
|
|
68
|
+
raise ValueError("uncalibrated statistical components cannot claim alerts")
|
|
69
|
+
if (self.m_score is not None or self.b_score is not None) and (
|
|
70
|
+
self.detector_bundle_sha256 is None
|
|
71
|
+
):
|
|
72
|
+
raise ValueError("scores require their model bundle provenance")
|
|
73
|
+
decisions = (self.r.decision, self.m.decision)
|
|
74
|
+
expected = (
|
|
75
|
+
"alert"
|
|
76
|
+
if "alert" in decisions
|
|
77
|
+
else "no_alert"
|
|
78
|
+
if decisions == ("no_alert", "no_alert")
|
|
79
|
+
else "not_evaluable"
|
|
80
|
+
)
|
|
81
|
+
if self.h.decision != expected or self.h.complete != (self.r.complete and self.m.complete):
|
|
82
|
+
raise ValueError("H must preserve the OR and availability of R and M")
|
|
83
|
+
if self.pipeline_sha256 is None:
|
|
84
|
+
if self.failure_reason is None or any(_component(self, m).complete for m in METHODS):
|
|
85
|
+
raise ValueError("a missing pipeline is an explicit unavailable execution")
|
|
86
|
+
if self.m_score is not None or self.b_score is not None:
|
|
87
|
+
raise ValueError("a missing pipeline cannot contain invented scores")
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BootstrapPlan(Document):
|
|
92
|
+
"""Explicit support justification; a group count alone never proves independence."""
|
|
93
|
+
|
|
94
|
+
seed: Annotated[int, Field(ge=0, le=2**32 - 1)]
|
|
95
|
+
repetitions: PositiveInt = 2000
|
|
96
|
+
confidence: Text = "0.95"
|
|
97
|
+
min_groups: PositiveInt
|
|
98
|
+
independence_supported: bool
|
|
99
|
+
rationale: Text
|
|
100
|
+
|
|
101
|
+
@model_validator(mode="after")
|
|
102
|
+
def probability(self) -> Self:
|
|
103
|
+
if not re.fullmatch(r"0\.[0-9]{1,18}", self.confidence):
|
|
104
|
+
raise ValueError("confidence must be an explicit decimal probability")
|
|
105
|
+
if not 0 < Fraction(self.confidence) < 1:
|
|
106
|
+
raise ValueError("confidence must lie strictly between zero and one")
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class EvaluationConfig(Document):
|
|
111
|
+
mode: Literal["pilot", "final"]
|
|
112
|
+
freeze_sha256: Digest | None
|
|
113
|
+
bootstrap: BootstrapPlan
|
|
114
|
+
expected_families: Annotated[tuple[Family, ...], Field(min_length=1)] = FAMILIES
|
|
115
|
+
expected_migrations: tuple[Text, ...] = ()
|
|
116
|
+
|
|
117
|
+
@model_validator(mode="after")
|
|
118
|
+
def binding(self) -> Self:
|
|
119
|
+
if (self.mode == "final") != (self.freeze_sha256 is not None):
|
|
120
|
+
raise ValueError("final evaluation requires a freeze; pilot has no final binding")
|
|
121
|
+
for scopes in (self.expected_families, self.expected_migrations):
|
|
122
|
+
if len(scopes) != len(set(scopes)):
|
|
123
|
+
raise ValueError("declared evaluation scopes must be unique")
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def evaluation_protocol_sha256(config: EvaluationConfig) -> str:
|
|
128
|
+
"""Noncircular fingerprint to fix inside a freeze before its digest exists."""
|
|
129
|
+
config = EvaluationConfig.model_validate(config)
|
|
130
|
+
payload = {
|
|
131
|
+
"schema_version": "1.0",
|
|
132
|
+
"bootstrap": config.bootstrap.model_dump(mode="json"),
|
|
133
|
+
"expected_families": config.expected_families,
|
|
134
|
+
"expected_migrations": config.expected_migrations,
|
|
135
|
+
"principal": "confirmed_label_and_complete_R_M_B_H",
|
|
136
|
+
"average_precision": "tied_thresholds_two_classes_homogeneous_family_and_bundle",
|
|
137
|
+
"interval": "whole_groups_execution_aggregate_nearest_ceil_no_undefined_omission",
|
|
138
|
+
}
|
|
139
|
+
return hashlib.sha256(
|
|
140
|
+
json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
|
141
|
+
).hexdigest()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class MetricValue(Document):
|
|
145
|
+
"""Exact rational estimate, or an explicit undefined denominator/reason."""
|
|
146
|
+
|
|
147
|
+
numerator: NonNegativeInt
|
|
148
|
+
denominator: NonNegativeInt
|
|
149
|
+
value: float | None
|
|
150
|
+
reason: Text | None
|
|
151
|
+
|
|
152
|
+
@model_validator(mode="after")
|
|
153
|
+
def arithmetic(self) -> Self:
|
|
154
|
+
if self.denominator == 0:
|
|
155
|
+
if self.numerator or self.value is not None or self.reason is None:
|
|
156
|
+
raise ValueError("undefined metrics require zero counts and an explicit reason")
|
|
157
|
+
elif self.reason is not None or self.value != float(
|
|
158
|
+
Fraction(self.numerator, self.denominator)
|
|
159
|
+
):
|
|
160
|
+
raise ValueError("metric value must equal its exact numerator/denominator")
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ConfusionMetrics(Document):
|
|
165
|
+
tp: NonNegativeInt
|
|
166
|
+
fp: NonNegativeInt
|
|
167
|
+
fn: NonNegativeInt
|
|
168
|
+
tn: NonNegativeInt
|
|
169
|
+
precision: MetricValue
|
|
170
|
+
recall: MetricValue
|
|
171
|
+
f1: MetricValue
|
|
172
|
+
false_positive_rate: MetricValue
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class MethodEvaluation(Document):
|
|
176
|
+
method: Method
|
|
177
|
+
complete_case_ids: tuple[Text, ...]
|
|
178
|
+
own_case_ids: tuple[Text, ...]
|
|
179
|
+
partial_alert_case_ids: tuple[Text, ...]
|
|
180
|
+
unavailable_case_ids: tuple[Text, ...]
|
|
181
|
+
coverage: MetricValue
|
|
182
|
+
principal: ConfusionMetrics
|
|
183
|
+
own_support: ConfusionMetrics
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class AveragePrecisionEvaluation(Document):
|
|
187
|
+
method: Literal["M", "B"]
|
|
188
|
+
principal_case_ids: tuple[Text, ...]
|
|
189
|
+
own_case_ids: tuple[Text, ...]
|
|
190
|
+
principal: MetricValue
|
|
191
|
+
own_support: MetricValue
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class IncrementalEvaluation(Document):
|
|
195
|
+
additional_true_positive_case_ids: tuple[Text, ...]
|
|
196
|
+
additional_false_positive_case_ids: tuple[Text, ...]
|
|
197
|
+
additional_true_positive_rate: MetricValue
|
|
198
|
+
additional_false_positive_rate: MetricValue
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class CoverageSummary(Document):
|
|
202
|
+
total_cases: NonNegativeInt
|
|
203
|
+
oracle_confirmed: NonNegativeInt
|
|
204
|
+
oracle_pending: NonNegativeInt
|
|
205
|
+
oracle_disagreement: NonNegativeInt
|
|
206
|
+
oracle_not_evaluable: NonNegativeInt
|
|
207
|
+
failed_case_ids: tuple[Text, ...]
|
|
208
|
+
principal_case_ids: tuple[Text, ...]
|
|
209
|
+
excluded_case_ids: tuple[Text, ...]
|
|
210
|
+
principal_coverage: MetricValue
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class IntervalEstimate(Document):
|
|
214
|
+
metric: Text
|
|
215
|
+
lower: float | None
|
|
216
|
+
upper: float | None
|
|
217
|
+
defined_replicates: NonNegativeInt
|
|
218
|
+
undefined_replicates: NonNegativeInt
|
|
219
|
+
reason: Text | None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class GroupBootstrapResult(Document):
|
|
223
|
+
group_ids: tuple[Text, ...]
|
|
224
|
+
attempted_replicates: NonNegativeInt
|
|
225
|
+
lower_rank: PositiveInt | None
|
|
226
|
+
upper_rank: PositiveInt | None
|
|
227
|
+
intervals: tuple[IntervalEstimate, ...]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class EvaluationStratum(Document):
|
|
231
|
+
kind: Literal["global", "family", "migration", "family_migration"]
|
|
232
|
+
family: Family | None
|
|
233
|
+
migration: Text | None
|
|
234
|
+
coverage: CoverageSummary
|
|
235
|
+
methods: tuple[MethodEvaluation, ...]
|
|
236
|
+
average_precision: tuple[AveragePrecisionEvaluation, ...]
|
|
237
|
+
incremental: IncrementalEvaluation
|
|
238
|
+
bootstrap: GroupBootstrapResult
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class EvaluationReport(Document):
|
|
242
|
+
"""Self-contained derived report, recalculated on every model/JSON validation."""
|
|
243
|
+
|
|
244
|
+
scientific: bool
|
|
245
|
+
config_sha256: Digest
|
|
246
|
+
protocol_sha256: Digest
|
|
247
|
+
records_sha256: Digest
|
|
248
|
+
config: EvaluationConfig
|
|
249
|
+
records: tuple[EvaluationRecord, ...]
|
|
250
|
+
strata: tuple[EvaluationStratum, ...]
|
|
251
|
+
|
|
252
|
+
@model_validator(mode="after")
|
|
253
|
+
def recalculate(self) -> Self:
|
|
254
|
+
_validate_inputs(self.records, self.config)
|
|
255
|
+
if self.records != tuple(sorted(self.records, key=lambda r: r.case_id)):
|
|
256
|
+
raise ValueError("report records require canonical case order")
|
|
257
|
+
if (
|
|
258
|
+
self.scientific != (self.config.mode == "final")
|
|
259
|
+
or self.config_sha256 != digest(self.config)
|
|
260
|
+
or self.protocol_sha256 != evaluation_protocol_sha256(self.config)
|
|
261
|
+
or self.records_sha256 != _records_digest(self.records)
|
|
262
|
+
or self.strata != _strata(self.records, self.config)
|
|
263
|
+
):
|
|
264
|
+
raise ValueError("report disagrees with its bound records, protocol or calculations")
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _component(record: EvaluationRecord, method: Method) -> ComponentDecision:
|
|
269
|
+
return {"R": record.r, "M": record.m, "B": record.b, "H": record.h}[method]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _confirmed(record: EvaluationRecord) -> bool:
|
|
273
|
+
return record.oracle_state == "confirmed"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _principal(record: EvaluationRecord) -> bool:
|
|
277
|
+
return _confirmed(record) and all(_component(record, method).complete for method in METHODS)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _ratio(numerator: int, denominator: int, reason: str = "zero_denominator") -> MetricValue:
|
|
281
|
+
return MetricValue(
|
|
282
|
+
numerator=numerator if denominator else 0,
|
|
283
|
+
denominator=denominator,
|
|
284
|
+
value=float(Fraction(numerator, denominator)) if denominator else None,
|
|
285
|
+
reason=None if denominator else reason,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _confusion(records: tuple[EvaluationRecord, ...], method: Method) -> ConfusionMetrics:
|
|
290
|
+
tp = fp = fn = tn = 0
|
|
291
|
+
for record in records:
|
|
292
|
+
positive = record.oracle_label == "divergent"
|
|
293
|
+
alert = _component(record, method).decision == "alert"
|
|
294
|
+
tp += positive and alert
|
|
295
|
+
fp += not positive and alert
|
|
296
|
+
fn += positive and not alert
|
|
297
|
+
tn += not positive and not alert
|
|
298
|
+
return ConfusionMetrics(
|
|
299
|
+
tp=tp,
|
|
300
|
+
fp=fp,
|
|
301
|
+
fn=fn,
|
|
302
|
+
tn=tn,
|
|
303
|
+
precision=_ratio(tp, tp + fp),
|
|
304
|
+
recall=_ratio(tp, tp + fn),
|
|
305
|
+
f1=_ratio(2 * tp, 2 * tp + fp + fn),
|
|
306
|
+
false_positive_rate=_ratio(fp, fp + tn),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _ap(records: tuple[EvaluationRecord, ...], method: Literal["M", "B"]) -> MetricValue:
|
|
311
|
+
if not records:
|
|
312
|
+
return _ratio(0, 0, "no_score_support")
|
|
313
|
+
if (
|
|
314
|
+
len({r.family for r in records}) != 1
|
|
315
|
+
or len({r.detector_bundle_sha256 for r in records}) != 1
|
|
316
|
+
):
|
|
317
|
+
return _ratio(0, 0, "not_defined_heterogeneous_model_scores")
|
|
318
|
+
positives = sum(r.oracle_label == "divergent" for r in records)
|
|
319
|
+
if positives in (0, len(records)):
|
|
320
|
+
return _ratio(0, 0, "average_precision_requires_two_classes")
|
|
321
|
+
thresholds: dict[float, list[int]] = defaultdict(lambda: [0, 0])
|
|
322
|
+
for record in records:
|
|
323
|
+
score = record.m_score if method == "M" else record.b_score
|
|
324
|
+
if score is None:
|
|
325
|
+
raise ValueError("average precision support contains a missing score")
|
|
326
|
+
bucket = thresholds[numeric_score(score)]
|
|
327
|
+
bucket[0] += record.oracle_label == "divergent"
|
|
328
|
+
bucket[1] += 1
|
|
329
|
+
accumulated_positive = accumulated_total = 0
|
|
330
|
+
result = Fraction(0)
|
|
331
|
+
for threshold in sorted(thresholds, reverse=True):
|
|
332
|
+
positive, total = thresholds[threshold]
|
|
333
|
+
accumulated_positive += positive
|
|
334
|
+
accumulated_total += total
|
|
335
|
+
result += Fraction(positive, positives) * Fraction(accumulated_positive, accumulated_total)
|
|
336
|
+
return _ratio(result.numerator, result.denominator)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _incremental(records: tuple[EvaluationRecord, ...]) -> IncrementalEvaluation:
|
|
340
|
+
additional = tuple(r for r in records if r.h.decision == "alert" and r.r.decision == "no_alert")
|
|
341
|
+
true = tuple(r.case_id for r in additional if r.oracle_label == "divergent")
|
|
342
|
+
false = tuple(r.case_id for r in additional if r.oracle_label == "normal")
|
|
343
|
+
positives = sum(r.oracle_label == "divergent" for r in records)
|
|
344
|
+
return IncrementalEvaluation(
|
|
345
|
+
additional_true_positive_case_ids=true,
|
|
346
|
+
additional_false_positive_case_ids=false,
|
|
347
|
+
additional_true_positive_rate=_ratio(len(true), positives),
|
|
348
|
+
additional_false_positive_rate=_ratio(len(false), len(records) - positives),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _estimates(records: tuple[EvaluationRecord, ...]) -> dict[str, MetricValue]:
|
|
353
|
+
estimates: dict[str, MetricValue] = {}
|
|
354
|
+
for method in METHODS:
|
|
355
|
+
metrics = _confusion(records, method)
|
|
356
|
+
for name in ("precision", "recall", "f1", "false_positive_rate"):
|
|
357
|
+
estimates[f"{method}.{name}"] = getattr(metrics, name)
|
|
358
|
+
estimates["M.average_precision"] = _ap(records, "M")
|
|
359
|
+
estimates["B.average_precision"] = _ap(records, "B")
|
|
360
|
+
incremental = _incremental(records)
|
|
361
|
+
estimates["H1.additional_true_positive_rate"] = incremental.additional_true_positive_rate
|
|
362
|
+
estimates["H2.additional_false_positive_rate"] = incremental.additional_false_positive_rate
|
|
363
|
+
return estimates
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _bootstrap(records: tuple[EvaluationRecord, ...], plan: BootstrapPlan) -> GroupBootstrapResult:
|
|
367
|
+
groups: dict[str, list[EvaluationRecord]] = defaultdict(list)
|
|
368
|
+
for record in records:
|
|
369
|
+
groups[record.group_id].append(record)
|
|
370
|
+
group_ids = tuple(sorted(groups))
|
|
371
|
+
points = _estimates(records)
|
|
372
|
+
reason = (
|
|
373
|
+
"independence_not_supported"
|
|
374
|
+
if not plan.independence_supported
|
|
375
|
+
else "insufficient_groups"
|
|
376
|
+
if len(group_ids) < plan.min_groups
|
|
377
|
+
else None
|
|
378
|
+
)
|
|
379
|
+
if reason is not None:
|
|
380
|
+
return GroupBootstrapResult(
|
|
381
|
+
group_ids=group_ids,
|
|
382
|
+
attempted_replicates=0,
|
|
383
|
+
lower_rank=None,
|
|
384
|
+
upper_rank=None,
|
|
385
|
+
intervals=tuple(
|
|
386
|
+
IntervalEstimate(
|
|
387
|
+
metric=name,
|
|
388
|
+
lower=None,
|
|
389
|
+
upper=None,
|
|
390
|
+
defined_replicates=0,
|
|
391
|
+
undefined_replicates=0,
|
|
392
|
+
reason=reason,
|
|
393
|
+
)
|
|
394
|
+
for name in points
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
samples: dict[str, list[Fraction]] = {name: [] for name in points}
|
|
398
|
+
undefined = dict.fromkeys(points, 0)
|
|
399
|
+
rng = random.Random(plan.seed)
|
|
400
|
+
for _ in range(plan.repetitions):
|
|
401
|
+
sampled = tuple(
|
|
402
|
+
record for _ in group_ids for record in groups[group_ids[rng.randrange(len(group_ids))]]
|
|
403
|
+
)
|
|
404
|
+
for name, value in _estimates(sampled).items():
|
|
405
|
+
if value.value is None:
|
|
406
|
+
undefined[name] += 1
|
|
407
|
+
else:
|
|
408
|
+
samples[name].append(Fraction(value.numerator, value.denominator))
|
|
409
|
+
tail = (1 - Fraction(plan.confidence)) / 2
|
|
410
|
+
lower_rank = max(1, ceil(tail * plan.repetitions))
|
|
411
|
+
upper_rank = ceil((1 - tail) * plan.repetitions)
|
|
412
|
+
intervals: list[IntervalEstimate] = []
|
|
413
|
+
for name, point in points.items():
|
|
414
|
+
values = sorted(samples[name])
|
|
415
|
+
failure = (
|
|
416
|
+
point.reason
|
|
417
|
+
if point.value is None
|
|
418
|
+
else "undefined_bootstrap_replicates"
|
|
419
|
+
if undefined[name]
|
|
420
|
+
else None
|
|
421
|
+
)
|
|
422
|
+
intervals.append(
|
|
423
|
+
IntervalEstimate(
|
|
424
|
+
metric=name,
|
|
425
|
+
lower=float(values[lower_rank - 1]) if failure is None else None,
|
|
426
|
+
upper=float(values[upper_rank - 1]) if failure is None else None,
|
|
427
|
+
defined_replicates=len(values),
|
|
428
|
+
undefined_replicates=undefined[name],
|
|
429
|
+
reason=failure,
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
return GroupBootstrapResult(
|
|
433
|
+
group_ids=group_ids,
|
|
434
|
+
attempted_replicates=plan.repetitions,
|
|
435
|
+
lower_rank=lower_rank,
|
|
436
|
+
upper_rank=upper_rank,
|
|
437
|
+
intervals=tuple(intervals),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _stratum(
|
|
442
|
+
records: tuple[EvaluationRecord, ...],
|
|
443
|
+
plan: BootstrapPlan,
|
|
444
|
+
kind: Literal["global", "family", "migration", "family_migration"],
|
|
445
|
+
family: Family | None = None,
|
|
446
|
+
migration: str | None = None,
|
|
447
|
+
) -> EvaluationStratum:
|
|
448
|
+
principal = tuple(r for r in records if _principal(r))
|
|
449
|
+
labelled = tuple(r for r in records if _confirmed(r))
|
|
450
|
+
principal_ids = tuple(r.case_id for r in principal)
|
|
451
|
+
support: dict[Method, tuple[EvaluationRecord, ...]] = {}
|
|
452
|
+
methods: list[MethodEvaluation] = []
|
|
453
|
+
for method in METHODS:
|
|
454
|
+
own = tuple(r for r in labelled if _component(r, method).complete)
|
|
455
|
+
support[method] = own
|
|
456
|
+
methods.append(
|
|
457
|
+
MethodEvaluation(
|
|
458
|
+
method=method,
|
|
459
|
+
complete_case_ids=tuple(
|
|
460
|
+
r.case_id for r in records if _component(r, method).complete
|
|
461
|
+
),
|
|
462
|
+
own_case_ids=tuple(r.case_id for r in own),
|
|
463
|
+
partial_alert_case_ids=tuple(
|
|
464
|
+
r.case_id
|
|
465
|
+
for r in records
|
|
466
|
+
if (
|
|
467
|
+
not _component(r, method).complete
|
|
468
|
+
and _component(r, method).decision == "alert"
|
|
469
|
+
)
|
|
470
|
+
),
|
|
471
|
+
unavailable_case_ids=tuple(
|
|
472
|
+
r.case_id
|
|
473
|
+
for r in records
|
|
474
|
+
if (_component(r, method).decision == "not_evaluable")
|
|
475
|
+
),
|
|
476
|
+
coverage=_ratio(len(own), len(labelled), "no_confirmed_labels"),
|
|
477
|
+
principal=_confusion(principal, method),
|
|
478
|
+
own_support=_confusion(own, method),
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
return EvaluationStratum(
|
|
482
|
+
kind=kind,
|
|
483
|
+
family=family,
|
|
484
|
+
migration=migration,
|
|
485
|
+
coverage=CoverageSummary(
|
|
486
|
+
total_cases=len(records),
|
|
487
|
+
oracle_confirmed=len(labelled),
|
|
488
|
+
oracle_pending=sum(r.oracle_state == "pending" for r in records),
|
|
489
|
+
oracle_disagreement=sum(r.oracle_state == "disagreement" for r in records),
|
|
490
|
+
oracle_not_evaluable=sum(r.oracle_state == "not_evaluable" for r in records),
|
|
491
|
+
failed_case_ids=tuple(r.case_id for r in records if r.failure_reason is not None),
|
|
492
|
+
principal_case_ids=principal_ids,
|
|
493
|
+
excluded_case_ids=tuple(r.case_id for r in records if not _principal(r)),
|
|
494
|
+
principal_coverage=_ratio(len(principal), len(labelled), "no_confirmed_labels"),
|
|
495
|
+
),
|
|
496
|
+
methods=tuple(methods),
|
|
497
|
+
average_precision=tuple(
|
|
498
|
+
AveragePrecisionEvaluation(
|
|
499
|
+
method=method,
|
|
500
|
+
principal_case_ids=principal_ids,
|
|
501
|
+
own_case_ids=tuple(r.case_id for r in support[method]),
|
|
502
|
+
principal=_ap(principal, method),
|
|
503
|
+
own_support=_ap(support[method], method),
|
|
504
|
+
)
|
|
505
|
+
for method in SCORE_METHODS
|
|
506
|
+
),
|
|
507
|
+
incremental=_incremental(principal),
|
|
508
|
+
bootstrap=_bootstrap(principal, plan),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _strata(
|
|
513
|
+
records: tuple[EvaluationRecord, ...],
|
|
514
|
+
config: EvaluationConfig,
|
|
515
|
+
) -> tuple[EvaluationStratum, ...]:
|
|
516
|
+
plan = config.bootstrap
|
|
517
|
+
# Identical sets occur, for example, in a one-family/one-migration pilot.
|
|
518
|
+
# Share only immutable calculations within this call; scope labels remain
|
|
519
|
+
# separate and no bootstrap samples or results survive into another report.
|
|
520
|
+
cache: dict[tuple[str, ...], EvaluationStratum] = {}
|
|
521
|
+
|
|
522
|
+
def scoped(
|
|
523
|
+
selected: tuple[EvaluationRecord, ...],
|
|
524
|
+
kind: Literal["global", "family", "migration", "family_migration"],
|
|
525
|
+
family: Family | None = None,
|
|
526
|
+
migration: str | None = None,
|
|
527
|
+
) -> EvaluationStratum:
|
|
528
|
+
key = tuple(r.case_id for r in selected)
|
|
529
|
+
if key not in cache:
|
|
530
|
+
cache[key] = _stratum(selected, plan, kind, family, migration)
|
|
531
|
+
return cache[key].model_copy(
|
|
532
|
+
update={"kind": kind, "family": family, "migration": migration}
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
result = [scoped(records, "global")]
|
|
536
|
+
families = sorted(config.expected_families)
|
|
537
|
+
migrations = sorted(set(config.expected_migrations) | {r.migration for r in records})
|
|
538
|
+
for family in families:
|
|
539
|
+
result.append(scoped(tuple(r for r in records if r.family == family), "family", family))
|
|
540
|
+
for migration in migrations:
|
|
541
|
+
result.append(
|
|
542
|
+
scoped(
|
|
543
|
+
tuple(r for r in records if r.migration == migration),
|
|
544
|
+
"migration",
|
|
545
|
+
migration=migration,
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
for family in families:
|
|
549
|
+
for migration in migrations:
|
|
550
|
+
result.append(
|
|
551
|
+
scoped(
|
|
552
|
+
tuple(r for r in records if r.family == family and r.migration == migration),
|
|
553
|
+
"family_migration",
|
|
554
|
+
family,
|
|
555
|
+
migration,
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
return tuple(result)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _records_digest(records: tuple[EvaluationRecord, ...]) -> str:
|
|
562
|
+
return hashlib.sha256(
|
|
563
|
+
json.dumps(
|
|
564
|
+
[r.model_dump(mode="json") for r in records],
|
|
565
|
+
sort_keys=True,
|
|
566
|
+
separators=(",", ":"),
|
|
567
|
+
ensure_ascii=True,
|
|
568
|
+
allow_nan=False,
|
|
569
|
+
).encode()
|
|
570
|
+
).hexdigest()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _validate_inputs(records: tuple[EvaluationRecord, ...], config: EvaluationConfig) -> None:
|
|
574
|
+
case_ids = tuple(r.case_id for r in records)
|
|
575
|
+
pipelines = tuple(r.pipeline_sha256 for r in records if r.pipeline_sha256 is not None)
|
|
576
|
+
if len(set(case_ids)) != len(case_ids) or len(set(pipelines)) != len(pipelines):
|
|
577
|
+
raise ValueError("each case and captured execution must occur exactly once")
|
|
578
|
+
scenarios: dict[str, tuple[str, Family]] = {}
|
|
579
|
+
bundles: dict[str, Family] = {}
|
|
580
|
+
for record in records:
|
|
581
|
+
if record.freeze_sha256 != config.freeze_sha256:
|
|
582
|
+
raise ValueError("records and configuration must bind the same freeze")
|
|
583
|
+
if record.family not in config.expected_families:
|
|
584
|
+
raise ValueError("record family is outside the declared evaluation scope")
|
|
585
|
+
if config.expected_migrations and record.migration not in config.expected_migrations:
|
|
586
|
+
raise ValueError("record migration is outside the declared evaluation scope")
|
|
587
|
+
identity = (record.group_id, record.family)
|
|
588
|
+
if scenarios.setdefault(record.scenario_id, identity) != identity:
|
|
589
|
+
raise ValueError("a scenario cannot change dependency group or family")
|
|
590
|
+
bundle = record.detector_bundle_sha256
|
|
591
|
+
if bundle is not None and bundles.setdefault(bundle, record.family) != record.family:
|
|
592
|
+
raise ValueError("a family-specific bundle cannot serve a different family")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def evaluate_records(
|
|
596
|
+
records: tuple[EvaluationRecord, ...],
|
|
597
|
+
config: EvaluationConfig,
|
|
598
|
+
) -> EvaluationReport:
|
|
599
|
+
"""Calculate paired metrics without detector access or unverified evidence IO."""
|
|
600
|
+
config = EvaluationConfig.model_validate(config)
|
|
601
|
+
if not isinstance(records, tuple):
|
|
602
|
+
raise ValueError("records must be a tuple of strict EvaluationRecord instances")
|
|
603
|
+
checked = tuple(EvaluationRecord.model_validate(record) for record in records)
|
|
604
|
+
ordered = tuple(sorted(checked, key=lambda record: record.case_id))
|
|
605
|
+
_validate_inputs(ordered, config)
|
|
606
|
+
return EvaluationReport(
|
|
607
|
+
scientific=config.mode == "final",
|
|
608
|
+
config_sha256=digest(config),
|
|
609
|
+
protocol_sha256=evaluation_protocol_sha256(config),
|
|
610
|
+
records_sha256=_records_digest(ordered),
|
|
611
|
+
config=config,
|
|
612
|
+
records=ordered,
|
|
613
|
+
strata=_strata(ordered, config),
|
|
614
|
+
)
|