feat(sp4): Layer B — atomic consumer migration to ISV-driven bounds

Single coordinated commit per `feedback_no_partial_refactor`. All
SP3-era hardcoded magnitude multipliers (10×, 100×, 1e3×, 1e6×) and
ε floors (.max(1.0)) replaced by per-slot ISV reads with consumer-side
EPS_CLAMP_FLOOR=1.0 numerical safety.

Mechanism mapping:
- Mech 1 target_q clamp: 10 × Q_ABS_REF.max(1.0) → ISV[TARGET_Q_BOUND]
- Mech 2 atom-position clamps (3 sites × 4 branches): 10 × Q_ABS_REF
  → ISV[ATOM_POS_BOUND[branch]]
- Mech 5 fused diagnostic: per-slot ISV reads in
  `dqn_nan_check_fused_f32_kernel` (kernel takes `isv_signals*` instead
  of `q_abs_ref_eff` / `h_s2_rms_ema_eff` host args)
- Mech 6 adaptive_clip upper_bound: 100 × slow_ema × Q_ABS_REF
  → ISV[GRAD_CLIP_BOUND]
- Mech 9 post-Adam weight_clamp (5 Adam kernels): 100 × Q_ABS_REF
  → ISV[WEIGHT_BOUND[group]]
- Mech 10 h_s2 clamp: 100 × H_S2_RMS_EMA → ISV[H_S2_BOUND]
- AdamW weight_decay (5 kernels): config field → ISV[WD_RATE[group]]
- L1 lambda (trunk only): 1e-3 → ISV[L1_LAMBDA_TRUNK_INDEX]

DQN main Adam split into 3 per-group sub-launches (DqnTrunk / DqnValue /
DqnBranches) per `feedback_no_quickfixes`. Overrides the plan's
"max/min-of-3 single-launch shortcut" recommendation. Each sub-launch
reads its own WEIGHT_BOUND[group], WD_RATE[group], and (trunk only)
L1_LAMBDA_TRUNK_INDEX. Pearl C engagement-counter deferral from
A14/A15 resolved in this same commit — per-group split means each
sub-launch writes per-block counts at its own offset, and
`pearl_c_post_adam_engagement_check` is invoked per group from
fused_training.rs (DqnTrunk/DqnValue/DqnBranches separate calls).

`weight_decay` field removed from:
- DQNHyperparameters (crates/ml/src/trainers/dqn/config.rs)
- GpuDqnTrainConfig (crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs)
- GpuIqnConfig (crates/ml/src/cuda_pipeline/gpu_iqn_head.rs)
- GpuIqlConfig (crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs)
- TrialOverrides + PSO search-space (crates/ml/src/training_profile.rs)
- apply_family_scaling (`weight_decay *= li` line removed)

Aux trainers outside SP4 8-group taxonomy (DT, ofi_embed, denoise,
sel/recursive_conf) keep `weight_clamp_max_abs = 0.0` disable —
mirrors the existing DT pattern. They have no individual ISV producer,
so they don't read SP4 bounds.

Files-touched (17): atoms_update_kernel.cu, iql_value_kernel.cu,
experience_kernels.cu, dqn_utility_kernels.cu, gpu_dqn_trainer.rs,
gpu_iqn_head.rs, gpu_iql_trainer.rs, gpu_attention.rs, gpu_tlob.rs,
fused_training.rs, training_loop.rs, constructor.rs, config.rs,
generalization.rs (smoke), training_profile.rs, train_baseline_rl.rs,
dqn-wire-up-audit.md.

Verification (local, RTX 3050 Ti):
- `cargo check -p ml --offline`: clean.
- `git grep -nE "10\.0_f32 \* q_abs_ref|10\.0f \* q_abs_ref|100\.0_f32
  \* q_abs_ref|100\.0f \* q_abs_ref|1e6_f32 \* q_abs_ref|1e3_f32 \*
  q_abs_ref|100\.0_f32 \* h_s2|100\.0f \* h_s2_rms" crates/ml/src/`:
  ZERO matches.
- `git grep -nE "weight_decay:\s*f64|l1_lambda:" crates/ml/src/trainers/dqn/`:
  ZERO matches.
- `git grep -n "self.config.weight_decay" crates/ml/src/`: only TFT
  remains (separate trainer outside SP4 scope).
- `git grep -n "q_abs_ref_eff|h_s2_rms_ema_eff"
  crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu`: ZERO matches.
- 8 SP4 lib tests pass (sp4_wiener_ema, sp4_isv_slots,
  state_reset_registry).
- 14 SP4 producer GPU tests pass on RTX 3050 Ti (no behavior change at
  producer level — consumer-side migration only).
- `cargo test -p ml --lib --offline`: 928 passed, 14 failed (all 14
  pre-existing on HEAD `1389d1c81`; no new failures).

Validation deferred to Layer C smoke. Expected: F0/F1/F2 all complete
5 epochs; F1 trains past step 1000; F0 ≥ 37.5; F2 ≥ 55; slot 49 quiet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-01 11:10:41 +02:00
parent 1389d1c810
commit 58ffb3a48e
17 changed files with 671 additions and 320 deletions

View File

@@ -444,7 +444,8 @@ fn build_dqn_hyperparams(
huber_delta: hp_f32(hp, "huber_delta").unwrap_or(10.0),
entropy_coefficient: hp_f64(hp, "entropy_coefficient").unwrap_or(0.01),
curiosity_weight: hp_f32(hp, "curiosity_weight").unwrap_or(0.1),
weight_decay: hp_f64(hp, "weight_decay").unwrap_or(1e-4),
// SP4 Layer B: `weight_decay` removed from DQNHyperparameters; per-group
// rates are sourced from ISV[WD_RATE[group]] at runtime.
kelly_fractional: hp_f32(hp, "kelly_fractional").unwrap_or(0.5),
kelly_max_fraction: hp_f32(hp, "kelly_max_fraction").unwrap_or(0.25),
mbp10_data_dir: args.mbp10_data_dir.as_ref().map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "test_data/futures-baseline-mbp10".to_string()),

View File

@@ -20,6 +20,11 @@
* with v_min = isv[23+2*b] - isv[24+2*b], v_max = isv[23+2*b] + isv[24+2*b].
*/
/* SP4 Layer B: per-branch atom-position bound from ISV[ATOM_POS_BOUND_BASE + branch].
* Mirrors `crates/ml/src/cuda_pipeline/sp4_isv_slots.rs::ATOM_POS_BOUND_BASE = 132`.
*/
#define ATOM_POS_BOUND_BASE 132
extern "C" __global__ void atoms_update(
unsigned long long spacing_ptr_b0, /* device ptr to spacing_raw[dir] */
unsigned long long spacing_ptr_b1, /* device ptr to spacing_raw[mag] */
@@ -101,14 +106,13 @@ extern "C" __global__ void atoms_update(
float frac = (tid == 0) ? 0.0f : s_cumsum[tid - 1];
float new_pos = v_min + range * frac;
/* SP3 Mech 2: bound atom positions to ±10 × ISV[Q_ABS_REF=16].max(1.0).
* Atoms span the Q range; capping at 10× allows full distribution coverage
* without extreme-tail growth. Same ISV bound as Mech 1 (target_q clip)
* — atoms and target_q share the magnitude scale. ε on the multiplier
* (`isv.max(1.0)`) per the SP1 ε-floor pearl; bound ≥ 10 even with
* cold-start ISV. */
const float q_abs_ref_eff = fmaxf(isv_signals[16], 1.0f);
const float max_atom_abs = 10.0f * q_abs_ref_eff;
/* SP4 Layer B (Mech 2): bound atom positions to ±ISV[ATOM_POS_BOUND[branch]].
* Per-branch p99(|atom_positions|) populated by the SP4 producer
* `launch_sp4_atom_pos_p99` (Layer A Task A6) per branch ∈ [0,4).
* ε floor on the bound itself (`max(.., 1.0)`) per the SP1 cold-start
* pearl — pre-first-observation ISV reads as 0 and the consumer must
* not collapse the bound to zero. */
const float max_atom_abs = fmaxf(isv_signals[ATOM_POS_BOUND_BASE + branch], 1.0f);
new_pos = fminf(fmaxf(new_pos, -max_atom_abs), max_atom_abs);
out[tid] = new_pos;

View File

@@ -1792,62 +1792,85 @@ extern "C" __global__ void popart_normalize_robust(
//
// SP3 Mech 5 (Task B6) extension: grid extended from 12 → 24 blocks. Slots
// 0-11 (= absolute flag indices 24-35) keep NaN-only check (threshold = +inf
// trivially false). Slots 12-23 (= absolute 36-47) add ISV-derived magnitude
// threshold checks against backward-path Adam state, weights, target_q, and
// atom-positions buffers. Threshold is computed inline per-slot from a single
// `q_abs_ref_eff` arg (no per-slot threshold buffer, no per-step HtoD copies).
// trivially false). Slots 12-25 (= absolute 36-49) add SP4-driven magnitude
// threshold checks against backward-path Adam state, weights, target_q,
// atom-positions, IQN online_params, and post-trunk h_s2 buffers. Each
// threshold reads ISV[SP4_*_BOUND[group/branch]] directly — no magnitude
// multiplier, no per-slot threshold buffer, no per-step HtoD copies.
//
// SP3 close-out v2 extension: grid extended 24 → 25 blocks. Relative slot 24
// (= absolute 48) covers IQN's separate online_params buffer — the F1 step-
// 3540 NaN was traced to this buffer drifting through `iqn_adam_kernel` (its
// own param + Adam state, never reached by the single-kernel Mech 9 in
// `dqn_adam_update_kernel`). Same `1e3 × q_abs_ref_eff` weight-slice
// threshold as slots 44-45 — IQN online_params share the trunk weight regime.
// `dqn_adam_update_kernel`). SP4 Layer B reads ISV[WEIGHT_BOUND[Iqn=3]] for
// this slot.
//
// SP3 Mech 10 extension: grid extended 25 → 26 blocks. Relative slot 25
// (= absolute 49) covers post-trunk h_s2 activation max-abs — the regression
// sentinel for Mech 10's ISV-driven activation clamp. Mech 10 clamps h_s2 at
// 100 × H_S2_RMS_EMA.max(1.0); slot 49 fires at 1e3 × the same base, so it
// stays quiet under normal Mech 10 operation and only triggers if the clamp
// is disabled or `H_S2_RMS_EMA` itself drifted. Mirrors the Mech 5 family
// pattern (clamp at 100×, diagnostic at 1000×). The threshold base is
// `h_s2_rms_ema_eff = max(ISV[H_S2_RMS_EMA_INDEX=96], 1.0)` — distinct from
// `q_abs_ref_eff` because h_s2 lives in pre-readout activation space, not Q
// space. Don't conflate the two ISV bases.
// sentinel for Mech 10's ISV-driven activation clamp. SP4 Layer B reads
// ISV[H_S2_BOUND_INDEX=169] for this slot (no magnitude multiplier; the
// p99 producer kernel `h_s2_p99_update` already tracks the upper bound that
// Mech 10 clamps to). Mirrors the Mech 5 family pattern.
//
// Slot-index → threshold mapping (relative slot ≥ 12; absolute = slot + 24):
// 12-15 (abs 36-39): Adam m max-abs ≥ 100 × q_abs_ref_eff
// 16-19 (abs 40-43): Adam v max-abs ≥ 1e6 × q_abs_ref_eff²
// 20-21 (abs 44-45): Weight max-abs ≥ 1e3 × q_abs_ref_eff
// 22 (abs 46) : target_q post-clip ≥ 95 × q_abs_ref_eff
// (= 9.5 × max_abs_target_q, where max_abs_target_q =
// 10 × q_abs_ref_eff per `clamp_target_q_buf`)
// 23 (abs 47) : atom-span max ≥ 190 × q_abs_ref_eff
// (= 9.5 × max_atom_abs × 2, max_atom_abs = 10 ×
// q_abs_ref_eff)
// 24 (abs 48) : IQN online_params max-abs ≥ 1e3 × q_abs_ref_eff
// (SP3 close-out v2 — slot-26 GEMM regression sentinel)
// 25 (abs 49) : h_s2 post-clamp max-abs ≥ 1e3 × h_s2_rms_ema_eff
// (SP3 Mech 10 — activation-clamp regression sentinel)
// SP4 Layer B (2026-05-01): per-slot threshold sourced directly from the
// SP4 ISV bound for that slot. No magnitude multipliers; the diagnostic
// fires when |buf[i]| ≥ ISV[bound_idx] (i.e. when a buffer overshoots its
// own clamp threshold). Mech 5's regression-sentinel headroom is now
// supplied by Pearl D's adaptive α tracking the underlying p99 directly.
//
// q_abs_ref_eff = max(ISV[Q_ABS_REF=16], 1.0) (slots 12-24)
// h_s2_rms_ema_eff = max(ISV[H_S2_RMS_EMA_INDEX=96], 1.0)(slot 25 only)
// Both computed host-side at launch, passed by-value as float kernel args.
// ε on multiplier per SP1 pearl: cold-start ISV (~0) gives *_eff = 1, so
// all thresholds floor at their ε-floor multiplier × 1. No per-slot
// threshold buffer, no new ISV slots.
// Slot-index → ISV index mapping (relative slot ≥ 12; absolute = slot + 24):
// 12-15 (abs 36-39): Adam m max-abs ≥ ISV[ADAM_M_BOUND[group_idx]]
// group_idx = abs_slot - 36 (groups 0..3)
// 16-19 (abs 40-43): Adam v max-abs ≥ ISV[ADAM_V_BOUND[group_idx]]
// group_idx = abs_slot - 40 (groups 0..3)
// 20-21 (abs 44-45): Weight max-abs ≥ ISV[WEIGHT_BOUND[group_idx]]
// group_idx = abs_slot - 44 (groups 0..1 — trunk/value)
// 22 (abs 46) : target_q post-clip ≥ ISV[TARGET_Q_BOUND]
// 23 (abs 47) : atom-position span ≥ ISV[ATOM_POS_BOUND[branch=0]]
// (any branch is representative since the 4-branch
// diagnostic checks the full positions buffer
// union; branch 0 = direction)
// 24 (abs 48) : IQN online_params max-abs ≥ ISV[WEIGHT_BOUND[Iqn=3]]
// 25 (abs 49) : h_s2 post-clamp max-abs ≥ ISV[H_S2_BOUND]
//
// SP4 ISV slot constants (mirrored from `sp4_isv_slots.rs`):
// ATOM_POS_BOUND_BASE = 132
// WEIGHT_BOUND_BASE = 136
// ADAM_M_BOUND_BASE = 144
// ADAM_V_BOUND_BASE = 152
// TARGET_Q_BOUND_INDEX = 131
// H_S2_BOUND_INDEX = 169
//
// Invariants preserved from dqn_nan_check_f32:
// - sticky-flag semantics (writes 1 only; never clears)
// - block-local reduce (no atomicAdd)
// - graph-capture safe (pure kernel launch, no host ops)
// ============================================================================
#ifndef SP4_TARGET_Q_BOUND_INDEX
#define SP4_TARGET_Q_BOUND_INDEX 131
#endif
#ifndef SP4_ATOM_POS_BOUND_BASE
#define SP4_ATOM_POS_BOUND_BASE 132
#endif
#ifndef SP4_WEIGHT_BOUND_BASE
#define SP4_WEIGHT_BOUND_BASE 136
#endif
#ifndef SP4_ADAM_M_BOUND_BASE
#define SP4_ADAM_M_BOUND_BASE 144
#endif
#ifndef SP4_ADAM_V_BOUND_BASE
#define SP4_ADAM_V_BOUND_BASE 152
#endif
#ifndef SP4_H_S2_BOUND_INDEX
#define SP4_H_S2_BOUND_INDEX 169
#endif
extern "C" __global__ void dqn_nan_check_fused_f32_kernel(
const float* const* buf_ptrs, // [N_SLOTS] (26 entries: 12 NaN-only + 14 threshold)
const int* buf_lens, // [N_SLOTS]
float q_abs_ref_eff, // SP3 Mech 5: ISV-derived multiplier (slots 12-24)
float h_s2_rms_ema_eff, // SP3 Mech 10: ISV-derived multiplier (slot 25 only)
int base_flag_idx, // first slot index (24 for backward checks)
const float* const* buf_ptrs, // [N_SLOTS] (26 entries: 12 NaN-only + 14 threshold)
const int* buf_lens, // [N_SLOTS]
const float* __restrict__ isv_signals, // SP4 Layer B: per-slot threshold source (mapped-pinned)
int base_flag_idx, // first slot index (24 for backward checks)
int* nan_flags_buf
) {
const int slot = blockIdx.x;
@@ -1855,39 +1878,39 @@ extern "C" __global__ void dqn_nan_check_fused_f32_kernel(
const int n = buf_lens[slot];
if (buf == nullptr || n == 0) return; // deferred slot or unused entry
// SP3 Mech 5: per-slot threshold by relative slot index. Slots 0-11
// SP4 Layer B: per-slot threshold by relative slot index. Slots 0-11
// (= absolute 24-35) are NaN-only checks (threshold = +inf so the
// magnitude check is trivially false). Slots 12-25 (= absolute 36-49)
// check magnitude exceedance per the audit table above. Slots 12-24
// derive thresholds from q_abs_ref_eff = max(isv[Q_ABS_REF=16], 1.0);
// slot 25 uses h_s2_rms_ema_eff = max(isv[H_S2_RMS_EMA_INDEX=96], 1.0)
// because h_s2 lives in activation space, not Q space.
// read directly from the SP4 ISV bound for that slot. The bound is
// floored at 1.0 to handle pre-first-observation (Pearl A sentinel)
// ISV reads as 0 — same cold-start convention as the Rust consumers.
float thr = INFINITY;
if (slot >= 12 && slot < 16) {
// slots 36-39: Adam m
thr = 100.0f * q_abs_ref_eff;
// slots 36-39: Adam m, groups 0..3
const int group_idx = slot - 12; // abs_slot - 36
thr = fmaxf(isv_signals[SP4_ADAM_M_BOUND_BASE + group_idx], 1.0f);
} else if (slot >= 16 && slot < 20) {
// slots 40-43: Adam v
thr = 1.0e6f * q_abs_ref_eff * q_abs_ref_eff;
// slots 40-43: Adam v, groups 0..3
const int group_idx = slot - 16; // abs_slot - 40
thr = fmaxf(isv_signals[SP4_ADAM_V_BOUND_BASE + group_idx], 1.0f);
} else if (slot >= 20 && slot < 22) {
// slots 44-45: weight slices (trunk / heads)
thr = 1.0e3f * q_abs_ref_eff;
// slots 44-45: weight slices, groups 0..1 (trunk / value)
const int group_idx = slot - 20; // abs_slot - 44
thr = fmaxf(isv_signals[SP4_WEIGHT_BOUND_BASE + group_idx], 1.0f);
} else if (slot == 22) {
// slot 46: target_q post-clip (max_abs_target_q = 10 × q_abs_ref_eff)
thr = 95.0f * q_abs_ref_eff;
// slot 46: target_q post-clip
thr = fmaxf(isv_signals[SP4_TARGET_Q_BOUND_INDEX], 1.0f);
} else if (slot == 23) {
// slot 47: atom-positions span (max_atom_abs = 10 × q_abs_ref_eff)
thr = 190.0f * q_abs_ref_eff;
// slot 47: atom-positions (branch 0 = direction; positions buffer
// union diagnostic — any branch's bound is representative).
thr = fmaxf(isv_signals[SP4_ATOM_POS_BOUND_BASE], 1.0f);
} else if (slot == 24) {
// slot 48 (SP3 close-out v2): IQN online_params — same weight-slice
// regime as slots 44-45. Captures the slot-26 GEMM blind spot.
thr = 1.0e3f * q_abs_ref_eff;
// slot 48 (SP3 close-out v2): IQN online_params — uses the IQN
// (group 3) weight bound.
thr = fmaxf(isv_signals[SP4_WEIGHT_BOUND_BASE + 3], 1.0f);
} else if (slot == 25) {
// slot 49 (SP3 Mech 10): post-trunk h_s2 max-abs sentinel. Mech 10
// clamps h_s2 at 100 × h_s2_rms_ema_eff; this fires at 1e3 ×, so
// it never trips under normal operation. If it does, the clamp was
// disabled or H_S2_RMS_EMA itself drifted.
thr = 1.0e3f * h_s2_rms_ema_eff;
// slot 49 (SP3 Mech 10): post-trunk h_s2 max-abs sentinel.
thr = fmaxf(isv_signals[SP4_H_S2_BOUND_INDEX], 1.0f);
}
int flag_set = 0;

View File

@@ -165,6 +165,13 @@ __device__ __forceinline__ int argmax_n(const float* arr, int n) {
#define N_IQN_QUANTILES 5
#endif
/* SP4 Layer B: per-branch atom-position bound from ISV[ATOM_POS_BOUND_BASE + branch].
* Mirrors `crates/ml/src/cuda_pipeline/sp4_isv_slots.rs::ATOM_POS_BOUND_BASE = 132`.
*/
#ifndef ATOM_POS_BOUND_BASE
#define ATOM_POS_BOUND_BASE 132
#endif
/**
* Inverse-CDF sample from a C51 atom distribution.
*
@@ -5974,12 +5981,13 @@ extern "C" __global__ void adaptive_atom_positions(
float frac = (tid == 0) ? 0.0f : s_cumsum[tid - 1];
float new_pos = v_min + range * frac;
/* SP3 Mech 2: bound atom positions to ±10 × ISV[Q_ABS_REF=16].max(1.0).
* Mirrors atoms_update_kernel.cu — same ISV bound as Mech 1 (target_q
* clip). Kept in lockstep with the active atoms_update kernel so this
* legacy single-branch entry point stays bound-consistent if re-wired. */
const float q_abs_ref_eff = fmaxf(isv_signals[16], 1.0f);
const float max_atom_abs = 10.0f * q_abs_ref_eff;
/* SP4 Layer B (Mech 2): bound atom positions to ±ISV[ATOM_POS_BOUND[branch_idx]].
* Per-branch p99(|atom_positions|) populated by SP4 producer
* `launch_sp4_atom_pos_p99` (Layer A Task A6). Mirrors atoms_update_kernel.cu;
* kept in lockstep with the active atoms_update kernel so this legacy
* single-branch entry point stays bound-consistent if re-wired.
* ε floor on the bound itself (`max(.., 1.0)`) per the SP1 cold-start pearl. */
const float max_atom_abs = fmaxf(isv_signals[ATOM_POS_BOUND_BASE + branch_idx], 1.0f);
new_pos = fminf(fmaxf(new_pos, -max_atom_abs), max_atom_abs);
positions_out[tid] = new_pos;

View File

@@ -1057,10 +1057,13 @@ impl GpuAttention {
max_grad_norm: f32,
override_stream: Option<&Arc<CudaStream>>,
override_workspace: Option<(u64, usize)>,
/* SP3 Mech 9 (close-out v2): ISV-driven |p| bound for `attn_adam_kernel`
* — caller computes `100 × ISV[Q_ABS_REF].max(1.0)` host-side.
/* SP4 Layer B (Mech 9): ISV-driven |p| bound for `attn_adam_kernel`
* — caller reads `ISV[WEIGHT_BOUND[Attn=6]]` with EPS_CLAMP_FLOOR ε.
* 0=disabled (debug only). */
weight_clamp_max_abs: f32,
/* SP4 Layer B (Mech 9): ISV-driven AdamW weight_decay rate.
* Caller reads `ISV[WD_RATE[Attn=6]]` with `.max(0.0)`. */
weight_decay_value: f32,
) -> Result<(), MLError> {
let stream = override_stream.unwrap_or(&self.stream);
// override_workspace is accepted for API symmetry; adam_step does not use
@@ -1111,7 +1114,11 @@ impl GpuAttention {
let beta1 = 0.9_f32;
let beta2 = 0.999_f32;
let eps = 1e-8_f32;
let wd = 1e-5_f32;
// SP4 Layer B (Mech 9): AdamW weight_decay sourced from
// ISV[WD_RATE[Attn=6]] by the caller (`FusedTrainingCtx`) and
// threaded through `adam_step`. `.max(0.0)` upstream enforces the
// AdamW non-negativity contract.
let wd: f32 = weight_decay_value;
let t_ptr = self.t_dev_ptr;
// SP4 Task A14: Pearl C engagement-counter args. Both

View File

@@ -1537,8 +1537,11 @@ pub struct GpuDqnTrainConfig {
pub beta2: f32,
/// Adam ε (numerical stability).
pub epsilon: f32,
/// Decoupled weight decay (AdamW).
pub weight_decay: f32,
// SP4 Layer B: `weight_decay` field removed; per-group AdamW rates are
// sourced from ISV[WD_RATE[group]] inside `launch_adam_update`'s 3-way
// sub-launch loop (DqnTrunk / DqnValue / DqnBranches) and threaded
// through to IQN/IQL/Attn/Curiosity launchers via per-launch
// `weight_decay_value: f32` parameters.
/// Maximum gradient L2 norm for clipping.
pub max_grad_norm: f32,
/// Spectral norm clipping target (σ_max). Constrains ||W||_σσ_max.
@@ -1659,7 +1662,7 @@ impl Default for GpuDqnTrainConfig {
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
weight_decay: 1e-4, // Standard L2 regularization strength
// SP4 Layer B: weight_decay field removed; sourced from ISV at runtime.
max_grad_norm: 10000.0, // Safety-only — per-component clip removed, raw norms ~4000
spectral_norm_sigma_max: 3.0,
spectral_decoupling_lambda: 0.01,
@@ -6146,9 +6149,13 @@ impl GpuDqnTrainer {
let wd_mask_ptr = self.weight_decay_mask.raw_ptr();
let l1_end_aux: i32 = 0;
let l1_lambda_aux: f32 = 0.0;
// SP3 Mech 9: ISV-driven post-Adam weight clamp (see `launch_adam_update`).
let q_abs_ref_eff = self.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff;
// SP4 Layer B: aux trainer (OFI embed) is outside the SP4 8-group
// taxonomy — disable Mech 9 weight clamp here (mirrors DT pattern).
// Setting `weight_clamp_max_abs = 0.0` triggers the kernel's
// `if (bound > 0.0f)` short-circuit; Adam behaves as before for
// ofi_embed without unbounded ISV-driven clamps that would
// require an SP4 producer this aux head doesn't have.
let weight_clamp_max_abs: f32 = 0.0_f32;
// SP4 Task A14: aux trainer (OFI embed) is outside the SP4 8-group
// taxonomy — disable Pearl C engagement counter for this launch.
let _aux_nan_flags_null: u64 = 0;
@@ -7488,6 +7495,24 @@ impl GpuDqnTrainer {
self.total_params
}
/// SP4 Layer B: per-group param counts for the 3 DQN-internal groups
/// (Trunk / Value / Branches). Mirrors the byte-offset slicing in
/// `param_group_buffers` and `launch_adam_update`. Returned counts feed
/// `pearl_c_post_adam_engagement_check` per group, replacing Layer A's
/// "total_params under group 0 only" approximation.
pub fn dqn_group_param_counts(&self) -> (usize, usize, usize) {
let f32_sz = std::mem::size_of::<f32>() as u64;
let param_sizes = compute_param_sizes(&self.config);
let trunk = self.trunk_param_count;
let value_off = padded_byte_offset(&param_sizes, 13);
let value_end = padded_byte_offset(&param_sizes, 17);
let value = ((value_end - value_off) / f32_sz) as usize;
let branches_off = padded_byte_offset(&param_sizes, 17);
let branches_end = padded_byte_offset(&param_sizes, 33);
let branches = ((branches_end - branches_off) / f32_sz) as usize;
(trunk, value, branches)
}
/// Apply IQN auxiliary gradient to the shared trunk via SAXPY into `grad_buf`.
///
/// After `graph_forward` replay (C51 forward → backward) and IQN backward
@@ -17070,21 +17095,11 @@ impl GpuDqnTrainer {
let nan_flags_ptr = self.nan_flags_buf.raw_ptr();
let buf_ptrs_dev = self.nan_check_buf_ptrs.dev_ptr;
let buf_lens_dev = self.nan_check_buf_lens.dev_ptr;
// SP3 Mech 5: read ISV[Q_ABS_REF=16] for slot 36-48 threshold
// computation. Inline ε on multiplier (`max(.., 1.0)`) per the SP1
// pearl — cold-start ISV (~0) gives q_abs_ref_eff = 1, so all
// thresholds floor at their ε-floor multiplier × 1. Pass as f32 to
// match the kernel's `float q_abs_ref_eff` arg (cudarc f64→float ABI
// bug pearl: passing f64 to a `float` param reads only low 4 bytes).
let q_abs_ref = self.read_isv_signal_at(Q_ABS_REF_INDEX);
let q_abs_ref_eff: f32 = q_abs_ref.max(1.0_f32);
// SP3 Mech 10: read ISV[H_S2_RMS_EMA_INDEX=96] for slot 49 threshold
// (= 1e3 × h_s2_rms_ema_eff). Distinct ISV base from `q_abs_ref_eff`
// because h_s2 lives in activation space, not Q space — don't conflate
// the two. Same ε floor convention (`max(.., 1.0)`) so cold-start RMS
// (~0) gives h_s2_rms_ema_eff = 1 (threshold floors at 1e3).
let h_s2_rms_ema = self.read_isv_signal_at(H_S2_RMS_EMA_INDEX);
let h_s2_rms_ema_eff: f32 = h_s2_rms_ema.max(1.0_f32);
// SP4 Layer B: per-slot threshold sourced directly from the SP4 ISV
// bound for that slot. Kernel reads `isv_signals[SP4_*_BOUND[...]]`
// inline — no host-side multiplier, no per-step HtoD copy. The
// mapped-pinned ISV buffer is graph-capture-safe via its dev_ptr.
let isv_signals_dev_ptr: u64 = self.isv_signals_dev_ptr;
const BLOCKS: u32 = 26;
const THREADS: u32 = 256;
const BASE_FLAG_IDX: i32 = 24;
@@ -17098,8 +17113,7 @@ impl GpuDqnTrainer {
.launch_builder(&self.nan_check_fused_f32_kernel)
.arg(&buf_ptrs_dev)
.arg(&buf_lens_dev)
.arg(&q_abs_ref_eff)
.arg(&h_s2_rms_ema_eff)
.arg(&isv_signals_dev_ptr)
.arg(&BASE_FLAG_IDX)
.arg(&nan_flags_ptr)
.launch(cfg)
@@ -17870,18 +17884,19 @@ impl GpuDqnTrainer {
.map_err(|e| MLError::ModelError(format!("compute_denoise_target_q expected_q: {e}")))?;
}
// SP3 Mech 1: clip target_q to ±10 × ISV[Q_ABS_REF=16].max(1.0).
// Hard clamp via dqn_clamp_finite_f32_kernel (reused from SP1). Single
// point — all downstream losses (C51 / IQN / MSE) consume the same
// denoise_target_q_buf, so one clip covers every consumer. ISV-driven
// bound per feedback_isv_for_adaptive_bounds; ε on the multiplier
// (`isv.max(1.0)`) per the SP1 ε-floor pearl.
//
// F0 paper-review: F0-typical |target_q| bounded by reward × horizon
// ≤ ~10. With Q_ABS_REF EMA ≈ 1-5 at F0 convergence, max_abs = 10-50.
// F0 guard is no-op. F1 inflation (thousands+) gets clamped.
let q_abs_ref = self.read_isv_signal_at(Q_ABS_REF_INDEX);
let max_abs_target_q = 10.0_f32 * q_abs_ref.max(1.0_f32);
// SP4 Layer B (Mech 1): clip target_q to ±ISV[TARGET_Q_BOUND=131].
// Hard clamp via dqn_clamp_finite_f32_kernel (reused from SP1).
// Single point — all downstream losses (C51 / IQN / MSE) consume the
// same denoise_target_q_buf, so one clip covers every consumer.
// SP4 producer `launch_sp4_target_q_p99` (Layer A Task A5) tracks
// p99(|target_q|) into the ISV bound directly; the multiplier-driven
// bound from SP3 (`10 × Q_ABS_REF.max(1.0)`) is replaced with this
// adaptive p99 bound per `feedback_no_quickfixes` and
// `feedback_isv_for_adaptive_bounds`. EPS_CLAMP_FLOOR=1.0 ε on the
// bound itself (SP1 ε-floor pearl) handles cold-start ISV (= 0).
use crate::cuda_pipeline::sp4_isv_slots::TARGET_Q_BOUND_INDEX;
use crate::cuda_pipeline::sp4_wiener_ema::EPS_CLAMP_FLOOR;
let max_abs_target_q = self.read_isv_signal_at(TARGET_Q_BOUND_INDEX).max(EPS_CLAMP_FLOOR);
self.launch_clamp_finite_f32(
self.denoise_target_q_buf.raw_ptr(),
self.denoise_target_q_buf.len(),
@@ -18554,9 +18569,9 @@ impl GpuDqnTrainer {
let wd_mask_ptr = self.weight_decay_mask.raw_ptr();
let l1_end_aux: i32 = 0;
let l1_lambda_aux: f32 = 0.0;
// SP3 Mech 9: ISV-driven post-Adam weight clamp (see `launch_adam_update`).
let q_abs_ref_eff = self.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff;
// SP4 Layer B: aux trainer (denoise) is outside the SP4 8-group
// taxonomy — disable Mech 9 weight clamp here (mirrors DT pattern).
let weight_clamp_max_abs: f32 = 0.0_f32;
// SP4 Task A14: aux trainer (denoise) is outside the SP4 8-group
// taxonomy — Pearl C engagement counter disabled for this launch.
let _aux_nan_flags_null: u64 = 0;
@@ -18707,15 +18722,11 @@ impl GpuDqnTrainer {
// consumer (loss path, IQN, value head, replay save, eval-action
// select) reads the buffer.
//
// Bound: `100 × ISV[H_S2_RMS_EMA_INDEX=96].max(1.0)`. ISV slot 96
// already tracks the per-batch RMS EMA of h_s2 (per Plan 4 Task
// 2c.3c.5 — same ISV slot consumed by `mag_concat_qdir` adaptive
// scale). Reusing the existing slot keeps Mech 10 consistent with
// `feedback_isv_for_adaptive_bounds.md` (no new ISV slots, no
// hardcoded multipliers). The `max(1.0)` ε floor on the multiplier
// honours the SP1 cold-start convention — at fold boundary the EMA
// resets to 1.0 (neutral RMS) per `state_reset_registry.rs`, so the
// clamp floor is always at least 100 × 1 = 100, never zero.
// SP4 Layer B (Mech 10): bound = ISV[H_S2_BOUND=169]. Producer
// `launch_sp4_h_s2_p99` (Layer A Task A9) tracks p99(|save_h_s2|)
// through Pearls A+D into ISV[169] directly — no magnitude
// multiplier, no separate RMS scaling. The EPS_CLAMP_FLOOR ε on
// the bound itself preserves SP1 cold-start convention.
//
// Diagnostic ordering (SP1 pearl): the inline NaN check on slot 12
// fires BEFORE the clamp sanitises, so the sentinel sees the original
@@ -18731,9 +18742,10 @@ impl GpuDqnTrainer {
// The clamp adds a single kernel launch in the captured graph
// (expected — captured graph topology grows by one node).
{
use crate::cuda_pipeline::sp4_isv_slots::H_S2_BOUND_INDEX;
use crate::cuda_pipeline::sp4_wiener_ema::EPS_CLAMP_FLOOR;
self.check_nan_f32(self.save_h_s2.raw_ptr(), self.save_h_s2.len(), 12)?;
let h_s2_rms_ema_eff = self.read_isv_signal_at(H_S2_RMS_EMA_INDEX).max(1.0_f32);
let max_abs_h_s2 = 100.0_f32 * h_s2_rms_ema_eff;
let max_abs_h_s2 = self.read_isv_signal_at(H_S2_BOUND_INDEX).max(EPS_CLAMP_FLOOR);
self.launch_clamp_finite_f32(
self.save_h_s2.raw_ptr(),
self.save_h_s2.len(),
@@ -21071,25 +21083,44 @@ impl GpuDqnTrainer {
/// Argument order matches `dqn_adam_update_kernel` in `dqn_utility_kernels.cu`:
/// params, grads, m, v, grad_norm_sq, lr, beta1, beta2, epsilon,
/// weight_decay, max_grad_norm, t_ptr, total_params,
/// weight_decay_mask, l1_end, l1_lambda, weight_clamp_max_abs = 17 args.
/// weight_decay_mask, l1_end, l1_lambda, weight_clamp_max_abs,
/// nan_flags_buf, diag_slot, clamp_engage_per_block_buf, engage_buf_offset
/// = 21 args.
///
/// Must be launched AFTER `launch_grad_norm` so that `grad_norm_buf`
/// contains the completed sum of squares (no race condition).
/// `t_ptr` is a device buffer (not scalar) so the CUDA Graph can be
/// replayed with an updated step counter.
///
/// Single Adam launch covers ALL parameters `[0..total_params)`,
/// including the VSN tensors at indices `[95..119)`. VSN gradient noise
/// **SP4 Layer B (2026-05-01)**: split into 3 sub-launches per group
/// (DqnTrunk / DqnValue / DqnBranches). Each sub-launch reads its own
/// `ISV[WEIGHT_BOUND[group]]` clamp + `ISV[WD_RATE[group]]` AdamW rate.
/// L1 lambda (`ISV[L1_LAMBDA_TRUNK_INDEX]`) applies to the trunk only;
/// value/branches pass `l1_end=0 + l1_lambda=0`. Per-group split
/// resolves the Layer A Pearl C engagement-counter accounting deferral
/// — each sub-launch writes per-block counts at its own group offset.
/// Per `feedback_no_quickfixes`: NOT a max/min-of-3 single-launch
/// shortcut. Per `feedback_no_partial_refactor`: every consumer of
/// the SP4 contract migrates in this same commit.
///
/// All parameters `[0..total_params)` are covered across the 3
/// sub-launches (trunk = `params[0..trunk_param_count]`, value =
/// indices [13..17), branches = indices [17..33)). VSN tensors at
/// indices `[95..119)` fall inside the trunk slice; gradient noise
/// from aux paths is attenuated upstream by the in-place
/// `VSN_DW_DAMP` scaler in `aux_bottleneck_vsn_backward_dispatch`
/// (1B-iv-rc) before it reaches `grad_buf[95..119)` — no Adam-state
/// isolation is required.
fn launch_adam_update(&self) -> Result<(), MLError> {
use crate::cuda_pipeline::sp4_isv_slots::{
weight_bound, wd_rate, ParamGroup as SP4Group, L1_LAMBDA_TRUNK_INDEX,
};
use crate::cuda_pipeline::sp4_wiener_ema::EPS_CLAMP_FLOOR;
let lr_ptr = self.lr_dev_ptr; // pinned device-mapped — graph-safe, value read at replay
let beta1 = self.config.beta1;
let beta2 = self.config.beta2;
let epsilon = self.config.epsilon;
let weight_decay = self.config.weight_decay;
let adaptive_clip_ptr = self.ptrs.adaptive_clip_buf;
let norm_ptr = self.ptrs.grad_norm_buf;
let t_ptr = self.ptrs.t_buf;
@@ -21097,84 +21128,129 @@ impl GpuDqnTrainer {
// G1+G8: weight_decay_mask + L1 proximal on w_s1.
let wd_mask_base = self.weight_decay_mask.raw_ptr();
let param_sizes = compute_param_sizes(&self.config);
let l1_end: i32 = align4(param_sizes[0]) as i32; // end of w_s1
let l1_lambda: f32 = 1e-3; // L1 penalty strength on w_s1
let trunk_l1_end: i32 = align4(param_sizes[0]) as i32; // end of w_s1
// SP3 Mech 9: ISV-driven post-Adam weight clamp.
// Bound = 100 × Q_ABS_REF.max(1.0). ε on the multiplier per the
// SP1 pearl (`isv.max(1.0)` — never floor the bound itself, that
// re-introduces the cold-start clamp pathology). One OOM below the
// slot 44-45 diagnostic threshold (1e3 × Q_ABS_REF) so the
// regression sentinel retains 10× firing headroom.
let q_abs_ref_eff = self.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff;
let count = self.total_params as i32;
// SP4 Task A14: cap blocks at MAX_BLOCKS_PER_ADAM (256). Per-block
// engagement-counter buffer is sized for that ceiling; running with
// more blocks would have the kernel write past the per-group slice.
// For DQN main-trainer total_params can exceed 256×256=65 536, so we
// assert the bound here. If it ever fires, the buffer needs sizing
// up before this Adam launch can grow.
let blocks = ((self.total_params + 255) / 256) as u32;
debug_assert!(
(blocks as usize) <= MAX_BLOCKS_PER_ADAM,
"dqn_adam blocks={} exceeds MAX_BLOCKS_PER_ADAM={} — Pearl C \
clamp_engage_per_block_buf cannot accommodate; resize buffer \
or chunk the launch.",
blocks, MAX_BLOCKS_PER_ADAM
);
let cfg = LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
};
let params_ptr = self.ptrs.params_ptr;
let grad_ptr = self.ptrs.grad_buf;
let m_ptr = self.ptrs.m_buf;
let v_ptr = self.ptrs.v_buf;
// SP4 Task A14: Pearl C engagement-counter args. The single main
// DQN Adam launch covers param-groups 0/1/2 (trunk/value/branches);
// we account engagement under group 0 (DqnTrunk) for Layer A — see
// `pearl_c_post_adam_engagement_check` doc-comment for the
// sub-launch deferral rationale.
// SP4 Layer B (Mech 9 + Pearl C): per-group sub-launches replace the
// single fused launch. Each group reads its own ISV-driven weight
// clamp (`ISV[WEIGHT_BOUND[group]]`) and AdamW weight_decay
// (`ISV[WD_RATE[group]]`); L1 lambda is read from
// `ISV[L1_LAMBDA_TRUNK_INDEX]` for the trunk only (groups 1+2 pass 0).
// The split also resolves Layer A's Pearl C engagement-counter
// limitation (engagement was previously accounted under group 0
// only — A14/A15 deferral). Each sub-launch now writes per-block
// counts at distinct offsets `group_idx × MAX_BLOCKS_PER_ADAM`,
// and `pearl_c_post_adam_engagement_check` reads each group's
// dedicated range. Per `feedback_no_quickfixes`: NOT a
// max/min-of-3 single-launch shortcut. Per `feedback_no_partial_refactor`:
// every consumer of the WEIGHT_BOUND/WD_RATE/L1_LAMBDA_TRUNK
// contract migrates in this same commit.
let nan_flags_ptr = self.nan_flags_buf.raw_ptr();
let diag_slot: i32 = SP4_DQN_ADAM_DIAG_SLOT;
let engage_buf_ptr: u64 = self.clamp_engage_per_block_buf.dev_ptr;
let engage_buf_offset: i32 =
(ParamGroup::DqnTrunk.idx() * MAX_BLOCKS_PER_ADAM) as i32;
// Per-group descriptor: (ParamGroup, byte_offset, count). Mirrors
// `param_group_buffers` for the DQN-internal groups exactly (trunk
// = full main params, value = bytes [13..17], branches = bytes
// [17..33]). We compute byte-offsets here directly to avoid
// threading `SP4AuxBuffers` through this launcher (DQN-internal
// groups never need aux pointers).
let f32_sz = std::mem::size_of::<f32>() as u64;
let trunk_count = self.trunk_param_count;
let trunk_byte_off: u64 = 0;
let value_byte_off = padded_byte_offset(&param_sizes, 13);
let value_byte_end = padded_byte_offset(&param_sizes, 17);
let value_count = ((value_byte_end - value_byte_off) / f32_sz) as usize;
let branches_byte_off = padded_byte_offset(&param_sizes, 17);
let branches_byte_end = padded_byte_offset(&param_sizes, 33);
let branches_count = ((branches_byte_end - branches_byte_off) / f32_sz) as usize;
let groups: [(SP4Group, u64, usize); 3] = [
(SP4Group::DqnTrunk, trunk_byte_off, trunk_count),
(SP4Group::DqnValue, value_byte_off, value_count),
(SP4Group::DqnBranches, branches_byte_off, branches_count),
];
// Safety: argument order matches the extern "C" kernel signature exactly.
// grad_norm_buf contains L2 norm from launch_grad_norm_finalize().
unsafe {
self.stream
.launch_builder(&self.adam_update_kernel)
.arg(&params_ptr)
.arg(&grad_ptr)
.arg(&m_ptr)
.arg(&v_ptr)
.arg(&norm_ptr)
.arg(&lr_ptr)
.arg(&beta1)
.arg(&beta2)
.arg(&epsilon)
.arg(&weight_decay)
.arg(&adaptive_clip_ptr) // device buffer — EMA-based adaptive clip
.arg(&t_ptr) // device pointer — not baked scalar
.arg(&count)
.arg(&wd_mask_base) // G1: per-param weight decay mask
.arg(&l1_end) // G8: L1 boundary (end of w_s1)
.arg(&l1_lambda) // G8: L1 penalty strength
.arg(&weight_clamp_max_abs) // SP3 Mech 9: post-Adam |p_val| clamp
.arg(&nan_flags_ptr) // SP4 A14: sticky engage flag store
.arg(&diag_slot) // SP4 A14: diag slot (50 for dqn_adam)
.arg(&engage_buf_ptr) // SP4 A14: per-block engagement buf
.arg(&engage_buf_offset) // SP4 A14: group offset (0 = DqnTrunk)
.launch(cfg)
.map_err(|e| {
MLError::ModelError(format!("dqn_adam_update_kernel launch: {e}"))
})?;
for (group, byte_off, count) in groups {
if count == 0 {
continue;
}
// Per-group ISV-driven post-Adam weight clamp + AdamW
// weight_decay rate. EPS_CLAMP_FLOOR=1.0 ε on the bound itself
// (SP1 cold-start pearl); `.max(0.0)` on weight_decay enforces
// the non-negativity contract on the AdamW rate.
let weight_clamp_max_abs: f32 =
self.read_isv_signal_at(weight_bound(group.idx())).max(EPS_CLAMP_FLOOR);
let weight_decay_value: f32 =
self.read_isv_signal_at(wd_rate(group.idx())).max(0.0);
// L1 proximal step is configured for the trunk only (G8 boundary
// at end of `w_s1`). Value/branches pass `l1_end=0 + l1_lambda=0`
// which deactivates the in-kernel L1 path.
let (l1_end_arg, l1_lambda_arg): (i32, f32) = if group == SP4Group::DqnTrunk {
let l1_lambda = self.read_isv_signal_at(L1_LAMBDA_TRUNK_INDEX).max(0.0);
(trunk_l1_end, l1_lambda)
} else {
(0_i32, 0.0_f32)
};
// Sub-buffer pointers via byte-offset — same arithmetic as
// `param_group_buffers`.
let params_ptr = self.ptrs.params_ptr + byte_off;
let grad_ptr = self.ptrs.grad_buf + byte_off;
let m_ptr = self.m_buf.raw_ptr() + byte_off;
let v_ptr = self.v_buf.raw_ptr() + byte_off;
// wd_mask is a per-param byte-aligned buffer matching params layout.
let wd_mask_ptr = wd_mask_base + byte_off;
let count_i32 = count as i32;
let blocks = ((count + 255) / 256) as u32;
debug_assert!(
(blocks as usize) <= MAX_BLOCKS_PER_ADAM,
"dqn_adam[{:?}] blocks={} exceeds MAX_BLOCKS_PER_ADAM={} — Pearl C \
clamp_engage_per_block_buf cannot accommodate; resize buffer \
or chunk the launch.",
group, blocks, MAX_BLOCKS_PER_ADAM
);
let cfg = LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
};
let engage_buf_offset: i32 = (group.idx() * MAX_BLOCKS_PER_ADAM) as i32;
unsafe {
self.stream
.launch_builder(&self.adam_update_kernel)
.arg(&params_ptr)
.arg(&grad_ptr)
.arg(&m_ptr)
.arg(&v_ptr)
.arg(&norm_ptr)
.arg(&lr_ptr)
.arg(&beta1)
.arg(&beta2)
.arg(&epsilon)
.arg(&weight_decay_value) // SP4 Layer B: per-group ISV-driven
.arg(&adaptive_clip_ptr) // device buffer — EMA-based adaptive clip
.arg(&t_ptr) // device pointer — not baked scalar
.arg(&count_i32)
.arg(&wd_mask_ptr) // G1: per-param weight decay mask (sub-slice)
.arg(&l1_end_arg) // G8: L1 boundary (trunk only)
.arg(&l1_lambda_arg) // G8: SP4 Layer B: ISV-driven for trunk; 0 elsewhere
.arg(&weight_clamp_max_abs) // SP4 Layer B: per-group ISV-driven
.arg(&nan_flags_ptr) // SP4 A14: sticky engage flag store
.arg(&diag_slot) // SP4 A14: diag slot (50 for dqn_adam)
.arg(&engage_buf_ptr) // SP4 A14: per-block engagement buf
.arg(&engage_buf_offset) // SP4 Layer B: per-group offset (resolves A14/A15 deferral)
.launch(cfg)
.map_err(|e| {
MLError::ModelError(format!("dqn_adam_update_kernel[{:?}] launch: {e}", group))
})?;
}
}
Ok(())
@@ -21198,10 +21274,14 @@ impl GpuDqnTrainer {
/// engagement rate. All other groups read the single contiguous
/// `[group_idx × MAX_BLOCKS_PER_ADAM, +MAX_BLOCKS_PER_ADAM)` range.
///
/// **Layer A limitation #3** (groups 1/2 accounting): the main DQN
/// Adam covers param groups 0 (Trunk), 1 (Value), and 2 (Branches)
/// in a single fused launch — engagement is recorded under group 0
/// only. Layer B will split per-group sub-launches.
/// **Layer B (2026-05-01) — split sub-launches landed.** The main
/// DQN Adam now runs as 3 sub-launches (Trunk/Value/Branches), each
/// writing per-block engagement counts at its own group offset.
/// `pearl_c_post_adam_engagement_check(group, count)` reads the
/// dedicated range for any of the 8 SP4 groups directly. The Layer A
/// "groups 1/2 accounted under group 0" limitation is resolved by
/// the same Layer B atomic commit that introduces the
/// `WEIGHT_BOUND[group]` consumer migration.
pub(crate) fn pearl_c_post_adam_engagement_check(
&mut self,
group: ParamGroup,
@@ -22176,9 +22256,19 @@ impl GpuDqnTrainer {
// SP3 closes the cross-fold Adam pathology with Mech 9 (post-Adam
// weight clamp at 100 × Q_ABS_REF.max(1.0)) rather than further
// tuning the clip-anchor side of the chain.
let abs_mult = self.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let prev_slow_ema = unsafe { *self.grad_norm_slow_ema_pinned };
let upper_bound = (prev_slow_ema * 100.0_f32 * abs_mult).max(MIN_CLIP);
//
// SP4 Layer B (Mech 6): adaptive clip upper bound = ISV[GRAD_CLIP_BOUND=168].
// Producer `launch_sp4_grad_norm_p99` (Layer A Task A8) writes the
// Pearl Dsmoothed p99(grad_norm) into ISV[168] directly; the
// hardcoded `100 × prev_slow_ema × Q_ABS_REF.max(1.0)` bound from
// SP3 is replaced with this signal-driven upper-bound. The
// EPS_CLAMP_FLOOR ε on the bound itself preserves cold-start
// semantics (pre-first-observation ISV reads as 0). Note:
// `grad_norm_slow_ema_pinned` is no longer read by this consumer
// — its retirement is deferred to Layer C per the audit doc.
use crate::cuda_pipeline::sp4_isv_slots::GRAD_CLIP_BOUND_INDEX;
use crate::cuda_pipeline::sp4_wiener_ema::EPS_CLAMP_FLOOR;
let upper_bound = self.read_isv_signal_at(GRAD_CLIP_BOUND_INDEX).max(EPS_CLAMP_FLOOR);
let new_clip = new_clip.min(upper_bound);
// Write directly to pinned memory — GPU sees it on next kernel read
@@ -22773,9 +22863,9 @@ impl GpuDqnTrainer {
let wd_mask_ptr = self.weight_decay_mask.raw_ptr();
let l1_end_aux: i32 = 0;
let l1_lambda_aux: f32 = 0.0;
// SP3 Mech 9: ISV-driven post-Adam weight clamp (see `launch_adam_update`).
let q_abs_ref_eff = self.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff;
// SP4 Layer B: aux trainer (sel/recursive_conf) is outside the SP4
// 8-group taxonomy — disable Mech 9 weight clamp here (mirrors DT pattern).
let weight_clamp_max_abs: f32 = 0.0_f32;
// SP4 Task A14: aux trainer (sel/recursive_conf) is outside the SP4
// 8-group taxonomy — Pearl C engagement counter disabled.
let _aux_nan_flags_null: u64 = 0;

View File

@@ -96,8 +96,9 @@ pub struct GpuIqlConfig {
pub beta2: f32,
/// Adam epsilon (numerical stability).
pub epsilon: f32,
/// Decoupled weight decay (AdamW).
pub weight_decay: f32,
// SP4 Layer B: `weight_decay` field removed; per-step AdamW rate is
// sourced from ISV[WD_RATE[group]] (group = IqlHigh=4 or IqlLow=5)
// and threaded into `train_value_step` as `weight_decay_value`.
/// Maximum gradient L2 norm for clipping.
pub max_grad_norm: f32,
/// Number of C51 atoms.
@@ -122,7 +123,7 @@ impl Default for GpuIqlConfig {
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
weight_decay: 1e-5,
// SP4 Layer B: weight_decay field removed; sourced from ISV at runtime.
max_grad_norm: 1.0,
num_atoms: 51,
total_actions: 108,
@@ -580,10 +581,13 @@ impl GpuIqlTrainer {
pub fn train_value_step(
&mut self,
states_f32: &CudaSlice<f32>,
/* SP3 Mech 9 (close-out v2): ISV-driven |p| bound for `iql_adam_kernel`
* — caller computes `100 × ISV[Q_ABS_REF].max(1.0)` host-side.
* 0=disabled (debug only). */
/* SP4 Layer B (Mech 9): ISV-driven |p| bound for `iql_adam_kernel`
* — caller reads `ISV[WEIGHT_BOUND[group]]` (group = IqlHigh=4 or
* IqlLow=5) with EPS_CLAMP_FLOOR ε. 0=disabled (debug only). */
weight_clamp_max_abs: f32,
/* SP4 Layer B (Mech 9): ISV-driven AdamW weight_decay rate.
* Caller reads `ISV[WD_RATE[group]]` with `.max(0.0)` non-negativity. */
weight_decay_value: f32,
) -> Result<(), MLError> {
let b = self.config.batch_size;
let h = self.config.value_hidden_dim;
@@ -1035,7 +1039,11 @@ impl GpuIqlTrainer {
let beta1 = self.config.beta1;
let beta2 = self.config.beta2;
let eps = self.config.epsilon;
let wd = self.config.weight_decay;
// SP4 Layer B (Mech 9): AdamW weight_decay sourced from
// ISV[WD_RATE[IqlHigh|IqlLow]] by the caller (`FusedTrainingCtx`)
// and threaded through `train_value_step`. `.max(0.0)` upstream
// enforces the AdamW non-negativity contract.
let wd: f32 = weight_decay_value;
let mgn = self.config.max_grad_norm;
let t_ptr = self.t_dev_ptr;

View File

@@ -100,8 +100,9 @@ pub struct GpuIqnConfig {
pub beta2: f32,
/// Adam epsilon (numerical stability).
pub epsilon: f32,
/// Decoupled weight decay (AdamW).
pub weight_decay: f32,
// SP4 Layer B: `weight_decay` field removed; per-step AdamW rate is
// sourced from ISV[WD_RATE[Iqn=3]] and threaded into
// `execute_training_pipeline` as `weight_decay_value`.
/// Maximum gradient L2 norm for clipping.
pub max_grad_norm: f32,
/// Discount factor for Bellman projection.
@@ -128,7 +129,7 @@ impl Default for GpuIqnConfig {
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
weight_decay: 1e-5,
// SP4 Layer B: weight_decay field removed; sourced from ISV at runtime.
max_grad_norm: 1.0,
gamma: 0.99,
}
@@ -816,11 +817,13 @@ impl GpuIqnHead {
dqn_actions_buf: &CudaSlice<i32>,
dqn_rewards_buf: &CudaSlice<f32>,
dqn_dones_buf: &CudaSlice<f32>,
/* SP3 Mech 9 (close-out v2): ISV-derived |p| bound for `iqn_adam_kernel`
* — caller computes `100 × ISV[Q_ABS_REF].max(1.0)` and passes it
* through. 0=disabled (debug only); production callers always set
* a non-zero bound. */
/* SP4 Layer B (Mech 9): ISV-derived |p| bound for `iqn_adam_kernel`
* — caller reads `ISV[WEIGHT_BOUND[Iqn=3]]` and passes it through.
* 0=disabled (debug only); production callers always set a non-zero
* bound. */
weight_clamp_max_abs: f32,
/* SP4 Layer B (Mech 9): ISV-derived AdamW weight_decay rate. */
weight_decay_value: f32,
) -> Result<f32, MLError> {
// 1-2. Decode actions + DtoD copy rewards/dones
self.prepare_buffers(dqn_actions_buf, dqn_rewards_buf, dqn_dones_buf)?;
@@ -833,6 +836,7 @@ impl GpuIqnHead {
None,
None,
weight_clamp_max_abs,
weight_decay_value,
)
}
@@ -868,10 +872,13 @@ impl GpuIqnHead {
target_dueling: &DuelingWeightSet,
override_stream: Option<&Arc<CudaStream>>,
override_workspace: Option<(u64, usize)>,
/* SP3 Mech 9 (close-out v2): ISV-derived |p| bound for the trailing
* `iqn_adam_kernel` arg. Caller computes `100 × ISV[Q_ABS_REF].max(1.0)`
* host-side. 0=disabled (debug only). */
/* SP4 Layer B (Mech 9): ISV-derived |p| bound for the trailing
* `iqn_adam_kernel` arg. Caller reads `ISV[WEIGHT_BOUND[Iqn=3]]`
* with EPS_CLAMP_FLOOR ε. 0=disabled (debug only). */
weight_clamp_max_abs: f32,
/* SP4 Layer B (Mech 9): ISV-derived AdamW weight_decay rate.
* Caller reads `ISV[WD_RATE[Iqn=3]]` with `.max(0.0)` non-negativity. */
weight_decay_value: f32,
) -> Result<f32, MLError> {
let effective_stream = override_stream.unwrap_or(&self.stream);
let (lt_ws_ptr, lt_ws_size) = override_workspace
@@ -1495,7 +1502,12 @@ impl GpuIqnHead {
let beta1 = self.config.beta1;
let beta2 = self.config.beta2;
let eps = self.config.epsilon;
let wd = self.config.weight_decay;
// SP4 Layer B (Mech 9): AdamW weight_decay sourced from
// ISV[WD_RATE[Iqn=3]] by the caller (`FusedTrainingCtx`) and
// threaded through `execute_training_pipeline` alongside
// `weight_clamp_max_abs`. `.max(0.0)` upstream enforces the
// AdamW non-negativity contract.
let wd = weight_decay_value;
let mgn = self.config.max_grad_norm;
let t_ptr = self.t_dev_ptr;

View File

@@ -647,15 +647,18 @@ impl GpuTlob {
/// Adam step: grad norm clip + Adam update on TLOB params.
///
/// SP3 Mech 9 (close-out v2): `weight_clamp_max_abs` is the ISV-driven
/// |p| bound for `attn_adam_kernel` (TLOB shares this kernel via the
/// attention cubin). Caller computes `100 × ISV[Q_ABS_REF].max(1.0)`.
/// 0=disabled (debug only).
/// SP4 Layer B (Mech 9): `weight_clamp_max_abs` is the ISV-driven |p|
/// bound for `attn_adam_kernel` (TLOB shares this kernel via the
/// attention cubin). Caller reads `ISV[WEIGHT_BOUND[Attn=6]]` (TLOB
/// co-lives with attention in the SP4 group taxonomy). 0=disabled
/// (debug only). `weight_decay_value` is sourced from
/// `ISV[WD_RATE[Attn=6]]` with `.max(0.0)`.
pub(crate) fn adam_step(
&mut self,
lr: f32,
max_grad_norm: f32,
weight_clamp_max_abs: f32,
weight_decay_value: f32,
) -> Result<(), MLError> {
let tp = TLOB_TOTAL_PARAMS;
let tp_i32 = tp as i32;
@@ -695,7 +698,11 @@ impl GpuTlob {
let beta1 = 0.9_f32;
let beta2 = 0.999_f32;
let eps = 1e-8_f32;
let wd = 1e-5_f32;
// SP4 Layer B (Mech 9): AdamW weight_decay sourced from
// ISV[WD_RATE[Attn=6]] by the caller and threaded through
// `adam_step`. `.max(0.0)` upstream enforces the AdamW
// non-negativity contract.
let wd: f32 = weight_decay_value;
let t_ptr = self.t_dev_ptr;
// SP4 Task A14: Pearl C engagement-counter args. TLOB shares
// the `attn_adam_kernel` cubin with `GpuAttention`. For Layer A

View File

@@ -29,6 +29,13 @@
#define VALUE_HIDDEN_DIM 128
#endif
/* SP4 Layer B: per-branch atom-position bound from ISV[ATOM_POS_BOUND_BASE + branch].
* Mirrors `crates/ml/src/cuda_pipeline/sp4_isv_slots.rs::ATOM_POS_BOUND_BASE = 132`.
*/
#ifndef ATOM_POS_BOUND_BASE
#define ATOM_POS_BOUND_BASE 132
#endif
/* Parameter sizes that don't depend on state_dim */
#define V_B1_SIZE (VALUE_HIDDEN_DIM)
#define V_W2_SIZE (VALUE_HIDDEN_DIM * VALUE_HIDDEN_DIM)
@@ -693,17 +700,14 @@ void iql_compute_per_sample_support(
float v_max = r * iql_vmax + (1.0f - r) * ( 1.0f);
float delta_z = r * iql_dz + (1.0f - r) * default_dz;
/* SP3 Mech 2: bound atom positions to ±10 × ISV[Q_ABS_REF=16].max(1.0).
* Per-sample support [v_min, v_max] spans the C51 atom grid for this
* (sample, branch); capping at 10× allows full distribution coverage
* without extreme-tail growth. Same ISV bound as Mech 1 (target_q clip)
* — atoms and target_q share the magnitude scale. ε on the multiplier
* (`isv.max(1.0)`) per the SP1 ε-floor pearl; bound ≥ 10 even with
* cold-start ISV. delta_z is recomputed from the clamped span so the
/* SP4 Layer B (Mech 2): bound per-sample support [v_min, v_max] to
* ±ISV[ATOM_POS_BOUND[d]]. Per-branch p99(|atom_positions|) populated
* by SP4 producer `launch_sp4_atom_pos_p99` (Layer A Task A6).
* ε floor on the bound itself (`max(.., 1.0)`) per the SP1 cold-start
* pearl. delta_z is recomputed from the clamped span so the
* (v_min, v_max, delta_z) triple stays self-consistent. */
if (isv_signals != nullptr) {
const float q_abs_ref_eff = fmaxf(isv_signals[16], 1.0f);
const float max_atom_abs = 10.0f * q_abs_ref_eff;
const float max_atom_abs = fmaxf(isv_signals[ATOM_POS_BOUND_BASE + d], 1.0f);
v_min = fminf(fmaxf(v_min, -max_atom_abs), max_atom_abs);
v_max = fminf(fmaxf(v_max, -max_atom_abs), max_atom_abs);
delta_z = (v_max - v_min) / (float)(num_atoms - 1);

View File

@@ -869,12 +869,13 @@ pub struct DQNHyperparameters {
/// Steps for sigma annealing (default: 10000)
pub noisy_sigma_anneal_steps: usize,
// WAVE 30: L2 Weight Decay for Overfitting Prevention
/// L2 weight decay for AdamW optimizer (default: 1e-4, range: [1e-5, 1e-3]).
/// Prevents overfitting by penalizing large weights via L2 regularization.
/// Recommended: 1e-4 for standard training, 1e-3 for aggressive regularization.
/// f64: tiny values (1e-5..1e-3) benefit from f64 precision during decay math.
pub weight_decay: f64,
// SP4 Layer B (2026-05-01): `weight_decay` field removed from
// hyperparams. Per-group AdamW weight_decay rates are now sourced
// from the SP4 ISV bus at `ISV[WD_RATE_BASE..+SP4_PARAM_GROUP_COUNT)`
// (slot indices 160..168) — see
// `crates/ml/src/cuda_pipeline/sp4_isv_slots.rs::wd_rate(group)`.
// Per-step rate adapts via the `param_group_oracle` Pearl B kernel
// (Layer A Task A7) and Pearls A+D smoothing (Task A12).
/// Adam epsilon — BF16 requires ≥1e-4 (1e-8 rounds to 0 in f32 → div-by-zero).
/// f64: tiny values (1e-8..1e-4) preserve denorm behaviour.
@@ -1209,11 +1210,13 @@ impl DQNHyperparameters {
pub fn apply_family_scaling(&mut self) {
// ── Core families ──
// Learning family: scales learning_rate, tau, weight_decay
// Learning family: scales learning_rate, tau.
// (SP4 Layer B: `weight_decay` family-scaling removed — per-group
// weight_decay rates are now ISV-driven; PSO learning-intensity no
// longer perturbs the static config field that no longer exists.)
let li = self.learning_intensity;
self.learning_rate *= li;
self.tau *= li as f32;
self.weight_decay *= li;
// Exploration family: scales entropy_coefficient, noisy_sigma_init
let exi = self.exploration_intensity;
@@ -1519,8 +1522,8 @@ impl DQNHyperparameters {
noisy_sigma_final: 0.4, // Default: 40% final noise
noisy_sigma_anneal_steps: 10000, // Default: 10K steps for annealing
// WAVE 30: L2 Weight Decay
weight_decay: 1e-4, // Default: 0.0001 (standard regularization strength)
// SP4 Layer B: `weight_decay` field removed; per-group rates
// are sourced from ISV[WD_RATE[group]] at runtime.
adam_epsilon: 1e-8, // f32 Adam (master weights are f32)
// Conservative Q-Learning (CQL)
@@ -1749,8 +1752,9 @@ pub(crate) fn dqn_default_config() -> DQNConfig {
cvar_alpha: 0.05,
minimum_profit_factor: 1.5,
weight_decay: 1e-4,
adam_epsilon: 1e-8, // f32 Adam (master weights are f32)
// SP4 Layer B: `weight_decay` field removed; per-group rates are
// sourced from ISV[WD_RATE[group]] at runtime.
adam_epsilon: 1e-8, // f32 Adam (master weights are f32)
..Default::default()
}
}

View File

@@ -37,8 +37,7 @@ use crate::cuda_pipeline::mapped_pinned::{MappedF32Buffer, MappedI32Buffer};
use crate::cuda_pipeline::gpu_attention::{GpuAttention, GpuAttentionConfig};
use crate::cuda_pipeline::gpu_tlob::GpuTlob;
use crate::cuda_pipeline::gpu_dqn_trainer::{
GpuDqnTrainConfig, GpuDqnTrainer, Q_ABS_REF_INDEX, TAU_EFF_INDEX,
TLOB_REGIME_FOCUS_EMA_INDEX,
GpuDqnTrainConfig, GpuDqnTrainer, TAU_EFF_INDEX,
};
use crate::cuda_pipeline::gpu_her::{GpuHer, GpuHerConfig, parse_her_strategy};
use crate::cuda_pipeline::gpu_iql_trainer::{GpuIqlConfig, GpuIqlTrainer};
@@ -458,7 +457,8 @@ impl FusedTrainingCtx {
beta1: 0.9,
beta2: 0.999,
epsilon: hyperparams.adam_epsilon as f32,
weight_decay: hyperparams.weight_decay as f32,
// SP4 Layer B: `weight_decay` field removed from GpuDqnTrainConfig
// and DQNHyperparameters; per-group rates flow from ISV at runtime.
max_grad_norm: resolved_grad_norm as f32,
spectral_norm_sigma_max: hyperparams.spectral_norm_sigma_max,
spectral_decoupling_lambda: hyperparams.spectral_decoupling_lambda,
@@ -1615,15 +1615,26 @@ impl FusedTrainingCtx {
self.gpu_tlob
.backward(d_concat_ref, self.batch_size, concat_dim, tlob_concat_off)
.map_err(|e| anyhow::anyhow!("TLOB backward: {e}"))?;
// SP3 Mech 9 (close-out v2): TLOB shares `attn_adam_kernel`
// (cubin-shared with GpuAttention). Same ISV-derived bound.
let q_abs_ref_eff_tlob = self.trainer.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let tlob_weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff_tlob;
// SP4 Layer B (Mech 9): TLOB shares `attn_adam_kernel`
// (cubin-shared with GpuAttention). TLOB params co-live with
// attention in the Attn (group 6) param-group taxonomy, so
// the same ISV-driven WEIGHT_BOUND[Attn] / WD_RATE[Attn]
// contract applies. ε floor (EPS_CLAMP_FLOOR = 1.0) handles
// cold-start; `.max(0.0)` enforces wd non-negativity.
let tlob_weight_clamp_max_abs: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::weight_bound(
crate::cuda_pipeline::ParamGroup::Attn.idx()))
.max(crate::cuda_pipeline::EPS_CLAMP_FLOOR);
let tlob_weight_decay_value: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::wd_rate(
crate::cuda_pipeline::ParamGroup::Attn.idx()))
.max(0.0);
self.gpu_tlob
.adam_step(
self.trainer.config().lr,
self.trainer.config().max_grad_norm,
tlob_weight_clamp_max_abs,
tlob_weight_decay_value,
)
.map_err(|e| anyhow::anyhow!("TLOB Adam: {e}"))?;
}
@@ -1687,18 +1698,17 @@ impl FusedTrainingCtx {
// ── Outside graph: kernel-free host ops only ─────────────────
self.pending_vaccine_batch = None;
// SP4 Task A15: Pearl C post-Adam engagement-rate check (Layer A).
// SP4 Task A15: Pearl C post-Adam engagement-rate check.
// Reads per-block engagement counts from the mapped-pinned buffer
// (zero-copy host access), reduces → engagement_rate, applies
// Pearls A+D to the rate-deficit time series. Layer A logs only;
// Layer B will mutate ISV[WEIGHT_BOUND[group]] on sustained
// over-engagement.
// Pearls A+D to the rate-deficit time series.
//
// The 5 main Adam launches captured in this step's graph-replay
// each contribute to a distinct param-group offset:
// group 0 (DqnTrunk) — covers DQN Trunk/Value/Branches
// (Layer A simplification: counted under
// group 0 only — Layer B will split).
// SP4 Layer B (2026-05-01): the main DQN Adam now splits into 3
// sub-launches (Trunk/Value/Branches) — each writes per-block
// counts at its own group offset, so Pearl C is now per-group:
// group 0 (DqnTrunk) — DQN trunk Adam sub-launch.
// group 1 (DqnValue) — DQN value-head Adam sub-launch.
// group 2 (DqnBranches) — DQN branch-head Adam sub-launch.
// group 3 (Iqn) — IQN dual-head trainer.
// group 4 (IqlHigh) — IQL high-tau trainer.
// group 5 (IqlLow) — IQL low-tau trainer.
@@ -1706,13 +1716,25 @@ impl FusedTrainingCtx {
// but disables Pearl C).
// group 7 (Curiosity) — handled in `train_curiosity_gpu`,
// not here (separate caller path).
let total_params_dqn = self.trainer.total_params();
let (trunk_n, value_n, branches_n) = self.trainer.dqn_group_param_counts();
if let Err(e) = self.trainer.pearl_c_post_adam_engagement_check(
crate::cuda_pipeline::ParamGroup::DqnTrunk,
total_params_dqn,
trunk_n,
) {
tracing::warn!("SP4 Pearl C check (DQN trunk) failed (non-fatal): {e}");
}
if let Err(e) = self.trainer.pearl_c_post_adam_engagement_check(
crate::cuda_pipeline::ParamGroup::DqnValue,
value_n,
) {
tracing::warn!("SP4 Pearl C check (DQN value) failed (non-fatal): {e}");
}
if let Err(e) = self.trainer.pearl_c_post_adam_engagement_check(
crate::cuda_pipeline::ParamGroup::DqnBranches,
branches_n,
) {
tracing::warn!("SP4 Pearl C check (DQN branches) failed (non-fatal): {e}");
}
if let Some(ref iqn) = self.gpu_iqn {
let n = iqn.total_params();
if let Err(e) = self.trainer.pearl_c_post_adam_engagement_check(
@@ -1900,18 +1922,33 @@ impl FusedTrainingCtx {
// 3. Train V(s) — both tau=high and tau=low.
//
// SP3 Mech 9 (close-out v2): ISV-driven post-Adam |p| clamp for
// `iql_adam_kernel`. Same `100 × Q_ABS_REF.max(1.0)` bound as
// the main DQN Adam — IQL's separate value-net params drift via
// its own Adam state and were never reached by the single-kernel
// Mech 9 in commit 8956c2fb7. ε on multiplier per SP1 pearl.
let q_abs_ref_eff = self.trainer.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff;
// SP4 Layer B (Mech 9): ISV-driven post-Adam |p| clamp for
// `iql_adam_kernel`. Per-group bounds: IqlHigh (group 4) and
// IqlLow (group 5) each read `ISV[WEIGHT_BOUND[group]]`. ε
// floor (EPS_CLAMP_FLOOR = 1.0) handles cold-start ISV. AdamW
// weight_decay sourced from `ISV[WD_RATE[group]]` with
// `.max(0.0)` non-negativity contract.
let iql_high_clamp: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::weight_bound(
crate::cuda_pipeline::ParamGroup::IqlHigh.idx()))
.max(crate::cuda_pipeline::EPS_CLAMP_FLOOR);
let iql_high_wd: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::wd_rate(
crate::cuda_pipeline::ParamGroup::IqlHigh.idx()))
.max(0.0);
let iql_low_clamp: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::weight_bound(
crate::cuda_pipeline::ParamGroup::IqlLow.idx()))
.max(crate::cuda_pipeline::EPS_CLAMP_FLOOR);
let iql_low_wd: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::wd_rate(
crate::cuda_pipeline::ParamGroup::IqlLow.idx()))
.max(0.0);
let states_f32 = self.trainer.states_buf();
self.gpu_iql.train_value_step(states_f32, weight_clamp_max_abs)
self.gpu_iql.train_value_step(states_f32, iql_high_clamp, iql_high_wd)
.map_err(|e| anyhow::anyhow!("IQL high train: {e}"))?;
let states_f32 = self.trainer.states_buf();
self.gpu_iql_low.train_value_step(states_f32, weight_clamp_max_abs)
self.gpu_iql_low.train_value_step(states_f32, iql_low_clamp, iql_low_wd)
.map_err(|e| anyhow::anyhow!("IQL low train: {e}"))?;
// 4. Compute advantage weights (from high-tau V(s)).
@@ -2003,19 +2040,26 @@ impl FusedTrainingCtx {
let online_h_s2 = self.trainer.save_h_s2();
let next_states_buf = self.trainer.next_states_buf();
// SP3 Mech 9 (close-out v2): ISV-driven post-Adam |p| clamp
// for `iqn_adam_kernel`. Same `100 × Q_ABS_REF.max(1.0)`
// bound as the main DQN Mech 9 launch (`launch_adam_update`)
// — IQN's separate online_params drives the slot-26 GEMM and
// was never reached by the single-kernel Mech 9 in commit
// 8956c2fb7. ε on multiplier per SP1 pearl.
let q_abs_ref_eff = self.trainer.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff;
// SP4 Layer B (Mech 9): ISV-driven post-Adam |p| clamp for
// `iqn_adam_kernel`. IQN params live in group 3
// (`ParamGroup::Iqn`); read `ISV[WEIGHT_BOUND[Iqn]]` with
// EPS_CLAMP_FLOOR = 1.0 ε for cold-start. AdamW
// weight_decay sourced from `ISV[WD_RATE[Iqn]]` with
// `.max(0.0)` non-negativity contract.
let weight_clamp_max_abs: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::weight_bound(
crate::cuda_pipeline::ParamGroup::Iqn.idx()))
.max(crate::cuda_pipeline::EPS_CLAMP_FLOOR);
let weight_decay_value: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::wd_rate(
crate::cuda_pipeline::ParamGroup::Iqn.idx()))
.max(0.0);
match iqn.execute_training_pipeline(
online_h_s2, next_states_buf, &self.target_dueling,
Some(&iqn_stream_ref), ws_override,
weight_clamp_max_abs,
weight_decay_value,
) {
Ok(_) => { iqn_ok = true; }
Err(e) => {
@@ -2084,11 +2128,21 @@ impl FusedTrainingCtx {
// Adam on attn_stream.
let lr = self.trainer.config().lr;
let mgn = self.trainer.config().max_grad_norm;
// SP3 Mech 9 (close-out v2): ISV-driven post-Adam |p| clamp
// for `attn_adam_kernel`. Same pattern as IQN/IQL/main DQN.
let q_abs_ref_eff_attn = self.trainer.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let attn_weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff_attn;
attn.adam_step(lr, mgn, Some(&attn_stream_ref), ws_override, attn_weight_clamp_max_abs)
// SP4 Layer B (Mech 9): ISV-driven post-Adam |p| clamp for
// `attn_adam_kernel`. Attention params live in group 6
// (`ParamGroup::Attn`); read `ISV[WEIGHT_BOUND[Attn]]` with
// EPS_CLAMP_FLOOR = 1.0 ε for cold-start. AdamW weight_decay
// sourced from `ISV[WD_RATE[Attn]]` with `.max(0.0)`.
let attn_weight_clamp_max_abs: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::weight_bound(
crate::cuda_pipeline::ParamGroup::Attn.idx()))
.max(crate::cuda_pipeline::EPS_CLAMP_FLOOR);
let attn_weight_decay_value: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::wd_rate(
crate::cuda_pipeline::ParamGroup::Attn.idx()))
.max(0.0);
attn.adam_step(lr, mgn, Some(&attn_stream_ref), ws_override,
attn_weight_clamp_max_abs, attn_weight_decay_value)
.map_err(|e| anyhow::anyhow!("Attention Adam: {e}"))?;
// Record completion on attn_stream.
@@ -2121,11 +2175,18 @@ impl FusedTrainingCtx {
.map_err(|e| anyhow::anyhow!("Attention backward: {e}"))?;
let lr = self.trainer.config().lr;
let mgn = self.trainer.config().max_grad_norm;
// SP3 Mech 9 (close-out v2): same ISV-derived bound as
// parallel arm. Sequential-fallback path.
let q_abs_ref_eff_attn = self.trainer.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let attn_weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff_attn;
attn.adam_step(lr, mgn, None, None, attn_weight_clamp_max_abs)
// SP4 Layer B (Mech 9): same ISV-driven bound as parallel arm.
// Sequential-fallback path.
let attn_weight_clamp_max_abs: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::weight_bound(
crate::cuda_pipeline::ParamGroup::Attn.idx()))
.max(crate::cuda_pipeline::EPS_CLAMP_FLOOR);
let attn_weight_decay_value: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::wd_rate(
crate::cuda_pipeline::ParamGroup::Attn.idx()))
.max(0.0);
attn.adam_step(lr, mgn, None, None,
attn_weight_clamp_max_abs, attn_weight_decay_value)
.map_err(|e| anyhow::anyhow!("Attention Adam: {e}"))?;
}
@@ -2134,15 +2195,22 @@ impl FusedTrainingCtx {
let online_h_s2 = self.trainer.save_h_s2();
let next_states_buf = self.trainer.next_states_buf();
// SP3 Mech 9 (close-out v2): same ISV-derived bound as the
// SP4 Layer B (Mech 9): same ISV-driven bound as the
// parallel arm above. Mirror the dqn_adam pattern.
let q_abs_ref_eff = self.trainer.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let weight_clamp_max_abs: f32 = 100.0_f32 * q_abs_ref_eff;
let weight_clamp_max_abs: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::weight_bound(
crate::cuda_pipeline::ParamGroup::Iqn.idx()))
.max(crate::cuda_pipeline::EPS_CLAMP_FLOOR);
let weight_decay_value: f32 = self.trainer
.read_isv_signal_at(crate::cuda_pipeline::wd_rate(
crate::cuda_pipeline::ParamGroup::Iqn.idx()))
.max(0.0);
match iqn.execute_training_pipeline(
online_h_s2, next_states_buf, &self.target_dueling,
None, None,
weight_clamp_max_abs,
weight_decay_value,
) {
Ok(_) => { iqn_ok = true; }
Err(e) => {

View File

@@ -37,7 +37,11 @@ fn test_generalization_components_smoke() -> anyhow::Result<()> {
params.epochs = 3;
params.batch_size = 4096; // Larger batch to catch OOB that only trigger at scale
params.buffer_size = 8192;
params.weight_decay = 1e-4; // G1: AdamW active
// SP4 Layer B: `weight_decay` removed from hyperparams. Per-group AdamW
// rates flow from ISV[WD_RATE[group]] at runtime; smoke tests inherit
// the bootstrap value (Pearl A sentinel = 0 → zero-decay until the
// first real observation, identical to the historical "AdamW active"
// contract once the EMA spins up).
let mut trainer = smoke_trainer_with(params)?;
let rt = tokio::runtime::Builder::new_current_thread()

View File

@@ -298,7 +298,10 @@ impl DQNTrainer {
#[allow(clippy::cast_possible_truncation)]
minimum_profit_factor: hyperparams.minimum_profit_factor,
weight_decay: hyperparams.weight_decay,
// SP4 Layer B: weight_decay no longer flows from hyperparams;
// ml-dqn `DQNConfig.weight_decay` retains its own default
// (currently unread by any consumer post-migration; cleanup
// deferred to a separate ml-dqn refactor pass).
dropout_rate: f64::from(hyperparams.dropout_initial),
entropy_coefficient: hyperparams.entropy_coefficient,
noisy_epsilon_floor: hyperparams.noisy_epsilon_floor.unwrap_or(0.0), // C2: NoisyNet handles exploration

View File

@@ -1771,14 +1771,16 @@ impl DQNTrainer {
}
if count > 0 {
// SP3 Mech 9 (close-out v2): ISV-driven post-Adam |p| clamp for
// `curiosity_adam_step`. Same `100 × Q_ABS_REF.max(1.0)` bound as
// the main DQN / IQN / IQL / attention Adam launches. Curiosity
// SP4 Layer B (Mech 9): ISV-driven post-Adam |p| clamp for
// `curiosity_adam_step`. Curiosity params live in group 7
// (`ParamGroup::Curiosity`); read `ISV[WEIGHT_BOUND[Curiosity]]`
// with EPS_CLAMP_FLOOR = 1.0 ε for cold-start. Curiosity
// collector doesn't own ISV — read from fctx here and thread the
// bound through. ε on multiplier per SP1 pearl.
// bound through to the 4 sub-launches (W1/b1/W2/b2).
let weight_clamp_max_abs: f32 = if let Some(ref fused) = self.fused_ctx {
let q_abs_ref_eff = fused.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
100.0_f32 * q_abs_ref_eff
fused.read_isv_signal_at(crate::cuda_pipeline::weight_bound(
crate::cuda_pipeline::ParamGroup::Curiosity.idx()))
.max(crate::cuda_pipeline::EPS_CLAMP_FLOOR)
} else {
// No fctx → no ISV → disable Mech 9. Reachable only on the
// CPU-only build path (CUDA build asserts fctx Some at

View File

@@ -59,7 +59,9 @@ pub struct TrainingSection {
pub tau: Option<f64>,
pub warmup_steps: Option<usize>,
pub gradient_clip_norm: Option<f64>,
pub weight_decay: Option<f64>,
// SP4 Layer B: `weight_decay` removed from trial overrides. Per-group
// AdamW weight_decay rates are now sourced from ISV[WD_RATE[group]];
// PSO no longer searches over a static config field.
pub hidden_dim_base: Option<usize>,
/// Reward scale factor for v_range computation.
/// v_range = (reward_scale / (1 - gamma) * 1.2).clamp(20.0, 300.0)
@@ -389,7 +391,8 @@ pub struct SearchSpaceSection {
pub dueling_hidden_dim: Option<[f64; 2]>,
pub n_steps: Option<[f64; 2]>,
pub num_atoms: Option<[f64; 2]>,
pub weight_decay: Option<[f64; 2]>,
// SP4 Layer B: `weight_decay` removed from PSO search bounds. Per-group
// weight_decay rates are ISV-driven at runtime — no static knob.
pub kelly_fractional: Option<[f64; 2]>,
pub kelly_max_fraction: Option<[f64; 2]>,
pub volatility_window: Option<[f64; 2]>,
@@ -558,7 +561,7 @@ impl HyperoptProfile {
"dueling_hidden_dim" => ss.dueling_hidden_dim,
"n_steps" => ss.n_steps,
"num_atoms" => ss.num_atoms,
"weight_decay" => ss.weight_decay,
// SP4 Layer B: `weight_decay` removed from PSO search-space.
"kelly_fractional" => ss.kelly_fractional,
"kelly_max_fraction" => ss.kelly_max_fraction,
"volatility_window" => ss.volatility_window,
@@ -746,9 +749,8 @@ impl DqnTrainingProfile {
if let Some(v) = t.gradient_clip_norm {
hp.gradient_clip_norm = Some(v);
}
if let Some(v) = t.weight_decay {
hp.weight_decay = v;
}
// SP4 Layer B: `weight_decay` removed from trial overrides; per-group
// rates flow through ISV[WD_RATE[group]] at runtime.
if let Some(v) = t.hidden_dim_base {
hp.hidden_dim_base = Some(v);
}

View File

@@ -2676,3 +2676,107 @@ Verification: `git grep -nE "_ema_alpha_unused|launch_reward_component_ema\b|lau
asserting kernel output matches host-reference `pearls_ad_update`
within fp32 ULP across 5 representative trajectories + 1 multi-slot
batch). 13 existing tests + 1 new test pass on RTX 3050 Ti.
### Layer B atomic consumer migration (2026-05-01)
**Layer B atomic consumer migration — all SP3 hardcoded multipliers
replaced by ISV reads, DQN main Adam per-group split, Pearl C engagement
deferral resolved.** Single coordinated commit per
`feedback_no_partial_refactor`. The SP3-era multiplier-driven bounds
(`10 × Q_ABS_REF`, `100 × Q_ABS_REF`, `1e3 × Q_ABS_REF`,
`1e6 × Q_ABS_REF²`, `100 × H_S2_RMS_EMA`) and `.max(1.0)` ε floors are
replaced by per-slot ISV reads (`ISV[TARGET_Q_BOUND]`,
`ISV[ATOM_POS_BOUND[branch]]`, `ISV[WEIGHT_BOUND[group]]`,
`ISV[ADAM_M_BOUND[group]]`, `ISV[ADAM_V_BOUND[group]]`,
`ISV[GRAD_CLIP_BOUND]`, `ISV[H_S2_BOUND]`,
`ISV[L1_LAMBDA_TRUNK_INDEX]`, `ISV[WD_RATE[group]]`) with
consumer-side `EPS_CLAMP_FLOOR = 1.0` ε for cold-start.
DQN main Adam launch split into 3 sub-launches (DqnTrunk / DqnValue /
DqnBranches) per `feedback_no_quickfixes` — overrides the plan's
"max/min-of-3 single-launch shortcut" recommendation. Each sub-launch
reads its own `WEIGHT_BOUND[group]`, `WD_RATE[group]`, and (trunk only)
`L1_LAMBDA_TRUNK_INDEX`. Per-group split also resolves the Pearl C
engagement-counter accounting deferral from A14/A15 (groups 1+2 were
silently rolled into group 0's offset; now each writes per-block
counts at its own `group_idx × MAX_BLOCKS_PER_ADAM` offset, and
`pearl_c_post_adam_engagement_check` is invoked per group from
`fused_training.rs`).
Mech 5 fused diagnostic kernel (`dqn_nan_check_fused_f32_kernel`)
takes `isv_signals` device pointer instead of two host-side `*_eff`
floats — per-slot ISV reads inside the kernel eliminate the per-step
HtoD copy and remove `q_abs_ref_eff` / `h_s2_rms_ema_eff` kernel args
entirely. Mapped-pinned ISV pointer is graph-capture-safe.
`weight_decay` field removed from:
- `DQNHyperparameters` (`crates/ml/src/trainers/dqn/config.rs`)
- `GpuDqnTrainConfig` (`crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs`)
- `GpuIqnConfig` (`crates/ml/src/cuda_pipeline/gpu_iqn_head.rs`)
- `GpuIqlConfig` (`crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs`)
- `TrialOverrides` / search-space (`crates/ml/src/training_profile.rs`)
- `DQNHyperparameters::apply_family_scaling`
(`weight_decay *= li` line — no static field to scale anymore)
DT (`decision_transformer.rs`) and aux trainers (ofi_embed, denoise,
sel/recursive_conf in `gpu_dqn_trainer.rs`) keep `weight_clamp_max_abs
= 0.0` disable — they're outside the SP4 8-group taxonomy and have no
ISV producer. Mirrors DT's existing pattern.
Files-touched manifest:
- `crates/ml/src/cuda_pipeline/atoms_update_kernel.cu` (Mech 2 per-branch
ATOM_POS_BOUND read)
- `crates/ml/src/cuda_pipeline/iql_value_kernel.cu` (Mech 2 per-branch
ATOM_POS_BOUND read)
- `crates/ml/src/cuda_pipeline/experience_kernels.cu` (Mech 2 per-branch
ATOM_POS_BOUND read for the legacy single-branch entry point)
- `crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu` (Mech 5 fused
diagnostic per-slot ISV reads + drop `q_abs_ref_eff`/`h_s2_rms_ema_eff`
args; doc updates)
- `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (Mech 1 + 6 + 9 + 10
consumer migrations; `launch_adam_update` 3-way split; aux trainers
disable clamp; `weight_decay` field removed; nan_check_fused launcher
drops `q_abs_ref_eff`/`h_s2_rms_ema_eff`; `dqn_group_param_counts`
accessor for fused_training Pearl C invocation)
- `crates/ml/src/cuda_pipeline/gpu_iqn_head.rs` (`weight_decay` field
removed; `execute_training_pipeline` + `train_iqn_step_gpu` accept
`weight_decay_value: f32`)
- `crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs` (`weight_decay` field
removed; `train_value_step` accepts `weight_decay_value: f32`)
- `crates/ml/src/cuda_pipeline/gpu_attention.rs` (`adam_step` accepts
`weight_decay_value: f32`)
- `crates/ml/src/cuda_pipeline/gpu_tlob.rs` (`adam_step` accepts
`weight_decay_value: f32`; co-lives in Attn group taxonomy)
- `crates/ml/src/trainers/dqn/fused_training.rs` (per-launch ISV reads
for IQN/IQL/Attn/TLOB clamp + weight_decay; `weight_decay` removed
from `GpuDqnTrainConfig` builder; per-group `pearl_c_post_adam_engagement_check`
invocation for DqnTrunk/DqnValue/DqnBranches)
- `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (Curiosity
weight_clamp reads `ISV[WEIGHT_BOUND[Curiosity]]`)
- `crates/ml/src/trainers/dqn/trainer/constructor.rs` (`weight_decay:
hyperparams.weight_decay` line removed)
- `crates/ml/src/trainers/dqn/config.rs` (field + default + family-scaling
removed)
- `crates/ml/src/trainers/dqn/smoke_tests/generalization.rs`
(`params.weight_decay = 1e-4` line removed)
- `crates/ml/src/training_profile.rs` (TrialOverrides + search-space +
apply_to weight_decay branch removed)
- `crates/ml/examples/train_baseline_rl.rs` (`weight_decay:` initialiser
line removed)
Verification (local, RTX 3050 Ti, post-commit):
- `SQLX_OFFLINE=true cargo check -p ml --offline`: clean, 11 warnings
(all pre-existing).
- `git grep -nE "10\.0_f32 \* q_abs_ref|10\.0f \* q_abs_ref|100\.0_f32
\* q_abs_ref|100\.0f \* q_abs_ref|1e6_f32 \* q_abs_ref|1e3_f32 \*
q_abs_ref|100\.0_f32 \* h_s2|100\.0f \* h_s2_rms" crates/ml/src/`:
ZERO matches.
- `git grep -nE "weight_decay:\s*f64|l1_lambda:" crates/ml/src/trainers/dqn/`:
ZERO matches.
- `git grep -n "self.hyperparams.weight_decay|self.config.weight_decay|
self.hyperparams.l1_lambda" crates/ml/src/`: only TFT remains
(separate trainer outside SP4 scope).
- `git grep -n "q_abs_ref_eff|h_s2_rms_ema_eff" crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu`:
ZERO matches.
- `cargo test -p ml --lib --offline`: 928 passed, 14 failed (all 14
pre-existing on HEAD `1389d1c81`; no new failures introduced).