Files
foxhunt/docs/superpowers/plans/2026-03-27-bug-fixes.md
jgrusewski be8f3310d5 feat: gradient stability + 9-agent audit bug fixes
Per-component gradient clipping:
- 2 new CUDA kernels (dqn_clipped_saxpy, dqn_clip_grad)
- CQL gradient isolated into separate scratch buffer
- Budget allocation: C51=70%, CQL=15%, IQN=10%, Ens=5%
- Dynamic budget — inactive components' share goes to C51

Spectral norm extended to all 10 weight matrices:
- Was trunk-only (W_s1, W_s2), now covers all heads
- 16 u/v power iteration buffers, batched GOFF sync-back
- Uniform sigma_max, end-to-end Lipschitz bounded

Bug fixes from 9-agent audit:
- Backtest episode reset: all 8 fields (was 5), max_equity updated before floor check
- gradient_clip_norm unified: 10.0 everywhere (was 10.0 vs 1.0)
- entropy_coefficient: Option<f64> → f64, single default 0.001
- Close price: unwrap_or(1.0) → direct indexing (no silent wrong rewards)
- step_count: only increments during training (was incrementing on inference)
- Quantile loss: single CUDA context (was 3×), silent fallbacks removed
- MaybeNoisyLinear: single-variant enum removed → direct NoisyLinear
- Budget fractions: exported as pub(crate) constants, referenced not hardcoded

Monitoring:
- Per-component gradient Prometheus gauges (C51 raw, combined)
- Diagnostic logging every 1000 steps

Tests: 7 new gradient budget tests, 1 gradient bounds smoke test
All 488 tests pass (359 ml-dqn + 129 ml)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:52:44 +01:00

502 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bug Fixes Implementation Plan (Spec B)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix all 9 CRITICAL bugs and top-priority IMPORTANT issues found by the 9-agent audit, eliminating wrong training results and silent error suppression.
**Architecture:** Config-only fixes first (lowest risk), then Rust refactors, then CUDA kernel changes last. Each task is independently testable and committable. Order follows the spec's Phase 1 → Phase 2 risk-based sequencing.
**Tech Stack:** Rust, CUDA (.cu kernels compiled by build.rs), cudarc 0.19.3, serde
**Spec:** `docs/superpowers/specs/2026-03-27-bug-fixes-design.md`
---
## File Map
| File | Bugs | Action |
|---|---|---|
| `crates/ml/src/trainers/dqn/config.rs` | #4, #5 | Make gradient_clip_norm and entropy_coefficient non-optional |
| `crates/ml/src/trainers/dqn/fused_training.rs` | #4 | Remove unwrap_or on gradient_clip_norm |
| `crates/ml/src/trainers/dqn/trainer/constructor.rs` | #4, #5 | Remove unwrap_or fallbacks |
| `crates/ml/src/trainers/dqn/data_loading.rs` | #3 | Replace unwrap_or(1.0) with direct indexing |
| `crates/ml-dqn/src/network.rs` | #8 | Conditional step_count increment |
| `crates/ml-dqn/src/quantile_regression.rs` | #6, #7 | Single CUDA context, remove silent fallbacks |
| `crates/ml/src/cuda_pipeline/backtest_env_kernel.cu` | #1, #2 | Complete episode reset, update max_equity |
| `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` | #10 | Export budget fraction constants |
| `crates/ml-dqn/src/branching.rs` | #16 | Remove MaybeNoisyLinear enum |
| `crates/ml/src/trainers/dqn/trainer/action.rs` | #15 | Replace unwrap_or(2) with expect |
| `crates/ml/src/trainers/dqn/trainer/state.rs` | #15 | Replace unwrap_or(0.0) with expect |
| `crates/ml/src/cuda_pipeline/gpu_action_selector.rs` | #9 | Add NoisyNet architecture comment |
---
### Task 1: Unify gradient_clip_norm — eliminate inconsistent defaults
Bug #4: Main trainer clips at 10.0, IQN/IQL clip at 1.0. The fix: resolve `gradient_clip_norm` once at the top of `FusedTrainingCtx::new()` and pass the resolved value to ALL sub-configs.
**Files:**
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs`
- [ ] **Step 1: Resolve gradient_clip_norm once at top of constructor**
At the top of `FusedTrainingCtx::new()` (before any config construction), add:
```rust
let resolved_grad_norm = hyperparams.gradient_clip_norm.unwrap_or(10.0);
```
Then replace all 3 sites:
- Line 194: `max_grad_norm: hyperparams.gradient_clip_norm.unwrap_or(10.0) as f32,``max_grad_norm: resolved_grad_norm as f32,`
- Line 263: `max_grad_norm: hyperparams.gradient_clip_norm.unwrap_or(1.0) as f32,``max_grad_norm: resolved_grad_norm as f32,`
- Line 298: `max_grad_norm: hyperparams.gradient_clip_norm.unwrap_or(1.0) as f32,``max_grad_norm: resolved_grad_norm as f32,`
- [ ] **Step 2: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn 2>&1 | tail -5`
Expected: Compiles, all tests pass.
- [ ] **Step 3: Commit**
```bash
git add crates/ml/src/trainers/dqn/fused_training.rs
git commit -m "fix: unify gradient_clip_norm — resolve once, pass to all sub-trainers (was 10.0 vs 1.0)"
```
---
### Task 2: Make entropy_coefficient non-optional
Bug #5: `Option<f64>` with 3 different `unwrap_or` defaults (0.001, 0.01, 0.02). Fix: make it non-optional with a single canonical default.
**Files:**
- Modify: `crates/ml/src/trainers/dqn/config.rs`
- Modify: `crates/ml/src/trainers/dqn/trainer/constructor.rs`
- [ ] **Step 1: Change type in DQNHyperparameters**
In `config.rs` (~line 960), change:
```rust
pub entropy_coefficient: Option<f64>,
```
To:
```rust
#[serde(default = "default_entropy_coefficient")]
pub entropy_coefficient: f64,
```
Add the default function nearby:
```rust
fn default_entropy_coefficient() -> f64 { 0.001 }
```
In the `conservative()` and `default()` impl, change:
```rust
entropy_coefficient: Some(0.001),
```
To:
```rust
entropy_coefficient: 0.001,
```
- [ ] **Step 2: Remove unwrap_or in constructor.rs**
Line 314: `entropy_coefficient: hyperparams.entropy_coefficient.unwrap_or(0.01),``entropy_coefficient: hyperparams.entropy_coefficient,`
Line 399: `let coeff = hyperparams.entropy_coefficient.unwrap_or(0.01);``let coeff = hyperparams.entropy_coefficient;`
- [ ] **Step 3: Fix all compilation errors from type change**
Run `SQLX_OFFLINE=true cargo check --workspace 2>&1 | head -40` — fix any sites that still use `Some(...)` or `.unwrap()` on entropy_coefficient. Common patterns:
- `Some(0.001)``0.001`
- `.unwrap_or(x)` → remove
- Pattern match on `Some(v)` → use directly
- [ ] **Step 4: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check --workspace && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn && SQLX_OFFLINE=true cargo test -p ml-dqn --lib 2>&1 | tail -5`
Expected: All pass.
- [ ] **Step 5: Commit**
```bash
git add crates/ml/src/trainers/dqn/config.rs crates/ml/src/trainers/dqn/trainer/constructor.rs
git commit -m "fix: make entropy_coefficient non-optional — single canonical default 0.001"
```
---
### Task 3: Replace close price unwrap_or(1.0) with direct indexing
Bug #3: Silent wrong rewards when price parse fails. The indices are always in-bounds (loop from 1..n), so direct indexing is safe and surfaces any future bugs.
**Files:**
- Modify: `crates/ml/src/trainers/dqn/data_loading.rs`
- [ ] **Step 1: Find and replace the unwrap_or(1.0)**
At line 306, change:
```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);
```
To:
```rust
let prev = close_prices_f32[i - 1];
let curr = close_prices_f32[i];
```
- [ ] **Step 2: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn 2>&1 | tail -5`
- [ ] **Step 3: Commit**
```bash
git add crates/ml/src/trainers/dqn/data_loading.rs
git commit -m "fix: replace close price unwrap_or(1.0) with direct indexing — no silent wrong rewards"
```
---
### Task 4: Fix step_count incrementing during inference
Bug #8: `forward()` unconditionally increments step_count, advancing dropout schedule during eval.
**Files:**
- Modify: `crates/ml-dqn/src/network.rs`
- [ ] **Step 1: Read current forward() to understand full context**
Read `network.rs` around line 245-260 to see how `step_count` and `dropout_scheduler` are used.
- [ ] **Step 2: Add `training: bool` parameter to forward()**
Change the forward method to accept a training flag. Only increment step_count and advance dropout scheduler when training=true. Update the method signature and all call sites.
If there's a trait constraint, add a `forward_eval` convenience method that calls `forward(state, false)`.
- [ ] **Step 3: Update all call sites**
Search for `.forward(` across the codebase. Inference sites (backtest, action selection) pass `false`. Training sites pass `true`.
- [ ] **Step 4: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check --workspace && SQLX_OFFLINE=true cargo test -p ml-dqn --lib 2>&1 | tail -5`
- [ ] **Step 5: Commit**
```bash
git add crates/ml-dqn/src/network.rs
git commit -m "fix: step_count only increments during training — no inference side effects"
```
---
### Task 5: Single CUDA context in quantile loss
Bug #6: Creates CudaContext 3× per call. Refactor to create once and pass through.
**Files:**
- Modify: `crates/ml-dqn/src/quantile_regression.rs`
- [ ] **Step 1: Read the full quantile_huber_loss function**
Understand the 3 context creation sites and the inner `_per_sample` function.
- [ ] **Step 2: Refactor to single context**
Create context + stream once at the top. Pass the stream to all operations that need it. If `to_host()` creates its own context internally, check if there's a `to_host_with_stream()` variant or use manual `memcpy_dtoh`.
- [ ] **Step 3: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check -p ml-dqn && SQLX_OFFLINE=true cargo test -p ml-dqn --lib 2>&1 | tail -5`
- [ ] **Step 4: Commit**
```bash
git add crates/ml-dqn/src/quantile_regression.rs
git commit -m "fix: single CUDA context per quantile loss call — was 3× context creation"
```
---
### Task 6: Remove silent fallbacks in quantile loss
Bug #7: `unwrap_or(0.0)` / `unwrap_or(0.5)` on tensor indexing hides bugs.
**Files:**
- Modify: `crates/ml-dqn/src/quantile_regression.rs`
- [ ] **Step 1: Replace unwrap_or with direct indexing**
At line ~385-387, change:
```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);
```
To:
```rust
let pred_val = pred_host[idx];
let tgt_val = tgt_host[idx];
let tau_val = tau_host[idx];
```
- [ ] **Step 2: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo test -p ml-dqn --lib 2>&1 | tail -5`
- [ ] **Step 3: Commit**
```bash
git add crates/ml-dqn/src/quantile_regression.rs
git commit -m "fix: remove silent 0.0/0.5 fallbacks in quantile loss — panic on OOB"
```
---
### Task 7: Complete backtest episode reset + fix stale max_equity
Bugs #1 + #2: The most impactful CUDA kernel changes. Reset all 8 portfolio fields, update max_equity before floor check.
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/backtest_env_kernel.cu`
- [ ] **Step 1: Fix pre-trade floor reset (lines 105-112)**
Replace:
```cuda
portfolio_state[ps + 0] = liq_value;
portfolio_state[ps + 1] = 0.0f;
portfolio_state[ps + 2] = liq_value;
portfolio_state[ps + 3] = 0.0f;
portfolio_state[ps + 4] = max_equity;
portfolio_state[ps + 5] = 0.0f;
portfolio_state[ps + 6] = cum_return + liq_ret;
portfolio_state[ps + 7] += 1.0f;
```
With:
```cuda
portfolio_state[ps + 0] = liq_value; // value = new initial capital
portfolio_state[ps + 1] = 0.0f; // position = flat
portfolio_state[ps + 2] = liq_value; // cash = new initial capital
portfolio_state[ps + 3] = 0.0f; // entry_price = none
portfolio_state[ps + 4] = liq_value; // max_equity = RESET (was stale)
portfolio_state[ps + 5] = 0.0f; // hold_time = 0
portfolio_state[ps + 6] = 0.0f; // cum_return = RESET (was accumulating)
portfolio_state[ps + 7] = 0.0f; // step_count = RESET (was incrementing)
```
- [ ] **Step 2: Fix max_equity update before post-trade floor check (line ~176-184)**
After `float new_value = cash + position * close;` (line 176), insert BEFORE the floor check:
```cuda
// Update max_equity BEFORE floor check — prevents stale peak
max_equity = fmaxf(max_equity, new_value);
```
- [ ] **Step 3: Fix post-trade floor reset (lines 199-206)**
Replace with complete reset (same pattern as pre-trade):
```cuda
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
```
- [ ] **Step 4: Verify build (cubins recompile via build.rs)**
Run: `SQLX_OFFLINE=true cargo check -p ml 2>&1 | tail -5`
Expected: build.rs recompiles the .cu file, check passes.
- [ ] **Step 5: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib -- dqn 2>&1 | tail -5`
- [ ] **Step 6: Commit**
```bash
git add crates/ml/src/cuda_pipeline/backtest_env_kernel.cu
git commit -m "fix: complete backtest episode reset (all 8 fields) + update max_equity before floor check"
```
---
### Task 8: Export budget fraction constants + fix hardcoded values
Bug #10: `0.10` and `0.05` hardcoded in gpu_dqn_trainer.rs should reference the constants from fused_training.rs.
**Files:**
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs`
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs`
- [ ] **Step 1: Make budget constants public**
In `fused_training.rs`, change:
```rust
const CQL_GRAD_BUDGET: f32 = 0.15;
const IQN_GRAD_BUDGET: f32 = 0.10;
const ENS_GRAD_BUDGET: f32 = 0.05;
```
To:
```rust
pub(crate) const CQL_GRAD_BUDGET: f32 = 0.15;
pub(crate) const IQN_GRAD_BUDGET: f32 = 0.10;
pub(crate) const ENS_GRAD_BUDGET: f32 = 0.05;
```
- [ ] **Step 2: Reference constants in gpu_dqn_trainer.rs**
At line ~831 in `apply_iqn_trunk_gradient`, change:
```rust
let max_component_norm = self.config.max_grad_norm * 0.10; // IQN_GRAD_BUDGET
```
To:
```rust
let max_component_norm = self.config.max_grad_norm * crate::trainers::dqn::fused_training::IQN_GRAD_BUDGET;
```
At line ~1072 in `apply_ensemble_trunk_gradient`, change:
```rust
let max_component_norm = self.config.max_grad_norm * 0.05; // ENS_GRAD_BUDGET
```
To:
```rust
let max_component_norm = self.config.max_grad_norm * crate::trainers::dqn::fused_training::ENS_GRAD_BUDGET;
```
- [ ] **Step 3: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn 2>&1 | tail -5`
- [ ] **Step 4: Commit**
```bash
git add crates/ml/src/trainers/dqn/fused_training.rs crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "fix: reference budget fraction constants — eliminate hardcoded 0.10/0.05 in gpu_dqn_trainer"
```
---
### Task 9: Remove MaybeNoisyLinear single-variant enum
Bug #16: Enum with only `Noisy` variant — unnecessary indirection.
**Files:**
- Modify: `crates/ml-dqn/src/branching.rs`
- [ ] **Step 1: Read current MaybeNoisyLinear usage**
Search for all `MaybeNoisyLinear` references in branching.rs to understand the scope of changes.
- [ ] **Step 2: Replace enum with direct NoisyLinear**
- Remove the `MaybeNoisyLinear` enum definition
- Change all struct fields from `MaybeNoisyLinear` to `NoisyLinear`
- Remove all `let MaybeNoisyLinear::Noisy(n) = self;` destructuring — use the field directly
- Inline the thin wrapper methods (forward, reset_noise, etc.) or call directly on NoisyLinear
- [ ] **Step 3: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check --workspace && SQLX_OFFLINE=true cargo test -p ml-dqn --lib 2>&1 | tail -5`
- [ ] **Step 4: Commit**
```bash
git add crates/ml-dqn/src/branching.rs
git commit -m "fix: remove MaybeNoisyLinear single-variant enum — use NoisyLinear directly"
```
---
### Task 10: Fix Tier 1 silent unwrap_or fallbacks
Bug #15: Critical silent fallbacks in action selection and state processing.
**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/action.rs`
- Modify: `crates/ml/src/trainers/dqn/trainer/state.rs`
- [ ] **Step 1: Fix action.rs:161 — argmax fallback**
Change `unwrap_or(2)` (silent Flat action) to `.expect("argmax on non-empty Q-values")`.
- [ ] **Step 2: Fix state.rs:73 — price to_f32 fallback**
Change `unwrap_or(0.0)` to `.expect("trading price fits f32")`.
- [ ] **Step 3: Verify compilation + tests**
Run: `SQLX_OFFLINE=true cargo check -p ml && SQLX_OFFLINE=true cargo test -p ml --lib -- dqn 2>&1 | tail -5`
- [ ] **Step 4: Commit**
```bash
git add crates/ml/src/trainers/dqn/trainer/action.rs crates/ml/src/trainers/dqn/trainer/state.rs
git commit -m "fix: replace silent unwrap_or fallbacks in action selection and state processing"
```
---
### Task 11: Add NoisyNet architecture comment to action selector
Bug #9 (NOT A BUG): Document the architectural choice so future audits don't re-flag this.
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_action_selector.rs`
- [ ] **Step 1: Add comment at top of file**
After the module doc comment, add:
```rust
// NoisyNet exploration note: Q-values received by the action selector already
// include factorized Gaussian noise from NoisyLinear layers in the network
// forward pass. Epsilon-greedy acts as a secondary fallback, not the primary
// exploration mechanism when NoisyNet is enabled. Explicit noise injection
// here would double-count exploration.
```
- [ ] **Step 2: Commit**
```bash
git add crates/ml/src/cuda_pipeline/gpu_action_selector.rs
git commit -m "docs: clarify NoisyNet exploration is in network forward, not action selector"
```
---
## Execution Dependencies
```
Task 1 (gradient_clip_norm) — independent
Task 2 (entropy_coefficient) — independent
Task 3 (close price) — independent
Task 4 (step_count) — independent
Task 5 (CUDA context) — independent
Task 6 (quantile fallbacks) — after Task 5 (same file)
Task 7 (backtest reset) — independent
Task 8 (budget constants) — independent
Task 9 (MaybeNoisyLinear) — independent
Task 10 (unwrap_or fixes) — independent
Task 11 (NoisyNet comment) — independent
```
Tasks 5-6 must be sequential (same file). All others are independent and can run in parallel.
## Verification
After ALL tasks:
```bash
SQLX_OFFLINE=true cargo check --workspace
SQLX_OFFLINE=true cargo test -p ml --lib -- dqn
SQLX_OFFLINE=true cargo test -p ml-dqn --lib
```
All must pass with zero failures.