diff --git a/crates/ml/src/cuda_pipeline/q_disagreement_update_kernel.cu b/crates/ml/src/cuda_pipeline/q_disagreement_update_kernel.cu index e69ee779c..22b8f6320 100644 --- a/crates/ml/src/cuda_pipeline/q_disagreement_update_kernel.cu +++ b/crates/ml/src/cuda_pipeline/q_disagreement_update_kernel.cu @@ -184,45 +184,63 @@ void q_disagreement_update_kernel( if (tid == 0) { const float total_num = num_smem[0]; const float total_cnt = cnt_smem[0]; - // `fmaxf(total_cnt, 1.0f)` keeps the divide finite when every row - // is masked; when total_cnt > 0 it is the natural denominator. - const float batch_mean = total_num / fmaxf(total_cnt, 1.0f); - const float prev_short = isv[Q_DISAGREEMENT_SHORT_EMA_IDX]; - const float prev_long = isv[Q_DISAGREEMENT_LONG_EMA_IDX]; - const float prev_var = isv[Q_DISAGREEMENT_VARIANCE_EMA_IDX]; + // ── Empty-batch gate (post-train-6fcml fix, 2026-05-07) ──────── + // When the batch has zero non-masked contributions (every row + // Q-picked Hold or Flat — common for the training replay buffer + // because Hold/Flat dominate naturally), preserve all three EMAs + // at their previous values. Without this gate, the kernel would + // blend `batch_mean = 0/1 = 0` into the EMAs every step, which + // exponentially decayed the post-rollout signal to zero across + // the per-step training launches (commit 5608b866b cadence + // migration). Per `pearl_first_observation_bootstrap.md`: + // "no observation" preserves prior; only "first observation" + // replaces sentinel. Decay-on-empty was inconsistent with both + // rules. Verified via train-6fcml HEALTH_DIAG: post-rollout + // q_dis_s=0.0595 → post-training q_dis_s=0.0000 in one epoch. + if (total_cnt > 0.0f) { + const float batch_mean = total_num / total_cnt; - // Pearl-A first-observation: sentinel = 0.5 exact (per - // `SENTINEL_Q_DISAGREEMENT`). 0.5 is exactly representable in - // IEEE 754 (mantissa 1.0, exponent -1) so the `==` compare is - // safe. The first observation REPLACES the sentinel directly - // — see `pearl_first_observation_bootstrap.md`. Both EMAs are - // gated on `(total_cnt > 0)` to avoid the cold-start case - // where the batch has zero contributions (would replace the - // sentinel with batch_mean = 0, defeating bootstrap). - const bool is_first = - (prev_short == 0.5f) && (prev_long == 0.5f) && (total_cnt > 0.0f); + const float prev_short = isv[Q_DISAGREEMENT_SHORT_EMA_IDX]; + const float prev_long = isv[Q_DISAGREEMENT_LONG_EMA_IDX]; + const float prev_var = isv[Q_DISAGREEMENT_VARIANCE_EMA_IDX]; - const float new_short = is_first - ? batch_mean - : (alpha_short * batch_mean + (1.0f - alpha_short) * prev_short); - const float new_long = is_first - ? batch_mean - : (alpha_long * batch_mean + (1.0f - alpha_long) * prev_long); + // Pearl-A first-observation: sentinel = 0.5 exact (per + // `SENTINEL_Q_DISAGREEMENT`). 0.5 is exactly representable + // in IEEE 754 (mantissa 1.0, exponent -1) so the `==` + // compare is safe. The first observation REPLACES the + // sentinel directly — see + // `pearl_first_observation_bootstrap.md`. The previous + // `&& (total_cnt > 0.0f)` guard on `is_first` is now + // redundant — the outer empty-batch gate above guarantees + // total_cnt > 0 here. + const bool is_first = + (prev_short == 0.5f) && (prev_long == 0.5f); - // Welford-style variance EMA: var_new = α_var × (x - mean)² + - // (1 - α_var) × var_old. Uses `new_short` as the running mean - // (the fast EMA tracks the mean's current best estimate); the - // alternative would be a separate mean-tracking slot, but the - // adaptive k_q controller in B.4 only consumes the variance - // ratio against `VARIANCE_REF_Q`, so the choice of "mean" here - // is mostly a numerical-stability detail (small bias toward - // the fast-EMA estimate, which is what B.4 wants anyway). - const float diff = batch_mean - new_short; - const float new_var = alpha_var * (diff * diff) + (1.0f - alpha_var) * prev_var; + const float new_short = is_first + ? batch_mean + : (alpha_short * batch_mean + (1.0f - alpha_short) * prev_short); + const float new_long = is_first + ? batch_mean + : (alpha_long * batch_mean + (1.0f - alpha_long) * prev_long); - isv[Q_DISAGREEMENT_SHORT_EMA_IDX] = new_short; - isv[Q_DISAGREEMENT_LONG_EMA_IDX] = new_long; - isv[Q_DISAGREEMENT_VARIANCE_EMA_IDX] = new_var; + // Welford-style variance EMA: var_new = α_var × (x - mean)² + // + (1 - α_var) × var_old. Uses `new_short` as the running + // mean (the fast EMA tracks the mean's current best + // estimate); the alternative would be a separate + // mean-tracking slot, but the adaptive k_q controller in + // B.4 only consumes the variance ratio against + // `VARIANCE_REF_Q`, so the choice of "mean" here is mostly + // a numerical-stability detail (small bias toward the + // fast-EMA estimate, which is what B.4 wants anyway). + const float diff = batch_mean - new_short; + const float new_var = alpha_var * (diff * diff) + (1.0f - alpha_var) * prev_var; + + isv[Q_DISAGREEMENT_SHORT_EMA_IDX] = new_short; + isv[Q_DISAGREEMENT_LONG_EMA_IDX] = new_long; + isv[Q_DISAGREEMENT_VARIANCE_EMA_IDX] = new_var; + } + // else: total_cnt == 0 → preserve EMA state untouched. Kernel + // is a no-op for this step; stream-ordered launches still run. } } diff --git a/crates/ml/tests/sp14_oracle_tests.rs b/crates/ml/tests/sp14_oracle_tests.rs index e2f6926c0..bc1fdd3f5 100644 --- a/crates/ml/tests/sp14_oracle_tests.rs +++ b/crates/ml/tests/sp14_oracle_tests.rs @@ -332,6 +332,117 @@ mod gpu { ); } + // ── Test B.3c: empty-batch preserves pre-seeded EMAs (post-train-6fcml) ── + + /// Regression test for the post-train-6fcml decay-to-zero bug. Pre-seeds + /// the q-disagreement EMAs with non-zero values matching the post-rollout + /// HEALTH_DIAG[0] state observed in train-6fcml (q_dis_s = 0.0595, + /// q_dis_l = 0.1329, var_q = 0.0009 — clearly past the Pearl-A sentinel + /// 0.5 so the bootstrap branch is NOT taken), then launches the kernel + /// against an all-Hold batch (every row masked, total_cnt = 0). + /// + /// Pre-fix (commit 5608b866b cadence migration symptom): the kernel's + /// ISV write block ran unconditionally with `batch_mean = 0/1 = 0`, + /// blending zero into the EMAs every step. Across the per-step training + /// launches the rollout signal exponentially decayed to zero in one + /// epoch, leaving the EGF gate1 stuck closed forever. + /// + /// Post-fix: when total_cnt == 0 the kernel preserves all three EMAs at + /// their previous values. The `q_disagreement_all_hold_no_contribution` + /// test above only covers the cold-start case (prev = sentinel 0.5) + /// where blend-toward-0 happened to settle in [0.0, 0.5]; this test + /// catches the warm-start regression where blend-toward-0 silently + /// destroys the rollout-collected signal. + #[test] + #[ignore = "requires GPU"] + fn q_disagreement_empty_batch_preserves_ema() { + let stream = make_test_stream(); + let kernel = load_q_disagreement(&stream); + + const B: usize = 4; + const K_AUX: usize = 2; + const K_DIR: usize = 4; + const ISV_DIM: usize = 1024; + + // Uniform aux (won't matter — all rows masked anyway). + let aux_softmax = vec![0.5_f32; B * K_AUX]; + + // Hold winner everywhere (DIR_HOLD = 1) → every row masked → total_cnt = 0. + let mut q_logits = vec![0.0_f32; B * K_DIR]; + for b in 0..B { + q_logits[b * K_DIR + 1] = 1.0; + } + + // Pre-seed EMAs with the train-6fcml HEALTH_DIAG[0] values (post- + // rollout signal). These are deliberately NOT the 0.5 sentinel so + // the Pearl-A bootstrap branch does not fire, isolating the + // empty-batch decay path. + const SEED_SHORT: f32 = 0.0595; + const SEED_LONG: f32 = 0.1329; + const SEED_VAR: f32 = 0.0009; + let mut isv = vec![0.0_f32; ISV_DIM]; + isv[Q_DISAGREEMENT_SHORT_EMA_INDEX] = SEED_SHORT; + isv[Q_DISAGREEMENT_LONG_EMA_INDEX] = SEED_LONG; + isv[Q_DISAGREEMENT_VARIANCE_EMA_INDEX] = SEED_VAR; + + let aux_buf = unsafe { MappedF32Buffer::new(B * K_AUX) } + .expect("alloc aux_softmax buffer"); + let q_buf = unsafe { MappedF32Buffer::new(B * K_DIR) } + .expect("alloc q_logits buffer"); + let isv_buf = unsafe { MappedF32Buffer::new(ISV_DIM) } + .expect("alloc isv buffer"); + aux_buf.write_from_slice(&aux_softmax); + q_buf.write_from_slice(&q_logits); + isv_buf.write_from_slice(&isv); + + let bsi: i32 = B as i32; + let q_stride: i32 = K_DIR as i32; + let alpha_short: f32 = 0.3; + let alpha_long: f32 = 0.05; + let alpha_var: f32 = 0.05; + + unsafe { + stream + .launch_builder(&kernel) + .arg(&aux_buf.dev_ptr) + .arg(&q_buf.dev_ptr) + .arg(&isv_buf.dev_ptr) + .arg(&bsi) + .arg(&q_stride) + .arg(&alpha_short) + .arg(&alpha_long) + .arg(&alpha_var) + .launch(LaunchConfig { + grid_dim: (1, 1, 1), + block_dim: (Q_DIS_BLOCK, 1, 1), + shared_mem_bytes: Q_DIS_SMEM_BYTES, + }) + .expect("launch q_disagreement_update_kernel"); + } + stream.synchronize().expect("sync after q_disagreement_update_kernel"); + + let result = isv_buf.read_all(); + let short_ema = result[Q_DISAGREEMENT_SHORT_EMA_INDEX]; + let long_ema = result[Q_DISAGREEMENT_LONG_EMA_INDEX]; + let var_ema = result[Q_DISAGREEMENT_VARIANCE_EMA_INDEX]; + + // Strict bit-equality on the seed values — the kernel must skip the + // ISV write block entirely when total_cnt == 0. Any deviation + // indicates the empty-batch gate is missing or buggy. + assert_eq!( + short_ema, SEED_SHORT, + "Empty-batch must preserve short EMA bit-exactly; expected {SEED_SHORT}, got {short_ema}", + ); + assert_eq!( + long_ema, SEED_LONG, + "Empty-batch must preserve long EMA bit-exactly; expected {SEED_LONG}, got {long_ema}", + ); + assert_eq!( + var_ema, SEED_VAR, + "Empty-batch must preserve variance EMA bit-exactly; expected {SEED_VAR}, got {var_ema}", + ); + } + // ── B.4 cubin handle ──────────────────────────────────────────────────── const SP14_ALPHA_GRAD_CUBIN: &[u8] = diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 3d1e5bb26..5612dd3d8 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -2,6 +2,8 @@ **Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7. +SP14 B.3 empty-batch decay fix — gate q_disagreement EMA writes on `total_cnt > 0` (2026-05-07): root-caused the L40S `train-6fcml` 5-epoch trajectory's `α_smoothed=0.0002` + `gate1=closed-forever` symptoms (continuation of the per-step cadence migration in commit `5608b866b`) to a second-order bug exposed by that migration. Pre-migration the kernel fired once per epoch with batches that had meaningful Q-direction diversity; post-migration it fires per training step against replay batches whose Q-direction picks are dominated by Hold/Flat (the natural distribution of the policy's argmax over the 4-way direction head). HEALTH_DIAG sequence proved the regression: HEALTH_DIAG[0] post-experience-collection showed `q_dis_s=0.0595 q_dis_l=0.1329 var_q=0.00091` (meaningful rollout signal); HEALTH_DIAG[1+] post-training showed `q_dis_s=0.0000 q_dis_l=0.0000 var_q=0.00000` — signal decayed to zero inside ONE epoch (~178 training steps × 0.7^n ≈ 0). Mechanism: in `q_disagreement_update_kernel.cu` the `tid==0` ISV write block ran unconditionally even when `total_cnt` (post-Hold/Flat-mask non-empty contribution count) was 0; the `fmaxf(total_cnt, 1.0f)` guard kept the divide finite but produced `batch_mean = 0/1 = 0`, which then blended into the EMAs as `(1-α)·prev + α·0` — exponential decay of the rollout signal. Fix (atomic, single kernel): wrap the entire ISV write block in `if (total_cnt > 0.0f) { ... }`; remove the now-redundant `&& (total_cnt > 0.0f)` clause from the `is_first` Pearl-A bootstrap check (guaranteed by the outer gate). When the training batch has no non-masked rows the kernel becomes a no-op for that step — EMAs stay at the prior step's values; stream-ordered launches still run, only the ISV write is skipped. Per `pearl_first_observation_bootstrap`: "no observation" preserves prior; only "first observation" replaces sentinel — decay-on-empty was inconsistent with both rules. Other EGF-chain kernels audited: `alpha_grad_compute_kernel.cu` operates on persistent ISV state with no batch concept (var_aux/var_alpha Welford updates use diffs of persistent EMAs, not batch means) — SAFE; `aux_dir_acc_reduce_kernel.cu` emits sentinel 0.5 when denom==0 and the downstream `apply_fixed_alpha_ema` blends 0.5 toward EMA, which pulls toward the harmless random-baseline rather than zero (different semantics from the destructive 0 in q_disagreement) — SAFE; `gradient_hack_detect_kernel.cu` is a single-thread state machine on persistent ISV with no batch — SAFE. Files modified: `crates/ml/src/cuda_pipeline/q_disagreement_update_kernel.cu` (+27 / −20 lines: outer gate, dedented body, redundant-guard removal, post-train-6fcml audit-trail comment); `crates/ml/tests/sp14_oracle_tests.rs` (+111 lines: new `q_disagreement_empty_batch_preserves_ema` strict bit-equality test, pre-seeds the train-6fcml HEALTH_DIAG[0] values 0.0595/0.1329/0.0009 — deliberately NOT the 0.5 sentinel — and asserts identity preservation across an all-Hold batch). Existing `q_disagreement_all_hold_no_contribution` test still passes — its loose bound `[0.0, 0.5]` accepted both the old blend-toward-0.35 behavior and the new preserve-at-0.5; the new strict test is what catches the warm-start regression. All 7 sp14_oracle_tests pass on RTX 3050 Ti (CUDA 12.4, sm_86): `alpha_grad_adaptive_beta`, `alpha_grad_schmitt_hysteresis`, `dir_concat_qaux_correct`, `gradient_hack_circuit_breaker_fires`, `q_disagreement_all_hold_no_contribution`, `q_disagreement_empty_batch_preserves_ema` (NEW), `q_disagreement_k4_k2_mapping`. Build clean: `SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check -p ml --tests --all-targets`. Invariants honored: `pearl_no_host_branches_in_captured_graph` (kernel modification is pure GPU code, no host branches added — the `if (total_cnt > 0.0f)` is a device-side branch on a value already in shared memory, identical hardware semantics to the existing `if (tid == 0)` and `if (gate1 >= 0.5f)` patterns elsewhere in the EGF chain); `feedback_no_partial_refactor` (the 3 ISV slots — short EMA 383, long EMA 384, variance EMA 389 — are gated atomically inside the same `if` block, no partial coverage); `feedback_no_stubs` (no commented-out gates, no `if false`, no dead params); `pearl_first_observation_bootstrap` (sentinel 0.5 still bootstraps on first valid observation; preservation on empty is the missing third case the pearl already mandates). HEALTH_DIAG validation pending L40S re-dispatch — expect post-training `q_dis_s/q_dis_l/var_q` to track the post-rollout values rather than collapse to zero, unblocking the EGF gate1 from its closed-forever state. + Producer-cadence sweep — 4 more per-step producers MOVED out of `process_epoch_boundary` (2026-05-07): continuation of SP14 B.11 (commit `200f05fce`). Audit of `training_loop.rs:4100-4262` per the `feedback_no_partial_refactor` rule found 4 ADDITIONAL producers whose docstrings explicitly claim "per-step" cadence but whose launches lived in `process_epoch_boundary` (single call site at `training_loop.rs:780`, fires once per epoch — not per training step inside `run_training_steps_slices`). Each has a verified per-step consumer reading the ISV slot every training step inside the parent captured graph: `launch_h_s2_rms_ema` (ISV[H_S2_RMS_EMA_INDEX=96] → `mag_concat_kernel` per-step in forward graph for adaptive `save_h_s2` magnitude-branch concat scale, AND `dqn_clamp_finite_f32_kernel`'s `1e6 × ISV[96]` cuBLAS-backward IQN-trunk sanitiser bound), `launch_fold_warmup_factor` (ISV[FOLD_WARMUP_FACTOR_INDEX=130] → `training_loop.rs:2662` per-step host read deriving `lr_eff = lr_base × max(0.05, factor)` and `clip_eff = clip_base × (0.1 + 0.9 × factor)`), `launch_moe_expert_util_ema` (ISV[MOE_EXPERT_UTIL_EMA_BASE..+8) + ISV[MOE_GATE_ENTROPY_EMA_INDEX=126] → consumed by `launch_moe_lambda_eff_update` immediately below), and `launch_moe_lambda_eff_update` (ISV[MOE_LAMBDA_EFF_INDEX=128] → `moe_load_balance_loss` kernel reads `λ` per-step at `gpu_dqn_trainer.rs:16864` in the captured-graph backward path). All 4 launchers use pre-loaded `CudaFunction` fields with no per-call `load_cubin` (`gpu_dqn_trainer.rs:12646, 30983, 16901, 16969`) — graph-capture safe per `pearl_no_host_branches_in_captured_graph`. Atomic migration per `feedback_no_partial_refactor`. MoE producer + λ-controller order preserved (`launch_moe_lambda_eff_update` MUST run AFTER `launch_moe_expert_util_ema` per the kernel docstring at `gpu_dqn_trainer.rs:16962`). Cold-start ordering: `h_s2_rms_ema` reads `save_h_s2` populated by this step's online forward, MoE producers read `moe_gate_softmax_buf` populated by `launch_moe_forward` (in `submit_forward_ops_main`); both run BEFORE `submit_aux_ops` per `capture_training_graph` ordering (forward_child → ddqn_child → aux_child). State-reset registry coverage already in place (`isv_h_s2_rms_ema`, `isv_fold_warmup_factor`, `isv_moe_expert_util_ema`, `isv_moe_gate_entropy_ema`, `isv_moe_lambda_eff` at `state_reset_registry.rs:304/427/380/387/398` plus companion `isv_grad_norm_fast_ema` at line 442 for the warmup factor's numerator). Producers DELIBERATELY KEPT per-epoch (audit trail): `launch_trade_attempt_rate_ema_inplace` / `launch_plan_threshold_update_inplace` / `launch_seed_step_counter_update_inplace` / `launch_cql_alpha_seed_update_inplace` (collector EMAs reading per-sample buffers populated by `experience_env_step` during `collect_experiences_gpu` — input data only updates per-epoch, so per-step launches would compute the same EMA repeatedly off the same data); `launch_iqn_quantile_ema` / `launch_vsn_mask_ema` / `launch_aux_heads_loss_ema` (HEALTH_DIAG diag-only consumers — slots 99..103, 105..111, 113, 114 are read by per-epoch HEALTH_DIAG emit lines and the GPU `health_diag_kernel`'s per-epoch snapshot path; no per-step consumer). HEALTH_DIAG read points unchanged — per-epoch reads of per-step-updated slots get the latest value (strict improvement over per-epoch reads of per-epoch-stale slots). Verification: `cargo check -p ml --tests --all-targets` clean (dev profile, only pre-existing unused-var warnings unrelated to this change). Files modified: `crates/ml/src/trainers/dqn/fused_training.rs` (+72 lines: 4 launches + comment block in `submit_aux_ops` immediately after the SP14 EGF chain); `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (−45 / +21 lines: deletion of stale per-epoch launches + replacement audit comments). DEFERRED follow-up — additional SP4 Layer A / SP5 Pearl producers in `process_epoch_boundary` lines 4264-4406 (`launch_sp4_target_q_p99`, `launch_sp4_atom_pos_p99_all_branches`, `launch_sp4_grad_norm_p99`, `launch_sp4_h_s2_p99`, `launch_sp4_param_group_oracles_all_groups`, `launch_sp5_pearl_1_atom`, `launch_sp5_pearl_3_sigma`, `launch_sp5_pearl_2_budget`, `launch_max_budget_compute`, `launch_loss_balance_controller`, `launch_sp5_pearl_4_adam_hparams`, `launch_sp5_pearl_5_iqn_tau`, `launch_sp5_pearl_8_trail`, `launch_sp5_pearl_1_ext_num_atoms`) write ISV bounds with VERIFIED per-step consumers (Mech 1 target_q clip, ISV-driven `read_group_adam_bounds` at `submit_aux_ops` lines 1648/2062/2074, `apply_c51_budget_scale` at line 1941, IQN τ schedule, atoms_update via `pearl_1_ext_num_atoms`'s ATOM_NUM_ATOMS_BASE and trail_dist consumers in `experience_env_step`); their docstrings DON'T explicitly claim per-step cadence (silence on cadence) so they do not match the explicit migration heuristic from this commit's audit. Per the user-specified strict heuristic ("docstring per-step + per-step consumer ⇒ migrate"), this sweep deliberately scoped only to docstring-claimant producers; the SP4/SP5 epoch-block producers staleness is a known follow-up tracked here but NOT changed in this commit to keep the scope tight and the validation surface manageable. The atom_pos/SP4 bound producers AND the SP7 controller chain were intentionally LEFT in `process_epoch_boundary` for that reason. SP14 B.11 cadence fix — EGF producer chain MOVED to per-step in `submit_aux_ops` (2026-05-07): root-caused the L40S `train-v8ztm` 10-epoch run's `α_smoothed=0.0002` + `gate1=closed` + `var_aux:var_q = 290:1` symptoms (HEALTH_DIAG `pearl_egf_diag` at epoch 10) to the original B.11 wire-up (commit `857722e77`) placing the per-step launches in `process_epoch_boundary` instead of the per-step training body. `process_epoch_boundary` runs ONCE per epoch (single call site at `training_loop.rs:780`, called from the per-epoch `for epoch in 0..n_epochs` loop, NOT from the per-step `for _step in 0..num_training_steps` loop in `run_training_steps_slices`). The captured backward consumer `launch_sp14_scale_wire_col` (inside `launch_cublas_backward_to`, called via the parent graph's replay every training step) reads `ISV[ALPHA_GRAD_SMOOTHED=393]` per step but the PRODUCER chain was firing only at epoch boundary — every step inside the epoch observed (steps_per_epoch − 1)-step-stale `α_grad_smoothed`, with the EMA chain barely accumulating past sentinel between rare per-epoch updates. Fix (atomic): MOVED `launch_sp13_aux_dir_metrics`, `launch_sp14_q_disagreement_update`, `launch_sp14_alpha_grad_compute` from `training_loop.rs:process_epoch_boundary` (lines 4245-4319) into `fused_training.rs:submit_aux_ops` immediately after `populate_q_out` (line ~1972). `submit_aux_ops` is captured into the `aux_child` sub-graph (see `capture_training_graph`), so each parent-graph replay re-fires the full producer chain — restoring the per-step cadence the SP14 plan §2550 specifies. `launch_sp13_aux_dir_metrics` had to migrate alongside the SP14 launches because `alpha_grad_compute_kernel` consumes its outputs (`ISV[373/374]`); leaving sp13 per-epoch while SP14 went per-step would have re-introduced the same staleness bug for the aux-dir-acc reads (atomic dependency migration per `feedback_no_partial_refactor`). The per-epoch `launch_sp14_gradient_hack_detect` circuit breaker stays in `process_epoch_boundary` — its lockout decrement is one-per-epoch by design. Forward consumers (the 6 `launch_sp14_dir_concat_qaux` sites at lines 12120, 25666, 25735, 27788, 27978, 29076 in `gpu_dqn_trainer.rs`) and backward consumers (the 2 `launch_sp14_scale_wire_col` sites at lines 12171, 29126) are unchanged — they continue reading the same ISV slot 393, but now see a value that updates every step instead of every epoch. Build clean (`cargo check -p ml --tests --all-targets`); all 6 SP14 oracle GPU tests pass (`alpha_grad_adaptive_beta`, `alpha_grad_schmitt_hysteresis`, `dir_concat_qaux_correct`, `gradient_hack_circuit_breaker_fires`, `q_disagreement_all_hold_no_contribution`, `q_disagreement_k4_k2_mapping`). HEALTH_DIAG validation pending L40S re-dispatch — expect `α_smoothed` to track real EGF-driven values (~0.5 in steady state) instead of the bootstrap-near-zero observed in `train-v8ztm`. Invariants honored: `pearl_no_host_branches_in_captured_graph` (kernels are pure GPU state machines, capture-safe — verified all use `launch_builder` with primitive args, no per-call `load_cubin`); `feedback_no_partial_refactor` (sp13 + 2 SP14 launches migrated atomically); `feedback_wire_everything_up` (all 3 producers fire on every parent graph replay; the previously-stale-cadence EGF wire is now production hot-path); `feedback_isv_for_adaptive_bounds` (no `warmup_gate` parameter — variance-driven k_aux/k_q in alpha_grad_compute_kernel handles cold-start adaptively, per c0fc28e45). Files modified: `crates/ml/src/trainers/dqn/fused_training.rs` (+74 lines: 3 launches + comment block in `submit_aux_ops`); `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (−109 / +24 lines: deletion of stale per-epoch launches + replacement audit comment).