Files
foxhunt/crates/ml-alpha/tests/isv_bootstrap.rs
jgrusewski 644fbe0348 fix(rl): ISV-driven K-loop divisor + max ceiling (slot 450, 451)
f2ggr confirmed K-loop wiring works mechanically but K=8 firing on
22 % of steps over-trained at b_size=1: KL excursions to 12.44
(vs prior 3.4e-4), policy overshoot, reward/trade -$0.585 → -$0.723.

Per `feedback_isv_for_adaptive_bounds` the K-loop config must live
in ISV, not as hardcoded values in the trainer. Two new slots:

  RL_K_LOOP_DIVISOR_INDEX (450) — divides n_rollout_steps to get K
                                  Default 2048 (matches ROLLOUT_BOOTSTRAP
                                  so K=1 at controller bootstrap)
  RL_K_LOOP_MAX_INDEX     (451) — clamp ceiling on K
                                  Default 4 (was hardcoded 8; halved
                                  to prevent gradient overtraining)

K computation in step_with_lobsim now reads both from ISV:
  K = clamp(isv[404] / isv[450], 1, isv[451])

Halves worst-case overtraining while preserving the controller
cascade activation (KL above noise floor, ε actively adapting,
ratio_clamp firing). Distribution shifts from K=8 @ 22% → K=4 @ 22%
(half the gradient updates in the high-noise case).

## Wiring

`rl_streaming_clamp_init.cu` extended to seed 5 ISV-resident design
constants (was 3): adv_var_clamp, td_kurt_clamp, adv_var_target,
k_loop_divisor, k_loop_max. Still one kernel call, no HtoD.

## Diag bake-in

JSONL `k_updates` field replaced with `k_loop` block:
  k_loop.k_updates  — actual K used this step
  k_loop.divisor    — current divisor (reads isv[450])
  k_loop.max        — current max (reads isv[451])

Post-hoc analysis can verify the K-computation by independently
recomputing K from isv[404] / k_loop.divisor.

## Slot allocation

RL_SLOTS_END: 450 → 452 (+2 new config slots).

## Test updates

G1 + G3 skip slots 450, 451 in sentinel-zero loop and assert seeded
values (2048.0 + 4.0) separately.

## Verified gates (local sm_86)

  G1 isv_bootstrap   
  G3 controllers     
  G4 target_update   
  integrated_smoke   

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

214 lines
9.2 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_K_LOOP_DIVISOR_INDEX, RL_K_LOOP_MAX_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
|| slot == RL_K_LOOP_DIVISOR_INDEX
|| slot == RL_K_LOOP_MAX_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]
);
// R9 — K-loop config (divisor + max), seeded by rl_streaming_clamp_init.
assert!(
(isv[RL_K_LOOP_DIVISOR_INDEX] - 2048.0).abs() < EPS,
"ISV[k_loop_divisor={RL_K_LOOP_DIVISOR_INDEX}] expected 2048.0, got {}",
isv[RL_K_LOOP_DIVISOR_INDEX]
);
assert!(
(isv[RL_K_LOOP_MAX_INDEX] - 4.0).abs() < EPS,
"ISV[k_loop_max={RL_K_LOOP_MAX_INDEX}] expected 4.0, got {}",
isv[RL_K_LOOP_MAX_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],
);
}