Files
foxhunt/scripts/populate-metric-bands-from-runs.py
jgrusewski 6cdfbff8d6 plan5(task2): A.4 regression-detection hard-stop on 2N consecutive error-band
Adds the convergence guardrail: every per-epoch HEALTH_DIAG metric is
checked against the bands in config/metric-bands.toml; N consecutive
warn-band epochs emit a tracing::warn; 2N consecutive error-band epochs
return Err(CommonError::RegressionDetected{...}) cleanly from the
training loop, which propagates to the train_baseline_rl subprocess
exit code (no libc::raise — clean Rust error path).

Wire-points:
- New module: crates/ml/src/trainers/dqn/trainer/monitoring.rs
  - MetricBands {warn_low, warn_high, error_low, error_high}
  - BandSettings {consecutive_epochs_for_warn, consecutive_epochs_for_error}
  - MetricBandsRegistry: load_from_toml + update_and_check
  - TerminationReason {RegressionWarn, RegressionError}
  - NaN treated as out-of-band (consecutive++; never resets streak)
  - Unknown metrics return None (silent OK per Invariant 7 audit)
- crates/common/src/error.rs: new CommonError::RegressionDetected variant
  carrying {metric, value, band, consecutive}
- crates/ml/src/trainers/dqn/trainer/constructor.rs: load
  config/metric-bands.toml at trainer init; warn-only on missing file
  (backward compat for environments without the config)
- crates/ml/src/trainers/dqn/trainer/training_loop.rs: harvest per-epoch
  metrics (parallel emit alongside HEALTH_DIAG), feed each through
  registry.update_and_check; on Some(TerminationReason::RegressionError)
  emit final HEALTH_DIAG[N]: TERMINATED_BY_REGRESSION line and return Err
- services/trading_service/src/error.rs: minimal handler for the new
  CommonError variant (existing pattern)

Validation:
- 8 unit tests in monitoring::tests pass (band logic, NaN, warn-only
  behaviour, error-streak threshold, unknown-metric, invalid TOML)
- regression_detection GPU smoke (3.19s): trainer with intentionally
  narrow train_loss error band [0, 1e-9] self-terminates at epoch 5
  after 6 consecutive error-band epochs; final HEALTH_DIAG line emits
  TERMINATED_BY_REGRESSION with metric/value/consecutive/band fields
- multi_fold_convergence smoke (650s, --release): all 3 folds train
  to completion, all 3 checkpoints saved, no false-positive
  termination on the populated metric bands. Per-fold best train
  Sharpe: F0=-9.7831 (bit-baseline), F1=25.8272, F2=39.2687. F1/F2
  on the lower end of observed noise distribution
  ({74.56, 61.10, 71.53, 25.83} for F1; {88.20, 61.57, 65.96, 39.27}
  for F2) but training healthy throughout: aux clauses fire every
  epoch, sharpe_ema recovers from F0 collapse (-9.78 → +14.8 by start
  of F2), no regression detection trips.

config/metric-bands.toml populated for the metrics emitted by
HEALTH_DIAG today (avg_q_value, train_loss, val_sharpe, train_sharpe,
aux_next_bar_mse, aux_regime_ce, isv_* slot EMAs, sharpe_ema, etc.).
Bands derived from current cleanroom smoke + permissive defaults
where only one sample exists; populate-metric-bands-from-runs.py will
tighten them after Plan 5 Task 5's multi-seed pass produces real
distributions.

Constraints honoured: GPU-only in hot path (band check is CPU-side
post-HEALTH_DIAG, off the captured graph); no atomicAdd; no stubs;
no // ok: band-aids; no tuned constants beyond the toml-loaded bands;
no .unwrap() introduced; cargo check clean at 11 warnings (workspace
baseline preserved, plus ml-dqn pre-existing 1 warning).

Audit doc: new row added documenting monitoring.rs module, the
CommonError variant, the training_loop wire-point, and the design
choice that band-checks run AFTER HEALTH_DIAG emit (not before) so
the diag log already reflects the metric values that triggered any
termination.

Plan 5 T1 (multi-seed harness) landed at c6634254e+47c8b783c; T2
(this) gives the regression hard-stop that the multi-seed final
pass (T5) consumes to bail out early on bad seeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:49:14 +02:00

329 lines
12 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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": { "<metric>": [ {"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.<metric>[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.<metric>]
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.<name>]` 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.<metric>]` 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())