Files
foxhunt/crates/ml-alpha/tests/isv_bootstrap.rs
jgrusewski 95dcc4e312 fix(rl): ISV-driven ADV_VAR_RATIO_TARGET for rl_rollout_steps_controller
cvf86 controller_branch diag (commit 708c121f2) revealed:
  rollout_steps: 99.99% WIDEN, 0% HOLD, 0% SHRINK

The bounded-step + noise-floor fix was correctly applied, but the
controller WIDENED 99.99% of steps because the hardcoded
ADV_VAR_RATIO_TARGET = 0.1 (`#define` in the kernel) was a b_size>1
design choice. The streaming-EMA regime at b_size=1 has var/|mean|
naturally living in [1, 10] (median 3.9), so input is ALWAYS >> 0.1
and the controller correctly says "noisy advantages → widen". Result:
n_rollout pegs at MAX=8192 within ~5 steps and stays for 50k steps,
PPO update frequency drops 4×, KL stays in numerical noise (median
1.7e-8), Q can't learn (l_q stuck at ~2.7 vs uniform 3.04).

## Fix: ISV-driven target

Per `feedback_isv_for_adaptive_bounds`: ADV_VAR_RATIO_TARGET now
lives in ISV slot 449 (`RL_ADV_VAR_RATIO_TARGET_INDEX`), seeded at
trainer init to 5.0 (matches streaming-regime median 3.9). The
controller reads `isv[RL_ADV_VAR_RATIO_TARGET_INDEX]` each step
instead of a `#define`.

Expected behavior at TARGET=5.0:
  * Median input 3.9 lands in-band [3.33, 7.5] → HOLD
  * n_rollout stays near BOOTSTRAP=2048 instead of MAX
  * 4× more PPO updates per step → policy actually moves
  * KL leaves noise floor → ε controller activates
  * Q has gradient signal → can learn

Noise floor is now derived multiplicatively from the ISV target
(`target × ADV_VAR_RATIO_NOISE_FLOOR_FRAC = 0.01`) so adjusting
the target proportionally adjusts the floor — no separate slot
needed.

## Wiring

`rl_streaming_clamp_init.cu` extended to seed all three ISV-resident
design constants (adv_var clamp ceiling, td_kurt clamp ceiling, AND
adv_var regression target). Single kernel call at trainer init —
still no HtoD per `feedback_no_htod_htoh_only_mapped_pinned`.

## Diag bake-in

`controller_branch.rollout_steps_target` now reads from
`isv[RL_ADV_VAR_RATIO_TARGET_INDEX]` instead of the prior hardcoded
`0.1f32` literal. The diag shows the current ISV-resident target
so post-hoc branch analysis uses the actual value the controller
saw, and lets us track whether a future adaptive controller (one
that maintains target from observed-input percentile EMA) is
moving the target correctly.

## Slot allocation

RL_SLOTS_END: 449 → 450 (one new design-constant slot).

## Test updates

G1 (isv_bootstrap) + G3 (r5_controllers) skip slot 449 in the
sentinel-zero loop and assert the seeded value (5.0) separately.
G3's `advantage_var_ratio` input bumped from 5.0 → 20.0 so the
WIDEN branch still fires (input > new target × 1.5 = 7.5) and the
test still validates that the controller moves off bootstrap.

## Verified gates (local sm_86)

  G1 isv_bootstrap   
  G3 controllers      (with updated input)
  G4 target_update   
  integrated_smoke   

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:18:51 +02:00

200 lines
8.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Phase R1 gate **G1** — `IntegratedTrainer::new` bootstraps each of
//! the 7 RL adaptive-controller ISV slots to its canonical default via
//! the kernel-defined `*_BOOTSTRAP` constants (`pearl_first_observation_bootstrap`
//! sentinel-zero path).
//!
//! This catches the canonical Phase F defect (commit history preserved
//! on `ml-alpha-phase-f-g-flawed`) where ISV[400..406] stayed at
//! `alloc_zeros` sentinel `0.0` in production, causing
//! `bellman_target_projection`, `dqn_distributional_q`, and
//! `ppo_clipped_surrogate` to consume γ=0 / ε=0 / entropy_coef=0 and
//! train against degenerate Bellman / surrogate targets.
//!
//! Per `feedback_no_cpu_test_fallbacks`: the oracle is the kernel's
//! own `#define *_BOOTSTRAP` constant, not a CPU computation.
//!
//! Per `pearl_tests_must_prove_not_lock_observations`: this asserts an
//! invariant ("bootstrap path wrote the canonical value defined by the
//! kernel") not a tuned magic number — if a kernel rewrites its
//! bootstrap constant in a future refactor, this test updates with the
//! constant, not with empirical observation.
//!
//! Run with:
//! `cargo test -p ml-alpha --test isv_bootstrap -- --ignored --nocapture`
use ml_alpha::rl::isv_slots::{
RL_ADV_VAR_RATIO_CLAMP_INDEX, RL_ADV_VAR_RATIO_TARGET_INDEX, RL_ENTROPY_COEF_INDEX,
RL_GAMMA_INDEX, RL_MEAN_TRADE_DURATION_EMA_INDEX, RL_N_ROLLOUT_STEPS_INDEX,
RL_PER_ALPHA_INDEX, RL_PPO_CLIP_INDEX, RL_PPO_RATIO_CLAMP_MAX_INDEX, RL_REWARD_SCALE_INDEX,
RL_SLOTS_END, RL_TARGET_TAU_INDEX, RL_TD_KURTOSIS_CLAMP_INDEX,
};
use ml_alpha::trainer::integrated::{IntegratedTrainer, IntegratedTrainerConfig};
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
use ml_core::device::MlDevice;
// Canonical bootstrap values — each MUST match the kernel's
// corresponding `*_BOOTSTRAP` #define in
// `crates/ml-alpha/cuda/rl_*_controller.cu`. Source of truth is the
// .cu file; this test asserts the kernel actually wrote those values.
// Bootstrap value at sentinel input (post-R9 audit: derive-from-input
// pattern in rl_gamma_controller.cu). Sentinel d_ema = 0 → clamped
// d=1 → target = 0.5^1 = 0.5 → clamped to GAMMA_MIN = 0.90. Was
// hardcoded 0.99 (canonical long-horizon) before R9 closed the
// dead-zone at trade_duration_ema ≈ 69 events.
const GAMMA_BOOTSTRAP: f32 = 0.90;
const TAU_BOOTSTRAP: f32 = 0.005;
const EPS_BOOTSTRAP: f32 = 0.2;
// Bootstrap value at sentinel input (post-R9 audit: derive-from-input
// pattern in rl_entropy_coef_controller.cu). Sentinel h_obs = 0 →
// deficit = h_target = 1.538 → target = (1.538 / 2.197) × 0.05 ≈
// 0.035. Was hardcoded 0.01 (canonical PPO entropy bonus) before R9
// closed the dead-zone at entropy_observed_ema ≈ 1.099.
const COEF_BOOTSTRAP: f32 = 0.035;
const ROLLOUT_BOOTSTRAP: f32 = 2048.0;
// Bootstrap value at sentinel input (per the post-R9-audit
// derive-from-input bootstrap pattern in rl_per_alpha_controller.cu):
// kurt_excess=0 → target = 0.4. Was hardcoded 0.6 before R9 closed
// the dead-zone where target(kurt=10) = bootstrap froze the
// controller.
const PER_ALPHA_BOOTSTRAP: f32 = 0.4;
const REWARD_SCALE_BOOTSTRAP: f32 = 1.0;
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn g1_isv_bootstrap_writes_canonical_values() {
let dev = match MlDevice::cuda(0) {
Ok(d) => d,
Err(e) => {
eprintln!("CUDA 0 not available — skipping G1 ({e})");
return;
}
};
let cfg = IntegratedTrainerConfig {
perception: PerceptionTrainerConfig {
seq_len: 4,
n_batch: 1,
..PerceptionTrainerConfig::default()
},
dqn_seed: 0xCAFE,
ppo_seed: 0xBEEF,
..IntegratedTrainerConfig::default()
};
let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
// Read full ISV slice to host. Uses the same pattern as the
// trainer's own per-step ISV mirror refresh.
let mut isv = vec![0.0_f32; RL_SLOTS_END];
let stream = dev.cuda_stream().expect("cuda_stream");
stream
.memcpy_dtoh(&trainer.isv_d, isv.as_mut_slice())
.expect("isv dtoh");
// Floating-point exact equality is the right oracle here — each
// kernel's bootstrap path is `isv[slot] = K_BOOTSTRAP; return;`
// with no arithmetic, so the host-side f32 must equal the kernel's
// f32 literal bit-for-bit. EPS guards against any unexpected
// post-bootstrap math we don't see here.
const EPS: f32 = 1e-6;
assert!(
(isv[RL_GAMMA_INDEX] - GAMMA_BOOTSTRAP).abs() < EPS,
"ISV[γ={RL_GAMMA_INDEX}] expected {GAMMA_BOOTSTRAP}, got {}",
isv[RL_GAMMA_INDEX]
);
assert!(
(isv[RL_TARGET_TAU_INDEX] - TAU_BOOTSTRAP).abs() < EPS,
"ISV[τ={RL_TARGET_TAU_INDEX}] expected {TAU_BOOTSTRAP}, got {}",
isv[RL_TARGET_TAU_INDEX]
);
assert!(
(isv[RL_PPO_CLIP_INDEX] - EPS_BOOTSTRAP).abs() < EPS,
"ISV[ε={RL_PPO_CLIP_INDEX}] expected {EPS_BOOTSTRAP}, got {}",
isv[RL_PPO_CLIP_INDEX]
);
assert!(
(isv[RL_ENTROPY_COEF_INDEX] - COEF_BOOTSTRAP).abs() < EPS,
"ISV[entropy_coef={RL_ENTROPY_COEF_INDEX}] expected {COEF_BOOTSTRAP}, got {}",
isv[RL_ENTROPY_COEF_INDEX]
);
assert!(
(isv[RL_N_ROLLOUT_STEPS_INDEX] - ROLLOUT_BOOTSTRAP).abs() < EPS,
"ISV[n_rollout_steps={RL_N_ROLLOUT_STEPS_INDEX}] expected {ROLLOUT_BOOTSTRAP}, got {}",
isv[RL_N_ROLLOUT_STEPS_INDEX]
);
assert!(
(isv[RL_PER_ALPHA_INDEX] - PER_ALPHA_BOOTSTRAP).abs() < EPS,
"ISV[per_α={RL_PER_ALPHA_INDEX}] expected {PER_ALPHA_BOOTSTRAP}, got {}",
isv[RL_PER_ALPHA_INDEX]
);
assert!(
(isv[RL_REWARD_SCALE_INDEX] - REWARD_SCALE_BOOTSTRAP).abs() < EPS,
"ISV[reward_scale={RL_REWARD_SCALE_INDEX}] expected {REWARD_SCALE_BOOTSTRAP}, got {}",
isv[RL_REWARD_SCALE_INDEX]
);
// Invariant: the EMA-input slots are NOT yet bootstrapped. Phase
// R3 wires the EMA producer kernels that fill these; until then
// they MUST remain at `alloc_zeros` sentinel 0. Asserting this
// here protects against accidental controller-side writes to the
// wrong slot (a defect a one-character bug could introduce, given
// the slots are sequential).
//
// Exception: RL_PPO_RATIO_CLAMP_MAX_INDEX is a controller-OUTPUT
// slot (the R9 ppo ratio clamp ceiling). Its bootstrap path fires
// during `with_controllers_bootstrapped`, leaving the slot at
// PPO_RATIO_CLAMP_BOOTSTRAP = 10.0 — checked separately below.
for slot in RL_MEAN_TRADE_DURATION_EMA_INDEX..RL_SLOTS_END {
// R9 controller-OUTPUT / ISV-resident-design-constant slots
// that bootstrap to non-zero values.
if slot == RL_PPO_RATIO_CLAMP_MAX_INDEX
|| slot == RL_ADV_VAR_RATIO_CLAMP_INDEX
|| slot == RL_TD_KURTOSIS_CLAMP_INDEX
|| slot == RL_ADV_VAR_RATIO_TARGET_INDEX
{
continue;
}
assert_eq!(
isv[slot], 0.0,
"ISV[{slot}] expected sentinel 0.0 (R3 wires EMA producers), got {}",
isv[slot]
);
}
// R9 — PPO ratio clamp ceiling bootstrap.
assert!(
(isv[RL_PPO_RATIO_CLAMP_MAX_INDEX] - 10.0).abs() < EPS,
"ISV[ppo_ratio_clamp_max={RL_PPO_RATIO_CLAMP_MAX_INDEX}] expected 10.0, got {}",
isv[RL_PPO_RATIO_CLAMP_MAX_INDEX]
);
// R9 — streaming-kernel output clamp ceilings (seeded device-side
// by rl_streaming_clamp_init during bootstrap).
assert!(
(isv[RL_ADV_VAR_RATIO_CLAMP_INDEX] - 100.0).abs() < EPS,
"ISV[adv_var_ratio_clamp={RL_ADV_VAR_RATIO_CLAMP_INDEX}] expected 100.0, got {}",
isv[RL_ADV_VAR_RATIO_CLAMP_INDEX]
);
assert!(
(isv[RL_TD_KURTOSIS_CLAMP_INDEX] - 30.0).abs() < EPS,
"ISV[td_kurtosis_clamp={RL_TD_KURTOSIS_CLAMP_INDEX}] expected 30.0, got {}",
isv[RL_TD_KURTOSIS_CLAMP_INDEX]
);
// R9 — ISV-driven advantage-variance-ratio target (replaces the
// prior hardcoded #define = 0.1 in rl_rollout_steps_controller).
assert!(
(isv[RL_ADV_VAR_RATIO_TARGET_INDEX] - 5.0).abs() < EPS,
"ISV[adv_var_ratio_target={RL_ADV_VAR_RATIO_TARGET_INDEX}] expected 5.0, got {}",
isv[RL_ADV_VAR_RATIO_TARGET_INDEX]
);
eprintln!(
"G1 OK — bootstraps: γ={:.4} τ={:.4} ε={:.4} entropy_coef={:.4} \
n_rollout_steps={:.1} per_α={:.4} reward_scale={:.4}",
isv[RL_GAMMA_INDEX],
isv[RL_TARGET_TAU_INDEX],
isv[RL_PPO_CLIP_INDEX],
isv[RL_ENTROPY_COEF_INDEX],
isv[RL_N_ROLLOUT_STEPS_INDEX],
isv[RL_PER_ALPHA_INDEX],
isv[RL_REWARD_SCALE_INDEX],
);
}