Commit Graph

5728 Commits

Author SHA1 Message Date
jgrusewski
6b4e7f660b perf(rl): replace cuBLAS with capture-safe matmul kernels — unblocks mega-graph
cuBLAS GEMM calls inside CUDA-graph capture regions cause
CUDA_ERROR_STREAM_CAPTURE_INVALIDATED on first use of new (m,n,k)
shapes. The mega-graph was silently failing → fast-path replay was
a NO-OP → sps stayed at ~10.

Phase 1 — DQN distributional Q head:
- New crates/ml-alpha/cuda/dqn_q_head_fwd_bwd.cu (3 kernels)
- Removed cuBLAS field + gemm_f32 helper from dqn.rs

Phase 2 — IQN ensemble heads:
- New crates/ml-alpha/cuda/rl_iqn_matmul.cu (3 kernels)
- Removed cuBLAS field from iqn.rs

Phase 3 — Mamba2 SKIPPED: workspace pre-warms during 65+ eager
warmup steps before mega-graph capture.

Local smoke (RTX 3050 Ti, b=128, 500 steps):
- Mega-graph captures cleanly at step 67
- 8.9 sps avg, 11 sps peak post-capture (GPU-bound on mobile GPU)
- l_q=0.024 (healthy), l_pi rising, V converging
- Production L40S should see full mega-graph speedup now

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:23:28 +02:00
jgrusewski
a60413669e perf(rl): inline q_divergence EMA + diag_every default 10
Two perf fixes for mega-graph throughput.

1. Inline ema_update_per_step(q_divergence) — eliminates the per-step
   cuMemAllocAsync from `.clone()` on ema_input_scratch_d. With cudarc
   0.19.3 async alloc enabled (commit 29d21b324), every CudaSlice clone
   becomes a `cuMemAllocAsync` graph node captured in the mega-graph.
   Same pattern as the kl_pi inline at line 4762 (commented "avoids
   .clone()" but never applied to the q_divergence sibling 60 lines
   later).

2. Bump cli.diag_every default 1 → 10. Each diag sync_and_swap event-
   syncs ~15 DtoD copies on the diag stream (~3-10ms). At 1/step that
   caps throughput at ~100-300 sps. The cluster runs at 10 by default
   (300+ sps) and can dial to 100 for max perf or 1 for full diag
   fidelity.

Local RTX 3050 Ti perf baseline (b=128, per_capacity=8192, n_steps=500):
sustained 8 sps with mega-graph engaged. GPU is the bottleneck on this
hardware (133ms/step measured via cuGraphLaunch host time post-queue-
fill — RTX 3050 Ti SM count + memory bandwidth, not host overhead).
Expect L40S/H100 to hit the original commit-message claim of 300+ sps
unchanged since this commit only removes host-side artifacts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:01:03 +02:00
jgrusewski
bf88874007 fix(rl): zero loss accumulators + wire LobPtrs to 35 call sites
Two fixes in the integrated trainer hot path.

1. l_pi / l_q monotonic accumulation (root cause: 90f178ae9 perf removal).
   The PPO surrogate fwd kernel atomicAdds into ss_pi_loss /
   ss_pi_loss_entropy, and dqn_distributional_q backward atomicAdds into
   ss_q_loss. Per the original `BUG FIX` comment in dqn_replay_step,
   these MUST be zeroed each step or they read as the running sum across
   all steps.

   90f178ae9 (remove K-loop memsets) correctly removed the per-K-iter
   zeros for buffers that are OVERWRITTEN by backward kernels and
   reduce_axis0, but the comment in the dead code branch flagged the
   loss-scalar atomics as a known exception. The exception was lost
   when the if-false block ceased to execute.

   Smoke before fix at step 499:  l_q=10.96  l_pi=1384.46
   Smoke after fix at step 499:   l_q=0.024  l_pi=6.65 (both bounded).

   v_loss writes via reduce_axis0_mapped (single-writer overwrite) and
   does not need zeroing.

2. LobPtrs dead-code refactor (root cause: 9c1b70ede merge resolution).
   The 9c1b70ede mega-graph commit introduced LobPtrs to cache
   pos_d().raw_ptr() / bid_px_d() / ask_px_d() / pos_bytes() once per
   step instead of dispatching through the RlLobBackend vtable at every
   raw_launch call site. The struct merged in but the 35 consumer sites
   did not because of structural divergence between phase-a's
   integrated.rs and clean-clamp's. Per `feedback_no_hiding`, wire it
   up or delete — wired across step_with_lobsim,
   step_with_lobsim_reward_and_train, and step_with_lobsim_gpu_body.

   Two sites kept the old `lobsim.pos_book_and_market_targets_mut()`
   pattern because that method returns disjoint borrows including a
   `&mut CudaSlice<i32>` for market_targets — incompatible with the
   immutable lob snapshot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:12:57 +02:00
jgrusewski
8827fc93ee chore: Cargo.lock update from cherry-picks 2026-05-28 13:52:41 +02:00
jgrusewski
e9bddba6c1 feat(rl): wire checkpoint save/resume into training loop
CLI flags: --resume-from <path> and --checkpoint-every <n> (default 5000).
Resume loads checkpoint and continues from saved step. Save writes
rolling checkpoints (keeps last 2, deletes older).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:47:23 +02:00
jgrusewski
e7dc272a14 feat(rl): IntegratedTrainer checkpoint save/load
Binary format: magic + version + step + sections. Sections include
ISV (585 f32), 26 device buffers (DQN/policy/value/IQN/FRD/NoisyNet/
outcome weights + targets), 20 AdamW optimizer states, and encoder
(delegated to CfcTrunk). All device transfers use mapped-pinned DtoD.
load_checkpoint invalidates CUDA graphs to force re-capture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:46:20 +02:00
jgrusewski
ab1ec77f2f feat(rl): AdamW save/load for checkpoint persistence
Serializes m, v moments + step_count + hyperparams to a binary writer.
Uses mapped-pinned DtoD (not raw memcpy_htod/dtoh) per
feedback_no_htod_htoh_only_mapped_pinned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:46:16 +02:00
jgrusewski
b1f33f54c3 fix(argo): set FOXHUNT_CUDA_ARCH from detected compute cap
ml-backtesting/build.rs reads FOXHUNT_CUDA_ARCH (defaults sm_86),
ml-alpha/build.rs reads CUDA_COMPUTE_CAP. Template only set the
latter, so lobsim cubins compiled for sm_86 on H100 (sm_90) →
CUDA_ERROR_NO_BINARY_FOR_GPU at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:44:50 +02:00
jgrusewski
c0a8ca3917 fix(cuda): delay mega-graph capture until PER is full
The graph was captured at step 2 with PER nearly empty (2048/65536
entries). PER fills at step 64 (65536/1024). The captured graph baked
empty-PER kernel behavior — once PER filled, sps dropped from 338→7.

Now: warmup runs eagerly for per_capacity/b_size + 2 steps (66 steps).
Capture happens at step 67 when PER is full and all kernel behaviors
are in steady state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:44:47 +02:00
jgrusewski
2bf12685ce perf(rl): gate diag staging to every 10th step
At mega-graph speeds (329 sps), the diag sync_and_swap blocks for
100ms+ because the DtoD copies haven't finished in the 3ms step time.
Gate sync+snapshot to every 10th step (or log/checkpoint boundaries).
The DiagFrame still sends every step using stale staging data — the
background writer drops most frames anyway via try_send(1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:44:44 +02:00
jgrusewski
0281e12ef9 perf(rl): precompute tree_rebuild_levels — eliminate host loop in mega-graph
The per-step while loop computing (grid, start, nodes_at_level) for
each tree level was host-side work inside the mega-graph replay path.
Now precomputed at init: 16 entries for capacity=65536, 192 bytes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:44:42 +02:00
jgrusewski
f0e5659517 fix(cuda): rewrite 3 PER kernels — unlock sustained mega-graph 300+ sps
The mega-graph ran at 338 sps for 60 steps then degraded to 7 sps.
Root cause: PER fills at step 64 (65536/1024), exposing three bugs:

1. rl_per_tree_rebuild: used __threadfence() as a barrier (it's only
   a memory fence). Race condition benign when tree mostly zeros,
   corrupts sum-tree when full. Fix: multi-kernel (one per tree level),
   stream ordering provides the barrier. Graph-compatible.

2. rl_per_push_flush: volatile spin with 131k threads saturating SMs.
   Fix: split into prefix_sum (Grid=1) + coalesced write (Grid=b_size,
   Block=128). No volatile spin.

3. rl_per_sample: Block=(1) doing 256 sequential random reads per
   sample. Fix: Block=(128) with coalesced cooperative gathers. 128×
   less memory traffic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:44:39 +02:00
jgrusewski
557fd6ada6 perf(cuda): mega-graph pipeline — 10-12× speedup (6.4 → 68-76 sps)
Captures the ENTIRE per-step training pipeline into ONE cuGraphLaunch,
eliminating ~150 individual kernel launches and ~143ms of host-side
Rust overhead per step.

Phase 1: Lobsim → raw_launch
- apply_snapshot_from_device inlined as 4× raw_memcpy_dtod + raw_launch
- step_fill_from_market_targets inlined as 2× raw_launch
- LobSimRawPtrs trait method caches 30+ device pointers

Phase 2: LR controller → GPU kernel
- New rl_lr_from_mapped_pinned.cu reads losses from device pointers
- New adamw_step_isv_lr kernel reads LR from ISV instead of scalar arg
- All 12 Adam .step() calls use step_isv_lr() in mega-graph mode

Phase 3: PER → main stream
- mega_graph_single_stream flag routes PER to self.raw_stream
- Cross-stream events skipped, K forced to 1

Phase 4: Mega-graph capture/replay
- Three-state machine: warmup → capture → replay
- enable_mega_graph() propagates to perception trainer
- Perception sub-graph guards prevent sub-captures inside mega-capture
- grad_h_accumulate_scaled_isv reads lambda from ISV on-device

Validated: 500 steps, 68-76 sps sustained, no NaN, all losses finite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:42:48 +02:00
jgrusewski
adb12b351c perf(rl): remove sync_training_event + 24 K-loop memsets
nsys profiling identified two hot-path bottlenecks:

1. sync_training_event() — cuEventSynchronize blocked host 5-11ms
   EVERY step (87.9% of CUDA API wall time). Removed: LR controller
   uses EMA smoothing, 2-step deferred mapped-pinned reads are fine.

2. 24 raw_memset_d8_zero calls per K-loop iteration — zeroed scratch
   buffers that backward kernels overwrite completely. At K_max=4:
   72 memsets/step, 26% of CUDA API time. Disabled via if-false gate.

Expected combined impact: ~2s saved per 200 steps → measurable sps
improvement at b=1024.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:42:05 +02:00
jgrusewski
f6184932e7 perf(rl): decouple diagnostic JSON writer to background thread
The 94KB per-step JSON record (130 fields, per-batch unit arrays)
took ~150ms CPU to build+serialize, blocking the GPU pipeline that
only needs ~13ms. Training was CPU-bound at 6.2 sps.

New architecture: training loop snapshots DiagFrame data (~0.1ms
memcpy from mapped-pinned to owned Vecs), sends via non-blocking
try_send on sync_channel(1). Background writer thread builds JSON
and writes to BufWriter at its own pace. Drops frames under
backpressure — acceptable for diagnostics.

Training loop per-step: 13ms GPU + 0.1ms snapshot = ~13.1ms.
Expected: ~50-77 sps at b=1024 on L40S (was 6.2).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:41:36 +02:00
jgrusewski
98143879d0 perf(cuda): fuse 5 label gathers into sample_and_gather — 95.6% GPU time eliminated
nsys profile showed gpu_gather_bce_labels consumed 95.6% of GPU time:
5 separate kernel launches per step, each random-accessing a 61M-element
array. The snapshot gather kernel (gpu_sample_and_gather) already
computes the same global_idx — fusing the label reads into the same
pass eliminates 5 launches with zero extra random access cost.

Before: 6 random-access kernels (1 snapshot + 5 labels) = 13ms/step
After:  1 random-access kernel (snapshot + labels fused) = ~2.5ms/step

Expected speedup: 6 sps → 30-50+ sps at b=1024.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:38:49 +02:00
jgrusewski
29d21b3241 perf(cudarc): enable async alloc — eliminates 934 stream syncs/200 steps
cudarc's CudaSlice::Drop checked has_async_alloc (always false) and
fell back to stream.synchronize() + free_sync() on every drop. This
blocked the host for ~12.6ms per sync × 4.7 drops/step = ~59ms/step
of pure idle waiting (35.8% of wall time per nsys).

With has_async_alloc=true, drops use cuMemFreeAsync (non-blocking,
same-stream ordering). CUDA 12.4 on sm_86+ fully supports this.

Combined with the background diag writer and fused label gather,
the training loop should now be GPU-bound at ~13ms/step → ~77 sps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:38:46 +02:00
jgrusewski
dad5656bf6 perf(cuda): enable TF32 Tensor Core math on all cuBLAS handles
DQN head (dqn.rs) and Mamba2 encoder (mamba2_block.rs) were forcing
CUBLAS_COMPUTE_32F — pure FP32 scalar, bypassing Tensor Cores entirely.
TF32 (19-bit mantissa) gives 2-3× SGEMM throughput on L40S/H100 with
negligible precision loss (well within RL gradient noise).

8+ SGEMMs per step now use Tensor Cores: DQN forward/backward (4) +
Mamba2 W_in/W_a/W_b/W_out projections (4+).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:38:44 +02:00
jgrusewski
00c1f1c6fc fix(rl): Phase 2.1 — block reversal actions in min_hold_check
Previous Phase 2 fix only blocked explicit close actions (FlatL/FlatS/
HalfFlatL/HalfFlatS). But position reversal via direct directional
actions (ShortHard while long, LongHard while short) can also reduce
|position| by transitioning through zero, firing done events that
bypass the min_hold gate.

Local smoke comparison @ step 499:
  Without reversal block: avg_hold=15.7, trades=3660
  With reversal block:    avg_hold=36.8, trades=1474

The block doubles avg_hold and halves trade frequency, consistent with
enforcing wave-scale operation. Per pearl_edge_lives_at_wave_timescale_not_tick.

Logic by position sign:
- pos > 0 (long):  block actions {0,1,3,9} — short reversal + close + half-close
- pos < 0 (short): block actions {4,5,6,10} — close + long reversal + half-close
- pos == 0 (flat): no blocking — opening allowed

Heat-cap exemption preserved BEFORE the block — margin protection
overrides patience (safety wins).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:19:52 +02:00
jgrusewski
46893952e0 fix(rl): Phase 2 + Phase 3 — γ-derived min-hold + realistic ES costs
Phase 2: γ-derived min-hold bootstrap
  - rl_min_hold_check.cu rewritten to compute hold = current_step -
    oldest_active_unit.entry_step, NOT steps_since_done (which counts
    time since last close, including flat periods — wrong signal).
  - Bootstrap value changed from hardcoded 100.0 → round(-ln(2)/-ln(γ))
    = 138 at γ=0.995. Wave-scale operation per
    pearl_edge_lives_at_wave_timescale_not_tick.
  - Two launch sites updated to push unit_active_d + unit_entry_step_d
    (matches new kernel signature).

Phase 3: Realistic published ES futures transaction costs
  - cost_per_lot_per_side = $0.82 (CME $0.31 + NFA $0.02 + AMP $0.49)
  - Set in alpha_rl_train.rs after LobSimCuda::new
  - Half-spread cost is already modeled by lobsim's bid-ask matching.

Phase 4 audit (no code changes): Mbp10Raw struct has no forward-looking
fields. FRD/BCE labels only flow to aux supervised heads, not encoder
state. No data pipeline leakage detected.

Local smoke (RTX 3050 Ti, b=128, 500 steps):
  Build + run clean. PnL drops from $560k → $64k under realistic costs
  (8.8× reduction). avg_hold metric reports 15.7 but this is "avg
  time between consecutive close events" (broken metric — includes
  flat time between trades), not the actual position hold time.

KNOWN LIMITATION: min_hold blocks explicit FlatL/FlatS but reversal
actions (e.g., ShortHard while long) can also close positions via
transition through zero in the lobsim. The block doesn't catch these.
Phase 2.1 follow-up may add reversal-direction blocking when hold <
min_hold. Cluster validation will show if this materially affects
hold dynamics under full data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:40:28 +02:00
jgrusewski
4df0867622 fix(rl): drawdown-from-peak — symmetric exit signal (replaces clip artifact)
Yesterday's fix `trade_context[1] = fminf(0.0f, unrealized_R)` created a
structural 12× W/L bias: the agent saw losses fast (closed them), couldn't
see wins (rode blindly until max_hold/session). The reproducibility across
folds (f0=f1=f2 at step 500) was the SAME exploit pattern in each window,
not real edge.

This replaces the asymmetric clip with drawdown-from-peak:

  trade_context[1] = unrealized_R - peak(unrealized_R)  (always ≤ 0)

The peak tracks per-active-unit and resets on slot activation. Symmetric:
fires on losing positions (monotone drawdown from entry) AND on
winning-then-retracing positions (drawdown from high-water mark even
while still net-positive).

Sentinel-zero bootstrap per pearl_first_observation_bootstrap: peak==0
on freshly opened/added/reversed slot; first observation directly
replaces peak with unrealized_R.

Implementation:
- New CudaSlice unit_peak_unrealized_r_d [B×MAX_UNITS], allocated zero
- rl_unit_state_update + rl_fused_reward_pipeline write 0.0f sentinel
  on each OPEN/REVERSE/PYRAMID-ADD activation
- rl_trade_context_update reads/updates peak inline, outputs drawdown

Local smoke (RTX 3050 Ti, b=128, 500 steps):
- avg_hold rose from 17.6 → 26-32 steps (wave-scale-ier)
- W/L magnitude ratio is no longer pinned at 12× — varies 0.67-27 across
  the run, sometimes L>W (genuinely symmetric signal)
- No crash, training trajectory healthy

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:59:17 +02:00
jgrusewski
78b781a523 fix(rl): root-cause overfit fixes — value leak + environment timeout
Two architectural fixes addressing the "never close" overfit attractor
that caused walk-forward fold 0 to converge to dones=0 by step 2000.

1. Asymmetric clip on unrealized_R in trade_context (value leak fix):
   trade_context[1] = fminf(0.0f, unrealized_R)

   The model previously saw unrealized_R as a state feature. Q learned
   "high unrealized = high V(state)" → Q(close) < V(hold) → never close.
   Now Q only sees the LOSS side: losing positions visible (cut losses),
   winning positions invisible (close decision driven by general policy,
   not state-conditioned on profit). Realized PnL on close still
   teaches "take profits" via Bellman backup.

2. Enable max_hold_ns = 60s (environment constraint):
   LobSimCuda::max_hold_ns_d was alloc_zeros (disabled). The lobsim
   has session-gap force-close (>1 hour gaps) but no per-trade timeout,
   letting the agent hold positions indefinitely within sessions.
   Now alpha_rl_train sets max_hold to 60s via new upload_max_hold_ns
   method backed by fill_u64.cu kernel (device-side, complies with
   feedback_no_htod_htoh_only_mapped_pinned).

These are environment/architectural fixes, NOT reward shaping hacks.
Together they address why the model COULD learn "never close" and
ensure dones signal density for Q-learning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:13:39 +02:00
jgrusewski
6d33f18b75 fix(rl): advantage normalization — scale-invariant PPO learning
Standard PPO practice missing in this codebase. Without it, lifting
the WIN clamp from 1.0 to 167× (yesterday's fix) expanded |A| by
167× → gradients 167× → KL 167× → ε saturated MAX → unbounded
policy drift → overfit (alpha-rl-wf-f0 step 2000: l_pi=105k, dones=0).

Implementation:
- New kernel compute_advantage_rms.cu: single-block warp-shuffle
  reduce computes sqrt(mean(A²)) over the batch each step
- New ISV slot 589 RL_ADVANTAGE_BATCH_RMS_INDEX
- ppo_clipped_surrogate fwd/bwd divide A by RMS (with floor bootstrap)
- Dispatched after compute_advantage_return in step_with_lobsim

Scale-only normalization (RMS, not z-score) preserves A=0 on
non-done steps → no spurious PPO gradient on Hold actions where
no trade closed.

This is architectural correctness, not a quickfix. Fights the
ROOT CAUSE (reward range expansion breaking LR calibration), not
the SYMPTOM (ε saturation, l_pi explosion).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:39:12 +02:00
jgrusewski
6f21513319 fix(rl): unfreeze WIN clamp adaptation — 100% clip rate was killing learning
Diag analysis of alpha-rl-pifix-4h5fd (500 steps, 80MB JSONL) revealed:
  clip_rate_ema = 99.99% across the entire run
  pos_scaled_max_ema = 30-116 (30× the WIN=1.0 clamp)
  MARGIN saturated at MAX=5.0 (controller wants WIN wider)
  reward_clamp_win STUCK at 1.0 for 500 steps

Root cause: rl_reward_clamp_controller.cu had `(void) margin;` at
Step 4 — the MARGIN was computed but discarded. WIN was hardcoded
to the 1.0 bootstrap from an old G.2 feedback-loop fix. With 99.99%
of rewards clipped to ±1, Q saw saturated signal and couldn't
distinguish small from huge wins.

Fix: write WIN = MARGIN × pos_max_ema (the controller's intent),
and LOSS = RATIO × WIN (preserves loss-aversion asymmetry). The
G.2 feedback concern is addressed by the slow atom-span EWMA
(α=0.001, half-life 700 steps) at Step 5 — atom span damps the
WIN→atom→Q loop sufficiently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:55:41 +02:00
jgrusewski
b8e7b30042 fix(rl): zero ss_pi_loss + ss_pi_loss_entropy between steps
PPO surrogate uses atomicAdd to accumulate into these buffers, but
they were NEVER reset between steps. After 10k steps, l_pi reported
1.27e8 — the SUM of all per-step losses, not the per-step mean.

This explains the apparent l_pi explosion in alpha-rl-floor-zwlk4
(step 9999: l_pi=127580728). The actual per-step l_pi is ~12k/step
(typical PPO surrogate magnitude with the current ratio clamp).

ss_q_loss was correctly zeroed; ss_v_loss uses reduce_axis0_mapped
which overwrites. Only the PPO accumulators leaked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:36:49 +02:00
jgrusewski
45686d82b7 feat(cuda): Thompson sampling floor + LOSS=3.0 bootstrap for entropy stability
Two fixes that decouple entropy stability from loss aversion:

1. Thompson sampling probability floor (RL_THOMPSON_FLOOR_INDEX=588,
   bootstrap 0.05): 5% of steps pick uniform random action. Prevents
   any action from reaching π=0 (δ-function attractor). ISV-driven.
   Per pearl_pi_actor_collapses_without_entropy_floor.

2. LOSS bootstrap restored to 3.0 (was 1.5). LOSS=1.5 caused entropy
   collapse to 0.94 despite max SAC. LOSS=3.0 keeps entropy stable at
   1.87 (proven over 58k steps). The done-gated EMAs (slots 585/586)
   will adapt LOSS toward ~1.45 AFTER entropy stabilizes.

Together: entropy stays stable (LOSS=3.0 + 5% floor) AND PnL improves
(adaptive LOSS lowers toward real L/W ratio after warmup).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 02:05:30 +02:00
jgrusewski
f9a83a03d0 feat(lobsim): per-step unrealized PnL in PosFlat
Added unrealized_pnl field to PosFlat (offset 24, 24→28 bytes).
Computed every step in both fill kernels (order_match + resting_orders)
as (mid - vwap_entry) × direction × |lots|. Zeroed when flat.

This gives Q real-time visibility into open-position drawdown through
the trade_context features. The confidence gate's margin floor now
checks total equity (realized + unrealized) instead of just realized.

Units: price-units × lots (same as realized_pnl). USD = value × $50.

14 files updated: struct def (Rust + CUDA), both fill kernels,
margin floor consumer, layout docs in 6 kernels, tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:56:48 +02:00
jgrusewski
05bee20adb feat(cuda): hard margin floor — force Hold when equity < $20k drawdown
Broker hard constraint: $35k capital, ES maintenance margin ~$14.4k.
If cumulative realized_pnl drops below -$20k, ALL trading actions
are overridden to Hold (if flat) or FlatL/FlatS (if positioned).
ISV-driven at slot 587 (RL_MARGIN_FLOOR_USD_INDEX, bootstrap 20000).

Fires FIRST in the confidence gate — before warmup check, before
exploration slots, before everything. The broker will liquidate at
margin call; this prevents the model from reaching that point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:47:41 +02:00
jgrusewski
a9c74bac33 fix(rl): LOSS bootstrap 3.0 → 1.5 — don't start in a hole
LOSS=3.0 bootstrap means Q tolerates 3× losses during warmup before
the done-gated EMAs adapt. This accumulates -$2M before the controller
kicks in. Bootstrap 1.5 = moderate loss aversion from step 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:42:09 +02:00
jgrusewski
9355118e8b feat(cuda): done-gated trade-level EMAs drive adaptive LOSS clamp
The old approach used pre-clamp reward tail maxes (neg_ema/pos_ema ≈
0.07) → LOSS hit floor at 1.0 → no loss aversion → wr dropped to 0.44.

New approach: the clamp controller loops over done-step rewards,
separates wins from losses, maintains two ISV EMAs:
- RL_DONE_WIN_MAGNITUDE_EMA_INDEX (585) — avg winning trade magnitude
- RL_DONE_LOSS_MAGNITUDE_EMA_INDEX (586) — avg losing trade magnitude

LOSS = clamp(1.0, loss_ema/win_ema × 1.1, 3.0). With observed L/W
ratio of 1.32, LOSS should settle at ~1.45 — preserves loss aversion
while preventing the 3× tolerance that caused massive losses.

On top of dd049d9a4 baseline (wr=0.567). Only this one change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:35:44 +02:00
jgrusewski
c0143d7e34 feat(cuda): adaptive LOSS clamp from observed neg/pos ratio
On top of dd049d9a4 baseline (wr=0.567). Single change: LOSS clamp
adapts from observed loss/win magnitude ratio instead of static 3.0.
LOSS = clamp(1.0, ratio × 1.1, 3.0). WIN stays structural at 1.0.

Gives Q accurate loss magnitude perception without over-allocating
resolution to rare catastrophic tails. Should improve L/W from 1.32
toward break-even (1.28) while preserving wr≥0.55.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:14:34 +02:00
jgrusewski
dd049d9a4c fix(rl): reward clamp bootstrap WIN=1.0 LOSS=3.0 (was 0.5/0.5)
The clamp bounds were bootstrapped at ±0.5 (matching the old ±0.5 atom
span) instead of the intended asymmetric WIN=1.0, LOSS=3.0. This
caused: (1) apply_reward_scale clamped at ±0.5 instead of [-3,+1],
(2) the C51 atom span EWMA target = max(1.0, 0.5) = 1.0 → V_MIN
never moved because target min(-1.0, -0.5) = -1.0 = bootstrap.

Now: WIN=1.0 (positive reward ceiling), LOSS=3.0 (loss-aversion
asymmetry per pearl_audit_unboundedness). Atom span will EWMA from
[-1, +1] toward [-3, +1] over ~2100 steps (α=0.001).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:46:59 +02:00
jgrusewski
18e19b4733 fix(rl): wire reward_clamp_controller + atom_support_update in GPU path
Same issue as apply_reward_scale: the clamp controller and atom
updater were wired in the OLD step path (step_with_lobsim_reward_and_train)
but not in the GPU path (step_with_lobsim_gpu_body). My earlier fix
inlined apply_reward_scale but missed the two kernels that follow it.

Without this, the C51 atom span EWMA never fires — V_MIN/V_MAX stay
frozen at bootstrap [-1.0, +1.0] despite the EWMA code being correct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:25:20 +02:00
jgrusewski
17b426ba5b feat(cuda): re-enable C51 atom span EWMA anchored on clamp bounds
G.2 disabled atom span adaptation to break a positive feedback loop.
With clamp bounds now FROZEN, re-enabling span adaptation is safe.

The span anchors on the CLAMP bounds (WIN=1.0, LOSS=3.0) — the
structural ceiling on post-clamp rewards that Q actually sees. The
earlier version incorrectly used pre-clamp EMAs (pos_ema ≈ 50) which
would blow atoms to [-50, +50] with Δ_z=5.

V_MAX EWMAs from 1.0 toward WIN=1.0 (stays put).
V_MIN EWMAs from -1.0 toward -LOSS=-3.0 (slowly widens to cover the
loss tail). Final asymmetric span [-3, +1] with Δ_z=0.2 gives Q
full resolution across the clamped reward range.

α=0.001 (half-life ~700 steps), floor ±1.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:04:20 +02:00
jgrusewski
0a066a469d fix(rl): C51 atom span ±0.5 → ±1.0 to match reward clamp range
G.2 audit froze the atom span and said "atom span tracks the SEED
clamp range" — but the seed was ±0.5 while the clamp WIN=1.0. With
reward_scale keeping typical scaled rewards at ±0.6-0.9, half the
reward range was projected to edge atoms (binary resolution above
V_MAX=0.5).

Now: V_MIN=-1.0, V_MAX=+1.0 in both Q_V_MIN/Q_V_MAX constants and
ISV bootstrap. Δ_z = 2.0/20 = 0.1, giving ~6 atoms for a typical
scaled reward of 0.6. Q can now distinguish "good trade" from "great
trade" instead of just "positive" vs "negative."

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:46:50 +02:00
jgrusewski
3b1265bc20 fix(rl): wire apply_reward_scale into step body — was dead code
launch_apply_reward_scale was defined but NEVER CALLED from the step
body. Raw PnL ($100-1000) went directly into C51 Bellman with atom
span [-0.5, +0.5]. Every reward projected to edge atoms → binary Q
resolution → Thompson sampling couldn't distinguish actions → wr stuck
at 0.36.

The full reward chain: apply_reward_scale → reward_clamp_controller →
atom_support_update was all wired (kernels loaded, methods written)
but the entry point was orphaned. The clamp controller and atom
updater ran but saw zero input (pos_max_ema=0, neg_max_ema=0).

Now: fused_reward_pipeline → apply_reward_scale → (existing) clamp
controller → atom_support_update. Rewards are scaled to fit the C51
atom span, the span adapts to reward magnitudes, and Q gets proper
distributional resolution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:35:55 +02:00
jgrusewski
ad3e8d1528 fix(cuda): confidence gate exploration floor + symmetric threshold decay
The gate created a self-reinforcing Hold trap: gate forces Hold → Q
learns Hold is best → conf=0 for trading actions → gate blocks
everything → Hold=100%. Even max SAC (α=2.0, τ=50) couldn't break it.

Two fixes:

1. Exploration slots: the first `b_size × (1 - max_hold_frac)` batch
   elements are NEVER gated. At max_hold_frac=0.85 with b=1024, 154
   slots always use the Thompson-sampled action. This guarantees Q
   sees trading outcomes and can learn their value.

2. Symmetric threshold decay: the adaptive threshold had 100× asymmetry
   (10% raise vs 0.1% lower). When Hold exceeded target, the threshold
   barely decreased. Now both directions use THRESHOLD_ADJUST_RATE=1.1.

New ISV slot: RL_CONF_GATE_MAX_HOLD_FRAC_INDEX=584 (bootstrap 0.85).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:03:19 +02:00
jgrusewski
69d8038a80 fix(cuda): SAC co-tuning reads ACTION entropy, not policy entropy
Root cause: confidence gate decouples policy from actions. Policy
softmax stays high-entropy (~2.4) while the gate forces Hold, producing
action entropy ~0.7. SAC read policy entropy EMA (slot 420), saw
"above target", and kept LOWERING τ — the exact opposite of what was
needed.

New kernel `action_entropy_per_step` computes H(action_histogram) from
the POST-gate actions buffer and writes to ISV slot 583
(RL_ACTION_ENTROPY_EMA_INDEX). SAC co-tuning in rl_q_pi_distill_grad
now reads this slot. Launched OUTSIDE CUDA Graph capture (after
confidence gate + FRD gate) in both training and prefill paths.

Kernel design: 11 threads (N_ACTIONS), each thread counts its action
across all b_size elements. Thread 0 computes entropy from the
histogram and updates the EMA. No atomics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:44:10 +02:00
jgrusewski
cfc89313bb fix(cuda): SAC co-tuning uses batch-average entropy + asymmetric rates
Two bugs caused τ to collapse to floor (1.0) instead of ramping up:

1. Single-sample entropy: SAC auto-tune computed s_entropy from block 0
   only (one batch element). At b=1024 this is noise. Now reads
   RL_ENTROPY_OBSERVED_EMA_INDEX (slot 420) — the batch-average entropy
   EMA written by ema_update_per_step via tree reduction.

2. Symmetric step rate: SAC_ALPHA_STEP=0.001 was identical for ramp and
   decay. Entropy collapsed faster than the controller could recover.
   Now asymmetric: SAC_STEP_RAMP=0.01 (100× faster when entropy is below
   target) vs SAC_STEP_DECAY=0.0001. Per pearl_asymmetric_controller_decay.

Also adds sac_alpha + sac_entropy_target to JSONL isv_config for
diagnostic visibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:29:57 +02:00
jgrusewski
e3ca1a7113 feat(rl): co-tune τ with SAC α — adapts to batch size automatically
When entropy drops below target, BOTH α (entropy gradient) AND τ
(distillation softness) increase together. At b=1024 where Q learns
fast and peaks quickly, τ auto-ramps to soften the target. At b=16
where Q is slower, τ stays lower.

τ range [1.0, 50.0], same exponential step rate as α.
This couples the explore-exploit tradeoff into a single adaptive
mechanism that scales with batch size automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:55:16 +02:00
jgrusewski
d011676d75 tune(rl): distillation τ=5.0 — softer target, entropy stabilized
With τ=1.0 the target softmax(E_Q/τ) was too peaked, causing entropy
collapse despite SAC α=2.0. At τ=5.0 the target is soft enough for
the entropy term to maintain equilibrium.

Entropy: STABLE at 0.78-0.81 from step 1000 to 5000 (was declining
to 0.10 at τ=1.0). Hold: 56-100% oscillating. wr: 0.38.

The τ-α balance controls the explore-exploit tradeoff:
  high τ + high α = explore (soft target + entropy bonus)
  low τ + low α = exploit (sharp target + no entropy)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:31:04 +02:00
jgrusewski
2959e06ef2 tune(rl): SAC α_max=2.0, distillation λ=0.01 — give entropy more room
Entropy still declines at b=16 (0.28 at 5k) but slower than before.
α_max raised 0.5→2.0, λ lowered 0.1→0.01. wr=0.38. The b=16
environment is too sparse for the SAC auto-tuning to maintain entropy.
b=1024 with 60× denser Q signal should produce different dynamics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:25:43 +02:00
jgrusewski
4a08696128 feat(rl): target-Q distillation + SAC entropy — proper π architecture
Replace PPO surrogate with target-Q distillation as sole π gradient:
  grad = λ×(π_θ - softmax(E[Q_TARGET]/τ)) - α×π×(logπ+1+H)

Uses TARGET Q (slow τ=0.005) not online Q — fixes phase lag.
SAC α auto-tunes to maintain entropy target (70% of ln(11)=1.68).
ISV slots: SAC_ALPHA=581 (bootstrap 0.01), SAC_ENTROPY_TARGET=582.

compute_advantage_return cleaned to pure 8-arg done-gated V-advantage.
PPO surrogate removed entirely from step_synthetic_body.

Local b=16: wr=0.39, Hold=75-100%, entropy declining (SAC α may need
higher max or λ reduction). L40S b=1024 test needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:20:30 +02:00
jgrusewski
01c9cce9f8 feat(rl): distillation-only π — remove PPO surrogate entirely
π trained solely by Q→π distillation: grad = λ × (π_θ - softmax(E_Q/τ)).
No PPO, no advantages for π, no importance ratios. π is a behavioral
clone of Q-Thompson's action preferences.

Remove KL reward augmentation from compute_advantage_return (back to
pure env reward). Advantage only feeds V regression now.

qpa still negative (-0.94) due to Q changing between distillation and
measurement (phase lag). But wr=0.37 and entropy stable at 0.45-1.20.
The system learns profitably — qpa may be the wrong metric for this
architecture where Q drives action selection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:00:25 +02:00
jgrusewski
e5ced809aa feat(rl): split KL into static reward + dynamic advantage
Static: β×log(π_ref(a)) added to reward, stored in PER. Q sees
consistent Hold preference across replays.

Dynamic: -β×log(π_θ(a)) added to advantage only (not stored in PER).
Provides entropy-regularized signal (SAC-like) that adapts to
current policy. Low π_θ → positive advantage (explore). High π_θ →
near-zero (don't over-concentrate).

Result: entropy STABLE 0.94-1.29 through 5000 steps (no collapse).
Hold 62-100%. wr=0.36 (best local). Removed KL gradient kernel
from training step (KL is now entirely in the reward/advantage).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:46:13 +02:00
jgrusewski
844412f1df feat(rl): KL penalty as REWARD not gradient — fixes structural imbalance
Replace the separate KL gradient kernel with an RLHF-style KL reward:
  r_modified = r_env - β × (log π_θ(a|s) - log π_ref(a))

This feeds through PPO's standard advantage pipeline — no structural
imbalance between KL (100% of steps) and PPO (6% done-steps). The KL
signal has the same per-step coverage as the advantage because it IS
the advantage on non-done steps.

Remove done-gating: all steps now get advantages (KL reward provides
directional signal on non-done steps). Remove distillation done-gating
too (distill on all steps now that PPO has signal everywhere).

Result: entropy STABLE 0.81-1.31 (was collapsing to 0.0 with gradient
KL). Hold oscillates 62-100% at b=16 (expected — sparse PnL). At
b=1024 should settle ~50% with 60× denser PnL signal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:38:09 +02:00
jgrusewski
c67b58d0d0 perf(lobsim): convert step_fill + step_pnl_track to raw_launch
Replace 24 cudarc .arg() calls (8+16) with RawArgs + raw_launch.
Eliminates ~48 cuStreamWaitEvent + cuEventRecord per step from
cudarc's event tracking. Last hot-path launch_builder in the GPU
RL training pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:25:48 +02:00
jgrusewski
9e2c036c5e tune(rl): lower KL β=0.0005 + reward_kl=0.001 for b=1024
β=0.003 pinned Hold=100% at b=1024 despite 60 dones/step.
The KL gradient overwhelms even the dense PPO signal.
Try 6× lower β to find the balance point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:14:58 +02:00
jgrusewski
6b89dbfcb8 perf(rl): eliminate 2 of 3 per-step lobsim syncs — 21ms → 10ms/step
Remove unnecessary stream.synchronize() from:
1. step_fill_from_market_targets (lobsim): stream ordering handles
   the dependency — step_pnl_track launches on the same stream
2. step_pnl_track (lobsim): downstream kernels read pos_d via
   stream ordering, no host read needed per step

Defer pos_fraction readback by one step (same pattern as loss
readback) — eliminates the third 7ms sync. pos_fraction is a
dataset-level statistic that barely changes step to step.

nsys: 3.0 → 1.0 slow syncs/step. Sync time: 21.8 → 9.65 ms/step.
The remaining sync is the training graph event wait (actual compute).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:07:57 +02:00
jgrusewski
8f9e4b269d fix: rename RL_KL_TARGET_INDEX → RL_KL_REF_TARGET_INDEX (avoid dupe)
Old RL_KL_TARGET_INDEX at slot 454 was for the Q-distill KL target.
New reference-policy KL target at slot 580 needs a distinct name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:59:55 +02:00