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,347 @@
1
+ """Regenerate exact CSV tables from the bound evaluation, never from edited totals."""
2
+
3
+ import csv
4
+ import hashlib
5
+ import io
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ from equiframe._path_compat import is_junction
10
+ from pydantic import Field, model_validator
11
+ from typing_extensions import Self
12
+
13
+ from equiframe_benchmark.evaluation import EvaluationReport, MetricValue
14
+ from equiframe_benchmark.io import load_document, save_document
15
+ from equiframe_benchmark.models import Digest, Document, Text, digest
16
+
17
+
18
+ class EvaluationTable(Document):
19
+ filename: Text
20
+ sha256: Digest
21
+ size_bytes: Annotated[int, Field(ge=0)]
22
+ rows: Annotated[int, Field(ge=0)]
23
+
24
+ @model_validator(mode="after")
25
+ def filename_only(self) -> Self:
26
+ if Path(self.filename).name != self.filename or not self.filename.endswith(".csv"):
27
+ raise ValueError("a table must have one local CSV filename")
28
+ return self
29
+
30
+
31
+ class TableExport(Document):
32
+ evaluation_sha256: Digest
33
+ scientific: bool
34
+ tables: tuple[EvaluationTable, ...]
35
+
36
+ @model_validator(mode="after")
37
+ def unique(self) -> Self:
38
+ if len({t.filename for t in self.tables}) != len(self.tables):
39
+ raise ValueError("duplicate table filenames")
40
+ if tuple(t.filename for t in self.tables) != (
41
+ "coverage.csv",
42
+ "metrics.csv",
43
+ "average_precision.csv",
44
+ "incremental.csv",
45
+ "bootstrap.csv",
46
+ "cases.csv",
47
+ ):
48
+ raise ValueError("the complete table inventory is required")
49
+ return self
50
+
51
+
52
+ def _csv(header: tuple[str, ...], rows: list[list[object]]) -> bytes:
53
+ stream = io.StringIO(newline="")
54
+ writer = csv.writer(stream, lineterminator="\n")
55
+ writer.writerow(header)
56
+ writer.writerows(rows)
57
+ return stream.getvalue().encode("utf-8")
58
+
59
+
60
+ def _value(metric: MetricValue) -> list[object]:
61
+ return [metric.numerator, metric.denominator, metric.value, metric.reason]
62
+
63
+
64
+ def table_payloads(report: EvaluationReport) -> dict[str, tuple[bytes, int]]:
65
+ """Return deterministic bytes and data-row counts, including undefined/empty strata."""
66
+ return _validated_payloads(EvaluationReport.model_validate(report))
67
+
68
+
69
+ def _validated_payloads(report: EvaluationReport) -> dict[str, tuple[bytes, int]]:
70
+ """Format a report already revalidated at the caller's public boundary.
71
+
72
+ Keeping formatting separate avoids a second full deterministic bootstrap
73
+ during write/read, without caching or trusting a caller-supplied instance.
74
+ """
75
+ coverage: list[list[object]] = []
76
+ metrics: list[list[object]] = []
77
+ ap: list[list[object]] = []
78
+ incremental: list[list[object]] = []
79
+ bootstrap: list[list[object]] = []
80
+ cases: list[list[object]] = []
81
+ scope_header = ("mode", "scope", "family", "migration")
82
+ ratio_header = ("numerator", "denominator", "value", "undefined_reason")
83
+ for stratum in report.strata:
84
+ scope: list[object] = [
85
+ report.config.mode,
86
+ stratum.kind,
87
+ stratum.family,
88
+ stratum.migration,
89
+ ]
90
+ summary = stratum.coverage
91
+ coverage.append(
92
+ [
93
+ *scope,
94
+ summary.total_cases,
95
+ summary.oracle_confirmed,
96
+ summary.oracle_pending,
97
+ summary.oracle_disagreement,
98
+ summary.oracle_not_evaluable,
99
+ len(summary.failed_case_ids),
100
+ len(summary.principal_case_ids),
101
+ len(summary.excluded_case_ids),
102
+ *_value(summary.principal_coverage),
103
+ ]
104
+ )
105
+ for method in stratum.methods:
106
+ for support_name, confusion in (
107
+ ("principal", method.principal),
108
+ ("own_support", method.own_support),
109
+ ):
110
+ for metric_name in ("precision", "recall", "f1", "false_positive_rate"):
111
+ value = getattr(confusion, metric_name)
112
+ metrics.append(
113
+ [
114
+ *scope,
115
+ method.method,
116
+ support_name,
117
+ len(method.complete_case_ids),
118
+ len(method.own_case_ids),
119
+ len(method.partial_alert_case_ids),
120
+ len(method.unavailable_case_ids),
121
+ method.coverage.numerator,
122
+ method.coverage.denominator,
123
+ confusion.tp,
124
+ confusion.fp,
125
+ confusion.tn,
126
+ confusion.fn,
127
+ metric_name,
128
+ *_value(value),
129
+ ]
130
+ )
131
+ for average in stratum.average_precision:
132
+ for support_name, value, ids in (
133
+ ("principal", average.principal, average.principal_case_ids),
134
+ ("own_support", average.own_support, average.own_case_ids),
135
+ ):
136
+ ap.append([*scope, average.method, support_name, len(ids), *_value(value)])
137
+ gain = stratum.incremental
138
+ for name, ids, value in (
139
+ (
140
+ "additional_true_positives_H_vs_R",
141
+ gain.additional_true_positive_case_ids,
142
+ gain.additional_true_positive_rate,
143
+ ),
144
+ (
145
+ "additional_false_positives_H_vs_R",
146
+ gain.additional_false_positive_case_ids,
147
+ gain.additional_false_positive_rate,
148
+ ),
149
+ ):
150
+ incremental.append([*scope, name, len(ids), *_value(value)])
151
+ interval = stratum.bootstrap
152
+ for estimate in interval.intervals:
153
+ bootstrap.append(
154
+ [
155
+ *scope,
156
+ estimate.metric,
157
+ len(interval.group_ids),
158
+ interval.attempted_replicates,
159
+ estimate.defined_replicates,
160
+ estimate.undefined_replicates,
161
+ report.config.bootstrap.seed,
162
+ report.config.bootstrap.confidence,
163
+ interval.lower_rank,
164
+ interval.upper_rank,
165
+ estimate.lower,
166
+ estimate.upper,
167
+ estimate.reason,
168
+ ]
169
+ )
170
+ for record in report.records:
171
+ cases.append(
172
+ [
173
+ report.config.mode,
174
+ record.case_id,
175
+ record.scenario_id,
176
+ record.group_id,
177
+ record.family,
178
+ record.migration,
179
+ record.engine,
180
+ record.oracle_state,
181
+ record.oracle_label,
182
+ record.r.decision,
183
+ record.r.complete,
184
+ record.m.decision,
185
+ record.m.complete,
186
+ record.b.decision,
187
+ record.b.complete,
188
+ record.h.decision,
189
+ record.h.complete,
190
+ record.m_score,
191
+ record.b_score,
192
+ record.failure_reason,
193
+ ]
194
+ )
195
+ definitions = (
196
+ (
197
+ "coverage.csv",
198
+ (
199
+ *scope_header,
200
+ "scheduled",
201
+ "confirmed",
202
+ "pending",
203
+ "disagreement",
204
+ "oracle_not_evaluable",
205
+ "failed",
206
+ "principal",
207
+ "excluded",
208
+ *ratio_header,
209
+ ),
210
+ coverage,
211
+ ),
212
+ (
213
+ "metrics.csv",
214
+ (
215
+ *scope_header,
216
+ "method",
217
+ "support",
218
+ "complete",
219
+ "own",
220
+ "partial_alert",
221
+ "not_evaluable",
222
+ "coverage_numerator",
223
+ "coverage_denominator",
224
+ "tp",
225
+ "fp",
226
+ "tn",
227
+ "fn",
228
+ "metric",
229
+ *ratio_header,
230
+ ),
231
+ metrics,
232
+ ),
233
+ (
234
+ "average_precision.csv",
235
+ (*scope_header, "method", "support", "cases", *ratio_header),
236
+ ap,
237
+ ),
238
+ (
239
+ "incremental.csv",
240
+ (*scope_header, "statistic", "additional_cases", *ratio_header),
241
+ incremental,
242
+ ),
243
+ (
244
+ "bootstrap.csv",
245
+ (
246
+ *scope_header,
247
+ "statistic",
248
+ "groups",
249
+ "attempted",
250
+ "defined",
251
+ "undefined",
252
+ "seed",
253
+ "confidence",
254
+ "lower_rank",
255
+ "upper_rank",
256
+ "lower",
257
+ "upper",
258
+ "reason",
259
+ ),
260
+ bootstrap,
261
+ ),
262
+ (
263
+ "cases.csv",
264
+ (
265
+ "mode",
266
+ "case_id",
267
+ "scenario_id",
268
+ "group_id",
269
+ "family",
270
+ "migration",
271
+ "engine",
272
+ "oracle_state",
273
+ "label",
274
+ "R",
275
+ "R_complete",
276
+ "M",
277
+ "M_complete",
278
+ "B",
279
+ "B_complete",
280
+ "H",
281
+ "H_complete",
282
+ "M_score",
283
+ "B_score",
284
+ "failure_reason",
285
+ ),
286
+ cases,
287
+ ),
288
+ )
289
+ return {name: (_csv(header, rows), len(rows)) for name, header, rows in definitions}
290
+
291
+
292
+ def write_tables(report: EvaluationReport, directory: Path) -> TableExport:
293
+ """Publish in a new directory; an interruption cannot leave a valid final receipt."""
294
+ report = EvaluationReport.model_validate(report)
295
+ payloads = _validated_payloads(report)
296
+ directory = directory.resolve()
297
+ directory.mkdir(parents=True, exist_ok=False)
298
+ tables: list[EvaluationTable] = []
299
+ for filename, (data, rows) in payloads.items():
300
+ with (directory / filename).open("xb") as stream:
301
+ stream.write(data)
302
+ tables.append(
303
+ EvaluationTable(
304
+ filename=filename,
305
+ sha256=hashlib.sha256(data).hexdigest(),
306
+ size_bytes=len(data),
307
+ rows=rows,
308
+ )
309
+ )
310
+ result = TableExport(
311
+ evaluation_sha256=digest(report), scientific=report.scientific, tables=tuple(tables)
312
+ )
313
+ save_document(result, directory / "tables.json")
314
+ return result
315
+
316
+
317
+ def read_tables(directory: Path, *, report: EvaluationReport) -> TableExport:
318
+ """Verify every byte against regeneration and reject unlisted files or links."""
319
+ report = EvaluationReport.model_validate(report)
320
+ if directory.is_symlink() or is_junction(directory):
321
+ raise ValueError("the table directory cannot be a filesystem link")
322
+ directory = directory.resolve()
323
+ manifest = directory / "tables.json"
324
+ if manifest.is_symlink() or not manifest.is_file() or manifest.stat().st_size > 65536:
325
+ raise ValueError("table receipt must be a bounded regular file")
326
+ result = load_document(TableExport, manifest)
327
+ expected = _validated_payloads(report)
328
+ if result.evaluation_sha256 != digest(report) or result.scientific != report.scientific:
329
+ raise ValueError("tables belong to a different evaluation")
330
+ if {p.name for p in directory.iterdir()} != {*expected, "tables.json"}:
331
+ raise ValueError("table directory inventory is not closed")
332
+ for table in result.tables:
333
+ path = directory / table.filename
334
+ if path.is_symlink() or not path.is_file():
335
+ raise ValueError("tables must be regular local files")
336
+ payload, rows = expected[table.filename]
337
+ if path.stat().st_size != len(payload):
338
+ raise ValueError("table size differs from bound regeneration")
339
+ data = path.read_bytes()
340
+ if (
341
+ data != payload
342
+ or hashlib.sha256(data).hexdigest() != table.sha256
343
+ or len(data) != table.size_bytes
344
+ or rows != table.rows
345
+ ):
346
+ raise ValueError("table bytes or row counts differ from bound regeneration")
347
+ return result
@@ -0,0 +1,340 @@
1
+ """Exact V2 paired tables, with exclusions, denominators and common-support appendix."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from equiframe._path_compat import is_junction
8
+ from pydantic import model_validator
9
+ from typing_extensions import Self
10
+
11
+ from equiframe_benchmark.evaluation_v2 import EvaluationReportV2, SignedMetricV2
12
+ from equiframe_benchmark.io import load_document, save_document
13
+ from equiframe_benchmark.models import Digest, digest
14
+ from equiframe_benchmark.models_v2 import DocumentV2 as Document
15
+ from equiframe_benchmark.tables import EvaluationTable, _csv
16
+
17
+ TABLES_V2 = (
18
+ "coverage.csv",
19
+ "metrics.csv",
20
+ "bootstrap.csv",
21
+ "exclusions.csv",
22
+ "common_metrics.csv",
23
+ "common_bootstrap.csv",
24
+ "cases.csv",
25
+ )
26
+
27
+
28
+ class TableExportV2(Document):
29
+ schema_version: Literal["2.0"] = "2.0"
30
+ evaluation_sha256: Digest
31
+ scientific: bool
32
+ tables: tuple[EvaluationTable, ...]
33
+
34
+ @model_validator(mode="after")
35
+ def inventory(self) -> Self:
36
+ if tuple(table.filename for table in self.tables) != TABLES_V2:
37
+ raise ValueError("V2 requires its complete ordered seven-table inventory")
38
+ return self
39
+
40
+
41
+ def _value(value: SignedMetricV2) -> list[object]:
42
+ return [value.numerator, value.denominator, value.value, value.reason]
43
+
44
+
45
+ def _validated_payloads(report: EvaluationReportV2) -> dict[str, tuple[bytes, int]]:
46
+ rows: dict[str, list[list[object]]] = {name: [] for name in TABLES_V2}
47
+ scope_header = ("mode", "scope", "family", "migration")
48
+ pair_header = (*scope_header, "left_method", "right_method")
49
+ ratio = ("numerator", "denominator", "value", "undefined_reason")
50
+ for stratum in report.strata:
51
+ scope: list[object] = [
52
+ report.config.mode,
53
+ stratum.kind,
54
+ stratum.family,
55
+ stratum.migration,
56
+ ]
57
+ for contrast in stratum.principal:
58
+ pair = contrast.comparison
59
+ identity = [*scope, pair.left_method, pair.right_method]
60
+ rows["coverage.csv"].append(
61
+ [
62
+ *identity,
63
+ stratum.total_cases,
64
+ stratum.oracle_confirmed,
65
+ len(pair.support_case_ids),
66
+ len(pair.group_ids),
67
+ pair.divergent_cases,
68
+ pair.normal_cases,
69
+ pair.coverage_confirmed.numerator,
70
+ pair.coverage_confirmed.denominator,
71
+ pair.coverage_confirmed.value,
72
+ pair.coverage_confirmed.reason,
73
+ pair.coverage_scheduled.numerator,
74
+ pair.coverage_scheduled.denominator,
75
+ pair.coverage_scheduled.value,
76
+ pair.coverage_scheduled.reason,
77
+ len(pair.exclusions),
78
+ len(pair.left_partial_alert_case_ids),
79
+ len(pair.right_partial_alert_case_ids),
80
+ ]
81
+ )
82
+ for estimate in contrast.estimates:
83
+ rows["metrics.csv"].append(
84
+ [
85
+ *identity,
86
+ estimate.name,
87
+ *_value(estimate.estimate),
88
+ ]
89
+ )
90
+ for interval in contrast.bootstrap.intervals:
91
+ rows["bootstrap.csv"].append(
92
+ [
93
+ *identity,
94
+ interval.metric,
95
+ len(contrast.bootstrap.group_ids),
96
+ contrast.bootstrap.attempted_replicates,
97
+ contrast.bootstrap.lower_rank,
98
+ contrast.bootstrap.upper_rank,
99
+ interval.lower,
100
+ interval.upper,
101
+ interval.defined_replicates,
102
+ interval.undefined_replicates,
103
+ interval.reason,
104
+ ]
105
+ )
106
+ for exclusion in pair.exclusions:
107
+ for reason in exclusion.reasons:
108
+ rows["exclusions.csv"].append(
109
+ [
110
+ *identity,
111
+ exclusion.case_id,
112
+ exclusion.oracle_state,
113
+ reason,
114
+ ]
115
+ )
116
+ for method in stratum.common.methods:
117
+ confusion = method.metrics
118
+ values = [
119
+ (metric, getattr(confusion, metric))
120
+ for metric in ("precision", "recall", "f1", "false_positive_rate")
121
+ ]
122
+ if method.average_precision is not None:
123
+ values.append(("average_precision", method.average_precision))
124
+ for name, value in values:
125
+ rows["common_metrics.csv"].append(
126
+ [
127
+ *scope,
128
+ stratum.common.role,
129
+ len(stratum.common.case_ids),
130
+ method.method,
131
+ confusion.tp,
132
+ confusion.fp,
133
+ confusion.fn,
134
+ confusion.tn,
135
+ name,
136
+ value.numerator,
137
+ value.denominator,
138
+ value.value,
139
+ value.reason,
140
+ ]
141
+ )
142
+ for interval in stratum.common.bootstrap.intervals:
143
+ bootstrap = stratum.common.bootstrap
144
+ rows["common_bootstrap.csv"].append(
145
+ [
146
+ *scope,
147
+ stratum.common.role,
148
+ interval.metric,
149
+ len(bootstrap.group_ids),
150
+ bootstrap.attempted_replicates,
151
+ bootstrap.lower_rank,
152
+ bootstrap.upper_rank,
153
+ interval.lower,
154
+ interval.upper,
155
+ interval.defined_replicates,
156
+ interval.undefined_replicates,
157
+ interval.reason,
158
+ ]
159
+ )
160
+ for record in report.records:
161
+ decisions: list[object] = []
162
+ for component in (record.r, record.m, record.b, record.h):
163
+ decisions.extend((component.decision, component.complete, component.reason))
164
+ rows["cases.csv"].append(
165
+ [
166
+ record.case_id,
167
+ record.scenario_id,
168
+ record.group_id,
169
+ record.family,
170
+ record.migration,
171
+ record.engine,
172
+ record.oracle_state,
173
+ record.oracle_label,
174
+ *decisions,
175
+ record.m_score,
176
+ record.b_score,
177
+ record.candidate_sha256,
178
+ record.pipeline_sha256,
179
+ record.oracle_sha256,
180
+ record.detector_bundle_sha256,
181
+ record.freeze_sha256,
182
+ record.failure_reason,
183
+ ]
184
+ )
185
+ headers = {
186
+ "coverage.csv": (
187
+ *pair_header,
188
+ "scheduled",
189
+ "confirmed",
190
+ "paired",
191
+ "groups",
192
+ "divergent",
193
+ "normal",
194
+ "confirmed_numerator",
195
+ "confirmed_denominator",
196
+ "confirmed_coverage",
197
+ "confirmed_undefined",
198
+ "scheduled_numerator",
199
+ "scheduled_denominator",
200
+ "scheduled_coverage",
201
+ "scheduled_undefined",
202
+ "excluded",
203
+ "left_partial_alerts",
204
+ "right_partial_alerts",
205
+ ),
206
+ "metrics.csv": (*pair_header, "metric", *ratio),
207
+ "bootstrap.csv": (
208
+ *pair_header,
209
+ "metric",
210
+ "groups",
211
+ "attempted",
212
+ "lower_rank",
213
+ "upper_rank",
214
+ "lower",
215
+ "upper",
216
+ "defined",
217
+ "undefined",
218
+ "reason",
219
+ ),
220
+ "exclusions.csv": (*pair_header, "case_id", "oracle_state", "reason"),
221
+ "common_metrics.csv": (
222
+ *scope_header,
223
+ "role",
224
+ "common_support",
225
+ "method",
226
+ "tp",
227
+ "fp",
228
+ "fn",
229
+ "tn",
230
+ "metric",
231
+ *ratio,
232
+ ),
233
+ "common_bootstrap.csv": (
234
+ *scope_header,
235
+ "role",
236
+ "metric",
237
+ "groups",
238
+ "attempted",
239
+ "lower_rank",
240
+ "upper_rank",
241
+ "lower",
242
+ "upper",
243
+ "defined",
244
+ "undefined",
245
+ "reason",
246
+ ),
247
+ "cases.csv": (
248
+ "case_id",
249
+ "scenario_id",
250
+ "group_id",
251
+ "family",
252
+ "migration",
253
+ "engine",
254
+ "oracle_state",
255
+ "oracle_label",
256
+ "R",
257
+ "R_complete",
258
+ "R_reason",
259
+ "M",
260
+ "M_complete",
261
+ "M_reason",
262
+ "B",
263
+ "B_complete",
264
+ "B_reason",
265
+ "H",
266
+ "H_complete",
267
+ "H_reason",
268
+ "M_score",
269
+ "B_score",
270
+ "candidate_sha256",
271
+ "pipeline_sha256",
272
+ "oracle_sha256",
273
+ "bundle_sha256",
274
+ "freeze_sha256",
275
+ "failure_reason",
276
+ ),
277
+ }
278
+ return {name: (_csv(headers[name], rows[name]), len(rows[name])) for name in TABLES_V2}
279
+
280
+
281
+ def table_payloads_v2(report: EvaluationReportV2) -> dict[str, tuple[bytes, int]]:
282
+ return _validated_payloads(EvaluationReportV2.model_validate(report))
283
+
284
+
285
+ def write_tables_v2(report: EvaluationReportV2, directory: Path) -> TableExportV2:
286
+ checked = EvaluationReportV2.model_validate(report)
287
+ payloads = _validated_payloads(checked)
288
+ directory = directory.resolve()
289
+ directory.mkdir(parents=True, exist_ok=False)
290
+ tables = []
291
+ for name, (payload, rows) in payloads.items():
292
+ with (directory / name).open("xb") as output:
293
+ output.write(payload)
294
+ tables.append(
295
+ EvaluationTable(
296
+ filename=name,
297
+ sha256=hashlib.sha256(payload).hexdigest(),
298
+ size_bytes=len(payload),
299
+ rows=rows,
300
+ )
301
+ )
302
+ receipt = TableExportV2(
303
+ evaluation_sha256=digest(checked),
304
+ scientific=checked.scientific,
305
+ tables=tuple(tables),
306
+ )
307
+ save_document(receipt, directory / "tables.json")
308
+ return receipt
309
+
310
+
311
+ def read_tables_v2(directory: Path, *, report: EvaluationReportV2) -> TableExportV2:
312
+ checked = EvaluationReportV2.model_validate(report)
313
+ if directory.is_symlink() or is_junction(directory):
314
+ raise ValueError("V2 table directory must not be a filesystem link")
315
+ directory = directory.resolve()
316
+ path = directory / "tables.json"
317
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > 65536:
318
+ raise ValueError("V2 table receipt must be a bounded regular file")
319
+ receipt = load_document(TableExportV2, path)
320
+ expected = _validated_payloads(checked)
321
+ if receipt.evaluation_sha256 != digest(checked) or receipt.scientific != checked.scientific:
322
+ raise ValueError("V2 tables belong to a different paired evaluation")
323
+ if {entry.name for entry in directory.iterdir()} != {*expected, "tables.json"}:
324
+ raise ValueError("V2 table inventory must be closed")
325
+ for table in receipt.tables:
326
+ target = directory / table.filename
327
+ if target.is_symlink() or is_junction(target) or not target.is_file():
328
+ raise ValueError("V2 tables must be regular local files")
329
+ payload, rows = expected[table.filename]
330
+ if target.stat().st_size != len(payload):
331
+ raise ValueError("V2 table size differs from regeneration")
332
+ data = target.read_bytes()
333
+ if (
334
+ data != payload
335
+ or hashlib.sha256(data).hexdigest() != table.sha256
336
+ or len(data) != table.size_bytes
337
+ or rows != table.rows
338
+ ):
339
+ raise ValueError("V2 table bytes or counts differ from captured decisions")
340
+ return receipt