anhtld commited on
Commit
c3a545a
·
verified ·
1 Parent(s): 8ec088e

Add dominance-calibrated CTT selector diagnostics

Browse files
workspace/scripts/eval_dominance_selector.py ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import subprocess
8
+ import sys
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
14
+ if str(PROJECT_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(PROJECT_ROOT))
16
+
17
+ import torch # noqa: E402
18
+
19
+ from cil.metrics import macro_micro_summary # noqa: E402
20
+ from cil.models import CTTConfig, ChartEncoder, TangentNormalizer, UtilityEnergy # noqa: E402
21
+ from scripts.eval_ctt_generated_rollout import load_chart_items # noqa: E402
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ parser = argparse.ArgumentParser(
26
+ description=(
27
+ "Calibrate a causal-dominance fallback rule on measured generated "
28
+ "candidate rollouts and evaluate it on a held-out measured rollout set."
29
+ )
30
+ )
31
+ parser.add_argument("--calibration-input", type=Path, required=True)
32
+ parser.add_argument("--calibration-target-index", type=Path, required=True)
33
+ parser.add_argument("--eval-input", type=Path, required=True)
34
+ parser.add_argument("--eval-target-index", type=Path, required=True)
35
+ parser.add_argument(
36
+ "--checkpoint-template",
37
+ default="runs/ctt_residual_full_seed{seed}/model.pt",
38
+ help="Template used to load the train-seed utility-energy checkpoint.",
39
+ )
40
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/ctt_dominance_val_to_test"))
41
+ parser.add_argument("--alpha", type=float, default=0.1)
42
+ parser.add_argument(
43
+ "--tau",
44
+ default="auto",
45
+ help=(
46
+ "Dominance threshold. Use a float, or 'auto' to choose the threshold "
47
+ "that maximizes selected success on the calibration split after "
48
+ "conformal residual subtraction."
49
+ ),
50
+ )
51
+ parser.add_argument("--k", type=int, default=8)
52
+ parser.add_argument("--bootstrap-samples", type=int, default=1000)
53
+ args = parser.parse_args(argv)
54
+
55
+ if not 0.0 < args.alpha < 1.0:
56
+ parser.error("--alpha must be in (0, 1)")
57
+ if args.k <= 0:
58
+ parser.error("--k must be positive")
59
+
60
+ out_dir = args.out_dir
61
+ out_dir.mkdir(parents=True, exist_ok=True)
62
+ _write_provenance(out_dir, args)
63
+
64
+ calibrator = _DominanceScorer(args.checkpoint_template)
65
+ calibration_rows = _rows(json.loads(args.calibration_input.read_text()))
66
+ eval_rows = _rows(json.loads(args.eval_input.read_text()))
67
+ calibration_charts, calibration_index = _chart_map(args.calibration_target_index)
68
+ eval_charts, eval_index = _chart_map(args.eval_target_index)
69
+
70
+ calibration_cases = [
71
+ _dominance_case(row, calibration_charts, scorer=calibrator, k=args.k)
72
+ for row in calibration_rows
73
+ ]
74
+ residual_quantile = _conformal_quantile(
75
+ [abs(case["measured_margin"] - case["predicted_margin"]) for case in calibration_cases],
76
+ alpha=args.alpha,
77
+ )
78
+ tau = (
79
+ _choose_tau(calibration_cases, residual_quantile=residual_quantile)
80
+ if args.tau == "auto"
81
+ else float(args.tau)
82
+ )
83
+
84
+ evaluated_cases = [
85
+ _evaluate_case(
86
+ _dominance_case(row, eval_charts, scorer=calibrator, k=args.k),
87
+ residual_quantile=residual_quantile,
88
+ tau=tau,
89
+ )
90
+ for row in eval_rows
91
+ ]
92
+ calibration_eval_cases = [
93
+ _evaluate_case(case, residual_quantile=residual_quantile, tau=tau)
94
+ for case in calibration_cases
95
+ ]
96
+ metric_names = sorted(
97
+ {
98
+ key
99
+ for row in evaluated_cases
100
+ for key, value in row.items()
101
+ if key not in {"chart_id", "task_id", "seed", "train_seed"}
102
+ and isinstance(value, (int, float))
103
+ and math.isfinite(float(value))
104
+ }
105
+ )
106
+ summary = {
107
+ name: macro_micro_summary(
108
+ evaluated_cases,
109
+ name,
110
+ bootstrap_samples=args.bootstrap_samples,
111
+ confidence=0.95,
112
+ )
113
+ for name in metric_names
114
+ }
115
+ metrics = {
116
+ "report_type": "dominance_calibrated_selector_eval",
117
+ "schema_version": 1,
118
+ "k": args.k,
119
+ "alpha": args.alpha,
120
+ "tau": tau,
121
+ "tau_mode": args.tau,
122
+ "residual_quantile": residual_quantile,
123
+ "calibration_input": str(args.calibration_input),
124
+ "eval_input": str(args.eval_input),
125
+ "calibration_target_content_hash": calibration_index.get("content_hash"),
126
+ "calibration_target_split_hash": calibration_index.get("split_hash"),
127
+ "eval_target_content_hash": eval_index.get("content_hash"),
128
+ "eval_target_split_hash": eval_index.get("split_hash"),
129
+ "num_calibration_rows": len(calibration_cases),
130
+ "num_eval_rows": len(evaluated_cases),
131
+ "calibration_summary": _simple_summary(calibration_eval_cases),
132
+ "eval_summary": _simple_summary(evaluated_cases),
133
+ "summary": summary,
134
+ "rows": evaluated_cases,
135
+ }
136
+ (out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
137
+ (out_dir / "metrics_by_task.json").write_text(
138
+ json.dumps(_group_means(evaluated_cases, "task_id", metric_names), indent=2, sort_keys=True)
139
+ + "\n"
140
+ )
141
+ (out_dir / "metrics_by_seed.json").write_text(
142
+ json.dumps(_group_means(evaluated_cases, "seed", metric_names), indent=2, sort_keys=True)
143
+ + "\n"
144
+ )
145
+ (out_dir / "table.tex").write_text(_table(metrics) + "\n")
146
+ (out_dir / "report.md").write_text(_report(metrics) + "\n")
147
+ (out_dir / "train.log").write_text(
148
+ "fit conformal residual quantile and tau on calibration measured rows only\n"
149
+ f"calibration_input={args.calibration_input}\n"
150
+ f"residual_quantile={residual_quantile:.6f}\n"
151
+ f"tau={tau:.6f}\n"
152
+ )
153
+ (out_dir / "eval.log").write_text(
154
+ "evaluated calibrated fallback rule on held-out measured rollout rows\n"
155
+ f"eval_input={args.eval_input}\n"
156
+ f"num_eval_rows={len(evaluated_cases)}\n"
157
+ )
158
+ print(json.dumps({"out_dir": str(out_dir), "tau": tau, "rows": len(evaluated_cases)}, indent=2))
159
+ return 0
160
+
161
+
162
+ class _DominanceScorer:
163
+ def __init__(self, checkpoint_template: str) -> None:
164
+ self.checkpoint_template = checkpoint_template
165
+ self._models: dict[str, tuple[ChartEncoder, UtilityEnergy, TangentNormalizer, CTTConfig]] = {}
166
+
167
+ def base_score(self, row: dict[str, Any], chart: Any) -> float:
168
+ if "base_predicted_score" in row:
169
+ return float(row["base_predicted_score"])
170
+ seed = str(row.get("train_seed", "0"))
171
+ encoder, utility_energy, normalizer, config = self._model(seed)
172
+ with torch.no_grad():
173
+ feature = torch.as_tensor(chart.feature, dtype=torch.float32).unsqueeze(0)
174
+ z_chart = encoder(feature)
175
+ zero = torch.zeros((1, config.tangent_dim), dtype=torch.float32)
176
+ zero_norm = normalizer.transform(zero)
177
+ return float(utility_energy(z_chart, zero_norm).squeeze(0).item())
178
+
179
+ def _model(self, seed: str) -> tuple[ChartEncoder, UtilityEnergy, TangentNormalizer, CTTConfig]:
180
+ if seed in self._models:
181
+ return self._models[seed]
182
+ checkpoint_path = Path(self.checkpoint_template.format(seed=seed))
183
+ checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
184
+ config = CTTConfig(**checkpoint["config"])
185
+ encoder = ChartEncoder(config.chart_feature_dim, output_dim=config.chart_dim)
186
+ utility_energy = UtilityEnergy(chart_dim=config.chart_dim, tangent_dim=config.tangent_dim)
187
+ encoder.load_state_dict(checkpoint["chart_encoder"])
188
+ utility_energy.load_state_dict(checkpoint["utility_energy"])
189
+ normalizer = TangentNormalizer.from_dict(checkpoint["normalizer"])
190
+ encoder.eval()
191
+ utility_energy.eval()
192
+ self._models[seed] = (encoder, utility_energy, normalizer, config)
193
+ return self._models[seed]
194
+
195
+
196
+ def _dominance_case(
197
+ row: dict[str, Any],
198
+ charts: dict[str, Any],
199
+ *,
200
+ scorer: _DominanceScorer,
201
+ k: int,
202
+ ) -> dict[str, Any]:
203
+ generated_utilities = [float(value) for value in row.get("generated_utilities", [])[:k]]
204
+ candidate_success = [float(bool(value)) for value in row.get("candidate_success", [])[:k]]
205
+ predicted_scores = [float(value) for value in row.get("predicted_scores", [])[:k]]
206
+ if not generated_utilities or not predicted_scores:
207
+ raise ValueError("dominance evaluation requires generated utilities and predicted scores")
208
+ chart_id = str(row.get("chart_id", row.get("group_id", "")))
209
+ if chart_id not in charts:
210
+ raise KeyError(f"chart_id {chart_id!r} not found in target index")
211
+ top_index = max(range(len(predicted_scores)), key=lambda index: predicted_scores[index])
212
+ base_score = scorer.base_score(row, charts[chart_id])
213
+ base_utility = float(row["base_utility"])
214
+ base_success = float(bool(row.get("base_success", False)))
215
+ selected_generated_utility = generated_utilities[top_index]
216
+ selected_generated_success = candidate_success[top_index]
217
+ proposal_oracle_utility = max(generated_utilities)
218
+ proposal_oracle_success = float(any(candidate_success))
219
+ hidden = [float(value) for value in row.get("hidden_chart_utilities", [])]
220
+ hidden_oracle_utility = max(hidden) if hidden else math.nan
221
+ hidden_oracle_success = float(any(value >= 1.0 for value in hidden)) if hidden else math.nan
222
+ predicted_margin = predicted_scores[top_index] - base_score
223
+ measured_margin = selected_generated_utility - base_utility
224
+ return {
225
+ "chart_id": chart_id,
226
+ "task_id": str(row.get("task_id", "unknown")),
227
+ "seed": str(row.get("seed", "unknown")),
228
+ "train_seed": str(row.get("train_seed", "unknown")),
229
+ "top_index": top_index,
230
+ "base_predicted_score": base_score,
231
+ "top_predicted_score": predicted_scores[top_index],
232
+ "predicted_margin": predicted_margin,
233
+ "measured_margin": measured_margin,
234
+ "base_utility": base_utility,
235
+ "base_success": base_success,
236
+ "top_generated_utility": selected_generated_utility,
237
+ "top_generated_success": selected_generated_success,
238
+ "proposal_oracle_utility": proposal_oracle_utility,
239
+ "proposal_oracle_success": proposal_oracle_success,
240
+ "hidden_chart_oracle_utility": hidden_oracle_utility,
241
+ "hidden_chart_oracle_success": hidden_oracle_success,
242
+ "outcome_ptr": float(any(value > base_utility for value in generated_utilities)),
243
+ }
244
+
245
+
246
+ def _evaluate_case(case: dict[str, Any], *, residual_quantile: float, tau: float) -> dict[str, Any]:
247
+ lcb = float(case["predicted_margin"]) - float(residual_quantile)
248
+ execute_generated = lcb > float(tau)
249
+ selected_utility = (
250
+ float(case["top_generated_utility"]) if execute_generated else float(case["base_utility"])
251
+ )
252
+ selected_success = (
253
+ float(case["top_generated_success"]) if execute_generated else float(case["base_success"])
254
+ )
255
+ proposal_oracle_utility = float(case["proposal_oracle_utility"])
256
+ proposal_oracle_success = float(case["proposal_oracle_success"])
257
+ hidden_utility = float(case["hidden_chart_oracle_utility"])
258
+ hidden_success = float(case["hidden_chart_oracle_success"])
259
+ output = dict(case)
260
+ output.update(
261
+ {
262
+ "lcb_margin": lcb,
263
+ "execute_generated": float(execute_generated),
264
+ "fallback_to_base": float(not execute_generated),
265
+ "coverage": float(execute_generated),
266
+ "fallback_rate": float(not execute_generated),
267
+ "selected_utility": selected_utility,
268
+ "selected_success": selected_success,
269
+ "selected_utility_gain_over_base": selected_utility - float(case["base_utility"]),
270
+ "selected_success_gain_over_base": selected_success - float(case["base_success"]),
271
+ "selector_regret": max(0.0, proposal_oracle_utility - selected_utility),
272
+ "branch_car": max(0.0, proposal_oracle_utility - selected_utility),
273
+ "success_selector_gap": max(0.0, proposal_oracle_success - selected_success),
274
+ "support_gap": max(0.0, hidden_utility - proposal_oracle_utility)
275
+ if math.isfinite(hidden_utility)
276
+ else math.nan,
277
+ "success_support_gap": max(0.0, hidden_success - proposal_oracle_success)
278
+ if math.isfinite(hidden_success)
279
+ else math.nan,
280
+ "success_total_car_to_hidden": max(0.0, hidden_success - selected_success)
281
+ if math.isfinite(hidden_success)
282
+ else math.nan,
283
+ }
284
+ )
285
+ return output
286
+
287
+
288
+ def _choose_tau(cases: list[dict[str, Any]], *, residual_quantile: float) -> float:
289
+ candidates = sorted({float(case["predicted_margin"]) - float(residual_quantile) for case in cases})
290
+ thresholds = [min(candidates, default=0.0) - 1.0, *candidates, max(candidates, default=0.0) + 1.0]
291
+ best_tau = thresholds[0]
292
+ best_key: tuple[float, float, float] | None = None
293
+ for tau in thresholds:
294
+ evaluated = [_evaluate_case(case, residual_quantile=residual_quantile, tau=tau) for case in cases]
295
+ summary = _simple_summary(evaluated)
296
+ # Maximize selected success, then selected utility, then coverage.
297
+ key = (
298
+ float(summary.get("selected_success", 0.0) or 0.0),
299
+ float(summary.get("selected_utility", 0.0) or 0.0),
300
+ float(summary.get("coverage", 0.0) or 0.0),
301
+ )
302
+ if best_key is None or key > best_key:
303
+ best_key = key
304
+ best_tau = tau
305
+ return float(best_tau)
306
+
307
+
308
+ def _conformal_quantile(values: list[float], *, alpha: float) -> float:
309
+ clean = sorted(float(value) for value in values if math.isfinite(float(value)))
310
+ if not clean:
311
+ raise ValueError("cannot calibrate dominance without residuals")
312
+ index = min(len(clean) - 1, max(0, math.ceil((1.0 - alpha) * (len(clean) + 1)) - 1))
313
+ return clean[index]
314
+
315
+
316
+ def _chart_map(index_path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
317
+ charts, index = load_chart_items(
318
+ index_path,
319
+ max_charts=None,
320
+ require_positive=True,
321
+ include_hidden=True,
322
+ include_metadata=True,
323
+ )
324
+ return {chart.chart_id: chart for chart in charts}, index
325
+
326
+
327
+ def _rows(payload: Any) -> list[dict[str, Any]]:
328
+ rows = payload.get("rows", payload) if isinstance(payload, dict) else payload
329
+ if not isinstance(rows, list):
330
+ raise SystemExit("input must be a JSON list or object with rows")
331
+ return rows
332
+
333
+
334
+ def _simple_summary(rows: list[dict[str, Any]]) -> dict[str, float | None]:
335
+ keys = [
336
+ "base_success",
337
+ "selected_success",
338
+ "proposal_oracle_success",
339
+ "hidden_chart_oracle_success",
340
+ "selected_success_gain_over_base",
341
+ "coverage",
342
+ "fallback_rate",
343
+ "outcome_ptr",
344
+ "success_support_gap",
345
+ "success_selector_gap",
346
+ "base_utility",
347
+ "selected_utility",
348
+ "proposal_oracle_utility",
349
+ "hidden_chart_oracle_utility",
350
+ "support_gap",
351
+ "selector_regret",
352
+ ]
353
+ return {key: _mean([row.get(key) for row in rows]) for key in keys}
354
+
355
+
356
+ def _group_means(
357
+ rows: list[dict[str, Any]],
358
+ key: str,
359
+ metric_names: list[str],
360
+ ) -> dict[str, dict[str, float]]:
361
+ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
362
+ for row in rows:
363
+ grouped[str(row.get(key, "unknown"))].append(row)
364
+ output: dict[str, dict[str, float]] = {}
365
+ for group, group_rows in sorted(grouped.items()):
366
+ payload: dict[str, float] = {}
367
+ for metric in metric_names:
368
+ value = _mean([row.get(metric) for row in group_rows])
369
+ if value is not None:
370
+ payload[metric] = value
371
+ output[group] = payload
372
+ return output
373
+
374
+
375
+ def _mean(values: list[Any]) -> float | None:
376
+ clean = [
377
+ float(value)
378
+ for value in values
379
+ if isinstance(value, (int, float)) and math.isfinite(float(value))
380
+ ]
381
+ return sum(clean) / len(clean) if clean else None
382
+
383
+
384
+ def _table(metrics: dict[str, Any]) -> str:
385
+ summary = metrics["eval_summary"]
386
+ lines = [
387
+ "% Auto-generated by scripts/eval_dominance_selector.py",
388
+ "\\begin{tabular}{lrrrrrrrr}",
389
+ "\\toprule",
390
+ "Rows & Coverage & Fallback & Base succ. & Selected succ. & Oracle succ. & OutcomePTR & Succ. support gap & Succ. selector gap \\\\",
391
+ "\\midrule",
392
+ f"{metrics['num_eval_rows']} & {_fmt(summary.get('coverage'))} & "
393
+ f"{_fmt(summary.get('fallback_rate'))} & {_fmt(summary.get('base_success'))} & "
394
+ f"{_fmt(summary.get('selected_success'))} & {_fmt(summary.get('proposal_oracle_success'))} & "
395
+ f"{_fmt(summary.get('outcome_ptr'))} & {_fmt(summary.get('success_support_gap'))} & "
396
+ f"{_fmt(summary.get('success_selector_gap'))} \\\\",
397
+ "\\bottomrule",
398
+ "\\end{tabular}",
399
+ ]
400
+ return "\n".join(lines)
401
+
402
+
403
+ def _report(metrics: dict[str, Any]) -> str:
404
+ summary = metrics["eval_summary"]
405
+ calibration = metrics["calibration_summary"]
406
+ lines = [
407
+ "# Dominance-Calibrated CTT Selector",
408
+ "",
409
+ f"Calibration rows: `{metrics['num_calibration_rows']}`",
410
+ f"Eval rows: `{metrics['num_eval_rows']}`",
411
+ f"Alpha: `{metrics['alpha']}`",
412
+ f"Residual quantile: `{metrics['residual_quantile']:.6f}`",
413
+ f"Tau: `{metrics['tau']:.6f}` (`{metrics['tau_mode']}`)",
414
+ "",
415
+ "The threshold is fit on calibration rows only. Eval outcomes are used only for reporting.",
416
+ "",
417
+ "| Split | Coverage | Fallback | Base success | Selected success | Proposal oracle | OutcomePTR | Success support gap | Success selector gap |",
418
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
419
+ f"| calibration | {_fmt(calibration.get('coverage'))} | {_fmt(calibration.get('fallback_rate'))} | "
420
+ f"{_fmt(calibration.get('base_success'))} | {_fmt(calibration.get('selected_success'))} | "
421
+ f"{_fmt(calibration.get('proposal_oracle_success'))} | {_fmt(calibration.get('outcome_ptr'))} | "
422
+ f"{_fmt(calibration.get('success_support_gap'))} | {_fmt(calibration.get('success_selector_gap'))} |",
423
+ f"| eval | {_fmt(summary.get('coverage'))} | {_fmt(summary.get('fallback_rate'))} | "
424
+ f"{_fmt(summary.get('base_success'))} | {_fmt(summary.get('selected_success'))} | "
425
+ f"{_fmt(summary.get('proposal_oracle_success'))} | {_fmt(summary.get('outcome_ptr'))} | "
426
+ f"{_fmt(summary.get('success_support_gap'))} | {_fmt(summary.get('success_selector_gap'))} |",
427
+ "",
428
+ "This is a calibrated fallback diagnostic. It is not a final safety claim because unsafe-contact labels are not measured yet.",
429
+ ]
430
+ return "\n".join(lines)
431
+
432
+
433
+ def _write_provenance(out_dir: Path, args: argparse.Namespace) -> None:
434
+ (out_dir / "config.yaml").write_text(
435
+ "\n".join(f"{key}: {value}" for key, value in sorted(vars(args).items())) + "\n"
436
+ )
437
+ (out_dir / "command.txt").write_text(
438
+ "python scripts/eval_dominance_selector.py " + " ".join(sys.argv[1:]) + "\n"
439
+ )
440
+ (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
441
+ hashes = {
442
+ "calibration_input": _sha256(args.calibration_input),
443
+ "calibration_target_index": _sha256(args.calibration_target_index),
444
+ "eval_input": _sha256(args.eval_input),
445
+ "eval_target_index": _sha256(args.eval_target_index),
446
+ }
447
+ (out_dir / "data_hash.txt").write_text(json.dumps(hashes, indent=2, sort_keys=True) + "\n")
448
+ (out_dir / "split_hash.txt").write_text(
449
+ json.dumps(
450
+ {
451
+ "calibration_target_index": _index_hash(args.calibration_target_index),
452
+ "eval_target_index": _index_hash(args.eval_target_index),
453
+ },
454
+ indent=2,
455
+ sort_keys=True,
456
+ )
457
+ + "\n"
458
+ )
459
+
460
+
461
+ def _index_hash(path: Path) -> dict[str, Any]:
462
+ payload = json.loads(path.read_text())
463
+ return {
464
+ "split": payload.get("split"),
465
+ "content_hash": payload.get("content_hash"),
466
+ "split_hash": payload.get("split_hash"),
467
+ "retrieval_index_allowed": payload.get("retrieval_index_allowed"),
468
+ }
469
+
470
+
471
+ def _sha256(path: Path) -> str:
472
+ import hashlib
473
+
474
+ h = hashlib.sha256()
475
+ h.update(path.read_bytes())
476
+ return h.hexdigest()
477
+
478
+
479
+ def _fmt(value: Any) -> str:
480
+ if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
481
+ return "n/a"
482
+ return f"{float(value):.4f}"
483
+
484
+
485
+ def _run(command: list[str]) -> str:
486
+ try:
487
+ return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip()
488
+ except (subprocess.CalledProcessError, FileNotFoundError):
489
+ return ""
490
+
491
+
492
+ if __name__ == "__main__":
493
+ raise SystemExit(main())