Commit Graph

4015 Commits

Author SHA1 Message Date
jgrusewski
9eb5618c49 fix: use graph_forward (not graph_mega) for eval — prevents Adam corruption
graph_mega replay during eval corrupted Adam moments (m_buf/v_buf),
target network, and auxiliary weights. Only params_buf was restored,
leaving optimizer state corrupted for subsequent training steps.

graph_forward only runs forward + loss + grad + backward — NO Adam,
no aux ops. The grad/loss writes go to scratch buffers overwritten
before the next training step. Safe to replay during eval.

Result: within-process determinism is COMPLETE. Runs that capture
the same cublasLtMatmul kernel config produce bit-identical results
across all 30 epochs (3 folds × 10 epochs). Cross-process variation
remains from the initial cublasLt heuristic (NVIDIA library limitation).

Also: removed eval_params_snapshot (no longer needed), cleaned up
unused prev variable in set_c51_alpha.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:14:48 +02:00
jgrusewski
bfd88a7e12 feat: replay graph_mega for eval with param save/restore
Route evaluation Q-values through graph_mega (the EXACT graph used for
training). Save params before replay, restore after to undo Adam's
weight update. Persist graph_mega and graph_forward across fold
boundaries — all buffer addresses are stable, kernel args use pinned
device-mapped pointers.

This guarantees training and eval use identical cublasLtMatmul kernel
configs from the same capture session. Both are fully deterministic
WITHIN a given process. Cross-process variation remains from the
initial graph capture (NVIDIA cublasLtMatmul heuristic selects
different kernel configs between process invocations).

Cleanup: removed separate eval_fwd_graph, eval_params_snapshot serves
dual purpose (fold boundary save + eval rollback).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:07:16 +02:00
jgrusewski
8f8d230588 feat: frozen eval graph — replay first-capture forward for deterministic eval
Capture a separate eval-only CUDA Graph immediately after graph_forward
(same cublasLtMatmul internal state). The exec handle is leaked via
mem::forget to survive graph_forward invalidation between folds.

Eval replays this frozen graph instead of the recaptured training
graph_forward, giving consistent results across walk-forward folds.

Training: fully bit-identical (verified with weight checksums).
Evaluation: identical epochs 1-4 across runs. Remaining minor
variation from cublasLtMatmul internal state differences between
consecutive capture sessions — NVIDIA library limitation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:44:59 +02:00
jgrusewski
0f5b2b07b0 docs: spec for deterministic eval via training graph replay
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:26:14 +02:00
jgrusewski
37eb4918dd feat: route evaluation through trainer's CUDA-Graphed forward pass
Replace the evaluator's independent CublasForward with QValueProvider
trait that routes Q-value computation through the trainer's graphed
CublasForward. This eliminates the separate cuBLAS handle that caused
evaluation non-determinism.

Architecture:
- QValueProvider trait in q_value_provider.rs (compute_q_values_to)
- FusedTrainingCtx implements it (chunks through trainer batch_size=64)
- GpuDqnTrainer::compute_q_values_graphed captures eval forward in
  CUDA Graph (cuBLAS forward + compute_expected_q) on first call
- Pre-captured at deterministic point (right after mega graph)
- evaluate_dqn_graphed takes &mut dyn QValueProvider (mandatory)

Cleanup (-398 lines):
- Deleted evaluator's internal CublasForward + all chunked scratch buffers
- Deleted compute_q_values (non-graphed), flatten_weights_for_cublas,
  ensure_cublas_ready, compute_backtest_param_sizes
- Deleted evaluate_dqn (fallback path)
- Removed dead imports and stale comments

Training: fully bit-identical across runs (CUDA Graph replay).
Evaluation: deterministic within process (graph replay), minor
variation across processes (cublasLtMatmul capture non-determinism).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:21:58 +02:00
jgrusewski
9ac1409bba perf: restore TF32 tensor cores for cublasLt GEMM
PEDANTIC was only needed for ungraphed cublasLtMatmul determinism.
Once eval routes through the trainer's CUDA Graph, determinism comes
from graph replay (which freezes kernel config), not compute type.
TF32 gives ~2x SGEMM throughput on Ampere/Hopper tensor cores.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:36:40 +02:00
jgrusewski
88cf7a321e fix: remove machine-specific cuBLAS algo cache
The algo cache serialized heuristic-selected algo bytes to disk with
GPU name + CUDA version validation. This created a divergent code path
between dev (RTX 3050) and prod (H100) — different machines would get
different cached algos, producing different but "frozen" results.

Determinism should come from the algorithm itself being deterministic,
not from caching one machine's non-deterministic output. The proper
fix is routing evaluation through the trainer's CUDA-Graphed forward
pass (which IS deterministic by design).

Kept: COMPUTE_32F_PEDANTIC on all cublasLt matmul descriptors (disables
TF32, universal across GPUs). Kept: IQN + attention backward determinism,
single-stream eval, evaluator reuse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:34:39 +02:00
jgrusewski
43aa5185c8 fix: use main stream for evaluation, reuse evaluator across folds
Two changes to reduce evaluation non-determinism:

1. Use main training stream for evaluation instead of forked
   validation_stream. NVIDIA docs: "bit-wise reproducibility is valid
   only when a single CUDA stream is active."

2. Don't reset gpu_evaluator between folds. Reusing the same
   CublasForward instance keeps cuBLAS internal state stable.

Training is fully bit-identical across runs (verified with per-step
weight checksums). Evaluation still has minor variation from
cublasLtMatmul internal non-determinism on first call — this is an
NVIDIA library limitation that only affects displayed val_Sharpe,
not training weights or hyperopt model selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:27:07 +02:00
jgrusewski
1fc9f4c10e fix: remove determinism diagnostics, restore IQL step
Training is CONFIRMED BIT-IDENTICAL across runs (all per-step weight
checksums match). The remaining val_Sharpe variation is from the
backtest evaluator's cublasLt forward pass (evaluation-only, does not
affect training weights or hyperopt selection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:04:06 +02:00
jgrusewski
349885cc6e feat: deterministic IQN + attention backward, PEDANTIC cuBLAS, algo cache
Eliminate training non-determinism from three major sources:

1. IQN backward: split iqn_backward_kernel into iqn_backward_per_sample
   (saves dL_dq[B,N], d_h_s2 via register accumulation) +
   iqn_weight_grad_reduce (deterministic per-parameter reduction from
   dL_dq + saved activations). Two-phase grad norm, deterministic loss
   reduce. Zero atomicAdd in IQN training path.

2. Attention backward: per-sample d_params buffer replaces cross-batch
   atomicAdd on weight gradients. attn_weight_grad_reduce sums across
   samples deterministically. Two-phase grad norm.

3. cublasLt: COMPUTE_32F_FAST_TF32 → COMPUTE_32F_PEDANTIC (disables
   TF32 19-bit rounding). New cublas_algo_cache module serializes
   heuristic-selected algo structs to disk (config/cublas_algo_cache.json)
   with CUDA version + GPU name validation. Eliminates algo selection
   variability between process invocations (91/91 cache hits verified).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:43:53 +02:00
jgrusewski
3d15e26b43 feat: deterministic training — eliminate atomicAdd, seed all RNG, FP32 cuBLAS
Full-stack determinism for reproducible training and valid hyperopt comparisons.

CUDA gradient kernels (zero atomicAdd):
- c51_grad_kernel: restructured from B×4×NA to B×NA threads, each loops 4 branches
  d_value accumulates in register, d_adv written directly (unique slot per thread)
- mse_grad_kernel: same restructure, zero atomicAdd
- bn_bias_grad_kernel: plain write (was unnecessary atomicAdd, one thread per slot)

CUDA loss kernels (deterministic reduction):
- c51_loss_batched: removed atomicAdd(total_loss), per_sample_loss written directly
- mse_loss_batched: same removal
- c51_mixup_ce: same removal
- New c51_loss_reduce kernel: sequential sum grid=(1,1,1) for deterministic total_loss

cuBLAS deterministic GEMM:
- CUBLAS_TF32_TENSOR_OP_MATH → CUBLAS_DEFAULT_MATH (both forward and backward)
- Forces IEEE FP32 accumulation, eliminates TF32 reduction non-determinism

Deterministic RNG seeds (all GPU + CPU):
- Experience collector: fastrand → LCG with fixed seed 0xDEAD_BEEF
- Backtest evaluator: fastrand → LCG with fixed seed 0xBAC0_7E57
- PPO collector: fastrand → LCG with fixed seed 0xAA0_5EED
- Stochastic depth: process ID → fixed seed 0x5D5E_ED00
- CPU RNG: rand::thread_rng() → StdRng::seed_from_u64() in IQN, HER, IQL, action.rs

Adaptive tau → cosine-annealed tau:
- Disconnected q_divergence atomicAdd from training path
- q_divergence is monitoring-only (non-deterministic acceptable)
- Cosine schedule provides smooth tau adaptation without stochastic coupling

Result: epochs 1-2 are bit-identical across runs. Divergence at epoch 3
from remaining C51 loss kernel atomicAdd on q_divergence (monitoring-only,
does not affect gradients). 903/903 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:45:14 +02:00
jgrusewski
c05f75d0d8 feat: adaptive v_range reward-scale floor from observed reward_std
The hardcoded REWARD_SCALE_FLOOR=0.01 was tuned for smoketest (reward_std=0.007)
but 940× too small for production (reward_std=6.57). The v_range floor must match
the actual reward scale to ensure the Bellman projection can shift atoms meaningfully.

- adapt_v_range_full takes observed reward_std from experience collector
- EMA-smoothed reward_std (β=0.99) prevents single-epoch noise from jerking floor
- Falls back to 0.01 before first observation, then adapts automatically
- reward_std_ema field on GpuDqnTrainer, observed_reward_std on DQNTrainer
- Decaying floor uses actual reward scale: floor = R_std * exp(-|Q_mean|/R_std)

903/903 tests passing. Smoketest val_Sharpe positive (60.76 epoch 1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:03:44 +02:00
jgrusewski
9054b6aefb feat: gap-aware v_range with decaying reward-scale floor
Q-gap drives v_range contraction: atoms are distributed to maximize action
discrimination, not just cover the Q-range. Target: Q-gap spans 10 atoms
(20% of 52), giving C51 enough resolution for distributional value.

Decaying reward-scale floor: starts at 0.01 (breaks fixed point at init),
decays as exp(-|Q_mean|/0.01) as Q-values mature. Automatically transitions
from "Bellman projection headroom" to "tight atom resolution" without
requiring step counters or epoch awareness.

903/903 tests passing. Q-gap grows 72× (2.7e-6 → 1.93e-4) through epoch 17.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:34:16 +02:00
jgrusewski
d42a8f4000 fix: reward-scale v_range floor + fold-boundary shrink-and-perturb
Breaks C51 stable fixed point: tight v_range → tiny Bellman shift → tiny gradient
→ Q-values stuck near zero → v_range stays tight (self-reinforcing trap).

- REWARD_SCALE_FLOOR=0.01 replaces ABS_FLOOR=1e-4 (was 1400× too small)
- v_range floor proportional to single-step reward magnitude, not Q-statistics
- Ensures Bellman projection can shift atoms by at least 1 reward unit
- Removes pessimistic Q-init bias (incompatible with adaptive v_range)

Fold transition stability:
- Shrink-and-perturb at fold boundary (alpha=0.8, sigma=0.01)
- Reduces overfit to previous fold's data distribution
- Eliminates val_Sharpe=-33 crash on fold 2/3 transitions

Result: val_Sharpe positive across all 50 epochs and 3 walk-forward folds.
Q-values grow monotonically (0→0.023), Q-gap grows 245× (2.7e-6→6.6e-4).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:25:38 +02:00
jgrusewski
b21c6d5cff feat: comprehensive C51 training stability overhaul — Q-stats diagnostics, adaptive atoms, gradient safety
Major architectural fixes discovered through systematic investigation:

Q-gap measurement (3 bugs):
- compute_expected_q never ran during training → q_out_buf was zeros
- epoch_q_gap reset in process_epoch_boundary before logging read it
- flush_q_stats_readback drained async readback before in-loop read

Zero-copy pinned memory (4 hot-path scalars):
- t_buf, tau_buf, v_range_buf, adaptive_clip_buf → pinned device-mapped
- GPU reads via cuMemHostGetDevicePointer, host writes directly, no HtoD

Q-stats-driven adaptive v_range:
- v_range = q_mean ± 3σ + Bellman headroom (was fixed ±1.0)
- Adaptive MIN_RANGE scales with |Q_mean| (was fixed 0.02)
- Per-step adaptation (was every 50 steps)
- 100× finer atom resolution from epoch 1

Gradient stability:
- IS-weight clamp at 10.0 in all loss/grad kernels (PER spike prevention)
- 3 power iterations in spectral norm (was 1 — underestimated sigma)
- Bottleneck w_bn added as 13th spectral-normed matrix (was missing)
- Pre-Adam grad_buf clip via clip_grad_buf_inplace (activation amplification)
- EMA-based adaptive gradient clipping (pinned device buffer)
- Consolidated grad_norm to single buffer (was 2 — eliminated grad_norm_f32_buf)

Adaptive tau from online-target Q-divergence:
- C51 loss kernel accumulates (E[Q_online] - E[Q_target])² per batch
- Tau scales with sqrt(divergence/baseline), clamped [0.5×, 10×] base
- Accelerates target convergence during discovery, stabilizes during plateau

Deterministic evaluation:
- eval_mode in action_select kernel: pure greedy argmax, no Boltzmann/RNG
- Eliminated ±40 val_Sharpe noise from near-uniform Boltzmann sampling
- Backtest evaluator uses adaptive v_range (was config v_min/v_max — 1500× mismatch)

Atom utilization metrics:
- compute_expected_q accumulates entropy + utilization per step
- q_stats_kernel extended to 7 outputs (was 5)
- Logged per epoch: atoms=98%ent/92%util

Pessimistic Q-init removed — incompatible with adaptive v_range (bias was
255× outside ±0.01 support, causing 5-epoch cold-start and late Q-value drift).

903/903 tests passing. val_Sharpe positive from epoch 1 with greedy eval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:10:25 +02:00
jgrusewski
0d534f1dc0 feat: dense OFI-alignment reward — state-conditional direction signal every bar
Replaces dead Gem 1 micro-reward (used future price = hindsight leak) and
moves OFI-alignment from sparse (trade completion) to dense (every positioned bar).

Uses CURRENT-BAR observable OFI features:
- depth_imbalance (70%): bid vs ask volume ratio from MBP-10
- trade_imbalance (30%): inferred trade direction flow
Rewards Long when order flow says buy pressure, Short when sell pressure.

This gives the direction advantage head a STATE-CONDITIONAL signal:
"Long is better than Short WHEN order book shows buy pressure."
Without this, direction rewards are 50/50 (price is random) and the
advantage gradient cancels via dueling mean-subtraction.

Combined with dense DSR, every positioned bar now gets:
1. DSR: "is holding improving Sharpe?" (consistency signal)
2. OFI-alignment: "does order flow confirm your direction?" (microstructure signal)
3. Inventory penalty: "holding costs money" (urgency signal)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:17:26 +02:00
jgrusewski
21bd05c9a2 feat: advantage noise kernel — breaks dueling symmetry trap (dead NoisyLinear replacement)
NoisyLinear was completely dead after cuBLAS migration — neither training
nor experience collection applied ANY exploration noise. With identical
Q-values across actions, Boltzmann selection was uniform, making the
dueling mean-subtraction cancel ALL advantage gradients. The advantage
heads could NEVER learn (Q-gap permanently 0.0000).

Fix: add_advantage_noise CUDA kernel injects per-action Gaussian noise
into Q-values AFTER compute_expected_q, BEFORE action selection.
- Philox PRNG + Box-Muller for GPU-native Gaussian sampling
- Per-sample, per-action noise (breaks within-batch symmetry)
- Only during experience collection (not training targets)
- noise_sigma=0.1 (configurable, TOML + hyperopt tunable)

The noise creates non-uniform Boltzmann selection → different actions
have different frequencies → advantage gradient survives the dueling
mean-subtraction → Q-gap can grow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:01:52 +02:00
jgrusewski
6989598150 feat: Q-gap + Q-variance diagnostics — validates state discrimination
Q-gap = max_a Q(s,a) - mean_a Q(s,a): measures action preference per state.
Q-var = Var_s[Q(s,a)]: measures state differentiation across the batch.

Both are 0.0000 through all 10 smoketest epochs despite Sharpe 8+ and
Q-values growing to 0.012. This proves the good metrics are from
reward-induced mechanical bias, NOT learned Q-values.

Also: c51_warmup_epochs=0 (C51 from step 1, bypass MSE dead zone),
smoketest lr=1e-4 (50× higher, makes 40 steps representative).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:07:54 +02:00
jgrusewski
8d9bf15cfa feat: dense DSR — per-bar Sharpe contribution when positioned, flat excluded
Sparse DSR (trade completion only) left 80% of bars with near-zero reward.
Q-values couldn't grow. Dense DSR fires EVERY bar when positioned:
- r_bar = vol-normalized unrealized PnL per bar
- EMA statistics update only from positioned bars (flat excluded)
- DSR reward = how much this bar's PnL improves the running Sharpe
- Flat bars get zero DSR — safe default, no penalty for not trading

Naturally adaptive: high-vol → variance grows → DSR shrinks per unit
return → model becomes conservative. Low-vol → DSR amplifies.

Smoketest: WinRate 52-55% (was 44%), Sharpe peaks at 11.53, PF 1.67.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:48:15 +02:00
jgrusewski
1f6a8436d3 feat: v9 Differential Sharpe Ratio reward — optimize for consistency, not PnL
Fundamental shift: reward optimizes Sharpe ratio contribution per trade
instead of directional PnL magnitude. Targets Sharpe 5+ via many small
consistent gains (spread capture, execution quality) instead of few
large directional bets.

DSR implementation (Moody & Saffell, 2001):
- Per-episode EMA statistics in portfolio state slots [3]-[5]
- DSR = (B*R - 0.5*A*R²) / (B - A²)^1.5 at trade completion
- 10-trade warmup, clamped [-5, +5]
- w_dsr=5.0 (primary reward signal)

Reward hierarchy restructured:
- DSR: 5.0 (NEW — primary, rewards Sharpe consistency)
- Directional PnL: 2.0 (was 10.0 — demoted to secondary)
- Order credit: 1.0 (was 0.1 — spread capture amplified 10×)
- Urgency credit: 0.5 (was 0.1 — fill quality amplified 5×)
- Inventory penalty: -0.005×|pos|/max_pos per bar (NEW)
- Dense micro-reward: 0.0 (removed — noisy direction signal)

Expected: WinRate increases (many small spread captures), per-trade
variance decreases, Sharpe rises from consistency not prediction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:33:56 +02:00
jgrusewski
42de872f1a fix: gradient_clip_norm 1.0→10000 — safety-only clip for unclipped gradient pipeline
With per-component clipping removed, raw gradient norms are ~4000.
The old gradient_clip_norm=1.0 (calibrated for pre-clipped norms ~7)
discarded 99.97% of gradient magnitude every step, making the effective
learning rate 570× too small. Q-values grew 9× slower than old run.

10000 lets normal gradients (~4000) pass through unclipped. Only fires
on genuine divergence spikes (>2.5× normal). Adam's internal adaptive
step sizing handles the rest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:35:27 +02:00
jgrusewski
3a16aeacfd fix: remove per-component gradient clipping — restores Adam's adaptive step size
Per-component budget clipping (C51→4.5, IQN→1.0, CQL→1.0) produced a
CONSTANT gradient norm every step (0.655 old split, 0.756 new split).
Adam's second moment v[i] converged to a constant, making
m_hat/sqrt(v_hat) → sign(m_hat) — Adam degenerated into SignSGD.
The optimizer could not adapt step sizes to loss landscape curvature.

Fix: remove all per-component clips. Loss weights (iqn_lambda=0.25,
CQL coefficient, ensemble weight) control component balance. Single
global clip inside Adam kernel is the sole safety mechanism.

Saves 9 kernel launches per training step:
- 3× grad_norm phase1+phase2 removed (IQN, CQL, ensemble)
- 3× clipped_saxpy → plain saxpy_f32 (no norm computation needed)
- 1× primary budget clip_grad_buf_inplace removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:48:26 +02:00
jgrusewski
fbe185cb92 feat: configurable gradient budget (IQN 75%→40% default, C51 10%→45%)
Hardcoded IQN_GRAD_BUDGET=0.75 starved C51 directional learning:
grad_norm frozen at 0.655 for 20+ epochs, WinRate stuck at 44-46%.
IQN dominated the gradient, C51's contribution after budget clipping
was effectively zero.

Now configurable via DQNHyperparameters:
- iqn_grad_budget: 0.40 (was 0.75 const)
- cql_grad_budget: 0.10 (was 0.10 const)
- ens_grad_budget: 0.05 (was 0.05 const)
- C51 gets remainder: 1.0 - 0.40 - 0.10 - 0.05 = 0.45 (was 0.10)

Wired through GpuDqnTrainConfig, TOML profiles, hyperopt search space.
Hyperopt can now tune the gradient budget split.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:58:48 +02:00
jgrusewski
91e05990a9 fix: node-bootstrap resolv.conf keeps VPC upstream as DNS fallback
Root cause of DNS deadlock (2026-03-18, 2026-04-12): node-bootstrap
DaemonSet overwrote /etc/resolv.conf with ONLY nameserver 10.32.0.10
(cluster DNS). When nodes rebuild, CoreDNS can't pull its image because
containerd resolves via 10.32.0.10 which requires CoreDNS to be running.

Fix: resolv.conf now has DUAL nameservers:
  nameserver 10.32.0.10          # primary: cluster DNS
  nameserver 169.254.169.254     # fallback: Scaleway metadata DNS

Containerd tries kube-dns first (fast, resolves .svc.cluster.local),
falls back to VPC resolver when kube-dns is unreachable. Breaks the
chicken-and-egg permanently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:49:45 +02:00
jgrusewski
51dd530c35 fix: update training_profile test assertions for epochs=10, num_atoms=52, explicit v_min/v_max
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:51:34 +02:00
jgrusewski
eb7139c436 feat: adaptive C51 v_range — Q-values 0.008→0.525 (65× improvement)
Fundamental fix: C51 atom spacing (dz=0.6) was larger than the Q-value
range (0.02), making the distributional loss unable to resolve rewards.
The network was blind to its own learning signal.

Adaptive v_range via device buffer (same pattern as tau_buf):
- v_range_buf[2] on GPU, all C51/MSE/CQL/expected_q kernels read via
  pointer (graph captures stable address, reads value at replay time)
- Cold start: v_range=±1.0 → dz=2/51=0.039 → 25× atom resolution
- Monotonic expansion based on Q-stats every 50 training steps
- Never shrinks (avoids invalidating Bellman targets in replay buffer)

Kernel changes (5 files):
- c51_loss_kernel.cu, mse_loss_kernel.cu, mse_grad_kernel.cu,
  cql_grad_kernel.cu, compute_expected_q: scalar v_min/v_max → pointer

Also fixed:
- PopArt warmup 100→1 (start normalizing from step 1)
- PopArt variance floor 0.0001→1e-8 (allow sigma < 0.01)
- Backtest evaluator: allocate own v_range_buf for compute_expected_q
- num_atoms 51→52 in all TOMLs (TF32 4-element alignment)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 07:58:02 +02:00
jgrusewski
dab4478b47 fix: production v_min/v_max ±15→±50 for gamma=0.99 + PopArt GPU buffer reset
v_min/v_max ±15 with gamma=0.99 only covers 15% of theoretical Q range
(Q_max = 1/(1-0.99) = 100 for unit-variance PopArt rewards). C51 top atom
saturates on sustained winners, degrading distributional learning. ±50
covers 95th percentile.

PopArt GPU buffers (popart_mean, popart_var, popart_count) were never
zeroed between walk-forward folds — fold 2's reward normalization was
contaminated by fold 1's statistics. Now reset alongside Adam state in
reset_adam_state().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 06:56:37 +02:00
jgrusewski
e18ab46e13 fix: min_epochs_before_stopping 5→10 — prevents early stopping in 10-epoch smoke tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:00:12 +02:00
jgrusewski
d391e308d1 fix: max_dirmag 7→9 — report against full dir×mag combinatorial space
Flat+epsilon can hit any of the 9 dir×mag combos. Reporting against 7
(old reachable-without-epsilon count) showed 9/7 which is confusing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:44:13 +02:00
jgrusewski
320a64bb90 fix: monitoring 7→9 bin readback mismatch — diversity 15/81 → 81/81
The monitoring summary layout was shifted by 2 positions:
- Kernel writes: summary[5..14]=exp[9], [14..17]=order[3], [17..20]=urgency[3]
- Rust read (old): raw[5..12]=exp[7], raw[12..15]=order, raw[15..18]=urgency
- Rust read (new): raw[5..14]=exp[9], raw[14..17]=order, raw[17..20]=urgency

When num_actions changed 7→9, exp[7] and exp[8] became non-zero but Rust
read them as order_counts — the "order=3/3" was actually exp[7]+exp[8]+order[0].

Also fixed:
- MonitoringSummary.action_counts: [usize; 7] → [usize; 9]
- All downstream: monitoring.rs, metrics.rs, financials.rs, training_loop.rs
- Flat cell indices 3→{3,4,5} in diversity threshold
- exposure_names array expanded to 9 entries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:42:28 +02:00
jgrusewski
e2bf655be2 fix: order diversity 1/3→3/3 — three root causes found and fixed
1. expected_q_kernel layout mismatch: cuBLAS writes branch-major logits
   [Branch0: B×b0×NA | Branch1: ...] but kernel read sample-major
   [Sample0: 12×NA | Sample1: ...]. Fixed to use branch_logit_offset
   accumulator matching cuBLAS output layout.

2. Duplicate expected_q_kernel.cu deleted — single implementation in
   experience_kernels.cu. Trainer loads from experience_kernels.cubin.

3. Monitoring num_actions=7 (old 7-level exposure) → 9 (dir×mag = 3×3).
   Actions with dir*mag≥7 silently dropped from order/urgency counts,
   making it appear only 1 order type was active.

Result: Action diversity 15/81 (18.5%) → 45/81 (55.6%), order=3/3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:34:53 +02:00
jgrusewski
ad5c2b7ded refactor: exp collector zero-copy — direct pointer into trainer params_buf
Dead code sweep (167 lines deleted):
- online_params_flat (synced but never read by forward)
- online_params_f32 (never synced, always zeros — ROOT CAUSE of garbage Q-values)
- DuelingWeightSet/BranchingWeightSet/CuriosityWeightSet/RmsNormWeightSet fields
- sync_weights_flat, sync_weights_f32, flatten_online_weights methods
- sync_gpu_weights epoch-boundary DtoD copy

Zero-copy replacement:
- trainer_params_ptr: u64 points directly into trainer's params_buf
- param_sizes computed from real config (bottleneck_dim=16, market_dim=42)
- CublasForward with correct s1_input_dim=54 for bottleneck
- Bottleneck forward activated (bn_hidden + tanh + concat)
- Fused context init moved before Phase 2 (experience collection)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:10:25 +02:00
jgrusewski
dd6143936e fix: IQN 4-branch runtime + order counterfactual + per-branch entropy + histogram
IQN kernel rewrite (critical — was corrupting 75% of gradient budget):
- Eliminate compile-time BRANCH_*_SIZE defines (BRANCH_0_SIZE was 5, should be 3)
- All 9 IQN kernels now take runtime b0/b1/b2/b3 params
- 4-branch forward, backward, decode (was 3-branch, missing magnitude)
- branch_actions buffer B*3 → B*4

Action diversity infrastructure:
- 3-cycle counterfactual: dir mirror / mag relabel / order-type cost delta (50× amplified)
- Per-branch entropy: order (d==2) gets 3× boost in C51 + MSE grad kernels
- Position histogram expanded 6→12 bins (all 4 branches get entropy bonus)
- Boltzmann temperature floor raised to 0.5 for order + urgency branches
- Smoke test epochs 3→10 for diversity verification

Note: experience collector still needs bottleneck forward path — currently reads
trainer's bottleneck-layout weights with state_dim K, producing misaligned Q-values.
Next: eliminate weight copy, use direct pointer views into trainer's params_buf.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:40:47 +02:00
jgrusewski
63006bb870 fix: xavier_init_params_buf + cuBLAS tile padding + bottleneck GEMM dims — 0 compute-sanitizer errors
VarStore elimination completion:
- xavier_init_params_buf(): CPU Xavier init directly into flat params_buf + target_params_buf
  using compute_param_sizes() for correct bottleneck dims (s1_input_dim=54, not state_dim=80)
- Pad all cuBLAS-accessed flat buffers (grad/m/v/cql/vaccine + IQN + evaluator)
  with cutlass_tile_pad to prevent SGEMM 32-element M-tile overreads
- Fix forward+backward GEMM for layer s1: use s1_input_dim/s1_ldb when bottleneck
  active (was state_dim=80, writing 80-wide rows into 54-wide bn_d_concat_buf)
- Pad bn_concat_buf/bn_d_concat_buf for K-tile overreads (concat_dim=54 not multiple of 32)
- Vaccine gradient: eliminate 2× DtoD copies with std::mem::swap on CudaSlice structs
- Fix IQN Xavier init to include branch_3 (was [b0,b1,b2], now [b0,b1,b2,b3])
- Pad backtest evaluator buffers (params_flat, activations, logits)

Verified: compute-sanitizer --tool memcheck → 0 errors, 286+57 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:43:27 +02:00
jgrusewski
31c610b69a WIP: training quality fixes + VarStore elimination (INCOMPLETE — needs xavier_init_params_buf)
Phase 1 — Training quality (14 structural bugs):
- Reward: sqrt micro-reward scaling, remove sparse normalization, Kelly penalty,
  additive OFI, magnitude-scaled capital floor penalty
- Gradient: CQL 25→10%, IQN 60→75%, MSE magnitude 4×, 3-phase counterfactual,
  tau_anneal 100K→500K, epsilon floor 2%, PopArt fold reset, dd_threshold 0.5%
- Monitoring: per-branch Q-value instrumentation
- Hold: min_hold_bars 5→1

Phase 2 — VarStore elimination:
- Deleted copy_weights_from from all network types
- Deleted to_varstore from DuelingQNetwork + DistributionalDuelingQNetwork
- Deleted branching_to_varstore, 7 extract/sync VarStore functions
- Deleted update_target_networks, legacy CPU training paths
- Deleted iqn_target_network, target_network fields
- Deleted gpu_kernel_parity_test.rs (705 lines)
- Refactored GpuExperienceCollector to single flat buffer
- Added weight_sets_from_branching() zero-copy replacement
- EMA tau=1.0 init for target_params_buf

KNOWN ISSUE: params_buf is zero-initialized but from_flat_buffer() creates
weight set pointers into it BEFORE flatten_online_weights runs. The online
forward reads zeros → NaN. Needs xavier_init_params_buf() at construction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:03:05 +02:00
jgrusewski
9f18772527 fix: update all test/example files for VarStore removal + 7-level exposure
Add to_varstore() compatibility shims on DuelingQNetwork and
DistributionalDuelingQNetwork so test/example code can rebuild a
GpuVarStore snapshot when needed. Delete dead tests that referenced
removed DQNAgent, PrioritizedReplayBuffer, and ReplayBufferType.
Fix action index references (action_19/21 -> action_28/30) and
type annotation issues (sin ambiguity, remainder operator).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:21:21 +02:00
jgrusewski
9c139586f3 refactor: zero-copy weight views from params_buf — VarStore bypass complete
DuelingWeightSet/BranchingWeightSet now created via from_flat_buffer()
pointing directly into GpuDqnTrainer's params_buf. No intermediate
VarStore extraction, no D2D copies, no flatten/unflatten cycle for
the primary weight sets.

bottleneck_dim=16 now works — smoke test passes. The dimension mismatch
is resolved because params_buf is allocated at the correct reduced
dimension, and weight sets point directly into it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:49:53 +02:00
jgrusewski
7cf2cfdc39 cleanup: remove ALL GpuVarStore from DQN path + re-enable bottleneck_dim=16
- Added OwnedGpuLinear to ml-core (self-contained linear layer with owned
  weight/bias CudaSlice, forward_with_slices bypasses GpuVarStore lookup)
- Removed GpuVarStore from all 12 ml-dqn modules: Sequential, branching,
  distributional_dueling, dueling, network, rmsnorm, residual, curiosity,
  attention, iql, quantile_regression, regime_conditional
- Removed get_q_network_vars/vars/store accessors from DQN, branching,
  distributional_dueling, dueling, network, quantile_regression
- Replaced GpuLinear+GpuVarStore with OwnedGpuLinear in all cold-path modules
- Made load_from_safetensors a no-op (flat buffer path handles checkpoints)
- Made sync_to_varmap a no-op (flat buffer is source of truth)
- Moved branching->VarStore conversion to gpu_weights::branching_to_varstore
- Re-enabled bottleneck_dim=16 in config defaults + all 3 TOML configs
- Removed flatten_online_weights bottleneck skip workaround
- Zero GpuVarStore references in crates/ml-dqn/src + crates/ml/src/trainers/dqn

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:42:54 +02:00
jgrusewski
1c3eb3e331 refactor: eliminate GpuVarStore — DuelingWeightSet is zero-copy pointer view into params_buf
DuelingWeightSet and BranchingWeightSet fields changed from owned
CudaSlice<f32> to raw u64 device pointers + usize element counts.
This enables zero-copy views directly into the flat params_buf for
the training hot path (no D2D copies, no shadow allocations).

Key changes:
- Weight sets store u64 + usize pairs instead of CudaSlice<f32>
- from_flat_buffer() creates zero-copy views into params_buf
- from_slices() creates pointer views from owned CudaSlice allocations
- DuelingWeightBacking/BranchingWeightBacking hold owned CudaSlice
  arrays for callers that need independent allocations (experience
  collector, ensemble heads, tests)
- flatten/unflatten become no-ops when weight sets point into params_buf
- extract functions return (Backing, WeightSet) tuples
- KernelWeightPack::build() uses u64 fields directly (no raw_device_ptr)
- All sync functions updated for pointer-based signatures
- Ensemble clone functions return backing + pointer view pairs
- gradient_budget tests updated (7/7 pass)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:02:55 +02:00
jgrusewski
a662e54e86 plan: VarStore elimination — zero-copy pointer views into flat params_buf (8 tasks)
DuelingWeightSet becomes raw u64 pointers into params_buf, not separate
CudaSlice allocations. Eliminates ALL D2D copies between weight
representations. flatten/unflatten cycle deleted entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:35:15 +02:00
jgrusewski
1a3da6f6b0 spec: Candle GpuVarStore elimination — 40+ call sites, 15 files, blocks bottleneck
VarStore is a CPU-trip corrupting data + degrading performance. Must be
replaced with direct pointer buffers (flat params_buf is already the
source of truth). Checkpoint, weight extraction, and polyak updates
all need porting to direct CudaSlice operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:30:12 +02:00
jgrusewski
d797661bbe fix: revert bottleneck_dim to 0 — blocked by Candle VarStore dimension mismatch
bottleneck_dim=16 causes w_s1 size mismatch between VarStore (full state_dim)
and flat params_buf (reduced bottleneck_concat_dim). EMA target sync crashes
with CUDA_ERROR_ILLEGAL_ADDRESS.

Root cause: GpuVarStore allocates weights at full state_dim but GpuDqnTrainer's
flat buffer uses bottleneck-reduced dimensions. These two weight sources are
fundamentally incompatible when bottleneck is active.

Fix: eliminate GpuVarStore entirely (next task), then re-enable bottleneck.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:28:51 +02:00
jgrusewski
0a68bb9713 feat: ensemble variance as exploration bonus — high disagreement = explore
Launch ensemble_aggregate_kernel per training step to populate
ensemble_var_q_buf with per-atom C51 logit variance across K heads.
Expose via ensemble_var_ptr() on FusedTrainingContext. Add
ensemble_exploration_beta config (default 0.2). Wire into
set_ensemble_variance_bonus() on GpuExperienceCollector which computes
beta * sqrt(mean_var) and tiles it as a constant SAXPY bonus over all
Q-values before Boltzmann action selection. No-op when ensemble_count <= 1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:05:40 +02:00
jgrusewski
c246afbbdb feat: bottleneck 2→16 — 8× more market feature information for all branches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:59:38 +02:00
jgrusewski
c57bd250a6 feat: IQN quantile spread (IQR) as anti-collapse exploration bonus
IQR (Q75-Q25) from IQN's 32 quantile estimates is computed per-action
after each training step and added to Q-values before Boltzmann action
selection. High-IQR actions (uncertain outcomes, typically large-magnitude
positions) get an exploration boost, countering C51's structural bias
toward low-variance Small actions that causes magnitude collapse.

Pipeline: IQN train -> compute_iqr() -> set_iqr_bonus() -> SAXPY on
q_values [N, q_stride] each experience timestep. Alpha default 0.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:56:26 +02:00
jgrusewski
81538081bc feat: hindsight magnitude relabeling via alternating counterfactual
Even timesteps use the existing directional mirror (Short<->Long, negated
reward).  Odd timesteps substitute a different magnitude and linearly scale
the reward by alt_mag_frac / actual_mag_frac, giving the magnitude branch
direct gradient signal about what bigger or smaller positions would have
earned.  Falls back to directional mirror when reward is near-zero (scaling
uninformative).  No extra buffer needed -- reuses the existing N*L
counterfactual slot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:47:36 +02:00
jgrusewski
8d568158a7 feat: gradient vaccine every 5 steps (was 10) — more diversity correction 2026-04-11 12:42:28 +02:00
jgrusewski
59695d67b7 feat: per-branch epsilon — magnitude 2× exploration, Boltzmann for order/urgency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:39:02 +02:00
jgrusewski
d6ab83d37c fix: c51_alpha starts at 0.5 from epoch 0 — eliminates 3 graph recaptures per fold
Set c51_warmup_epochs default to 0. The ramp logic in training_loop.rs
already returns alpha_max immediately when c51_warmup_epochs==0, so all
epochs now see c51_alpha=0.5 from the first step. Stable loss landscape,
no CUDA graph recaptures from alpha transitions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:30:59 +02:00
jgrusewski
370319f7c8 plan: ABI Complete — per-branch epsilon, IQN anti-collapse, hindsight relabeling, vaccine, ensemble, bottleneck (8 tasks)
Unified plan combining Sub-plans B+C + novel findings:
1. Fix c51_alpha ramp (eliminate 3 graph recaptures)
2. Per-branch epsilon (magnitude 2×, Boltzmann for order/urgency)
3. IQN quantile spread as anti-collapse bonus
4. Hindsight magnitude relabeling
5. Gradient vaccine with diverse batches
6. Ensemble variance at inference
7. Per-branch bottleneck width
8. Full verification + deploy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:27:56 +02:00