From 9ece1a4daa12f2237678be8dce5d3b1ba0817caa Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Thu, 30 Apr 2026 22:16:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(sp4):=20Task=20A3=20=E2=80=94=20Pearls=20A?= =?UTF-8?q?+D=20shared=20host-side=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single source-of-truth implementation of: - Pearl A (first-observation bootstrap): sentinel-detect at fold reset, replace x_mean directly with first observation when prev_x_mean=0 AND state.x_lag=0. Bypass Pearl D's Wiener math. - Pearl D (Wiener-optimal adaptive α): for t≥1, α* = diff_var / (diff_var + sample_var + ε_div); variances tracked at uniform meta-α. 6 unit tests: Pearl A sentinel replacement; Pearl D anchors at stationary signal; Pearl D tracks step-change; Pearl D does NOT subsume Pearl A at t=0 (mathematical correctness check from spec self-review); meta-constants are structural; Pearl A only fires when both x_mean and x_lag are zero (does not re-fire post-Pearl-D). ALPHA_META = 1e-3 (structural — single uniform meta-rate, no per-signal tuning). EPS_DIV = 1e-8 (Adam-ε numerical category). EPS_CLAMP_FLOOR = 1.0 (consumer cold-start floor). No consumers wired yet — helper is library code unused by the producer pipeline. Behavior unchanged. cargo check clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ml/src/cuda_pipeline/mod.rs | 5 + crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs | 182 ++++++++++++++++++ docs/dqn-wire-up-audit.md | 2 + 3 files changed, 189 insertions(+) create mode 100644 crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs diff --git a/crates/ml/src/cuda_pipeline/mod.rs b/crates/ml/src/cuda_pipeline/mod.rs index e40adcba1..2a5ff79b8 100644 --- a/crates/ml/src/cuda_pipeline/mod.rs +++ b/crates/ml/src/cuda_pipeline/mod.rs @@ -66,6 +66,11 @@ pub use sp4_isv_slots::{ SP4_PARAM_GROUP_COUNT, SP4_BRANCH_COUNT, SP4_SLOT_BASE, SP4_SLOT_END, ParamGroup, weight_bound, adam_m_bound, adam_v_bound, wd_rate, atom_pos_bound, }; +pub mod sp4_wiener_ema; +pub use sp4_wiener_ema::{ + pearls_ad_update, WienerState, + ALPHA_META, EPS_DIV, EPS_CLAMP_FLOOR, +}; // gpu_replay_buffer moved to ml-dqn crate /// Maximum bytes allowed for a single GPU upload (2 GB safety limit). diff --git a/crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs b/crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs new file mode 100644 index 000000000..72de9a592 --- /dev/null +++ b/crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs @@ -0,0 +1,182 @@ +//! SP4 Pearl A (first-observation bootstrap) + Pearl D (Wiener-optimal adaptive α) +//! shared host-side helper. Single source-of-truth implementation used by all +//! producer launchers AND unit tests. +//! +//! See `docs/superpowers/specs/2026-04-30-sp4-signal-driven-magnitude-control-design.md` +//! sections "Pearl A: First-observation bootstrap" and "Pearl D: Wiener-optimal +//! adaptive α" for the design rationale. + +/// Pearl D meta-α: the rate at which the Wiener state's variance estimates +/// (`sample_var`, `diff_var`) themselves update. Structurally derived from +/// typical per-fold step count (~1000 steps in smoke). Single uniform value +/// across all producers — no per-signal tuning. Same theoretical-constant +/// category as Adam β values (a published-algorithm constant, not a tuning +/// knob). +pub const ALPHA_META: f32 = 1.0e-3; + +/// Pearl D division-safety ε. Prevents 0/0 in the optimal-α formula +/// `α* = diff_var / (diff_var + sample_var + ε_div)`. Same Adam-ε numerical +/// category as `ε = 1e-8` in the AdamW denominator — protects arithmetic, +/// not a magnitude estimate. +pub const EPS_DIV: f32 = 1.0e-8; + +/// Consumer-side cold-start floor. Used by clamps as +/// `bound = isv[X].max(EPS_CLAMP_FLOOR)` to prevent `bound = 0` from +/// collapsing the clamp at step 0 before any producer runs. Adam-ε category. +pub const EPS_CLAMP_FLOOR: f32 = 1.0; + +/// Per-slot Pearl D Wiener state. Stored in `wiener_state_buf` indexed by +/// the ISV slot's offset from `SP4_SLOT_BASE` (× 3 floats per slot). +/// +/// Layout matches the mapped-pinned buffer's flat-float layout: +/// `[slot_0_sample_var, slot_0_diff_var, slot_0_x_lag, +/// slot_1_sample_var, slot_1_diff_var, slot_1_x_lag, ...]`. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct WienerState { + pub sample_var: f32, + pub diff_var: f32, + pub x_lag: f32, +} + +impl WienerState { + /// Zero state — Pearl A sentinel (untouched / reset to fold-boundary). + pub const ZERO: WienerState = WienerState { sample_var: 0.0, diff_var: 0.0, x_lag: 0.0 }; +} + +/// Apply Pearls A + D to a producer's step observation. +/// +/// - `prev_x_mean`: current ISV slot value. Zero on the first call after a +/// fold-boundary reset (Pearl A sentinel). +/// - `state`: this slot's Wiener state. Mutated in place. +/// - `step_observation`: the producer's per-step output (e.g., step_p99). +/// +/// Returns: new x_mean to write back to the ISV slot. +/// +/// **Pearl A** (sentinel-detect): if `prev_x_mean == 0.0` AND `state.x_lag == 0.0`, +/// this is step 0 after a fold reset. Replace x_mean directly with +/// step_observation, initialize `state.x_lag = step_observation`, +/// keep `sample_var = diff_var = 0`. Bypass Pearl D's Wiener formula. +/// +/// **Pearl D** (steps 1+): compute +/// `α* = diff_var / (diff_var + sample_var + EPS_DIV)`, then +/// `x_mean[t] = (1 - α*) · x_mean[t-1] + α* · x[t]`. +/// Variances themselves use uniform meta-α `ALPHA_META`. +/// +/// **NOTE:** Pearl D does NOT subsume Pearl A. At t=0 with both variances +/// zero, the formula yields `x_mean[0] = 0` — not `x[0]`. The explicit +/// sentinel branch is REQUIRED. (See test +/// `pearl_d_does_not_subsume_pearl_a_at_t0` below for the math.) +pub fn pearls_ad_update( + prev_x_mean: f32, + state: &mut WienerState, + step_observation: f32, +) -> f32 { + // Pearl A: first-observation replacement at sentinel. + if prev_x_mean == 0.0 && state.x_lag == 0.0 { + state.x_lag = step_observation; + // sample_var and diff_var stay at 0 — they will accumulate from step 1. + return step_observation; + } + + // Pearl D: Wiener-optimal blend. + let dx_mean = step_observation - prev_x_mean; // residual from prior estimate + let dx_step = step_observation - state.x_lag; // step-to-step delta + + state.sample_var = + (1.0 - ALPHA_META) * state.sample_var + ALPHA_META * (dx_mean * dx_mean); + state.diff_var = + (1.0 - ALPHA_META) * state.diff_var + ALPHA_META * (dx_step * dx_step); + + let alpha_star = state.diff_var / (state.diff_var + state.sample_var + EPS_DIV); + let new_x_mean = (1.0 - alpha_star) * prev_x_mean + alpha_star * step_observation; + + state.x_lag = step_observation; + new_x_mean +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pearl_a_first_observation_replaces_sentinel() { + let mut state = WienerState::ZERO; + let new_mean = pearls_ad_update(0.0, &mut state, 42.0); + assert_eq!(new_mean, 42.0, + "Pearl A: first observation replaces sentinel directly"); + assert_eq!(state.x_lag, 42.0, + "Pearl A: x_lag initialized to first observation"); + assert_eq!(state.sample_var, 0.0); + assert_eq!(state.diff_var, 0.0); + } + + #[test] + fn pearl_d_stationary_signal_alpha_approaches_zero() { + // After many observations of a constant signal, α* → 0 + // (sample_var > 0, diff_var → 0). + let mut state = WienerState::ZERO; + let mut x_mean = pearls_ad_update(0.0, &mut state, 5.0); // step 0 (Pearl A) + for _ in 0..10_000 { + x_mean = pearls_ad_update(x_mean, &mut state, 5.0); + } + // Constant signal: diff_var ≈ 0, x_mean stays at 5.0. + assert!(state.diff_var < 1e-6, + "diff_var should be near 0 for constant signal, got {}", state.diff_var); + assert!((x_mean - 5.0).abs() < 1e-3, + "x_mean should anchor at constant signal, got {}", x_mean); + } + + #[test] + fn pearl_d_step_change_tracks_within_meta_window() { + // Signal jumps from 1.0 → 100.0; α* should rise; x_mean should track. + let mut state = WienerState::ZERO; + let mut x_mean = pearls_ad_update(0.0, &mut state, 1.0); + for _ in 0..1000 { + x_mean = pearls_ad_update(x_mean, &mut state, 1.0); + } + + // Now step-change. + for _ in 0..2000 { + x_mean = pearls_ad_update(x_mean, &mut state, 100.0); + } + + // After ~2000 steps post-change, x_mean should be much closer to 100 than 1. + assert!(x_mean > 50.0, + "x_mean should track step-change toward 100, got {}", x_mean); + } + + #[test] + fn pearl_d_does_not_subsume_pearl_a_at_t0() { + // Mathematical correctness check: at t=0 with all variances at 0, + // Pearl D's formula `x_mean = (1 - α*) · prev + α* · obs` would yield + // x_mean = (1 - 0) · 0 + 0 · obs = 0, NOT obs. Pearl A's explicit + // sentinel-detect branch is REQUIRED. + let mut state = WienerState::ZERO; + let result = pearls_ad_update(0.0, &mut state, 7.5); + assert_eq!(result, 7.5, + "Pearl A sentinel branch must override Pearl D at t=0"); + } + + #[test] + fn meta_constants_are_structural() { + // Document-as-code: these are the single set of structural constants. + // ALPHA_META is the meta-EMA rate for variance tracking; EPS_DIV is + // numerical division-safety; EPS_CLAMP_FLOOR is consumer cold-start. + assert_eq!(ALPHA_META, 1.0e-3); + assert_eq!(EPS_DIV, 1.0e-8); + assert_eq!(EPS_CLAMP_FLOOR, 1.0); + } + + #[test] + fn pearl_a_only_fires_when_both_x_mean_and_x_lag_are_zero() { + // If prev_x_mean is 0 but x_lag is non-zero (e.g., a previous Pearl A + // fired and was followed by Pearl D), Pearl A must NOT re-fire. + let mut state = WienerState { sample_var: 0.5, diff_var: 0.3, x_lag: 10.0 }; + let result = pearls_ad_update(0.0, &mut state, 20.0); + // Result is Pearl D's output, not 20.0 directly. + assert_ne!(result, 20.0, + "Pearl A must not re-fire when x_lag is already populated"); + // x_lag advanced to step_observation. + assert_eq!(state.x_lag, 20.0); + } +} diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 4d08137be..ad0d01a83 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -2300,3 +2300,5 @@ SP3 Mech 7 — per-element gradient clip in Adam kernel (2026-04-29): added a pe SP3 Mech 6 — anchored upper bound on adaptive grad clip (2026-04-29): added an upper bound to `GpuDqnTrainer::update_adaptive_clip`'s `new_clip` formula in `gpu_dqn_trainer.rs`. The existing winsorizer (Plan C T11 follow-up N) caps a SINGLE input sample at `K=100 × prev_clip` before the EMA absorbs it, but does NOT prevent CONSECUTIVE elevated samples from compounding the EMA upward without bound. Over hundreds of steps the clip threshold ratchets to thousands while actual `grad_norm` tracks it from below — clipping becomes a no-op against in-distribution drift, Adam m/v EMAs are poisoned, and they saturate at the SP3 Mech 5 slot 36-43 thresholds. This is the F1 NaN root cause from `smoke-test-5rqzs` (commit `b9edccfc1`) at step 3060: Mech 5 diagnostic flags `[36=trunk_adam_m_max, 37=value_adam_m_max, 38=branch_adam_m_max, 40=trunk_adam_v_max, 41=value_adam_v_max, 42=branch_adam_v_max]` fired with target_q + atoms bounded (Mechs 1+2 working) and weights still finite — narrowing the divergence to the Adam state itself. **Bound formula**: `upper_bound = (grad_norm_slow_ema × 100 × ISV[Q_ABS_REF=16].max(1.0)).max(MIN_CLIP=1.0)`; final `new_clip = (grad_norm_ema × CLIP_MULTIPLIER).max(MIN_CLIP).min(upper_bound)`. **Anchor**: `grad_norm_slow_ema` is the existing α=0.001 slow-EMA scalar (mapped-pinned, updated later in this same function via `*self.grad_norm_slow_ema_pinned`) — read from pinned memory BEFORE the slow-EMA update on this step, so it reflects the previous step's slow EMA (the legitimate steady-state grad norm at the time of clip computation). **Headroom 100×**: legitimate per-step deviations can be 10-100× the slow average without being pathological, so `100 ×` keeps Mech 6 invisible in normal training and only kicks in when the EMA-driven clip ratchets past plausible-deviation bounds. **ISV-adaptive multiplier**: `ISV[Q_ABS_REF_INDEX = 16].max(1.0_f32)` scales the cap with the Q-magnitude regime per `feedback_isv_for_adaptive_bounds` — same ISV slot used by SP3 Mechs 1, 2, 3, and 5 (no new slot). ε on the multiplier (`.max(1.0)`) per the SP1 ε-floor pearl — cold-start `ISV[16] ≈ 0` would otherwise collapse `upper_bound` toward zero. **ε-floor on the bound itself** (`.max(MIN_CLIP=1.0)`): cold-start `grad_norm_slow_ema ≈ 0` (first ~200 steps before the slow EMA warms up) would otherwise pin `upper_bound` at 0, which combined with `new_clip.min(upper_bound)` would force `new_clip = MIN_CLIP=1.0` every step until the slow EMA established a meaningful baseline — exactly the cold-start ratcheting the upper bound is meant to PREVENT. The `MIN_CLIP` floor on the bound aligns with the existing `MIN_CLIP` floor on `new_clip` itself, so the bound is at minimum a no-op (matching the floor below) until the slow EMA warms up. **What stays unchanged**: the existing winsorizer (single-sample input cap before EMA update), the `EMA_BETA = 0.95` adaptive_clip EMA, the `CLIP_MULTIPLIER = 2.0` and `MIN_CLIP = 1.0` constants, the `grad_norm_fast_ema` / `grad_norm_slow_ema` updates further down in the same function (still receive the RAW `observed_grad_norm` per follow-up K's "fast/slow EMAs are stability signal that should respond to outliers" rationale), the `fold_warmup_factor_update` kernel that consumes the fast/slow EMAs, and every ISV slot. Pure formula change in one function — no new buffer, no new kernel, no new ISV slot, no new launch site, no graph recapture. F0 risk is low: F0-typical `grad_norm_slow_ema` is on the order of 1-10 → `upper_bound = 100-1000 × ISV[16].max(1)`; F0-typical `new_clip` (= `grad_norm_ema × 2`) is single-digits to low tens, well below the cap, so Mech 6 is invisible in normal F0 operation. F1+F2 benefit by preventing the EMA-ratchet pathology. Composes with: Mech 1 (target_q clipping at single-point source, B2), Mech 2 (atom-position growth bounds, B3), Mech 3 (CQL aux loss clamping, deferred), Mech 4 (Adam EMA reset comprehensive, B5), Mech 5 (fused threshold-check kernel for diagnostic slots 36-47, B6+B7). Zero new HtoD/DtoD/HtoH copies; zero new ISV slots; zero new buffers; zero new kernels. `cargo check -p ml --lib` clean. SP4 Layer A Task A2 — mapped-pinned buffers for Pearls B/C/D (2026-04-30): allocated three mapped-pinned buffers in `GpuDqnTrainer` (`crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs`) reserved for the upcoming SP4 Pearls B/C/D wiring. (1) `wiener_state_buf: MappedF32Buffer[141]` — Pearl D Wiener-EMA state, 47 producers × 3 floats `[sample_var, diff_var, x_lag]` (40 SP4 + 7 retrofit existing producers). (2) `clamp_engage_per_block_buf: MappedI32Buffer[2048]` — Pearl C engagement counters, 8 param-groups × 256 max blocks per Adam launch; host reduces across blocks to derive engagement_rate. (3) `producer_step_scratch_buf: MappedF32Buffer[47]` — per-producer per-step `step_observation` scratch; host applies Pearls A+D (`pearls_ad_update`, Task A3) to map step_obs to its ISV bound slot. All three zero-initialized at construction by `MappedF32Buffer::new` / `MappedI32Buffer::new` (Pearl A sentinels — first-observation replacement on first producer launch); per-fold re-zero entries follow in Task A12 via the state-reset registry. Per `feedback_no_htod_htoh_only_mapped_pinned`: mapped-pinned (`cuMemHostAlloc(DEVICEMAP|PORTABLE)`) is the only allowed CPU↔GPU path for these state buffers. No consumers wired yet — buffers are reserved but unread; behaviour unchanged from before this allocation. Producer kernel writes (Tasks A5-A11), Pearls A+D host-side mapper (Task A3), Pearl C engagement-counter Adam-kernel writes (Tasks A8-A11), and per-fold reset wiring (Task A12) all follow in subsequent commits. Zero new ISV slots; zero new kernels; zero new HtoD/DtoD/HtoH copies. `cargo check -p ml --lib` clean (12 pre-existing warnings, no new warnings). + +SP4 Layer A Task A3 — Pearls A+D shared host-side helper (2026-04-30): created `crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs` providing the single source-of-truth implementation of Pearl A (first-observation bootstrap) and Pearl D (Wiener-optimal adaptive α) used by all SP4 producer launchers (Tasks A5-A11) and unit tests. Re-exported from `cuda_pipeline/mod.rs`: `pearls_ad_update`, `WienerState`, `ALPHA_META`, `EPS_DIV`, `EPS_CLAMP_FLOOR`. **Pearl A** (sentinel-detect): on the first producer-step call after a fold reset (`prev_x_mean == 0.0 && state.x_lag == 0.0`), the helper replaces `x_mean` directly with `step_observation` and initialises `state.x_lag = step_observation`, leaving `sample_var = diff_var = 0`. This bypasses Pearl D's Wiener formula on the very first observation per the spec self-review math: at t=0 with both variances zero, `α* = 0/(0+0+ε_div) = 0` and Pearl D's `(1-α*)·prev + α*·obs = 0·0 + 0·obs = 0`, NOT `obs`. The explicit sentinel branch is required and is exercised by the dedicated test `pearl_d_does_not_subsume_pearl_a_at_t0`. **Pearl D** (steps 1+): for all subsequent steps, the helper computes `α* = diff_var / (diff_var + sample_var + EPS_DIV)` and blends `x_mean[t] = (1-α*)·x_mean[t-1] + α*·x[t]`; the variances themselves are tracked at the uniform meta-rate `ALPHA_META = 1e-3` (`sample_var ← (1-α_meta)·sample_var + α_meta·(x[t]-x_mean[t-1])²`, `diff_var ← (1-α_meta)·diff_var + α_meta·(x[t]-x_lag)²`). **Constants** (Adam-ε category, structural — not tuning knobs): `ALPHA_META = 1e-3` (single uniform meta-rate, no per-signal tuning, derived from typical per-fold step count ~1000 in smoke); `EPS_DIV = 1e-8` (numerical division-safety, prevents `0/0` in optimal-α formula); `EPS_CLAMP_FLOOR = 1.0` (consumer cold-start floor — used by clamps as `bound = isv[X].max(EPS_CLAMP_FLOOR)` to prevent `bound = 0` from collapsing the clamp at step 0 before any producer runs). **Tests** (6, all passing): `pearl_a_first_observation_replaces_sentinel`, `pearl_d_stationary_signal_alpha_approaches_zero` (constant signal: diff_var → 0, x_mean anchors at signal value), `pearl_d_step_change_tracks_within_meta_window` (signal jumps 1→100 over ~2000 steps, x_mean tracks past 50), `pearl_d_does_not_subsume_pearl_a_at_t0` (mathematical correctness of explicit sentinel branch), `meta_constants_are_structural` (document-as-code assertion), `pearl_a_only_fires_when_both_x_mean_and_x_lag_are_zero` (Pearl A does not re-fire post-Pearl-D when `x_lag` is already populated). No consumers wired yet — helper is library code unused by the producer pipeline; producer launchers in Tasks A5-A11 will call `pearls_ad_update` after each kernel-step writes `producer_step_scratch_buf` (allocated in Task A2), then the new `x_mean` gets written back to the corresponding ISV bound slot. Zero new ISV slots; zero new kernels; zero new HtoD/DtoD/HtoH copies; zero behavior change. `cargo check -p ml --lib` clean (12 pre-existing warnings, no new warnings); `cargo test -p ml --lib sp4_wiener_ema::` 6/6 passing.