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>
200 lines
8.6 KiB
Rust
200 lines
8.6 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_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],
|
||
);
|
||
}
|