From c274b99ea996228cc3b123d37d836520d4f2973f Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 10 May 2026 21:35:58 +0200 Subject: [PATCH] docs(sp21): T2.2 Phase 1.5 + Phase 2 continuation plan + amendment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the continuation plan for SP21 T2.2 Phase 1.5 (entry_q tracking in portfolio_state slot 6) + Phase 2 (wire enrichment to real per-trade tape from gpu_backtest_evaluator). Self-contained plan that a fresh session can pick up cold: - State-at-session-start summary with all 8 prior commits - Phase 1.5 design (storage slot, capture site, plumbing, EvalTrade extension) - Phase 2 design (training_loop wire-up, enrichment refactor, E5 alternate-signal options with recommendation) - Hard rules carried from prior session (no NULLs, no stubs, atomic per feedback_no_partial_refactor) - Verification plan (5 GPU oracle test suites) - Open design question (E5 alternate signal) with recommendation - Multi-phase continuation map (Phases 3-7 + Phase 8) - Final cascade verification (smoke run criteria) Also amends the parent SP21 plan (2026-05-10-sp21-train-eval-coherence-isv-defrost.md) with the T2.2 multi-phase scope section that documents the full per-trade-tape expansion the user committed to (option 3 — full wiring across multiple sessions instead of the original recommended option (b) aggregate-stats refactor). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...0-sp21-train-eval-coherence-isv-defrost.md | 20 +- ...22-phase15-phase2-enrichment-realwiring.md | 397 ++++++++++++++++++ 2 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-05-11-sp21-t22-phase15-phase2-enrichment-realwiring.md diff --git a/docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md b/docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md index 56c4ceebd..00aa5db57 100644 --- a/docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md +++ b/docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md @@ -111,13 +111,29 @@ This principle is the SP21 unifying invariant. Per `feedback_adaptive_not_tuned. | # | Finding | Site | Decision needed | |---|---|---|---| | T2.1 | `check_early_stopping(avg_q_value)` — separate stop path on Q-value magnitude | `crates/ml/src/trainers/dqn/trainer/metrics.rs:436` | Q-value magnitude is not a learning signal — high Q can mean high alpha OR catastrophic value-explosion. Three options: (a) delete entirely (subsumed by val-loss path); (b) repurpose as Q-explosion guard with **signal-driven** threshold from `ISV[Q_VALUE_VAR_EMA]` per `feedback_isv_for_adaptive_bounds`; (c) keep but rename so its purpose is clear. Recommendation: **(a) delete** — `feedback_no_legacy_aliases` says no deprecated wrappers. If we keep it as a Q-explosion guard, it MUST be signal-driven (no hardcoded threshold). | -| T2.2 | `extract_eval_trades_from_metrics` fabricates per-trade tape from aggregate stats; downstream enrichment functions are themselves constants-soup (T1.3) | `crates/ml/src/trainers/dqn/trainer/enrichment.rs:99-138` (synth) + entire 381-line file | Function is mathematically a fiction — it generates synthetic trades that "average to" the input aggregates. Each downstream enrichment function has multiple hardcoded thresholds. | Two options: (a) emit real per-trade tape from `gpu_evaluator` (significant new code) AND signal-drive each downstream threshold; (b) refactor enrichment functions to consume aggregate stats directly without the fake-trade detour AND signal-drive each downstream threshold (this is the bigger half — consequential refactor since each enrichment fn must read from ISV). Recommendation: **(b)**. Scope: T2.2 + T1.3 are mathematically the same superset of changes — enrichment.rs gets rewritten as a set of ISV-aware functions. | +| T2.2 | `extract_eval_trades_from_metrics` fabricates per-trade tape from aggregate stats; downstream enrichment functions are themselves constants-soup (T1.3); 5 of 8 enrichment outputs (E1/E4/E6/E7/E8) computed-and-discarded since SP3+ | `crates/ml/src/trainers/dqn/trainer/enrichment.rs:99-138` (synth) + entire 381-line file | Function is mathematically a fiction — generates synthetic trades that "average to" the input aggregates. 3 of 8 outputs consumed (E2/E3/E5); 5 of 8 (E1/E4/E6/E7/E8) require per-trade data that never made it past the fake-trade synthesizer. | **DECISION 2026-05-10 (user-directed)**: option (a) — full per-trade tape emission + wire all 8 enrichments to real consumers. Multi-session scope (~5-6 days). See **T2.2 multi-phase scope** below. | | T2.3 | `EarlyStopping::should_stop` test signature changes (T1.1b) | `crates/ml/src/trainers/dqn/early_stopping.rs:158, 183, 200, 216, 236, 250` (6 unit tests) | Refactoring `should_stop(val_loss, epoch)` → `should_stop(val_loss, min_delta, epoch)` breaks the test signatures. | Update all 6 tests to pass explicit `min_delta` per call. Per `feedback_no_partial_refactor`, this is part of the atomic T1.1b commit, not a separate task. | | T2.4 | `MIN_HOLD_TARGET=30.0f` zombie #define after Class A audit | `state_layout.cuh:317` | Comment at lines 177, 296-322 explicitly says this constant was REPLACED by ISV slot 460 (`MIN_HOLD_TEMPERATURE_ADAPTIVE_INDEX`). But the #define still exists. Either: (a) zombie code that should be deleted; (b) used as fallback when ISV at sentinel — comment line 320 hints at fallback usage. Audit needed. | Trace usages of `MIN_HOLD_TARGET` macro. If only used via the fallback path, rename to `MIN_HOLD_TARGET_FALLBACK` and document. If unused, DELETE per `feedback_no_legacy_aliases`. | | T2.5 | PER `per_alpha=0.6` and `per_beta_start=0.6` hardcoded | `config.rs:1479-1480` | PER (Prioritized Experience Replay) parameters control training-data sampling distribution. The original Schaul et al. paper uses these as fixed but other works adapt them. With `feedback_always_per` mandating PER ON, the *parameters* are still fixed. | Two options: (a) keep fixed as paper-canonical (but then the hyperparams should be derived from a research citation, not a magic number); (b) adapt from priority distribution variance EMA. Recommendation: **keep fixed for SP21**, but file the question for a separate SP if PER tuning is later identified as a leverage point. Document choice in `pearl_per_alpha_beta_paper_canonical`. | | ~~T2.6~~ | ~~Curiosity trainer beta=0.0 hardcoded — silently disabled?~~ | ~~`gpu_curiosity_trainer.rs:342, 401`~~ | **WITHDRAWN 2026-05-10 (false alarm)**: the `beta=0.0` is the cuBLAS `cublasSgemm_v2` β API parameter (`C = α·A·B + β·C`), not a curiosity reward weight. Actual curiosity coefficient is `ISV[CURIOSITY_PRESSURE_INDEX=346]`, produced by SP11 Fix 39's `reward_subsystem_controller_kernel`, already signal-driven per `pearl_blend_formulas_must_have_permanent_floor`. Confirmed firing in xmd6b logs: `curiosity_p: 0.000 → 0.109 → 0.406` across epochs 0-2 (Pearl-A bootstrap from sentinel, then live). | **No fix needed.** System working as designed. | -**Success criterion** (T2.1): file no longer contains `check_early_stopping(avg_q_value, ...)`; tests that exercised it migrated or deleted. (T2.2): `extract_eval_trades_from_metrics` removed; enrichment functions take `&WindowMetrics` directly; behavior preserved for E2/gamma/ensemble code paths. +**Success criterion** (T2.1): file no longer contains `check_early_stopping(avg_q_value, ...)`; tests that exercised it migrated or deleted. (T2.2): `extract_eval_trades_from_metrics` removed; enrichment functions consume real per-trade tape from `gpu_backtest_evaluator`; all 8 outputs wired to real consumers; all thresholds signal-driven from ISV. + +#### T2.2 multi-phase scope (multi-session) + +Phase decomposition for the per-trade-tape-driven enrichment refactor: + +- **Phase 1**: Per-trade tape emission from `gpu_backtest_evaluator`. New buffers `[MAX_TRADES_PER_WINDOW]` × 7 fields (pnl, predicted_q at entry, direction, magnitude, holding_bars, ensemble_var at entry, bar_index). New CUDA kernel writes at trade-close events. Host readback path. Mapped-pinned for streaming. Sentinel/cold-start handling. +- **Phase 2**: Replace `extract_eval_trades_from_metrics` with real tape consumer. Drop the fabrication. +- **Phase 3**: Wire E1 (q_correction). New ISV slot `Q_BIAS_CORRECTION_INDEX`. Apply as additive Q-target offset at C51 loss + Bellman target. +- **Phase 4**: Wire E4 (per-branch LR scaling). Apply via existing per-group Adam LR infrastructure (`pearl_per_group_adam_hyperparams`). +- **Phase 5**: Wire E6 (winner indices) → PER priority bumps. Use existing PER infra to mark indices high-priority. +- **Phase 6**: Wire E7 (hindsight) → replay buffer synthetic experience injection. +- **Phase 7**: Wire E8 (curriculum weights) → segment sampling weights at episode start. +- **Phase 8**: Signal-drive ALL enrichment thresholds (replace hardcoded `2.0`, `0.5`, `1.5×`, etc. with ISV-driven from `val_sharpe_var_ema`, ensemble disagreement EMA, etc.). +- **Phase 9**: GPU oracle tests for each new code path. + +Each phase is its own atomic commit (per `feedback_no_partial_refactor` — phase-internal contract migrations are atomic; cross-phase wiring is sequential). Phases 1-2 are mandatory together (can't have consumer without producer); Phases 3-7 can land independently in any order after Phase 2. **Pearl compliance**: T2.1 honors `feedback_no_legacy_aliases`. T2.2 honors `feedback_no_stubs` (fake-trade synthesis is a stub-shaped pattern — produces shaped output without real signal). diff --git a/docs/plans/2026-05-11-sp21-t22-phase15-phase2-enrichment-realwiring.md b/docs/plans/2026-05-11-sp21-t22-phase15-phase2-enrichment-realwiring.md new file mode 100644 index 000000000..878502043 --- /dev/null +++ b/docs/plans/2026-05-11-sp21-t22-phase15-phase2-enrichment-realwiring.md @@ -0,0 +1,397 @@ +# SP21 T2.2 Phase 1.5 + Phase 2 — Enrichment real-tape wireup + +**Author**: jgrusewski + assistant pair +**Date**: 2026-05-11 (continuation of 2026-05-10 SP21 work) +**Branch**: `feat/sp18-combined` (off worktree branch `sp20-aux-h-fixed`) +**Predecessor plan**: `docs/plans/2026-05-10-sp21-train-eval-coherence-isv-defrost.md` + +## State at session start + +8 SP21 commits landed in the prior session: + +``` +6df44e4c6 docs(sp21): plan + revert sp20-aux-h-fixed experiment +1790a31b6 feat(sp21): T3.1+T3.2 ISV defrost — wr_ema + hold_pct_ema +4d4cd996d feat(sp21): T3.3 ISV defrost — hold_reward_ema +74c7a8011 feat(sp21): T1.1a+T1.1b+T2.3 — signal-driven early-stopping +c90553953 feat(sp21): T1.2+T1.4 — enrichment real metrics + backtracking signal-driven +4ab1c132e feat(sp21): T2.1+T2.4 — Q-value early-stop + MIN_HOLD zombies deleted +5d01190e1 feat(sp21): T2.2 Phase 1 Step A — per-trade tape kernel ABI +7d538d930 feat(sp21): T2.2 Phase 1 Step B — per-trade tape buffers + readback +``` + +Audit-doc detail: `docs/dqn-wire-up-audit.md` (all 2026-05-10 entries). + +### What's done + +- **Tier 1 closed**: val-loss-driven early-stopping with signal-driven + `min_delta` from `ISV[VAL_SHARPE_VAR_EMA_INDEX=351]`. +- **Tier 3 closed**: wr_ema/hold_pct_ema/hold_reward_ema all signal-driven + (binary-majority → fractional refactor); 25/25 GPU oracle tests pass. +- **Tier 2 partial**: T2.1 (Q-value early-stop deleted), T2.4 (MIN_HOLD + zombies removed), T2.5 (PER hyperparams documented as deferred). +- **T2.2 Phase 1**: per-trade tape infrastructure — both backtest kernels + emit per-trade records at close events (5 fields: bar_index, pnl, + holding_bars, direction, magnitude). `GpuBacktestEvaluator` owns the + SoA buffers + `pub fn read_per_trade_tape() -> Vec`. + +### What's pending + +T2.2 **Phase 1.5** (entry_q capture) + **Phase 2** (wire enrichment to +real tape) — atomic single commit. + +--- + +## Phase 1.5 — entry_q tracking + +Captures predicted Q at trade open so the per-trade tape carries the +6th field needed by E1 (q_correction = `predicted_q − pnl` bias). + +### Storage + +`portfolio_state[ps + 6]` → `entry_q`. Slot 6 is currently unused +(zeroed at init, see `backtest_env_kernel.cu:57`). **Verify** before +coding that `backtest_state_gather` doesn't already write to slot 6. +Using slot 6 means **no `PORTFOLIO_STATE_SIZE` bump** — gather/forward +kernels untouched. + +### Capture site + +In `backtest_env_step` and `backtest_env_step_batch`, AFTER the +`unified_env_step_core` call returns, detect "new trade opened": + +```cuda +const int prev_pos_active = (fabsf(prev_position) > 0.001f); +const int now_pos_active = (fabsf(position) > 0.001f); +const int is_open = !prev_pos_active && now_pos_active; +const int is_reverse = (prev_position * position < 0.0f); + +if (is_open || is_reverse) { + /* Read q_values_per_window[w * num_actions + actions[w]] and + * write to portfolio_state[ps + 6]. New entry_q replaces the + * snapshot from before unified_env_step_core (which closed the + * old trade). */ + const float entry_q_new = q_values_per_window[ + (long long)w * num_actions + actions[w]]; + portfolio_state[ps + 6] = entry_q_new; /* via shmem_pf */ +} +``` + +### Plumbing + +New kernel arg on both backtest kernels: + +```cuda +const float* __restrict__ q_values_per_window /* [n_windows * num_actions] */ +int num_actions /* size of action space */ +``` + +Pass real device pointer from launcher. The Q-values are computed by +`experience_action_select` immediately before env_step. In the chunked +batch path they're in `chunked_q_values`; in the single-step path +`q_values_buf` (or whichever buffer the action_select kernel writes). +**Locate the actual buffer name** — grep for `expected_q` or +`backtest_action_select` in `gpu_backtest_evaluator.rs`. + +### Per-trade emission + +Snapshot `pre_entry_q` BEFORE the call (line ~265 of +`backtest_env_kernel.cu` after the existing `pre_entry_price` snapshot): + +```cuda +const float pre_entry_price = entry_price; +const int pre_hold_bars = (int)hold_time; +const float pre_entry_q = portfolio_state[ps + 6]; /* via shmem_pf[local_tid * STATE_SIZE + 6] */ +``` + +In the existing close-detection block, write the new buffer: + +```cuda +per_trade_predicted_q_out[idx] = pre_entry_q; +``` + +### Buffer additions + +`GpuBacktestEvaluator` gains a 6th SoA buffer: + +```rust +per_trade_predicted_q_buf: CudaSlice, // [n_windows × MAX_TRADES_PER_WINDOW] +``` + +Allocated alongside the existing 5 buffers. Constructor zero-init. +Reset at fold boundary (already handled by reset_evaluation_state if +we just zero the count; predicted_q values past `count[w]` are never +read host-side). + +### EvalTrade struct extension + +```rust +#[derive(Debug, Clone, Copy)] +pub struct EvalTrade { + pub bar_index: u32, + pub pnl: f32, + pub predicted_q: f32, // SP21 Phase 1.5 (2026-05-11): entry_q at trade open + pub holding_bars: u32, + pub direction: u8, + pub magnitude: u8, +} +``` + +`read_per_trade_tape` reads the 6th SoA buffer, populates `predicted_q` +in each `EvalTrade`. + +--- + +## Phase 2 — wire enrichment to real tape + +### training_loop.rs changes + +`crates/ml/src/trainers/dqn/trainer/training_loop.rs:1510-1568` — +replace the fake-trade synthesizer call with `read_per_trade_tape()`: + +```rust +// BEFORE (fake synthesizer): +let eval_trades = super::enrichment::extract_eval_trades_from_metrics( + val_sharpe_for_backtrack as f32, + real_trade_count, real_total_pnl, real_win_rate, + log_output.avg_q_value as f32, + val_bars, +); + +// AFTER (real per-trade tape): +let eval_trades = match self.gpu_evaluator.as_ref() { + Some(ev) => ev.read_per_trade_tape().unwrap_or_default(), + None => Vec::new(), +}; +``` + +Cold-start (no evaluator yet): empty Vec → enrichment runs with no +trades → returns defaults (current epsilon, gamma, agreement_threshold +unchanged). Honest "no signal yet" — matches the pattern already used +in T1.2 for `last_val_metrics: None`. + +### enrichment.rs changes + +1. **Drop `ensemble_var` field** from `EvalTrade`. The val backtest uses + a single deterministic forward — no ensemble, no variance to capture. + Refactor E5 (see below). + +2. **Make `EvalTrade` a re-export** of + `cuda_pipeline::gpu_backtest_evaluator::EvalTrade` (single source of + truth): + + ```rust + pub(crate) use crate::cuda_pipeline::gpu_backtest_evaluator::EvalTrade; + ``` + + Or duplicate-with-conversion if module visibility prevents re-export. + +3. **Refactor E5 (`compute_agreement_threshold`)** to use trade-pnl + quartile-spread Sharpe instead of `ensemble_var`. Concrete formula: + + ```rust + /// E5 (SP21 Phase 2, 2026-05-11): agreement threshold from quartile- + /// spread Sharpe. High spread between top-25% pnl Sharpe and bottom- + /// 25% pnl Sharpe = high "trades when policy is right" vs "trades + /// when wrong" separation = lower agreement threshold (trust agreement + /// more). No ensemble_var needed — fully self-contained on per-trade + /// pnl tape from gpu_backtest_evaluator. + fn compute_agreement_threshold( + trades: &[EvalTrade], + current: f32, + ) -> f32 { + if trades.len() < 4 { return current; } + let mut pnls: Vec = trades.iter().map(|t| t.pnl).collect(); + pnls.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let q1_end = pnls.len() / 4; + let q4_start = 3 * pnls.len() / 4; + let q1 = &pnls[..q1_end]; + let q4 = &pnls[q4_start..]; + let sharpe_of = |slice: &[f32]| -> f32 { + let mean = slice.iter().sum::() / slice.len() as f32; + let var = slice.iter().map(|x| (x - mean).powi(2)).sum::() + / slice.len() as f32; + if var > 1e-12 { mean / var.sqrt() } else { 0.0 } + }; + let q1_sharpe = sharpe_of(q1); + let q4_sharpe = sharpe_of(q4); + let spread = q4_sharpe - q1_sharpe; + /* Larger spread → tighten threshold (× 0.9). Smaller spread → + * loosen (× 1.1). Spread > 1.5σ qualifies as "tight". */ + let new = if spread > 1.5 { current * 0.9 } + else if spread < 0.5 { current * 1.1 } + else { current }; + new.clamp(0.01, 10.0) + } + ``` + + Note: hardcoded `1.5`/`0.5` thresholds here — flag for follow-up + signal-drive (Phase 8 of T2.2 multi-phase scope). + +4. **Delete `extract_eval_trades_from_metrics`** function entirely + (the fake-trade synthesizer). Per `feedback_no_legacy_aliases`. + +5. **Delete `HindsightExperience` struct** if unused after refactor. + Check E7 (`compute_hindsight_labels`) — keeps if E7 still emits. + +### Verify all 8 enrichment functions consume real per-trade data + +| Fn | Uses | After Phase 1.5+2 | +|---|---|---| +| E1 `compute_q_correction` | predicted_q − pnl | ✓ real (Phase 1.5 supplies predicted_q) | +| E2 `compute_adaptive_epsilon` | val_sharpe (aggregate) | ✓ real (already aggregate-only) | +| E3 `compute_dynamic_gamma` | holding_bars, pnl | ✓ real | +| E4 `compute_branch_lr_scale` | direction, magnitude, pnl, holding_bars | ✓ real | +| E5 `compute_agreement_threshold` | (refactored) pnl quartile spread | ✓ real | +| E6 `compute_winner_indices` | pnl, bar_index | ✓ real | +| E7 `compute_hindsight_labels` | direction, pnl, bar_index | ✓ real | +| E8 `compute_curriculum_weights` | pnl | ✓ real | + +All 8 land on real data after this commit. + +--- + +## Hard rules (carried from prior session) + +- **NO NULL launcher passes.** Real buffers from the start. +- **NO `// TODO` markers** (per `feedback_no_todo_fixme`). +- **NO stub return values** / placeholder zeros for fields meant to + carry signal. Refactor or delete the consumer if computation + isn't honest. +- **Atomic commits** per `feedback_no_partial_refactor`: contract + changes ship with all consumers migrated together. +- **If a kernel signature changes, both call sites migrate in the + same commit.** +- **Pre-commit hook runs `cargo check` + GPU hot-path leak check**; + commits must pass both. (Note: the hook complains about pre-existing + `unwrap()` and the line-78 `return 0.0` in training_loop.rs — those + are pre-existing warnings, not introduced.) +- **NEVER use `--no-verify`.** Per CLAUDE.md. + +--- + +## Verification plan + +Before committing: + +1. `SQLX_OFFLINE=true cargo check -p ml --tests` — must pass clean. +2. `SQLX_OFFLINE=true cargo test -p ml --test sp20_aggregate_inputs_test + --features cuda -- --ignored --nocapture` — should still be 12/12 + (aggregator unaffected by these changes). +3. `SQLX_OFFLINE=true cargo test -p ml --test sp20_phase1_4_wireup_test + --features cuda -- --ignored --nocapture` — should still be 2/2. +4. `SQLX_OFFLINE=true cargo test -p ml --test sp20_emas_compute_test + --features cuda -- --ignored --nocapture` — should still be 4/4. +5. `SQLX_OFFLINE=true cargo test -p ml --test sp20_controllers_compute_test + --features cuda -- --ignored --nocapture` — should still be 7/7. + +Optional (skip if blocking; document as Phase 2.5 follow-up): + +6. NEW smoke test exercising `GpuBacktestEvaluator`'s per-trade tape + end-to-end. Synthetic eval window → run kernel → read tape → + assert non-empty `Vec` with valid `predicted_q` values. + Test scaffolding for the eval kernel is heavy; the existing + `sp20_aggregate_inputs` GPU oracle tests have a similar shape and + can be adapted as a reference. Place in + `crates/ml/tests/gpu_backtest_per_trade_tape.rs`. + +--- + +## Files affected this session + +- `crates/ml/src/cuda_pipeline/backtest_env_kernel.cu` + - Both kernels: new `q_values_per_window` + `num_actions` args. + - Snapshot `pre_entry_q` BEFORE `unified_env_step_core` call. + - On close: emit `per_trade_predicted_q_out[idx] = pre_entry_q`. + - On open / reverse: write + `portfolio_state[ps + 6] = q_values_per_window[w * num_actions + + actions[w]]` (via shmem path). + +- `crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs` + - New struct field `per_trade_predicted_q_buf: CudaSlice`. + - Constructor: alloc 6th buffer. + - Both launcher sites: pass q_values device pointer + num_actions. + - `EvalTrade`: add `pub predicted_q: f32` field. + - `read_per_trade_tape`: read 6th buffer + populate `predicted_q`. + +- `crates/ml/src/trainers/dqn/trainer/enrichment.rs` + - `EvalTrade` re-exports / matches gpu_backtest_evaluator's struct + (drop `ensemble_var`). + - `compute_agreement_threshold`: refactor to quartile-spread Sharpe. + - Delete `extract_eval_trades_from_metrics`. + - Delete `HindsightExperience` if unused. + +- `crates/ml/src/trainers/dqn/trainer/training_loop.rs:1510-1568` + - Replace `extract_eval_trades_from_metrics(...)` call with + `evaluator.read_per_trade_tape()?`. + - Drop the `extract_*`-related plumbing (`val_bars`, + `real_trade_count` etc.) since real tape supplies these directly. + +- `docs/dqn-wire-up-audit.md` + - New section dated 2026-05-11 documenting Phase 1.5 + Phase 2 + closure. Per Invariant 7, every CUDA-pipeline change requires + audit-doc update — pre-commit hook enforces this. + +--- + +## Open question to resolve before coding + +**E5 alternate signal — quartile-spread vs ISV ensemble disagreement?** + +(a) **Quartile-spread Sharpe** (recommended): top-25% pnl Sharpe minus +bottom-25% pnl Sharpe. Fully self-contained on per-trade pnl tape. +No new infrastructure, no cross-coupling val/training data. + +(b) **ISV ensemble disagreement EMA**: read from training-side ISV slot +(if exposed to val read path). Requires checking per-epoch ordering +(does training set ensemble_var EMA before val runs?). Adds val ↔ +training coupling. + +**Recommend (a)** unless the user has a specific reason to reach +across to training-side ensemble state. (a) is also philosophically +cleaner: enrichment runs against val data, so val should be its +self-contained signal source. + +--- + +## After this commit lands + +T2.2 multi-phase scope continues with Phases 3-7 + Phase 8: + +- Phase 3: Wire E1 (q_correction) → ISV slot consumer (apply as + additive Q-target offset at C51 loss + Bellman target). +- Phase 4: Wire E4 (per-branch LR scaling) via existing per-group + Adam infrastructure (`pearl_per_group_adam_hyperparams`). +- Phase 5: Wire E6 (winner indices) → PER priority bumps. +- Phase 6: Wire E7 (hindsight) → replay buffer synthetic experience + injection. +- Phase 7: Wire E8 (curriculum weights) → segment sampling weights. +- Phase 8: Signal-drive remaining hardcoded thresholds in enrichment + (the `1.5` / `0.5` constants in the new E5 quartile-spread, the + `2.0` / `0.5` / `-0.5` in E2 epsilon adapter, etc.) using + appropriate ISV variance EMAs. + +Each is its own atomic commit (per `feedback_no_partial_refactor` — +phase-internal contract migrations are atomic; cross-phase wiring is +sequential). + +--- + +## After all phases land + +Dispatch a smoke training run on the L40S to validate the full SP21 +cascade end-to-end. Watch: + +- HEALTH_DIAG `sp20_isv` line shows variance > 0 across epochs for + wr_ema, hold_pct_ema, hold_reward_ema (Tier 3 verification). +- `🛑 Early stopping triggered` fires within `patience` epochs of + val_Sharpe peak (Tier 1 verification). +- Enrichment receives real `Vec` instead of fake aggregates + (T2.2 verification). +- HEALTH_DIAG controller readouts (`c51_budget`, `cql_budget`, + `q_var`, `iqn_budget`, `lb_active`) show inter-branch variation + (Tier 4 cascade verification). +- `evaluate_baseline` (or modern eval entry point) reproduces the + val_Sharpe / val_PF observed in training (within 5% relative + tolerance) on the same checkpoint + window (success criterion #6 + from the SP21 plan).