feat(B4/G5): adaptive gradient budget — IQN/CQL/C51 scale with health & regime

IQN and CQL SAXPY contributions into grad_buf are now scaled by adaptive
budget factors derived from learning_health (ISV[12]) and regime_stability
(ISV[11]):
  iqn_budget = 0.10 + 0.30 × health  (0.10..0.40)
  cql_budget = 0.10 × (1−regime) × health  (0 at collapse, volatile+healthy → 0.10)
  ens_budget = 0.05  (constant)
  c51_budget = 1 − iqn − cql − ens  (C51 absorbs headroom at collapse → 0.85)

At collapse (health=0): IQN backed off to 10%, CQL off, C51 takes 85% —
stable directional learning when distributional components are unreliable.

Changes:
- GpuDqnTrainer: add 4 last_*_budget_eff fields (initialized to health=1 defaults)
- GpuDqnTrainer: add read_isv_health_and_regime() public accessor
- apply_iqn_trunk_gradient(): add iqn_budget param; scale = iqn_lambda × readiness × iqn_budget
- apply_cql_saxpy(): add cql_budget param; SAXPY alpha = cql_budget (was 1.0)
- FusedTrainingCtx: add compute_adaptive_budgets() — reads ISV, computes all 4, caches to trainer
- FusedTrainingCtx: add last_{iqn,cql,c51,ens}_budget_eff() accessors
- submit_aux_ops(): call compute_adaptive_budgets() once per step, thread to IQN/CQL sites
- training_loop.rs: B4/G5 propagation block for HEALTH_DIAG logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-20 20:13:04 +02:00
parent aaf6e70f7c
commit 8d088cbc38
3 changed files with 90 additions and 12 deletions

View File

@@ -1671,6 +1671,19 @@ pub struct GpuDqnTrainer {
/// B3/G4: Last health-scaled Expected SARSA tau factor (1.0 at full health, 6.0 at collapse).
/// Populated by `launch_c51_loss`. Initialized to 0.0.
pub(crate) last_sarsa_tau_factor: f32,
/// B4/G5: Last adaptive gradient budget for IQN (0.10 + 0.30×health).
/// Populated by `FusedTrainingCtx::compute_adaptive_budgets`. Default: health=1 → 0.40.
pub(crate) last_iqn_budget_eff: f32,
/// B4/G5: Last adaptive gradient budget for CQL (0.10×(1regime)×health).
/// Populated by `FusedTrainingCtx::compute_adaptive_budgets`. Default: 0.00 (regime_stability=1).
pub(crate) last_cql_budget_eff: f32,
/// B4/G5: Last adaptive gradient budget for C51 (1iqncqlens).
/// Populated by `FusedTrainingCtx::compute_adaptive_budgets`. Default: health=1 → 0.55.
pub(crate) last_c51_budget_eff: f32,
/// B4/G5: Adaptive gradient budget for ensemble (constant 0.05).
/// Populated by `FusedTrainingCtx::compute_adaptive_budgets`.
pub(crate) last_ens_budget_eff: f32,
}
impl GpuDqnTrainer {
@@ -4064,6 +4077,7 @@ impl GpuDqnTrainer {
&mut self,
iqn_d_h_s2_ptr: u64,
_online_dueling: &mut DuelingWeightSet,
iqn_budget: f32,
) -> Result<(), MLError> {
let b = self.config.batch_size;
let _eg = EventTrackingGuard::new(self.stream.context());
@@ -4184,13 +4198,14 @@ impl GpuDqnTrainer {
)?;
}
// ── 7. Plain SAXPY: grad_buf[trunk] += iqn_lambda * scratch ──
// ── 7. Plain SAXPY: grad_buf[trunk] += iqn_lambda * iqn_budget * scratch ──
// Adaptive lambda: scales with IQN loss readiness (0 when uncertain, 1 when converged).
// B4/G5: further scaled by iqn_budget (0.10+0.30×health).
// Uses aux_child-specific handle — saxpy_f32_kernel is captured in forward_child.
{
let scratch_ptr = self.ptrs.iqn_trunk_m;
let grad_ptr = self.ptrs.grad_buf;
let scale = self.config.iqn_lambda * self.iqn_readiness;
let scale = self.config.iqn_lambda * self.iqn_readiness * iqn_budget;
let n_i32 = trunk_grad_total as i32;
let blocks = ((trunk_grad_total + 255) / 256) as u32;
unsafe {
@@ -4771,14 +4786,15 @@ impl GpuDqnTrainer {
///
/// Called after `apply_cql_gradient` populated `cql_grad_scratch`.
/// No per-component clip — single global clip in Adam handles safety.
pub fn apply_cql_saxpy(&mut self) -> Result<(), MLError> {
pub fn apply_cql_saxpy(&mut self, cql_budget: f32) -> Result<(), MLError> {
let total = self.total_params as i32;
let blocks = self.grad_norm_blocks as u32;
let scratch_ptr = self.ptrs.cql_grad_scratch;
let grad_ptr = self.ptrs.grad_buf;
let alpha = 1.0_f32;
// B4/G5: scale CQL gradient contribution by adaptive budget (0.10×(1regime)×health).
let alpha = cql_budget;
// Plain SAXPY: grad_buf += 1.0 * cql_scratch
// Plain SAXPY: grad_buf += cql_budget * cql_scratch
// Uses aux_child-specific handle — saxpy_f32_kernel is captured in forward_child.
unsafe {
self.stream
@@ -7452,6 +7468,10 @@ impl GpuDqnTrainer {
last_cql_alpha_eff: 0.0,
last_tau_eff: 0.0,
last_sarsa_tau_factor: 0.0,
last_iqn_budget_eff: 0.40,
last_cql_budget_eff: 0.00,
last_c51_budget_eff: 0.55,
last_ens_budget_eff: 0.05,
})
}
@@ -7633,6 +7653,18 @@ impl GpuDqnTrainer {
}
}
/// B4/G5: Read ISV health index and regime stability from pinned host memory.
/// Returns (health [0,1], regime_stability [0,1]). Falls back to (0.5, 0.5) if
/// pinned pointer is null.
pub(crate) fn read_isv_health_and_regime(&self) -> (f32, f32) {
if self.isv_signals_pinned.is_null() { return (0.5_f32, 0.5_f32); }
unsafe {
let h = (*self.isv_signals_pinned.add(LEARNING_HEALTH_INDEX)).clamp(0.0, 1.0);
let s = (*self.isv_signals_pinned.add(11)).clamp(0.0, 1.0);
(h, s)
}
}
/// B2/G3: Apply a health-coupled minimum to a scheduled tau value.
/// Returns `max(tau_scheduled, 0.01 * (1.0 - health))`. During collapse
/// (health≈0), the floor rises to 0.01 to accelerate target-network adaptation.

View File

@@ -1157,11 +1157,13 @@ impl FusedTrainingCtx {
).map_err(|e| anyhow::anyhow!("HER in-place relabel kernel: {e}"))?;
}
// NOTE: Per-component gradient budget clip REMOVED.
// The old code clipped C51+MSE gradient to a fraction of max_grad_norm,
// producing a CONSTANT gradient norm every step. Adam degenerates into
// SignSGD when it never sees true gradient magnitude variation.
// Safety is now handled by the single global clip inside Adam's kernel.
// NOTE: Per-component gradient budget clip REMOVED (old explicit clip → constant grad norm).
// B4/G5: Budgets are now applied as SCALE factors on individual SAXPY contributions,
// not as norm clips. This preserves gradient magnitude variation while steering
// component balance. Safety is still handled by the single global clip in Adam.
// B4/G5: Compute adaptive budgets once per step from ISV health/regime signals.
let (_c51_budget, iqn_budget, cql_budget, _ens_budget) = self.compute_adaptive_budgets();
// DIAGNOSTIC: sync between each aux op to find hanging kernel
@@ -1425,7 +1427,7 @@ impl FusedTrainingCtx {
if let Some(ref mut iqn) = self.gpu_iqn {
let d_h_s2_ptr = iqn.d_h_s2_raw_ptr();
self.trainer.apply_iqn_trunk_gradient(
d_h_s2_ptr, &mut self.online_dueling,
d_h_s2_ptr, &mut self.online_dueling, iqn_budget,
).map_err(|e| anyhow::anyhow!("IQN trunk gradient: {e}"))?;
let dqn = agent.primary_dqn_mut();
@@ -1457,10 +1459,11 @@ impl FusedTrainingCtx {
// CQL gradient + SAXPY (no per-component clip — Adam handles safety).
// B4/G5: cql_budget scales the SAXPY contribution (0.10×(1regime)×health).
if self.trainer.has_cql() {
match self.trainer.apply_cql_gradient() {
Ok(true) => {
self.trainer.apply_cql_saxpy()
self.trainer.apply_cql_saxpy(cql_budget)
.map_err(|e| anyhow::anyhow!("CQL SAXPY: {e}"))?;
}
Ok(false) => {}
@@ -2203,6 +2206,42 @@ impl FusedTrainingCtx {
self.trainer.last_sarsa_tau_factor
}
/// B4/G5: Compute adaptive gradient budgets from learning_health and regime_stability.
/// Returns (c51_budget, iqn_budget, cql_budget, ens_budget). Caches to trainer fields
/// for HEALTH_DIAG logging.
///
/// Formula:
/// iqn_budget = 0.10 + 0.30 × health
/// cql_budget = 0.10 × (1 regime_stability) × health
/// ens_budget = 0.05 (constant)
/// c51_budget = 1.0 iqn_budget cql_budget ens_budget
///
/// At health=1: iqn=0.40, cql variable (depends on regime_stability), ens=0.05, c51=rest.
/// At health=0: iqn=0.10, cql=0, ens=0.05, c51=0.85 (C51 takes over).
pub(crate) fn compute_adaptive_budgets(&mut self) -> (f32, f32, f32, f32) {
let (health, regime_stability) = self.trainer.read_isv_health_and_regime();
let iqn_budget = 0.10_f32 + 0.30_f32 * health;
let cql_budget = 0.10_f32 * (1.0 - regime_stability) * health;
let ens_budget = 0.05_f32;
let c51_budget = (1.0_f32 - iqn_budget - cql_budget - ens_budget).max(0.0);
self.trainer.last_iqn_budget_eff = iqn_budget;
self.trainer.last_cql_budget_eff = cql_budget;
self.trainer.last_c51_budget_eff = c51_budget;
self.trainer.last_ens_budget_eff = ens_budget;
(c51_budget, iqn_budget, cql_budget, ens_budget)
}
/// B4/G5: Last adaptive IQN gradient budget (0.10 + 0.30×health).
pub(crate) fn last_iqn_budget_eff(&self) -> f32 { self.trainer.last_iqn_budget_eff }
/// B4/G5: Last adaptive CQL gradient budget (0.10×(1regime)×health).
pub(crate) fn last_cql_budget_eff(&self) -> f32 { self.trainer.last_cql_budget_eff }
/// B4/G5: Last adaptive C51 gradient budget (1iqncqlens).
pub(crate) fn last_c51_budget_eff(&self) -> f32 { self.trainer.last_c51_budget_eff }
/// B4/G5: Last ensemble gradient budget (constant 0.05).
pub(crate) fn last_ens_budget_eff(&self) -> f32 { self.trainer.last_ens_budget_eff }
/// Compute spectral gap on the last Q readback slice (coarse max/min ratio proxy).
pub(crate) fn compute_q_spectral_gap(&self) -> f32 {
self.trainer.compute_q_spectral_gap()

View File

@@ -1873,6 +1873,13 @@ impl DQNTrainer {
self.last_sarsa_tau_factor = Some(fused.last_sarsa_tau_factor());
}
// B4/G5: propagate adaptive gradient budgets for logging.
if let Some(ref fused) = self.fused_ctx {
self.last_iqn_budget_eff = Some(fused.last_iqn_budget_eff());
self.last_cql_budget_eff = Some(fused.last_cql_budget_eff());
self.last_c51_budget_eff = Some(fused.last_c51_budget_eff());
}
// HEALTH_DIAG: components are [0, 1] normalized. effective = hyperparams after health-adaptation. novels = mechanism states.
tracing::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}]",