Files
foxhunt/scripts/validation/check_tier2.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

179 lines
5.9 KiB
Python
Executable File
Raw 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
"""Tier 2 behavioural 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 2 — behavioural):
1. `val_trades_per_bar` mean ≥ 0.005 on stable epochs (model trades).
2. `val_active_frac` mean > 0.2 on stable epochs (model is not always Hold).
3. Direction-argmax entropy > 0.8 × log(4) ≈ 1.109 on stable epochs.
Preferred source: a `val_dir_entropy` aggregate metric. Fallback:
derive from `val_dir_dist_*` (per-direction probability) if present.
If neither is in the aggregate, FAIL with an explanatory message so
the missing metric gets added to HEALTH_DIAG in a follow-up run.
Exits 0 on PASS, 1 on FAIL. Each check prints a `PASS:` / `FAIL:` line.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from statistics import mean
# 0.8 * log(4) ≈ 1.10903549
DIR_ENTROPY_FLOOR = 0.8 * math.log(4.0)
def _stable(series, warmup_end):
return [x for x in series if x["epoch"] >= warmup_end]
def check_trades_per_bar(agg, warmup_end):
series = agg.get("val_trades_per_bar")
if not series:
return False, (
"val_trades_per_bar missing from aggregate — required for Tier 2"
)
stable = _stable(series, warmup_end)
if not stable:
return False, (
f"No stable epochs for val_trades_per_bar past warmup_end={warmup_end}"
)
m = mean(x["mean"] for x in stable)
if m < 0.005:
return False, f"val_trades_per_bar mean = {m:.5f} < 0.005"
return True, f"val_trades_per_bar mean = {m:.5f} >= 0.005"
def check_active_frac(agg, warmup_end):
series = agg.get("val_active_frac")
if not series:
return False, (
"val_active_frac missing from aggregate — required for Tier 2"
)
stable = _stable(series, warmup_end)
if not stable:
return False, (
f"No stable epochs for val_active_frac past warmup_end={warmup_end}"
)
m = mean(x["mean"] for x in stable)
if m <= 0.2:
return False, f"val_active_frac mean = {m:.4f} <= 0.2"
return True, f"val_active_frac mean = {m:.4f} > 0.2"
def _entropy_from_dist(probs):
"""Shannon entropy in nats; ignores non-positive probabilities."""
return -sum(p * math.log(p) for p in probs if p > 0)
def check_dir_entropy(agg, warmup_end):
"""Direction argmax entropy > 0.8 × log(4).
Prefers a pre-computed `val_dir_entropy` aggregate. Falls back to
deriving entropy from per-direction probability mass (`val_dir_dist_short`
/ `val_dir_dist_hold` / `val_dir_dist_long` / `val_dir_dist_flat` —
naming kept loose to match whatever HEALTH_DIAG ends up emitting).
"""
direct = agg.get("val_dir_entropy")
if direct:
stable = _stable(direct, warmup_end)
if not stable:
return False, (
f"No stable epochs for val_dir_entropy past warmup_end={warmup_end}"
)
m = mean(x["mean"] for x in stable)
if m <= DIR_ENTROPY_FLOOR:
return False, (
f"val_dir_entropy mean = {m:.4f} <= {DIR_ENTROPY_FLOOR:.4f} "
f"(0.8 × log 4)"
)
return True, (
f"val_dir_entropy mean = {m:.4f} > {DIR_ENTROPY_FLOOR:.4f} "
f"(0.8 × log 4)"
)
# Fallback: derive from per-direction distribution.
dist_keys = [
"val_dir_dist_short",
"val_dir_dist_hold",
"val_dir_dist_long",
"val_dir_dist_flat",
]
dist_series = [agg.get(k) for k in dist_keys]
if all(s for s in dist_series):
# Align by epoch on stable epochs.
stables = [_stable(s, warmup_end) for s in dist_series]
if any(not s for s in stables):
return False, (
f"No stable epochs for val_dir_dist_* past warmup_end={warmup_end}"
)
# Average per-direction mean across stable epochs, then compute entropy.
means = [mean(x["mean"] for x in s) for s in stables]
total = sum(means)
if total <= 0:
return False, "val_dir_dist_* sums to <= 0; cannot compute entropy"
probs = [m / total for m in means]
ent = _entropy_from_dist(probs)
if ent <= DIR_ENTROPY_FLOOR:
return False, (
f"derived dir entropy = {ent:.4f} <= {DIR_ENTROPY_FLOOR:.4f}"
)
return True, (
f"derived dir entropy = {ent:.4f} > {DIR_ENTROPY_FLOOR:.4f}"
)
return False, (
"neither val_dir_entropy nor val_dir_dist_* present in aggregate — "
"add a direction-distribution metric to HEALTH_DIAG to enable "
"Tier 2 entropy check"
)
def main():
ap = argparse.ArgumentParser(
description="Tier 2 behavioural 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_trades_per_bar(agg, args.warmup_end),
check_active_frac(agg, args.warmup_end),
check_dir_entropy(agg, args.warmup_end),
]
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()