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>
329 lines
12 KiB
Python
Executable File
329 lines
12 KiB
Python
Executable File
#!/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())
|