docs(sp21): T2.2 Phase 1.5 + Phase 2 continuation plan + amendment

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) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-10 21:35:58 +02:00
parent 7d538d9304
commit c274b99ea9
2 changed files with 415 additions and 2 deletions

View File

@@ -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).

View File

@@ -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<EvalTrade>`.
### 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<f32>, // [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<f32> = 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::<f32>() / slice.len() as f32;
let var = slice.iter().map(|x| (x - mean).powi(2)).sum::<f32>()
/ 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<EvalTrade>` 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<f32>`.
- 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<EvalTrade>` 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).