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>
2017 lines
72 KiB
Markdown
2017 lines
72 KiB
Markdown
# 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)
|