Commit Graph

4015 Commits

Author SHA1 Message Date
jgrusewski
171b9a241c diag: standalone cublasLtMatmul Rust test for H100 debugging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:06:05 +02:00
jgrusewski
45ff940e4b diag: disable multi-stream branch dispatch in f32 forward to test H100 cublasLtMatmul
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:29:04 +02:00
jgrusewski
06f9819f05 fix(critical): use cuMemAlloc for cublasLt workspace (cross-stream access)
cudarc's stream.alloc_zeros uses cuMemAllocAsync (stream-ordered).
These allocations are only accessible from the allocating stream.
When cublasLtMatmul runs on a forked BRANCH stream (multi-stream
branch dispatch), the stream-ordered workspace is inaccessible,
causing CUBLAS_STATUS_NOT_SUPPORTED on H100 at batch=4096.

Fix: allocate workspace via cuMemAlloc_v2 (synchronous, globally
accessible from all streams). This matches the C++ debug test
which uses cudaMalloc and works on H100 for all dimensions.

Also: add d_layout destroy (leaked handle cleanup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:14:00 +02:00
jgrusewski
90826adad3 fix(critical): destroy d_layout in lt_matmul — leaked handle caused stale reuse
The forward lt_matmul destroyed a_layout, b_layout, c_layout, matmul_desc,
and matmul_pref — but NOT d_layout. The leaked handle caused cublasLt to
return stale descriptors on subsequent create calls. On H100 at batch=4096,
the dimension mismatch between the stale first-call layout and the actual
GEMM dimensions triggered CUBLAS_STATUS_NOT_SUPPORTED.

On RTX 3050 with small batches, the stale layout happened to be compatible
(oversized) for all subsequent GEMMs, masking the bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:48:33 +02:00
jgrusewski
03c4f872e9 fix: convert remaining bf16 activation buffers in experience collector + evaluator
Experience collector: exp_h_s1, exp_h_s2, exp_h_v changed from
CudaSlice<half::bf16> to CudaSlice<f32>. These were unused legacy
fields but their bf16 type was inconsistent with the f32 pipeline.

Backtest evaluator: states_buf and chunked_states_buf padded to
state_dim_padded (pad128). gather_states kernel updated with padded_sd
parameter. DtoD copy uses padded row bytes.

C++ debug test confirms cublasLtMatmul works on H100 for all
dimensions including (128,4096,256) and (128,16384,256) with
both COMPUTE_32F and FAST_TF32.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:18:35 +02:00
jgrusewski
eda50b4eb5 fix(critical): pad backtest evaluator states buffer to state_dim_padded
Root cause of cublasLtMatmul CUBLAS_STATUS_NOT_SUPPORTED:
- states_buf allocated [batch * state_dim] (unpadded)
- cuBLAS forward GEMM reads with stride state_dim_padded (pad128)
- Buffer overflow: GEMM reads past buffer end

cublasLtMatmul validates buffer sizes against layout descriptors and
returns NOT_SUPPORTED for undersized buffers. cublasSgemm silently
read garbage — this was the source of the "parallel test congestion
errors" seen previously.

Fix:
- Allocate states_buf with state_dim_padded stride (3 allocation sites)
- gather_states kernel: add padded_sd parameter, write with padded stride
- DtoD copy: use padded row bytes
- Both gather_states call sites updated with padded_sd arg

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:31:35 +02:00
jgrusewski
b456691de5 fix: use CUBLAS_COMPUTE_32F for cublasLtMatmul (SM86 compat)
CUBLAS_COMPUTE_32F_FAST_TF32 returns NOT_SUPPORTED on RTX 3050 (SM86)
for certain (m,n,k) via cublasLtMatmul (e.g., 128×512×64). Plain
CUBLAS_COMPUTE_32F works for all dimensions. TF32 tensor cores are
still enabled via CUBLAS_TF32_TENSOR_OP_MATH math mode on the cuBLAS
handle — this activates TF32 on Ampere+ automatically when the
hardware supports the specific matrix dimensions.

Also: use sys::cublasLtMatmul directly (bypass result:: wrapper)
for clearer argument ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:33:36 +02:00
jgrusewski
754b695bc0 feat: migrate all GEMMs from cublasGemmEx to cublasLtMatmul
cublasLtMatmul passes workspace+stream per-call (no handle state),
eliminating the cublasSetStream workspace conflict that hung CUDA
Graph mega-capture on H100 with cublasGemmEx.

Forward (22 call sites via sgemm_f32/sgemm_f32_ldb):
- cublasLtMatmul with CUBLAS_COMPUTE_32F + per-call stream
- No more cublasSetStream for branch dispatch (stream passed directly)
- 32MB workspace unconditionally (TF32 HMMA needs it even on Ampere)

Backward (7 call sites via sgemm_f32):
- Same cublasLtMatmul pattern with flexible transa/transb
- Stream parameter threaded through backward_fc_layer methods

TF32 tensor cores enabled via CUBLAS_TF32_TENSOR_OP_MATH math mode
on the cuBLAS handle. CUBLAS_COMPUTE_32F (not FAST_TF32) used in
matmul descriptors for SM86 compatibility.

19/19 smoke tests pass (sequential). Ready for H100 graph_mega test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:11:51 +02:00
jgrusewski
d48cb89980 fix(critical): restore cuBLAS workspace after every cublasSetStream
cublasSetStream() resets workspace to the default pool, which uses
stream-ordered allocation — incompatible with CUDA Graph capture.
This caused cublasGemmEx TF32 to hang inside graph_mega on H100.

Fix:
- Store workspace raw pointer + size in CublasForward struct
- Call cublasSetWorkspace_v2 after every cublasSetStream (5 sites)
- Add 32MB workspace to CublasBackward (had none — used default pool)
- Increase forward workspace 4MB → 32MB (TF32 HMMA needs more)

Root cause from NVIDIA docs: "cublasSetStream() unconditionally
resets the cuBLAS library workspace back to the default workspace pool"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:07:24 +02:00
jgrusewski
1cd9d4039c perf: switch cublasSgemm → cublasGemmEx with CUBLAS_COMPUTE_32F_FAST_TF32
cublasSgemm was running on pure f32 CUDA cores (~60 TFLOPS on H100),
causing 15× slowdown vs the old bf16 tensor core path (77s vs 5s/epoch).

cublasGemmEx with CUBLAS_COMPUTE_32F_FAST_TF32 + CUBLAS_GEMM_DEFAULT_TENSOR_OP
uses TF32 tensor cores (~500 TFLOPS on H100) — 8× faster than pure f32.

The earlier NOT_SUPPORTED error was caused by bf16 weight pointers feeding
f32 GEMMs (stride mismatch), not API incompatibility. Now that all pointers
are f32, TF32 GemmEx works on both RTX 3050 (SM86) and H100 (SM90).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:53:15 +02:00
jgrusewski
30f3156369 diag: RECAPTURE_DIAG — per-buffer norms after graph recapture
Fires for 3 steps after any CUDA graph recapture. Reports:
- d_val/adv_norm: blended d_logits (C51 × alpha + MSE × (1-alpha))
- mse_val/adv_norm: MSE-only scratch buffers
- grad_norm: backward output grad_buf

Purpose: diagnose C51 grad_norm=0 on H100 at batch=16384 (epoch 2+).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:34:01 +02:00
jgrusewski
a2370d7f33 fix: evaluate_baseline example bf16→f32 closure signatures
The evaluate closures now receive &CudaSlice<f32> from the backtest
evaluator. Updated all 3 closures (DQN, PPO, supervised) to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:02:13 +02:00
jgrusewski
8329ca4187 fix(critical): eliminate ALL bf16 from training — pure f32/TF32 pipeline
The bf16 backward chain destroyed gradient precision at batch=16384 with
mean-reduced gradients (~6e-5). bf16's 8-bit mantissa couldn't represent
these values, producing zero weight gradients on H100.

This commit removes bf16 from the ENTIRE training pipeline:

Forward pass:
- cublasSgemm with CUBLAS_TF32_TENSOR_OP_MATH math mode (auto TF32 on Ampere+)
- f32 master weights used directly (no bf16 shadow for forward)
- All activation saves (h_s1, h_s2, h_v, h_b[0..3]) now f32
- States buffer f32 (pad_states_kernel outputs f32)
- Bias kernels: pure f32 (removed 5 bf16 variants)

Backward pass:
- Single cublasSgemm GEMM (was 6 variants: bf16, bf16_acc_f32, f32dy, etc.)
- f32 activations + f32 weights → no casts needed
- relu_mask_kernel reads f32 activation (was bf16)
- Removed: bf16 staging buffer, cast_dx_to_staging, all _f32dy duplicates

Backtest evaluator:
- All activation/state/weight buffers converted bf16→f32
- gather_states outputs f32 (kernel reads bf16 features, writes f32)
- Weight flattening: bf16→f32 conversion via kernel

Net: -1290 lines, +556 lines (734 lines removed)
Rule: bf16 is ONLY for stored weight tensors (spectral norm). Everything else is f32.
19/19 smoke tests pass. Gradient norms healthy (0.39-1.03).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:46:03 +02:00
jgrusewski
d25ade2087 feat: add f32 dY backward chain — no bf16 precision loss
New methods: backward_fc_layer_f32dy, backward_fc_layer_lda_f32dy,
launch_dw_only_f32dy, launch_dx_only_f32dy, launch_bias_grad_f32.
Uses gemmex_f32b_bf16a_acc_f32 (f32 dY + bf16 activations → f32 output).
backward_full now passes f32 dX directly between layers — no bf16 cast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:30:40 +02:00
jgrusewski
a4fb15fce0 diag: per-step BUFFER_DIAG — d_value_logits, d_adv_logits, grad_buf norms
Logs L2 norms of critical buffers after forward+backward+aux, before Adam.
Fires on first 3 steps of each epoch only. Identifies exactly which buffer
goes zero on H100 — is it the loss gradient or the cuBLAS backward output?

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:30:57 +02:00
jgrusewski
d82519118b fix: align blend n_val/n_adv with allocation and cast dimensions
Blend used b*na but cast used b*pad32(na). Now both use the padded
size so scale/saxpy process the same element count as the cast.
Padding elements are zero — no behavioral change, just consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:07:04 +02:00
jgrusewski
49c8c1745e fix(critical): branch-major d_adv_logits layout + revert diagnostic hacks
Root cause of H100 gradient collapse: c51_grad, mse_grad, and cql_grad
kernels wrote d_adv_logits in sample-major interleaved layout [B, TBA]
but cuBLAS backward read it as branch-major [B*B0*NA | B*B1*NA | ...].
At batch=16384, the GEMM read cross-contaminated data from wrong branches,
producing zero weight gradients by symmetry cancellation at scale.

Fix: changed indexing in all 3 grad kernels to branch-major.
Reverted diagnostic hacks (ungraphed forward, forced diagnostics).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:05:46 +02:00
jgrusewski
9582fd7c73 fix(critical): cql_grad writes branch-major d_adv_logits layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:01:00 +02:00
jgrusewski
562a41483b fix(critical): mse_grad writes branch-major d_adv_logits layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:58:04 +02:00
jgrusewski
1f910d0aac fix(critical): c51_grad writes branch-major d_adv_logits layout
The old sample-major interleaved layout did not match cuBLAS backward
branch-major pointer arithmetic. At batch=16384, backward GEMM read
cross-contaminated data producing zero weight gradients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:56:38 +02:00
jgrusewski
4e95f5142d diag: disable graph_forward — run forward+backward ungraphed every step
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:41:06 +02:00
jgrusewski
ef6fab5f3b diag: force ungraphed aux path — isolate CUDA Graph vs kernel issue
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:10:53 +02:00
jgrusewski
3964e111e7 fix: IQN/ensemble trunk finalize no longer overwrites main grad_norm_buf
The grad_norm_finalize kernel writes both f32 (sum-of-squares) and bf16
(L2 norm). The IQN and ensemble trunk gradient computations were passing
the MAIN grad_norm_buf for the bf16 output, overwriting it mid-pipeline.

While compute_grad_norm_outside_graph() overwrites it again before Adam,
this was still a correctness hazard. Now uses a dedicated 1-element bf16
scratch buffer (aux_norm_bf16_scratch) for IQN/ensemble finalize calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:48:30 +02:00
jgrusewski
0b3cb35ef4 fix(critical): compute_grad_norm used captured kernel handle after mega-graph
compute_grad_norm_outside_graph() called launch_grad_norm() which uses
the graph-captured CUfunction handle. After graph_mega capture, this
handle cannot be launched outside the graph — CUDA silently corrupts
kernel state on H100 (SM_90). The corrupted grad_norm_f32_buf caused
Adam to read bogus norms, zeroing all gradient updates.

Fix: use launch_grad_norm_standalone() which has its own separately
loaded kernel handle, safe for post-graph launches.

This was the ROOT CAUSE of the H100 gradient collapse at epoch 3-4.
The collapse happened after mega-graph capture (step 2 of each epoch),
and the corrupted norm persisted for all subsequent steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:44:49 +02:00
jgrusewski
bf3091c6f2 fix: re-enable IQN + remove diagnostics for H100 baseline
- iqn_lambda restored to 0.25 (was 0.0 for NaN isolation)
- STEP_DIAG per-step logging removed (was temporary)
- FOXHUNT_GRAD_DIAG env var removed from Argo template

IQN NaN root cause fixed in 8cbabcef5 (f32-as-bf16 type mismatch).
Ready for H100 baseline deployment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:04:01 +02:00
jgrusewski
796fbd01cf fix: set c51_alpha_max=0.5 — prevent C51 gradient convergence on H100
H100 fold 2 showed grad_norm→0 when C51 fully replaced MSE (alpha=1.0).
MSE provides a non-vanishing gradient floor. Default 0.5 = 50/50 blend.
Hyperopt can search [0.3, 0.9].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:02:48 +02:00
jgrusewski
8cbabcef5e fix(critical): IQN d_h_s2 is f32 not bf16 — remove bogus bf16_to_f32 cast
The IQN backward kernel writes f32 via atomicAdd into d_h_s2_buf
(allocated as CudaSlice<f32>). But apply_iqn_trunk_gradient called
bf16_to_f32_kernel on this buffer, reinterpreting f32 bits as bf16.
This produced garbage/NaN that poisoned the trunk gradient on H100.

Replace with direct f32 DtoD copy — no cast needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:59:26 +02:00
jgrusewski
5b4561fc27 diag: disable IQN (iqn_lambda=0) to isolate H100 NaN source
Graph recapture ruled out as NaN cause (NaN persists without graph
invalidation). Now testing: does NaN disappear when IQN is disabled?
If yes → IQN backward/Adam produces NaN on H100.
If no → NaN is in the main C51/MSE pipeline.

Also reverts graph invalidation hack from previous diagnostic commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:42 +02:00
jgrusewski
6352413e01 diag: disable graph invalidation on c51_alpha change (testing recapture NaN)
TEMPORARY: graphs keep stale alpha (wrong training, but isolates NaN source).
If NaN disappears → recapture path is the bug.
If NaN persists → something else causes it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:30:04 +02:00
jgrusewski
fce606a421 fix(critical): d_adv_logits buffer under-allocation — 32 elements short
The f32→bf16 cast in cast_d_logits_to_bf16() reads
b*(b0+b1+b2+b3)*na + 32*4 elements, but d_adv_logits_buf and
d_adv_logits_bf16 were allocated with only +32*3 padding (96 vs 128).

The 32 extra f32 reads went past the allocation into adjacent GPU
memory. On H100 with 80GB and different memory layout, this grabbed
NaN/garbage values. The NaN propagated through the cuBLAS backward
pass into grad_buf, killing training at step ~720 (non-deterministic
depending on what lives in adjacent memory).

Fix: allocate +32*4 padding in d_adv_logits_buf, d_adv_logits_bf16,
and d_adv_logits_mse to match the cast kernel's access pattern.

Also reverts c51_alpha_max default to 1.0 — the gradient collapse
was from this buffer bug, not C51 premature convergence. The
c51_alpha_max parameter is kept for hyperopt exploration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:06:30 +02:00
jgrusewski
c26f0ae451 diag: add per-step STEP_DIAG logging (zero-cost, uses existing readback)
Logs grad_norm, loss, c51_alpha every 50 steps via existing async readback
scalars. No stream sync or extra GPU work. Works with mega-graph path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:35:26 +02:00
jgrusewski
7036ba3589 chore: enable FOXHUNT_GRAD_DIAG in Argo train-best step
Temporary diagnostic: logs per-stage gradient norms (pre-clip, post-clip,
post-IQN, final) to identify where H100 gradient collapse occurs.
Remove after root cause is found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:10:14 +02:00
jgrusewski
a0aab3baa5 feat: add c51_alpha_max to cap MSE→C51 blend and prevent gradient starvation
H100 baseline (20 epochs) showed gradient collapse at epoch 10: C51
cross-entropy converges its distributional fit before the policy converges,
leaving zero gradient signal. The collapse happened 5 epochs after C51
reached alpha=1.0 (pure C51, zero MSE).

Fix: cap the C51 alpha ramp at c51_alpha_max (default 0.5) so MSE always
contributes (1 - alpha_max) of the primary gradient. MSE loss measures
Q-error directly and only goes to zero when Q-values are correct, not
just when the distribution shape is right.

- c51_alpha_max added to DQNHyperparameters (default 0.5)
- Added to PSO search space as 15th dimension (range [0.3, 0.9])
- Added to TOML profiles: smoketest, production, hyperopt
- Training loop caps alpha ramp at alpha_max instead of 1.0
- All 907 ml tests + 300 ml-core tests + 6 smoke tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:42:41 +02:00
jgrusewski
30f46c04c1 fix(critical): mean-reduce gradients + fix ExposureLevel 4-branch mismatch
Three root causes found and fixed:

1. SUM-reduced gradients without 1/N: all CUDA loss gradient kernels
   (C51, MSE, IQN backward, CQL) accumulated per-sample gradients as
   raw SUM. At batch=16384 (H100) the raw norm was 282x larger than
   batch=58, causing gradient clipping to destroy signal-to-noise ratio
   and collapse training at epoch 2-3. Now all kernels multiply by
   1/batch_size, making gradient scale batch-invariant.

2. ExposureLevel::target_exposure() used a flat 9-level scale that did
   not match the 4-branch dir*mag encoding. The Rust backtest evaluator
   computed wrong position sizes (e.g. 4x oversize for Short+Small).
   Now uses dir x mag formula. Also fixed is_buy/is_sell/is_hold and
   from_trading_action for 4-branch semantics.

3. Rust epsilon-greedy only explored 5/9 exposure combos (0..5 instead
   of dir*3+mag), ignored the magnitude branch on greedy, and used wrong
   indices for order/urgency (get(1)/get(2) instead of get(2)/get(3)).

LR recalibrated: old gradient_clip_norm was accidentally a batch-size-
dependent LR reducer (~60x at batch=58, ~16000x at batch=16384). With
mean-reduced gradients the clip rarely fires, so LR is now the sole
training speed control. Smoketest 1e-4 -> 2e-6, production 1e-4 -> 1e-5,
hyperopt range [1e-5,3e-4] -> [1e-7,1e-4].

Diagnostics: FOXHUNT_GRAD_DIAG=1 enables per-stage gradient norm logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:18:36 +02:00
jgrusewski
5bdeb6c6d6 fix: restore reward normalization — was present in the only stable H100 run
RUN 4 (1540c0287) survived 9 epochs with stable grad_norm=0.44.
That commit INCLUDED reward normalization (÷pos_frac). The revert
in 09fb80baa removed it, and the resulting H100 run collapsed at
epoch 7 (grad_norm=0.000).

The reward normalization is part of the stable configuration.
experience_kernels.cu restored to exact RUN 4 state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:33:03 +02:00
jgrusewski
09fb80baa8 revert: remove reward normalization + magnitude gradient restore
Both destabilized H100 training:
- Reward normalization (÷pos_frac): collapsed gradients at epoch 2-3
- Magnitude gradient restore (beta*MSE): Q-value explosion at epoch 25
  (tested beta=0.10, 0.05, 0.02 — ALL cause Q explosion >50)

The magnitude MSE gradient is structurally unstable post-warmup.
ANY non-zero beta feeds a Q-overestimation loop that CQL can't counter.
The magnitude branch head learns during MSE warmup (epochs 1-5), then
the trunk continues improving via IQN (60% budget). This is the ONLY
stable configuration proven on H100 (RUN 4: stable through 9 epochs,
grad_norm=0.44, Q=2.03).

Reverts 76478559b (reward norm) and 22b7bc083 (gradient restore).
Keeps 1540c0287 (CUDA Graph fixes — the critical root cause).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:06:15 +02:00
jgrusewski
22b7bc0834 fix(magnitude): restore MSE gradient killed by alpha blend
The alpha blend (grad = alpha*C51 + (1-alpha)*MSE) progressively killed
magnitude gradient: at alpha=1.0 post-warmup, magnitude got 0*C51 + 0*MSE
= zero gradient. The magnitude branch head stopped learning at epoch 5.

H100 confirmed: grad_norm stabilized at 0.44 (epochs 6-9) — other
components alive but magnitude frozen. Q-values plateaued at 2.03.

Fix: after the alpha blend, SAXPY d_adv[d==1] += alpha * d_adv_mse[d==1]
to restore the MSE portion removed by the (1-alpha) scaling. This gives
magnitude full MSE gradient at all alpha values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:10:56 +02:00
jgrusewski
1540c0287d fix(critical): frozen c51_alpha in CUDA Graph + corrupted grad_norm handle
Two critical bugs causing gradient collapse at epoch 3-4 on H100
(batch=16384) while smoke test (batch=58) works:

1. c51_alpha baked as literal scalar in CUDA Graph at capture time.
   set_c51_alpha() updated the CPU field but the graphed SAXPY blend
   kernels kept using the frozen capture-time value (~0.01). The C51/MSE
   gradient blend NEVER ramped — model trained with pure MSE forever.
   Fix: invalidate graph_mega + graph_forward when alpha changes.
   Graph re-captures on next step with the correct blend.

2. grad_norm_standalone loaded but never wired in. clip_grad_buf_inplace
   used the CAPTURED grad_norm_kernel CUfunction outside graph context.
   CUDA forbids launching a captured CUfunction outside its graph —
   silently corrupts kernel state. grad_norm buffer contains garbage,
   Adam reads garbage clip scale, gradients effectively zero.
   Fix: use grad_norm_standalone (separate CUfunction handle) for
   all non-graph gradient norm computations.

Root cause of every H100 gradient collapse this session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:40:57 +02:00
jgrusewski
76478559be feat(reward): magnitude-neutral reward normalization
Divide segment_return by position fraction (|pos|/max_pos) so the
reward measures directional skill per unit of risk, not absolute PnL.

Root cause: Quarter (0.25) produces 4× lower PnL variance than Full
(1.00). MSE Bellman target for Quarter has 16× lower variance →
gradient 16× more consistent → Quarter Q converges first → Boltzmann
locks in (82%) → model never tries Full enough to learn its true Q.
Same mechanism as C51 distributional collapse but through reward
variance instead of distributional shape.

After normalization, all magnitudes produce identical reward variance
for the same directional skill. The model selects magnitude based on
risk-adjusted return per unit position, not convergence speed.

Applied after Kelly stats (which need raw returns) and after reversal
override. NOT applied to backtest evaluator (measures actual PnL).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:13:47 +02:00
jgrusewski
b77cf4314e fix(direction): restore C51 for direction — mean-advantage caused Q convergence
Mean-advantage argmax + zero C51 gradient for direction (d==0) caused
all 9 per-action Q-values to converge to 0.85 by epoch 6 on H100.
Gradients collapsed to 0.000 at epoch 4. Root cause: mean-advantage
gives nearly equal values for all direction actions after centering,
causing degenerate argmax → same target action every step → all Q
converge to same Bellman target.

Direction does NOT need the magnitude treatment:
- Flat bias from C51 is smaller relative to genuine directional Q-gaps
- C51 provides essential distributional signal for risk-aware direction
- Boltzmann + 30% Flat floor already prevents Flat lock-in

Reverted to d==1 only (magnitude) for:
- C51 gradient zeroing
- Mean-advantage Bellman argmax
- Mean-logit in compute_expected_q
- 2× MSE amplification

Direction (d==0) uses full C51 softmax everywhere.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:57:12 +02:00
jgrusewski
92d88ec464 fix(learning): restore softmax for online_eq + target_eq in MSE loss
Mean-logit for online_eq/target_eq caused Q-value stagnation on H100
(5.7M bars): Q froze at 1.8 after epoch 3, action distribution identical
every epoch, Sharpe drifted +1.12→-0.36 over 11 epochs.

Root cause: mean-logit gradient is uniform across 51 atoms (1/51 each).
Too diffuse for continued learning — model converges to local minimum
in 3 epochs then can't fine-tune. C51 softmax focuses gradient on
high-probability atoms, enabling continued learning.

The distributional bias (Small/Flat learn faster) is countered by:
- C51 gradient zeroed for d<=1 (no cross-entropy pull)
- Bellman argmax uses mean-advantage (unbiased action selection)
- Boltzmann + Flat floor (diverse experience collection)
- compute_expected_q uses mean-logit (unbiased action selection/eval)

Softmax bias is in gradient EFFICIENCY (convergence speed), not in
the converged Q-VALUE (both sides of TD use same softmax → unbiased).

Result: Q-values 0.71→0.83 (progressing), Sharpe +7.41 at epoch 10
(was +1.98 with mean-logit), 7/7 diversity maintained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:27:54 +02:00
jgrusewski
f4295e6902 fix(trading): 30% Flat floor + consistent mean-logit everywhere
Three fixes for production readiness:

1. Direction Flat floor: 30% unconditional Flat probability prevents
   over-trading (was 7% Flat = 93% directional = massive cost drag).
   Hard constraint, not Q-dependent, can't snowball. Combined with
   hold enforcement (min_hold_bars=10), actual Flat is ~13%.

2. MSE loss online_eq + target_eq: consistent mean-logit for d<=1.
   Both sides of TD error use the same representation — no mismatch.
   Removes the last C51 softmax bias from magnitude gradient path.
   Half grows 2.7%→5.7%, Full grows 2.7%→5.3% across epochs.

3. compute_expected_q: mean-logit for d<=1 (action selection + eval).
   Safe — not in training loss path.

Result: 7/7 diversity sustained, Flat stable at 13%, Sharpe positive
at 3/4 epochs. 19/19 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:44:31 +02:00
jgrusewski
75bd6192b1 fix(magnitude): consistent mean-logit in MSE loss + compute_expected_q
Two remaining C51 softmax paths were causing Quarter (smallest position)
to dominate at 94%:

1. MSE loss online_eq: C51 softmax biased towards Quarter via gradient
   d(td²)/d(weights) flowing through d(softmax_expected_Q)/d(weights).
   Fix: mean-logit for d<=1, matching target_eq representation.

2. compute_expected_q: C51 softmax Q-values fed Boltzmann action
   selection, inflating Quarter's Q. Fix: mean-logit for d<=1.
   Safe — this kernel is NOT in the training loss path.

Both online_eq and target_eq now consistently use mean-logit for
direction+magnitude. The TD error is unbiased: no representation
mismatch. C51 softmax retained for order+urgency (d>=2) where
C51 gradient is active.

Result: Half grows 2.7%→5.7%, Full grows 2.7%→5.3% across epochs.
No collapse. 19/19 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:32:56 +02:00
jgrusewski
5d5a2c1f3d fix(direction+magnitude): complete C51 variance bias elimination
Root cause: C51 distributional softmax structurally favors zero/low-variance
actions (Flat for direction, Small for magnitude). This created irrecoverable
feedback loops through FOUR paths: gradient, Bellman target argmax, action
selection, and the Q-gap conviction filter.

Direction branch (new):
- Zero C51 gradient for direction (d==0) — same treatment as magnitude
- Boltzmann softmax replaces argmax for direction selection (tau=2×Q_range)
- 2× MSE gradient amplification for direction branch head
- Mean advantage (not C51 softmax) for Bellman target argmax at d==0
- Q-gap conviction filter REMOVED from training path (redundant with
  Boltzmann, harmful after high-Sharpe epochs where Bellman max bootstrap
  raises Q(Flat) towards Q(directional), shrinking the gap)

Magnitude branch (Bellman target fix):
- Mean advantage for Bellman target argmax at d==1 in both MSE + C51 kernels
- Proper softmax retained for online_eq and target_eq (learning objective)

Metric fixes:
- Diversity denominator: 9 → 7 (Flat forces mag=Half, max reachable is 7)
- Threshold: 0.5% of total → 1% of directional (Flat-dominant policies
  mechanically killed diversity under the old metric)

Result: 7/7 dir×mag diversity sustained epochs 7-10, Flat stable at 7-9%
(was 60-87% growing). 19/19 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:02:20 +02:00
jgrusewski
5ba9f376c4 fix(magnitude): mean-logit Bellman target — close the last C51 feedback path
The MSE and C51 loss kernels computed Bellman targets using C51's
distributional softmax expected Q for the argmax over next-state actions.
For magnitude (d==1), this structurally favored Small (tight distribution
→ higher softmax expected Q), creating an irrecoverable target feedback
loop even when C51 gradient was zeroed.

Fix: for magnitude branch (d==1), use mean-logit Q (average of V+A
across atoms without softmax weighting) for both argmax selection AND
target Q computation. This is variance-neutral — only the average
advantage level matters, not the distributional shape.

Applied to all 3 kernels:
- mse_loss_kernel.cu: argmax + target_eq
- c51_loss_kernel.cu: argmax
- expected_q_kernel.cu: expected Q for backtest evaluator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:28:03 +02:00
jgrusewski
9b86b9d385 fix(s&p): protect all branch heads, not just magnitude
S&P on direction branch kills Q-gap conviction → forces Flat →
magnitude diversity collapses mechanically. Protect all 4 branch
heads [8..24], perturb only trunk + value head + bottleneck.

Note: smoke test (3200 bars, 10 epochs) naturally converges to Flat
regardless of S&P — insufficient data for sustained directional
conviction. H100 production run (5.7M bars) showed 7/9 diversity
sustained through all 7 epochs with positive Sharpe at epoch 6.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:16:59 +02:00
jgrusewski
0de7f69418 fix(training): gradient cliff at C51 warmup + stale val_loss
Two issues found in the H100 production run:

1. Primary gradient budget dropped from 1.0 to 0.10 when C51 alpha
   ramped to 1.0 (c51_frac=0.10 with IQN at 60%). Branch heads
   depend entirely on primary gradient — starved → grad_norm=0.000
   at epoch 7. Fix: floor c51_frac at 0.30 so branch heads always
   get meaningful gradient.

2. val_loss identical at epochs 1-2 (6.228316) because the backtest
   evaluator's CUDA Graph was never invalidated between epochs.
   Fix: call invalidate_dqn_graph() before each evaluation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:09:17 +02:00
jgrusewski
aa611a938a fix(magnitude): IQN primary + Boltzmann selection — break C51 Bellman collapse
C51 cross-entropy structurally favors low-variance actions (Small positions),
creating an irrecoverable feedback loop once the target network locks in.
MSE warmup learns, then C51 kills magnitude diversity at epoch 4+.

Four-layer fix:
- IQN gradient budget 10%→60% (primary distributional, Huber is variance-neutral)
- C51 gradient zeroed for magnitude branch, MSE amplification 4×→2×
- v_min/v_max ±240→±15 (16× atom resolution, delta_z 9.6→0.6)
- Boltzmann softmax replaces argmax for magnitude action selection
- Q-gap conviction filter now adaptive (fraction of Q-range, auto-scales)
- Magnitude branch weights excluded from Shrink-and-Perturb

Result: 7/9 dir×mag diversity at epoch 9 (was 3/9 collapsing to Small-only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:42:21 +02:00
jgrusewski
10e445f2c7 feat(magnitude): advantage standardization in expected_q forward pass — matches loss kernels
The forward pass (action selection) must apply the same advantage
standardization as the loss kernels. Otherwise the network's raw
advantages can encode stale C51 preferences that don't match the
standardized training signal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:32:59 +02:00
jgrusewski
49bedfcd69 feat(magnitude): per-branch loss weighting — 0.2× C51 + 4.0× MSE for magnitude
C51 cross-entropy structurally prefers low-variance actions (Small positions
have tighter return distributions). MSE is variance-neutral. By reducing
C51's gradient to 20% and amplifying MSE to 4× for the magnitude branch,
MSE becomes the dominant loss signal for position sizing.

Direction/order/urgency keep full C51 gradient for distributional risk awareness.
Replaces the previous mag_amp=1.5 which addressed a symptom (Flat gradient
starvation) rather than the root cause (C51 variance bias).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:23:19 +02:00