Commit Graph

5748 Commits

Author SHA1 Message Date
jgrusewski
1aa92f57f0 fix(rl): eval-boundary addendum — reward_scale warmed_flag + pos_max_ema rate-cap
The parent eval-boundary fix (72684ed3e) preserved env.max/clamp EMAs but
left the σ explosion intact. Diagnosis from alpha-rl-jnct8 (10k+2500 eval,
fold-1, -$106M eval pnl, σ → 4451 by eval step 5) revealed two pre-existing
mechanisms compounding the eval shock:

ISSUE A — reward_scale snaps to 0.10 at boundary
================================================
rl_fused_controllers' reward_scale block had a bootstrap-fraction-floor
gate: `(cumulative_dones < min_trades) → boot_floor = 0.10`. The intent
was cold-start protection (prevent scale crash before any closed-trade
ground truth). But cumulative_dones (slot 660) is reset by
reset_session_state for Kelly's PREDICTIVE-warmup purpose. At fold
boundary the gate fires again, clamping the preserved scale (0.0046 in
jnct8) UP to 0.10 — a 22× upward jump. Eval rewards are then 22× larger,
overwhelming env.max preservation; σ explodes regardless.

FIX A — decouple via monotonic warmed_flag (slot 716, never reset).
Set ONCE when cumulative_dones first crosses min_trades; persists across
all subsequent fold boundaries. boot_floor reads the flag instead of
re-evaluating trade_count. Math: at cold-start flag=0 → boot_floor=0.10
(original protection preserved). Post-warmup flag=1 → boot_floor=scale_min
(~1e-4) → preserved scale survives boundaries.

ISSUE B — pos_max_ema growth unbounded (train-phase fat-tail spike)
====================================================================
Pre-existing fat-tail behavior: at alpha-rl-jnct8 step 3895 a single
account had pre_clamp scaled reward 724. The clamp's Wiener-α EMA
(α=0.4 floor) admitted 40% of the observation, jumping pos_max_ema
112 → 834 in 5 steps. clamp_win = MARGIN × pos_max_ema followed
magnitude up rather than bounding it; env.max captured the unclamped
reward (121 → 1306). Recovery via slow-decay over 600 steps, but
during the spike PPO gradients were mis-scaled.

FIX B — asymmetric per-step growth cap on pos_max_ema (1.5× max).
Same Schulman-bounded-step pattern as reward_scale's 2% per-step
movement clamp. Decreases unbounded (allows fast recovery from spike).
Bootstrap path unchanged (ema_prev=0 → ema_new=pos_max, no cap).
Math: 5-step max growth = 1.5^5 ≈ 7.6× vs prior uncapped 7.4× in
practice — similar steady-state, bounded transient.

Local validation (b=16, 800+200 fold-1):
- σ preserved across boundary (51.9 → 51.4 at eval[1])
- σ stays bounded in 23-57 range across full eval phase (no 60× explosion)
- scale=0.10 stable across boundary
- warmed_flag stays 0 at b=16 (cumulative_dones never crosses min_trades
  at this scale — flip behavior tested at cluster b=1024)

Spec: docs/superpowers/specs/2026-05-31-eval-boundary-addendum-reward-scale-and-train-spike.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:25:24 +02:00
jgrusewski
72684ed3ef fix(rl): preserve normalization EMAs across eval boundary
reset_session_state was resetting four EMAs that calibrate signal
scale rather than predict behavior:
  - RL_POS_SCALED_REWARD_MAX_EMA_INDEX (clamp scale anchor)
  - RL_NEG_SCALED_REWARD_MAX_EMA_INDEX (clamp scale anchor)
  - RL_REWARD_CLAMP_CLIP_RATE_EMA_INDEX (clamp feedback)
  - RL_POPART_MAX_ABS_REWARD_EMA_INDEX (F4 envelope)

At the train→eval fold boundary this disabled the reward clamp
(cap = pos_max_ema × ratio = 0) and let eval's first ~10 trade
outliers blow up the popart envelope σ 1500× (57 → 4471) over
~14 steps. PPO surrogate (A_unnorm = σ × A_norm) ran with
catastrophically mis-scaled gradients for the next ~1000 eval
steps, accounting for the bulk of the -$185M eval loss at
alpha-rl-6kghr fold 1.

Refines pearl_adaptive_carryover_discipline: RESET PREDICTIVE
EMAs (Kelly, dd, recency) but PRESERVE NORMALIZATION EMAs.

Spec: docs/superpowers/specs/2026-05-31-eval-boundary-normalization-preservation-design.md
Pearl: pearl_popart_reset_at_eval_boundary_shocks_normalization.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:10:59 +02:00
jgrusewski
22e6ddbcac fix(rl): eval_summary aggregates across all b_size accounts (E.4-E.5)
Replaces the broken single-account + scale-mismatch slicing block in
alpha_rl_train.rs eval phase with proper per-account aggregation.

Pre-fix (cluster v11 alpha-rl-8ll7j, b=1024):
  head_before_eval = sim.read_total_trade_count()  // aggregate=1,203,376
  all_records      = sim.read_trade_records(0)     // backtest 0 only, ≤1024
  // 1.2M > 1024 → wrap branch fires → eval_records = ALL 1024 records
  //   from account 0, mixing train+eval trades
  eval_summary: n_trades=1024 pnl=$61,513 wr=0.217 — MEANINGLESS

Post-fix (this commit, b=16 local smoke):
  head_before_per_b = sim.read_per_backtest_trade_counts()
  all_per_b         = sim.read_trade_records_all()
  // for each backtest: slice ring by per-account head_before vs head_after,
  //   handle wrap correctly, aggregate eval-only records
  eval_summary: n_trades=226 pnl=$-41,137 wr=0.376
  n_eval_trades_seen=226 (= captured) n_dropped=0 n_pre_eval_wrapped=0

Local smoke validation (b=16, 1k train + 250 eval, fold 1 ES.FUT all):
  - n_trades jumped 58 → 226 (4× — confirms aggregation across 16 accts)
  - per-account pre-eval heads: min=16 max=59 sum=596 (correct scale)
  - n_eval_trades_seen == n_trades (no wrap at this scale)
  - eval_summary.json gained 4 new fields:
      n_eval_trades_seen, n_eval_trades_dropped,
      n_pre_eval_trades_wrapped, b_size

Existing keys (n_trades, total_pnl_usd, profit_factor, sharpe_ann,
max_drawdown_usd, win_rate) preserved for downstream G8-gate / Argo
aggregator consumers (per spec §3).

Cluster validation pending Phase A cluster (alpha-rl-jz48s) completion
to avoid alpha_rl_train.rs branch conflict. After Phase A merges,
this fix can submit alongside.

Spec: docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md
Plan: docs/superpowers/plans/2026-05-31-eval-summary-trade-aggregation.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:09:16 +02:00
jgrusewski
fa0858f0b1 feat(lobsim): per-backtest trade-count read + 4× TRADE_LOG_CAP (E.1-E.3)
Adds two new public methods to LobSimCuda + bumps TRADE_LOG_CAP to
prevent eval-phase ring-buffer wrap at cluster scale.

- read_per_backtest_trade_counts() -> Vec<u32>: cumulative trade
  counters per backtest (length n_backtests). Replaces the broken
  pattern in alpha_rl_train.rs where head_before_eval = aggregate
  across batch was compared against all_records = single-account ring.

- read_trade_records_all() -> Vec<Vec<TradeRecord>>: all backtests'
  rings in one call. Mapped-pinned staging per
  feedback_no_htod_htoh_only_mapped_pinned: allocate
  MappedRecordBuffer<u8> for the payload + MappedRecordBuffer<u32>
  for heads, DtoD copy from device buffers into mapped-pinned
  dev_ptrs, sync, read host_ptrs.

- TRADE_LOG_CAP 1024 → 4096: cluster v11 (alpha-rl-8ll7j) showed
  ~342 eval trades/account mean with peaks toward 1000. 4096 gives
  4× headroom; memory cost b=1024 × cap × 40 B = 167 MB (was 41 MB),
  comfortable on L40S 48GB / H100 80GB.

Both methods synchronize after DtoD so host reads see the data.
E.4 (alpha_rl_train.rs aggregation block replacement) lands
separately after Phase A cluster validates to avoid alpha_rl_train.rs
conflict.

See spec docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md
and plan docs/superpowers/plans/2026-05-31-eval-summary-trade-aggregation.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:01:16 +02:00
jgrusewski
ad16e9d941 fix(trainer): address Phase A code review minors (8 fixes)
Per `feedback_always_fix_minor_review_findings`, all 8 minor review
findings from Phase A (eval-diag emission) are applied:

1. Restore aliasing comment for trail_fired_step / conf_gate_step
   (both read RL_CONF_GATE_FIRED_COUNT_INDEX deliberately).
2. Replace act_hist[7/8/9/10] magic indices with `Action::*` enum
   variants in both `IntegratedTrainer::build_diag_value` and the
   train/eval loops in `alpha_rl_train.rs`.
3. Rewrite `recursion_limit = "256"` comment in `lib.rs` to describe
   macro recursion DEPTH (~30 nested object blocks), not leaf count.
4. Move `RL_CONF_GATE_FIRED_COUNT_INDEX`, `RL_PYRAMID_ADD_COUNT_INDEX`,
   `RL_FRD_GATE_FIRED_COUNT_INDEX`, `RL_HEAT_CAP_FIRED_COUNT_INDEX`
   to the top-of-file `use` block (each appears ≥2× as a fully-
   qualified path); 10 call sites switched to bare names.
5. Harmonise leaf-count documentation to 643 (642 scalars + 1 bool
   `pyramid.max_units_reached`) across `build_diag_value` docstring,
   `--eval-diag-jsonl` arg docs, eval-phase comment, and the test
   module-level docstring.
6. Drop `_host` suffix from every `DiagInputs` field (14 fields).
   The struct's docstring already states all slices are host-side;
   the suffix was redundant. `DiagStaging.*_host_ptr` fields are
   NOT renamed — those still refer to actual host pointers.
   28 internal trainer references and 28 call-site references
   updated in lockstep.
7. Align eval_diag_emission test default data dir with foxhunt
   convention: `test_data/futures-baseline/ES.FUT` (resolved from
   `CARGO_MANIFEST_DIR`) instead of `/tmp/rl-smoke-lpi-diag/data`.
   Switch `--instrument-mode` from `front-month` to `all` to match
   the all-instrument predecoded files at that path. Env override
   `FOXHUNT_EVAL_DIAG_DATA` still wins.
8. Add eval-step monotone assertion: verify `eval[0].step == n_steps`
   and `eval[N-1].step == n_steps + n_eval_steps - 1`. Catches a
   regression where the eval loop would reuse the training step
   counter instead of continuing past it.

Verification: `SQLX_OFFLINE=true cargo check -p ml-alpha` clean +
`cargo test --release -p ml-alpha --test eval_diag_emission --
--ignored --nocapture` PASS with curated data (100 train + 50 eval
lines, 643 leaves both phases, schema parity, step axis 100..=149).
2026-05-31 17:49:23 +02:00
jgrusewski
210794626a feat(rl): emit eval-phase per-step diag to eval_diag.jsonl
Cluster run alpha-rl-8ll7j ended with +$61,513 pnl and max_dd -$444,512
but the eval phase emitted ZERO per-step diag, leaving the drawdown
trajectory invisible. Phase A of the 2026-05-31 checkpoints+eval-diag
plan wires the eval loop into the same diag pipeline as train using
the builder extracted in the previous commit.

Changes:
  * `--eval-diag-jsonl <PATH>` CLI flag (defaults to
    `<out>/eval_diag.jsonl`).
  * Eval loop now calls `diag_staging.sync_and_swap` +
    `snapshot_async` after every `step_with_lobsim_gpu`, builds a
    `DiagInputs` from the staging reads, and writes a JSONL line via
    the same `IntegratedTrainer::build_diag_value` the train loop
    uses. Step indices continue past the train phase
    (`cli.n_steps + eval_step`) so post-hoc tooling can concatenate
    train + eval JSONL into a monotone step axis.
  * Eval-phase running counters (pnl_cum_usd, trades, gates, …) are
    independent of train counters so the eval JSONL reflects the
    eval window only — mirrors the trade-record checkpoint that
    eval_summary.json uses.
  * New integration test `eval_diag_emission` validates schema
    parity: same 643 leaf paths in `diag.jsonl` and `eval_diag.jsonl`,
    correct line counts (n_steps / n_eval_steps). Ignored by default
    because it requires CUDA + the pre-built release binary.

Verification (locally on RTX 3050 Ti):
  100 train + 50 eval @ b=16, n_folds=2 →
  `diff <(head -1 diag.jsonl | jq 'paths(scalars)|sort')
        <(head -1 eval_diag.jsonl | jq 'paths(scalars)|sort')`
  returns empty (schema parity confirmed).
2026-05-31 17:26:21 +02:00
jgrusewski
8a93a77adc refactor(trainer): extract json!{} into IntegratedTrainer::build_diag_value
The per-step diag JSONL `json!{...}` block had grown to 642 leaf paths
across ~30 nested objects, duplicating every ISV slot read into the
example binary. Phase A of the 2026-05-31 checkpoints+eval-diag plan
extracts it into a single builder on the trainer so the eval phase
can reuse it (next commit) without duplicating the schema.

Changes:
  * `IntegratedTrainer::build_diag_value(step, elapsed_s, &DiagInputs)
    -> Result<serde_json::Value>` — same 642-leaf schema as before,
    bit-equivalent ISV reads (all from `self.isv_host_slice()`).
  * `DiagInputs<'a>` struct bundles the host-side per-step state the
    trainer doesn't own (DiagStaging reads + running counters +
    windowed act histogram), so the call site stays a 1-liner.
  * Train loop in `alpha_rl_train.rs` swaps the inline json! for the
    builder call; the ~140-slot ISV-imports wall collapses to four
    slots still read by the stderr ticker.
  * `#![recursion_limit = "256"]` moves from the example into
    `ml-alpha/src/lib.rs` since the builder now lives in the library.

Schema parity verified: `head -1 diag.jsonl | jq 'paths(scalars)|sort'`
yields the same 642 keys as before this refactor (no schema drift).
2026-05-31 17:25:47 +02:00
jgrusewski
13d8ed76da docs: checkpoints + eval-diag spec + plans (v1 superseded by v2) 2026-05-31 16:57:43 +02:00
jgrusewski
7b3309edcc fix(rl): eliminate atomicAdd in PPO+DQN loss reduction (F4.1)
Replaces atomicAdd accumulators in `ppo_clipped_surrogate_fwd` and
`dqn_distributional_q_bwd` with per-batch [B] outputs + a dedicated
single-block tree-reduce kernel. Per feedback_no_atomicadd.

Root cause: `ss_pi_loss_dev_ptr` was zero-init at trainer construction
but never reset between steps; the PPO atomicAdd accumulated across
every step since startup. Result: `loss.pi` = step_count × mean_per_step,
bit-exact step-count fingerprints:
  - local 1k × ~9 ≈ 9080 ✓
  - cluster 20k × ~1200 ≈ 24M ✓

The DQN distributional Q kernel had the same atomicAdd pattern. Its
trainer caller happened to memset between launches in dqn_replay_step
so the symptom was masked, but the anti-pattern was identical. Fixed
in the same commit per the user's "atomicAdd should not be used at all"
reminder.

Local smoke (RTX 3050, b=16, 1k steps, seed=16962):

  step | l_pi (pre → post)  | l_q (pre → post)
   100 |   27.5 → 0.56      |  18.1 → 0.19
   500 |  743.1 → 4.59      |  93.3 → 0.19
   999 | 9080.5 → 65.1      | 186.1 → 0.19

l_q now bit-flat at per-step mean (~0.19) — no step-count fingerprint.
l_pi grows organically with policy excursion (PPO surrogate magnitude
when ratio→clamp_max under Q-distillation-driven policy updates),
which is the legitimate diagnostic the prior staleness was burying.

Changes:
  - ppo_clipped_surrogate_fwd: scalar [1] outputs → per-batch [B]
  - dqn_distributional_q_bwd: remove atomicAdd; per_batch[B] only
  - ppo_loss_reduce_b.cu (NEW): two block tree-reduce kernels
      • ppo_loss_reduce_b (dual: PPO loss + entropy loss)
      • mean_reduce_b_f32 (single, reused by DQN head)
  - PolicyHead + DqnHead: load reducer cubin, add reduce_loss_to_scalar
  - Trainer: allocate ss_pi_loss_per_b_d + ss_pi_loss_entropy_per_b_d,
    invoke reducer after each backward (PPO + DQN replay paths)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 14:40:58 +02:00
jgrusewski
30982963ef feat(rl): IQN τ tail-recency consumer + G24/G25 invariants (F5)
When TAIL_EVENT_RECENCY < N_window (default 100), boost τ_min by
factor (default 1.5) — agent uses more pessimistic action selection
during tail-recent regimes.

Defense-in-depth alongside Kelly resurrection (F2): F2 catches the
sizing-layer absorbing state; F5 makes action selection more
risk-averse right after a shock.

Tests:
- G24: TAIL_EVENT_RECENCY < 100 → τ_action ≥ τ_min × 1.5
- G25: TAIL_EVENT_RECENCY ≥ 100 → τ_action behavior unchanged

22 risk_stack_invariants now pass on RTX 3050 Ti.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:59:48 +02:00
jgrusewski
6dacde95ef feat(rl): popart per-account max-magnitude envelope (F4) — Problem 3 fix
Adds envelope-detector floor on popart_sigma so single-account tail events
within a batch are captured instead of diluted by the batch-mean variance.

Kernel changes (rl_popart_normalize.cu):
- warp_reduce_max helper alongside existing warp_reduce_sum
- Pass 1 extension: per-thread local_max_abs via fmaxf(fabsf(r))
- 3rd shared-mem bank for max_abs reduction (s_max_abs[])
- envelope-detector inside tid==0 block: fast-up, slow-down (α_d=0.01)
- new_sigma = fmaxf(new_sigma, max_r_ema) before write
- CRITICAL (Issue β): Pass 3 broadcast slots moved block_dim*2 → block_dim*3

Trainer launch update: smem_bytes = (block_x * 3 + 3) * sizeof(f32).

Per Theorem 6 (spec v3): at Run #8 step 7377 with r_tail=-19.45, the
envelope captures 19.45 instantly via fast-up path; sigma jumps from
2.327 → 19.45 in one step. The popart_v_correct kernel handles the
σ discontinuity affinely so V regression doesn't destabilize.

Decay phase: α_d=0.01 (69-step half-life); max_r_ema returns to typical
batch-max baseline over ~200 steps after a single shock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:50:31 +02:00
jgrusewski
cfe40f2f3f test(rl): G10-G12 back-compat + G15-G21 Kelly resurrection (F2.3+F2.4)
G10/G11/G12: explicit dead-zone disable (DEAD_ZONE_FLAG=0, TIMEOUT_FLAG=0)
before Kelly kernel launch — verifies analytic-Kelly path stays unmodified.

New invariants:
- G15: DEAD_ZONE_FLAG = 1 on composite kelly=0 ∧ all-flat ∧ no-cooldown
- G16: DEAD_ZONE_FLAG = 0 when any condition violated
- G17: Kelly resurrection sets kelly_f = ε_recovery_live when flag set
- G18: Kelly retains analytic value when flag NOT set
- G19: ε_recovery_live ramps linearly from ε_min at T=0 to ε_max at T≥N
- G20: TIMEOUT_FLAG fires when DURATION > MAX_DURATION
- G21: Kelly resurrection NOT triggered when TIMEOUT_FLAG = 1

20 risk_stack_invariants now pass on RTX 3050 Ti.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:42:16 +02:00
jgrusewski
ad05ffeb2a feat(rl): Kelly resurrection override (F2) — Problem 1 fix
When DEAD_ZONE_FLAG fires (kelly_f=0 ∧ all-flat ∧ no-cooldown), override
Kelly with ε_recovery_live (regime_observer-computed value ramping from
ε_min=0.05 to ε_max=0.50 as TAIL_EVENT_RECENCY grows). TIMEOUT_FLAG
gates resurrection off after MAX_DURATION=1000 steps (safety net).

Per Theorem 1 (spec v3): exit probability from absorbing state per step is
> 1 - 10^-23 given π(any Open) ≥ 0.05 over 1024 accounts. The Kelly
absorbing state no longer exists in the state graph (except the
operator-intended TIMEOUT terminal).

Block appended to end of rl_kelly_fraction_controller.cu (analytic Kelly
clamp first, then conditional override) and mirrored in Layer-4 branch
of rl_fused_controllers.cu.
2026-05-31 12:33:13 +02:00
jgrusewski
bf214d1a09 refactor(rl): v9 slot rename — RL_EVAL_WARMUP_* → RL_REGIME_TRANSITION_* (F3)
Theorem 2 (v9 behavior preserved): pure constant identifier rename across 3 files;
no physical migration. Slots 685/686/687 keep their addresses; v9 kernel,
trainer reset_session_state, and diag emit all reference the same memory.

- crates/ml-alpha/src/trainer/integrated.rs: 4 references renamed
- crates/ml-alpha/cuda/rl_eval_warmup_decay.cu: 3 `#define`s renamed
- crates/ml-alpha/examples/alpha_rl_train.rs: ~12 references renamed (import + diag emit)

Unblocks the 4 pre-existing errors that F1.1 introduced. Branch now compiles
cleanly with regime_observer foundation (F1) + v9 cleanup (F3).
2026-05-31 12:26:54 +02:00
jgrusewski
b09feaf9e4 feat(rl): regime_observer diag emit (F1.8)
Adds 'regime' block to risk_stack diag JSON, surfacing 5 nested groups:
- dead_zone: flag, duration, timeout_flag, max_duration
- tail: recency, session_pnl_variance_ema, sigma_threshold, welford_count
- kelly_eps_recovery: factor, live, min, max, n_recovery
- popart_envelope: max_abs_reward_ema, decay_alpha
- iqn_tau_boost: factor, n_window

All values read directly from ISV slots (drift-free per spec issue #9).
F3 will add the 'transition' group when v9 slot renames land in the diag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:17:13 +02:00
jgrusewski
0a9ff73b1f feat(rl): reset_session_state regime boundary policy (F1.7)
Extends reset_session_state with 8 new regime_observer slot resets per
spec section "Regime observer reset policy at fold/eval boundaries":

- Transient state (FLAG/DURATION/TIMEOUT) → 0
- RECOVERY_FACTOR → 1.0
- TAIL_EVENT_RECENCY → 1e6 sentinel
- KELLY_EPS_RECOVERY_LIVE → 0.50 (= ε_max)
- PREV_WORST_PNL → 0 (CRITICAL — prevents spurious 3σ tail at boundary)
- POPART_MAX_ABS_REWARD_EMA → 0 (F4 envelope reset)

Welford state (M2/MEAN/COUNT) intentionally NOT reset — variance of
session_pnl_change is regime-invariant per pearl_adaptive_carryover_discipline.

Issue γ fix: without PREV_WORST_PNL reset, the first regime_observer call
after a fold boundary would see Δworst = 0 - (-\$15k_train) = +\$15k, fire
a spurious 3σ tail event, and peg ε_recovery at ε_min for 100 steps.
2026-05-31 12:15:34 +02:00
jgrusewski
67f862191f feat(rl): regime_observer bootstrap entries (F1.6) — array 179→199
Initial values for the 20 new regime_observer slots per spec v3:
- Transient state: DEAD_ZONE_FLAG/DURATION/TIMEOUT = 0, RECOVERY_FACTOR = 1
- Sentinels: TAIL_EVENT_RECENCY = 1e6, PREV_WORST_PNL = 0, MAX_ABS_REWARD_EMA = 0
- Welford state: M2/MEAN/COUNT = 0 (bootstrap via first-observation, gated count>=10)
- Configs: ε_recovery_min=0.05, ε_recovery_max=0.50, N_recovery=100
- Configs: TAIL_SIGMA_THRESHOLD=3.0, MAX_DURATION=1000, popart α_d=0.01
- IQN τ tail boost: factor=1.5, window=100
2026-05-31 12:14:30 +02:00
jgrusewski
9e97c2acaf feat(rl): wire regime_observer into step_with_lobsim_reward_and_train (F1.5)
Inserts flat_count helper + regime_observer kernel launches BEFORE the
risk-stack controllers (CMDP/IQN/Inventory/Kelly). Uses prev_position_lots_d
(current-step snapshot, just-updated by LobSim per existing pipeline) as
the input to flat_count.

One-step lag on kelly_f/cooldown/worst_pnl reads is benign per Theorem 1:
absorbing state persists across step boundaries, detection at T+1 still
fires before harm escalates.

No consumer changes yet — F2/F3/F4/F5 land independently.
2026-05-31 12:12:46 +02:00
jgrusewski
79b8a43491 style(rl): F1.4 code review fixes — borrow + naming + format
Code-quality review found 1 Important + 2 Minor; applied all 3:
- Important: launch_rl_regime_flat_count(&mut self) → (&self)
  GPU writes through device pointer are invisible to borrow checker;
  matches pattern of launch_rl_win_rate_ema_update (&self) at adjacent line.
- Minor: smem_bytes → smem (matches convention of 11 other launchers)
- Minor: removed column-padding alignment on 5 new struct fields + changed
  `///` doc comment to `//` for private field flat_count_d (matches surrounding
  private fields).

Zero functional change.
2026-05-31 12:11:02 +02:00
jgrusewski
6e0c9abff2 feat(rl): regime_observer trainer wiring — loaders + struct + launchers (F1.4)
Adds:
- include_bytes! for rl_regime_flat_count + rl_regime_observer cubins
- IntegratedTrainer struct fields: 2 modules + 2 functions + flat_count_d helper buffer
- new() cubin loading + struct field assignment
- launch_rl_regime_flat_count(): Grid=(1), Block=(256), smem=256*sizeof(int)
- launch_rl_regime_observer(): Grid=(1), Block=(1), smem=0

Does NOT wire launchers into step_with_lobsim (that's F1.5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:05:49 +02:00
jgrusewski
6ded2c55c2 style(rl): F1.3 code review fixes — comments + spec annotations
Code-quality review found 1 Important + 4 Minor; applied all 5:
- I1: restored `(issue #9, drift-free)` cross-link in Phase 4 header
- M1: added explicit launch profile in header `Grid=(1,1,1), Block=(1,1,1), smem=0`
- M2: comment explaining `count` reuse in Phase 3 (Welford state retained in registers)
- M3: inline rationale for `kelly == 0.0f` and `cooldown == 0.0f` exact equality
- M4: extended Welford-bootstrap comment with statistical floor justification
       (exempt from ISV-promotion per "physics/math constants" rule)

Zero functional change — comments only; cubin rebuilds identical bytes.
2026-05-31 12:01:50 +02:00
jgrusewski
b8272221db feat(rl): rl_regime_observer kernel (F1.3)
Single-thread single-block kernel emitting 4 surface signals
+ internal Welford state + drift-free recovery_factor/eps_live.

Spec: docs/superpowers/specs/2026-05-31-regime-observer-design.md v3
Pearls: [[pearl_kelly_trade_stream_death]], [[pearl_dead_signal_resurrection_discipline]]

4 phases:
- 1. Dead-zone detection (composite kelly=0 ∧ all-flat ∧ no-cooldown)
- 2. Welford variance on Δworst_pnl (skips bootstrap, gated count >= 10)
- 3. 3σ tail event detection → resets TAIL_EVENT_RECENCY
- 4. Linear ramp recovery_factor + eps_recovery_live for F2 Kelly consumer

Per feedback_no_atomicadd: single-thread, no atomics.
Per feedback_cpu_is_read_only: pure device kernel.
2026-05-31 11:58:02 +02:00
jgrusewski
8ba9417837 style(rl): F1.2 code review fixes — comment + const + power-of-2 doc
Code-quality review found 1 Important + 3 Minor; applied all 4:
- comment: "warp-aware shared-mem reduction" → "shared-mem block reduction"
  (no __shfl_down_sync used; claim was misleading per reviewer)
- const-correctness: tid is now `const int tid = threadIdx.x` (matches sibling kernels)
- power-of-2 assumption: documented via `#define BLOCK_X 256` + comment
- removed unused `#include <stdint.h>` (kernel uses plain int only)

Zero functional change — kernel emit is identical; cubin rebuilds and passes.
2026-05-31 11:55:06 +02:00
jgrusewski
c76d960645 feat(rl): rl_regime_flat_count kernel (F1.2)
Block-reduce per-account lots[b] → flat_count single int.
Per feedback_no_atomicadd: shared-mem tree-reduce, no atomics.

Foundation for regime_observer (F1.3) which reads flat_count
to compose the dead-zone detection (kelly=0 ∧ all-flat ∧ no-cooldown).

Launch profile (for trainer in F1.4):
  Grid=(1), Block=(256), smem=256*sizeof(int)=1024 bytes.
2026-05-31 11:51:29 +02:00
jgrusewski
6353deed15 style(rl): F1.1 code review fixes — separator + comments
Code-quality review found 2 Important + 2 Minor issues; applied all 4:
- separator style: ────── → ============ (file convention)
- restored split sub-headers: outputs (6) / Welford state (4)
- restored full safety-net comment with issue #4 reference
- added inline value hints on every constant (per spec block)

Zero functional change — comments and whitespace only.
2026-05-31 11:49:37 +02:00
jgrusewski
c1a0143311 feat(rl): regime_observer ISV slot allocation (F1.1)
20 new slots (696-715) + 3 v9 slot renames (685-687).
Per spec docs/superpowers/specs/2026-05-31-regime-observer-design.md v3.

Slot map:
- 696-705: regime_observer outputs + internal Welford state
- 706-709: Kelly resurrection config + live value
- 710: dead-zone timeout config
- 711-712: IQN τ tail-boost config
- 713: tail σ threshold
- 714-715: popart max envelope (F4)

v9 renames are zero-move (same physical addresses):
- 685 RL_EVAL_WARMUP_REMAINING → RL_REGIME_TRANSITION_REMAINING
- 686 RL_EVAL_WARMUP_STEPS_CONFIG → RL_REGIME_TRANSITION_STEPS_CONFIG
- 687 RL_EVAL_WARMUP_DECAY_STEPS_CONFIG → RL_REGIME_TRANSITION_DECAY_STEPS_CONFIG

RL_SLOTS_END: 696 → 716

Downstream sites (eval_warmup_decay.cu, integrated.rs) will show
"cannot find" errors against the renamed v9 constants until F3 updates them.
2026-05-31 11:44:35 +02:00
jgrusewski
baf971ba54 diag(rl): emit v9 eval_warmup state to JSONL + cleanup lints
Diag: surfaces `risk_stack.eval_warmup.{remaining,active,blend,
floor_*,target_*}` so the v9 defensive-warmup window is observable
in diag.jsonl. `remaining` is the counter; `blend` is the
defensive-vs-normal mix coefficient (1.0 = full defensive, 0.0 =
normal); `floor_*` reflect the LIVE override values (read AFTER the
warmup kernel ran). Pre-warmup the kernel is a no-op (remaining=-1),
so v9 train-phase diag is bit-identical to v8.

Cleanup: tightens unreachable_pub items in tests/behavioral/* and
tests/sp5_producer_unit_tests.rs (pub → pub(crate)), removes
unused_mut on 6 sp5 scratch buffers, renames unused `step` loop
counter in alpha_baseline example, and explicitly discards an
intentionally-no-op `Command::assert` in cli_integration_test.
Reduces lint count by ~25; remaining 3 dead_code warnings flag
SP15 Phase 2A behavioral scaffolding (Phase 2B never landed —
deliberate signal, not noise).

Pre-existing pearl per feedback_no_hiding: do NOT suppress these
with #[allow]; the warnings ARE the design call surface.
2026-05-31 02:12:04 +02:00
jgrusewski
0c8cb6ad5b fix(rl): v9 defensive eval-boundary calibration
Implements docs/superpowers/specs/2026-05-31-v9-defensive-eval-boundary-calibration.md.
Closes the [[pearl_adaptive_carryover_discipline]] gap: every adaptive
EMA must reset OR re-bootstrap at regime boundaries, never selectively
preserve train-acquired statistics.

Layer 1 — full EMA reset in `reset_session_state`:
  - win_rate, avg_win/loss EMAs → neutral sentinels
  - cumulative_dones → 0 (re-enter Kelly bootstrap)
  - inventory_beta + inventory_variance EMAs → 0
  - reward_clamp pos/neg max EMAs + clip_rate EMA → 0
  - Kelly fraction → 1.0 (bootstrap)
  Lifts Fix D's deliberate carryover (commit 7064c9269), which was
  diagnosed in v8 fold-1 eval as the primary -$507k driver: agent
  entered eval with train wr=0.34 EMA and took aggressively-sized
  losing trades while EMA decayed to true eval wr=0.25.

Layer 2 — defensive warmup window:
  - New `rl_eval_warmup_decay` CUDA kernel: single-thread single-block,
    no atomics, mapped-pinned only. Runs after fused controllers each
    step. Overrides 4 risk-sizing floors (Kelly safety_frac, IQN τ_min,
    entropy_coef_min, PPO clip ε_min) with conservative defensive
    values for the first 500 steps post-boundary, with linear decay
    over the final 200 steps back to normal targets.
  - 11 new ISV slots (685-695): remaining counter, configured
    durations, defensive overrides (0.25/0.30/0.05/0.10), normal
    targets (0.50/0.10/0.01/0.05).
  - Sentinel counter = -1 at boot → kernel is no-op until reset arms it.

All boundary semantics live in ISV; no hardcoded constants in the
kernel. Build verified, all 13 risk-stack invariants + 5 controller
adaptive-floor tests + integrated-trainer smoke pass on RTX 3050.
2026-05-31 01:52:29 +02:00
jgrusewski
6c4945fe16 docs(spec): v9 defensive eval-boundary calibration (completes adaptive principle)
Per the newly-saved memory pearl pearl_adaptive_carryover_discipline,
diagnoses Fix D's "intentionally preserve train Kelly + inventory EMAs
into eval" as the dominant cause of v8's eval-phase regression.

Design — 3 layers at the train→eval boundary:

  Layer 1: Reset every adaptive EMA (Kelly wr/avg-win/avg-loss/
  cumulative_dones, inventory β/variance, reward-clamp pos/neg/clip
  EMAs) to neutral sentinels. Lets the controllers re-bootstrap from
  eval-distribution observations rather than carrying poisoned train
  state.

  Layer 2: Defensive warmup window (500 steps) overrides risk-sizing
  controllers — Kelly safety_frac 0.5→0.25, IQN τ_min 0.10→0.30,
  entropy coef floor 0.01→0.05, PPO ε floor 0.05→0.10. Linear decay
  back to normal over additional 200 steps.

  Layer 3: 2× LR multiplier during warmup window. Network weights
  adapt fast to new-regime statistics; this is the slowest-adapting
  layer of the agent.

Adds 8 new ISV slots (686-693), bumps RL_SLOTS_END 686→694. New
small kernel rl_eval_warmup_decay applies overrides each step during
warmup. Pure additive design — no kernel logic changes outside the
new warmup-decay kernel.

Validation requires running ALL 3 folds (vs v8's single fold-1) per
pearl_single_window_oos_is_not_oos. Success criterion: mean eval pnl
across 3 folds > v8 mean.

No code change in this commit — design document only. Implementation
deferred until decision is made on whether to ship v9 or first
collect v8's fold-0 + fold-2 baseline for cleaner comparison.
2026-05-31 01:31:46 +02:00
jgrusewski
ad5b29e652 diag(rl): emit atom-span calibration signals (no behavior change)
Per docs/superpowers/specs/2026-05-31-c51-atom-span-math-validation.md,
the binding constraint on atom_max during training is the dynamic
Bellman bound `atom_max ≥ WIN + γ × atom_max`, not the overstated
fixed-point `WIN/(1-γ)`. The math validation against local b=128
smoke confirmed atom_max can be 4.5× the fixed-point bound yet train
cleanly (qpa=+0.969 at step 999).

This commit adds derived diag fields under
`risk_stack.atom_calibration` to expose the bound directly:

  - win_bound, atom_max, gamma         (inputs)
  - dynamic_bound = WIN + γ × atom_max (the binding constraint)
  - atom_max_headroom (=atom_max - dynamic_bound; >0 = self-consistent)
  - popart_sigma, v_target_max_3sigma  (statistical V_target estimate)
  - atom_max_over_3sigma                (resolution waste ratio)

These let future cluster runs measure CURRENT design's over-sizing
empirically (smoke step 999 showed atom_max ~5× larger than 3σ of
V_target requires — wasteful but safe). Future iterations can use
this data to safely tighten atom_max anchor toward V_target_max
without speculating about which design works.

Pure additive diag — no kernel changes, no behavior change. Pulls
values from existing ISV slots (RL_REWARD_CLAMP_WIN_INDEX,
RL_C51_V_MAX_INDEX, RL_GAMMA_INDEX, RL_POPART_SIGMA_INDEX).

Validates the math from 2026-05-31-c51-atom-span-math-validation.md
empirically in every cluster run going forward.
2026-05-31 01:20:37 +02:00
jgrusewski
82572ff3bd docs(spec): C51 atom span math validation + empirical proof
Formal proof of why the current `atom_max = EWMA(WIN_bound)` design
trains successfully despite empirical violations of the "structural
minimum atom_max ≥ WIN/(1-γ)" claim made in 2026-05-30-c51-atom-
resolution-design-alternatives.md.

Key findings (proven by local smoke + cluster v8 trajectory):

1. The fixed-point bound `WIN/(1-γ)` is overstated as a structural
   constraint. Empirical: smoke step 999 atom_max = 4.5× the bound,
   system trains healthy (qpa=+0.969).

2. The actually-binding constraint is the dynamic bound:
   `atom_max ≥ max V_target_observed ≤ WIN + γ × V_max_observed`
   Self-consistent and satisfied with margin 23 at smoke step 999.

3. Both speculative alternatives (Fix F = mean_abs_pnl anchor,
   Fix F-v2 = V_target_observed anchor) have closed-form instability
   at γ(1+ε) ≥ 1 — for γ=0.99 only ε ≤ 0.01 stable, no resolution
   benefit over current design.

4. Current design is *conservative* (atom_max ~5× larger than V_target
   3σ requires) but *correct* — slow EWMA hysteresis absorbs WIN
   transients and keeps Q-V Bellman self-consistent.

Includes empirical trajectory data:
- Local smoke (b=128, HEAD d57bee054, 1000 steps): atom_max 1798→1468,
  qpa +0.65→+0.97, clip_rate 5-11%, dynamic bound satisfied with
  margin 23 at convergence.
- Cluster v8 (b=1024, alpha-rl-vxbpq @ 6d4a962e5): popart_σ collapses
  56→1.69 from step 100→16830, qpa stays >+0.93, pnl_cum $164M.

Saves pearl_atom_span_dynamic_vs_fixed_point for future sessions.

Diagnostic emit of V_target_max_observed proposed (§5) but deferred
— current signals (WIN + γ × atom_max) already provide the bound
indirectly via JSONL. Real-time V_target_max is a nice-to-have for
future calibration work, not required to validate the current design.
2026-05-31 01:13:47 +02:00
jgrusewski
d57bee0542 docs(spec): atom resolution design alternatives (post Fix F failure)
Documents why Fix F crashed at cluster scale (qpa: +0.898 → -0.976
between step 371 and step 500 in alpha-rl-qrgjr v7), the structural
Bellman self-consistency constraint atom_max >= WIN/(1-γ) that makes
the resolution trade-off fundamental, and the design alternatives
considered (non-uniform atoms, adaptive γ, two-headed Q). Recommends
shipping A+B+C+D+E only — the pre-Fix-F design implements the
structurally-correct atom span via WIN-tracking EWMA.

No code change in this commit — design document only. Empirical
result from v8 (alpha-rl-vxbpq, SHA 6d4a962e5) will determine whether
deeper atom-resolution work (Options 4.C or 4.E) is justified.
2026-05-31 00:10:05 +02:00
jgrusewski
6d4a962e5c Revert "fix(rl): decouple C51 atom span from reward-clamp ceiling"
This reverts commit 0fe825a8c5.
2026-05-31 00:01:19 +02:00
jgrusewski
0fe825a8c5 fix(rl): decouple C51 atom span from reward-clamp ceiling
Spec: docs/superpowers/specs/2026-05-30-c51-atom-span-decouple-from-clamp.md

Diagnosed in cluster v5 (alpha-rl-rjsjq SHA 7064c9269) at step 371:
c51_v_max ratcheted up 1 → 24 → 53 → 859 → 1938 while WIN clamp swung
4910 → 568 → 7540 → 44 (volatile from MARGIN saturation). The slow
EWMA tracking win_bound time-averaged the early spikes, locking
atom span at extreme-value magnitude. Δz reached ~184 per atom — Q
could no longer discriminate +$500 from +$5000 wins.

Root cause: atom-span target was anchored on `MARGIN × pos_max_ema`,
where pos_max is the EXTREME-VALUE statistic across b=1024 batch
elements. The clamp bound (rightly fat-tail) and the atom span (should
be typical-magnitude) were treating two distinct concerns as one.

Fix F: anchor atom span on `atom_margin × mean_abs_pnl_ema` — true
central-tendency signal — while leaving the reward clamp on
pos_max_ema. The clamp can still admit fat-tail wins ($42k → +245)
and V regression (scalar head) retains the magnitude for PPO advantage,
but Q's CATEGORICAL distributional support now sizes itself for the
trades where most learning happens.

New ISV slot 685 (RL_C51_ATOM_MARGIN_INDEX, bootstrap 10.0).
RL_SLOTS_END 685 → 686.

Validation (local b=128 1k smoke):
  metric          | pre-Fix-F      | with Fix F
  v_max final      | 1929           | 86.6        (22× tighter)
  Δz per atom      | 184            | 8.7         (resolution restored)
  qpa final        | +0.957         | +0.958      (Q-π alignment preserved)
  mean_active      | +$47k          | +$34k       (within smoke variance)

13/13 risk_stack_invariants + 20/20 trade_management_kernels +
integrated_trainer_smoke pass.

Tradeoff documented in spec: Q loses fat-tail categorical magnitude
(rewards beyond ±87 project to top/bottom atom), but V regression
preserves it. Acceptable for the lottery-ticket strategy where typical-
magnitude resolution matters more than fat-tail magnitude precision.
2026-05-30 23:52:43 +02:00
jgrusewski
1a05af803d fix(cuda): force-close existing position on DD trip / cooldown entry
Cluster v5 alpha-rl-rjsjq step 371 revealed worst-account session_pnl
growing monotonically across cooldown windows (-$3.7k → -$9.3k from
step 100 → 371). Root cause: when DD triggers, actions_to_market_targets
forces {side=2, size=0} — a no-op that suppresses EVERY action type
including trail-stop-driven closes. If the account was holding a
losing position when DD fired, that position bleeds mark-to-market for
the entire 500-step cooldown with no exit path.

Fix: on first DD/cooldown step, emit a closing market order matched to
the current position (side=1 sell for long, side=0 buy for short, size
= |position_lots|). Once flat, subsequent cooldown steps emit the
existing no-op. Net effect: account closes its position at the moment
of DD trip, then sits flat until its recovery clock expires.

Validation: 13/13 risk_stack_invariants pass, 20/20 trade_management
pass, integrated_trainer_smoke passes. Local b=128 1k smoke:
  qpa: +0.95 (best result of session)
  mean_active: +$47k
  worst: stabilizes at -$11.8k (vs unbounded growth pre-fix); residual
  loss is from fat-tail single-step market moves that cross dd_limit
  before the controller can react — out of scope for this fix.
2026-05-30 23:35:54 +02:00
jgrusewski
7064c9269e fix(rl): un-freeze adaptive WIN/LOSS clamp + reset per-batch state on fold
Two compounding bugs found via cluster diag at alpha-rl-gwmkf step 4083:

Fix C — reward-clamp writeback un-freeze:
  rewards.clip_rate_ema = 0.9999 — essentially every win clamped to +1.
  With R-multiple 43 trade outcomes ($7k avg win / $163 avg loss), V
  regression saw identical targets for +$500 wins and +$42k wins; PPO
  advantage lost magnitude signal; policy stagnated at qpa = -0.95.

  Root cause: a 2026-05-24 "G.2" patch in rl_reward_clamp_controller.cu
  added `(void) margin;` and froze WIN=1.0, LOSS=3.0 over concern that
  simultaneously adapting clamp bounds + atom span could create a
  positive feedback loop. Empirically refuted — MARGIN is bounded
  [1.0, 5.0] and pos_max_ema is bounded by reward_scale × raw_PnL, so
  WIN has natural structural ceiling. The freeze was over-conservative.

  Restore the writeback: WIN = max(MIN_WIN, MARGIN × pos_max_ema),
  LOSS = max(MIN_WIN, WIN × RATIO). Atom span Step 5 then follows via
  the slow EWMA already in place.

  Local b=128 1k smoke confirms:
    clip_rate_ema: 0.9999 → 0.094  (target 5%, near hit)
    WIN clamp:     1.0    → 245.6
    V_max atom:    1.0    → 1929
    qpa:           -0.95  → +0.71  (Q and π aligned!)
    mean_active:   +$47k stays positive

Fix D — reset_session_state extends to per-batch buffers:
  The 39efacf77 per-batch CMDP refactor added 4 device buffers but
  reset_session_state() was only zeroing the 4 ISV summary slots. On
  train→eval fold transition, eval inherited all the accumulated
  dead/cooldown accounts from training — eval started with a poisoned
  fleet.

  reset_session_state now also memsets session_pnl_per_batch_d,
  session_dd_triggered_per_batch_d, consec_loss_per_batch_d, and
  cooldown_remaining_per_batch_d to zero. Plus also resets the new
  RL_SESSION_PNL_WORST_INDEX slot.

Validation: 13/13 risk_stack_invariants pass, 20/20 trade_management
pass, integrated_trainer_smoke end-to-end passes.
2026-05-30 23:19:20 +02:00
jgrusewski
5e4c2e62b6 fix(rl): CMDP DD recovery + IQN τ reads mean-of-active (not worst)
Diagnosed via the diag-emit added in 6e0f56816 — alpha-rl-d6d8d step 2000
showed worst-account at -$18k, IQN τ pinned at floor 0.1, popart σ
collapsing 1.00 → 0.47. Root cause: dead-account accumulation in a
sticky-DD fleet.

The per-batch CMDP from 39efacf77 fixed the GLOBAL lockout but left
two failure modes:

  1. Once an account hit `dd_limit`, `session_dd_triggered_per_batch[b]`
     stayed sticky for the fold. `actions_to_market_targets` forced
     those accounts to no-op → V_target ≈ γ·V(s'_unchanged) → popart
     σ leaked variance from accumulating dead-weight.

  2. ISV[RL_SESSION_PNL_USD_INDEX] exposed worst-account pnl, which
     IQN-τ consumes for `drawdown_frac`. A single broken account
     dragged τ to its floor for the entire fold — defensive action
     selection for a fleet that was, on average, doing fine.

Fix A: DD recovery on cooldown expiry. When `dd_limit` trips, also
start a recovery cooldown clock. When the clock decrements to 0,
clear `dd_triggered` + reset `session_pnl` to 0. Account rejoins the
active fleet. Matches surfer trading philosophy: accept the wipeout,
take the forced break, get back on the board.

Fix B: IQN-τ signal split. CMDP now writes mean-of-active-accounts
to RL_SESSION_PNL_USD_INDEX (slot 662, IQN-τ consumer) and mirrors
worst per-batch pnl to a new slot RL_SESSION_PNL_WORST_INDEX (684,
diag only). τ now tracks fleet-typical drawdown instead of a single
catastrophic outlier.

Subtle kernel detail caught by G1: `cool_prev` must be snapshotted
BEFORE the DD-trip section, otherwise a fresh cooldown set this step
gets immediately decremented by 1 in the same launch.

Validation (local b=128 1k smoke):
  metric            | pre-fix (HEAD) | with A+B
  worst pnl          | -$34,016       | -$7,183     (79% less bleed)
  mean active pnl    | n/a            | +$40,343    (typical account profitable)
  IQN τ              | 0.10 (floored) | 0.50        (neutral)
  popart σ           | 0.47→collapsing| 0.84        (variance preserved)
  win rate           | 0.21           | 0.56        (positive edge)

13/13 risk_stack_invariants pass (G1 updated, G1b NEW for cooldown
recovery). 20/20 trade_management_kernels pass. integrated_trainer_smoke
end-to-end passes.

Slot 684 added: RL_SESSION_PNL_WORST_INDEX. RL_SLOTS_END 684 → 685.
2026-05-30 22:40:36 +02:00
jgrusewski
39efacf77d fix(rl): CMDP gates per-batch (one independent session per b)
Each batch element is an independent backtest session with its own
$35k starting capital. The CMDP kernel had been summing per-batch
rewards into a single ISV slot — at b=1024 the accumulator grew
b_size× faster than the -$3500 single-account DD limit, tripping
session_dd_triggered globally in ~250 steps (cluster alpha-rl-2j9k9
step 320: session_pnl_usd=-$5775, all 1024 sessions locked out).
Same flaw on consec_loss_count: any 10 losses across the batch in a
single step triggered cooldown for the entire fleet.

Refactor — every Layer-1 state shard is per-batch:
  session_pnl_per_batch_d            [b_size] f32
  session_dd_triggered_per_batch_d   [b_size] f32
  consec_loss_per_batch_d            [b_size] f32
  cooldown_remaining_per_batch_d     [b_size] f32

`actions_to_market_targets` now reads per-batch DD-triggered + cooldown,
so one account hitting its limit does not lock out the other 1023.

Summary ISV slots (RL_SESSION_PNL_USD, RL_CONSEC_LOSS_COUNT,
RL_SESSION_DD_TRIGGERED, RL_COOLDOWN_REMAINING_STEPS) are now
kernel-OUT only — they expose worst-account / any-triggered / max
aggregates for diag + the single IQN-τ consumer ("be pessimistic
when ANY session is in trouble").

3 raw `actions_to_market_targets_fn.cu_function()` launch sites in
integrated.rs were updated atomically per `feedback_no_partial_refactor`
— public wrapper + 2 internal step_with_lobsim sites. The first
attempt hit CUDA_ERROR_INVALID_VALUE in integrated_trainer_smoke
because only the public wrapper had been updated.

Validation:
- 12/12 risk_stack_invariants pass on per-batch semantics (G1-G4
  rewritten to seed per-batch buffers via write_slice_f32_d_pub).
- 20/20 trade_management_kernels still pass (actions gating intact).
- integrated_trainer_smoke passes end-to-end.
- Local b=128 1k smoke: 1000/1000 steps clean, no NaN. CMDP behavior
  matches design: worst-of-128 hit DD (-$34k > $3500 limit), other 127
  unaffected. IQN τ floored at 0.1 (worst-account drawdown >>10%),
  Kelly = 0.0 (observed edge negative: wr=0.21, R=1.57).
2026-05-30 22:06:22 +02:00
jgrusewski
6e0f568160 diag(rl): emit risk-stack ISVs to JSONL
Added `risk_stack` section to alpha_rl_train.rs diag emitter so the five
risk-management layers introduced in 285d42aa7 are observable from the
diag JSONL. Without this the kernels run but the output is invisible.

  cmdp           session_pnl_usd, session_dd_triggered, consec_loss_*,
                 cooldown_*, max_open_units, net_inventory_limit_usd
  iqn_tau        action_tau, tau_min, dd_sensitivity
  inventory      penalty_beta, variance_ema
  kelly          fraction, win_rate_ema, avg_win/loss_usd_ema,
                 safety_frac, min_trades_for_release, cumulative_dones
  trail_factors  tighten, loosen

Pure additive emission — no kernel changes, no perturbation of training
state. Local 5-step smoke b=16 confirms every field appears with values
that match the spec math (e.g. IQN τ adapts linearly with drawdown).

Doesn't affect the in-flight alpha-rl-2bm59 (pinned SHA 285d42aa7);
takes effect on the next submission.
2026-05-30 21:24:22 +02:00
jgrusewski
285d42aa7b feat(rl): adaptive risk-management stack — 5 layers, all ISV-driven
Layered risk stack per spec docs/superpowers/specs/2026-05-30-adaptive-risk-management-design.md:

  Layer 1 (CMDP)        hard session-DD, cooldown, max-open, inventory limits
  Layer 2 (IQN τ)       risk-averse action selection adapts to session drawdown
  Layer 3 (Inventory)   Avellaneda-Stoikov penalty β scales with reward magnitude
                        and inventory variance
  Layer 4 (Kelly)       half-Kelly fraction sizing from observed win-rate +
                        R-multiple, warmup-gated until cumulative_dones >= 1000
  Layer D (Trail)       wire dead a7/a8 (TrailTighten/Loosen) via independent
                        ISV factors, replacing the symmetric reciprocal

Architecture: every threshold ISV-driven (22 new slots, RL_SLOTS_END 662→684).
Every adaptive bound follows the canonical Wiener-α blend with floor 0.4,
sentinel-zero bootstrap, and asymmetric Schulman where applicable.

Kernels:
  rl_cmdp_constraints_check        session pnl + cooldown + consec-loss tracking
  rl_iqn_action_tau_controller     τ = clamp(0.5 - 5·dd_frac, τ_min, 1.0)
  rl_inventory_beta_controller     β_target = 0.01·E|reward| / (2·σ_inventory)
  rl_kelly_fraction_controller     f = clamp(safety · (p·b - q)/b, 0, 1)
  rl_win_rate_ema_update           closed-trade win-rate EMA from rewards + dones
  rl_avg_win_loss_ema_update       separate avg-win and avg-loss EMAs
  rl_inventory_variance_update     Welford variance of net-position-per-batch

Integration:
- All 7 new cubins loaded in IntegratedTrainer + launched per step in spec order
  (CMDP after reward pipeline, before actions_to_market_targets reads override
  flags; Layer 2/3/4 controllers in rl_fused_controllers.cu).
- actions_to_market_targets.cu: Layer 1 hard overrides (DD-triggered → 0 lots;
  cooldown → 0 lots; max-open → block opening actions; inventory cap → block
  one-sided expansion) and Layer 4 Kelly fraction scaling on target lots.
- rl_fused_reward_pipeline.cu: Layer 3 inventory penalty term in reward shaping.
- rl_trail_mutate.cu: a7 multiplies trail by RL_TRAIL_TIGHTEN_FACTOR_INDEX,
  a8 by RL_TRAIL_LOOSEN_FACTOR_INDEX (was symmetric reciprocal — pearls
  pearl_dead_trail_stop_actions_a7_a8).

Validation:
- 12 GPU-oracle invariants pass (tests/risk_stack_invariants.rs):
  G1-G4 CMDP, G5-G7 IQN τ, G8-G9 inventory β, G10-G12 Kelly.
- 20/20 trade_management_kernels.rs tests pass (a7/a8 migrated).
- 5/5 controller_adaptive_floors.rs tests still pass.
- integrated_trainer_smoke passes (end-to-end pipeline launch).
- Local 1k smoke b=128: completes 1000/1000 steps, no NaN, controllers steady.
- compute-sanitizer memcheck (5 steps b=128): ERROR SUMMARY: 0 errors.

Plan: docs/superpowers/plans/2026-05-30-adaptive-risk-management-plan.md
2026-05-30 20:52:28 +02:00
jgrusewski
448c5189cf fix(cuda): confidence gate honors pos_state (skip non-flat positions)
rl_confidence_gate.cu accepted pos_state as a kernel argument but the
body never dereferenced it. Reading position_lots from bytes [0..4] and
returning early when non-flat makes Case 3 of the existing test
(`confidence_gate_overrides_low_confidence_opening`) pass and restores
the documented opening-only gating semantics.

Repro before fix:
  SQLX_OFFLINE=true cargo test -p ml-alpha --release \
    --test trade_management_kernels \
    confidence_gate_overrides_low_confidence_opening \
    -- --ignored --nocapture
  → FAIL Case 3: non-flat position got Hold instead of original action

After: 20/20 trade_management_kernels tests pass.
2026-05-30 20:39:58 +02:00
jgrusewski
b1ef6664ab fix(rl): reward_scale floor uses cumulative dones, not closed-trade-steps
Follow-up to the 2026-05-30 adaptive controller floor refactor (commit
083a88f7c). Spec Special case R wired the bootstrap-fraction floor to
release after `RL_TRADE_DUR_VAR_COUNT_INDEX < MIN_TRADES_FOR_RELEASE
(=100)`. That counter increments via the Welford trade-duration-EMA
producer ONCE PER STEP where any trade closed — not once per closed
trade. At b=1024 with typical ~7 dones/step, the gate released after
step ~100 (= ~700 actual closed trades, far short of the intended
"100 trades of confidence"). After release reward_scale crashed to
~0.001 within 200 more steps, identical to the pre-fix fold 0/1
behavior the spec was supposed to prevent.

Fix: track REAL cumulative closed trades by summing `dones[b]` per
step in the fused kernel's reward_scale block. New ISV slots:
  - RL_CUMULATIVE_DONES_INDEX (660) — running total
  - RL_MIN_TRADES_FOR_RELEASE_INDEX (661) — gate threshold,
    ISV-driven per `feedback_adaptive_not_tuned`, bootstrap 5000

At ~7 dones/step (typical) the floor now holds for ~715 steps before
release — enough for the reward magnitude EMA to stabilize against
genuine trade outcomes rather than early-training noise. Threshold is
ISV-resident so production runs can tune without recompile.

Verified locally (500-step b=128 smoke):
  step 50:  reward_scale = 0.384 (decaying toward floor)
  step 100: reward_scale = 0.140 (approaching floor)
  step 200: reward_scale = 0.100 (AT FLOOR, held)
  step 500: reward_scale = 0.100 (still at floor)

Without this fix (pre-commit): reward_scale crashed to 0.001 by step 500.

Tests: integrated_trainer_smoke + signal_variance_kernel +
controller_adaptive_floors all pass. Release build clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:38:17 +02:00
jgrusewski
083a88f7c3 feat(rl): adaptive controller floors — 12 controllers, all signal-driven
Replaces hardcoded thresholds AND clamp bounds across 12 RL controllers
with observed-signal-driven ISV-slot bounds. Eliminates the architectural
failure mode that surfaced in walk-forward fold 0 (alpha-rl-m9cx5) and
fold 1 (alpha-rl-jgdh6): under Phase 4.5 advantage normalization, eight
controllers (PPO clip, target_tau, rollout_steps, entropy_coef, per_α,
gamma, reward_scale, q_distill_lambda) saturated at extrema within ~50
steps and stayed pinned for the rest of training — same pattern across
two different data slices, confirming the bug is structural rather than
data-size-dependent.

Mechanism: each controller's noise floor was hardcoded as a small
fraction of its target (`_NOISE_FLOOR_FRAC = 0.01f`), calibrated against
a pre-Phase-4.5 signal regime. Phase 4.5 normalization reduces operating
KL by ~50× — observed signal stays below the 1%-of-target floor, the
Schulman widen path fires continuously (asymmetric in the wrong
direction), and ε hits MAX 0.50 within ~50 training steps. ksll2's full-
data run (n_folds=1) happened to escape via a single above-band KL
observation that triggered tighten; both walk-forward folds (3 and 6
files) did not.

Per `feedback_adaptive_not_tuned`, `feedback_isv_for_adaptive_bounds`,
`pearl_controller_anchors_isv_driven`: every threshold now derives from
observed signal statistics (Welford online variance) rather than
constants calibrated against a prior signal regime. User explicitly
expanded scope mid-implementation: "if all clamps ISV bound should be
added to this spec and tasks" — applying the principle consistently
means hardcoded MIN/MAX clamp bounds count too, not just the saturating
noise floors. 12 controllers, single atomic commit per
`feedback_no_partial_refactor`.

Spec: docs/superpowers/specs/2026-05-30-adaptive-controller-floor-design.md
Plan: docs/superpowers/plans/2026-05-30-adaptive-controller-floor-plan.md

# New kernel
- rl_signal_variance_update.cu (80 LOC) — Welford online variance.
  Single-thread single-block. Sentinel-zero skip per pearl. Per-controller
  Welford triple (count, mean, M²) drives every adaptive noise floor.

# Per-controller refactors (12 .cu files)
- ppo_clip + target_tau + rollout_steps + entropy_coef + per_α —
  adaptive noise floor = max(target × 0.5, sqrt(observed_var) × 2);
  asymmetric Schulman (tighten on single observation, widen requires 3
  consecutive below-band).
- gamma (Special G) — hardcoded GAMMA_MIN = 0.995 → adaptive via
  Welford MEAN of trade duration. Per spec Q6 resolution: the Welford
  mean's natural N-smoothing lag breaks the gamma↔trade_duration
  feedback loop without explicit step-period gating. 100-observation
  warmup falls back to EMA before Welford has enough samples.
- reward_scale (Special R) — asymmetric DECREASE rate cap (5% per
  step) on both bootstrap-replace and Wiener-blend paths; bootstrap-
  fraction floor (10% of bootstrap) until 100 trades close.
- q_distill_lambda (Special Q) — hardcoded MIN_LAMBDA = 0.05 → adaptive
  via Welford on q_distill_kl_ema: max(0.001, std × 0.05).
- v_blend_alpha (Phase 4.4) — 5 hardcoded constants → 5 ISV slots
  (DEAD_SIGNAL_FLOOR, TARGET_TRACK_RATIO, SCHULMAN_STEP, EMA_ALPHA,
  BOOTSTRAP_ALPHA) + adaptive dead-signal floor from V_scalar magnitude
  variance.
- ppo_ratio_clamp — adaptive MIN/MAX from observed log-ratio variance.
  Architectural 2.0 absolute floor preserved (don't degenerate to
  vanilla policy gradient).
- reward_clamp — V_BOUND_FLOOR, V_BOUND_EWMA_ALPHA, MIN_WIN, MIN_RATIO,
  MAX_RATIO, MIN/MAX_MARGIN, MARGIN_TOLERANCE/ADJUST_RATE,
  CLIP_RATE_EMA_ALPHA → 10 ISV slots.
- gate_threshold — hardcoded alpha = 0.01 → ISV slot 638.
- Clamp-bound expansion: EPS_MIN/MAX, TAU_MIN, ROLLOUT_MIN/MAX,
  COEF_MIN/MAX, PER_ALPHA_MIN/MAX, GAMMA_MAX, MAX_LAMBDA, KL_TOLERANCE,
  LAMBDA_RAMP_RATE, LAMBDA_DECAY_RATE → all ISV.
- WIENER_ALPHA_FLOOR shared across 9 controllers → single ISV slot 659.

# Trainer integration (integrated.rs)
- rl_signal_variance_update kernel loaded + helper method
  `launch_rl_signal_variance_update`.
- Per-step Welford launches for 9 controller inputs, placed between
  EMA producers and rl_fused_controllers in the per-step pipeline.
- Input-slot lookup array for rl_fused_controllers updated: rollout_steps
  now consumes RL_ADV_VAR_PRE_NORM_INDEX (emitted by
  rl_advantage_normalize before in-place normalize) instead of the
  post-norm advantage_var_ratio (definitionally ~0 under Phase 4.5).
- ~30 new ISV bootstrap entries in with_controllers_bootstrapped.

# ISV slot allocation (isv_slots.rs)
- 72 new slots, RL_SLOTS_END 588 → 660. +288 bytes mapped-pinned.
- 9 Welford variance triples + 5 asymmetric Schulman counters +
  3 new input signals (var_pre_norm, gamma_min_adaptive, reward_mag) +
  v_blend (5 + 3 Welford) + ppo_ratio_clamp (1 + 3 Welford) +
  reward_clamp (5) + gate_threshold (1) + q_distill (1) + 20 clamp bounds +
  shared wiener floor.

# Bootstrap-clamp consistency fix (caught by Task 16 testing)
The original draft bootstrapped RL_EPS_BOOTSTRAP at 0.01 (= KL target),
but EPS_MIN was 0.05 — bootstrap value below clamp range. First post-
bootstrap step always snapped ε to MIN regardless of signal direction
("snap to MIN" behavior the unit tests surfaced). Fixed by bootstrapping
ε at MIN (0.05) so asymmetric Schulman operates from a valid state.

# Validation
- cargo build --release: clean
- cargo build --tests: clean
- 3 GPU Welford kernel tests (G1 constant→0var, G2 sequence→known var,
  G3 sentinel skip): all pass
- 5 GPU adaptive-floor invariant tests (G3 PPO clip holds at bootstrap
  when signal below floor, G4 tightens on single above-band, G5 widens
  only after 3 consecutive below-band, G6 post-warmup uses Welford mean,
  G6 pre-warmup uses EMA): all pass
- integrated_trainer_smoke (full GPU pipeline): passes
- 1k local smoke at b=128: 1000/1000 steps, completed_clean, no NaN,
  controllers genuinely adapting (γ 0.90→0.974, per_α 0.40→0.52,
  q_distill_λ 0.05→0.21, ε held at 0.05 = MIN per Phase 4.5 small-KL
  regime — was previously stuck at MAX 0.50 throughout fold 0/1)
- compute-sanitizer memcheck b=128 5 steps: 0 errors

Cluster validation (G7-G9) submitted as follow-up runs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:41:51 +02:00
jgrusewski
a3dfcd63f5 test(ml-alpha): migrate integration tests to post-Phase-4 trainer API
After the Phase 4 dueling-head merge landed on main, six integration
tests no longer compiled — they referenced trainer fields and methods
that were renamed or removed during the R-series refactor:

  isv_d (CudaSlice<f32>)              → isv_dev_ptr: u64 (raw, cached)
  isv_host (Vec<f32>)                 → isv_mapped: MappedF32Buffer
  launch_rl_controllers_per_step()    → launch_rl_fused_controllers()
  softmax_ce_grad(..&mut CudaSlice)   → softmax_ce_grad(..&u64)
  trainer.replay (Vec-based PER)      → gpu_replay (CUDA buffers)
  N_HORIZONS = 5                      → N_HORIZONS = 3

Release binary built clean throughout (the cluster doesn't pull in
test sources), so the breakage was invisible until `cargo test --tests`
surfaced it post-merge.

Per `feedback_no_partial_refactor`: when a contract changes, every
consumer migrates atomically — the test suite was left behind by
those R-series PRs, this commit closes the gap.

Per `feedback_no_htod_htoh_only_mapped_pinned`: tests now use the
same mapped-pinned ISV view as production (zero-copy host reads via
`isv_host_slice()` / `read_isv_host(slot)`, single-slot writes via
`isv_mapped.write_record(slot, val)`).

Changes per file:

  isv_bootstrap.rs (1 site)
    Read full ISV via `trainer.isv_host_slice()` instead of dtoh
    of the now-removed `isv_d` CudaSlice. Sync producing stream
    first so bootstrap-controller writes are visible host-side.

  r3_ema_advantage.rs (5 sites)
    Rewrote `readback_isv` helper to take `&IntegratedTrainer`
    and use the mapped-pinned mirror. All 5 call sites simplified
    from `readback_isv(&dev, &trainer.isv_d)` to `readback_isv(&trainer)`.

  r5_controllers_and_soft_update.rs
    Deleted G3 (`launch_rl_controllers_per_step` no longer exists;
    `launch_rl_fused_controllers` is the architectural replacement
    with different setup requirements — its 'all controllers move
    slots' invariant is exercised end-to-end by every cluster run).
    Kept G4 (DqnHead soft-update Polyak formula) with updated API.

  trade_management_kernels.rs (3 sites)
    `set_isv_slot` helper now uses `isv_mapped.write_record(slot, val)`
    — single volatile write to mapped-pinned, GPU sees it after next
    sync, no explicit HtoD copy needed.

  frd_head.rs (11 sites incl. ce_total_loss helper)
    Added `alloc_loss_buf(n) -> MappedF32Buffer` helper. All callers
    of `FrdHead::softmax_ce_grad` now pass `&loss_buf.dev_ptr`
    (raw u64) instead of `&mut loss_d` (CudaSlice), and read results
    via `stream.synchronize()?; loss_buf.read_all()`.

  heads_bit_equiv.rs (per_head_independence)
    N_HORIZONS dropped from 5 to 3 in production. Test was hardcoded
    against the old count (probs[3], probs[4], 5-element bias vec)
    causing compile-time index-out-of-bounds. Per
    `feedback_use_consts_not_literals_for_structural_dims`: rewrote
    to address by N_HORIZONS-relative offsets (first / last / middle).

  r7d_per_wiring.rs (deleted)
    The old Rust-side `PrioritizedReplay` struct (R7c's
    `src/rl/replay.rs`) was removed when the PER buffer moved fully
    GPU-side as `gpu_replay: GpuReplayBuffer`. The test was a guard
    against re-introducing that dead Rust struct; the dead file no
    longer exists in the tree (verified `crates/ml-alpha/src/rl/replay.rs`
    is gone), so the guard is moot. The new buffer's correctness is
    exercised end-to-end by every cluster training run.

Validation:
  - cargo build -p ml-alpha --release: clean
  - cargo build -p ml-alpha --tests:    clean (all files compile)
  - integrated_trainer_smoke (GPU, --ignored): passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:52:36 +02:00
jgrusewski
6695785666 feat(rl): Phase 4.5 — per-batch advantage normalization
Standard PPO practice (Schulman et al. 2017): normalize advantages
per-batch BEFORE PPO surrogate computation:

    advantage[b] ← (advantage[b] − mean_b) / sqrt(var_b + ε²)

Self-adaptive — uses observed per-batch statistics, no tuned
hyperparameters (fits feedback_adaptive_not_tuned).

Why now: Phase 4.3 cluster (alpha-rl-qkdm2) ended with pnl=+$16.28M
(2x Plan A v2's +$8.5M) but l_pi=1.6e9 vs Plan A v2's 3.6e5 — a
4500× increase in PPO surrogate magnitude. Adam internally normalizes
the gradient direction, but per-step magnitude variance is high
→ pnl trajectory chops (saw ±$2-4M swings between milestones).

Advantage normalization fixes this regardless of V baseline source:
batches with high-magnitude advantages get scaled down to ~unit
variance, ensuring consistent PPO update strength across batches.
Standard in Stable-Baselines3, RLlib, OpenAI Baselines.

New kernel: rl_advantage_normalize.cu (~80 LOC).
  Grid=(1,1,1), block=(min(B,1024),1,1).
  Single block parallel reduction: pass 1 = mean, pass 2 = variance,
  pass 3 = normalize in-place. ε² floor on variance prevents
  div-by-zero when all advantages identical.

Trainer wiring (~30 LOC):
  Load kernel + handle field + struct init.
  Single launch immediately AFTER compute_advantage_return,
  BEFORE PPO surrogate consumes advantages_d. In-place.

Stacks with Phase 4.4 adaptive V blend on same branch
(ml-alpha-phase4-dueling-head). Two complementary stabilization
mechanisms:
  - Phase 4.4: adapts WHICH V baseline (scalar vs dq) to use based
    on observed tracking error → reduces variance source
  - Phase 4.5: normalizes advantages regardless of variance source
    → reduces variance impact

Validated:
  - cargo build --release clean
  - integrated_trainer_smoke 1 step passes
  - alpha_rl_train --steps 3 --b 128 under compute-sanitizer: 0 errors

Cluster validation pending — submit Phase 4.4+4.5 combined to test
whether dampened-variance preserves Phase 4.3's +$16M pnl gain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:22:24 +02:00
jgrusewski
12635bd708 feat(rl): Phase 4.4 — ISV-adaptive V blend controller
Replaces Phase 4.3's hard V_dq → PPO swap with an adaptive blend
driven by an on-device controller. Per the project's no-tuning
philosophy (pearl_controller_anchors_isv_driven, feedback_adaptive_not_tuned):

    V_used[b] = α × V_scalar[b] + (1 − α) × V_dq[b]

where α ∈ [0, 1] is emitted by rl_v_blend_alpha_controller from
the observed V_dq vs V_scalar tracking ratio:

    track_ratio = EMA(|V_dq − V_scalar|) / EMA(|V_scalar|)

    if track_ratio > 1.5 × TARGET:   α ← min(α + 0.01, 1.0)
    if track_ratio < TARGET / 1.5:   α ← max(α - 0.01, 0.0)
    else:                            hold α

Plus dead-signal guard: if EMA(|V_scalar|) < 1e-4, hold α (no V
signal yet to calibrate against).

Bootstrap on sentinel 0: α = 1.0 (Plan A v2 behavior on first step).
EMAs first-observation bootstrap (no Wiener-α blend on first sample).

Two new kernels:
  - rl_v_blend.cu: elementwise blend (~25 LOC). Grid (ceil(B/256),1,1).
  - rl_v_blend_alpha_controller.cu: single-block parallel reduction
    + Schulman-bounded controller (~90 LOC). Grid (1,1,1), block (1024,1,1).

Three new ISV slots (585/586/587):
  - RL_V_BLEND_ALPHA_INDEX        — current α
  - RL_V_TRACK_ERR_EMA_INDEX      — EMA(|V_dq − V_scalar|)
  - RL_V_SCALAR_MAG_EMA_INDEX     — EMA(|V_scalar|), dead-signal floor

IntegratedTrainer wiring (~80 LOC):
  - 2 new buffers v_blended_d, v_blended_tp1_d
  - In step_with_lobsim_gpu_body, after DuelingQHead Adam steps:
    1. Launch controller (reads V_scalar at h_t + V_dq at h_t, emits α)
    2. Launch blend kernel for s_t   → v_blended_d
    3. Launch blend kernel for s_tp1 → v_blended_tp1_d
  - compute_advantage_return now reads v_blended_d / v_blended_tp1_d
    instead of dueling_v_d / dueling_v_tp1_d (Phase 4.3's direct swap)

Both value_head and DuelingQHead still train independently. The blend
just selects which baseline drives PPO advantage per step based on
observed calibration. As V_dq learns to track V_scalar, the controller
gradually shifts α down toward V_dq usage. If V_dq diverges (e.g., late
training entropy spikes producing volatile advantages), controller
raises α back to V_scalar safety.

Phase 4.3 cluster (alpha-rl-qkdm2 @ 25f5ce99b) is still running and
showing dramatic late-training pnl growth (+$20M at step 18132 vs
Plan A v2 peak +$9.3M). Phase 4.4 adds adaptive control on top —
should reduce variance while preserving the architectural benefit.

Validated:
  - cargo build --release clean
  - integrated_trainer_smoke 1 step passes
  - alpha_rl_train --steps 3 --b 128 under compute-sanitizer: 0 errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:09:32 +02:00
jgrusewski
25f5ce99b6 feat(rl): Phase 4.3 — V_dq → PPO advantage swap + target net soft-update
Activates DuelingQHead's V output as the PPO advantage baseline,
replacing scalar value_head's contribution. Also wires soft-update
of DuelingQHead's target net (was deferred from 4.2).

Two changes:

1. DuelingQHead.soft_update_target(): reuses dqn_target_soft_update
   kernel (generic element-wise blend with τ from ISV[401]) across
   all 4 weight tensors (w_v, b_v, w_a, b_a). Called once per training
   step after Adam, mirroring DQN's pattern.

2. compute_advantage_return call: v_pred_d → dueling_v_d,
   v_pred_tp1_d → dueling_v_tp1_d. PPO advantage is now:
       A(s_t, a_t) = E_Q_C51(s_t, a_taken) − V_dq(s_t)
       returns(s_t) = r_t + γ(1−done) × V_dq(s_{t+1})
   value_head still trains via MSE on returns for diagnostic
   comparison; can be retired in a future commit if V_dq proves
   itself.

Phase 4.2 validated structurally at b=1024 5k that DuelingQHead's
training does not perturb Plan A v2's dynamics (qpa tracked within
0.02 at every milestone). Phase 4.3 is the smallest possible commit
that USES V_dq downstream — fully de-risked by 4.2's structural test.

Cluster expectation: qpa trajectory roughly matches Plan A v2 (V_dq
calibrated by joint training on same Bellman reward signal as C51,
so cross-architecture scale issue from Phase 3.2 doesn't apply here).

Validated:
  - cargo build --release clean
  - integrated_trainer_smoke 1 step passes
  - alpha_rl_train --steps 3 --b 128 under compute-sanitizer: 0 errors,
    l_q=0.024, l_v=0.0003 (slightly higher than Plan A v2's 0.0001
    because V_scalar now sees different gradient prop now that V_dq
    drives advantage — still healthy)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:14:46 +02:00
jgrusewski
acdafe508e feat(rl): Phase 4.2 — DuelingQHead trainer integration (diagnostic mode)
Implements §6 of the Phase 4 spec — wires DuelingQHead into
IntegratedTrainer in DIAGNOSTIC-ONLY mode (V_dq trains via Bellman
loss every step, but does NOT yet feed PPO advantage — that's
Phase 4.3, a 10-LOC follow-up commit).

IntegratedTrainer additions:
  - dueling_q_head: DuelingQHead field
  - 4 AdamW states (w_v, b_v, w_a, b_a) — LR from RL_LR_Q_INDEX
  - 19 per-step buffer fields:
    Forward outputs:
      dueling_v_d [B], dueling_v_tp1_d [B], dueling_a_d [B×N],
      dueling_q_composed_d [B×N]
    Target net forward scratch:
      dueling_q_target_composed_d [B×N], dueling_v_target_tp1_d [B],
      dueling_a_target_tp1_d [B×N]
    Loss path scratch:
      dueling_v_loss_d [B], dueling_a_loss_d [B×N],
      dueling_target_value_d [B], dueling_loss_pb_d [B],
      dueling_grad_composed_d [B×N]
    Per-batch weight grads:
      dueling_grad_w_v_pb_d [B×HIDDEN_DIM], dueling_grad_b_v_pb_d [B],
      dueling_grad_w_a_pb_d [B×HIDDEN_DIM×N], dueling_grad_b_a_pb_d [B×N]
    Reduced weight grads:
      dueling_grad_w_v_d [HIDDEN_DIM], dueling_grad_b_v_d [1],
      dueling_grad_w_a_d [HIDDEN_DIM×N], dueling_grad_b_a_d [N]

10-step launch sequence in step_with_lobsim_gpu_body (after existing
IQN forward block):

  1. dueling_q_head.forward(h_t_borrow)            → V_dq, A, composed_Q
  2. dueling_q_head.forward(h_tp1_d)               → V_dq_tp1
  3. dueling_q_head.forward(sampled_h_t_d)         → online composed_Q for loss
  4. dueling_q_head.forward_target(sampled_h_tp1_d) → target composed_Q
  5. build_bellman_target(target_composed, r, dones, γ^n) → target_value
  6. compute_loss_and_grad(online, target, actions)    → loss + grad_composed
  7. decompose_and_backward_to_weights(sampled_h_t, grad, actions)
                                                       → per-batch w/b grads
  8. reduce_axis0_free × 4 → final weight grads
  9. LR ← ISV[RL_LR_Q_INDEX] (DuelingQHead is a Q learner)
 10. Adam step × 4 (w_v, b_v, w_a, b_a)

What's NOT yet done (Phase 4.3):
  - compute_advantage_return STILL uses v_pred_d / v_pred_tp1_d
    (Plan A v2 path UNTOUCHED — qpa should stay healthy in cluster smoke)
  - Soft-update of target net weights (recommend reusing
    dqn_target_soft_update_fn kernel in Phase 4.3)
  - Diag JSONL fields for dueling_v_at_taken_ema, dueling_loss

Validated:
  - cargo build --release clean
  - integrated_trainer_smoke (1 step end-to-end) passes
  - alpha_rl_train --steps 3 --n-backtests 128 under compute-sanitizer
    memcheck: 0 errors, l_q rising 0 → 0.024, l_v = 0.0001

CRITICAL: This commit should be cluster-validated BEFORE Phase 4.3.
At b=1024 5k steps, expected:
  - qpa stays healthy (composed_Q_dq doesn't feed ensemble)
  - pnl matches Plan A v2 trajectory exactly (V_dq doesn't drive PPO)
  - V_dq trains stably (visible in compute-sanitizer's grad flow)

If cluster shows any regression, the bug is in this commit's wiring,
NOT in Plan A v2 (which is structurally preserved).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:48:35 +02:00
jgrusewski
13bf277cd6 feat(rl): Phase 4.1 — DuelingQHead loss + Bellman target + decompose backward
Implements §4.2/4.3/4.4 + §5 of Phase 4 spec.

Three new CUDA kernels:

1. rl_dueling_q_bellman_target.cu — argmax over target composed_Q at
   s_{t+1}, build target_value = r + γ^n × (1-done) × max_Q. Grid
   (B,1,1), block (N_ACTIONS,1,1). Reads γ via per-sample n_step_gammas
   passed by trainer (matches PER convention).

2. rl_dueling_q_loss_and_grad.cu — scalar Huber loss on
   (target − online_composed_Q[taken]). Emits per-batch loss and
   grad_composed (only taken action has nonzero gradient — this is
   single-Q scalar regression, not distributional CE). Grid (B,1,1),
   block (N_ACTIONS,1,1). Threads write zero into non-taken cells.

3. rl_dueling_q_decompose_and_bwd.cu — decompose grad_composed →
   grad_V + grad_A via mean-subtraction Jacobian:
     grad_V[b]    = Σ_a grad_composed[b, a] = grad_composed[b, a_taken]
     grad_A[b, a] = grad_composed[b, a] − (1/N) × grad_V[b]
   Then computes per-batch weight gradients via outer product with h_t:
     grad_w_v_pb[b, c]    = grad_V[b] × h_t[b, c]
     grad_b_v_pb[b]       = grad_V[b]
     grad_w_a_pb[b, c, a] = grad_A[b, a] × h_t[b, c]
     grad_b_a_pb[b, a]    = grad_A[b, a]
   Grid (B,1,1), block (HIDDEN_DIM,1,1). Thread 0 also writes biases.

DuelingQHead Rust API (3 new methods):
  - build_bellman_target(target_composed_q, r, dones, n_step_gammas, B, out)
  - compute_loss_and_grad(online_composed_q, target_value, actions, B,
                          loss_pb, grad_composed_out)
  - decompose_and_backward_to_weights(h_t, grad_composed, actions, B,
                                      grad_w_v_pb, grad_b_v_pb,
                                      grad_w_a_pb, grad_b_a_pb)

Caller (trainer) is responsible for reduce_axis0 of per-batch grads
→ final weight gradients [HIDDEN_DIM], [1], [HIDDEN_DIM × N_ACTIONS],
[N_ACTIONS]. Will be wired in Phase 4.2.

Soft-update of target weights still TODO — recommend reusing
dqn_target_soft_update kernel pattern in Phase 4.2.

Validated:
  - cargo build --release clean
  - integrated_trainer_smoke (1 step) passes

Per pearl_complement_internal_loss_vs_external_consumers: this loss
path is fully internal to DuelingQHead. No downstream consumer ever
sees composed_Q or grad_composed. The four prior session failure
modes are structurally impossible.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:33:18 +02:00
jgrusewski
af35bc778e feat(rl): Phase 4.0 — DuelingQHead forward kernel + struct skeleton
Implements §4.1 + §5 of the Phase 4 spec
(docs/superpowers/specs/2026-05-30-phase4-independent-dueling-head-design.md).

Forward kernel rl_dueling_q_forward.cu:
  V[b]             = Σ_c w_v[c] × h_t[b, c] + b_v[0]
  A[b, a]          = Σ_c w_a[c, a] × h_t[b, c] + b_a[a]
  composed_Q[b, a] = V[b] + A[b, a] − (1/N) Σ_a' A[b, a']

Single fused kernel — three outputs (V, A, composed_Q) computed in one
pass with shared-mem cache of h_t row. Grid (B,1,1), block (HIDDEN_DIM,1,1),
smem HIDDEN_DIM × 4 bytes. Tree-reduce for V projection; per-action
matmul (threads 0..N-1 active); mean-over-actions in shared mem.

DuelingQHead struct (crates/ml-alpha/src/rl/dueling_q.rs, NEW FILE):
  - Config + new() with Xavier init (HIDDEN_DIM → 1 for V,
    HIDDEN_DIM → N_ACTIONS for A); seed 0x4DEAD_1234
  - Online weights: w_v_d, b_v_d, w_a_d, b_a_d
  - Target weights: mirrors of online (soft-update wiring in Phase 4.1)
  - forward(h_t, b_size, v_out, a_out, q_composed_out)
  - forward_target(...) — same kernel with target weights
  - forward_inner — parameterized over weight slices

What this is NOT yet:
  - No loss kernel (Phase 4.1)
  - No backward / decompose (Phase 4.1)
  - No trainer wiring (Phase 4.2)
  - No PPO advantage swap (Phase 4.3)

Built off Plan A v2 baseline fd3174262 on branch
ml-alpha-phase4-dueling-head. Build verified clean.

Per session pearls:
  - pearl_complement_dont_replace_with_dual_architecture: this IS the
    'add a complement' pattern, but in fully encapsulated form (zero
    shared state with downstream consumers, unlike Phase 3.x attempts).
  - pearl_complement_internal_loss_vs_external_consumers: composed_Q
    from this head NEVER feeds ensemble/distill/selection (the failure
    mode that broke Phase 3.x is structurally impossible here).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:26:00 +02:00