#!/usr/bin/env python3 """Plan 5 Task 2 — populate `config/metric-bands.toml` from N known-good aggregate JSONs. Consumes the per-stream aggregate JSONs produced by `scripts/aggregate-multi-seed-metrics.py` (Plan 5 Task 1B.2) and emits TOML band entries at `mean ± 5σ` (warn) / `mean ± 10σ` (error). The N>=2 case is the production path; for N=1 we fall back to safe symmetric multiplicative defaults documented inline. Usage: populate-metric-bands-from-runs.py \\ --aggregate run-a.json --aggregate run-b.json --aggregate run-c.json \\ --warmup-end-epoch 10 \\ --include-metrics avg_q_value train_loss val_sharpe ... \\ --output config/metric-bands.toml Each `--aggregate` JSON is expected to contain the schema written by `aggregate-multi-seed-metrics.py`: { "aggregates": { "": [ {"epoch": int, "mean": float, ...}, ... ], ... } } The script computes per-metric POST-WARMUP statistics (epoch >= warmup-end) across the union of all samples from all aggregate JSONs: band_warn = [grand_mean - 5σ, grand_mean + 5σ] band_error = [grand_mean - 10σ, grand_mean + 10σ] Where σ is the population standard deviation of the per-(epoch, run) sample values (NOT the std of per-run means — we want to capture WITHIN-RUN epoch drift in the band, not just CROSS-RUN dispersion). For metrics where the variance is genuinely zero post-warmup (e.g. `tau_eff = 0.005` always), we widen the band slightly so a single out-of- band epoch doesn't trip on numerical noise: σ_eff = max(σ, |grand_mean| * 1e-4 + 1e-9) For N=1 sample we fall back to multiplicative defaults — same shape as the plan's `[settings]` block but multiplicative since we have no sigma: band_warn = [|x| * 0.5, |x| * 2.0] (signed: [min(x*0.5,x*2), max(x*0.5,x*2)]) band_error = [|x| * 0.1, |x| * 10.0] Existing bands in the output toml are PRESERVED unless --force is passed. The `[settings]` block is preserved verbatim. """ from __future__ import annotations import argparse import json import math import sys from collections import defaultdict from pathlib import Path from statistics import pstdev def load_aggregate_json(path: Path) -> dict: """Load an aggregate JSON written by aggregate-multi-seed-metrics.py.""" with path.open("r", encoding="utf-8") as fh: return json.load(fh) def collect_post_warmup_samples( aggregates: list[dict], warmup_end_epoch: int, ) -> dict[str, list[float]]: """For each metric, collect per-epoch samples from epoch >= warmup_end across all runs. Each aggregate's `aggregates.[i].mean` already represents the cross-seed mean at epoch i. We keep that as a single sample (one per run × epoch). Cross-run dispersion is captured by including all runs' means; within-run epoch drift is captured by including all post-warmup epochs. """ by_metric: dict[str, list[float]] = defaultdict(list) for agg in aggregates: for metric_name, rows in agg.get("aggregates", {}).items(): for row in rows: if row.get("epoch", -1) >= warmup_end_epoch: val = row.get("mean") if val is not None and math.isfinite(val): by_metric[metric_name].append(float(val)) return dict(by_metric) def compute_bands( samples: list[float], warn_sigma: float = 5.0, error_sigma: float = 10.0, ) -> dict[str, float] | None: """Compute warn / error bands from N samples. Returns None if N==0.""" n = len(samples) if n == 0: return None mean_val = sum(samples) / n if n == 1: # Multiplicative fallback — captures sign while widening generously. x = samples[0] # Symmetric multiplicative shape; collapse to additive for x near 0. if abs(x) < 1e-9: return { "warn_low": -1.0, "warn_high": 1.0, "error_low": -10.0, "error_high": 10.0, } warn_lo = min(x * 0.5, x * 2.0) warn_hi = max(x * 0.5, x * 2.0) err_lo = min(x * 0.1, x * 10.0) err_hi = max(x * 0.1, x * 10.0) # Make sure error envelope strictly contains warn envelope. err_lo = min(err_lo, warn_lo - abs(warn_hi - warn_lo)) err_hi = max(err_hi, warn_hi + abs(warn_hi - warn_lo)) return { "warn_low": warn_lo, "warn_high": warn_hi, "error_low": err_lo, "error_high": err_hi, } sigma = pstdev(samples) # Effective sigma — never zero. sigma_eff = max(sigma, abs(mean_val) * 1e-4 + 1e-9) return { "warn_low": mean_val - warn_sigma * sigma_eff, "warn_high": mean_val + warn_sigma * sigma_eff, "error_low": mean_val - error_sigma * sigma_eff, "error_high": mean_val + error_sigma * sigma_eff, } def format_toml( settings_block: str, bands: dict[str, dict[str, float]], ) -> str: """Render a metric-bands.toml document with the given settings + bands. Settings block is taken verbatim from the existing file (or a sensible default). Bands are written under a flat `[bands]` table with one sub-table per metric: [bands.] warn_low = ... warn_high = ... error_low = ... error_high = ... """ lines: list[str] = [] lines.append("# Regression-detection bands for DQN training metrics.") lines.append("# Invariant 7. Per A.4, bands populated from last 3 known-good runs.") lines.append("# Band = [mean - 5σ, mean + 5σ] per metric (warn);") lines.append("# [mean - 10σ, mean + 10σ] (error). Recomputed after baseline promotion.") lines.append("# N consecutive out-of-band epochs → WARN; 2N consecutive → ERROR + clean termination.") lines.append("") lines.append(settings_block.rstrip()) lines.append("") lines.append("[bands]") lines.append("") for metric in sorted(bands.keys()): b = bands[metric] lines.append(f"[bands.{metric}]") lines.append(f"warn_low = {_fmt_float(b['warn_low'])}") lines.append(f"warn_high = {_fmt_float(b['warn_high'])}") lines.append(f"error_low = {_fmt_float(b['error_low'])}") lines.append(f"error_high = {_fmt_float(b['error_high'])}") lines.append("") return "\n".join(lines).rstrip() + "\n" def _fmt_float(x: float) -> str: """TOML-friendly float rendering with reasonable precision.""" if not math.isfinite(x): # toml has no NaN/Inf; use a sentinel that downstream load-time # validation will reject loudly. return "0.0 # WARNING: non-finite computed band — please check inputs" if x == 0.0: return "0.0" if abs(x) >= 1e6 or abs(x) < 1e-3: return f"{x:.6e}" return f"{x:.6f}" def parse_existing_settings(path: Path) -> str: """Extract the `[settings]` block from the existing toml (verbatim).""" if not path.is_file(): return ( "[settings]\n" "consecutive_epochs_for_warn = 3\n" "consecutive_epochs_for_error = 6\n" ) text = path.read_text(encoding="utf-8") out_lines: list[str] = [] in_block = False for line in text.splitlines(): stripped = line.strip() if stripped == "[settings]": in_block = True out_lines.append(line) continue if in_block: if stripped.startswith("[") and stripped.endswith("]"): break out_lines.append(line) if not out_lines: return ( "[settings]\n" "consecutive_epochs_for_warn = 3\n" "consecutive_epochs_for_error = 6\n" ) return "\n".join(out_lines) + "\n" def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument( "--aggregate", action="append", type=Path, default=[], help="Path to an aggregate JSON (output of aggregate-multi-seed-metrics.py). " "Pass once per known-good run. N>=3 is the plan-prescribed minimum; N=1 falls back to safe defaults.", ) ap.add_argument( "--warmup-end-epoch", type=int, default=10, help="Epoch at which warmup ends — only epoch >= this contributes to band statistics. Default 10.", ) ap.add_argument( "--include-metrics", nargs="+", default=[], help="If non-empty, restrict band emission to these metric names only. Otherwise emit all metrics found.", ) ap.add_argument( "--output", type=Path, required=True, help="Output TOML path (typically config/metric-bands.toml).", ) ap.add_argument( "--force", action="store_true", help="Overwrite existing band entries instead of preserving them. Settings block is always preserved.", ) args = ap.parse_args() if not args.aggregate: print("ERROR: at least one --aggregate JSON is required", file=sys.stderr) return 1 aggregates = [] for p in args.aggregate: if not p.is_file(): print(f"ERROR: aggregate JSON not found: {p}", file=sys.stderr) return 1 aggregates.append(load_aggregate_json(p)) by_metric = collect_post_warmup_samples(aggregates, args.warmup_end_epoch) if args.include_metrics: keep = set(args.include_metrics) by_metric = {k: v for k, v in by_metric.items() if k in keep} bands: dict[str, dict[str, float]] = {} for metric_name, samples in by_metric.items(): b = compute_bands(samples) if b is not None: bands[metric_name] = b if not bands: print( "ERROR: no bands computable — check warmup-end-epoch and metric names " "(post-warmup samples may be empty)", file=sys.stderr, ) return 1 settings_block = parse_existing_settings(args.output) # Preserve existing bands unless --force. if not args.force and args.output.is_file(): existing_text = args.output.read_text(encoding="utf-8") # Naive parse — we look for `[bands.]` headers and keep the # block intact if we're not generating a new one for that metric. # Full parse is overkill since we control the writer too. existing_blocks = _parse_band_blocks(existing_text) for metric, body in existing_blocks.items(): if metric not in bands: bands[metric] = body args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(format_toml(settings_block, bands), encoding="utf-8") print( f"Wrote {len(bands)} band entries to {args.output} " f"(from {len(args.aggregate)} aggregate JSON(s), " f"warmup-end={args.warmup_end_epoch})", file=sys.stderr, ) return 0 def _parse_band_blocks(text: str) -> dict[str, dict[str, float]]: """Naive parser for `[bands.]` sub-tables. Returns dict by metric name.""" out: dict[str, dict[str, float]] = {} current_metric: str | None = None current_body: dict[str, float] = {} for line in text.splitlines(): stripped = line.strip() if stripped.startswith("[bands.") and stripped.endswith("]"): if current_metric is not None and current_body: out[current_metric] = current_body current_metric = stripped[len("[bands."):-1] current_body = {} continue if current_metric is not None and "=" in stripped and not stripped.startswith("#"): key, _, value = stripped.partition("=") key = key.strip() value = value.split("#", 1)[0].strip() try: current_body[key] = float(value) except ValueError: pass if current_metric is not None and current_body: out[current_metric] = current_body return out if __name__ == "__main__": sys.exit(main())