fix(per-horizon-cfc): label-variance anchor + block-diagonal heads grad mask

Two follow-up fixes for Smoke 2 readiness, atomic per feedback_no_partial_refactor:

FIX 1 — Task 11 target_jitter_k anchor (spec §3.3 compliance):
- Previously: target_jitter_k = first non-zero raw_per_h[k] (model-state-anchored)
- Now: target_jitter_k = sqrt(realised_label_variance_k) (label-distribution-anchored)
- Per-horizon label variance computed host-side from existing per-horizon
  labels in stg_labels (layout [K, B, N_HORIZONS] row-major; typical K=32,
  B=1 -> 32 floats per horizon -> trivial host reduction).
- Wiener-α EMA with α = 0.4 floor + first-observation bootstrap per
  pearl_wiener_alpha_floor_for_nonstationary +
  pearl_first_observation_bootstrap.

FIX 2 — Task 10 Option B closure (block-diagonal heads via grad mask):
- Two new kernels: heads_w_skip_mask_init_kernel (one-shot at transition,
  builds [N_HORIZONS × HIDDEN_DIM] = 640-float mask + zeros off-bucket
  heads_w_skip in place) and heads_w_skip_grad_mask_apply_kernel
  (per-step Phase 2, multiplies grad_heads_w_skip by mask elementwise
  before opt_heads_w_skip.step).
- Achieves block-diagonal heads_w_skip behavior WITHOUT touching the
  existing GRN kernel: off-bucket positions stay 0 throughout training
  because gradient is masked out, so Adam never updates them.
- Existing GRN dispatch consumes heads_w_skip_d (full 640 floats) as
  today; off-bucket entries are always 0, so the dispatch is
  mathematically equivalent to a compact-only read.

Together these fixes restore the spec-mandated per-horizon differentiation
chain across all 3 mechanisms (CfC.tau bucketing + block-diagonal heads
+ per-bucket LR with label-variance anchor) before Smoke 2 fires the
mean_run_len ratio gate.

GPU oracle tests: 13 total (11 + heads_w_skip_mask_init + heads_w_skip_grad_mask_apply).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-21 18:59:28 +02:00
parent d435d8f270
commit c7bb79a625
3 changed files with 347 additions and 27 deletions

View File

@@ -364,6 +364,63 @@ extern "C" __global__ void h_mag_per_bucket_kernel(
}
}
// ─────────────────────────────────────────────────────────────────────
// heads_w_skip_mask_init_kernel — Phase 1→2 transition one-shot.
//
// Per spec §3.3 (block-diagonal heads) and follow-up to Task 10 Option B:
// build the per-channel routing mask AND zero-out off-bucket entries of
// `heads_w_skip` in place so that subsequent forward/backward never sees
// non-zero cross-bucket weights. Combined with the grad-mask apply kernel
// below, this is mathematically equivalent to a compact-only read of
// `heads_w_skip` at zero implementation cost for the GRN kernel.
//
// Layout: `heads_w_skip` is `[N_HORIZONS × HIDDEN_DIM]` row-major
// (horizon-major). `bucket_id_per_channel[c]` records which horizon owns
// column `c` in the block-diagonal layout. Entry `(h, c)` is in-bucket
// iff `bucket_id_per_channel[c] == h`.
//
// Launch: grid = (N_HORIZONS, 1, 1), block = (HIDDEN_DIM, 1, 1).
// One block per horizon × one thread per channel. No reductions, no
// shared memory, no atomicAdd — fully data-parallel.
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void heads_w_skip_mask_init_kernel(
const unsigned char* __restrict__ bucket_id_per_channel, // [HIDDEN_DIM]
float* __restrict__ heads_w_skip, // [N_HORIZONS × HIDDEN_DIM]
float* __restrict__ mask // [N_HORIZONS × HIDDEN_DIM]
) {
int h = blockIdx.x;
int c = threadIdx.x;
if (h >= N_HORIZONS || c >= HIDDEN_DIM) return;
int idx = h * HIDDEN_DIM + c;
float m = (bucket_id_per_channel[c] == (unsigned char)h) ? 1.0f : 0.0f;
mask[idx] = m;
heads_w_skip[idx] *= m; // zero off-bucket entries in place
}
// ─────────────────────────────────────────────────────────────────────
// heads_w_skip_grad_mask_apply_kernel — per-step Phase 2 gradient mask.
//
// Per spec §3.3 + follow-up Option B closure: multiply the post-reduction
// `grad_heads_w_skip` element-wise by the routing mask BEFORE the Adam
// step. Off-bucket positions receive zero gradient, never update, and
// (since they started at zero after the init kernel) remain at zero
// throughout training. This delivers block-diagonal `heads_w_skip`
// behaviour without touching the GRN forward/backward kernels.
//
// Launch: grid = (N_HORIZONS, 1, 1), block = (HIDDEN_DIM, 1, 1). Same
// layout as `heads_w_skip_mask_init_kernel`.
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void heads_w_skip_grad_mask_apply_kernel(
const float* __restrict__ mask, // [N_HORIZONS × HIDDEN_DIM]
float* __restrict__ grad_heads_w_skip // [N_HORIZONS × HIDDEN_DIM]
) {
int h = blockIdx.x;
int c = threadIdx.x;
if (h >= N_HORIZONS || c >= HIDDEN_DIM) return;
int idx = h * HIDDEN_DIM + c;
grad_heads_w_skip[idx] *= mask[idx];
}
// ─────────────────────────────────────────────────────────────────────
// bucket_iqr_double_widening_kernel — Controller D Recovery Attempt 1.
//

View File

@@ -760,6 +760,25 @@ pub struct PerceptionTrainer {
/// rescaling. DtoD-copied each Phase 2 step BEFORE `opt_heads_w_skip.step`.
/// Shape matches `heads_w_skip_d` (N_HORIZONS × HIDDEN_DIM = 640 floats).
heads_w_skip_pre_adam_d: CudaSlice<f32>,
/// Per-channel routing mask for `heads_w_skip` (block-diagonal heads
/// via gradient masking — follow-up to Task 10 Option B). Built once
/// at Phase 1→2 transition by `heads_w_skip_mask_init_kernel`:
/// `mask[h, c] = 1.0` iff `bucket_id_per_channel[c] == h`, else 0.0.
/// At the same launch the kernel zero-multiplies off-bucket entries
/// of `heads_w_skip_d` so the GRN forward sees only block-diagonal
/// weights. Each Phase 2 backward step
/// `heads_w_skip_grad_mask_apply_kernel` multiplies
/// `grad_heads_w_skip_d` by this mask before Adam — off-bucket
/// entries stay at 0 throughout training. Shape `[N_HORIZONS × HIDDEN_DIM]`.
heads_w_skip_mask_d: CudaSlice<f32>,
/// Cached function handle for `heads_w_skip_mask_init_kernel` —
/// one-shot at Phase 1→2 transition (builds mask + zeros off-bucket
/// entries of `heads_w_skip_d` in place).
heads_w_skip_mask_init_fn: CudaFunction,
/// Cached function handle for `heads_w_skip_grad_mask_apply_kernel` —
/// per-step Phase 2 (multiplies `grad_heads_w_skip_d` by the routing
/// mask before Adam, enforcing block-diagonal updates).
heads_w_skip_grad_mask_apply_fn: CudaFunction,
/// Per-horizon LR multiplier vector consumed by
/// `heads_lr_multiplier_scale_kernel`. Mapped-pinned per
/// `feedback_no_htod_htoh_only_mapped_pinned`: host computes
@@ -774,12 +793,24 @@ pub struct PerceptionTrainer {
/// smoothness ratio.
smoothness_jitter_ema_host_d: MappedF32Buffer,
/// Per-horizon target jitter for Controller C (`target_jitter_k` in
/// spec §3.3). Sentinel-bootstrapped per
/// `pearl_first_observation_bootstrap`: stays at 0 until the first
/// non-zero `raw_per_h` observation in Phase 2; then is written
/// directly with that value. After bootstrap, this acts as the
/// `realised_label_variance_k` proxy.
/// spec §3.3). Each Phase 2 step the host writes
/// `target_jitter_per_h[k] = sqrt(label_var_ema[k])` — anchored to the
/// observed label distribution per spec §3.3 + `feedback_isv_for_adaptive_bounds`
/// (data property, not model state). See `label_var_ema` below for
/// the variance EMA itself.
target_jitter_per_h: [f32; N_HORIZONS],
/// Per-horizon EMA of `Var(label_k)` over each batch's K×B labels.
/// Wiener-α with α = 0.4 floor per
/// `pearl_wiener_alpha_floor_for_nonstationary` (the closed-loop
/// gradient updates make Controller C a non-stationary target);
/// first-observation bootstrap per `pearl_first_observation_bootstrap`
/// (replace, don't blend, on the first sample). Source for
/// `target_jitter_per_h[k]` via `sqrt(label_var_ema[k])`.
label_var_ema: [f32; N_HORIZONS],
/// Has `label_var_ema` received its first observation? Gated separately
/// from `target_jitter_per_h` because the variance EMA is what's
/// bootstrapped — `target_jitter_per_h` is now derived each step.
label_var_ema_bootstrapped: bool,
// ── Controller D state (dead-bucket detector; spec §3.4) ──
//
@@ -1019,6 +1050,12 @@ impl PerceptionTrainer {
let bucket_iqr_double_widening_fn = bucket_transition_module
.load_function("bucket_iqr_double_widening_kernel")
.context("load bucket_iqr_double_widening_kernel")?;
let heads_w_skip_mask_init_fn = bucket_transition_module
.load_function("heads_w_skip_mask_init_kernel")
.context("load heads_w_skip_mask_init_kernel")?;
let heads_w_skip_grad_mask_apply_fn = bucket_transition_module
.load_function("heads_w_skip_grad_mask_apply_kernel")
.context("load heads_w_skip_grad_mask_apply_kernel")?;
// Mamba2 stacks live on the trunk from new_random; we wire optimizer
// + training scratches against the trunk-owned blocks.
@@ -1401,6 +1438,13 @@ impl PerceptionTrainer {
let heads_w_skip_pre_adam_d = stream
.alloc_zeros::<f32>(N_HORIZONS * crate::cfc::bucket_routing::HIDDEN_DIM)
.context("heads_w_skip_pre_adam_d alloc")?;
// Block-diagonal heads grad-mask (follow-up to Task 10 Option B).
// Zero-allocated; populated at Phase 1→2 transition by
// `heads_w_skip_mask_init_kernel`. Phase 1 reads it never — the
// grad-mask apply kernel only fires in Phase 2.
let heads_w_skip_mask_d = stream
.alloc_zeros::<f32>(N_HORIZONS * crate::cfc::bucket_routing::HIDDEN_DIM)
.context("heads_w_skip_mask_d alloc")?;
// Per-horizon LR multiplier staging. Mapped-pinned so the host
// writes from `target_jitter_per_h` + jitter EMA each Phase 2 step
// and the kernel reads the device-visible pointer without DtoH.
@@ -1796,9 +1840,14 @@ impl PerceptionTrainer {
bucket_routing_metadata: None,
// Controller C state (Task 11).
heads_w_skip_pre_adam_d,
heads_w_skip_mask_d,
heads_w_skip_mask_init_fn,
heads_w_skip_grad_mask_apply_fn,
lr_mult_per_horizon_staging,
smoothness_jitter_ema_host_d,
target_jitter_per_h: [0.0; N_HORIZONS], // sentinel; first obs bootstraps
target_jitter_per_h: [0.0; N_HORIZONS],
label_var_ema: [0.0; N_HORIZONS],
label_var_ema_bootstrapped: false,
// Controller D state (Task 12).
controller_d: ControllerDState::new(),
phase2_step_count: 0,
@@ -2152,6 +2201,41 @@ impl PerceptionTrainer {
// Controllers B / C / D.
self.bucket_routing_metadata = Some(metadata);
// 5.5. Block-diagonal heads grad-mask init (follow-up to
// Task 10 Option B). One-shot: build the routing
// mask AND zero-multiply off-bucket entries of
// `heads_w_skip_d` in place. From here on, the per-
// step grad-mask apply (below `opt_heads_w_skip.step`)
// keeps off-bucket positions at 0 throughout training,
// mathematically equivalent to a compact-only read of
// `heads_w_skip` at zero implementation cost for the
// GRN kernel. Spec §3.3 + `feedback_isv_for_adaptive_bounds`:
// the mask is derived from the same
// `bucket_id_per_channel_d` that Controllers B/C use.
{
let metadata_ref = self
.bucket_routing_metadata
.as_ref()
.expect("metadata just persisted above");
let cfg_mask_init = LaunchConfig {
grid_dim: (N_HORIZONS as u32, 1, 1),
block_dim: (HIDDEN_DIM as u32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = self
.stream
.launch_builder(&self.heads_w_skip_mask_init_fn);
launch
.arg(&metadata_ref.bucket_id_per_channel_d)
.arg(&mut self.trunk.heads_w_skip_d)
.arg(&mut self.heads_w_skip_mask_d);
unsafe {
launch
.launch(cfg_mask_init)
.context("heads_w_skip_mask_init_kernel launch (Phase 1→2 block-diagonal heads)")?;
}
}
// 6. Latch trainer into Phase 2.
//
// NOTE: Task 9 leaves the per-step dispatch path
@@ -2224,40 +2308,83 @@ impl PerceptionTrainer {
// ── 4. Controller C host-side LR multiplier update (spec §3.3) ──
//
// Now that the end-of-step sync has propagated the captured DtoD
// shadows, both `smoothness_jitter_ema_host_d` and
// `smoothness_loss_per_horizon_host_d` carry this step's per-horizon
// values. The host:
// 1. Sentinel-bootstraps `target_jitter_per_h[k]` from the FIRST
// non-zero raw smoothness loss in Phase 2 (per
// `pearl_first_observation_bootstrap`).
// 2. Computes `smoothness_ratio_k = max(0, jitter_ema - target) /
// shadows, `smoothness_jitter_ema_host_d` carries this step's
// per-horizon jitter EMA. Per spec §3.3 the host:
// 1. Computes per-horizon `label_var_k = E[label²] - E[label]²`
// over this batch's `[K × B]` labels for horizon k (host-side
// reduction on `stg_labels`; layout is `[K, B, N_HORIZONS]`
// row-major — see the staging fill earlier in this function).
// 2. Updates `label_var_ema[k]` via Wiener-α EMA (α = 0.4 floor
// per `pearl_wiener_alpha_floor_for_nonstationary`; first-
// observation bootstrap per `pearl_first_observation_bootstrap`).
// 3. Writes `target_jitter_per_h[k] = sqrt(label_var_ema[k])` —
// anchored to the label DISTRIBUTION per spec §3.3 +
// `feedback_isv_for_adaptive_bounds` (data property, not
// model state). Replaces the prior model-state-anchored
// bootstrap from `raw_per_h` which deviated from the spec.
// 4. Computes `smoothness_ratio_k = max(0, jitter_ema - target) /
// max(target, ε_floor)` per `pearl_blend_formulas_must_have_permanent_floor`.
// 3. Writes `lr_mult_k = 1 / (1 + smoothness_ratio_k)` (bounded
// 5. Writes `lr_mult_k = 1 / (1 + smoothness_ratio_k)` (bounded
// (0, 1]) to the mapped-pinned staging slice. The next
// step's captured-graph rescale kernel reads the updated
// values via the device-visible pointer (mapped-pinned
// coherence — no DtoH).
//
// Phase 1: no-op (the rescale kernel is also gated to Phase 2,
// and the staging keeps its zero-init / bootstrap value).
// and the staging keeps its 1.0-init value).
if self.phase == TrainingPhase::Phase2Routed {
// (1) Host-side per-horizon label variance over this batch.
//
// `stg_labels` layout is `[K × B × N_HORIZONS]` row-major
// (horizon innermost): index `(k * B + b) * N_HORIZONS + h`.
// For typical configs (K=32, B=1) this is 32 elements per
// horizon — a trivial host reduction.
let labels = self.stg_labels.read_all();
let n_per_h: usize = k_seq * b_sz;
debug_assert_eq!(labels.len(), n_per_h * N_HORIZONS);
let mut label_var = [0.0_f32; N_HORIZONS];
if n_per_h > 0 {
for h in 0..N_HORIZONS {
let mut s = 0.0_f32;
let mut s2 = 0.0_f32;
for kb in 0..n_per_h {
let v = labels[kb * N_HORIZONS + h];
s += v;
s2 += v * v;
}
let n_f = n_per_h as f32;
let mean = s / n_f;
let mean_sq = s2 / n_f;
// E[x²] E[x]²; clamped to ≥ 0 for floating-point safety.
label_var[h] = (mean_sq - mean * mean).max(0.0);
}
}
// (2) Wiener-α EMA with α = 0.4 floor + first-observation bootstrap.
const ALPHA_LABEL_VAR: f32 = 0.4;
if !self.label_var_ema_bootstrapped {
self.label_var_ema = label_var;
self.label_var_ema_bootstrapped = true;
} else {
for h in 0..N_HORIZONS {
self.label_var_ema[h] = (1.0 - ALPHA_LABEL_VAR)
* self.label_var_ema[h]
+ ALPHA_LABEL_VAR * label_var[h];
}
}
// (3) target_jitter_per_h[k] = sqrt(label_var_ema[k]) — spec §3.3.
for k in 0..N_HORIZONS {
self.target_jitter_per_h[k] = self.label_var_ema[k].sqrt();
}
let jitter_ema = self.smoothness_jitter_ema_host_d.read_all();
let raw_per_h = self.smoothness_loss_per_horizon_host_d.read_all();
let mut lr_mult = [1.0_f32; N_HORIZONS];
for k in 0..N_HORIZONS {
// Sentinel-bootstrap target_jitter_per_h from first non-zero
// observation. Both EMA and raw can legitimately read as
// sentinel-zero on the first Phase 2 step before the
// smoothness controller has run; once the first non-zero
// sample arrives we replace directly per the pearl.
if self.target_jitter_per_h[k] == 0.0 && raw_per_h[k] > 0.0 {
self.target_jitter_per_h[k] = raw_per_h[k];
}
let target = self.target_jitter_per_h[k];
if target <= 0.0 {
// Still pre-bootstrap: leave lr_mult at 1.0 (no
// attenuation). Once the first observation lands the
// next step picks up a non-zero target.
// Pre-bootstrap edge case (label_var_ema still 0 at
// Phase 2 step 0 corner). Leave lr_mult at 1.0 — no
// attenuation, no division by sentinel zero. The next
// step's batch supplies a non-zero label variance.
continue;
}
// ε_floor = target / 16 per `pearl_blend_formulas_must_have_permanent_floor`
@@ -3691,6 +3818,32 @@ impl PerceptionTrainer {
self.opt_heads_b_gate.step(&mut self.trunk.heads_b_gate_d, &self.grad_heads_b_gate_d)?;
self.opt_heads_w_main.step(&mut self.trunk.heads_w_main_d, &self.grad_heads_w_main_d)?;
self.opt_heads_b_main.step(&mut self.trunk.heads_b_main_d, &self.grad_heads_b_main_d)?;
// ── Block-diagonal heads: grad mask apply (Phase 2 only) ──
// Follow-up to Task 10 Option B. Multiplies `grad_heads_w_skip_d`
// element-wise by the routing mask built at transition. Off-bucket
// entries see zero gradient → Adam never updates them → they stay
// at 0 forever (init zero-multiplied them at the transition). This
// realizes block-diagonal `heads_w_skip` without modifying the GRN
// forward/backward kernels. MUST run BEFORE
// `opt_heads_w_skip.step` so the masked grad is the one Adam reads.
if self.phase == TrainingPhase::Phase2Routed {
let cfg_grad_mask = LaunchConfig {
grid_dim: (N_HORIZONS as u32, 1, 1),
block_dim: (HIDDEN_DIM as u32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = self
.stream
.launch_builder(&self.heads_w_skip_grad_mask_apply_fn);
launch
.arg(&self.heads_w_skip_mask_d)
.arg(&mut self.grad_heads_w_skip_d);
unsafe {
launch
.launch(cfg_grad_mask)
.context("heads_w_skip_grad_mask_apply_kernel launch (block-diagonal heads)")?;
}
}
// ── Controller C: pre-Adam snapshot of heads_w_skip_d (spec §3.3) ──
// Captured DtoD copy: heads_w_skip_d → heads_w_skip_pre_adam_d. Used
// by the post-Adam rescale below to compute the parameter delta

View File

@@ -460,6 +460,116 @@ fn bucket_iqr_double_widening_kernel_doubles_one_bucket_iqr() -> Result<()> {
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn heads_w_skip_mask_init_zeros_off_bucket() -> Result<()> {
let dev = MlDevice::cuda(0)?;
let stream = dev.cuda_stream()?.clone();
let func = load_kernel(&stream, "heads_w_skip_mask_init_kernel")?;
// Bucket assignment: channel c → bucket c/25 capped at 4 (quintile
// boundaries used elsewhere in this file: bucket_dim_k = [25, 25, 25,
// 25, 28]). heads_w_skip starts all 1.0; mask + zero-out result is
// 1.0 at in-bucket positions, 0.0 elsewhere.
let bucket_id_host: Vec<u8> =
(0..HIDDEN_DIM).map(|c| ((c / 25).min(4)) as u8).collect();
let heads_w_skip_host: Vec<f32> = vec![1.0; N_HORIZONS * HIDDEN_DIM];
let bucket_id_d = upload(&stream, &bucket_id_host)?;
let mut heads_w_skip_d = upload(&stream, &heads_w_skip_host)?;
let mut mask_d = stream.alloc_zeros::<f32>(N_HORIZONS * HIDDEN_DIM)?;
let cfg = LaunchConfig {
grid_dim: (N_HORIZONS as u32, 1, 1),
block_dim: (HIDDEN_DIM as u32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = stream.launch_builder(&func);
launch
.arg(&bucket_id_d)
.arg(&mut heads_w_skip_d)
.arg(&mut mask_d);
unsafe { launch.launch(cfg)?; }
stream.synchronize()?;
let mask = download(&stream, &mask_d)?;
let heads_w_skip_out = download(&stream, &heads_w_skip_d)?;
for h in 0..N_HORIZONS {
for c in 0..HIDDEN_DIM {
let idx = h * HIDDEN_DIM + c;
let in_bucket = (c / 25).min(4) == h;
let expected = if in_bucket { 1.0 } else { 0.0 };
assert!(
(mask[idx] - expected).abs() < 1e-6,
"mask[{},{}]={} expected {}",
h,
c,
mask[idx],
expected
);
assert!(
(heads_w_skip_out[idx] - expected).abs() < 1e-6,
"heads_w_skip[{},{}]={} expected {}",
h,
c,
heads_w_skip_out[idx],
expected
);
}
}
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn heads_w_skip_grad_mask_apply_zeros_off_bucket_grad() -> Result<()> {
let dev = MlDevice::cuda(0)?;
let stream = dev.cuda_stream()?.clone();
let func = load_kernel(&stream, "heads_w_skip_grad_mask_apply_kernel")?;
// Build a mask manually for the same quintile layout used above; feed
// an all-ones grad tensor and expect off-bucket entries to be zeroed.
let mask_host: Vec<f32> = (0..N_HORIZONS)
.flat_map(|h| {
(0..HIDDEN_DIM).map(move |c| {
if (c / 25).min(4) == h { 1.0_f32 } else { 0.0_f32 }
})
})
.collect();
let grad_host: Vec<f32> = vec![1.0; N_HORIZONS * HIDDEN_DIM];
let mask_d = upload(&stream, &mask_host)?;
let mut grad_d = upload(&stream, &grad_host)?;
let cfg = LaunchConfig {
grid_dim: (N_HORIZONS as u32, 1, 1),
block_dim: (HIDDEN_DIM as u32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = stream.launch_builder(&func);
launch.arg(&mask_d).arg(&mut grad_d);
unsafe { launch.launch(cfg)?; }
stream.synchronize()?;
let grad_out = download(&stream, &grad_d)?;
for h in 0..N_HORIZONS {
for c in 0..HIDDEN_DIM {
let idx = h * HIDDEN_DIM + c;
let in_bucket = (c / 25).min(4) == h;
let expected = if in_bucket { 1.0 } else { 0.0 };
assert!(
(grad_out[idx] - expected).abs() < 1e-6,
"grad[{},{}]={} expected {}",
h,
c,
grad_out[idx],
expected
);
}
}
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn slack_factor_apply_kernel_widens_iqr_geometrically() -> Result<()> {