Commit Graph

2 Commits

Author SHA1 Message Date
jgrusewski
fbee2a00f5 plan5(task5-A): wire tier 2/3 val_* metrics into HEALTH_DIAG
Plan 5 Task 4 left every tier-2/tier-3 check failing with "metric missing
from aggregate" because the existing 'Validation backtest:' free-form log
line was not parseable by the aggregate-multi-seed-metrics.py block-keyed
parser. Phase A closes that gap end-to-end (CPU-only, no kernel touch):

* metrics.rs::compute_validation_loss — emit a new
    HEALTH_DIAG[<epoch>]: val [sharpe=… sortino=… win_rate=…
                               max_drawdown=… trade_count=… calmar=…
                               omega_ratio=… total_pnl=… var_95=… cvar_95=…
                               trades_per_bar=… active_frac=… dir_entropy=…
                               sharpe_annualised=… profit_factor=…
                               window_bars=…]
  block immediately after the existing 'Validation backtest:' line. All
  16 keys derive from the existing GpuBacktestEvaluator WindowMetrics
  reduction (no new GPU work):
    - sharpe / sortino / win_rate / max_drawdown / total_trades /
      calmar / omega_ratio / total_pnl / var_95 / cvar_95 / buy_count /
      sell_count / hold_count come straight from m.*
    - window_bars = buy + sell + hold (kernel tallies one direction
      per bar)
    - trades_per_bar = total_trades / window_bars
    - active_frac = (buy + sell) / window_bars (kernel folds Hold AND
      Flat into hold_count, so 'active' = bars where the policy chose
      Short or Long — meets the Tier-2 'not always Hold' intent)
    - dir_entropy = -Σ p ln p over the 3-bucket {short, hold-or-flat,
      long} distribution. Documented limitation: max log(3) ≈ 1.099
      vs spec's 4-bucket 0.8·log(4) ≈ 1.109 ceiling — tier2 dir_entropy
      threshold is unreachable from this 3-bucket distribution; resolution
      tracked in audit row.
    - sharpe_annualised = m.sharpe alias (kernel already multiplies by
      sqrt(bars_per_day · 252) at backtest_metrics_kernel:266)
    - profit_factor = m.omega_ratio alias (kernel's omega computes
      gain_sum/loss_sum at threshold 0, equivalent to per-step PF;
      trade-level PF deferred — needs boundary-aware kernel work)

* mod.rs — adds last_val_metrics: Option<[f32; 14]> on DQNTrainer to
  snapshot the WindowMetrics-derived values for downstream consumers
  (smoke tests, future telemetry).

* constructor.rs — initialises the new field to None.

* aggregate-multi-seed-metrics.py — switches the block→key joiner from
  '__' to '_' so 'val [sharpe=…]' surfaces as the bare 'val_sharpe'
  aggregate key the tier check scripts and synthetic test fixtures
  already expect. The pre-existing '__' joiner was an oversight in
  Plan 5 Task 1B that was never validated against actual aggregator
  output (the aggregator emitted 90 'block__key' metrics that nothing
  consumed; the synthetic good_tier1.json / bad_tier1.json fixtures
  were always shaped as 'val_sharpe', confirming the single-underscore
  convention was intended). Renaming the 90 existing keys is safe — no
  consumers had locked in on the '__' form.

* docs/dqn-wire-up-audit.md — updates Plan 5 Task 4 row to reference
  the now-landed wiring and adds a new row documenting the val [...]
  HEALTH_DIAG block pipeline + aggregator joiner change + the deferred
  4-bucket dir_dist + trade-level PF caveats.

Validation:
  cargo check --workspace clean at 11 warnings.

  multi_fold_convergence smoke (629s, 3 folds × 5 epochs on RTX 3050 Ti)
  PASSES with 3/3 fold checkpoints. Per-fold best Sharpe: -9.78 / 42.46 /
  88.18 (within smoke noise band — no perturbation from the additive
  CPU-only HEALTH_DIAG line).

  scripts/aggregate-multi-seed-metrics.py against /tmp/p5t5a-smoke.log
  produces 3 streams (one per fold), 16 val_* keys all present:
  val_sharpe, val_sharpe_annualised, val_sortino, val_win_rate,
  val_max_drawdown, val_trade_count, val_calmar, val_omega_ratio,
  val_total_pnl, val_var_95, val_cvar_95, val_trades_per_bar,
  val_active_frac, val_dir_entropy, val_profit_factor, val_window_bars.

  check_tier2.py / check_tier3.py rejection messages are now substantive
  (threshold-based) rather than "missing key":
    Tier 2: trades_per_bar PASS @ 0.0127; active_frac FAIL @ 0.058 (model
            mostly Hold on 5-epoch smoke); dir_entropy FAIL @ 0.18 (within
            documented 3-bucket vs 4-bucket caveat).
    Tier 3: sharpe_annualised FAIL @ -0.25 (5-epoch smoke not converged);
            win_rate skipped (192 trades ≤ 500 noise gate); profit_factor
            FAIL @ 0.18 (untrained policy).
  Real validation pass requires the L40S 60-epoch run (Phase C).

Deferred (out of T5 Phase A scope):
  - val_dir_dist_{short,hold,long,flat} per-direction breakdown — kernel
    intentionally collapses Hold+Flat for trade-cycle counting; Tier-2's
    log(4) threshold needs either a kernel-level split or a 3-bucket
    threshold tweak in check_tier2.py.
  - Trade-level profit_factor (sum-winner-PnL / sum-loser-PnL) vs the
    per-step omega-equivalent emitted here.
  - avg_q_value bare-key aggregation — the metric is logged via separate
    Prometheus + tracing paths but not inside any HEALTH_DIAG block; out
    of T5 Phase A scope and pre-existing.
2026-04-26 13:04:39 +02:00
jgrusewski
47c8b783c4 plan5(task1B): metric aggregation across multi-seed runs
Companion to Plan 5 Task 1A (multi-seed Argo DAG). Adds the post-run
aggregation pipeline:

- scripts/gather-multi-seed-metrics.sh: thin wrapper that pulls Argo logs
  for every workflow tagged foxhunt-tag=<tag>, concatenates them into
  /tmp/all-logs-<tag>.txt, then dispatches to the Python aggregator.

- scripts/aggregate-multi-seed-metrics.py: stdlib-only HEALTH_DIAG parser.
  Recognises both bare and JSON-envelope-wrapped HEALTH_DIAG[<epoch>]
  lines, parses every <block>[<key=val> ...] segment, and emits per-
  (epoch, metric_name) mean / std / median / min / max across streams
  (where each stream is one (seed, fold) training run, attributed by
  pod-name prefix when present, else by epoch-rewind detection — naive
  epoch=0 trigger over-segments the multi-line per-epoch HEALTH_DIAG
  output, fixed by requiring epoch < last_epoch to start a new stream).
  Output JSON schema matches the plan example (top-level: tag,
  multi_seed, folds, warmup_end_epoch, streams_seen, aggregates;
  per-entry: epoch, mean, std, median, min, max, n_samples).

- scripts/aggregate-norm-stats.py: stdlib-only merger for
  norm_stats_foldN_seedM.json files. Per-fold output collapses N seeds
  into mean/median/std/std_dispersion arrays consumed by the
  `evaluate` step's inference normaliser.

- scripts/requirements.txt: declares numpy/scipy/matplotlib for downstream
  Plan 5 T4-T5 tier-validation/plotting scripts. The aggregators
  themselves are stdlib-only (statistics module).

Smoke-validated on /tmp/p4t6-cleanroom-smoke.log: detects 3 streams
(matching the 3 fold runs in that log), 90 unique metrics, n_samples=3
per (epoch, metric), schema matches plan.

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