anhtld commited on
Commit
671fc9a
·
verified ·
1 Parent(s): ee288aa

Add learned dominance selector diagnostic

Browse files
workspace/scripts/eval_learned_dominance_selector.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from collections import defaultdict
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
15
+ if str(PROJECT_ROOT) not in sys.path:
16
+ sys.path.insert(0, str(PROJECT_ROOT))
17
+
18
+ import numpy as np # noqa: E402
19
+
20
+ from cil.metrics import macro_micro_summary # noqa: E402
21
+ from scripts.eval_dominance_selector import _DominanceScorer, _chart_map, _rows # noqa: E402
22
+
23
+
24
+ FEATURE_NAMES = [
25
+ "bias",
26
+ "candidate_score",
27
+ "candidate_score_minus_base_score",
28
+ "row_score_z",
29
+ "candidate_index",
30
+ "is_top_score_candidate",
31
+ "source_chart_rank",
32
+ "tangent_rms_norm",
33
+ "tangent_linf_norm",
34
+ "num_candidates",
35
+ ]
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ parser = argparse.ArgumentParser(
40
+ description=(
41
+ "Train a lightweight dominance calibrator on measured calibration "
42
+ "rollouts and evaluate deployment-clean fallback on held-out rollouts."
43
+ )
44
+ )
45
+ parser.add_argument("--calibration-input", type=Path, required=True)
46
+ parser.add_argument("--calibration-target-index", type=Path, required=True)
47
+ parser.add_argument("--eval-input", type=Path, required=True)
48
+ parser.add_argument("--eval-target-index", type=Path, required=True)
49
+ parser.add_argument(
50
+ "--checkpoint-template",
51
+ default="runs/ctt_residual_full_seed{seed}/model.pt",
52
+ )
53
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/ctt_learned_dominance_val_to_test"))
54
+ parser.add_argument("--k", type=int, default=8)
55
+ parser.add_argument("--ridge-lambdas", default="0,0.01,0.1,1,10,100")
56
+ parser.add_argument("--bootstrap-samples", type=int, default=1000)
57
+ args = parser.parse_args(argv)
58
+
59
+ if args.k <= 0:
60
+ parser.error("--k must be positive")
61
+ lambdas = [float(item.strip()) for item in args.ridge_lambdas.split(",") if item.strip()]
62
+ if not lambdas or any(value < 0.0 for value in lambdas):
63
+ parser.error("--ridge-lambdas must contain non-negative values")
64
+
65
+ out_dir = args.out_dir
66
+ out_dir.mkdir(parents=True, exist_ok=True)
67
+ _write_provenance(out_dir, args)
68
+
69
+ scorer = _DominanceScorer(args.checkpoint_template)
70
+ calibration_rows = _rows(json.loads(args.calibration_input.read_text()))
71
+ eval_rows = _rows(json.loads(args.eval_input.read_text()))
72
+ calibration_charts, calibration_index = _chart_map(args.calibration_target_index)
73
+ eval_charts, eval_index = _chart_map(args.eval_target_index)
74
+
75
+ calibration_dataset = _candidate_dataset(
76
+ calibration_rows,
77
+ calibration_charts,
78
+ scorer=scorer,
79
+ k=args.k,
80
+ )
81
+ eval_dataset = _candidate_dataset(
82
+ eval_rows,
83
+ eval_charts,
84
+ scorer=scorer,
85
+ k=args.k,
86
+ )
87
+ best = _fit_select_ridge(calibration_dataset, lambdas=lambdas)
88
+ eval_cases = _evaluate_dataset(eval_dataset, best["weights"], best["mean"], best["std"], tau=best["tau"])
89
+ calibration_cases = _evaluate_dataset(
90
+ calibration_dataset,
91
+ best["weights"],
92
+ best["mean"],
93
+ best["std"],
94
+ tau=best["tau"],
95
+ )
96
+ metric_names = sorted(
97
+ {
98
+ key
99
+ for row in eval_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
+ eval_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": "learned_dominance_selector_eval",
117
+ "schema_version": 1,
118
+ "k": args.k,
119
+ "feature_names": FEATURE_NAMES,
120
+ "ridge_lambdas": lambdas,
121
+ "selected_lambda": best["lambda"],
122
+ "tau": best["tau"],
123
+ "weights": best["weights"].tolist(),
124
+ "feature_mean": best["mean"].tolist(),
125
+ "feature_std": best["std"].tolist(),
126
+ "calibration_input": str(args.calibration_input),
127
+ "eval_input": str(args.eval_input),
128
+ "calibration_target_content_hash": calibration_index.get("content_hash"),
129
+ "calibration_target_split_hash": calibration_index.get("split_hash"),
130
+ "eval_target_content_hash": eval_index.get("content_hash"),
131
+ "eval_target_split_hash": eval_index.get("split_hash"),
132
+ "num_calibration_rows": len(calibration_rows),
133
+ "num_eval_rows": len(eval_rows),
134
+ "num_calibration_candidates": len(calibration_dataset["samples"]),
135
+ "num_eval_candidates": len(eval_dataset["samples"]),
136
+ "calibration_model_selection": best["selection"],
137
+ "calibration_summary": _simple_summary(calibration_cases),
138
+ "eval_summary": _simple_summary(eval_cases),
139
+ "summary": summary,
140
+ "rows": eval_cases,
141
+ }
142
+ (out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
143
+ (out_dir / "metrics_by_task.json").write_text(
144
+ json.dumps(_group_means(eval_cases, "task_id", metric_names), indent=2, sort_keys=True)
145
+ + "\n"
146
+ )
147
+ (out_dir / "metrics_by_seed.json").write_text(
148
+ json.dumps(_group_means(eval_cases, "seed", metric_names), indent=2, sort_keys=True)
149
+ + "\n"
150
+ )
151
+ (out_dir / "table.tex").write_text(_table(metrics) + "\n")
152
+ (out_dir / "report.md").write_text(_report(metrics) + "\n")
153
+ (out_dir / "train.log").write_text(
154
+ "trained ridge dominance calibrator on calibration measured rows only\n"
155
+ f"selected_lambda={best['lambda']}\n"
156
+ f"tau={best['tau']:.6f}\n"
157
+ f"calibration_input={args.calibration_input}\n"
158
+ )
159
+ (out_dir / "eval.log").write_text(
160
+ "evaluated learned fallback rule on held-out measured rollout rows\n"
161
+ f"eval_input={args.eval_input}\n"
162
+ f"num_eval_rows={len(eval_rows)}\n"
163
+ )
164
+ print(
165
+ json.dumps(
166
+ {
167
+ "out_dir": str(out_dir),
168
+ "lambda": best["lambda"],
169
+ "tau": best["tau"],
170
+ "selected_success": metrics["eval_summary"]["selected_success"],
171
+ },
172
+ indent=2,
173
+ )
174
+ )
175
+ return 0
176
+
177
+
178
+ def _candidate_dataset(
179
+ rows: list[dict[str, Any]],
180
+ charts: dict[str, Any],
181
+ *,
182
+ scorer: _DominanceScorer,
183
+ k: int,
184
+ ) -> dict[str, Any]:
185
+ samples: list[dict[str, Any]] = []
186
+ by_row: dict[int, list[int]] = defaultdict(list)
187
+ for row_index, row in enumerate(rows):
188
+ chart_id = str(row.get("chart_id", row.get("group_id", "")))
189
+ if chart_id not in charts:
190
+ raise KeyError(f"chart_id {chart_id!r} not found in target index")
191
+ base_score = scorer.base_score(row, charts[chart_id])
192
+ scores = [float(value) for value in row.get("predicted_scores", [])[:k]]
193
+ utilities = [float(value) for value in row.get("generated_utilities", [])[:k]]
194
+ successes = [float(bool(value)) for value in row.get("candidate_success", [])[:k]]
195
+ tangents = row.get("generated_tangents", [])[:k]
196
+ candidate_types = row.get("candidate_types", [])[:k]
197
+ if not scores or len(scores) != len(utilities):
198
+ continue
199
+ score_mean = sum(scores) / len(scores)
200
+ score_std = math.sqrt(sum((score - score_mean) ** 2 for score in scores) / len(scores)) + 1.0e-6
201
+ for candidate_index, score in enumerate(scores):
202
+ tangent = np.asarray(
203
+ tangents[candidate_index] if candidate_index < len(tangents) else [],
204
+ dtype=float,
205
+ )
206
+ tangent_rms = float(np.linalg.norm(tangent) / math.sqrt(max(1, tangent.size)))
207
+ tangent_linf = float(np.max(np.abs(tangent))) if tangent.size else 0.0
208
+ feature = np.asarray(
209
+ [
210
+ 1.0,
211
+ score,
212
+ score - base_score,
213
+ (score - score_mean) / score_std,
214
+ float(candidate_index),
215
+ float(candidate_index == 0),
216
+ _source_rank(candidate_types[candidate_index] if candidate_index < len(candidate_types) else ""),
217
+ tangent_rms,
218
+ tangent_linf,
219
+ float(len(scores)),
220
+ ],
221
+ dtype=float,
222
+ )
223
+ sample_index = len(samples)
224
+ by_row[row_index].append(sample_index)
225
+ base_utility = float(row["base_utility"])
226
+ base_success = float(bool(row.get("base_success", False)))
227
+ hidden = [float(value) for value in row.get("hidden_chart_utilities", [])]
228
+ samples.append(
229
+ {
230
+ "row_index": row_index,
231
+ "candidate_index": candidate_index,
232
+ "feature": feature,
233
+ "target_margin": utilities[candidate_index] - base_utility,
234
+ "candidate_utility": utilities[candidate_index],
235
+ "candidate_success": successes[candidate_index],
236
+ "base_utility": base_utility,
237
+ "base_success": base_success,
238
+ "proposal_oracle_utility": max(utilities),
239
+ "proposal_oracle_success": float(any(successes)),
240
+ "hidden_chart_oracle_utility": max(hidden) if hidden else math.nan,
241
+ "hidden_chart_oracle_success": float(any(value >= 1.0 for value in hidden)) if hidden else math.nan,
242
+ "outcome_ptr": float(any(value > base_utility for value in utilities)),
243
+ "chart_id": chart_id,
244
+ "task_id": str(row.get("task_id", "unknown")),
245
+ "seed": str(row.get("seed", "unknown")),
246
+ "train_seed": str(row.get("train_seed", "unknown")),
247
+ }
248
+ )
249
+ return {"samples": samples, "by_row": dict(by_row), "num_rows": len(rows)}
250
+
251
+
252
+ def _fit_select_ridge(dataset: dict[str, Any], *, lambdas: list[float]) -> dict[str, Any]:
253
+ samples = dataset["samples"]
254
+ if not samples:
255
+ raise ValueError("cannot fit learned dominance selector without candidates")
256
+ x = np.stack([sample["feature"] for sample in samples], axis=0)
257
+ y = np.asarray([float(sample["target_margin"]) for sample in samples], dtype=float)
258
+ mean = np.zeros(x.shape[1], dtype=float)
259
+ std = np.ones(x.shape[1], dtype=float)
260
+ mean[1:] = x[:, 1:].mean(axis=0)
261
+ std[1:] = x[:, 1:].std(axis=0) + 1.0e-6
262
+ x_norm = (x - mean) / std
263
+ x_norm[:, 0] = 1.0
264
+ best: dict[str, Any] | None = None
265
+ for ridge_lambda in lambdas:
266
+ penalty = ridge_lambda * np.eye(x_norm.shape[1], dtype=float)
267
+ penalty[0, 0] = 0.0
268
+ weights = np.linalg.pinv(x_norm.T @ x_norm + penalty) @ (x_norm.T @ y)
269
+ predictions = x_norm @ weights
270
+ tau, selection = _choose_tau(dataset, predictions)
271
+ key = (
272
+ float(selection["selected_success"]),
273
+ float(selection["selected_utility"]),
274
+ float(selection["coverage"]),
275
+ -float(ridge_lambda),
276
+ )
277
+ if best is None or key > best["key"]:
278
+ best = {
279
+ "key": key,
280
+ "lambda": ridge_lambda,
281
+ "weights": weights,
282
+ "mean": mean,
283
+ "std": std,
284
+ "tau": tau,
285
+ "selection": selection,
286
+ }
287
+ assert best is not None
288
+ return best
289
+
290
+
291
+ def _choose_tau(dataset: dict[str, Any], predictions: np.ndarray) -> tuple[float, dict[str, float | None]]:
292
+ candidates = sorted(float(value) for value in predictions)
293
+ thresholds = [min(candidates) - 1.0, *candidates, max(candidates) + 1.0]
294
+ best_tau = thresholds[0]
295
+ best_summary: dict[str, float | None] | None = None
296
+ best_key: tuple[float, float, float] | None = None
297
+ for tau in thresholds:
298
+ cases = _evaluate_predictions(dataset, predictions, tau=tau)
299
+ summary = _simple_summary(cases)
300
+ key = (
301
+ float(summary.get("selected_success") or 0.0),
302
+ float(summary.get("selected_utility") or 0.0),
303
+ float(summary.get("coverage") or 0.0),
304
+ )
305
+ if best_key is None or key > best_key:
306
+ best_key = key
307
+ best_tau = float(tau)
308
+ best_summary = summary
309
+ assert best_summary is not None
310
+ return best_tau, best_summary
311
+
312
+
313
+ def _evaluate_dataset(
314
+ dataset: dict[str, Any],
315
+ weights: np.ndarray,
316
+ mean: np.ndarray,
317
+ std: np.ndarray,
318
+ *,
319
+ tau: float,
320
+ ) -> list[dict[str, Any]]:
321
+ x = np.stack([sample["feature"] for sample in dataset["samples"]], axis=0)
322
+ x_norm = (x - mean) / std
323
+ x_norm[:, 0] = 1.0
324
+ return _evaluate_predictions(dataset, x_norm @ weights, tau=tau)
325
+
326
+
327
+ def _evaluate_predictions(dataset: dict[str, Any], predictions: np.ndarray, *, tau: float) -> list[dict[str, Any]]:
328
+ samples = dataset["samples"]
329
+ rows: list[dict[str, Any]] = []
330
+ for row_index, sample_indices in sorted(dataset["by_row"].items()):
331
+ best_index = max(sample_indices, key=lambda index: float(predictions[index]))
332
+ sample = samples[best_index]
333
+ predicted_margin = float(predictions[best_index])
334
+ execute = predicted_margin > float(tau)
335
+ selected_utility = (
336
+ float(sample["candidate_utility"]) if execute else float(sample["base_utility"])
337
+ )
338
+ selected_success = (
339
+ float(sample["candidate_success"]) if execute else float(sample["base_success"])
340
+ )
341
+ hidden_utility = float(sample["hidden_chart_oracle_utility"])
342
+ hidden_success = float(sample["hidden_chart_oracle_success"])
343
+ rows.append(
344
+ {
345
+ "chart_id": sample["chart_id"],
346
+ "task_id": sample["task_id"],
347
+ "seed": sample["seed"],
348
+ "train_seed": sample["train_seed"],
349
+ "selected_candidate_index": int(sample["candidate_index"]),
350
+ "predicted_margin": predicted_margin,
351
+ "tau": float(tau),
352
+ "execute_generated": float(execute),
353
+ "coverage": float(execute),
354
+ "fallback_rate": float(not execute),
355
+ "base_utility": float(sample["base_utility"]),
356
+ "base_success": float(sample["base_success"]),
357
+ "selected_utility": selected_utility,
358
+ "selected_success": selected_success,
359
+ "selected_utility_gain_over_base": selected_utility - float(sample["base_utility"]),
360
+ "selected_success_gain_over_base": selected_success - float(sample["base_success"]),
361
+ "proposal_oracle_utility": float(sample["proposal_oracle_utility"]),
362
+ "proposal_oracle_success": float(sample["proposal_oracle_success"]),
363
+ "hidden_chart_oracle_utility": hidden_utility,
364
+ "hidden_chart_oracle_success": hidden_success,
365
+ "outcome_ptr": float(sample["outcome_ptr"]),
366
+ "selector_regret": max(0.0, float(sample["proposal_oracle_utility"]) - selected_utility),
367
+ "success_selector_gap": max(0.0, float(sample["proposal_oracle_success"]) - selected_success),
368
+ "support_gap": max(0.0, hidden_utility - float(sample["proposal_oracle_utility"]))
369
+ if math.isfinite(hidden_utility)
370
+ else math.nan,
371
+ "success_support_gap": max(0.0, hidden_success - float(sample["proposal_oracle_success"]))
372
+ if math.isfinite(hidden_success)
373
+ else math.nan,
374
+ }
375
+ )
376
+ return rows
377
+
378
+
379
+ def _simple_summary(rows: list[dict[str, Any]]) -> dict[str, float | None]:
380
+ keys = [
381
+ "base_success",
382
+ "selected_success",
383
+ "proposal_oracle_success",
384
+ "hidden_chart_oracle_success",
385
+ "selected_success_gain_over_base",
386
+ "coverage",
387
+ "fallback_rate",
388
+ "outcome_ptr",
389
+ "success_support_gap",
390
+ "success_selector_gap",
391
+ "base_utility",
392
+ "selected_utility",
393
+ "proposal_oracle_utility",
394
+ "hidden_chart_oracle_utility",
395
+ "support_gap",
396
+ "selector_regret",
397
+ ]
398
+ return {key: _mean([row.get(key) for row in rows]) for key in keys}
399
+
400
+
401
+ def _group_means(
402
+ rows: list[dict[str, Any]],
403
+ key: str,
404
+ metric_names: list[str],
405
+ ) -> dict[str, dict[str, float]]:
406
+ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
407
+ for row in rows:
408
+ grouped[str(row.get(key, "unknown"))].append(row)
409
+ output: dict[str, dict[str, float]] = {}
410
+ for group, group_rows in sorted(grouped.items()):
411
+ payload: dict[str, float] = {}
412
+ for metric in metric_names:
413
+ value = _mean([row.get(metric) for row in group_rows])
414
+ if value is not None:
415
+ payload[metric] = value
416
+ output[group] = payload
417
+ return output
418
+
419
+
420
+ def _mean(values: list[Any]) -> float | None:
421
+ clean = [
422
+ float(value)
423
+ for value in values
424
+ if isinstance(value, (int, float)) and math.isfinite(float(value))
425
+ ]
426
+ return sum(clean) / len(clean) if clean else None
427
+
428
+
429
+ def _source_rank(value: Any) -> float:
430
+ match = re.search(r"rank(\d+)", str(value))
431
+ return float(match.group(1)) if match else 0.0
432
+
433
+
434
+ def _table(metrics: dict[str, Any]) -> str:
435
+ summary = metrics["eval_summary"]
436
+ lines = [
437
+ "% Auto-generated by scripts/eval_learned_dominance_selector.py",
438
+ "\\begin{tabular}{lrrrrrrrr}",
439
+ "\\toprule",
440
+ "Rows & Coverage & Fallback & Base succ. & Selected succ. & Oracle succ. & OutcomePTR & Succ. support gap & Succ. selector gap \\\\",
441
+ "\\midrule",
442
+ f"{metrics['num_eval_rows']} & {_fmt(summary.get('coverage'))} & "
443
+ f"{_fmt(summary.get('fallback_rate'))} & {_fmt(summary.get('base_success'))} & "
444
+ f"{_fmt(summary.get('selected_success'))} & {_fmt(summary.get('proposal_oracle_success'))} & "
445
+ f"{_fmt(summary.get('outcome_ptr'))} & {_fmt(summary.get('success_support_gap'))} & "
446
+ f"{_fmt(summary.get('success_selector_gap'))} \\\\",
447
+ "\\bottomrule",
448
+ "\\end{tabular}",
449
+ ]
450
+ return "\n".join(lines)
451
+
452
+
453
+ def _report(metrics: dict[str, Any]) -> str:
454
+ summary = metrics["eval_summary"]
455
+ calibration = metrics["calibration_summary"]
456
+ lines = [
457
+ "# Learned Dominance-Calibrated CTT Selector",
458
+ "",
459
+ f"Calibration rows: `{metrics['num_calibration_rows']}`",
460
+ f"Eval rows: `{metrics['num_eval_rows']}`",
461
+ f"Selected ridge lambda: `{metrics['selected_lambda']}`",
462
+ f"Tau: `{metrics['tau']:.6f}`",
463
+ "",
464
+ "The ridge calibrator and threshold are fit on calibration measured rows only. Eval outcomes are used only for reporting.",
465
+ "",
466
+ "| Split | Coverage | Fallback | Base success | Selected success | Proposal oracle | OutcomePTR | Success support gap | Success selector gap |",
467
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
468
+ f"| calibration | {_fmt(calibration.get('coverage'))} | {_fmt(calibration.get('fallback_rate'))} | "
469
+ f"{_fmt(calibration.get('base_success'))} | {_fmt(calibration.get('selected_success'))} | "
470
+ f"{_fmt(calibration.get('proposal_oracle_success'))} | {_fmt(calibration.get('outcome_ptr'))} | "
471
+ f"{_fmt(calibration.get('success_support_gap'))} | {_fmt(calibration.get('success_selector_gap'))} |",
472
+ f"| eval | {_fmt(summary.get('coverage'))} | {_fmt(summary.get('fallback_rate'))} | "
473
+ f"{_fmt(summary.get('base_success'))} | {_fmt(summary.get('selected_success'))} | "
474
+ f"{_fmt(summary.get('proposal_oracle_success'))} | {_fmt(summary.get('outcome_ptr'))} | "
475
+ f"{_fmt(summary.get('success_support_gap'))} | {_fmt(summary.get('success_selector_gap'))} |",
476
+ "",
477
+ "This is a selector diagnostic over already measured candidates, not a new rollout.",
478
+ ]
479
+ return "\n".join(lines)
480
+
481
+
482
+ def _write_provenance(out_dir: Path, args: argparse.Namespace) -> None:
483
+ (out_dir / "config.yaml").write_text(
484
+ "\n".join(f"{key}: {value}" for key, value in sorted(vars(args).items())) + "\n"
485
+ )
486
+ (out_dir / "command.txt").write_text(
487
+ "python scripts/eval_learned_dominance_selector.py " + " ".join(sys.argv[1:]) + "\n"
488
+ )
489
+ (out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
490
+ hashes = {
491
+ "calibration_input": _sha256(args.calibration_input),
492
+ "calibration_target_index": _sha256(args.calibration_target_index),
493
+ "eval_input": _sha256(args.eval_input),
494
+ "eval_target_index": _sha256(args.eval_target_index),
495
+ }
496
+ (out_dir / "data_hash.txt").write_text(json.dumps(hashes, indent=2, sort_keys=True) + "\n")
497
+ (out_dir / "split_hash.txt").write_text(
498
+ json.dumps(
499
+ {
500
+ "calibration_target_index": _index_hash(args.calibration_target_index),
501
+ "eval_target_index": _index_hash(args.eval_target_index),
502
+ },
503
+ indent=2,
504
+ sort_keys=True,
505
+ )
506
+ + "\n"
507
+ )
508
+
509
+
510
+ def _index_hash(path: Path) -> dict[str, Any]:
511
+ payload = json.loads(path.read_text())
512
+ return {
513
+ "split": payload.get("split"),
514
+ "content_hash": payload.get("content_hash"),
515
+ "split_hash": payload.get("split_hash"),
516
+ "retrieval_index_allowed": payload.get("retrieval_index_allowed"),
517
+ }
518
+
519
+
520
+ def _sha256(path: Path) -> str:
521
+ import hashlib
522
+
523
+ h = hashlib.sha256()
524
+ h.update(path.read_bytes())
525
+ return h.hexdigest()
526
+
527
+
528
+ def _fmt(value: Any) -> str:
529
+ if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
530
+ return "n/a"
531
+ return f"{float(value):.4f}"
532
+
533
+
534
+ def _run(command: list[str]) -> str:
535
+ try:
536
+ return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip()
537
+ except (subprocess.CalledProcessError, FileNotFoundError):
538
+ return ""
539
+
540
+
541
+ if __name__ == "__main__":
542
+ raise SystemExit(main())