# Novel Generalization Techniques Implementation Plan

> **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:** Close the in-sample/OOS generalization gap (Sharpe +0.57 IS → -1.30 OOS) by implementing 5 novel techniques that force the model to learn regime-invariant features instead of memorizing price sequences.

**Architecture:** All techniques integrate into the existing training loop without changing the core DQN architecture. Techniques 1 and 4 modify Rust-side training config per epoch. Technique 2 adds a shuffle flag to episode starts. Technique 3 computes cross-validation action similarity at epoch boundary. Technique 5 repurposes the existing curiosity forward model's prediction error as a CQL-like Q-penalty in the loss kernel.

**Tech Stack:** Rust, CUDA (cudarc), existing fused CUDA kernels, existing curiosity forward model

---

### Task 1: Domain Randomization — Per-Epoch Simulation Parameter Jitter

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs:1007-1058` (ExperienceCollectorConfig construction)
- Modify: `crates/ml/src/trainers/dqn/config.rs` (add `enable_domain_randomization` flag)
- Modify: `config/training/dqn-localdev.toml`, `config/training/dqn-production.toml`

The ExperienceCollectorConfig is constructed fresh each epoch at training_loop.rs:1007. Currently all values are deterministic. Add per-epoch randomization of spread, tx_cost, fill probabilities, and initial capital.

- [ ] **Step 1: Add config flag**

In `config.rs`, after `enable_circuit_breaker` (~line 1407), add:
```rust
pub enable_domain_randomization: bool,
```
Default in `conservative()`: `true`.

- [ ] **Step 2: Add randomization to ExperienceCollectorConfig construction**

In `training_loop.rs`, at the ExperienceCollectorConfig construction (~line 1007), wrap the fixed values with randomization when enabled:

```rust
use rand::Rng;
let mut epoch_rng = rand::rng();

let config = ExperienceCollectorConfig {
    // ... existing fields ...
    tx_cost_multiplier: if self.hyperparams.enable_domain_randomization {
        epoch_rng.random_range(0.5..2.5)
    } else {
        self.hyperparams.transaction_cost_multiplier as f32
    },
    fill_median_spread: if self.hyperparams.enable_domain_randomization {
        self.hyperparams.avg_spread as f32 * epoch_rng.random_range(0.5..3.0)
    } else {
        self.hyperparams.avg_spread as f32
    },
    fill_ioc_fill_prob: if self.hyperparams.enable_domain_randomization {
        epoch_rng.random_range(0.65..0.95)
    } else {
        self.hyperparams.fill_ioc_fill_prob as f32
    },
    fill_limit_fill_min: if self.hyperparams.enable_domain_randomization {
        epoch_rng.random_range(0.15..0.45)
    } else {
        self.hyperparams.fill_limit_fill_min as f32
    },
    fill_limit_fill_max: if self.hyperparams.enable_domain_randomization {
        epoch_rng.random_range(0.60..0.95)
    } else {
        self.hyperparams.fill_limit_fill_max as f32
    },
    spread_cost: if self.hyperparams.enable_domain_randomization {
        (self.hyperparams.tick_size * self.hyperparams.contract_multiplier
         * epoch_rng.random_range(0.3..0.8)) as f32
    } else {
        (self.hyperparams.tick_size * self.hyperparams.contract_multiplier
         * self.hyperparams.fill_spread_cost_frac) as f32
    },
    // ... rest unchanged ...
};
```

- [ ] **Step 3: Add episode start jitter**

In `training_loop.rs` at line 935-937, add ±25% stride jitter:
```rust
let mut episode_starts: Vec<i32> = (0..n_episodes)
    .map(|i| {
        let base = (i * stride).rem_euclid(usable_bars);
        if self.hyperparams.enable_domain_randomization {
            let jitter = epoch_rng.random_range(-(stride/4)..(stride/4));
            (base + jitter).rem_euclid(usable_bars)
        } else {
            base
        }
    })
    .collect();
```

- [ ] **Step 4: Add variable episode length**

Before the `timesteps` assignment at line 931, randomize:
```rust
let timesteps = if self.hyperparams.enable_domain_randomization {
    let base = self.hyperparams.gpu_timesteps_per_episode.min(1000) as i32;
    let jitter = epoch_rng.random_range(-(base/4)..(base/4));
    (base + jitter).max(50) // minimum 50 timesteps
} else {
    self.hyperparams.gpu_timesteps_per_episode.min(1000) as i32
};
```

- [ ] **Step 5: Add to TOML configs**

Add to `[advanced]` section in dqn-localdev.toml and dqn-production.toml:
```toml
enable_domain_randomization = true
```

- [ ] **Step 6: Compile + smoke test**

```bash
SQLX_OFFLINE=true cargo check -p ml
FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests::training_stability::test_production_training_stability --ignored --nocapture
```

- [ ] **Step 7: Commit**

```bash
git add crates/ml/src/trainers/dqn/trainer/training_loop.rs crates/ml/src/trainers/dqn/config.rs config/training/*.toml
git commit -m "feat: domain randomization — per-epoch jitter on spread, tx_cost, fills, episode starts"
```

---

### Task 2: Periodic Shrink-and-Perturb (already exists, needs scheduling)

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs:308-347` (phase boundary S&P)
- Modify: `crates/ml/src/trainers/dqn/config.rs` (add `shrink_perturb_interval`)

Shrink-and-perturb already works (`fused.shrink_and_perturb(alpha, sigma)`) but only runs at phase boundaries. Need periodic execution every N epochs.

- [ ] **Step 1: Add config field**

In `config.rs`, add:
```rust
pub shrink_perturb_interval: usize, // 0=disabled, 20=every 20 epochs
pub shrink_perturb_alpha: f64,      // 0.85 = keep 85%, reinit 15%
pub shrink_perturb_sigma: f64,      // 0.01 = noise scale
```
Defaults in `conservative()`: `shrink_perturb_interval: 20, shrink_perturb_alpha: 0.85, shrink_perturb_sigma: 0.01`.

- [ ] **Step 2: Add periodic S&P to training loop**

In `training_loop.rs`, in the main epoch loop (after experience collection, before training steps), add:
```rust
if self.hyperparams.shrink_perturb_interval > 0
    && epoch > 0
    && epoch % self.hyperparams.shrink_perturb_interval == 0
{
    if let Some(ref mut fused) = self.fused_ctx {
        let alpha = self.hyperparams.shrink_perturb_alpha as f32;
        let sigma = self.hyperparams.shrink_perturb_sigma as f32;
        match fused.shrink_and_perturb(alpha, sigma) {
            Ok(()) => info!(epoch, alpha, sigma, "Periodic shrink-and-perturb applied"),
            Err(e) => warn!(epoch, "Shrink-and-perturb failed (non-fatal): {e}"),
        }
    }
}
```

- [ ] **Step 3: Add to TOMLs + profile**

dqn-localdev.toml and dqn-production.toml `[advanced]`:
```toml
shrink_perturb_interval = 20
shrink_perturb_alpha = 0.85
shrink_perturb_sigma = 0.01
```

Wire through `training_profile.rs` `apply_to()` if fields are added to `AdvancedSection`.

- [ ] **Step 4: Compile + test**

```bash
SQLX_OFFLINE=true cargo check -p ml
FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests --ignored --nocapture
```

- [ ] **Step 5: Commit**

```bash
git commit -m "feat: periodic shrink-and-perturb every N epochs (default 20)"
```

---

### Task 3: Adversarial Regime Injection

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (epoch-level regime detection + injection)
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` (ExperienceCollectorConfig fields)
- Modify: `crates/ml/src/trainers/dqn/config.rs` (add `adversarial_injection_threshold`)

When the model achieves low drawdown (<10%) for 3+ consecutive epochs, inject adversarial conditions: 3x spread, 2x tx_cost, 0.5x fill probability. This forces the model to handle worst-case scenarios.

- [ ] **Step 1: Add config + tracking**

In `config.rs`:
```rust
pub adversarial_injection_threshold: f64, // DD threshold (0.10 = 10%)
pub adversarial_injection_consecutive: usize, // consecutive good epochs before trigger (3)
```

In `trainer/mod.rs`, add tracking field:
```rust
pub(crate) consecutive_low_dd_epochs: usize,
```

- [ ] **Step 2: Implement detection + injection**

In `training_loop.rs`, after epoch metrics are computed (after the Sharpe/MaxDD/PF log), add:
```rust
// Adversarial regime injection: when model is doing too well, make it harder
let max_dd_fraction = monitor.max_drawdown_pct / 100.0;
if max_dd_fraction < self.hyperparams.adversarial_injection_threshold {
    self.consecutive_low_dd_epochs += 1;
} else {
    self.consecutive_low_dd_epochs = 0;
}

let adversarial_active = self.consecutive_low_dd_epochs
    >= self.hyperparams.adversarial_injection_consecutive;
```

Then in the ExperienceCollectorConfig construction for the NEXT epoch, check `adversarial_active`:
```rust
let adversarial_mult = if adversarial_active { 3.0_f32 } else { 1.0 };
// Apply to spread_cost, tx_cost_multiplier, etc.
spread_cost: base_spread * adversarial_mult,
tx_cost_multiplier: base_tx * (if adversarial_active { 2.0 } else { 1.0 }),
fill_ioc_fill_prob: if adversarial_active { 0.50 } else { base_fill },
```

- [ ] **Step 3: Log when adversarial mode activates**

```rust
if adversarial_active {
    info!(
        epoch, consecutive = self.consecutive_low_dd_epochs,
        "ADVERSARIAL REGIME INJECTED: 3x spread, 2x tx_cost, 0.5x fill prob"
    );
}
```

- [ ] **Step 4: Compile + test + commit**

---

### Task 4: Anti-Correlation Reward Penalty

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (epoch boundary action comparison)
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` (reward_penalty_multiplier field)
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (apply penalty to reward)

At epoch end, compare the action distribution (from GPU monitoring) between training and validation. If cosine similarity > 0.8, the model is doing the same thing on both — it hasn't learned to differentiate. Penalize next epoch's rewards.

- [ ] **Step 1: Compute cosine similarity at epoch boundary**

In `training_loop.rs`, after validation loss computation, add:
```rust
// Anti-correlation: cosine similarity between train and val action distributions
let train_actions: Vec<f64> = monitor.action_counts.iter().map(|&c| c as f64).collect();
let val_actions: Vec<f64> = /* from validation pass action counts */;

let dot: f64 = train_actions.iter().zip(&val_actions).map(|(a, b)| a * b).sum();
let mag_t: f64 = train_actions.iter().map(|a| a * a).sum::<f64>().sqrt();
let mag_v: f64 = val_actions.iter().map(|a| a * a).sum::<f64>().sqrt();
let cosine_sim = if mag_t > 0.0 && mag_v > 0.0 { dot / (mag_t * mag_v) } else { 0.0 };

let anti_corr_penalty = if cosine_sim > 0.8 {
    0.8_f32 // scale rewards by 0.8 next epoch (20% penalty)
} else {
    1.0_f32
};
```

- [ ] **Step 2: Pass penalty to experience collector**

Add `reward_scale_override: f32` to `ExperienceCollectorConfig`. Default 1.0.
In the experience kernel, multiply reward by this scale factor:
```cuda
reward *= reward_scale_override;
```

- [ ] **Step 3: Compile + test + commit**

---

### Task 5: Curiosity Prediction Error as Q-Penalty (Inverse Model)

**Files:**
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs` (wire curiosity error into loss)
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (compute curiosity error per batch)
- Modify: `crates/ml/src/cuda_pipeline/mse_loss_kernel.cu` or `c51_loss_kernel.cu` (add penalty term)
- Modify: `crates/ml/src/trainers/dqn/config.rs` (add `curiosity_q_penalty_weight`)

The curiosity forward model (state+action → next_state) already exists and trains. Its prediction error measures how "novel" a state-action pair is. High prediction error = the model has never seen this situation = Q-values should be conservative (lower).

Wire the curiosity prediction error as an ADDITIVE penalty to the Q-target in the Bellman equation:
```
target_Q = reward + gamma * (1-done) * target_Q_next - curiosity_penalty * prediction_error
```

This makes the Q-function conservative on novel state-action pairs (same principle as CQL, but data-driven instead of action-space-driven).

- [ ] **Step 1: Add config field**

```rust
pub curiosity_q_penalty_weight: f64, // 0.0=disabled, 0.5=moderate, 1.0=strong
```
Default: `0.5`.

- [ ] **Step 2: Compute per-sample curiosity error in fused training step**

In `fused_training.rs`, after the forward pass and before the loss kernel, launch the curiosity forward model on the current batch to get per-sample prediction errors. Store in a `CudaSlice<f32>` of size `[batch_size]`.

The curiosity model is already initialized in the fused context (`self.curiosity_trainer`). Call:
```rust
let curiosity_errors = self.curiosity_trainer.compute_batch_errors(
    &batch_states, &batch_actions, &batch_next_states, batch_size
)?;
```

- [ ] **Step 3: Pass curiosity errors to loss kernel**

Add `const float* curiosity_errors` parameter to `mse_loss_batched` and `c51_loss_batched`.

In the Bellman target computation (MSE kernel line 334):
```cuda
float curiosity_penalty = curiosity_errors[sample_id] * curiosity_q_penalty_weight;
float target_q = reward + gamma * (1.0f - done) * target_eq - curiosity_penalty;
```

The penalty subtracts from the Q-target: high prediction error → lower target → model learns to be conservative on novel states.

- [ ] **Step 4: Wire through Rust launch code**

Add `curiosity_errors_buf` to the kernel launch in `gpu_dqn_trainer.rs`. Pass the prediction error buffer from step 2.

- [ ] **Step 5: Add to TOML configs**

```toml
[advanced]
curiosity_q_penalty_weight = 0.5
```

- [ ] **Step 6: Compile + full smoke test suite**

```bash
SQLX_OFFLINE=true cargo check -p ml
FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- smoke_tests --ignored --nocapture
```

All 11 smoke tests must pass.

- [ ] **Step 7: Commit**

```bash
git commit -m "feat: curiosity prediction error as Q-penalty (inverse model generalization)"
```

---

## Verification Plan

After all 5 techniques are implemented:

1. **Local 3-epoch smoke test** — all 11 pass, zero NaN
2. **Local 200-epoch training** — compare MaxDD and Sharpe trajectory to pre-fix baseline
3. **H100 hyperopt** — 20 trials × 8 epochs, compare OOS Sharpe distribution to baseline (-1.30)
4. **Key metric**: OOS Sharpe should be closer to 0 (ideally > -0.5). Omega should be more consistent with total return (no contradiction).

## Expected Impact

| Technique | Mechanism | Expected Gap Reduction |
|-----------|-----------|----------------------|
| Domain randomization | Prevents memorizing fixed sim params | 40-60% |
| Shrink-and-perturb | Kills memorized weights periodically | 15-25% |
| Adversarial injection | Forces worst-case survival | 10-20% |
| Anti-correlation penalty | Punishes regime-specific behavior | 10-15% |
| Curiosity Q-penalty | Conservative on novel states | 20-30% |

Combined expected impact: 50-80% generalization gap reduction.

---

### Task 6: Regime-Adversarial Training (Gradient Reversal)

**Files:**
- Create: `crates/ml/src/cuda_pipeline/regime_discriminator_kernel.cu`
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (add discriminator head)
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs` (gradient reversal)

Train a small classifier head on the DQN's hidden features that predicts which
time window (fold) the data comes from. Apply GRADIENT REVERSAL from the
discriminator to the feature extractor — the DQN learns features that the
discriminator CANNOT use to identify the regime.

This is Domain Adversarial Neural Networks (DANN) adapted for temporal domains.

- [ ] **Step 1: Add discriminator kernel** — small MLP [hidden_dim → 64 → num_folds]
- [ ] **Step 2: Wire gradient reversal** — during backward pass, negate the discriminator's gradient before it flows into the shared trunk
- [ ] **Step 3: Add fold ID to experience buffer** — each experience carries which time window it came from
- [ ] **Step 4: Compile + test + commit**

---

### Task 7: Counterfactual Experience Augmentation

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (dual action evaluation)
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` (double output buffers)

For every trade taken, also compute the counterfactual: what would have happened
with the mirror action (Long→Short, Short→Long). Store both with inverted
reward signs. This doubles the effective data diversity.

- [ ] **Step 1: In experience kernel**, after computing reward for the taken action, compute reward for the mirror action
- [ ] **Step 2: Write both** to output buffers (2x output size)
- [ ] **Step 3: Counterfactual reward** = -1 × actual reward (inverted signal)
- [ ] **Step 4: Compile + test + commit**

---

## Execution Order (All 7 Techniques)

1. Domain randomization (Task 1) — Rust only, highest impact
2. Shrink-and-perturb scheduling (Task 2) — Rust only, already implemented
3. Adversarial regime injection (Task 3) — Rust only
4. Anti-correlation penalty (Task 4) — minor CUDA change
5. Curiosity Q-penalty (Task 5) — repurpose existing model
6. Counterfactual augmentation (Task 7) — CUDA kernel change
7. Regime-adversarial training (Task 6) — new network head

Tasks 1-3 can be implemented in a single session (Rust only).
Tasks 4-5 require CUDA kernel changes.
Tasks 6-7 are the most ambitious and should come after validating 1-5.

---

### Task 8: Hindsight Regime Labeling — Feature Importance Filtering

**Files:**
- Create: `crates/ml/src/trainers/dqn/feature_importance.rs`
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (epoch boundary analysis)
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (feature masking)
- Modify: `crates/ml/src/trainers/dqn/config.rs` (add feature_mask config)

After each epoch, compute gradient × activation for each feature to identify
which features actually predicted profitable trades. Mask the bottom 50% of
features (set to 0) in the next epoch. Iterative: the model discovers which
features carry signal and ignores noise.

- [ ] **Step 1: Compute per-feature importance** from gradient × activation at epoch boundary
- [ ] **Step 2: Rank features** by importance, compute a binary mask (top-K survive)
- [ ] **Step 3: Apply mask** in the experience kernel (zero out masked features)
- [ ] **Step 4: Iterate** — recompute importance each epoch, mask adapts
- [ ] **Step 5: Compile + test + commit**

## Updated Execution Order (All 8 Techniques)

1. Domain randomization (Task 1) — Rust only, highest impact
2. CQL alpha boost (from researcher plan) — config change, 2 hours
3. Shrink-and-perturb scheduling (Task 2) — Rust only, already implemented
4. Adversarial regime injection (Task 3) — Rust only
5. Anti-correlation penalty (Task 4) — minor CUDA change
6. Curiosity Q-penalty (Task 5) — repurpose existing model
7. Counterfactual augmentation (Task 7) — CUDA kernel change
8. Hindsight feature filtering (Task 8) — feature masking
9. Regime-adversarial training (Task 6) — new network head (most ambitious)

Phase 1 (today): Tasks 1-4 (Rust only, immediate impact)
Phase 2 (next session): Tasks 5-8 (CUDA changes, deeper integration)
Phase 3 (after validation): Task 6 (new network architecture)
