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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>