anhtld commited on
Commit
0637c8b
·
verified ·
1 Parent(s): cc9a7e8

auto-sync 2026-07-03T00:30:47Z workspace (part 3)

Browse files
workspace/runs/leakage_audit/report.json CHANGED
@@ -3,8 +3,8 @@
3
  "indexes": {
4
  "test": {
5
  "audience": "evaluator_only",
6
- "content_hash": "7409ed9e2a8c9f3515e23e14180ec51fbfd56536a59129ca759c4d855e257bf5",
7
- "include_outcomes": false,
8
  "num_groups_exported": 410,
9
  "num_rows": 6560,
10
  "path": "data/cil_charts/test/index.json",
@@ -23,8 +23,8 @@
23
  },
24
  "val": {
25
  "audience": "evaluator_only",
26
- "content_hash": "eb4c66b3f5952b80ad4dc8eee2fbd04742688d5bcdaec341fbc851e170371f82",
27
- "include_outcomes": false,
28
  "num_groups_exported": 419,
29
  "num_rows": 6704,
30
  "path": "data/cil_charts/val/index.json",
 
3
  "indexes": {
4
  "test": {
5
  "audience": "evaluator_only",
6
+ "content_hash": "1710ede7c73d8f72479fd63dd9941a0e8bb55211d3ea0267963abc9e7a043b91",
7
+ "include_outcomes": true,
8
  "num_groups_exported": 410,
9
  "num_rows": 6560,
10
  "path": "data/cil_charts/test/index.json",
 
23
  },
24
  "val": {
25
  "audience": "evaluator_only",
26
+ "content_hash": "fa0fe0e3881ee70ce91492445871614d01b3a47fc7c576659c53ff17ddee4e42",
27
+ "include_outcomes": true,
28
  "num_groups_exported": 419,
29
  "num_rows": 6704,
30
  "path": "data/cil_charts/val/index.json",
workspace/runs/leakage_audit/report.md CHANGED
@@ -4,9 +4,9 @@ Status: `pass`
4
 
5
  | Split | Rows | Charts | Outcomes in DB | Retrieval allowed | Audience |
6
  | --- | ---: | ---: | --- | --- | --- |
7
- | test | 6560 | 410 | False | False | evaluator_only |
8
  | train | 32704 | 2044 | True | True | train_retrieval |
9
- | val | 6704 | 419 | False | False | evaluator_only |
10
 
11
  ## Violations
12
 
 
4
 
5
  | Split | Rows | Charts | Outcomes in DB | Retrieval allowed | Audience |
6
  | --- | ---: | ---: | --- | --- | --- |
7
+ | test | 6560 | 410 | True | False | evaluator_only |
8
  | train | 32704 | 2044 | True | True | train_retrieval |
9
+ | val | 6704 | 419 | True | False | evaluator_only |
10
 
11
  ## Violations
12
 
workspace/runs/summary_ctt.csv CHANGED
@@ -2,6 +2,10 @@ method,run_path,clean,status,success,proposal_oracle,support_gap,selector_gap,ou
2
  V0 residual transport diagnostic,runs/reproduce_v0,yes,measured diagnostic baseline,0.3890,0.4435,0.1264,0.0545,n/a,0.2366,0.5269,0.0533,n/a,3,see run
3
  CTT gated residual,runs/ctt_gated_residual_smoke,yes,train,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,unknown,see run
4
  CTT gated residual,runs/ctt_gated_residual_smoke_proxy,yes,proxy,n/a,n/a,n/a,n/a,n/a,0.5000,0.7500,0.0573,n/a,unknown,see run
 
5
  CTT residual train,runs/ctt_residual_smoke,yes,train,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,unknown,see run
6
  CTT residual proxy,runs/ctt_residual_smoke_proxy,yes,proxy,n/a,n/a,n/a,n/a,n/a,1.0000,1.0000,0.1010,n/a,unknown,see run
 
 
 
7
  Utility energy smoke,runs/utility_energy_smoke,train-only,calibration diagnostic,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,0.1316,train split,see run
 
2
  V0 residual transport diagnostic,runs/reproduce_v0,yes,measured diagnostic baseline,0.3890,0.4435,0.1264,0.0545,n/a,0.2366,0.5269,0.0533,n/a,3,see run
3
  CTT gated residual,runs/ctt_gated_residual_smoke,yes,train,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,unknown,see run
4
  CTT gated residual,runs/ctt_gated_residual_smoke_proxy,yes,proxy,n/a,n/a,n/a,n/a,n/a,0.5000,0.7500,0.0573,n/a,unknown,see run
5
+ CTT gated residual,runs/ctt_gated_residual_val_proxy,yes,proxy,n/a,n/a,n/a,n/a,n/a,0.1739,0.5362,0.0281,n/a,47,see run
6
  CTT residual train,runs/ctt_residual_smoke,yes,train,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,unknown,see run
7
  CTT residual proxy,runs/ctt_residual_smoke_proxy,yes,proxy,n/a,n/a,n/a,n/a,n/a,1.0000,1.0000,0.1010,n/a,unknown,see run
8
+ CTT residual proxy,runs/ctt_residual_val_proxy,yes,proxy,n/a,n/a,n/a,n/a,n/a,0.3478,0.7101,0.0397,n/a,47,see run
9
+ task memory,runs/task_memory_val_proxy,yes,proxy baseline,n/a,n/a,n/a,n/a,n/a,0.3188,0.5362,0.0175,n/a,47,see run
10
+ local atlas,runs/local_atlas_val_proxy,yes,proxy baseline,n/a,n/a,n/a,n/a,n/a,0.4058,0.6812,0.0368,n/a,47,see run
11
  Utility energy smoke,runs/utility_energy_smoke,train-only,calibration diagnostic,n/a,n/a,n/a,n/a,n/a,n/a,n/a,n/a,0.1316,train split,see run
workspace/runs/summary_ctt.md CHANGED
@@ -5,8 +5,12 @@
5
  | V0 residual transport diagnostic | measured diagnostic baseline | 0.3890 | 0.4435 | 0.1264 | 0.0545 | n/a | 0.2366 | 0.5269 | 0.0533 | n/a | `runs/reproduce_v0` |
6
  | CTT gated residual | train | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | `runs/ctt_gated_residual_smoke` |
7
  | CTT gated residual | proxy | n/a | n/a | n/a | n/a | n/a | 0.5000 | 0.7500 | 0.0573 | n/a | `runs/ctt_gated_residual_smoke_proxy` |
 
8
  | CTT residual train | train | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | `runs/ctt_residual_smoke` |
9
  | CTT residual proxy | proxy | n/a | n/a | n/a | n/a | n/a | 1.0000 | 1.0000 | 0.1010 | n/a | `runs/ctt_residual_smoke_proxy` |
 
 
 
10
  | Utility energy smoke | calibration diagnostic | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.1316 | `runs/utility_energy_smoke` |
11
 
12
  `n/a` means the required measured artifact does not exist yet.
 
5
  | V0 residual transport diagnostic | measured diagnostic baseline | 0.3890 | 0.4435 | 0.1264 | 0.0545 | n/a | 0.2366 | 0.5269 | 0.0533 | n/a | `runs/reproduce_v0` |
6
  | CTT gated residual | train | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | `runs/ctt_gated_residual_smoke` |
7
  | CTT gated residual | proxy | n/a | n/a | n/a | n/a | n/a | 0.5000 | 0.7500 | 0.0573 | n/a | `runs/ctt_gated_residual_smoke_proxy` |
8
+ | CTT gated residual | proxy | n/a | n/a | n/a | n/a | n/a | 0.1739 | 0.5362 | 0.0281 | n/a | `runs/ctt_gated_residual_val_proxy` |
9
  | CTT residual train | train | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | `runs/ctt_residual_smoke` |
10
  | CTT residual proxy | proxy | n/a | n/a | n/a | n/a | n/a | 1.0000 | 1.0000 | 0.1010 | n/a | `runs/ctt_residual_smoke_proxy` |
11
+ | CTT residual proxy | proxy | n/a | n/a | n/a | n/a | n/a | 0.3478 | 0.7101 | 0.0397 | n/a | `runs/ctt_residual_val_proxy` |
12
+ | task memory | proxy baseline | n/a | n/a | n/a | n/a | n/a | 0.3188 | 0.5362 | 0.0175 | n/a | `runs/task_memory_val_proxy` |
13
+ | local atlas | proxy baseline | n/a | n/a | n/a | n/a | n/a | 0.4058 | 0.6812 | 0.0368 | n/a | `runs/local_atlas_val_proxy` |
14
  | Utility energy smoke | calibration diagnostic | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.1316 | `runs/utility_energy_smoke` |
15
 
16
  `n/a` means the required measured artifact does not exist yet.
workspace/scripts/audit_cil_charts.py CHANGED
@@ -79,10 +79,13 @@ def _audit_split_flags(indexes: dict[str, tuple[Path, dict[str, Any]]], violatio
79
  else:
80
  if retrieval:
81
  violations.append(f"{path}: non-train split is retrieval_index_allowed")
82
- if include_outcomes:
83
- violations.append(f"{path}: non-train split exposes outcomes in chart DB")
84
  if audience != "evaluator_only":
85
  violations.append(f"{path}: non-train split audience must be evaluator_only")
 
 
 
 
 
86
  if split != str(payload.get("split")):
87
  violations.append(f"{path}: index split field does not match directory")
88
  if not payload.get("deployment_candidate_excludes_expert", False):
 
79
  else:
80
  if retrieval:
81
  violations.append(f"{path}: non-train split is retrieval_index_allowed")
 
 
82
  if audience != "evaluator_only":
83
  violations.append(f"{path}: non-train split audience must be evaluator_only")
84
+ contract = payload.get("leakage_contract", {})
85
+ if not bool(contract.get("deployment_must_not_read_outcomes", False)):
86
+ violations.append(
87
+ f"{path}: non-train split must declare deployment_must_not_read_outcomes=true"
88
+ )
89
  if split != str(payload.get("split")):
90
  violations.append(f"{path}: index split field does not match directory")
91
  if not payload.get("deployment_candidate_excludes_expert", False):
workspace/scripts/eval_ctt_proxy.py CHANGED
@@ -16,7 +16,10 @@ if str(PROJECT_ROOT) not in sys.path:
16
  import torch # noqa: E402
17
 
18
  from cil.metrics import ( # noqa: E402
 
 
19
  macro_micro_summary,
 
20
  negative_near_at_threshold,
21
  positives_closer_than_negatives,
22
  proxy_positive_tangent_coverage_at_k,
@@ -51,6 +54,7 @@ def main(argv: list[str] | None = None) -> int:
51
 
52
  source_charts, source_index = load_charts(args.source_index, max_charts=None)
53
  target_charts, target_index = load_charts(args.target_index, max_charts=args.max_target_charts)
 
54
  rows = []
55
  log_lines = [
56
  f"source_charts={len(source_charts)} target_charts={len(target_charts)} k={args.k}",
@@ -85,7 +89,7 @@ def main(argv: list[str] | None = None) -> int:
85
  row: dict[str, Any] = {
86
  "chart_id": target.chart_id,
87
  "task_id": target.task_id,
88
- "seed": "unknown",
89
  "num_proposals": len(proposals),
90
  }
91
  for threshold in thresholds:
@@ -104,8 +108,14 @@ def main(argv: list[str] | None = None) -> int:
104
  )
105
  distance = proxy_support_distance(proposals, positives, k=args.k)
106
  row[f"proxy_support_distance_at_{args.k}"] = distance
 
 
 
 
107
  closer = positives_closer_than_negatives(proposals, positives, negatives, k=args.k)
108
  row[f"pos_closer_than_neg_at_{args.k}"] = closer
 
 
109
  rows.append(row)
110
 
111
  metric_names = sorted(
@@ -134,7 +144,7 @@ def main(argv: list[str] | None = None) -> int:
134
  }
135
  (out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
136
  (out_dir / "metrics_by_task.json").write_text(json.dumps(_by_task(rows, metric_names), indent=2, sort_keys=True) + "\n")
137
- (out_dir / "metrics_by_seed.json").write_text("{}\n")
138
  (out_dir / "eval.log").write_text("\n".join(log_lines) + "\n")
139
  (out_dir / "train.log").write_text("see checkpoint run\n")
140
  (out_dir / "table.tex").write_text(_table(summary) + "\n")
@@ -143,6 +153,29 @@ def main(argv: list[str] | None = None) -> int:
143
  return 0
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def _write_run_provenance(
147
  out_dir: Path,
148
  args: argparse.Namespace,
@@ -164,16 +197,24 @@ def _run(command: list[str]) -> str:
164
 
165
 
166
  def _by_task(rows: list[dict[str, Any]], metric_names: list[str]) -> dict[str, dict[str, float]]:
 
 
 
 
 
 
 
 
167
  output: dict[str, dict[str, float]] = {}
168
  for row in rows:
169
- task = str(row["task_id"])
170
- output.setdefault(task, {})
171
- for task in output:
172
- task_rows = [row for row in rows if row["task_id"] == task]
173
  for metric in metric_names:
174
- values = [float(row[metric]) for row in task_rows if isinstance(row.get(metric), (int, float))]
175
  if values:
176
- output[task][metric] = sum(values) / len(values)
177
  return output
178
 
179
 
 
16
  import torch # noqa: E402
17
 
18
  from cil.metrics import ( # noqa: E402
19
+ candidate_diversity,
20
+ collapse_rate,
21
  macro_micro_summary,
22
+ mean_nearest_distance_to_set,
23
  negative_near_at_threshold,
24
  positives_closer_than_negatives,
25
  proxy_positive_tangent_coverage_at_k,
 
54
 
55
  source_charts, source_index = load_charts(args.source_index, max_charts=None)
56
  target_charts, target_index = load_charts(args.target_index, max_charts=args.max_target_charts)
57
+ _validate_indexes(args.source_index, source_index, args.target_index, target_index)
58
  rows = []
59
  log_lines = [
60
  f"source_charts={len(source_charts)} target_charts={len(target_charts)} k={args.k}",
 
89
  row: dict[str, Any] = {
90
  "chart_id": target.chart_id,
91
  "task_id": target.task_id,
92
+ "seed": target.seed,
93
  "num_proposals": len(proposals),
94
  }
95
  for threshold in thresholds:
 
108
  )
109
  distance = proxy_support_distance(proposals, positives, k=args.k)
110
  row[f"proxy_support_distance_at_{args.k}"] = distance
111
+ positive_distance = mean_nearest_distance_to_set(proposals, positives, k=args.k)
112
+ row[f"mean_positive_distance_at_{args.k}"] = positive_distance
113
+ negative_distance = mean_nearest_distance_to_set(proposals, negatives, k=args.k)
114
+ row[f"mean_negative_distance_at_{args.k}"] = negative_distance
115
  closer = positives_closer_than_negatives(proposals, positives, negatives, k=args.k)
116
  row[f"pos_closer_than_neg_at_{args.k}"] = closer
117
+ row[f"candidate_diversity_at_{args.k}"] = candidate_diversity(proposals, k=args.k)
118
+ row[f"collapse_rate_at_{args.k}"] = collapse_rate(proposals, k=args.k)
119
  rows.append(row)
120
 
121
  metric_names = sorted(
 
144
  }
145
  (out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
146
  (out_dir / "metrics_by_task.json").write_text(json.dumps(_by_task(rows, metric_names), indent=2, sort_keys=True) + "\n")
147
+ (out_dir / "metrics_by_seed.json").write_text(json.dumps(_by_group(rows, metric_names, "seed"), indent=2, sort_keys=True) + "\n")
148
  (out_dir / "eval.log").write_text("\n".join(log_lines) + "\n")
149
  (out_dir / "train.log").write_text("see checkpoint run\n")
150
  (out_dir / "table.tex").write_text(_table(summary) + "\n")
 
153
  return 0
154
 
155
 
156
+ def _validate_indexes(
157
+ source_path: Path,
158
+ source_index: dict[str, Any],
159
+ target_path: Path,
160
+ target_index: dict[str, Any],
161
+ ) -> None:
162
+ if source_index.get("split") != "train" or not source_index.get("retrieval_index_allowed"):
163
+ raise SystemExit(
164
+ f"{source_path} is not a train-only retrieval index; CTT proxy eval must "
165
+ "retrieve source positives from train split only"
166
+ )
167
+ if not source_index.get("include_outcomes"):
168
+ raise SystemExit(f"{source_path} must include train outcomes for source positives")
169
+ if not target_index.get("include_outcomes"):
170
+ raise SystemExit(
171
+ f"{target_path} does not expose evaluator outcomes/labels. "
172
+ "Proxy support evaluation needs an evaluator-only target chart DB; "
173
+ "do not substitute hidden labels or distance proxies from train self-target."
174
+ )
175
+ if target_index.get("split") != "train" and target_index.get("retrieval_index_allowed"):
176
+ raise SystemExit(f"{target_path} is non-train but marked retrieval_index_allowed")
177
+
178
+
179
  def _write_run_provenance(
180
  out_dir: Path,
181
  args: argparse.Namespace,
 
197
 
198
 
199
  def _by_task(rows: list[dict[str, Any]], metric_names: list[str]) -> dict[str, dict[str, float]]:
200
+ return _by_group(rows, metric_names, "task_id")
201
+
202
+
203
+ def _by_group(
204
+ rows: list[dict[str, Any]],
205
+ metric_names: list[str],
206
+ group_key: str,
207
+ ) -> dict[str, dict[str, float]]:
208
  output: dict[str, dict[str, float]] = {}
209
  for row in rows:
210
+ group = str(row[group_key])
211
+ output.setdefault(group, {})
212
+ for group in output:
213
+ group_rows = [row for row in rows if str(row[group_key]) == group]
214
  for metric in metric_names:
215
+ values = [float(row[metric]) for row in group_rows if isinstance(row.get(metric), (int, float))]
216
  if values:
217
+ output[group][metric] = sum(values) / len(values)
218
  return output
219
 
220
 
workspace/scripts/eval_metrics.py CHANGED
@@ -16,7 +16,10 @@ if str(PROJECT_ROOT) not in sys.path:
16
  from cil.metrics import ( # noqa: E402
17
  MetricInputError,
18
  branch_car,
 
 
19
  macro_micro_summary,
 
20
  measured_support_gap,
21
  negative_near_at_threshold,
22
  outcome_ptr_at_k,
@@ -178,11 +181,17 @@ def _proxy_row(row: dict[str, Any], *, k: int, thresholds: list[float]) -> dict[
178
  distance = proxy_support_distance(generated, positives, k=k)
179
  if distance is not None:
180
  output[f"proxy_support_distance_at_{k}"] = distance
 
 
 
 
 
 
181
  closer = positives_closer_than_negatives(generated, positives, negatives, k=k)
182
  if closer is not None:
183
  output[f"pos_closer_than_neg_at_{k}"] = closer
184
- output[f"candidate_diversity_at_{k}"] = _mean_pairwise_distance(generated[:k])
185
- output[f"collapse_rate_at_{k}"] = _collapse_rate(generated[:k])
186
  return output
187
 
188
 
@@ -297,24 +306,6 @@ def _markdown_report(mode: str, k: int, summary: dict[str, Any]) -> str:
297
  return "\n".join(lines)
298
 
299
 
300
- def _mean_pairwise_distance(vectors: list[list[float]]) -> float:
301
- if len(vectors) < 2:
302
- return 0.0
303
- distances = []
304
- for left_index, left in enumerate(vectors):
305
- for right in vectors[left_index + 1 :]:
306
- distances.append(_rms_l2(left, right))
307
- return sum(distances) / len(distances)
308
-
309
-
310
- def _collapse_rate(vectors: list[list[float]], *, threshold: float = 1.0e-6) -> float:
311
- if not vectors:
312
- return 0.0
313
- first = vectors[0]
314
- collapsed = sum(1 for vector in vectors if _rms_l2(first, vector) <= threshold)
315
- return collapsed / len(vectors)
316
-
317
-
318
  def _rms_l2(left: list[float], right: list[float]) -> float:
319
  if len(left) != len(right):
320
  raise MetricInputError("vectors must have matching dimensions")
 
16
  from cil.metrics import ( # noqa: E402
17
  MetricInputError,
18
  branch_car,
19
+ candidate_diversity,
20
+ collapse_rate,
21
  macro_micro_summary,
22
+ mean_nearest_distance_to_set,
23
  measured_support_gap,
24
  negative_near_at_threshold,
25
  outcome_ptr_at_k,
 
181
  distance = proxy_support_distance(generated, positives, k=k)
182
  if distance is not None:
183
  output[f"proxy_support_distance_at_{k}"] = distance
184
+ positive_distance = mean_nearest_distance_to_set(generated, positives, k=k)
185
+ if positive_distance is not None:
186
+ output[f"mean_positive_distance_at_{k}"] = positive_distance
187
+ negative_distance = mean_nearest_distance_to_set(generated, negatives, k=k)
188
+ if negative_distance is not None:
189
+ output[f"mean_negative_distance_at_{k}"] = negative_distance
190
  closer = positives_closer_than_negatives(generated, positives, negatives, k=k)
191
  if closer is not None:
192
  output[f"pos_closer_than_neg_at_{k}"] = closer
193
+ output[f"candidate_diversity_at_{k}"] = candidate_diversity(generated, k=k)
194
+ output[f"collapse_rate_at_{k}"] = collapse_rate(generated, k=k)
195
  return output
196
 
197
 
 
306
  return "\n".join(lines)
307
 
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  def _rms_l2(left: list[float], right: list[float]) -> float:
310
  if len(left) != len(right):
311
  raise MetricInputError("vectors must have matching dimensions")
workspace/scripts/summarize_ctt_runs.py CHANGED
@@ -41,6 +41,11 @@ def main(argv: list[str] | None = None) -> int:
41
  rows.extend(_reproduce_rows(reproduce))
42
  for metrics_path in sorted(args.run_root.glob("ctt*/metrics.json")):
43
  rows.append(_ctt_row(metrics_path))
 
 
 
 
 
44
  utility = args.run_root / "utility_energy_smoke" / "metrics.json"
45
  if utility.exists():
46
  rows.append(_utility_row(utility))
@@ -107,7 +112,30 @@ def _ctt_row(path: Path) -> dict[str, str]:
107
  "pptc_0p40": _summary_mean(summary, "pptc_at_16_thr_0p40"),
108
  "negative_near_0p20": _summary_mean(summary, "negative_near_at_16_thr_0p20"),
109
  "calibration_ece": "n/a",
110
- "seeds": "unknown",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  "ci": "see run",
112
  }
113
 
@@ -152,6 +180,17 @@ def _fmt(value: Any) -> str:
152
  return f"{float(value):.4f}"
153
 
154
 
 
 
 
 
 
 
 
 
 
 
 
155
  def _markdown(rows: list[dict[str, str]]) -> str:
156
  lines = [
157
  "# CTT Summary",
 
41
  rows.extend(_reproduce_rows(reproduce))
42
  for metrics_path in sorted(args.run_root.glob("ctt*/metrics.json")):
43
  rows.append(_ctt_row(metrics_path))
44
+ for metrics_path in sorted(args.run_root.glob("*memory*_val_proxy/metrics.json")):
45
+ rows.append(_positive_memory_row(metrics_path))
46
+ local_atlas = args.run_root / "local_atlas_val_proxy" / "metrics.json"
47
+ if local_atlas.exists():
48
+ rows.append(_positive_memory_row(local_atlas))
49
  utility = args.run_root / "utility_energy_smoke" / "metrics.json"
50
  if utility.exists():
51
  rows.append(_utility_row(utility))
 
112
  "pptc_0p40": _summary_mean(summary, "pptc_at_16_thr_0p40"),
113
  "negative_near_0p20": _summary_mean(summary, "negative_near_at_16_thr_0p20"),
114
  "calibration_ece": "n/a",
115
+ "seeds": _seed_count(path.parent),
116
+ "ci": "see run",
117
+ }
118
+
119
+
120
+ def _positive_memory_row(path: Path) -> dict[str, str]:
121
+ data = json.loads(path.read_text())
122
+ summary = data.get("summary", {})
123
+ method = str(data.get("method", path.parent.name)).replace("_", " ")
124
+ return {
125
+ "method": method,
126
+ "run_path": str(path.parent),
127
+ "clean": "yes",
128
+ "status": "proxy baseline",
129
+ "success": "n/a",
130
+ "proposal_oracle": "n/a",
131
+ "support_gap": "n/a",
132
+ "selector_gap": "n/a",
133
+ "outcome_ptr": "n/a",
134
+ "pptc_0p20": _summary_mean(summary, "pptc_at_16_thr_0p20"),
135
+ "pptc_0p40": _summary_mean(summary, "pptc_at_16_thr_0p40"),
136
+ "negative_near_0p20": _summary_mean(summary, "negative_near_at_16_thr_0p20"),
137
+ "calibration_ece": "n/a",
138
+ "seeds": _seed_count(path.parent),
139
  "ci": "see run",
140
  }
141
 
 
180
  return f"{float(value):.4f}"
181
 
182
 
183
+ def _seed_count(run_dir: Path) -> str:
184
+ path = run_dir / "metrics_by_seed.json"
185
+ if not path.exists():
186
+ return "unknown"
187
+ try:
188
+ data = json.loads(path.read_text())
189
+ except json.JSONDecodeError:
190
+ return "unknown"
191
+ return str(len(data)) if isinstance(data, dict) and data else "unknown"
192
+
193
+
194
  def _markdown(rows: list[dict[str, str]]) -> str:
195
  lines = [
196
  "# CTT Summary",