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>
214 lines
9.2 KiB
Rust
214 lines
9.2 KiB
Rust
//! 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],
|
||
);
|
||
}
|