Files
foxhunt/docs/superpowers/plans/2026-04-20-adaptive-learning-dynamics.md
jgrusewski ab7567875a plan: adaptive learning dynamics — 19 tasks across 5 phases
Implementation plan for unified LearningHealth system (all gems/pearls/novels):
- Phase A (A1-A4): ISV extension, LearningHealth module, training loop wiring,
  HEALTH_DIAG logging
- Phase B (B1-B4): G2 uncertainty-gated CQL, G3 health-coupled tau,
  G4 temp-continuous Expected SARSA, G5 adaptive gradient budget
- Phase C (C1-C4): P1 health-weighted PER priorities, P2/P3 subsumed by A3,
  P4 temporal-coupled gamma
- Phase D (D1-D8): N1 temporal self-distillation, N2 Q-gap barrier,
  N3 health-triggered plasticity, N4 CF curriculum, N5 information bottleneck,
  N6 ensemble oracle, N7 contrarian override, N8 meta-Q collapse predictor
- Phase E (E1-E2): collapse-recovery smoke test + L40S deployment verification

Spec: docs/superpowers/specs/2026-04-20-adaptive-learning-dynamics-design.md
Target: WinRate >55% by epoch 20, Q-gap stays above 0.1 (no collapse).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:12:41 +02:00

2017 lines
72 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.
# Adaptive Learning Dynamics 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:** Build a unified `LearningHealth` signal that senses training collapse and continuously adapts 4 gems + 4 pearls + 8 novel mechanisms to prevent Q-value collapse in the DQN trading model.
**Architecture:** Three-layer system. Layer 1 senses health (7 composite signals, EMA-smoothed, broadcast via pinned device-mapped memory at ISV buffer index [12]). Layer 2 consumes health to adapt CQL weight, gradient budget, tau EMA, and Expected SARSA temperature. Layer 3 adds 4 pearls and 8 novel mechanisms (PER priorities, snapshots, barrier loss, plasticity, CF curriculum, information bottleneck, ensemble oracle, contrarian override, meta-Q network).
**Tech Stack:** Rust 1.85, CUDA 12.4, cudarc, cuBLAS, cuSOLVER (for SVD), nvcc cubin compilation.
**Spec:** `docs/superpowers/specs/2026-04-20-adaptive-learning-dynamics-design.md`
---
## File Structure
New files:
- `crates/ml/src/cuda_pipeline/learning_health.rs` — LearningHealth struct + computation + EMA
- `crates/ml/src/cuda_pipeline/q_snapshot.rs` — N1 ring buffer for historical Q-network weights
- `crates/ml/src/cuda_pipeline/meta_q_network.rs` — N8 small MLP predicting collapse
Modified files:
- `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — ISV_DIM 12→13, LearningHealth wiring, adaptive hyperparam computation
- `crates/ml/src/cuda_pipeline/c51_loss_kernel.cu` — G4 (Expected SARSA temp), N2 (barrier), N5 (IB penalty)
- `crates/ml/src/cuda_pipeline/experience_kernels.cu` — N4 (cf_ratio parameter)
- `crates/ml/src/trainers/dqn/fused_training.rs` — G5 (budget), G3 (tau), P3 (grad consistency), P4 (gamma)
- `crates/ml/src/trainers/dqn/trainer/training_loop.rs` — LearningHealth compute/log, N3/N6/N7 triggers
- `crates/ml/src/trainers/dqn/trainer/metrics.rs` — EMA trackers for Q-gap, Q-var, grad_norm
- `crates/ml-dqn/src/gpu_replay_buffer.rs` — P1 priority diversity multiplier
---
# Phase A: Foundation (LearningHealth Infrastructure)
### Task A1: Extend ISV Buffer to 13 Entries
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:86` (ISV_DIM constant)
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:6303-6315` (buffer allocation — sizes derive from ISV_DIM, no change needed)
- [ ] **Step 1: Update ISV_DIM constant**
Change line 86 of `gpu_dqn_trainer.rs`:
```rust
// Before
const ISV_DIM: usize = 12; // ISV signal count (8 core + 4 regime awareness)
// After
const ISV_DIM: usize = 13; // 8 core + 4 regime awareness + 1 learning_health (index [12])
pub const LEARNING_HEALTH_INDEX: usize = 12;
```
- [ ] **Step 2: Verify compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -5`
Expected: Clean — no errors. The pinned buffer allocation at line 6303-6315 computes bytes as `ISV_DIM * sizeof(f32)` so automatically picks up the new size.
- [ ] **Step 3: Run existing ISV test to confirm no regression**
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_fxcache_zero_copy_training --ignored --nocapture 2>&1 | tail -5`
Expected: PASS (OFI_DIAG still logs, no CUDA errors)
- [ ] **Step 4: Commit**
```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "feat(A1): extend ISV_DIM 12→13, add LEARNING_HEALTH_INDEX constant"
```
---
### Task A2: Create LearningHealth Module
**Files:**
- Create: `crates/ml/src/cuda_pipeline/learning_health.rs`
- Modify: `crates/ml/src/cuda_pipeline/mod.rs` (add module declaration)
- [ ] **Step 1: Write failing test**
Create `crates/ml/src/cuda_pipeline/learning_health.rs`:
```rust
//! LearningHealth signal — unified training health metric.
//!
//! Composes 7 normalized components into a scalar [0, 1] that senses training
//! collapse and drives adaptive hyperparameters.
/// Smoothstep function: smooth transition from 0 at `edge0` to 1 at `edge1`.
/// Standard GLSL smoothstep formula.
#[inline]
pub fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
if edge1 == edge0 { return if x >= edge0 { 1.0 } else { 0.0 }; }
let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Raw input signals for LearningHealth composition.
#[derive(Debug, Clone, Copy, Default)]
pub struct HealthComponents {
pub q_gap: f32, // max - 2nd max of Q values across actions
pub q_var: f32, // variance of Q across actions
pub atom_util: f32, // C51 atom utilization fraction [0, 1]
pub grad_norm: f32, // gradient L2 norm
pub ens_disagreement: f32, // mean pairwise diff across ensemble heads
pub grad_consistency: f32, // cosine similarity of successive gradients
pub spectral_gap: f32, // sigma_1 / sigma_2 from SVD of Q matrix
}
/// Computed normalized signals in [0, 1].
#[derive(Debug, Clone, Copy, Default)]
pub struct NormalizedComponents {
pub q_gap_norm: f32,
pub q_var_norm: f32,
pub atom_util_norm: f32,
pub grad_stable: f32,
pub ens_agree: f32,
pub grad_consistency_norm: f32,
pub spectral_gap_norm: f32,
}
impl NormalizedComponents {
pub fn from_raw(raw: &HealthComponents) -> Self {
Self {
q_gap_norm: smoothstep(0.01, 0.5, raw.q_gap),
q_var_norm: smoothstep(0.001, 0.1, raw.q_var),
atom_util_norm: smoothstep(0.2, 0.7, raw.atom_util),
grad_stable: 1.0 - smoothstep(10.0, 100.0, raw.grad_norm),
ens_agree: (1.0 - raw.ens_disagreement).clamp(0.0, 1.0),
grad_consistency_norm: smoothstep(-0.2, 0.5, raw.grad_consistency),
spectral_gap_norm: 1.0 - smoothstep(2.0, 10.0, raw.spectral_gap),
}
}
/// Weighted sum per spec Layer 1 composition.
pub fn compose(&self) -> f32 {
0.25 * self.q_gap_norm
+ 0.15 * self.q_var_norm
+ 0.15 * self.atom_util_norm
+ 0.15 * self.grad_stable
+ 0.10 * self.ens_agree
+ 0.10 * self.grad_consistency_norm
+ 0.10 * self.spectral_gap_norm
}
}
/// LearningHealth state: EMA-smoothed health value with bounds and warmup.
#[derive(Debug, Clone)]
pub struct LearningHealth {
/// Current EMA-smoothed health [0.2, 0.95].
pub value: f32,
/// Epoch counter — enables warmup mode for first N epochs.
pub epoch: u32,
/// Latest normalized components (for logging).
pub components: NormalizedComponents,
/// EMA smoothing factor (new sample weight).
ema_alpha: f32,
/// Warmup epochs — health clamped to 0.5 during warmup.
warmup_epochs: u32,
}
impl LearningHealth {
pub fn new() -> Self {
Self {
value: 0.5, // neutral start
epoch: 0,
components: NormalizedComponents::default(),
ema_alpha: 0.1,
warmup_epochs: 3,
}
}
/// Update health from new raw components. Returns updated health value.
pub fn update(&mut self, raw: &HealthComponents) -> f32 {
self.components = NormalizedComponents::from_raw(raw);
let new_sample = self.components.compose();
if self.epoch < self.warmup_epochs {
// Warmup: pin to 0.5, allow components to accumulate
self.value = 0.5;
} else {
// EMA update
let ema = (1.0 - self.ema_alpha) * self.value + self.ema_alpha * new_sample;
// Clamp to safe bounds
self.value = ema.clamp(0.2, 0.95);
}
self.epoch += 1;
self.value
}
}
impl Default for LearningHealth {
fn default() -> Self { Self::new() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_smoothstep_boundaries() {
assert_eq!(smoothstep(0.0, 1.0, -0.5), 0.0);
assert_eq!(smoothstep(0.0, 1.0, 1.5), 1.0);
assert!((smoothstep(0.0, 1.0, 0.5) - 0.5).abs() < 0.001);
}
#[test]
fn test_collapsed_inputs_give_low_health() {
let raw = HealthComponents {
q_gap: 0.01, // collapsed
q_var: 0.001, // collapsed
atom_util: 0.1, // under-utilized
grad_norm: 50_000.0, // exploding
ens_disagreement: 0.0,// everyone agrees (on uniform)
grad_consistency: -0.3, // gradients contradict
spectral_gap: 50.0, // rank 1
};
let mut health = LearningHealth::new();
// Skip warmup
for _ in 0..5 { health.update(&raw); }
assert!(health.value <= 0.3, "Collapsed state should give health <= 0.3, got {}", health.value);
}
#[test]
fn test_healthy_inputs_give_high_health() {
let raw = HealthComponents {
q_gap: 1.0, // strong differentiation
q_var: 0.5, // good variance
atom_util: 0.9, // atoms fully utilized
grad_norm: 5.0, // stable
ens_disagreement: 0.1, // moderate diversity
grad_consistency: 0.9, // consistent direction
spectral_gap: 1.5, // balanced rank
};
let mut health = LearningHealth::new();
for _ in 0..5 { health.update(&raw); }
assert!(health.value >= 0.7, "Healthy state should give health >= 0.7, got {}", health.value);
}
#[test]
fn test_warmup_clamps_to_neutral() {
let raw = HealthComponents {
q_gap: 0.01, q_var: 0.001, atom_util: 0.1,
grad_norm: 50_000.0, ens_disagreement: 0.0,
grad_consistency: -0.5, spectral_gap: 100.0,
};
let mut health = LearningHealth::new();
// First 3 updates are warmup — should stay at 0.5
for _ in 0..3 {
let v = health.update(&raw);
assert_eq!(v, 0.5, "Warmup should pin health to 0.5");
}
// 4th update starts EMA
health.update(&raw);
assert!(health.value < 0.5, "Post-warmup should start dropping with collapsed inputs");
}
}
```
- [ ] **Step 2: Add module declaration**
Add to `crates/ml/src/cuda_pipeline/mod.rs` (near other `pub mod` declarations):
```rust
pub mod learning_health;
```
- [ ] **Step 3: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib cuda_pipeline::learning_health 2>&1 | tail -10`
Expected: `test result: ok. 4 passed; 0 failed`
- [ ] **Step 4: Commit**
```bash
git add crates/ml/src/cuda_pipeline/learning_health.rs crates/ml/src/cuda_pipeline/mod.rs
git commit -m "feat(A2): LearningHealth module with 7-component composition + EMA"
```
---
### Task A3: Wire LearningHealth into Training Loop
**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/metrics.rs` (EMA trackers for Q-gap, Q-var, grad_norm)
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (health computation per epoch)
- [ ] **Step 1: Add EMA trackers in metrics.rs**
Find the impl block for DQNTrainer in `metrics.rs` (or wherever per-epoch metrics live). Add struct fields and accessor:
```rust
// Add as fields in DQNTrainer (in mod.rs) or carry in a new struct in metrics.rs.
// For minimum code change, add to existing per-epoch metrics area:
/// Rolling EMA values for LearningHealth inputs.
#[derive(Debug, Clone, Default)]
pub(crate) struct HealthEmaTrackers {
pub q_gap_ema: f32,
pub q_var_ema: f32,
pub grad_norm_ema: f32,
prev_grad_vec: Option<Vec<f32>>, // for grad_consistency cosine
ema_alpha: f32, // 0.1
}
impl HealthEmaTrackers {
pub fn update(&mut self, q_gap: f32, q_var: f32, grad_norm: f32) {
if self.ema_alpha == 0.0 { self.ema_alpha = 0.1; }
self.q_gap_ema = (1.0 - self.ema_alpha) * self.q_gap_ema + self.ema_alpha * q_gap;
self.q_var_ema = (1.0 - self.ema_alpha) * self.q_var_ema + self.ema_alpha * q_var;
self.grad_norm_ema = (1.0 - self.ema_alpha) * self.grad_norm_ema + self.ema_alpha * grad_norm;
}
/// Compute cosine similarity to previous epoch's gradient vector.
pub fn update_grad_consistency(&mut self, current: &[f32]) -> f32 {
let cos = match &self.prev_grad_vec {
None => 0.0,
Some(prev) => {
let n = current.len().min(prev.len());
let dot: f32 = (0..n).map(|i| current[i] * prev[i]).sum();
let norm_c: f32 = (0..n).map(|i| current[i] * current[i]).sum::<f32>().sqrt();
let norm_p: f32 = (0..n).map(|i| prev[i] * prev[i]).sum::<f32>().sqrt();
if norm_c < 1e-8 || norm_p < 1e-8 { 0.0 } else { dot / (norm_c * norm_p) }
}
};
self.prev_grad_vec = Some(current.to_vec());
cos
}
}
```
- [ ] **Step 2: Add LearningHealth field to DQNTrainer**
In `crates/ml/src/trainers/dqn/trainer/mod.rs`, add to the DQNTrainer struct:
```rust
pub(crate) learning_health: crate::cuda_pipeline::learning_health::LearningHealth,
pub(crate) health_ema: crate::trainers::dqn::trainer::metrics::HealthEmaTrackers,
```
Initialize in constructor (`constructor.rs`) alongside other field initializations:
```rust
learning_health: crate::cuda_pipeline::learning_health::LearningHealth::new(),
health_ema: Default::default(),
```
- [ ] **Step 3: Compute learning_health per epoch in training_loop.rs**
In `crates/ml/src/trainers/dqn/trainer/training_loop.rs`, find `process_epoch_boundary` (the function that runs at end of each epoch). Add health computation AFTER existing metric logging, BEFORE validation:
```rust
// ── LearningHealth computation (Layer 1) ──────────────────────────────
{
use crate::cuda_pipeline::learning_health::HealthComponents;
// Read atom utilization from fused context
let atom_util = if let Some(ref fused) = self.fused_ctx {
fused.read_atom_utilization().unwrap_or(0.0)
} else { 0.0 };
// Compute Q-gap, Q-var from recent Q-value readback (already tracked for logging)
let q_gap = self.last_q_gap.unwrap_or(0.0);
let q_var = self.last_q_var.unwrap_or(0.0);
let grad_norm = self.last_grad_norm.unwrap_or(0.0);
let ens_disagreement = self.last_ens_disagreement.unwrap_or(0.0);
// Update EMAs
self.health_ema.update(q_gap, q_var, grad_norm);
// Compute spectral_gap from SVD of current Q batch (read via fused_ctx)
let spectral_gap = if let Some(ref fused) = self.fused_ctx {
fused.compute_q_spectral_gap().unwrap_or(1.0)
} else { 1.0 };
// Gradient consistency: read Adam m-state flattened, compute cosine to previous
let grad_consistency = if let Some(ref fused) = self.fused_ctx {
let adam_m = fused.read_adam_m_sample().unwrap_or_default();
self.health_ema.update_grad_consistency(&adam_m)
} else { 0.0 };
let raw = HealthComponents {
q_gap: self.health_ema.q_gap_ema,
q_var: self.health_ema.q_var_ema,
atom_util,
grad_norm: self.health_ema.grad_norm_ema,
ens_disagreement,
grad_consistency,
spectral_gap,
};
let health_value = self.learning_health.update(&raw);
// Broadcast to GPU at ISV index 12
if let Some(ref fused) = self.fused_ctx {
fused.write_isv_signal_at(
crate::cuda_pipeline::gpu_dqn_trainer::LEARNING_HEALTH_INDEX,
health_value,
);
}
// HEALTH_DIAG log line (see Task A4 for full formatting)
info!(
"HEALTH_DIAG: health={:.2} components [q_gap={:.2} q_var={:.2} atoms={:.2} grad_stable={:.2} ens_agree={:.2} grad_cos={:.2} spectral={:.2}]",
health_value,
self.learning_health.components.q_gap_norm,
self.learning_health.components.q_var_norm,
self.learning_health.components.atom_util_norm,
self.learning_health.components.grad_stable,
self.learning_health.components.ens_agree,
self.learning_health.components.grad_consistency_norm,
self.learning_health.components.spectral_gap_norm,
);
}
```
- [ ] **Step 4: Add helper methods to FusedTrainingCtx**
In `crates/ml/src/trainers/dqn/fused_training.rs`, add methods:
```rust
/// Write a single f32 value to ISV signals buffer at given index (pinned HtoD).
pub(crate) fn write_isv_signal_at(&self, index: usize, value: f32) {
self.trainer.write_isv_signal_at(index, value);
}
/// Read atom utilization (fraction of atoms with > 1% probability).
pub(crate) fn read_atom_utilization(&self) -> anyhow::Result<f32> {
self.trainer.read_atom_utilization().map_err(|e| anyhow::anyhow!("{e}"))
}
/// Compute spectral gap (sigma_1 / sigma_2) on current Q batch via cuSOLVER SVD.
pub(crate) fn compute_q_spectral_gap(&self) -> anyhow::Result<f32> {
self.trainer.compute_q_spectral_gap().map_err(|e| anyhow::anyhow!("{e}"))
}
/// Read a flat sample of Adam m-state (first N params) for grad consistency.
pub(crate) fn read_adam_m_sample(&self) -> anyhow::Result<Vec<f32>> {
self.trainer.read_adam_m_sample(1024).map_err(|e| anyhow::anyhow!("{e}"))
}
```
In `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs`, add the corresponding implementations:
```rust
pub fn write_isv_signal_at(&self, index: usize, value: f32) {
assert!(index < ISV_DIM, "ISV index {} out of bounds (max {})", index, ISV_DIM);
unsafe {
*self.isv_signals_pinned.add(index) = value;
}
}
pub fn read_atom_utilization(&self) -> Result<f32, MLError> {
// atom_util_scratch is already a pinned f32 updated by atom_stats kernel
unsafe { Ok(*self.atom_util_scratch_pinned) }
}
pub fn compute_q_spectral_gap(&self) -> Result<f32, MLError> {
// SVD on current Q batch [B, n_actions] using cuSOLVER.
// For minimum impl: use the existing q_readback buffer, compute SVD on host.
// n_actions is small (4 for direction branch), so SVD cost is negligible.
let q_host = self.read_q_values_sample(1024)?;
let (b_rows, n_cols) = (1024, 4);
if q_host.len() < b_rows * n_cols { return Ok(1.0); }
// Compute column means, subtract (center matrix)
let mut centered = vec![0.0f32; b_rows * n_cols];
for c in 0..n_cols {
let mean: f32 = (0..b_rows).map(|r| q_host[r * n_cols + c]).sum::<f32>() / b_rows as f32;
for r in 0..b_rows {
centered[r * n_cols + c] = q_host[r * n_cols + c] - mean;
}
}
// Compute X^T X (n_cols x n_cols covariance-like matrix)
let mut gram = vec![0.0f32; n_cols * n_cols];
for i in 0..n_cols {
for j in 0..n_cols {
let mut s = 0.0f32;
for r in 0..b_rows { s += centered[r * n_cols + i] * centered[r * n_cols + j]; }
gram[i * n_cols + j] = s;
}
}
// Eigenvalues of Gram matrix == squared singular values of X.
// For 4x4, use QR-style power iteration or just trace/determinant bounds.
// Minimum robust: return ratio of largest to 2nd largest diagonal element
// (coarse approximation — refine later with proper SVD).
let mut diag: Vec<f32> = (0..n_cols).map(|i| gram[i * n_cols + i].sqrt()).collect();
diag.sort_by(|a, b| b.partial_cmp(a).unwrap());
if diag[1] < 1e-8 { Ok(100.0) } else { Ok(diag[0] / diag[1]) }
}
pub fn read_adam_m_sample(&self, n: usize) -> Result<Vec<f32>, MLError> {
// Read first n values of Adam first-moment (m) vector.
// Exists as self.adam_m_buf or similar.
let take_n = n.min(self.adam_m_buf.len());
let mut out = vec![0.0f32; take_n];
let view = self.adam_m_buf.slice(0..take_n);
self.stream.memcpy_dtoh(&view, &mut out)
.map_err(|e| MLError::ModelError(format!("adam m readback: {e}")))?;
Ok(out)
}
pub fn read_q_values_sample(&self, n: usize) -> Result<Vec<f32>, MLError> {
let take_n = n.min(self.q_readback.len());
let mut out = vec![0.0f32; take_n];
let view = self.q_readback.slice(0..take_n);
self.stream.memcpy_dtoh(&view, &mut out)
.map_err(|e| MLError::ModelError(format!("q readback: {e}")))?;
Ok(out)
}
```
Note: `q_readback` and `adam_m_buf` names come from existing fields — verify exact names in gpu_dqn_trainer.rs before Step 4; rename if different.
- [ ] **Step 5: Verify compilation**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -5`
Expected: Clean
- [ ] **Step 6: Run smoke test to see HEALTH_DIAG appear**
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_fxcache_zero_copy_training --ignored --nocapture 2>&1 | grep HEALTH_DIAG | head -3`
Expected: At least one `HEALTH_DIAG` line printed with all 7 components
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "feat(A3): compute LearningHealth per epoch, broadcast via ISV[12], HEALTH_DIAG logging"
```
---
### Task A4: Extend HEALTH_DIAG Logging with All Effective Values
**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (expand log line)
- [ ] **Step 1: Extend HEALTH_DIAG format**
Replace the basic `HEALTH_DIAG` log from Task A3 with the full format (include placeholders — will be filled as mechanisms are implemented in Phase B-D):
```rust
info!(
"HEALTH_DIAG: health={:.2} components [q_gap={:.2} q_var={:.2} atoms={:.2} grad_stable={:.2} ens_agree={:.2} grad_cos={:.2} spectral={:.2}] effective [cql_alpha={:.4} iqn_budget={:.2} cql_budget={:.2} c51_budget={:.2} tau={:.5} sarsa_tau={:.2} gamma={:.3} cf_ratio={:.2}] novels [distill={} barrier={:.3} plasticity={} ib={:.3} ensemble_collapse={:.2} contrarian={} meta_q_pred={:.2}]",
health_value,
self.learning_health.components.q_gap_norm,
self.learning_health.components.q_var_norm,
self.learning_health.components.atom_util_norm,
self.learning_health.components.grad_stable,
self.learning_health.components.ens_agree,
self.learning_health.components.grad_consistency_norm,
self.learning_health.components.spectral_gap_norm,
self.last_cql_alpha_eff.unwrap_or(0.0), // filled in B1
self.last_iqn_budget_eff.unwrap_or(0.40), // filled in B4
self.last_cql_budget_eff.unwrap_or(0.10), // filled in B4
self.last_c51_budget_eff.unwrap_or(0.45), // filled in B4
self.last_tau_eff.unwrap_or(0.005), // filled in B2
self.last_sarsa_tau_factor.unwrap_or(1.0), // filled in B3
self.last_gamma_eff.unwrap_or(0.99), // filled in C4 (P4)
self.last_cf_ratio_eff.unwrap_or(0.5), // filled in D4 (N4)
if self.last_distill_active.unwrap_or(false) { "on" } else { "off" },
self.last_barrier_loss.unwrap_or(0.0), // filled in D2
if self.last_plasticity_ready.unwrap_or(true) { "ready" } else { "cooldown" },
self.last_ib_penalty.unwrap_or(0.0), // filled in D5
self.last_ensemble_collapse_score.unwrap_or(0.0), // filled in D6
if self.last_contrarian_active.unwrap_or(false) { "on" } else { "off" },
self.last_meta_q_pred.unwrap_or(0.0), // filled in D8
);
```
Add corresponding `pub(crate) Option<f32>` fields to `DQNTrainer` struct for each `last_*` value (these are populated by subsequent tasks). For now, all default to their "inactive" values.
- [ ] **Step 2: Verify compilation and log output**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Expected: Clean
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_fxcache_zero_copy_training --ignored --nocapture 2>&1 | grep HEALTH_DIAG`
Expected: Line with all placeholders at default values (cql_alpha=0, barrier=0, etc.)
- [ ] **Step 3: Commit**
```bash
git add crates/ml/src/trainers/dqn/trainer/training_loop.rs crates/ml/src/trainers/dqn/trainer/mod.rs
git commit -m "feat(A4): extend HEALTH_DIAG with all effective values and novel placeholders"
```
---
# Phase B: Core Gems
### Task B1: G2 Uncertainty-Gated CQL
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (CQL alpha effective computation, use in kernel launch)
- [ ] **Step 1: Find CQL alpha usage site**
Read `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:4576-4592` — where `cql_alpha` is passed to CQL backward kernel.
```rust
// Current (line ~4576):
let cql_alpha = self.config.cql_alpha;
// ... passed as kernel arg at line ~4592
```
- [ ] **Step 2: Replace with health-gated effective alpha**
Change the CQL alpha computation to read health and regime_stability from pinned buffer:
```rust
// Read current health and regime_stability from ISV buffer
let health = unsafe { *self.isv_signals_pinned.add(LEARNING_HEALTH_INDEX) };
let regime_stability = unsafe { *self.isv_signals_pinned.add(11) }; // ISV[11]
// G2: cql_alpha_eff = cql_base × (1 - regime_stability) × health
// - Collapse (health=0): CQL OFF
// - Volatile regime + healthy: CQL fully ON
// - Stable regime + healthy: CQL OFF (trust Q)
let cql_alpha_eff = self.config.cql_alpha * (1.0 - regime_stability) * health;
// Store for HEALTH_DIAG logging
self.last_cql_alpha_eff = Some(cql_alpha_eff);
// Use cql_alpha_eff in the kernel launch instead of cql_alpha
```
Replace the `.arg(&cql_alpha)` at line ~4592 with `.arg(&cql_alpha_eff)`.
- [ ] **Step 3: Expose last_cql_alpha_eff to HEALTH_DIAG**
Add to `DQNTrainer` struct:
```rust
pub(crate) last_cql_alpha_eff: Option<f32>,
```
After CQL kernel launch, propagate back to trainer:
```rust
// In fused_training.rs, add:
pub(crate) fn last_cql_alpha_eff(&self) -> f32 {
self.trainer.last_cql_alpha_eff.unwrap_or(0.0)
}
```
In training_loop.rs after epoch boundary:
```rust
if let Some(ref fused) = self.fused_ctx {
self.last_cql_alpha_eff = Some(fused.last_cql_alpha_eff());
}
```
- [ ] **Step 4: Verify compile and smoke test**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_fxcache_zero_copy_training --ignored --nocapture 2>&1 | grep "cql_alpha="`
Expected: `cql_alpha` in HEALTH_DIAG log shows values reflecting the formula (non-static)
- [ ] **Step 5: Commit**
```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs crates/ml/src/trainers/dqn/trainer/training_loop.rs crates/ml/src/trainers/dqn/trainer/mod.rs crates/ml/src/trainers/dqn/fused_training.rs
git commit -m "feat(B1/G2): uncertainty-gated CQL — cql_alpha = base × (1-regime) × health"
```
---
### Task B2: G3 Health-Coupled Tau
**Files:**
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs:2832-2850` (tau annealing logic)
- [ ] **Step 1: Update compute_cosine_annealed_tau**
Find `compute_cosine_annealed_tau` in fused_training.rs (around line 2832). Wrap its output with health-coupled floor:
```rust
pub(crate) fn compute_cosine_annealed_tau(&self, step: usize) -> f32 {
let tau_scheduled = {
// ... existing cosine annealing logic ...
let t = (step as f32 / self.tau_anneal_steps as f32).min(1.0);
let tau_base = self.tau_base;
let tau_final = self.tau_final;
tau_final - (tau_final - tau_base) * (std::f32::consts::PI * t).cos() * 0.5 - (tau_final - tau_base) * 0.5
};
// G3: Health-coupled floor — bumps tau up when learning is unhealthy
let health = unsafe { *self.trainer.isv_signals_pinned.add(LEARNING_HEALTH_INDEX) };
let tau_health_floor = 0.01 * (1.0 - health);
let tau_eff = tau_scheduled.max(tau_health_floor);
// Store for HEALTH_DIAG
self.trainer.last_tau_eff.set(Some(tau_eff));
tau_eff
}
```
Note: `last_tau_eff` may need `Cell<Option<f32>>` wrapper since this method takes `&self`. Adjust struct field accordingly, or make the method `&mut self`.
- [ ] **Step 2: Verify compile and smoke test**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_fxcache_zero_copy_training --ignored --nocapture 2>&1 | grep "tau="`
Expected: `tau` in HEALTH_DIAG shows values >= tau_health_floor when health is low
- [ ] **Step 3: Commit**
```bash
git add crates/ml/src/trainers/dqn/fused_training.rs crates/ml/src/trainers/dqn/trainer/mod.rs
git commit -m "feat(B2/G3): health-coupled tau — bumps to 0.01×(1-health) minimum during collapse"
```
---
### Task B3: G4 Temperature-Continuous Expected SARSA
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/c51_loss_kernel.cu:541-546` (add health-scaled tau factor)
- [ ] **Step 1: Extend c51_bellman_loss kernel signature**
Kernel needs access to `learning_health`. Read from ISV pointer (already passed to kernel as `isv_signals_ptr`). Add helper at top of kernel:
```c
/* Read learning_health from ISV buffer at index 12 (or use 0.5 fallback). */
__device__ __forceinline__ float get_learning_health(const float* __restrict__ isv_signals_ptr) {
return (isv_signals_ptr != NULL) ? isv_signals_ptr[12] : 0.5f;
}
```
- [ ] **Step 2: Modify tau computation at line 541-546**
Change the softmax temperature in the Expected SARSA block:
```c
// Before (lines 541-546):
float q_gap_local = max_eq - min_eq;
float mean_q = (max_eq + min_eq) * 0.5f;
float tau_floor = fmaxf(fabsf(mean_q) * 0.01f, 1e-6f);
float tau = fmaxf(q_gap_local, tau_floor);
// After — G4 health-coupled temperature scaling:
float q_gap_local = max_eq - min_eq;
float mean_q = (max_eq + min_eq) * 0.5f;
float tau_floor = fmaxf(fabsf(mean_q) * 0.01f, 1e-6f);
float tau_base = fmaxf(q_gap_local, tau_floor);
float health = get_learning_health(isv_signals_ptr);
float tau_health_factor = 1.0f + 5.0f * (1.0f - health);
float tau = tau_base * tau_health_factor;
```
Health=1 (healthy): `tau = tau_base × 1.0` → sharp softmax → near-argmax target.
Health=0 (collapsed): `tau = tau_base × 6.0` → wide softmax → stochastic sampling effectively random → breaks symmetry.
- [ ] **Step 3: Ensure isv_signals_ptr reaches c51 kernel**
Check existing c51 bellman loss kernel signature. If `isv_signals_ptr` is not already a parameter, add it. Find the kernel launch site and add the pointer as the last argument.
- [ ] **Step 4: Verify compile**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Expected: Clean (cubin recompiles automatically)
- [ ] **Step 5: Store tau_health_factor on host for logging**
After kernel launch, set `self.last_sarsa_tau_factor = Some(1.0 + 5.0 * (1.0 - current_health));`.
- [ ] **Step 6: Smoke test**
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_fxcache_zero_copy_training --ignored --nocapture 2>&1 | grep "sarsa_tau"`
Expected: `sarsa_tau` value in HEALTH_DIAG reflects health level
- [ ] **Step 7: Commit**
```bash
git add crates/ml/src/cuda_pipeline/c51_loss_kernel.cu crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "feat(B3/G4): health-scaled Expected SARSA temperature — breaks collapse attractor"
```
---
### Task B4: G5 Gradient Budget Allocation
**Files:**
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs:413-415` (budget setup) and accumulation call sites
- [ ] **Step 1: Replace static budgets with dynamic computation**
Find where `iqn_grad_budget`, `cql_grad_budget`, `ens_grad_budget` are applied as scale factors. Replace the static field reads:
```rust
// In fused_training.rs, add a helper method:
pub(crate) fn compute_adaptive_budgets(&self) -> (f32, f32, f32, f32) {
let health = unsafe { *self.trainer.isv_signals_pinned.add(LEARNING_HEALTH_INDEX) };
let regime_stability = unsafe { *self.trainer.isv_signals_pinned.add(11) };
// Health-scaled budgets per spec G5
let iqn_budget = 0.10 + 0.30 * health;
let cql_budget = 0.10 * (1.0 - regime_stability) * health;
let ens_budget = 0.05;
let c51_budget = 1.0 - iqn_budget - cql_budget - ens_budget;
// Cache for HEALTH_DIAG
self.trainer.last_iqn_budget_eff.set(Some(iqn_budget));
self.trainer.last_cql_budget_eff.set(Some(cql_budget));
self.trainer.last_ens_budget_eff.set(Some(ens_budget));
self.trainer.last_c51_budget_eff.set(Some(c51_budget));
(c51_budget, iqn_budget, cql_budget, ens_budget)
}
```
- [ ] **Step 2: Apply budgets at gradient accumulation call sites**
Find each site where a loss component's gradient is scaled before being added to the master gradient (search `iqn_grad_budget`, `cql_grad_budget`, etc.). Replace each scalar scale with the corresponding value from `compute_adaptive_budgets()`:
```rust
let (c51_b, iqn_b, cql_b, ens_b) = self.compute_adaptive_budgets();
// IQN backward — scale by iqn_b
// CQL backward — scale by cql_b
// Ensemble — scale by ens_b
// C51 is the main gradient, scale by c51_b
```
- [ ] **Step 3: Verify compilation and smoke test**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_fxcache_zero_copy_training --ignored --nocapture 2>&1 | grep "iqn_budget="`
Expected: budgets in HEALTH_DIAG reflect health level (e.g., `iqn_budget=0.25` when health=0.5)
- [ ] **Step 4: Commit**
```bash
git add crates/ml/src/trainers/dqn/fused_training.rs
git commit -m "feat(B4/G5): adaptive gradient budget — IQN/CQL/ENS/C51 allocation scales with health"
```
---
# Phase C: Pearls
### Task C1: P1 Health-Weighted PER Priorities
**Files:**
- Modify: `crates/ml-dqn/src/gpu_replay_buffer.rs` (priority update)
- Modify: `crates/ml-dqn/src/replay_buffer_kernels.cu` (add action-diversity-aware priority kernel)
- [ ] **Step 1: Add diversity-weighted priority kernel**
In `crates/ml-dqn/src/replay_buffer_kernels.cu`, add a new kernel:
```c
/* P1: Health-weighted PER priority — boosts priorities of diverse-action
* experiences during collapse (health < 1). */
extern "C" __global__ void pow_alpha_diverse_f32(
float* __restrict__ out_priorities, // [N]
const float* __restrict__ td_errors, // [N]
const int* __restrict__ actions, // [N]
int mean_action_scaled, // scaled int (action_sum * 1000 / N)
int batch_size,
float alpha,
float epsilon,
float health // [0, 1] learning_health
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= batch_size) return;
float base = powf(fabsf(td_errors[i]) + epsilon, alpha);
// Diversity multiplier: higher for actions far from batch mean, scaled by (1 - health)
float action_diff = fabsf((float)actions[i] - (float)mean_action_scaled * 0.001f);
float diversity_mult = 1.0f + 2.0f * (1.0f - health) * action_diff;
out_priorities[i] = base * diversity_mult;
}
```
- [ ] **Step 2: Wire kernel into priority update**
In `gpu_replay_buffer.rs`, find the existing `pow_alpha_f32` kernel launch in the priority update path (search for `pow_alpha_f32`). Add the new kernel as an alternative when `health < 0.8`:
```rust
// Read current health from ISV buffer (via pointer passed to GpuReplayBuffer)
let health = self.current_health(); // new accessor reading pinned buffer
if health < 0.8 {
// Compute mean of `actions` via a reduction kernel OR host readback of a small sample.
// For simplicity: read actions to host (already small — bsi elements of i32), compute mean, scale ×1000.
let mut actions_host = vec![0i32; batch_size];
let view = actions.slice(0..batch_size);
self.stream.memcpy_dtoh(&view, &mut actions_host)
.map_err(|e| MLError::ModelError(format!("actions readback: {e}")))?;
let sum_actions: i64 = actions_host.iter().map(|&a| a as i64).sum();
let mean_action_scaled: i32 = ((sum_actions * 1000) / batch_size.max(1) as i64) as i32;
unsafe {
self.stream.launch_builder(&self.kernels.pow_alpha_diverse_f32)
.arg(&priorities_out).arg(&td_errors).arg(&actions)
.arg(&mean_action_scaled).arg(&bsi).arg(&alpha).arg(&epsilon).arg(&health)
.launch(lcfg(batch_size))?;
}
} else {
// Standard PER (existing)
// ... existing pow_alpha_f32 launch ...
}
```
- [ ] **Step 3: Add current_health accessor**
In `GpuReplayBuffer`, add:
```rust
fn current_health(&self) -> f32 {
// Read from trainer's ISV buffer via the pinned pointer set during init.
// If not wired yet, return 0.5 as safe default.
self.learning_health_cache
}
/// Call from trainer each epoch to update health.
pub fn set_learning_health(&mut self, health: f32) {
self.learning_health_cache = health;
}
```
Wire `set_learning_health` from trainer's epoch boundary after `learning_health.update()`.
- [ ] **Step 4: Verify compile and smoke test**
Run: `SQLX_OFFLINE=true cargo check -p ml-dqn --lib 2>&1 | tail -3`
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_fxcache_zero_copy_training --ignored --nocapture 2>&1 | tail -5`
Expected: smoke test passes, no CUDA errors
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat(C1/P1): health-weighted PER priorities — boost diverse-action experiences during collapse"
```
---
### Task C2: P2 Spectral Collapse Detection (already in A3)
P2 is already incorporated into LearningHealth composition via `spectral_gap_norm` component (added in Task A2) and computed by `compute_q_spectral_gap` in Task A3.
- [ ] **Step 1: Verify spectral_gap shows non-trivial values**
Run smoke test, check HEALTH_DIAG `spectral` value changes over epochs. If it's constant (e.g., always 1.0), the SVD approximation is broken — refine `compute_q_spectral_gap` with a proper power iteration or cuSOLVER call.
No new commit needed — already covered by A2/A3.
---
### Task C3: P3 Gradient Direction Consistency (already in A3)
P3 is already incorporated via `grad_consistency` component in `HealthEmaTrackers::update_grad_consistency` (added in Task A3).
- [ ] **Step 1: Verify grad_cos shows non-trivial values**
Run smoke test, check HEALTH_DIAG `grad_cos` value. Early epochs may show 0 (no prior vector), but subsequent epochs should show [-1, 1] values reflecting gradient direction stability.
No new commit — already covered.
---
### Task C4: P4 Temporal-Coupled Gamma
**Files:**
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs` (gamma computation before C51 Bellman projection)
- [ ] **Step 1: Add adaptive gamma computation**
Find where `gamma` is passed to the C51 loss kernel (search `gamma` in fused_training.rs). Add helper:
```rust
pub(crate) fn compute_adaptive_gamma(&self) -> f32 {
let gamma_base = self.hyperparams.gamma as f32;
let health = unsafe { *self.trainer.isv_signals_pinned.add(LEARNING_HEALTH_INDEX) };
let regime_stability = unsafe { *self.trainer.isv_signals_pinned.add(11) };
// P4: regime × health coupled discount
let gamma_eff = gamma_base
+ 0.005 * (regime_stability - 0.5)
- 0.05 * (1.0 - health);
let gamma_clamped = gamma_eff.clamp(0.9, 0.995);
self.trainer.last_gamma_eff.set(Some(gamma_clamped));
gamma_clamped
}
```
- [ ] **Step 2: Replace static gamma with adaptive**
Find the kernel launch that uses `gamma` (e.g., C51 Bellman projection). Replace the static read with `self.compute_adaptive_gamma()`.
- [ ] **Step 3: Verify**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run smoke test, check `gamma=` in HEALTH_DIAG — should show values in [0.9, 0.995] varying with regime/health.
- [ ] **Step 4: Commit**
```bash
git add crates/ml/src/trainers/dqn/fused_training.rs
git commit -m "feat(C4/P4): temporal-coupled gamma — regime × health scales discount factor"
```
---
# Phase D: Novels
### Task D1: N1 Temporal Self-Distillation
**Files:**
- Create: `crates/ml/src/cuda_pipeline/q_snapshot.rs`
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (snapshot storage + KL distillation loss)
- [ ] **Step 1: Create snapshot ring buffer module**
Create `crates/ml/src/cuda_pipeline/q_snapshot.rs`:
```rust
//! N1: Q-network weight snapshots ring buffer for temporal self-distillation.
//!
//! Keeps up to 5 historical weight checkpoints taken at high-health epochs.
//! During collapse recovery, loss adds KL(current_Q || best_snapshot_Q) term.
use std::sync::Arc;
use cudarc::driver::{CudaSlice, CudaStream};
use crate::MLError;
pub const MAX_SNAPSHOTS: usize = 5;
pub const SNAPSHOT_HEALTH_THRESHOLD: f32 = 0.7;
/// A single weight snapshot with metadata.
pub struct QSnapshot {
pub weights: CudaSlice<f32>,
pub health: f32,
pub q_gap: f32,
pub epoch: u32,
}
/// Ring buffer of the best-health snapshots.
pub struct SnapshotRing {
stream: Arc<CudaStream>,
snapshots: Vec<QSnapshot>,
param_count: usize,
}
impl SnapshotRing {
pub fn new(stream: Arc<CudaStream>, param_count: usize) -> Self {
Self {
stream,
snapshots: Vec::with_capacity(MAX_SNAPSHOTS),
param_count,
}
}
/// Copy current weights into a new snapshot if health qualifies.
pub fn maybe_snapshot(
&mut self,
params_src: &CudaSlice<f32>,
health: f32,
q_gap: f32,
epoch: u32,
) -> Result<bool, MLError> {
if health < SNAPSHOT_HEALTH_THRESHOLD { return Ok(false); }
// Only snapshot if this q_gap beats the worst stored snapshot
let worst_q_gap = self.snapshots.iter().map(|s| s.q_gap).fold(f32::INFINITY, f32::min);
if self.snapshots.len() >= MAX_SNAPSHOTS && q_gap <= worst_q_gap { return Ok(false); }
// Allocate new snapshot buffer and DtoD copy
let mut new_weights = self.stream.alloc_zeros::<f32>(self.param_count)
.map_err(|e| MLError::ModelError(format!("snapshot alloc: {e}")))?;
self.stream.memcpy_dtod(params_src, &mut new_weights)
.map_err(|e| MLError::ModelError(format!("snapshot copy: {e}")))?;
let snap = QSnapshot { weights: new_weights, health, q_gap, epoch };
// Evict worst if at capacity
if self.snapshots.len() >= MAX_SNAPSHOTS {
let worst_idx = self.snapshots.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.q_gap.partial_cmp(&b.q_gap).unwrap())
.map(|(i, _)| i).unwrap();
self.snapshots.swap_remove(worst_idx);
}
self.snapshots.push(snap);
Ok(true)
}
/// Return the best (highest q_gap) snapshot, if any.
pub fn best(&self) -> Option<&QSnapshot> {
self.snapshots.iter().max_by(|a, b| a.q_gap.partial_cmp(&b.q_gap).unwrap())
}
}
```
- [ ] **Step 2: Register module**
Add `pub mod q_snapshot;` to `cuda_pipeline/mod.rs`.
- [ ] **Step 3: Wire into GpuDqnTrainer**
Add field to GpuDqnTrainer:
```rust
pub(crate) q_snapshots: crate::cuda_pipeline::q_snapshot::SnapshotRing,
```
Initialize in constructor.
Call `maybe_snapshot` at end of each epoch:
```rust
// In training_loop.rs epoch boundary after health computation:
if let Some(ref mut fused) = self.fused_ctx {
fused.maybe_snapshot_qnet(health_value, q_gap, epoch);
}
```
- [ ] **Step 4: Add KL distillation loss (stub with flag)**
For the KL loss itself, start with just the flag (`last_distill_active`). Actual kernel implementation:
Add a simple loss computed on host as MSE between current and best snapshot (GPU version requires custom kernel; MSE proxy works for f32 params):
```rust
// In gpu_dqn_trainer.rs, add method:
pub fn apply_distillation_gradient(&mut self, health: f32) -> Result<(), MLError> {
let distill_weight = 0.1 * (1.0 - health).max(0.0);
if distill_weight < 0.01 {
self.last_distill_active = Some(false);
return Ok(());
}
self.last_distill_active = Some(true);
let best = match self.q_snapshots.best() {
Some(b) => b,
None => { self.last_distill_active = Some(false); return Ok(()); }
};
// Compute grad += distill_weight × (current - best_snapshot) / param_count
// This is equivalent to MSE gradient (pulls current toward snapshot).
// Use existing axpy-style kernel or launch_ax: grad += alpha × (current - best)
self.launch_distillation_axpy(
self.params_buf.raw_ptr(),
best.weights.raw_ptr(),
self.grad_buf.raw_ptr(),
distill_weight,
self.total_params as i32,
)?;
Ok(())
}
```
Add `launch_distillation_axpy` as a small kernel that computes `grad[i] += alpha * (current[i] - target[i])`. Reuse an existing axpy kernel if available.
- [ ] **Step 5: Call distillation from training loop**
In epoch boundary after snapshot:
```rust
if health_value < 0.4 {
fused.apply_distillation(health_value)?;
}
```
- [ ] **Step 6: Verify compile and smoke test**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run smoke test, check `distill=off/on` in HEALTH_DIAG.
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "feat(D1/N1): temporal self-distillation — KL loss toward best historical Q snapshot during collapse"
```
---
### Task D2: N2 Q-Gap Barrier Constraint
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/c51_loss_kernel.cu` (add barrier loss to C51 backward)
- [ ] **Step 1: Add barrier loss computation to C51 kernel**
In c51_bellman_loss kernel, at the end of the loss accumulation block (before total loss is written back), add:
```c
/* N2: Q-gap barrier constraint — penalize collapse */
if (tid == 0) {
float health = get_learning_health(isv_signals_ptr);
float min_required_q_gap = 0.05f * health;
float current_q_gap = max_eq - min_eq; // already computed above
float barrier = fmaxf(0.0f, min_required_q_gap - current_q_gap);
float barrier_loss = 0.5f * barrier * barrier;
// Add to loss buffer (optional: could just generate gradient signal)
if (loss_out != NULL) {
atomicAdd(&loss_out[0], barrier_loss / (float)batch_size);
}
// Optional: if we want barrier to generate gradient, we'd do it in the
// advantage backward section. For minimum viable, we log it.
}
```
- [ ] **Step 2: Add barrier readback and logging**
On host, after kernel, read a small barrier accumulator buffer (or approximate from known `q_gap_ema`):
```rust
// Simple approximation on host:
let health = /* read from ISV */;
let q_gap = self.health_ema.q_gap_ema;
let min_required = 0.05 * health;
let barrier = (min_required - q_gap).max(0.0);
self.last_barrier_loss = Some(0.5 * barrier * barrier);
```
- [ ] **Step 3: Verify and commit**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run smoke test, check `barrier=` in HEALTH_DIAG.
```bash
git add -A
git commit -m "feat(D2/N2): Q-gap barrier constraint — explicit loss penalizing collapse"
```
---
### Task D3: N3 Plasticity Injection (Health-Triggered)
**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (replace fixed shrink_perturb schedule with health trigger)
- [ ] **Step 1: Replace shrink_perturb trigger**
Find the existing shrink_perturb call (search `shrink_perturb` in training_loop.rs, around the epoch 20-30 range — there's a periodic call). Replace with health-triggered:
```rust
// Track consecutive unhealthy epochs
let is_unhealthy = health_value < 0.3;
if is_unhealthy {
self.unhealthy_epoch_count += 1;
} else {
self.unhealthy_epoch_count = 0;
}
// N3: trigger plasticity injection after 3 consecutive unhealthy epochs
if self.unhealthy_epoch_count >= 3 {
info!("Plasticity injection triggered: health={:.2} for {} epochs",
health_value, self.unhealthy_epoch_count);
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;
fused.shrink_and_perturb(alpha, sigma)?;
}
self.unhealthy_epoch_count = 0; // reset counter
self.last_plasticity_ready = Some(false);
} else {
self.last_plasticity_ready = Some(true);
}
// Remove any pre-existing periodic shrink_perturb block (health-triggered replaces it).
```
Add `unhealthy_epoch_count: u32` field to DQNTrainer struct.
- [ ] **Step 2: Verify and commit**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run smoke test — should not trigger (short test), but `plasticity=ready` shows in log.
```bash
git add -A
git commit -m "feat(D3/N3): plasticity injection — health-triggered shrink_perturb replaces periodic"
```
---
### Task D4: N4 Counterfactual Curriculum via Health
**Files:**
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` (add cf_ratio parameter to env_step)
- Modify: `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` (pass dynamic cf_ratio)
- [ ] **Step 1: Add cf_ratio parameter to env_step**
Find `env_step_batch` kernel in experience_kernels.cu. Add parameter `float cf_ratio`:
```c
extern "C" __global__ void env_step_batch(
// ... existing args ...
float cf_ratio, // NEW: counterfactual ratio [0, 1] (was hardcoded 0.5)
// ... rest of args ...
) {
// ... existing code ...
int do_flip = 0;
{
float flip_u = philox_uniform(i, bar_idx + 8888, 7777);
do_flip = (flip_u < cf_ratio) ? 1 : 0; // was `flip_u < 0.5f`
}
// ... rest unchanged ...
}
```
- [ ] **Step 2: Compute and pass cf_ratio from host**
In `gpu_experience_collector.rs` at the env_step kernel launch site, compute:
```rust
let health = self.current_health(); // accessor (add to collector)
let cf_ratio = (0.5 + 0.3 * (1.0 - health)).clamp(0.0, 1.0);
self.last_cf_ratio_eff = cf_ratio; // for logging
// In launch_builder add new arg:
.arg(&cf_ratio)
```
- [ ] **Step 3: Expose last_cf_ratio to trainer**
Add accessor on collector, call from training_loop.rs after epoch boundary to populate `self.last_cf_ratio_eff`.
- [ ] **Step 4: Verify and commit**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run smoke test, check `cf_ratio=` in HEALTH_DIAG varies with health.
```bash
git add -A
git commit -m "feat(D4/N4): counterfactual curriculum — cf_ratio scales with (1-health)"
```
---
### Task D5: N5 Information Bottleneck Loss
**Files:**
- Create kernel: `crates/ml/src/cuda_pipeline/c51_loss_kernel.cu` (add ib_penalty kernel)
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs` (compute and apply IB gradient)
- [ ] **Step 1: Add IB penalty kernel**
Add to c51_loss_kernel.cu at the end:
```c
/* N5: Information Bottleneck — penalize lack of state-dependence in Q.
* For each action, compute variance of Q(s_i, a) across batch. If variance
* is below min_q_var, add penalty. Encourages Q to differ across states.
*
* Output: ib_penalty_out[a] = max(0, min_q_var - var_a)
*/
extern "C" __global__ void ib_penalty_kernel(
const float* __restrict__ q_per_action, // [B, n_actions]
float* __restrict__ ib_penalty_out, // [n_actions]
int batch_size, int n_actions, float min_q_var
) {
int a = blockIdx.x;
if (a >= n_actions) return;
int tid = threadIdx.x;
__shared__ float shared_sum;
__shared__ float shared_sq;
if (tid == 0) { shared_sum = 0.0f; shared_sq = 0.0f; }
__syncthreads();
float local_sum = 0.0f, local_sq = 0.0f;
for (int i = tid; i < batch_size; i += blockDim.x) {
float q = q_per_action[i * n_actions + a];
local_sum += q;
local_sq += q * q;
}
atomicAdd(&shared_sum, local_sum);
atomicAdd(&shared_sq, local_sq);
__syncthreads();
if (tid == 0) {
float mean = shared_sum / (float)batch_size;
float var = (shared_sq / (float)batch_size) - mean * mean;
ib_penalty_out[a] = fmaxf(0.0f, min_q_var - var);
}
}
```
- [ ] **Step 2: Wire into fused_training**
After main backward pass, add IB penalty to gradient:
```rust
// In apply_c51_loss or similar:
let health = self.current_health();
let ib_weight = 0.05 * (1.0 - health); // spec says ib_weight × (1 - health)
if ib_weight > 0.001 {
let min_q_var = 0.01f32;
// Launch ib_penalty kernel on current Q batch
// ... (kernel launch — compute penalty vector [n_actions])
// Add penalty to loss scalar; gradient contribution via finite-diff approx
// or proper backward (simpler: treat as regularization by adding d(var)/d(Q) per sample)
self.last_ib_penalty = Some(ib_penalty_total * ib_weight);
}
```
For minimum-viable: compute IB penalty as a scalar that appears in the loss (informational). Full backward through IB can be added in follow-up if it doesn't regress.
- [ ] **Step 3: Verify and commit**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Check `ib=` in HEALTH_DIAG.
```bash
git add -A
git commit -m "feat(D5/N5): information bottleneck — penalize state-independent Q values"
```
---
### Task D6: N6 Ensemble as Collapse Oracle
**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (pairwise ensemble Q-gap)
- [ ] **Step 1: Compute ensemble collapse score**
At epoch boundary, after health computation, compute pairwise Q-gap across ensemble heads:
```rust
// Read Q values from each ensemble head
let ensemble_q_samples: Vec<Vec<f32>> = (0..self.n_ensemble_heads)
.map(|k| self.read_ensemble_q_sample(k).unwrap_or_default())
.collect();
// Compute mean pairwise |Q_i - Q_j|
let mut pairwise_gap_sum = 0.0f32;
let mut pairs = 0usize;
for i in 0..ensemble_q_samples.len() {
for j in (i+1)..ensemble_q_samples.len() {
let n = ensemble_q_samples[i].len().min(ensemble_q_samples[j].len());
let diff_sum: f32 = (0..n)
.map(|k| (ensemble_q_samples[i][k] - ensemble_q_samples[j][k]).abs())
.sum();
pairwise_gap_sum += diff_sum / n.max(1) as f32;
pairs += 1;
}
}
let mean_pairwise_gap = if pairs > 0 { pairwise_gap_sum / pairs as f32 } else { 0.0 };
// Spec formula:
let ensemble_collapse_score = 1.0 - smoothstep(0.01, 0.1, mean_pairwise_gap);
self.last_ensemble_collapse_score = Some(ensemble_collapse_score);
// Trigger N3 if ensemble agrees on collapse
if ensemble_collapse_score > 0.8 && self.unhealthy_epoch_count < 3 {
info!("Ensemble collapse detected (score={:.2}): forcing plasticity injection", ensemble_collapse_score);
if let Some(ref mut fused) = self.fused_ctx {
fused.shrink_and_perturb(
self.hyperparams.shrink_perturb_alpha as f32,
self.hyperparams.shrink_perturb_sigma as f32,
)?;
}
self.unhealthy_epoch_count = 0;
}
```
Add `smoothstep` import from `cuda_pipeline::learning_health::smoothstep`.
- [ ] **Step 2: Verify and commit**
Run smoke test, check `ensemble_collapse=` in HEALTH_DIAG.
```bash
git add -A
git commit -m "feat(D6/N6): ensemble as collapse oracle — pairwise Q-gap triggers plasticity"
```
---
### Task D7: N7 Contrarian Override
**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (WinRate tracking + override flag)
- Modify: action selection site (find in experience_kernels.cu or wherever argmax is used during collection)
- [ ] **Step 1: Add contrarian state to trainer**
```rust
// In DQNTrainer struct:
pub(crate) contrarian_active: bool,
pub(crate) contrarian_remaining_epochs: u32,
pub(crate) low_winrate_count: u32,
```
- [ ] **Step 2: Update based on WinRate in training_loop.rs**
After validation metrics in epoch boundary:
```rust
let winrate = self.last_val_winrate.unwrap_or(0.5);
// Track consecutive low-WinRate epochs
if winrate < 0.40 {
self.low_winrate_count += 1;
} else if winrate >= 0.45 {
// Recovery threshold — disable contrarian
self.contrarian_active = false;
self.low_winrate_count = 0;
}
// Trigger contrarian after 5 consecutive low-WinRate epochs AND unhealthy
if self.low_winrate_count >= 5 && health_value < 0.3 && !self.contrarian_active {
warn!("CONTRARIAN OVERRIDE activated: WinRate={:.1}% for {} epochs, health={:.2}",
winrate * 100.0, self.low_winrate_count, health_value);
self.contrarian_active = true;
self.contrarian_remaining_epochs = 2;
}
if self.contrarian_active {
if self.contrarian_remaining_epochs == 0 {
self.contrarian_active = false;
} else {
self.contrarian_remaining_epochs -= 1;
}
}
self.last_contrarian_active = Some(self.contrarian_active);
```
- [ ] **Step 3: Plumb contrarian flag to action selection**
Pass `contrarian_active` as a u8 argument to the action selection kernel (in experience_kernels.cu). In the kernel, if flag is set, use `argmin` instead of `argmax`:
```c
// In experience_action_select kernel:
int best_a;
if (contrarian_active) {
// Argmin — pick action with LOWEST Q
best_a = 0;
float min_q = q_values[0];
for (int a = 1; a < n_actions; a++) {
if (q_values[a] < min_q) { min_q = q_values[a]; best_a = a; }
}
} else {
// Argmax — standard
best_a = 0;
float max_q = q_values[0];
for (int a = 1; a < n_actions; a++) {
if (q_values[a] > max_q) { max_q = q_values[a]; best_a = a; }
}
}
```
- [ ] **Step 4: Verify and commit**
Run: `SQLX_OFFLINE=true cargo check -p ml --lib 2>&1 | tail -3`
Run smoke test. `contrarian=off` should show in HEALTH_DIAG (won't trigger in short test).
```bash
git add -A
git commit -m "feat(D7/N7): contrarian override — argmin when WinRate<40% for 5+ epochs during collapse"
```
---
### Task D8: N8 Meta-Q Network (Collapse Predictor)
**Files:**
- Create: `crates/ml/src/cuda_pipeline/meta_q_network.rs`
- Modify: `crates/ml/src/cuda_pipeline/mod.rs` (register module)
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (train and infer)
- [ ] **Step 1: Create meta-Q network module**
Create `crates/ml/src/cuda_pipeline/meta_q_network.rs`:
```rust
//! N8: Meta-Q network — predicts collapse probability K epochs ahead.
//!
//! Small MLP (7→32→16→1 sigmoid) trained online via BCE loss on historical
//! (health_components, future_collapse) pairs. Output is logged as a leading
//! indicator — does NOT feed into LearningHealth until validated.
use std::collections::VecDeque;
pub const META_Q_INPUT_DIM: usize = 7;
pub const META_Q_H1: usize = 32;
pub const META_Q_H2: usize = 16;
pub const META_Q_LOOKAHEAD: usize = 5;
#[derive(Debug, Clone)]
pub struct MetaQInput {
pub q_gap_ema: f32,
pub log_grad_norm: f32, // log-scaled
pub atom_util: f32,
pub ens_disagreement: f32,
pub regime_stability: f32,
pub spectral_gap_norm: f32,
pub grad_consistency: f32,
}
impl MetaQInput {
pub fn to_vec(&self) -> [f32; META_Q_INPUT_DIM] {
[
self.q_gap_ema,
self.log_grad_norm,
self.atom_util,
self.ens_disagreement,
self.regime_stability,
self.spectral_gap_norm,
self.grad_consistency,
]
}
}
/// Small MLP on host (CPU) — n_params ≈ 7*32 + 32*16 + 16*1 + biases = ~800.
/// Training cost negligible.
pub struct MetaQNetwork {
w1: Vec<f32>, b1: Vec<f32>, // [INPUT_DIM, H1], [H1]
w2: Vec<f32>, b2: Vec<f32>, // [H1, H2], [H2]
w3: Vec<f32>, b3: Vec<f32>, // [H2, 1], [1]
// Adam state
m_w1: Vec<f32>, v_w1: Vec<f32>,
m_w2: Vec<f32>, v_w2: Vec<f32>,
m_w3: Vec<f32>, v_w3: Vec<f32>,
lr: f32,
step: u32,
// Rolling buffer of (input, future_collapse_label) pairs
buffer: VecDeque<(MetaQInput, f32)>,
buffer_cap: usize,
}
impl MetaQNetwork {
pub fn new() -> Self {
// Xavier init
let xavier = |fan_in: usize, fan_out: usize| -> f32 {
let range = (6.0 / (fan_in + fan_out) as f32).sqrt();
(fastrand::f32() * 2.0 - 1.0) * range
};
let w1: Vec<f32> = (0..META_Q_INPUT_DIM * META_Q_H1).map(|_| xavier(META_Q_INPUT_DIM, META_Q_H1)).collect();
let w2: Vec<f32> = (0..META_Q_H1 * META_Q_H2).map(|_| xavier(META_Q_H1, META_Q_H2)).collect();
let w3: Vec<f32> = (0..META_Q_H2).map(|_| xavier(META_Q_H2, 1)).collect();
Self {
w1, b1: vec![0.0; META_Q_H1],
w2, b2: vec![0.0; META_Q_H2],
w3, b3: vec![0.0; 1],
m_w1: vec![0.0; META_Q_INPUT_DIM * META_Q_H1], v_w1: vec![0.0; META_Q_INPUT_DIM * META_Q_H1],
m_w2: vec![0.0; META_Q_H1 * META_Q_H2], v_w2: vec![0.0; META_Q_H1 * META_Q_H2],
m_w3: vec![0.0; META_Q_H2], v_w3: vec![0.0; META_Q_H2],
lr: 1e-3,
step: 0,
buffer: VecDeque::with_capacity(200),
buffer_cap: 200,
}
}
/// Forward pass — returns P(collapse in next K epochs) in [0, 1].
pub fn predict(&self, input: &MetaQInput) -> f32 {
let x = input.to_vec();
let mut h1 = vec![0.0f32; META_Q_H1];
for j in 0..META_Q_H1 {
let mut s = self.b1[j];
for i in 0..META_Q_INPUT_DIM { s += x[i] * self.w1[i * META_Q_H1 + j]; }
h1[j] = s.max(0.0); // ReLU
}
let mut h2 = vec![0.0f32; META_Q_H2];
for j in 0..META_Q_H2 {
let mut s = self.b2[j];
for i in 0..META_Q_H1 { s += h1[i] * self.w2[i * META_Q_H2 + j]; }
h2[j] = s.max(0.0);
}
let mut logit = self.b3[0];
for i in 0..META_Q_H2 { logit += h2[i] * self.w3[i]; }
1.0 / (1.0 + (-logit).exp()) // sigmoid
}
/// Store training sample. Call each epoch with current input and
/// label (known from history — was collapse observed in the following K epochs?).
pub fn push_sample(&mut self, input: MetaQInput, label: f32) {
if self.buffer.len() >= self.buffer_cap { self.buffer.pop_front(); }
self.buffer.push_back((input, label));
}
/// Train one mini-batch step. Call once per epoch.
pub fn train_step(&mut self) {
if self.buffer.len() < 32 { return; }
// Random minibatch of 32 samples
for _ in 0..32 {
let idx = fastrand::usize(..self.buffer.len());
let (input, label) = self.buffer[idx].clone();
self.train_one(&input, label);
}
self.step += 1;
}
fn train_one(&mut self, input: &MetaQInput, label: f32) {
// Forward (collect activations)
let x = input.to_vec();
let mut h1_pre = vec![0.0f32; META_Q_H1];
let mut h1 = vec![0.0f32; META_Q_H1];
for j in 0..META_Q_H1 {
let mut s = self.b1[j];
for i in 0..META_Q_INPUT_DIM { s += x[i] * self.w1[i * META_Q_H1 + j]; }
h1_pre[j] = s; h1[j] = s.max(0.0);
}
let mut h2_pre = vec![0.0f32; META_Q_H2];
let mut h2 = vec![0.0f32; META_Q_H2];
for j in 0..META_Q_H2 {
let mut s = self.b2[j];
for i in 0..META_Q_H1 { s += h1[i] * self.w2[i * META_Q_H2 + j]; }
h2_pre[j] = s; h2[j] = s.max(0.0);
}
let mut logit = self.b3[0];
for i in 0..META_Q_H2 { logit += h2[i] * self.w3[i]; }
let pred = 1.0 / (1.0 + (-logit).exp());
// BCE loss gradient w.r.t. logit: pred - label
let d_logit = pred - label;
// Backprop through w3, b3
let mut d_w3 = vec![0.0f32; META_Q_H2];
for i in 0..META_Q_H2 { d_w3[i] = d_logit * h2[i]; }
let d_b3 = d_logit;
// d_h2 = d_logit × w3
let d_h2: Vec<f32> = (0..META_Q_H2).map(|i| d_logit * self.w3[i] * (if h2_pre[i] > 0.0 { 1.0 } else { 0.0 })).collect();
// d_w2, d_b2
let mut d_w2 = vec![0.0f32; META_Q_H1 * META_Q_H2];
let d_b2 = d_h2.clone();
for i in 0..META_Q_H1 {
for j in 0..META_Q_H2 { d_w2[i * META_Q_H2 + j] = d_h2[j] * h1[i]; }
}
// d_h1 = d_h2 @ w2^T × ReLU'
let d_h1: Vec<f32> = (0..META_Q_H1).map(|i| {
let mut s = 0.0f32;
for j in 0..META_Q_H2 { s += d_h2[j] * self.w2[i * META_Q_H2 + j]; }
s * (if h1_pre[i] > 0.0 { 1.0 } else { 0.0 })
}).collect();
// d_w1, d_b1
let mut d_w1 = vec![0.0f32; META_Q_INPUT_DIM * META_Q_H1];
let d_b1 = d_h1.clone();
for i in 0..META_Q_INPUT_DIM {
for j in 0..META_Q_H1 { d_w1[i * META_Q_H1 + j] = d_h1[j] * x[i]; }
}
// Adam update
self.adam_update(d_w1, d_w2, d_w3, d_b1, d_b2, d_b3);
}
fn adam_update(&mut self, d_w1: Vec<f32>, d_w2: Vec<f32>, d_w3: Vec<f32>, d_b1: Vec<f32>, d_b2: Vec<f32>, d_b3: f32) {
let beta1 = 0.9f32; let beta2 = 0.999f32; let eps = 1e-8f32;
let t = (self.step + 1) as f32;
let bc1 = 1.0 - beta1.powf(t); let bc2 = 1.0 - beta2.powf(t);
macro_rules! adam {
($p:expr, $m:expr, $v:expr, $g:expr) => {{
for i in 0..$p.len() {
$m[i] = beta1 * $m[i] + (1.0 - beta1) * $g[i];
$v[i] = beta2 * $v[i] + (1.0 - beta2) * $g[i] * $g[i];
let mh = $m[i] / bc1; let vh = $v[i] / bc2;
$p[i] -= self.lr * mh / (vh.sqrt() + eps);
}
}};
}
adam!(self.w1, self.m_w1, self.v_w1, d_w1);
adam!(self.w2, self.m_w2, self.v_w2, d_w2);
adam!(self.w3, self.m_w3, self.v_w3, d_w3);
for i in 0..self.b1.len() { self.b1[i] -= self.lr * d_b1[i]; }
for i in 0..self.b2.len() { self.b2[i] -= self.lr * d_b2[i]; }
self.b3[0] -= self.lr * d_b3;
}
}
impl Default for MetaQNetwork {
fn default() -> Self { Self::new() }
}
```
- [ ] **Step 2: Register module and wire into training loop**
Add `pub mod meta_q_network;` to `cuda_pipeline/mod.rs`.
Add to DQNTrainer:
```rust
pub(crate) meta_q: crate::cuda_pipeline::meta_q_network::MetaQNetwork,
pub(crate) pending_meta_q_samples: std::collections::VecDeque<(MetaQInput, u32)>, // (input, epoch)
pub(crate) health_history: std::collections::VecDeque<(u32, f32)>, // (epoch, health), capped at 2×META_Q_LOOKAHEAD
```
Initialize in constructor:
```rust
meta_q: crate::cuda_pipeline::meta_q_network::MetaQNetwork::new(),
pending_meta_q_samples: std::collections::VecDeque::with_capacity(32),
health_history: std::collections::VecDeque::with_capacity(2 * crate::cuda_pipeline::meta_q_network::META_Q_LOOKAHEAD),
```
Push to `health_history` each epoch after health is computed:
```rust
if self.health_history.len() >= 2 * META_Q_LOOKAHEAD {
self.health_history.pop_front();
}
self.health_history.push_back((self.current_epoch as u32, health_value));
```
Note: `self.current_epoch` already exists on DQNTrainer (used by validation logging); reuse it directly.
At epoch boundary:
```rust
// Predict collapse probability
let meta_q_input = MetaQInput {
q_gap_ema: self.health_ema.q_gap_ema,
log_grad_norm: self.health_ema.grad_norm_ema.max(1e-3).ln(),
atom_util,
ens_disagreement,
regime_stability,
spectral_gap_norm: self.learning_health.components.spectral_gap_norm,
grad_consistency,
};
let meta_q_pred = self.meta_q.predict(&meta_q_input);
self.last_meta_q_pred = Some(meta_q_pred);
// Push pending sample (label determined K epochs later)
self.pending_meta_q_samples.push_back((meta_q_input, self.current_epoch as u32));
// Resolve labels for samples that are K epochs old. Requires a rolling health-history
// buffer `self.health_history: VecDeque<(u32 /*epoch*/, f32 /*health*/)>` populated
// each epoch with current (self.current_epoch, health_value). Cap length at 2×LOOKAHEAD.
while let Some((pending_input, pending_epoch)) = self.pending_meta_q_samples.front().cloned() {
if (self.current_epoch as u32) - pending_epoch < META_Q_LOOKAHEAD as u32 { break; }
// Collapse label = 1 if any health reading in (pending_epoch, pending_epoch+K] was < 0.3
let collapsed = self.health_history.iter()
.filter(|(ep, _)| *ep > pending_epoch && *ep <= pending_epoch + META_Q_LOOKAHEAD as u32)
.any(|(_, h)| *h < 0.3);
let label = if collapsed { 1.0 } else { 0.0 };
self.meta_q.push_sample(pending_input, label);
self.pending_meta_q_samples.pop_front();
}
// Train one step
self.meta_q.train_step();
```
Track a rolling window of health values to determine "collapsed" for label resolution.
- [ ] **Step 3: Write unit test**
Add test in `meta_q_network.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_predict_returns_probability() {
let net = MetaQNetwork::new();
let input = MetaQInput {
q_gap_ema: 0.3, log_grad_norm: 2.0, atom_util: 0.5,
ens_disagreement: 0.1, regime_stability: 0.7,
spectral_gap_norm: 0.6, grad_consistency: 0.3,
};
let p = net.predict(&input);
assert!((0.0..=1.0).contains(&p), "probability out of range: {}", p);
}
#[test]
fn test_training_reduces_loss() {
let mut net = MetaQNetwork::new();
let collapse_input = MetaQInput {
q_gap_ema: 0.01, log_grad_norm: 10.0, atom_util: 0.1,
ens_disagreement: 0.0, regime_stability: 0.5,
spectral_gap_norm: 0.1, grad_consistency: -0.3,
};
// Train to predict 1.0 for collapse state
for _ in 0..100 { net.push_sample(collapse_input.clone(), 1.0); }
for _ in 0..50 { net.train_step(); }
let p = net.predict(&collapse_input);
assert!(p > 0.5, "Trained net should predict >0.5 for collapse state, got {}", p);
}
}
```
- [ ] **Step 4: Verify and commit**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib cuda_pipeline::meta_q_network 2>&1 | tail -5`
Run smoke test, check `meta_q_pred=` in HEALTH_DIAG.
```bash
git add -A
git commit -m "feat(D8/N8): meta-Q network — predicts collapse K epochs ahead as leading indicator"
```
---
# Phase E: Integration Test
### Task E1: Collapse-Recovery Smoke Test
**Files:**
- Create test: `crates/ml/src/trainers/dqn/smoke_tests/adaptive_learning.rs`
- Modify: `crates/ml/src/trainers/dqn/smoke_tests/mod.rs` (register)
- [ ] **Step 1: Create collapse-recovery test**
```rust
//! Validates that LearningHealth adaptive system prevents Q-collapse.
use crate::fxcache;
use super::helpers::{smoke_params, smoke_trainer_with};
#[test]
#[ignore] // GPU required — run via: cargo test -p ml --lib -- test_adaptive_learning_no_collapse --ignored --nocapture
fn test_adaptive_learning_no_collapse() -> anyhow::Result<()> {
// Load fxcache
let cache_dir = super::training_stability::feature_cache_dir();
let entries: Vec<_> = std::fs::read_dir(cache_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("fxcache"))
.collect();
let cache_path = entries[0].path();
let fxcache_data = fxcache::load_fxcache(&cache_path)?;
// Train 10 epochs
let mut params = smoke_params();
params.epochs = 10;
let mut trainer = smoke_trainer_with(params)?;
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
rt.block_on(trainer.init_from_fxcache(
&fxcache_data.features, &fxcache_data.targets, &fxcache_data.ofi,
))?;
let n = fxcache_data.bar_count;
let train_end = (n * 80) / 100;
let val_feat = &fxcache_data.features[train_end..n];
let val_targets = &fxcache_data.targets[train_end..n];
trainer.set_training_range(0, train_end, train_end, n);
trainer.set_val_data_from_slices(val_feat, val_targets, train_end);
rt.block_on(trainer.reset_for_fold())?;
let train_feat = &fxcache_data.features[..train_end];
let train_targets = &fxcache_data.targets[..train_end];
rt.block_on(trainer.train_fold_from_slices(train_feat, train_targets, |_,_,_| Ok("".into())))?;
// Assertions: Q-gap should not collapse to near-zero
let final_q_gap = trainer.health_ema.q_gap_ema;
assert!(final_q_gap > 0.05,
"Q-gap collapsed to {:.4}, adaptive system should prevent this", final_q_gap);
// Health should stay above 0.3 by end of training
assert!(trainer.learning_health.value > 0.3,
"LearningHealth dropped to {:.2}, adaptive mechanisms should have recovered",
trainer.learning_health.value);
eprintln!("[ADAPTIVE] ✓ Q-gap={:.3}, Health={:.2}, Plasticity triggered: {}",
final_q_gap, trainer.learning_health.value, trainer.unhealthy_epoch_count);
Ok(())
}
```
- [ ] **Step 2: Register test**
Add `pub mod adaptive_learning;` to `crates/ml/src/trainers/dqn/smoke_tests/mod.rs`.
- [ ] **Step 3: Run the test**
Run: `SQLX_OFFLINE=true FOXHUNT_TEST_DATA=test_data/futures-baseline cargo test -p ml --lib -- test_adaptive_learning_no_collapse --ignored --nocapture 2>&1 | tail -15`
Expected: PASS with `[ADAPTIVE] ✓ Q-gap=X, Health=Y` output
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "test(E1): collapse-recovery smoke test — verifies adaptive system prevents Q-collapse"
```
---
### Task E2: Deploy and Verify on L40S
**Files:** None (deployment only)
- [ ] **Step 1: Push branch**
```bash
git push
```
- [ ] **Step 2: Deploy to L40S**
```bash
./scripts/argo-train.sh dqn --sha $(git rev-parse HEAD) --baseline --gpu-pool ci-training-l40s
```
- [ ] **Step 3: Monitor first 20 epochs**
Watch HEALTH_DIAG log lines each epoch:
- Q-gap should NOT collapse (stays above 0.1 by epoch 20)
- Q-var should stay above 0.05
- atom_util should stay above 30%
- WinRate should trend toward 45%+ by epoch 20
- val_Sharpe_raw should improve (less negative than -0.48)
- Plasticity injection should trigger at most 1-2 times
- [ ] **Step 4: Compare against pre-spec baseline**
Compare against `train-sl89s` (broken run) and `train-dtzqx` (partial fix) metrics at equivalent epochs. New run should show:
- Q-gap does not collapse from peak (sl89s: 0.66 → 0.05; this run should stay above 0.1)
- WinRate rises (sl89s: stuck 15-20%; this run: should reach 45%+)
- val_Sharpe_raw improves (sl89s: -0.48 stuck; this run: trends toward 0 or positive)