Files
foxhunt/scripts/validation/check_tier1.py
jgrusewski 0d373da490 plan5(task4): tiered-exit validation script suite (tier1/2/3 checks)
Creates scripts/validation/ with per-tier exit checks consuming the
aggregate JSON from scripts/aggregate-multi-seed-metrics.py (P5T1B):

  check_tier1.py — convergence (std/mean ≤ 0.15 on val_sharpe /
    avg_q_value / train_loss; avg_q_value max ≤ 500 fold-1 explosion
    guard; placeholders for Q-saturation + hot-path-DtoH per spec).
  check_tier2.py — behavioural (val_trades_per_bar ≥ 0.005,
    val_active_frac > 0.2, dir argmax entropy > 0.8·log4 with
    val_dir_entropy primary + val_dir_dist_* fallback).
  check_tier3.py — profitability (val_sharpe_annualised > 1.0 with
    val_sharpe per-bar fallback, val_win_rate ≥ 0.52 gated on
    >500 trades, val_profit_factor mean ≥ 1.1 AND cross-seed std < 0.3).
  check_all_tiers.py — subprocess wrapper, exits 0 only if all pass.

Stdlib-only (statistics / argparse / json / subprocess) — no new deps.
Defensive missing-metric handling: each check FAILs with an explanatory
message when its required aggregate key is absent rather than silently
passing, so missing HEALTH_DIAG metrics are surfaced loudly.

Test harness scripts/validation/tests/test_tier_checks.sh exercises
good + bad fixtures across all four scripts and against the wrapper.

Audit row added to docs/dqn-wire-up-audit.md documenting the suite +
the deferred metrics list (val_trades_per_bar, val_active_frac,
val_dir_entropy/_dist_*, val_sharpe_annualised, val_win_rate,
val_profit_factor, val_trade_count) that HEALTH_DIAG must emit before
tiers 2/3 can ever PASS on real data — tracked for Plan 5 Task 5
pre-flight wire-up.

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

134 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Tier 1 convergence exit check (Plan 5 Task 4).
Consumes the aggregated metrics JSON produced by
`scripts/aggregate-multi-seed-metrics.py` (Plan 5 Task 1B.2).
Pass criteria (per spec §2 Tier 1 — convergence):
1. std/mean ≤ 0.15 on stable epochs (>= warmup_end) for `val_sharpe`,
`avg_q_value`, `train_loss`.
2. avg_q_value.max ≤ 500 across all epochs/streams (no fold-1 explosion;
the fold-1 explosion bug from task #31 produced max_q ~ 1e4+).
3. Q-saturation placeholder (see A.6 audit — full check landed elsewhere).
4. Hot-path DtoH placeholder (enforced by pre-commit hook).
Exits 0 on PASS, 1 on FAIL. Each check prints a `PASS:` / `FAIL:` line.
"""
from __future__ import annotations
import argparse
import json
import sys
from statistics import mean
def _stable(series, warmup_end):
"""Return entries with epoch >= warmup_end."""
return [x for x in series if x["epoch"] >= warmup_end]
def check_std_over_mean(agg, warmup_end):
"""std/mean ≤ 0.15 on stable epochs for the three Tier 1 metrics.
Defensive: if a metric is missing from the aggregate, return False with
a clear message so the run can be flagged for re-aggregation rather
than silently passing.
"""
for metric in ("val_sharpe", "avg_q_value", "train_loss"):
series = agg.get(metric)
if not series:
return False, f"{metric} missing from aggregate JSON"
stable = _stable(series, warmup_end)
if not stable:
return False, (
f"No stable epochs for {metric} past warmup_end={warmup_end}"
)
m = mean(x["mean"] for x in stable)
s = mean(x["std"] for x in stable)
ratio = abs(s / (m + 1e-9)) if abs(m) > 1e-6 else float("inf")
if ratio > 0.15:
return False, (
f"{metric} std/mean = {ratio:.3f} > 0.15 on stable epochs"
)
return True, "std/mean <= 0.15 on all Tier 1 metrics"
def check_no_fold1_explosion(agg):
"""avg_q_value.max ≤ 500 — catches the fold-1 loss explosion (task #31)."""
series = agg.get("avg_q_value", [])
if not series:
# Defensive: missing series cannot prove there was no explosion;
# failing is the safe direction.
return False, "avg_q_value missing from aggregate JSON"
max_q = max((x["max"] for x in series), default=0)
if abs(max_q) > 500:
return False, f"avg_q_value max={max_q}, possible fold-1 explosion"
return True, "no fold-1 explosion detected"
def check_no_q_saturation(_agg):
"""Q-saturation placeholder.
Full per-bin saturation check (q_full / q_half / q_quarter within 1%
of ±abs_half) is tracked under the A.6 audit rather than embedded in
the tier1 script — the relevant signal lives in HEALTH_DIAG `mag`
block and is best validated end-to-end on a real run.
"""
return True, "no Q-saturation check (placeholder — see A.6 audit)"
def check_no_hot_path_dtoh(_agg):
"""Hot-path DtoH enforcement is a pre-commit responsibility.
`scripts/gpu-hotpath-guard.sh` rejects any new `DeviceCopy`-style
DtoH inside the per-step training path. Reaching this script implies
that guard already passed, so we surface the invariant here as PASS
rather than re-deriving it from runtime metrics.
"""
return True, "hot-path audit enforced by pre-commit"
def main():
ap = argparse.ArgumentParser(
description="Tier 1 convergence exit check (Plan 5 Task 4).",
)
ap.add_argument(
"metrics_json",
help="Aggregated metrics JSON (output of aggregate-multi-seed-metrics.py).",
)
ap.add_argument(
"--warmup-end",
type=int,
default=15,
help="Epoch at which warmup ends (stable epochs are > this); default 15.",
)
args = ap.parse_args()
try:
with open(args.metrics_json) as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"FAIL: cannot read {args.metrics_json}: {exc}")
sys.exit(1)
agg = data.get("aggregates")
if not isinstance(agg, dict):
print("FAIL: missing or malformed `aggregates` block in metrics JSON")
sys.exit(1)
checks = [
check_std_over_mean(agg, args.warmup_end),
check_no_fold1_explosion(agg),
check_no_q_saturation(agg),
check_no_hot_path_dtoh(agg),
]
all_pass = all(ok for ok, _ in checks)
for ok, msg in checks:
print(("PASS" if ok else "FAIL") + ": " + msg)
sys.exit(0 if all_pass else 1)
if __name__ == "__main__":
main()