Files
foxhunt/crates/ml-alpha/examples/alpha_rl_train.rs
jgrusewski 629ebd667c feat(ml-alpha): deterministic same-seed training + Tier 1.5 fast-dev-cycle
Two same-seed runs now produce bit-equal eval_summary.json, alpha_rl_train_summary.json,
and diag.jsonl (modulo wall-clock elapsed_s). The 5-phase falsification chain landed:

  Phase 2   PER tree-rebuild: __threadfence is NOT a grid-wide barrier; multiple blocks
            raced across sum-tree levels. Fix: Grid=(1) Block=(1024) + __syncthreads
            in rl_per_tree_rebuild.cu.

  Phase 2.3 cuBLAS GEMM_DFALT + TF32 default-math allowed split-K non-deterministic
            accumulation at 3 sites. New crates/ml-alpha/src/cublas_determinism.rs
            applies CUBLAS_PEDANTIC_MATH via FOXHUNT_DETERMINISTIC env toggle
            (0=TF32 prod, 1=PEDANTIC dev default, 2=DEFAULT_MATH control).

  Phase 2.6 Two bugs surfaced sequentially in the backward kernel chain:
            (1) rl_iqn_tau_cos_features had a multi-block r/w race on prng_state[batch]
                — all N_TAU=32 blocks read seed; only tau_idx==0 wrote back; no
                inter-block barrier. Fix: split into READ-ONLY rl_iqn_tau_cos_features
                + new sibling rl_iqn_advance_prng_state launched on same stream
                (kernel-launch ordering = grid-wide barrier).
            (2) OutcomeHead::new called near_zero_xavier without scoped_init_seed,
                falling back to time+thread-id RNG. Stayed dormant until first done
                event activated non-sentinel labels and divergent weights flowed via
                grad_h_t_outcome into encoder gradient. Fix: add seed param + install
                scoped_init_seed(dqn_seed.wrapping_add(0x0CE0)) guard.

Validation (./scripts/determinism-check.sh --quick, RTX 3050, b=128, 200+50 steps):
  - All 200 rows of checksums.* leaves match (rel-tol 1e-5, abs-tol 1e-7)
  - eval_summary.json, alpha_rl_train_summary.json byte-equal between runs
  - diag.jsonl byte-equal modulo elapsed_s
  - Eval pnl identical run-A vs run-B at seed 42

Pre-fix baseline (Phase 2.5 measurement): same-seed eval pnl spread $450k
($187k vs -$261k). Post-fix: $0 spread.

Speed cost: ~1.5ms/step amortised; ~10-15% slower than TF32 production
(PEDANTIC tax — acceptable in dev, toggle to FOXHUNT_DETERMINISTIC=0 for prod).

Mapped-pinned discipline: all 11 NEW memcpy_dtoh sites in diagnostic dump methods
+ per-step checksum readback use a new pub(crate) helper
read_slice_d_into<T: Copy>(stream, src, dst) — MappedRecordBuffer + raw
memcpy_dtod_async + raw_stream_sync + volatile read. Generic over T (f32, f64,
i32, u32, u8). Satisfies feedback_no_htod_htoh_only_mapped_pinned + hook guard.

Bundled Tier 1.5 fast-dev-cycle infrastructure (spec
docs/superpowers/specs/2026-06-02-fast-dev-cycle.md):
  - scripts/local-mid-smoke.sh        b=128, 2000+500, ~10min on RTX 3050
  - scripts/determinism-check.sh      runs mid-smoke twice, diffs checksums
  - scripts/tier1_5_verdict.py        behavioral kill verdict
  - AdamW checkpoint save/load (crates/ml-alpha/src/trainer/optim.rs)
  - IntegratedTrainer checkpoint save/load (resume from checkpoint)
  - 15 Phase 1 checksum leaves in build_diag_value
  - Env-gated dump methods (FOXHUNT_DETERMINISM_DEBUG_PER/MAMBA2/RL/BACKWARD)
    for future divergence-chasing — never run in production

Documentation:
  - docs/superpowers/specs/2026-06-02-determinism-foundation.md
  - docs/superpowers/specs/2026-06-02-fast-dev-cycle.md
  - docs/superpowers/plans/2026-06-02-determinism-foundation-implementation.md
  - docs/superpowers/notes/2026-06-02-determinism-phase{1,2,2.2,2.5,2.6}-*.md
  - Adjacent specs/plans/notes from the analytical chain that surfaced determinism
    as the load-bearing blocker (eval-summary, eval-boundary, regime-observer,
    multi-head policy, regime-invariance, Phase 3 IQN-complement post-mortem)

Unlocks: every controller / architecture / reward-shaping A/B from this commit
onward attributes outcome differences to the change, not random-init kernel-race
drift cascading through training x eval LOB-sim trajectories. The eval-collapse
investigation (pearl_reward_signal_anti_aligned_with_pnl, multi-head spec,
regime-invariance spec) is now testable with trustworthy verdicts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:56:00 +02:00

1192 lines
56 KiB
Rust
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.
// The per-step diag `json!{...}` block now spans ≥ 130 fields after the
// adaptive-clamp additions, exceeding serde_json's default macro recursion
// budget of 128. Bumping at the crate root keeps the expansion within limits.
#![recursion_limit = "256"]
//! Integrated RL trainer CLI (DQN + PPO on shared Mamba2 -> CfC encoder).
//!
//! Drives `IntegratedTrainer::step_with_lobsim` against a real `LobSimCuda`
//! backed by MBP-10 windows loaded via `MultiHorizonLoader::next_sequence_pair`
//! (R2) for true `(s_t, s_{t+1})` adjacency. The trainer combines:
//! * R3: GPU-resident EMA + advantage/return kernels.
//! * R4: GPU Thompson + Double-DQN argmax + log π_old kernels.
//! * R5: 7 RL controllers + target-net soft update.
//! * R6: GPU-pure env step (extract_realized_pnl_delta, apply_reward_scale,
//! actions_to_market_targets).
//! * R7a/R7b: host-lift of every per-step orchestration loop.
//! * R7c-data: true `h_{t+1}` / `V(s_{t+1})` / Bellman target on `h_{t+1}`.
//! * R7d: PER push/sample + off-policy DQN with stop-grad on encoder.
//!
//! Emits `alpha_rl_train_summary.json` on exit. Gate G8 (NaN abort): if any
//! per-head loss is non-finite at any step, the binary exits with code 2 so
//! the cluster smoke harness can terminate the workflow on first divergence.
//!
//! Usage:
//! alpha_rl_train \
//! --mbp10-data-dir /data/futures-baseline-mbp10/ES.FUT \
//! --predecoded-dir /feature-cache/predecoded \
//! --out /feature-cache/alpha-rl-runs/<sha> \
//! --n-steps 1000 \
//! --seed 16962 \
//! --instrument-mode front-month \
//! --gpu-idx 0
//!
//! Plan-doc reference: docs/superpowers/plans/2026-05-23-integrated-rl-trainer-rebuild.md
//! Section R8 (CLI binary + Argo template + dispatcher).
use anyhow::{Context, Result};
use clap::Parser;
use data::providers::databento::dbn_parser::InstrumentFilter;
// Use the canonical Action enum size, NOT a literal. Caught 2026-05-24
// during SP20 P4 dogfood: a `0..9` range survived the N_ACTIONS=9→11
// bump and silently dropped HalfFlat samples (a9, a10 showed 0% in
// diag despite P_MIN=0.02 floor guaranteeing 2% each). Per the
// emerging meta-pattern: any constant that mirrors a kernel-side
// structural dimension MUST reference the Rust const, not duplicate
// the literal value.
use ml_alpha::rl::common::N_ACTIONS;
use ml_alpha::data::gpu_dataset::{GpuDataLoader, GpuDataset};
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
DEFAULT_OUTCOME_LABEL_COST_ES,
};
use ml_alpha::heads::HORIZONS;
// ISV slot imports — only the slots still read DIRECTLY in this file.
// Phase A of the 2026-05-31 checkpoints+eval-diag plan moved all ~140
// diag-block ISV reads behind `IntegratedTrainer::build_diag_value`, so
// the previous wall of imports collapsed to the four slots still in
// the per-N-step stderr ticker.
use ml_alpha::rl::isv_slots::{
RL_CONF_GATE_FIRED_COUNT_INDEX, RL_FRD_GATE_FIRED_COUNT_INDEX, RL_GAMMA_INDEX,
RL_HEAT_CAP_FIRED_COUNT_INDEX, RL_PER_ALPHA_INDEX, RL_PPO_CLIP_INDEX, RL_PYRAMID_ADD_COUNT_INDEX,
RL_REWARD_SCALE_INDEX,
};
use ml_alpha::trainer::diag_staging::DiagStaging;
use ml_alpha::trainer::integrated::{
DiagInputs, IntegratedStepStats, IntegratedTrainer, IntegratedTrainerConfig,
};
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
use ml_backtesting::sim::LobSimCuda;
use ml_core::device::MlDevice;
use serde::Serialize;
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::process;
#[derive(Parser)]
#[command(name = "alpha_rl_train")]
struct Cli {
/// Directory containing MBP-10 .dbn.zst files. Per
/// `feedback_mbp10_mandatory`, this argument is required — no
/// synthetic-data fallback in the production training path.
#[arg(long)]
mbp10_data_dir: PathBuf,
/// Directory holding predecoded sidecar caches (created on miss).
#[arg(long)]
predecoded_dir: PathBuf,
/// Output directory for summary JSON + per-step log artefacts.
#[arg(long)]
out: PathBuf,
/// Total `step_with_lobsim` calls to run. The validation smoke
/// (audit prerequisite) ships with 1000; production scale-up runs
/// 50k+. Each call processes one `(s_t, s_{t+1})` sequence pair
/// across `n_backtests` parallel LobSim instances.
#[arg(long, default_value_t = 1000)]
n_steps: usize,
/// Encoder sequence length (snapshots per step). Must match
/// `PerceptionTrainerConfig::seq_len`. Default 32 matches the
/// validated single-scale `1:32` window from alpha_train.
#[arg(long, default_value_t = 32)]
seq_len: usize,
/// Batch dimension — number of parallel LobSim backtests run in
/// lockstep per step. Larger batches amortise the encoder forward
/// cost across more trajectories but push GPU memory pressure
/// linearly. Default 16 — fixes the b_size=1 signal starvation
/// per `pearl_b_size_1_signal_starvation_blocks_q_learning`
/// (Q stayed at uniform baseline ln(21)=3.04 across all b_size=1
/// smokes regardless of controller tuning; 16 parallel
/// transitions per Adam step give 16× the gradient signal). Was
/// 1 (validation-smoke default); production sweep can push to
/// 32-64 on L40S 48GB.
#[arg(long, default_value_t = 16)]
n_backtests: usize,
/// PER buffer capacity. At b_size=16, capacity/16 = unique steps
/// retained. 32768/16 = 2048 steps of replay depth.
#[arg(long, default_value_t = 32768)]
per_capacity: usize,
/// Random seed pinning RNG + Xavier init + ISV controller bootstrap
/// state. Per `pearl_scoped_init_seed_for_reproducibility`.
#[arg(long, default_value_t = 16962)]
seed: u64,
/// MBP-10 instrument filter — `all`, `front-month`, or `id=<N>`.
/// `front-month` is the canonical setting for ES (auto-detects the
/// dominant contract per file, handles quarterly roll).
#[arg(long, default_value = "front-month", value_parser = parse_instrument_mode)]
instrument_mode: InstrumentFilter,
/// CUDA device index. Single-GPU dev (RTX 3050 Ti) uses 0; cluster
/// L40S/H100 also 0 (one GPU per pod per the alpha-rl template).
#[arg(long, default_value_t = 0)]
gpu_idx: usize,
/// Round-trip trade cost in price units for the D-style outcome
/// labels (consumed by the perception aux head, not by the RL
/// path directly — but the loader needs it). Default 0.5 = 2 ticks
/// × $0.25/tick for ES.
#[arg(long, default_value_t = DEFAULT_OUTCOME_LABEL_COST_ES)]
outcome_label_cost: f32,
/// How often (in steps) to flush a progress line to stderr. The
/// summary JSON is written ONCE at the end; this controls in-flight
/// visibility only.
#[arg(long, default_value_t = 100)]
log_every: usize,
/// Walk-forward fold index for the multi-fold G8 gate (per
/// `pearl_single_window_oos_is_not_oos` — a single window is NOT
/// out-of-sample). Slices the MBP-10 file list into K equal-sized
/// blocks; fold k trains on blocks [0..k+1) and evaluates on block
/// [k+1] (purged walk-forward). At `fold_idx=0` the first block
/// is the train window, second is eval. At `fold_idx=K-2` the
/// trainer sees almost all the data and evaluates on the final
/// block. The dispatcher (`scripts/argo-alpha-rl.sh --n-folds K`)
/// fans out K-1 workflows, one per fold; the aggregator computes
/// mean ± SD of the per-fold profit_factor.
///
/// Default 0 + n-folds=1 = single-window mode (no eval split).
#[arg(long, default_value_t = 0)]
fold_idx: usize,
/// Total walk-forward fold count. Set with `--fold-idx` for the
/// multi-fold G8 gate. n_folds=1 disables the train/eval split
/// (single-window mode — only meaningful for smoke validation).
/// n_folds≥3 enables a real evaluation: train on first
/// `fold_idx + 1` of `n_folds` data blocks, evaluate on the next.
#[arg(long, default_value_t = 1)]
n_folds: usize,
/// Eval-phase step count. After the train phase completes its
/// `--n-steps`, the eval phase runs `--n-eval-steps` additional
/// steps on the held-out fold using the same trainer
/// (acknowledged minor in-eval learning contamination at b_size=1
/// — pure eval mode is a follow-up). LobSim trade records are
/// drained at the END so the summary reflects eval-phase trades
/// only (train-phase trades are flushed when the eval phase
/// starts).
///
/// 0 disables the eval phase entirely (single-window training
/// only — the summary's profit_factor will reflect the full run
/// including train).
#[arg(long, default_value_t = 0)]
n_eval_steps: usize,
/// Per-step diagnostic JSONL path. One record per step capturing:
/// * step number, wall time
/// * loss components (l_bce/q/pi/v/aux/total) + λs (5)
/// * all 7 RL controller outputs (γ τ ε coef n_roll per_α scale)
/// * all 5 per-head learning rates (lr_bce/q/pi/v/aux)
/// * all 7 EMA inputs the controllers consume
/// * replay buffer length
/// * per-step reward + done aggregates (DtoH b_size floats per stat)
/// * per-step action histogram (counts per action class)
///
/// Default: `<out>/diag.jsonl`. Pass `--diag-jsonl /dev/null` (or
/// any unwritable path that errors at open) to disable. The dump
/// happens AFTER each `step_with_lobsim` so it sees the freshly-
/// adapted ISV values. Writes are line-buffered + flushed every
/// `log_every` steps so a `tail -f` shows in-flight progress.
///
/// Critically: this provides the per-step diagnostic visibility
/// the cluster smoke needs to detect anomalies early. Without it,
/// the smoke is "blind" — only the final summary is observable,
/// and a controller drifting off-anchor mid-run is invisible until
/// it produces a non-finite loss (G8 NaN abort).
#[arg(long)]
diag_jsonl: Option<PathBuf>,
/// Per-step diagnostic JSONL path for the EVAL phase (Phase A of
/// the 2026-05-31 checkpoints+eval-diag plan). Same 643-leaf
/// schema (642 scalars + 1 boolean) as `--diag-jsonl` — produced by the SAME builder
/// (`IntegratedTrainer::build_diag_value`), guaranteeing parity
/// per `feedback_single_source_of_truth_no_duplicates`.
///
/// Motivation: cluster run alpha-rl-8ll7j completed with
/// +$61,513 final pnl but max_dd -$444,512 during the eval
/// phase — and the eval phase emitted ZERO per-step diag, so
/// the drawdown trajectory was invisible. This file unblocks
/// that diagnostic surface.
///
/// Default: `<out>/eval_diag.jsonl`. Only written when the eval
/// phase runs (`--n-eval-steps > 0` AND walk-forward enabled).
#[arg(long)]
eval_diag_jsonl: Option<PathBuf>,
}
fn parse_instrument_mode(s: &str) -> Result<InstrumentFilter, String> {
let trimmed = s.trim();
if trimmed.eq_ignore_ascii_case("all") {
return Ok(InstrumentFilter::All);
}
if trimmed.eq_ignore_ascii_case("front-month")
|| trimmed.eq_ignore_ascii_case("front_month")
{
return Ok(InstrumentFilter::FrontMonth);
}
if let Some(num) = trimmed.strip_prefix("id=") {
let id: u32 = num.parse().map_err(|e| {
format!("instrument-mode: expected `id=<u32>` but failed to parse '{num}': {e}")
})?;
return Ok(InstrumentFilter::Id(id));
}
Err(format!(
"instrument-mode: expected `all`, `front-month`, or `id=<N>`; got '{s}'"
))
}
#[derive(Serialize, Default)]
struct AlphaRlTrainSummary {
n_steps_planned: usize,
n_steps_completed: usize,
seq_len: usize,
n_backtests: usize,
per_capacity: usize,
seed: u64,
/// True iff `n_steps_completed == n_steps_planned` AND no NaN
/// abort fired. False if early-terminated for any reason (NaN,
/// loader EOF, panic — though panics also exit nonzero).
completed_clean: bool,
/// Final-step loss components (raw, not λ-weighted). All NaN if
/// trainer never returned a finite step.
final_l_bce: f32,
final_l_q: f32,
final_l_pi: f32,
final_l_v: f32,
final_l_aux: f32,
final_l_total: f32,
/// Replay buffer state at exit.
final_replay_len: usize,
/// Step at which the first NaN was observed, if any. -1 means
/// no NaN observed.
nan_abort_step: i64,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
std::fs::create_dir_all(&cli.out)
.with_context(|| format!("mkdir -p {}", cli.out.display()))?;
std::fs::create_dir_all(&cli.predecoded_dir)
.with_context(|| format!("mkdir -p {}", cli.predecoded_dir.display()))?;
let dev = MlDevice::cuda(cli.gpu_idx)
.with_context(|| format!("MlDevice::cuda({})", cli.gpu_idx))?;
eprintln!("CUDA device {} initialised", cli.gpu_idx);
// Loader: paired (s_t, s_{t+1}) sequences. inference_only=false so
// next_sequence_pair is available (the labels it preloads are
// unused by step_with_lobsim, but the API requires the label
// precomputation pass per `feedback_no_partial_refactor`).
let all_files = discover_mbp10_files_sorted(&cli.mbp10_data_dir)
.with_context(|| format!("discover_mbp10_files_sorted({})", cli.mbp10_data_dir.display()))?;
anyhow::ensure!(
!all_files.is_empty(),
"no MBP-10 .dbn.zst files under {}",
cli.mbp10_data_dir.display()
);
eprintln!("loader: {} MBP-10 files total", all_files.len());
// Walk-forward fold split per `pearl_single_window_oos_is_not_oos`.
// Slice the file list into `n_folds` equal blocks. fold k uses
// blocks [0..=k] for training and block [k+1] for evaluation. With
// n_folds=1 (default) train_files = all_files and eval_files is
// empty (single-window mode, no eval phase).
let (train_files, eval_files) = if cli.n_folds <= 1 || cli.n_eval_steps == 0 {
eprintln!("walk-forward: disabled (n_folds={}, n_eval_steps={})", cli.n_folds, cli.n_eval_steps);
(all_files, Vec::new())
} else {
anyhow::ensure!(
cli.fold_idx + 1 < cli.n_folds,
"fold_idx {} requires fold_idx + 1 < n_folds ({}); the last block must be the eval window",
cli.fold_idx,
cli.n_folds
);
let n_files = all_files.len();
let block_size = n_files / cli.n_folds;
anyhow::ensure!(
block_size >= 1,
"not enough MBP-10 files ({}) to split into n_folds={} (need ≥ n_folds files)",
n_files,
cli.n_folds
);
// Train: blocks 0..=fold_idx, Eval: block fold_idx+1.
let train_end = (cli.fold_idx + 1) * block_size;
let eval_end = (cli.fold_idx + 2) * block_size;
let train: Vec<_> = all_files[..train_end].to_vec();
let eval: Vec<_> = all_files[train_end..eval_end.min(n_files)].to_vec();
eprintln!(
"walk-forward fold {}/{}: train={} files ({}..{}), eval={} files ({}..{})",
cli.fold_idx, cli.n_folds, train.len(), 0, train_end,
eval.len(), train_end, eval_end.min(n_files)
);
(train, eval)
};
let multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig =
format!("1:{}", cli.seq_len)
.parse()
.with_context(|| format!("multi-resolution `1:{}`", cli.seq_len))?;
anyhow::ensure!(
multi_resolution.total_positions() == cli.seq_len,
"internal: single-scale 1:{} should produce total_positions={} but got {}",
cli.seq_len,
cli.seq_len,
multi_resolution.total_positions()
);
let loader_cfg = MultiHorizonLoaderConfig {
files: train_files,
predecoded_dir: cli.predecoded_dir.clone(),
multi_resolution: multi_resolution.clone(),
horizons: HORIZONS,
// Soft cap above n_steps × n_backtests × 2 so next_sequence_pair
// never returns Ok(None) mid-run. Each step samples n_backtests
// pairs (one per batch slot) to build the B×K snapshot tensor
// that forward_encoder consumes — per-batch market diversity is
// what gives the gradient-variance reduction promised by
// `pearl_b_size_1_signal_starvation_blocks_q_learning`.
n_max_sequences: cli.n_steps
.saturating_mul(cli.n_backtests.max(1))
.saturating_mul(2)
.max(64),
seed: cli.seed,
inference_only: false,
outcome_label_cost: cli.outcome_label_cost,
instrument_filter: cli.instrument_mode,
frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS,
frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA,
};
let loader = MultiHorizonLoader::new(&loader_cfg).context("MultiHorizonLoader::new")?;
// ── GPU-resident dataset upload. ────────────────────────────────
// Upload ALL pre-converted snapshots + FRD labels to GPU memory as
// a single contiguous device buffer. Per-step data loading becomes
// pure GPU kernel work (sample + gather) — zero CPU data work.
let gpu_dataset: GpuDataset = loader
.upload_to_gpu(&dev, cli.n_backtests.max(1), cli.seed)
.context("loader.upload_to_gpu")?;
let mut gpu_data_loader = GpuDataLoader::new(&dev, cli.n_backtests.max(1))
.context("GpuDataLoader::new")?;
eprintln!(
"GPU dataset uploaded: {} snapshots, {} files, {:.1} GB",
gpu_dataset.total_snapshots,
gpu_dataset.n_files,
(gpu_dataset.total_snapshots * 216) as f64 / 1e9,
);
// Trainer + LobSim.
let perception_cfg = PerceptionTrainerConfig {
seq_len: cli.seq_len,
n_batch: cli.n_backtests,
..PerceptionTrainerConfig::default()
};
let trainer_cfg = IntegratedTrainerConfig {
perception: perception_cfg,
dqn_seed: cli.seed.wrapping_add(0xDA),
ppo_seed: cli.seed.wrapping_add(0xBE),
per_capacity: cli.per_capacity,
per_seed: cli.seed.wrapping_add(0x9E37_79B9),
};
let mut trainer =
IntegratedTrainer::new(&dev, trainer_cfg).context("IntegratedTrainer::new")?;
let mut sim = LobSimCuda::new(cli.n_backtests, &dev).context("LobSimCuda::new")?;
eprintln!(
"trainer + LobSimCuda initialised: seq_len={} n_backtests={} per_capacity={}",
cli.seq_len, cli.n_backtests, cli.per_capacity
);
let mut summary = AlphaRlTrainSummary {
n_steps_planned: cli.n_steps,
seq_len: cli.seq_len,
n_backtests: cli.n_backtests,
per_capacity: cli.per_capacity,
seed: cli.seed,
nan_abort_step: -1,
..Default::default()
};
let mut last_stats: Option<IntegratedStepStats> = None;
// ── Per-step JSONL diag writer. ──────────────────────────────────
// Default path: `<out>/diag.jsonl`. The smoke run's failure-mode
// catalogue (NaN abort, controller drift, replay stagnation, reward
// explosion) is detected via post-hoc inspection of this file —
// without it, the run is blind beyond the eprintln every-N-step
// ticks.
let diag_path = cli
.diag_jsonl
.clone()
.unwrap_or_else(|| cli.out.join("diag.jsonl"));
let diag_file = std::fs::File::create(&diag_path)
.with_context(|| format!("create diag jsonl {}", diag_path.display()))?;
let mut diag = BufWriter::new(diag_file);
eprintln!("per-step diag JSONL: {}", diag_path.display());
// Async non-blocking diagnostic staging: separate CUDA stream +
// double-buffered mapped-pinned memory. ALL per-step device reads
// (ISV, rewards, dones, actions, raw_rewards, trade_duration,
// outcome_ema, position_lots, pyramid_count, unit state x4,
// close_unit_index, frd_logits) use async DtoD copies — zero
// stream syncs on the training stream. The host reads the PREVIOUS
// step's buffer while current step copies run on the diag stream.
let mut diag_staging = DiagStaging::new(&dev, cli.n_backtests)
.context("DiagStaging::new")?;
// Per-step scratch (replaced each step via mapped-pinned helpers).
// ── Training loop. ───────────────────────────────────────────────
// Windowed action distribution — EMA-smoothed across recent steps
// so action_entropy is meaningful at b_size=1 (per-step act_hist
// is one-hot, H=0; windowed dist captures actual policy variety).
// α = 1/1000 → half-life ≈ 690 steps. Tracks the recent ~1k steps'
// action distribution; entropy of normalised windowed_act_hist is
// a real exploration signal.
// SP20 P4: action histogram width MUST track the Action enum
// size. Always reference the const (NOT a literal) — see
// import-site comment for the dogfood-caught literal-drift bug.
let mut windowed_act_hist: [f32; N_ACTIONS] = [0.0; N_ACTIONS];
const WINDOWED_ACT_ALPHA: f32 = 1.0 / 1000.0;
let mut pnl_cum_usd: f64 = 0.0;
// B-7 observability: pnl_cum_usd uses POST-clamp rewards / scale (un-scales
// but doesn't un-clamp), so the diag's training pnl signal is truncated to
// [-clamp_loss, +clamp_win] per close. realized_pnl_cum_usd sums raw_rewards
// (pre-scale, pre-clamp) — direct USD trade pnl. Divergence between the two
// exposes the reward-hacking gap the clamp introduces.
let mut realized_pnl_cum_usd: f64 = 0.0;
let mut win_count: u64 = 0;
let mut total_trades: u64 = 0;
let mut hold_time_sum: f64 = 0.0;
let mut trail_fired_total: u64 = 0;
let mut trail_tighten_total: u64 = 0;
let mut trail_loosen_total: u64 = 0;
let mut pyramid_added_total: u64 = 0;
let mut partial_flat_total: u64 = 0;
let mut partial_flat_long_total: u64 = 0;
let mut partial_flat_short_total: u64 = 0;
let mut conf_gate_total: u64 = 0;
let mut frd_gate_total: u64 = 0;
let mut heat_cap_total: u64 = 0;
let t_start = std::time::Instant::now();
for step in 0..cli.n_steps {
// GPU-resident data loading: sample_and_gather + gather_next +
// gather_current + gather_frd_labels all run as GPU kernels
// inside step_with_lobsim_gpu. Zero CPU data work per step.
let stats = trainer
.step_with_lobsim_gpu(&mut gpu_data_loader, &gpu_dataset, &mut sim)
.with_context(|| format!("step_with_lobsim_gpu at step {step}"))?;
// Gate G8: NaN abort. Per `feedback_stop_on_anomaly` +
// `feedback_kill_runs_on_anomaly_quickly` the cluster smoke
// tail-watcher kills the workflow on non-zero exit; here we
// signal that with exit code 2 (distinct from clean exit 0
// and panic-derived exit codes).
let non_finite = !stats.l_total.is_finite()
|| !stats.l_bce.is_finite()
|| !stats.l_q.is_finite()
|| !stats.l_pi.is_finite()
|| !stats.l_v.is_finite()
|| !stats.l_aux.is_finite();
if non_finite {
eprintln!(
"G8 NaN ABORT at step {step}: l_bce={} l_q={} l_pi={} l_v={} l_aux={} l_total={}",
stats.l_bce, stats.l_q, stats.l_pi, stats.l_v, stats.l_aux, stats.l_total
);
summary.n_steps_completed = step;
summary.nan_abort_step = step as i64;
summary.completed_clean = false;
write_summary(&cli.out, &summary)?;
process::exit(2);
}
// Event-sync the diag snapshot and swap buffers. Previous step's
// async DtoD copies complete here. Placed AFTER step_with_lobsim
// so the diag copies have had the entire training step to finish —
// the event sync is instant (~0 us). On step 0 this is a no-op
// (event sync on a freshly-created event returns immediately).
diag_staging.sync_and_swap().context("diag sync_and_swap")?;
// Launch async DtoD copies of ALL diag buffers into the current
// staging buffer. Non-blocking on the training stream — the copies
// run on diag_staging's separate stream.
diag_staging
.snapshot_async(
trainer.isv_dev_ptr,
trainer.rewards_d.raw_ptr(),
trainer.dones_d.raw_ptr(),
trainer.actions_d.raw_ptr(),
trainer.raw_rewards_d.raw_ptr(),
trainer.trade_duration_emit_d.raw_ptr(),
trainer.outcome_ema_d.raw_ptr(),
trainer.prev_position_lots_d.raw_ptr(),
trainer.pyramid_units_count_d.raw_ptr(),
trainer.unit_entry_price_d.raw_ptr(),
trainer.unit_entry_step_d.raw_ptr(),
trainer.unit_lots_d.raw_ptr(),
trainer.unit_trail_distance_d.raw_ptr(),
trainer.close_unit_index_d.raw_ptr(),
trainer.frd_logits_d.raw_ptr(),
)
.context("diag snapshot_async")?;
// ── Per-step diag dump (async staging reads). ────────────────
// ALL diag reads come from the DiagStaging double-buffer — zero
// GPU stalls on the training stream. On step 0 these report
// zeros (no prior snapshot); from step 1+ they report step N-1's
// data (one-step delay, acceptable for diagnostics).
let rewards_host = diag_staging.read_rewards();
let dones_host = diag_staging.read_dones();
let actions_raw = diag_staging.read_actions_raw();
let actions_host: Vec<i32> = actions_raw
.iter()
.map(|f| f.to_bits() as i32)
.collect();
let raw_rewards_host = diag_staging.read_raw_rewards();
let trade_duration_host = diag_staging.read_trade_duration();
let outcome_ema_host = diag_staging.read_outcome_ema();
let position_lots_host = diag_staging.read_position_lots();
let pyramid_count_host = diag_staging.read_pyramid_count();
let unit_entry_price_host = diag_staging.read_unit_entry_price();
let unit_entry_step_host = diag_staging.read_unit_entry_step();
let unit_lots_host = diag_staging.read_unit_lots();
let unit_trail_host = diag_staging.read_unit_trail();
let close_unit_index_host = diag_staging.read_close_unit_index();
// Per-step action histogram (counts) — still computed here because
// the windowed EMA and several per-step counters (tighten/loosen/
// half-flat) read directly from it. `build_diag_value` recomputes
// its own act_hist internally — the two are bit-equivalent.
let mut act_hist = [0u32; N_ACTIONS];
for &a in &actions_host {
if (a as usize) < N_ACTIONS {
act_hist[a as usize] += 1;
}
}
// EMA-smoothed windowed action distribution (cross-step state).
// Mutated here so the next step inherits the updated EMA;
// build_diag_value reads the post-update value via `windowed_act_hist`
// in DiagInputs.
let total_actions: u32 = act_hist.iter().sum();
if total_actions > 0 {
for i in 0..N_ACTIONS {
let p_step = (act_hist[i] as f32) / (total_actions as f32);
windowed_act_hist[i] = (1.0 - WINDOWED_ACT_ALPHA)
* windowed_act_hist[i]
+ WINDOWED_ACT_ALPHA * p_step;
}
}
let reward_sum: f32 = rewards_host.iter().sum();
let done_count: u32 = dones_host.iter().map(|&d| if d > 0.5 { 1 } else { 0 }).sum();
// Per-trade cumulative stats (surfer validation) — mutate the
// train-loop running counters (pnl_cum_usd / total_trades /
// win_count / hold_time_sum). build_diag_value receives the
// post-update values via DiagInputs.
let current_scale = trainer.read_isv_host(RL_REWARD_SCALE_INDEX);
for b in 0..cli.n_backtests {
if dones_host[b] > 0.5 {
let pnl_usd = if current_scale > 1e-9 {
rewards_host[b] as f64 / current_scale as f64
} else {
rewards_host[b] as f64
};
pnl_cum_usd += pnl_usd;
// B-7 observability: raw_rewards is the shaped pnl in USD
// BEFORE scale and clamp (see rl_fused_reward_pipeline.cu).
// Direct trade-pnl signal — compare against pnl_cum_usd above
// to surface clamp-truncated tail losses.
realized_pnl_cum_usd += raw_rewards_host[b] as f64;
total_trades += 1;
if pnl_usd > 0.0 { win_count += 1; }
hold_time_sum += trade_duration_host[b] as f64;
}
}
let win_rate = if total_trades > 0 { win_count as f64 / total_trades as f64 } else { 0.0 };
// Snapshot the ISV slots we read on the host side. Determinism
// Phase 1 (2026-06-02) turned `build_diag_value` into `&mut self`
// (needed to launch the checksum kernels), so the `isv` slice
// can no longer co-exist with the trainer mutable borrow.
// Copying the eight specific values we use is cheaper than
// re-grabbing the slice twice and keeps the diff local.
let isv_trail_fired_step = trainer.read_isv_host(RL_CONF_GATE_FIRED_COUNT_INDEX) as u64;
let isv_pyramid_add_step = trainer.read_isv_host(RL_PYRAMID_ADD_COUNT_INDEX) as u64;
let isv_conf_gate_step = trainer.read_isv_host(RL_CONF_GATE_FIRED_COUNT_INDEX) as u64;
let isv_frd_gate_step = trainer.read_isv_host(RL_FRD_GATE_FIRED_COUNT_INDEX) as u64;
let isv_heat_cap_step = trainer.read_isv_host(RL_HEAT_CAP_FIRED_COUNT_INDEX) as u64;
let isv_gamma = trainer.read_isv_host(RL_GAMMA_INDEX);
let isv_ppo_clip = trainer.read_isv_host(RL_PPO_CLIP_INDEX);
let isv_per_alpha = trainer.read_isv_host(RL_PER_ALPHA_INDEX);
let isv_reward_scale = trainer.read_isv_host(RL_REWARD_SCALE_INDEX);
// Per-step counters derived from action histogram + ISV diag slots.
let trail_fired_step = isv_trail_fired_step; // reusing — trail stop uses actions override
let tighten_step = act_hist[ml_alpha::rl::common::Action::TrailTighten as usize] as u64;
let loosen_step = act_hist[ml_alpha::rl::common::Action::TrailLoosen as usize] as u64;
let pyramid_add_step = isv_pyramid_add_step;
let half_flat_long_step = act_hist[ml_alpha::rl::common::Action::HalfFlatLong as usize] as u64;
let half_flat_short_step = act_hist[ml_alpha::rl::common::Action::HalfFlatShort as usize] as u64;
let partial_flat_step = half_flat_long_step + half_flat_short_step;
let conf_gate_step = isv_conf_gate_step;
let frd_gate_step = isv_frd_gate_step;
let heat_cap_step = isv_heat_cap_step;
trail_fired_total += trail_fired_step;
trail_tighten_total += tighten_step;
trail_loosen_total += loosen_step;
pyramid_added_total += pyramid_add_step;
partial_flat_total += partial_flat_step;
partial_flat_long_total += half_flat_long_step;
partial_flat_short_total += half_flat_short_step;
conf_gate_total += conf_gate_step;
frd_gate_total += frd_gate_step;
heat_cap_total += heat_cap_step;
// Build the per-step diag record via the single-source-of-truth
// builder on the trainer (Phase A of the 2026-05-31
// checkpoints+eval-diag plan). Same builder is invoked from the
// eval phase below, guaranteeing schema parity per
// `feedback_single_source_of_truth_no_duplicates`.
let frd_logits_host = diag_staging.read_frd_logits();
let diag_inputs = DiagInputs {
b_size: cli.n_backtests,
stats: &stats,
rewards: rewards_host,
dones: dones_host,
actions: &actions_host,
raw_rewards: raw_rewards_host,
trade_duration: trade_duration_host,
outcome_ema: outcome_ema_host,
position_lots: &position_lots_host,
pyramid_count: &pyramid_count_host,
unit_entry_price: unit_entry_price_host,
unit_entry_step: &unit_entry_step_host,
unit_lots: &unit_lots_host,
unit_trail: unit_trail_host,
close_unit_index: &close_unit_index_host,
frd_logits: frd_logits_host,
pnl_cum_usd,
realized_pnl_cum_usd,
total_trades,
win_count,
hold_time_sum,
trail_fired_total,
trail_tighten_total,
trail_loosen_total,
pyramid_added_total,
partial_flat_total,
partial_flat_long_total,
partial_flat_short_total,
conf_gate_total,
frd_gate_total,
heat_cap_total,
windowed_act_hist: &windowed_act_hist,
};
let record = trainer.build_diag_value(
step as u64,
t_start.elapsed().as_secs_f32(),
&diag_inputs,
).context("build_diag_value (train)")?;
writeln!(diag, "{}", record).context("diag: writeln jsonl")?;
if step % cli.log_every == 0 || step == cli.n_steps - 1 {
diag.flush().context("diag: flush")?;
let elapsed = t_start.elapsed().as_secs_f32();
let sps = if elapsed > 0.0 { (step + 1) as f32 / elapsed } else { 0.0 };
eprintln!(
"step {:>6}/{}: l_q={:.4} l_pi={:.4} l_v={:.4} l_total={:.4} \
γ={:.4} ε={:.4} per_α={:.4} scale={:.4} \
replay={} dones={} rew_sum={:.3} sps={:.0} pnl=${:.0} wr={:.2} elapsed={:.1}s",
step,
cli.n_steps,
stats.l_q,
stats.l_pi,
stats.l_v,
stats.l_total,
isv_gamma,
isv_ppo_clip,
isv_per_alpha,
isv_reward_scale,
trainer.gpu_replay.capacity.min(step + 1),
done_count,
reward_sum,
sps,
pnl_cum_usd,
win_rate,
elapsed,
);
}
last_stats = Some(stats);
summary.n_steps_completed = step + 1;
}
diag.flush().context("diag: final flush")?;
// ── Eval phase (walk-forward G8). ────────────────────────────────
// After the train phase, run n_eval_steps additional step_with_lobsim
// calls on the held-out eval files. Trade records accumulated by
// LobSim during eval are isolated from train-phase records via the
// `head_before_eval` checkpoint — eval records are sliced as
// `all_records[head_before_eval..]` post-eval.
//
// Note: this is NOT pure-eval mode — step_with_lobsim still runs
// its learning machinery (Adam steps, PER updates). At b_size=1
// the per-step learning effect is small relative to the policy
// already accumulated in train; the eval profit_factor approximates
// the train-end policy's OOS performance. True pure-eval (forward
// only, no backward) is a follow-up architectural change.
if cli.n_eval_steps > 0 && !eval_files.is_empty() {
// Adaptive risk management (spec 2026-05-30): reset Layer 1
// session-level state on train→eval transition. Clears session
// pnl, DD-triggered flag, consec-loss counter, cooldown counter.
// Kelly + inventory EMAs intentionally preserved.
trainer
.reset_session_state()
.context("reset_session_state at train→eval boundary")?;
let head_before_per_b = sim
.read_per_backtest_trade_counts()
.context("snapshot per-backtest trade-count pre-eval")?;
let head_before_min = head_before_per_b.iter().min().copied().unwrap_or(0);
let head_before_max = head_before_per_b.iter().max().copied().unwrap_or(0);
let head_before_sum: u64 =
head_before_per_b.iter().map(|&h| h as u64).sum();
eprintln!(
"── eval phase: {} steps on {} held-out files; pre-eval head per b_size={} \
accounts: min={} max={} sum={} ──",
cli.n_eval_steps,
eval_files.len(),
head_before_per_b.len(),
head_before_min,
head_before_max,
head_before_sum
);
let eval_loader_cfg = MultiHorizonLoaderConfig {
files: eval_files.clone(),
predecoded_dir: cli.predecoded_dir.clone(),
multi_resolution,
horizons: HORIZONS,
n_max_sequences: cli.n_eval_steps
.saturating_mul(cli.n_backtests.max(1))
.saturating_mul(2)
.max(64),
// Different seed for eval so sequence sampling is independent
// of the train seed (deterministic given the eval seed).
seed: cli.seed.wrapping_add(0xE7AE),
inference_only: false,
outcome_label_cost: cli.outcome_label_cost,
instrument_filter: cli.instrument_mode,
frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS,
frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA,
};
let eval_loader = MultiHorizonLoader::new(&eval_loader_cfg)
.context("MultiHorizonLoader::new (eval)")?;
// Upload eval dataset to GPU (separate from train dataset).
let eval_gpu_dataset = eval_loader
.upload_to_gpu(&dev, cli.n_backtests.max(1), cli.seed.wrapping_add(0xE7AE))
.context("eval loader.upload_to_gpu")?;
let mut eval_gpu_loader = GpuDataLoader::new(&dev, cli.n_backtests.max(1))
.context("GpuDataLoader::new (eval)")?;
// ── Per-step eval-phase diag writer (Phase A of the 2026-05-31
// checkpoints+eval-diag plan). Same 643-leaf schema (642 scalars
// + 1 boolean) as train's diag.jsonl — emitted by the SAME builder.
// The eval-phase
// trajectory was previously invisible (alpha-rl-8ll7j: +$61k
// pnl, -$444k drawdown, zero per-step records).
let eval_diag_path = cli
.eval_diag_jsonl
.clone()
.unwrap_or_else(|| cli.out.join("eval_diag.jsonl"));
let eval_diag_file = std::fs::File::create(&eval_diag_path)
.with_context(|| format!("create eval diag jsonl {}", eval_diag_path.display()))?;
let mut eval_diag = BufWriter::new(eval_diag_file);
eprintln!("per-step eval diag JSONL: {}", eval_diag_path.display());
// ── Eval-phase running counters (independent of train counters)
// ───────────────────────────────────────────────────────────────
// reset_session_state above clears device-side Layer 1 state but
// does NOT zero the host-side counters maintained by THIS loop;
// they reflect the eval window only (matches eval_summary.json's
// post-checkpoint trade slicing).
let mut eval_windowed_act_hist: [f32; N_ACTIONS] = [0.0; N_ACTIONS];
let mut eval_pnl_cum_usd: f64 = 0.0;
// B-7 observability: mirror of realized_pnl_cum_usd for the eval
// window. See declaration in train loop for rationale.
let mut eval_realized_pnl_cum_usd: f64 = 0.0;
let mut eval_win_count: u64 = 0;
let mut eval_total_trades: u64 = 0;
let mut eval_hold_time_sum: f64 = 0.0;
let mut eval_trail_fired_total: u64 = 0;
let mut eval_trail_tighten_total: u64 = 0;
let mut eval_trail_loosen_total: u64 = 0;
let mut eval_pyramid_added_total: u64 = 0;
let mut eval_partial_flat_total: u64 = 0;
let mut eval_partial_flat_long_total: u64 = 0;
let mut eval_partial_flat_short_total: u64 = 0;
let mut eval_conf_gate_total: u64 = 0;
let mut eval_frd_gate_total: u64 = 0;
let mut eval_heat_cap_total: u64 = 0;
for eval_step in 0..cli.n_eval_steps {
let stats = trainer
.step_with_lobsim_gpu(&mut eval_gpu_loader, &eval_gpu_dataset, &mut sim)
.with_context(|| format!("step_with_lobsim_gpu eval step {eval_step}"))?;
if !stats.l_total.is_finite() {
eprintln!("G8 NaN ABORT during eval at step {eval_step}");
process::exit(2);
}
// Mirror train-phase diag pipeline: sync prior staging copies,
// then enqueue async DtoD copies of THIS step's device buffers.
// Per `feedback_no_partial_refactor` the eval path uses the
// SAME staging machinery as train (separate diag stream, no
// sync on the training stream, one-step latency).
diag_staging.sync_and_swap().context("eval diag sync_and_swap")?;
diag_staging
.snapshot_async(
trainer.isv_dev_ptr,
trainer.rewards_d.raw_ptr(),
trainer.dones_d.raw_ptr(),
trainer.actions_d.raw_ptr(),
trainer.raw_rewards_d.raw_ptr(),
trainer.trade_duration_emit_d.raw_ptr(),
trainer.outcome_ema_d.raw_ptr(),
trainer.prev_position_lots_d.raw_ptr(),
trainer.pyramid_units_count_d.raw_ptr(),
trainer.unit_entry_price_d.raw_ptr(),
trainer.unit_entry_step_d.raw_ptr(),
trainer.unit_lots_d.raw_ptr(),
trainer.unit_trail_distance_d.raw_ptr(),
trainer.close_unit_index_d.raw_ptr(),
trainer.frd_logits_d.raw_ptr(),
)
.context("eval diag snapshot_async")?;
// Host-side reads from the (just-swapped) staging buffer.
let rewards_host = diag_staging.read_rewards();
let dones_host = diag_staging.read_dones();
let actions_raw = diag_staging.read_actions_raw();
let actions_host: Vec<i32> = actions_raw
.iter()
.map(|f| f.to_bits() as i32)
.collect();
let raw_rewards_host = diag_staging.read_raw_rewards();
let trade_duration_host = diag_staging.read_trade_duration();
let outcome_ema_host = diag_staging.read_outcome_ema();
let position_lots_host = diag_staging.read_position_lots();
let pyramid_count_host = diag_staging.read_pyramid_count();
let unit_entry_price_host = diag_staging.read_unit_entry_price();
let unit_entry_step_host = diag_staging.read_unit_entry_step();
let unit_lots_host = diag_staging.read_unit_lots();
let unit_trail_host = diag_staging.read_unit_trail();
let close_unit_index_host = diag_staging.read_close_unit_index();
let frd_logits_host = diag_staging.read_frd_logits();
// Per-step action histogram (mirrors train pipeline — needed
// for windowed-EMA + per-counter updates below).
let mut act_hist = [0u32; N_ACTIONS];
for &a in &actions_host {
if (a as usize) < N_ACTIONS {
act_hist[a as usize] += 1;
}
}
let total_actions: u32 = act_hist.iter().sum();
if total_actions > 0 {
for i in 0..N_ACTIONS {
let p_step = (act_hist[i] as f32) / (total_actions as f32);
eval_windowed_act_hist[i] = (1.0 - WINDOWED_ACT_ALPHA)
* eval_windowed_act_hist[i]
+ WINDOWED_ACT_ALPHA * p_step;
}
}
// Per-trade cumulative stats — same scale-recovery as train.
let current_scale = trainer.read_isv_host(RL_REWARD_SCALE_INDEX);
for b in 0..cli.n_backtests {
if dones_host[b] > 0.5 {
let pnl_usd = if current_scale > 1e-9 {
rewards_host[b] as f64 / current_scale as f64
} else {
rewards_host[b] as f64
};
eval_pnl_cum_usd += pnl_usd;
// B-7 observability: raw pnl (pre-scale, pre-clamp) — see
// train-loop counterpart.
eval_realized_pnl_cum_usd += raw_rewards_host[b] as f64;
eval_total_trades += 1;
if pnl_usd > 0.0 { eval_win_count += 1; }
eval_hold_time_sum += trade_duration_host[b] as f64;
}
}
let isv = trainer.isv_host_slice();
// Per-step ISV-derived counters — same indices as train loop.
let trail_fired_step = isv[RL_CONF_GATE_FIRED_COUNT_INDEX] as u64;
let tighten_step = act_hist[ml_alpha::rl::common::Action::TrailTighten as usize] as u64;
let loosen_step = act_hist[ml_alpha::rl::common::Action::TrailLoosen as usize] as u64;
let pyramid_add_step = isv[RL_PYRAMID_ADD_COUNT_INDEX] as u64;
let half_flat_long_step = act_hist[ml_alpha::rl::common::Action::HalfFlatLong as usize] as u64;
let half_flat_short_step = act_hist[ml_alpha::rl::common::Action::HalfFlatShort as usize] as u64;
let partial_flat_step = half_flat_long_step + half_flat_short_step;
let conf_gate_step = isv[RL_CONF_GATE_FIRED_COUNT_INDEX] as u64;
let frd_gate_step = isv[RL_FRD_GATE_FIRED_COUNT_INDEX] as u64;
let heat_cap_step = isv[RL_HEAT_CAP_FIRED_COUNT_INDEX] as u64;
eval_trail_fired_total += trail_fired_step;
eval_trail_tighten_total += tighten_step;
eval_trail_loosen_total += loosen_step;
eval_pyramid_added_total += pyramid_add_step;
eval_partial_flat_total += partial_flat_step;
eval_partial_flat_long_total += half_flat_long_step;
eval_partial_flat_short_total += half_flat_short_step;
eval_conf_gate_total += conf_gate_step;
eval_frd_gate_total += frd_gate_step;
eval_heat_cap_total += heat_cap_step;
// Build inputs and emit the per-step record. Same builder as
// train → schema parity is guaranteed by construction.
let diag_inputs = DiagInputs {
b_size: cli.n_backtests,
stats: &stats,
rewards: rewards_host,
dones: dones_host,
actions: &actions_host,
raw_rewards: raw_rewards_host,
trade_duration: trade_duration_host,
outcome_ema: outcome_ema_host,
position_lots: &position_lots_host,
pyramid_count: &pyramid_count_host,
unit_entry_price: unit_entry_price_host,
unit_entry_step: &unit_entry_step_host,
unit_lots: &unit_lots_host,
unit_trail: unit_trail_host,
close_unit_index: &close_unit_index_host,
frd_logits: frd_logits_host,
pnl_cum_usd: eval_pnl_cum_usd,
realized_pnl_cum_usd: eval_realized_pnl_cum_usd,
total_trades: eval_total_trades,
win_count: eval_win_count,
hold_time_sum: eval_hold_time_sum,
trail_fired_total: eval_trail_fired_total,
trail_tighten_total: eval_trail_tighten_total,
trail_loosen_total: eval_trail_loosen_total,
pyramid_added_total: eval_pyramid_added_total,
partial_flat_total: eval_partial_flat_total,
partial_flat_long_total: eval_partial_flat_long_total,
partial_flat_short_total: eval_partial_flat_short_total,
conf_gate_total: eval_conf_gate_total,
frd_gate_total: eval_frd_gate_total,
heat_cap_total: eval_heat_cap_total,
windowed_act_hist: &eval_windowed_act_hist,
};
// Eval-phase records use a separate step namespace: continue
// the train-phase step index (`cli.n_steps + eval_step`) so
// post-hoc tooling can concatenate train + eval JSONL and
// get a monotone step axis.
let record = trainer.build_diag_value(
(cli.n_steps + eval_step) as u64,
t_start.elapsed().as_secs_f32(),
&diag_inputs,
).context("build_diag_value (eval)")?;
writeln!(eval_diag, "{}", record).context("eval diag: writeln jsonl")?;
if eval_step % cli.log_every == 0 || eval_step == cli.n_eval_steps - 1 {
eval_diag.flush().context("eval diag: flush")?;
eprintln!(
"eval {:>5}/{}: l_total={:.4} elapsed={:.1}s",
eval_step, cli.n_eval_steps, stats.l_total,
t_start.elapsed().as_secs_f32()
);
}
}
eval_diag.flush().context("eval diag: final flush")?;
// Aggregate eval-phase trades across ALL b_size accounts, with
// correct per-account head_before slicing. Replaces the previous
// single-account read of backtest 0 + aggregate-vs-per-account
// scale mismatch (see spec
// docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md
// for the root-cause analysis).
let all_per_b = sim
.read_trade_records_all()
.context("read all backtests' trade records post-eval")?;
let head_after_per_b = sim
.read_per_backtest_trade_counts()
.context("snapshot per-backtest trade-count post-eval")?;
let cap_u32 = ml_backtesting::lob::TRADE_LOG_CAP as u32;
let mut eval_records: Vec<ml_backtesting::order::TradeRecord> = Vec::new();
let mut n_eval_trades_seen: u64 = 0;
let mut n_eval_trades_dropped: u64 = 0;
let mut n_pre_eval_wrapped: u64 = 0;
for (b, records) in all_per_b.iter().enumerate() {
let head_before = head_before_per_b[b];
let head_after = head_after_per_b[b];
let eval_count_total = head_after.saturating_sub(head_before);
n_eval_trades_seen += eval_count_total as u64;
// The ring's contents cover cumulative-stream indices
// [max(0, head_after - cap), head_after).
let ring_start_stream_idx = head_after.saturating_sub(cap_u32);
if head_before < ring_start_stream_idx {
// Some pre-eval trades had wrapped out before eval started —
// diagnostic only, doesn't affect eval slicing.
n_pre_eval_wrapped += (ring_start_stream_idx - head_before) as u64;
}
if eval_count_total > cap_u32 {
// Eval-phase trades wrapped (lost). Should be 0 with
// TRADE_LOG_CAP=4096 at typical cluster scale.
n_eval_trades_dropped += (eval_count_total - cap_u32) as u64;
}
// Slice the ring contents to eval-only.
// ring index of first eval trade = head_before ring_start_stream_idx
// (clamped at 0 if all pre-eval already wrapped out).
let eval_start_in_ring =
head_before.saturating_sub(ring_start_stream_idx) as usize;
if eval_start_in_ring < records.len() {
eval_records.extend_from_slice(&records[eval_start_in_ring..]);
}
}
if n_eval_trades_dropped > 0 {
eprintln!(
"warning: {} eval trades wrapped out across {} accounts \
(TRADE_LOG_CAP={}); summary based on {} captured eval trades",
n_eval_trades_dropped,
all_per_b.len(),
cap_u32,
eval_records.len()
);
}
if n_pre_eval_wrapped > 0 {
eprintln!(
"info: {} pre-eval trades had already wrapped before eval phase \
(no effect on eval summary)",
n_pre_eval_wrapped
);
}
eprintln!(
"eval phase trade accounting: n_eval_trades_seen={} n_captured={} \
n_dropped={} b_size={}",
n_eval_trades_seen,
eval_records.len(),
n_eval_trades_dropped,
all_per_b.len()
);
// Synthesise a pnl_curve from per-trade cumulative PnL for
// compute_summary's max_drawdown calc.
let mut pnl_curve = Vec::with_capacity(eval_records.len());
let mut cum = 0.0f32;
for r in &eval_records {
cum += (r.realised_pnl_usd_fp as f32) / 100.0;
pnl_curve.push(cum);
}
let eval_summary =
ml_backtesting::artifacts::compute_summary(&eval_records, &pnl_curve);
eprintln!(
"eval summary: n_trades={} pnl_usd={:.2} pf={:.3} sharpe_ann={:.3} \
max_dd_usd={:.2} win_rate={:.3} | seen={} dropped={} b={}",
eval_summary.n_trades,
eval_summary.total_pnl_usd,
eval_summary.profit_factor,
eval_summary.sharpe_ann,
eval_summary.max_drawdown_usd,
eval_summary.win_rate,
n_eval_trades_seen,
n_eval_trades_dropped,
all_per_b.len(),
);
// Write eval_summary.json with the existing compute_summary fields
// PLUS four new aggregation-aware fields:
// n_eval_trades_seen — true total cumulative dones across b_size
// n_eval_trades_dropped — eval trades lost to ring wrap (0 at typical scale)
// n_pre_eval_trades_wrapped — pre-eval trades wrapped before eval (diagnostic)
// b_size — context for downstream interpretation
let aggregated_summary = serde_json::json!({
"n_trades": eval_summary.n_trades,
"total_pnl_usd": eval_summary.total_pnl_usd,
"profit_factor": eval_summary.profit_factor,
"sharpe_ann": eval_summary.sharpe_ann,
"max_drawdown_usd": eval_summary.max_drawdown_usd,
"win_rate": eval_summary.win_rate,
"n_eval_trades_seen": n_eval_trades_seen,
"n_eval_trades_dropped": n_eval_trades_dropped,
"n_pre_eval_trades_wrapped": n_pre_eval_wrapped,
"b_size": cli.n_backtests,
});
let eval_summary_path = cli.out.join("eval_summary.json");
let f = std::fs::File::create(&eval_summary_path)
.with_context(|| format!("create {}", eval_summary_path.display()))?;
serde_json::to_writer_pretty(f, &aggregated_summary)
.with_context(|| format!("write {}", eval_summary_path.display()))?;
eprintln!("eval summary written: {}", eval_summary_path.display());
}
// ── Compose + emit summary. ──────────────────────────────────────
if let Some(s) = last_stats {
summary.final_l_bce = s.l_bce;
summary.final_l_q = s.l_q;
summary.final_l_pi = s.l_pi;
summary.final_l_v = s.l_v;
summary.final_l_aux = s.l_aux;
summary.final_l_total = s.l_total;
}
summary.final_replay_len = trainer.gpu_replay.capacity.min(cli.n_steps);
summary.completed_clean = summary.n_steps_completed == summary.n_steps_planned;
write_summary(&cli.out, &summary)?;
eprintln!(
"alpha_rl_train complete: {} / {} steps in {:.1}s (replay len = {})",
summary.n_steps_completed,
summary.n_steps_planned,
t_start.elapsed().as_secs_f32(),
summary.final_replay_len
);
Ok(())
}
fn write_summary(out_dir: &std::path::Path, summary: &AlphaRlTrainSummary) -> Result<()> {
let path = out_dir.join("alpha_rl_train_summary.json");
let f = std::fs::File::create(&path)
.with_context(|| format!("create {}", path.display()))?;
serde_json::to_writer_pretty(f, summary)
.with_context(|| format!("write {}", path.display()))?;
eprintln!("summary written: {}", path.display());
Ok(())
}