# Design Spec B: DQN CUDA Training Pipeline -- Correctness Bug Fixes **Date**: 2026-03-27 **Spec**: B of 3 (A = Performance, B = Correctness, C = Dead code) **Scope**: 9 CRITICAL + 14 IMPORTANT bugs from 9-agent audit **Non-goals**: Performance optimization (Spec A), dead code removal (Spec C) --- ## 1. Problem Statement A 9-agent audit of the DQN CUDA training pipeline uncovered 23 correctness issues. Nine are CRITICAL (produce wrong training results), and 14 are IMPORTANT (hide problems via silent fallbacks). One item initially flagged as critical (NoisyNet in GPU action selector) was reclassified as NOT A BUG after investigation -- see Section 3.9 for the architectural rationale. The bugs fall into four categories: 1. **Incomplete state resets** -- backtest kernel resets 5 of 8 portfolio fields on episode termination, while the experience kernel resets all 20. Stale `max_equity`, `hold_time`, and `entry_price` leak across episodes. 2. **Inconsistent hyperparameter defaults** -- gradient clip norm defaults to 10.0 in the main trainer but 1.0 in IQN/IQL sub-trainers; entropy coefficient is `Option` with three different fallback values across constructors. 3. **Silent fallbacks masking data errors** -- `unwrap_or(1.0)` on close prices, `unwrap_or(0.0)` on quantile predictions, `unwrap_or(2)` on action indices. A single bad data row produces wrong rewards for an entire episode without any log message. 4. **Side effects in read-only paths** -- `step_count` increments during inference forward passes, and CUDA contexts are created 3x per quantile loss call. --- ## 2. Files Changed | File | Bugs | |------|------| | `crates/ml/src/cuda_pipeline/backtest_env_kernel.cu` | #1, #2, #11 | | `crates/ml/src/trainers/dqn/data_loading.rs` | #3, #15 | | `crates/ml/src/trainers/dqn/fused_training.rs` | #4 | | `crates/ml/src/trainers/dqn/trainer/constructor.rs` | #5 | | `crates/ml-dqn/src/quantile_regression.rs` | #6, #7 | | `crates/ml-dqn/src/network.rs` | #8 | | `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` | #10 | | `crates/ml/src/cuda_pipeline/experience_kernels.cu` | #14 | | `crates/ml-dqn/src/branching.rs` | #16 | | `crates/ml/src/trainers/dqn/trainer/action.rs` | #15 | | `crates/ml/src/trainers/dqn/trainer/state.rs` | #15 | | `crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs` | #12, #13 | | `crates/ml/src/trainers/dqn/config.rs` | #5 | --- ## 3. CRITICAL Bugs (9 items -- wrong results) ### 3.1 Backtest incomplete episode reset **Location**: `backtest_env_kernel.cu:105-112` (pre-trade floor) and `backtest_env_kernel.cu:199-206` (post-trade floor) **Problem**: The backtest kernel uses `PORTFOLIO_STATE_SIZE = 8` fields per window. On capital floor breach, both reset blocks only partially reinitialize the state: ``` // Current (WRONG) -- pre-trade floor reset (lines 105-112): portfolio_state[ps + 0] = liq_value; // value portfolio_state[ps + 1] = 0.0f; // position portfolio_state[ps + 2] = liq_value; // cash portfolio_state[ps + 3] = 0.0f; // entry_price portfolio_state[ps + 4] = max_equity; // STALE -- not updated to new_value portfolio_state[ps + 5] = 0.0f; // hold_time portfolio_state[ps + 6] = cum_return + liq_ret; // ACCUMULATES across episodes portfolio_state[ps + 7] += 1.0f; // step_count INCREMENTS, never resets ``` Compare with the experience kernel (`experience_kernels.cu:614-630`) which resets all 20 fields to fresh-episode values: position=0, cash=initial_capital, portfolio_value=initial_capital, peak_equity=initial_capital, and zeroes all counters. **Impact**: After a capital floor breach, the backtest continues the next episode with (a) stale `max_equity` from the blown episode, which prevents the floor check from firing correctly in subsequent episodes, (b) accumulated `cum_return` that conflates multiple episodes, and (c) a `step_count` that never resets, making per-episode metrics meaningless. **Fix**: Reset all 8 backtest portfolio fields to initial-episode state at both reset sites. Use `liq_value` (or `new_value` for the post-trade block) as the new initial capital: ``` // Pre-trade floor reset (lines 105-112): portfolio_state[ps + 0] = liq_value; // value = initial capital for next ep portfolio_state[ps + 1] = 0.0f; // position = flat portfolio_state[ps + 2] = liq_value; // cash = initial capital portfolio_state[ps + 3] = 0.0f; // entry_price = none portfolio_state[ps + 4] = liq_value; // max_equity = reset to new initial portfolio_state[ps + 5] = 0.0f; // hold_time = 0 portfolio_state[ps + 6] = 0.0f; // cum_return = reset for new episode portfolio_state[ps + 7] = 0.0f; // step_count = reset for new episode // Post-trade floor reset (lines 199-206): identical pattern with new_value portfolio_state[ps + 0] = new_value; portfolio_state[ps + 1] = 0.0f; portfolio_state[ps + 2] = new_value; portfolio_state[ps + 3] = 0.0f; portfolio_state[ps + 4] = new_value; // max_equity = reset portfolio_state[ps + 5] = 0.0f; portfolio_state[ps + 6] = 0.0f; // cum_return = reset portfolio_state[ps + 7] = 0.0f; // step_count = reset ``` **Testing**: Add a backtest scenario where a window triggers the capital floor. Verify that the next episode starts with fresh `max_equity` equal to the liquidated equity, `cum_return = 0`, and `step_count = 0`. --- ### 3.2 Backtest stale max_equity before post-trade floor check **Location**: `backtest_env_kernel.cu:184` **Problem**: The post-trade capital floor check at line 184 runs `check_capital_floor(new_value, max_equity)` using the *pre-trade* `max_equity`. If the trade was profitable enough to set a new high-water mark, the floor check uses a stale denominator, potentially missing a breach or triggering a false one. The normal (non-done) path updates `max_equity` at line 214: `float new_max = fmaxf(max_equity, new_value);` -- but this happens *after* the floor check. **Fix**: Insert the max_equity update before the floor check: ``` float new_value = cash + position * close; // Update max_equity BEFORE floor check max_equity = fmaxf(max_equity, new_value); if (check_capital_floor(new_value, max_equity)) { ... ``` **Testing**: Construct a scenario where a profitable trade raises equity above the previous peak, then verify the floor check uses the updated peak. --- ### 3.3 Close price defaults to 1.0 **Location**: `data_loading.rs:306` **Problem**: When computing simple returns, the code uses: ```rust let prev = close_prices_f32.get(i - 1).copied().unwrap_or(1.0); let curr = close_prices_f32.get(i).copied().unwrap_or(prev); ``` If `close_prices_f32` contains any parse failures that result in a missing value, the fallback `1.0` is a valid-looking but completely wrong price (ES trades around 5000). The resulting return `(5000 - 1) / 1 = 4999` or `(1 - 5000) / 5000 = -0.9998` will dominate the entire episode's reward signal. Since `close_prices_f32` is an indexed `Vec` accessed by in-bounds index `i-1` and `i` where `i in 1..n`, the `.get()` calls can never actually return `None` -- the indices are always valid. However, the `unwrap_or(1.0)` gives a false sense of safety and suppresses any future index bugs. **Fix**: Replace `unwrap_or` with direct indexing (which panics on OOB, correctly surfacing the bug): ```rust let prev = close_prices_f32[i - 1]; let curr = close_prices_f32[i]; ``` Alternatively, use `expect("close price index in bounds")` if a message is preferred over a bare panic. **Testing**: Existing tests cover the happy path. Add a `#[should_panic]` test that calls the return computation with an empty `close_prices_f32` to confirm the panic behavior. --- ### 3.4 gradient_clip_norm inconsistent defaults **Location**: `fused_training.rs:194,263,298` **Problem**: Three config construction sites read `gradient_clip_norm` from the same `Option` hyperparameter but use different defaults: | Site | Default | Component | |------|---------|-----------| | `fused_training.rs:194` | `unwrap_or(10.0)` | Main C51 trainer | | `fused_training.rs:263` | `unwrap_or(1.0)` | IQL value trainer | | `fused_training.rs:298` | `unwrap_or(1.0)` | IQN dual-head trainer | | `constructor.rs:257` | `unwrap_or(10.0)` | CPU-side DQN config | When hyperopt omits `gradient_clip_norm`, the main trainer clips at 10.0 while IQN/IQL clip at 1.0. The gradient budget fractions in `gpu_dqn_trainer.rs` (line 857: `max_grad_norm * 0.10` for IQN, line 1098: `max_grad_norm * 0.05` for ensemble) assume the main norm is the ceiling. If the sub-trainers independently clip to 1.0, the total gradient budget accounting is wrong. **Fix**: Make `gradient_clip_norm` required (non-optional) in `DqnHyperparams`. Set the canonical default in `DqnHyperparams::default()` to `10.0`. Remove all `unwrap_or()` fallbacks -- the value is always present: ```rust // In DqnHyperparams: pub gradient_clip_norm: f64, // was Option // All construction sites: max_grad_norm: hyperparams.gradient_clip_norm as f32, ``` If making the field non-optional is too disruptive for serialization compatibility, keep `Option` but resolve it once at the top of `FusedTrainingCtx::new()` and pass the resolved value to all sub-configs: ```rust let grad_norm = hyperparams.gradient_clip_norm.unwrap_or(10.0); // Use `grad_norm` everywhere -- single source of truth ``` **Testing**: Verify that a training run with `gradient_clip_norm = None` produces the same effective clip norm (10.0) in all three sub-trainers. Add an assertion in `FusedTrainingCtx::new()` that the IQN and IQL configs use the same `max_grad_norm` as the main config. --- ### 3.5 entropy_coefficient Option mismatch **Location**: `config.rs:960`, `constructor.rs:314,399`, `dqn.rs:287,740`, `hyperopt/adapters/dqn.rs:418,2553` **Problem**: The `entropy_coefficient` field has a fragmented type/default chain: | Location | Type | Default | |----------|------|---------| | `DqnHyperparams` (`config.rs:960`) | `Option` | `None` | | `DQNConfig` (`dqn.rs:287`) | `f64` (non-optional) | `0.001` | | `constructor.rs:314` | unwraps Option | `unwrap_or(0.01)` | | `constructor.rs:399` | unwraps Option | `unwrap_or(0.01)` | | Hyperopt default (`adapters/dqn.rs:418`) | `f64` | `0.02` | | Hyperopt search range (`adapters/dqn.rs:517`) | `f64` | `(0.05, 0.5)` | | Hyperopt to hyperparams (`adapters/dqn.rs:2553`) | `Some(f64)` | from PSO | The constructor's `unwrap_or(0.01)` disagrees with `DQNConfig::default()` (0.001) and with hyperopt's default (0.02). When hyperopt runs, it always provides a value via `Some(params.entropy_coefficient)`, so the disagreement only matters for manual (non-hyperopt) training runs. **Fix**: Make `entropy_coefficient` non-optional in `DqnHyperparams`. Set the canonical default to match the hyperopt search space center. Remove the `unwrap_or` in the constructor: ```rust // In DqnHyperparams (config.rs): pub entropy_coefficient: f64, // was Option // In constructor.rs: entropy_coefficient: hyperparams.entropy_coefficient, ``` Update `DqnHyperparams::default()` to use `0.01` as the default (midpoint between DQNConfig's 0.001 and hyperopt's 0.02 on log scale, and matching the old `unwrap_or`). **Testing**: Grep for all `entropy_coefficient.*unwrap` after the change and confirm zero hits in production code. --- ### 3.6 3x CUDA context creation in quantile loss **Location**: `quantile_regression.rs:320-346` **Problem**: `quantile_huber_loss()` creates a new `CudaContext::new(0)` three separate times in a single call: 1. Line 320-326: for the empty-batch early return `GpuTensor::scalar(0.0)` 2. Line 332-338: for `per_sample.to_host()` stream 3. Line 341-346: for the final `GpuTensor::scalar(mean_loss)` Each `CudaContext::new(0)` call invokes `cuCtxCreate` or `cuDevicePrimaryCtxRetain`, which is a heavyweight driver call. On the hot training path, this is called once per batch. Additionally, `quantile_huber_loss_per_sample()` creates its own context at line 369-374, for a total of 4 context creations per quantile loss computation. **Fix**: Create the context once at the top of `quantile_huber_loss()` and pass it (or the stream derived from it) to all operations: ```rust pub fn quantile_huber_loss(...) -> Result { let ctx = cudarc::driver::CudaContext::new(0) .map_err(|e| MLError::DeviceError(format!("CUDA context: {e}")))?; let stream = ctx.new_stream() .map_err(|e| MLError::DeviceError(format!("CUDA stream: {e}")))?; let per_sample = quantile_huber_loss_per_sample_with_stream( predicted, target, taus, kappa, &stream )?; let batch = per_sample.numel(); if batch == 0 { return GpuTensor::scalar(0.0, &stream); } let host = per_sample.to_host(&stream)?; let mean_loss: f32 = host.iter().sum::() / batch as f32; GpuTensor::scalar(mean_loss, &stream) } ``` Extract the inner loop of `quantile_huber_loss_per_sample` into a `_with_stream` variant that accepts an existing `&CudaStream`. **Testing**: Existing quantile loss tests cover correctness. No new tests needed -- this is a pure refactor. Verify tests pass after the change. --- ### 3.7 Silent 0.0/0.5 fallback in quantile loss **Location**: `quantile_regression.rs:385-387` **Problem**: Inside the per-sample quantile Huber loss loop: ```rust let pred_val = pred_host.get(idx).copied().unwrap_or(0.0); let tgt_val = tgt_host.get(idx).copied().unwrap_or(0.0); let tau_val = tau_host.get(idx).copied().unwrap_or(0.5); ``` The index `idx = b * num_q + q` with `b in 0..batch` and `q in 0..num_q` should always be in bounds since `pred_host` has exactly `batch * num_q` elements. If it is ever out of bounds, the loss computation silently uses `pred=0.0`, `target=0.0` (producing zero loss -- masking real errors) or `tau=0.5` (symmetric quantile -- incorrect for risk-sensitive IQN). **Fix**: Replace `unwrap_or` with direct indexing or `expect`: ```rust let pred_val = pred_host[idx]; let tgt_val = tgt_host[idx]; let tau_val = tau_host[idx]; ``` If bounds checking is desired for safety, use: ```rust let pred_val = *pred_host.get(idx).ok_or_else(|| { MLError::ModelError(format!( "pred OOB: idx={idx}, len={}", pred_host.len() )) })?; ``` **Testing**: Existing tests. Add a test with mismatched tensor shapes to verify the error is returned (not silently swallowed). --- ### 3.8 step_count increments during inference **Location**: `network.rs:250` **Problem**: The `forward()` method on the DQN network unconditionally increments `step_count`: ```rust let _step = self.step_count.fetch_add(1, Ordering::Relaxed); ``` This counter is used by the dropout scheduler (line 253-257) and potentially by other scheduling logic. During inference (backtest, action selection during epsilon-greedy exploration), the forward pass is read-only -- it should not advance the training schedule. Every inference forward pass advances the dropout schedule by one step, causing dropout to decay faster than intended. **Fix**: Add a `training: bool` parameter to `forward()`, or split into `forward_train()` and `forward_eval()`: Option A -- parameter: ```rust pub fn forward(&self, state: &[f32], training: bool) -> Result, MLError> { // ... existing logic ... if training { let _step = self.step_count.fetch_add(1, Ordering::Relaxed); if let Ok(mut scheduler_opt) = self.dropout_scheduler.lock() { if let Some(scheduler) = scheduler_opt.as_mut() { scheduler.step(1); } } } Ok(output_vec) } ``` Option B -- separate methods (preferred for API clarity): ```rust pub fn forward_impl(&self, state: &[f32], training: bool) -> Result, MLError> { // ... shared logic with conditional step_count ... } pub fn forward_train(&self, state: &[f32]) -> Result, MLError> { self.forward_impl(state, true) } pub fn forward_for_eval(&self, state: &[f32]) -> Result, MLError> { self.forward_impl(state, false) } ``` All inference call sites (backtest, action selection in greedy mode) must call the non-training variant. All training call sites must call the train variant. **Testing**: Call the non-training forward 1000 times and verify `step_count` does not change. Call the training forward once and verify it increments by 1. --- ### 3.9 NoisyNet noise in GPU action selector -- NOT A BUG **Location**: `gpu_action_selector.rs` **Initial finding**: The GPU action selector uses pure epsilon-greedy; there is no explicit Gaussian noise injection for NoisyNet exploration. **Analysis**: NoisyNet (Fortunato et al., 2018) adds parametric noise to linear layer weights. In this codebase, `NoisyLinear` layers are part of the `BranchingDuelingNetwork`. The noise is injected into the weight matrices during the forward pass, which means the Q-values output by the network *already incorporate NoisyNet exploration*. The action selector receives noise-perturbed Q-values and simply takes the argmax (or applies epsilon-greedy as a fallback). This is the standard NoisyNet architecture. The selector does not need separate noise injection -- that would double-count the exploration. **Verdict**: Not a bug. Document this architectural choice with a comment in `gpu_action_selector.rs`: ```rust // NoisyNet exploration note: Q-values received here already include // factorized Gaussian noise from NoisyLinear layers in the forward pass. // Epsilon-greedy acts as a secondary fallback, not the primary exploration // mechanism when NoisyNet is enabled. ``` --- ## 4. IMPORTANT Bugs (14 items -- hiding problems) ### 4.10 Budget fractions hardcoded in gpu_dqn_trainer **Location**: `gpu_dqn_trainer.rs:857,1098` **Problem**: ```rust let max_component_norm = self.config.max_grad_norm * 0.10; // IQN_GRAD_BUDGET let max_component_norm = self.config.max_grad_norm * 0.05; // ENS_GRAD_BUDGET ``` These magic numbers define what fraction of the total gradient budget is allocated to auxiliary components (IQN, ensemble). They should be configurable or at least centralized as named constants. **Fix**: Define constants in `fused_training.rs`: ```rust /// Fraction of max_grad_norm allocated to IQN gradient updates. pub const IQN_GRAD_BUDGET_FRACTION: f32 = 0.10; /// Fraction of max_grad_norm allocated to ensemble gradient updates. pub const ENSEMBLE_GRAD_BUDGET_FRACTION: f32 = 0.05; ``` Reference these from `gpu_dqn_trainer.rs`. Alternatively, add them to the `FusedConfig` struct if hyperopt should search over them. --- ### 4.11 Trailing stop distance hardcoded **Location**: `backtest_env_kernel.cu:151` **Problem**: `check_trailing_stop(..., 0.005f, ...)` -- the 0.5% base distance is ES-specific and not configurable via kernel arguments. **Fix**: Add `trailing_stop_base_dist` to the kernel parameter list and pass it from `GpuBacktestConfig`. Default to `0.005` for backward compatibility. --- ### 4.12-4.13 ES-specific defaults in GpuBacktestConfig **Location**: `gpu_backtest_evaluator.rs:205,211` **Problem**: `contract_multiplier: 50.0` and `margin_pct: 0.06` are ES-specific defaults. These are already configurable fields in `GpuBacktestConfig`, so the fix is documentation, not code. **Fix**: Add doc comments to both fields in the `Default` impl: ```rust /// ES mini S&P 500 default. Override for other instruments: /// NQ=20.0, CL=1000.0, GC=100.0, etc. contract_multiplier: 50.0, /// ES initial margin percentage (CME). Override for other instruments. margin_pct: 0.06, ``` --- ### 4.14 Kelly min trades hardcoded **Location**: `experience_kernels.cu:670` **Problem**: `if (total_trades >= 20.0f)` -- the minimum number of trades before Kelly criterion activates is hardcoded. For different bar frequencies (daily vs 1-min), 20 trades may be too few or too many. **Fix**: Add `kelly_min_trades` as a kernel parameter. Default to `20` for backward compatibility. Pass from `DqnBacktestConfig` or derive from `bars_per_day`. --- ### 4.15 Silent unwrap_or fallbacks in production paths **Priority tiers**: **Tier 1 -- data correctness (fix now)**: - `data_loading.rs:306` -- `unwrap_or(1.0)` on close price (covered in #3) - `action.rs:161` -- `unwrap_or(2)` on argmax result (flat action fallback). The argmax should never fail on a non-empty tensor. Replace with `.expect("argmax on non-empty Q-values")`. - `state.rs:73` -- `unwrap_or(0.0)` on price `to_f32()`. A zero price produces zero returns. Replace with `.expect("price fits f32")` since trading prices always fit in f32. **Tier 2 -- metrics/monitoring (fix later, lower risk)**: - `training_stability.rs` (15 instances) -- test code using `unwrap_or(f64::NAN)` on metric lookups. These are acceptable since NaN propagates to test failures. - `metrics.rs` (10 instances) -- monitoring aggregation using `unwrap_or(0.0)`. Missing metrics produce zeros in dashboards, which is misleading but not training-breaking. Replace with `unwrap_or(f64::NAN)` where appropriate. - `config.rs:300,419,773,774,788,789` -- shape/dimension lookups with fallbacks to 0 or 256. These are initialization-time and generally safe. **Tier 3 -- test code (leave as-is)**: - `smoke_tests/helpers.rs`, `tests.rs` -- test utilities. Fallbacks are acceptable in test code. --- ### 4.16 MaybeNoisyLinear single-variant enum **Location**: `branching.rs:252-324` **Problem**: The `MaybeNoisyLinear` enum has exactly one variant: ```rust enum MaybeNoisyLinear { Noisy(NoisyLinear), } ``` A `Linear` variant was presumably removed when the architecture converged on always-noisy layers. The enum wrapper adds indirection, match arms, and cognitive overhead for no benefit. **Fix**: Replace all occurrences of `MaybeNoisyLinear` with `NoisyLinear` directly. The methods on `MaybeNoisyLinear` (`forward`, `reset_noise`, `reset_noise_with_sigma`, `disable_noise`, `register_mu_in_varstore`, `noisy_sigma_slices`) are thin wrappers -- inline them. Fields in `BranchingDuelingNetwork` change from: ```rust value_fc: MaybeNoisyLinear, value_out: MaybeNoisyLinear, branch_fcs: Vec, branch_outs: Vec, ``` to: ```rust value_fc: NoisyLinear, value_out: NoisyLinear, branch_fcs: Vec, branch_outs: Vec, ``` **Testing**: Compile-only change. All existing tests cover the behavior. --- ### 4.17-4.20 Lower-priority silent fallbacks These items are acknowledged but deprioritized: - **4.17**: `action.rs:96-101` -- count bonus `unwrap_or(0.0)` on branch exploration arrays. Safe when `count_bonus_coefficient = 0.0` (disabled). Fix: bounds-check the slice access. - **4.18**: `attention.rs` fallbacks -- attention mechanism defaults. Low risk since attention is not on the critical training path. - **4.19**: PER beta defaults -- `unwrap_or` on PER importance sampling beta. Fix: make PER beta required in config (it always should be present when PER is enabled). - **4.20**: Hyperopt validation gaps -- PSO can produce out-of-range values that get clamped silently. Fix: add `validate()` method to `DqnPsoParams` that returns `Result` instead of clamping. --- ## 5. Implementation Plan ### Phase 1: CRITICAL fixes (bugs #1-#8) All changes in this phase affect training correctness and must be deployed together. | Order | Bug | Risk | Reason for ordering | |-------|-----|------|---------------------| | 1 | #4 gradient_clip_norm | Low | Config-only, no logic change | | 2 | #5 entropy_coefficient | Low | Config-only, no logic change | | 3 | #3 close price fallback | Low | Replace unwrap_or with indexing | | 4 | #8 step_count inference | Medium | API change to forward() | | 5 | #6 CUDA context 3x | Low | Pure refactor | | 6 | #7 quantile fallbacks | Low | Replace unwrap_or with error | | 7 | #1 backtest episode reset | Medium | CUDA kernel change | | 8 | #2 stale max_equity | Medium | CUDA kernel change, order matters | ### Phase 2: IMPORTANT fixes (bugs #10-#16) These can be deployed incrementally. | Order | Bug | Risk | |-------|-----|------| | 1 | #16 MaybeNoisyLinear | Low (compile-only) | | 2 | #10 budget fraction constants | Low | | 3 | #15 Tier 1 unwrap_or fixes | Low | | 4 | #11 trailing stop param | Medium (kernel API change) | | 5 | #14 Kelly min trades param | Medium (kernel API change) | | 6 | #12-#13 doc comments | None | ### Phase 3: Lower priority (#17-#20) Deferred to a follow-up PR. --- ## 6. Success Criteria 1. **Zero `unwrap_or` on data-critical paths**: `grep -rn 'unwrap_or'` in `data_loading.rs`, `quantile_regression.rs`, `action.rs:161`, and `state.rs:73` returns zero hits on the specific lines identified. 2. **Backtest episode reset matches experience kernel**: After capital floor breach, all 8 backtest portfolio fields are reset to fresh-episode values. `max_equity` is set to the new initial capital, not the stale peak. 3. **Gradient clip norm consistency**: A single `gradient_clip_norm` value propagates to all sub-trainers. Searching for `unwrap_or.*gradient_clip` returns zero hits. 4. **entropy_coefficient consistency**: The field is non-optional (or resolved once). No `unwrap_or` on entropy_coefficient in production code. 5. **No inference side effects**: The non-training forward pass does not increment `step_count` or advance the dropout scheduler. 6. **All 129+ existing tests pass**: `SQLX_OFFLINE=true cargo test -p ml --lib` and `SQLX_OFFLINE=true cargo test -p ml-dqn --lib` both pass. 7. **Smoke test passes**: `FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests --ignored --nocapture` completes without regression. --- ## 7. Risks and Mitigations | Risk | Mitigation | |------|------------| | Making `gradient_clip_norm` non-optional breaks deserialization of saved hyperparams | Use `#[serde(default = "default_grad_clip")]` with `fn default_grad_clip() -> f64 { 10.0 }` | | Making `entropy_coefficient` non-optional breaks deserialization | Same `#[serde(default)]` strategy | | Changing `forward()` signature breaks call sites | Use `forward_train()` / non-training forward split; deprecate `forward()` | | Backtest reset changes affect hyperopt metric consistency | Run a before/after comparison on the smoke test dataset to quantify metric drift | | Kernel parameter additions (#11, #14) require host-side changes | Add the new parameters to `GpuBacktestConfig` with defaults matching current hardcoded values -- zero behavioral change by default |