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.
Files changed (62) hide show
  1. equiframe_benchmark/PROTOCOL_V2.md +172 -0
  2. equiframe_benchmark/__init__.py +284 -0
  3. equiframe_benchmark/__main__.py +3 -0
  4. equiframe_benchmark/_scale_workload.py +375 -0
  5. equiframe_benchmark/calibration_types.py +112 -0
  6. equiframe_benchmark/cli.py +196 -0
  7. equiframe_benchmark/commands.py +149 -0
  8. equiframe_benchmark/commands_scale.py +38 -0
  9. equiframe_benchmark/commands_step10.py +275 -0
  10. equiframe_benchmark/commands_v2.py +206 -0
  11. equiframe_benchmark/cost_table.py +62 -0
  12. equiframe_benchmark/costs.py +788 -0
  13. equiframe_benchmark/distribution.py +932 -0
  14. equiframe_benchmark/evaluation.py +614 -0
  15. equiframe_benchmark/evaluation_v2.py +428 -0
  16. equiframe_benchmark/fixtures.py +299 -0
  17. equiframe_benchmark/freeze.py +869 -0
  18. equiframe_benchmark/freeze_v2.py +412 -0
  19. equiframe_benchmark/integration.py +655 -0
  20. equiframe_benchmark/io.py +29 -0
  21. equiframe_benchmark/models.py +313 -0
  22. equiframe_benchmark/models_v2.py +13 -0
  23. equiframe_benchmark/mutations.py +34 -0
  24. equiframe_benchmark/oracle.py +264 -0
  25. equiframe_benchmark/pairwise.py +248 -0
  26. equiframe_benchmark/pilot.py +254 -0
  27. equiframe_benchmark/py.typed +0 -0
  28. equiframe_benchmark/reconciliation.py +294 -0
  29. equiframe_benchmark/reproduction.py +542 -0
  30. equiframe_benchmark/scale_io.py +19 -0
  31. equiframe_benchmark/scale_process.py +1077 -0
  32. equiframe_benchmark/scale_types.py +120 -0
  33. equiframe_benchmark/scaling.py +529 -0
  34. equiframe_benchmark/schemas/protocol-v2/CommonSupportV2-2.0.schema.json +327 -0
  35. equiframe_benchmark/schemas/protocol-v2/ContrastSpecV2-2.0.schema.json +37 -0
  36. equiframe_benchmark/schemas/protocol-v2/EvaluationConfigV2-2.0.schema.json +184 -0
  37. equiframe_benchmark/schemas/protocol-v2/EvaluationReportV2-2.0.schema.json +1237 -0
  38. equiframe_benchmark/schemas/protocol-v2/EvaluationStratumV2-2.0.schema.json +747 -0
  39. equiframe_benchmark/schemas/protocol-v2/FreezeDefinitionV2-2.0.schema.json +5951 -0
  40. equiframe_benchmark/schemas/protocol-v2/FreezeManifestV2-2.0.schema.json +6222 -0
  41. equiframe_benchmark/schemas/protocol-v2/PrincipalContrastV2-2.0.schema.json +578 -0
  42. equiframe_benchmark/schemas/protocol-v2/ProtocolReviewV2-2.0.schema.json +56 -0
  43. equiframe_benchmark/schemas/protocol-v2/SignedMetricV2-2.0.schema.json +51 -0
  44. equiframe_benchmark/schemas/protocol-v2/StudyCaseResultV2-2.0.schema.json +4881 -0
  45. equiframe_benchmark/schemas/protocol-v2/StudyResultV2-2.0.schema.json +8370 -0
  46. equiframe_benchmark/schemas/protocol-v2/TableExportV2-2.0.schema.json +75 -0
  47. equiframe_benchmark/selection.py +178 -0
  48. equiframe_benchmark/snapshot.py +24 -0
  49. equiframe_benchmark/splits.py +92 -0
  50. equiframe_benchmark/study.py +526 -0
  51. equiframe_benchmark/study_bundle.py +379 -0
  52. equiframe_benchmark/study_bundle_v2.py +388 -0
  53. equiframe_benchmark/study_v2.py +544 -0
  54. equiframe_benchmark/tables.py +347 -0
  55. equiframe_benchmark/tables_v2.py +340 -0
  56. equiframe_benchmark/training_types.py +52 -0
  57. equiframe_benchmark-0.12.4.dist-info/METADATA +343 -0
  58. equiframe_benchmark-0.12.4.dist-info/RECORD +62 -0
  59. equiframe_benchmark-0.12.4.dist-info/WHEEL +4 -0
  60. equiframe_benchmark-0.12.4.dist-info/entry_points.txt +3 -0
  61. equiframe_benchmark-0.12.4.dist-info/licenses/LICENSE +21 -0
  62. 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
+ )