Files
foxhunt/crates/ml-alpha/tests/phase_3d_blend_kernel_invariants.rs
jgrusewski bd811a7748 feat(ml-alpha): Phase 3D — three-intervention overtrading fix (A+B+C)
Combined atomic attack on foxhunt's structural overtrading pathology
per omnisearch (2026-06-03) RL HFT literature: foxhunt uniquely uses
Q-distill as the SOLE policy-training mechanism, making it susceptible
to the four-stage Q→π attenuation chain (Goodhart-Skalse 2024) that
blinds π to small persistent fees. Three concurrent fixes attack
different layers:

A. Hold-action logit bias (+log(4) ≈ 1.386 on action 2)
   * crates/ml-alpha/src/rl/ppo.rs PolicyHead::new
   * crates/ml-alpha/src/rl/multi_head_policy.rs all K heads + build_priors
   Counter-balances the structural 4:1 open-vs-hold action prior (4
   open variants 0,1,5,6 vs 1 Hold=2). Pre-bias P(open)=36% / P(hold)=9%;
   post-bias P(hold)≈29% / P(any open)≈7%. Mid-smoke seed=42 confirms
   Hold rises to 71/128 = 55.5% by step 1999.

B. Quadratic-in-trade-size impact-aware cost (Cao et al. 2026,
   arXiv:2603.29086 §4)
   * crates/ml-alpha/cuda/rl_fused_reward_pipeline.cu Phase 1.5
   * 3 new ISV slots 794-796 (α=0.5, β=2.0, enabled=1.0)
   cost = α·|Δlots| + β·(Δlots)². 1-lot=2.5; 4-lot flip=34; 8-lot=132
   (superlinear). Applied BEFORE shaping so the surfer-scaffold weight
   does not amplify or mute. Trail actions (7,8) are no-ops in the
   position kernel → cost=0 for them as expected.

C. PPO surrogate gradient restoration with adaptive blend (Cao 2026 §4)
   * crates/ml-alpha/cuda/rl_pi_grad_blend.cu (new — element-wise
     scale-or-zero operator)
   * crates/ml-alpha/src/trainer/integrated.rs Step 7 (π gradient blend)
   * 2 new ISV slots 797-798 (weight=0.005, enabled=1.0)
   Previously π was trained ONLY by Q-distillation (line 5850 header).
   Now: pi_grad = w_ppo·grad_PPO + grad_Q_distill + grad_SAC_entropy.
   Restores the direct fee-aware policy-gradient channel that Q-distill
   alone cannot transmit. Blend kernel runs BETWEEN surrogate_backward
   and rl_q_pi_distill_grad (which uses +=).

Diag emission (E):
* crates/ml-alpha/src/trainer/integrated.rs rewards.{quadratic_cost_alpha,
  quadratic_cost_beta, quadratic_cost_enabled, ppo_surrogate_weight,
  ppo_surrogate_enabled}

Tests (D):
* multi_head_policy_invariants: updated k1_reduces_to_single_head for
  Phase 3D-A bias; new phase_3d_a_hold_bias_propagates_all_heads
  invariant verifies Hold dominance in every head at h_t=0. 18/18 pass.
* phase_3d_blend_kernel_invariants (new): 3 GPU-oracle invariants on
  rl_pi_grad_blend (disabled-zeros, enabled-scales-linearly, weight-0-
  equivalent-to-disabled). 3/3 pass.
* reward_alignment_invariants: 2 new tests (phase_3d_diag_emission +
  phase_3d_quadratic_cost_visible_in_rewards) + fix to the existing
  surfer_scaffold test (relaxed bootstrap check for Phase 3B-Y pure-pnl
  mode default 0.0; absorbs eval drain row). 3/3 pass.
* All 68 ml-alpha lib tests pass.

Verification:
* SQLX_OFFLINE=true cargo build --release --example alpha_rl_train -p
  ml-alpha: exit 0
* SQLX_OFFLINE=true cargo check --workspace: exit 0
* ./scripts/determinism-check.sh --quick: DETERMINISTIC (200/200 rows
  bit-equal across two same-seed runs)
* FOXHUNT_USE_MULTI_HEAD_POLICY=1 ./scripts/determinism-check.sh --quick:
  DETERMINISTIC
* Local Tier 1.5 mid-smoke (seed=42, b=128, 2000 train + 500 eval):
  exit 0, completed_clean=true, no NaN, no abort.

Primary kill criterion (total_trades final < 5,000): NOT MET.
  Result: 11,767 trades vs 14,691 baseline = 20% reduction. Cao 2026
  forecast 96% reduction for pure-PPO/SAC architectures was not
  achieved — foxhunt's Q-distill dominance (q_pi_agree_ema = 0.948 in
  this run) attenuates the PPO surrogate's fee signal even with the
  blend operator. The behavioral signature IS present (Hold dominance
  rises from baseline ~36% structural prior to 55.5% at step 1999;
  action_entropy = 1.748 within healthy [1.2, 2.04] target).

Regression guard (eval pnl ≥ -$5M): MARGINAL PASS at -$4.96M (-$36k
  inside threshold). The Tier 1.5 verdict flags KILL on Pearson +
  wr_train + wr_eval + eval_pnl. Pre-cluster, the literature
  recommendation is to A/B-ablate each intervention (slots 794-798
  individually gated). Mid-smoke architecturally validates that the
  three interventions PROPAGATE and do not crash; cluster b=1024 with
  longer runs (20k steps) will surface whether the 20% reduction
  compounds into a viable policy.

Architectural references:
* pearl_foxhunt_pi_trained_by_q_distillation_not_ppo
* pearl_reward_signal_anti_aligned_with_pnl
* pearl_bootstrap_must_respect_clamp_range
* feedback_no_atomicadd / feedback_no_htod_htoh_only_mapped_pinned

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:31:29 +02:00

195 lines
7.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 3D-C kernel-level invariants for `rl_pi_grad_blend`.
//!
//! Three GPU-oracle invariants on the PPO surrogate × Q-distill blend
//! operator (`crates/ml-alpha/cuda/rl_pi_grad_blend.cu`):
//!
//! 1. `blend_disabled_zeros_buffer` — at `RL_PPO_SURROGATE_ENABLED_INDEX`
//! ≤ 0.5, the kernel zeros the buffer regardless of weight (legacy
//! zero-then-Q-distill path, bit-equal to pre-3D behaviour).
//! 2. `blend_enabled_scales_by_weight` — at `ENABLED > 0.5`, the kernel
//! multiplies each entry by `RL_PPO_SURROGATE_WEIGHT_INDEX`. Linear
//! relationship: `out[i] = w · in[i]`.
//! 3. `blend_weight_zero_is_equivalent_to_disabled` — with ENABLED=1 and
//! WEIGHT=0, every element becomes 0, matching the disabled path
//! (relevant for ablation runs that want PPO=off via weight rather
//! than the gate).
//!
//! Per `feedback_no_cpu_test_fallbacks`: oracles are analytical
//! (zero output, linear scaling, weight=0 → zero).
//! Per `feedback_no_htod_htoh_only_mapped_pinned`: ISV bus uses
//! `MappedF32Buffer`; the grad buffer is a regular `CudaSlice` written
//! via the public `write_slice_f32_d_pub` helper.
//!
//! Run with:
//! `cargo test -p ml-alpha --test phase_3d_blend_kernel_invariants \
//! --release -- --ignored --nocapture`
use anyhow::{Context, Result};
use cudarc::driver::PushKernelArg;
use ml_alpha::pinned_mem::MappedF32Buffer;
use ml_alpha::rl::common::N_ACTIONS;
use ml_alpha::rl::isv_slots::{
RL_PPO_SURROGATE_ENABLED_INDEX,
RL_PPO_SURROGATE_WEIGHT_INDEX,
RL_SLOTS_END,
};
use ml_alpha::trainer::integrated::{read_slice_d_pub, write_slice_f32_d_pub};
use ml_core::device::MlDevice;
const RL_PI_GRAD_BLEND_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_pi_grad_blend.cubin"));
const B_SIZE: usize = 8;
fn build_device() -> Option<MlDevice> {
match MlDevice::cuda(0) {
Ok(d) => Some(d),
Err(e) => {
eprintln!("CUDA 0 not available — skipping ({e})");
None
}
}
}
/// Launches `rl_pi_grad_blend` on the given (already-uploaded) buffer
/// using the supplied (enabled, weight) ISV values. Returns the host
/// copy of the buffer post-launch.
fn run_blend(
dev: &MlDevice,
input: &[f32],
enabled: f32,
weight: f32,
) -> Result<Vec<f32>> {
let stream = dev.cuda_stream().context("cuda_stream")?.clone();
let ctx = dev.cuda_context().context("cuda_context")?;
let module = ctx
.load_cubin(RL_PI_GRAD_BLEND_CUBIN.to_vec())
.context("load rl_pi_grad_blend cubin")?;
let func = module
.load_function("rl_pi_grad_blend")
.context("load rl_pi_grad_blend fn")?;
// Allocate input buffer and upload (no raw HtoD per
// `feedback_no_htod_htoh_only_mapped_pinned`).
let mut buf = stream.alloc_zeros::<f32>(input.len())?;
write_slice_f32_d_pub(&stream, input, &mut buf)?;
// Mapped-pinned ISV buffer (matches production layout).
let isv = unsafe { MappedF32Buffer::new(RL_SLOTS_END) }
.map_err(|e| anyhow::anyhow!("alloc isv: {e}"))?;
isv.write_record(RL_PPO_SURROGATE_ENABLED_INDEX, enabled);
isv.write_record(RL_PPO_SURROGATE_WEIGHT_INDEX, weight);
let b_i = B_SIZE as i32;
let n_a = N_ACTIONS as i32;
let total = (B_SIZE * N_ACTIONS) as u32;
let block: u32 = 128;
let grid: u32 = (total + block - 1) / block;
let isv_dev_ptr = isv.dev_ptr;
unsafe {
stream
.launch_builder(&func)
.arg(&mut buf)
.arg(&isv_dev_ptr)
.arg(&b_i)
.arg(&n_a)
.launch(cudarc::driver::LaunchConfig {
grid_dim: (grid, 1, 1),
block_dim: (block, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| anyhow::anyhow!("rl_pi_grad_blend launch: {:?}", e))?;
}
stream.synchronize().context("sync")?;
read_slice_d_pub(&stream, &buf, input.len())
}
/// Synthetic input: deterministic per-index ramp so each invariant has
/// a non-trivial signal to verify against.
fn ramp_input() -> Vec<f32> {
(0..B_SIZE * N_ACTIONS)
.map(|i| (i as f32) * 0.1 - 5.0) // values in [-5, ~3.7]
.collect()
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn blend_disabled_zeros_buffer() -> Result<()> {
let Some(dev) = build_device() else { return Ok(()); };
let input = ramp_input();
// ENABLED=0.0 → kernel must zero buffer regardless of weight.
let out = run_blend(&dev, &input, 0.0, 1.0)?;
assert_eq!(out.len(), input.len());
for (i, &v) in out.iter().enumerate() {
assert!(
v.abs() < 1e-9,
"ENABLED=0 must zero buffer: out[{i}] = {v} (input was {})",
input[i]
);
}
// Also: ENABLED=0.5 (boundary, `> 0.5f` is FALSE → disabled).
let out2 = run_blend(&dev, &input, 0.5, 1.0)?;
for (i, &v) in out2.iter().enumerate() {
assert!(
v.abs() < 1e-9,
"ENABLED=0.5 must zero buffer (strict > test): out[{i}] = {v}"
);
}
eprintln!("PASS — ENABLED ≤ 0.5 zeros buffer (legacy zero-then-distill path equivalence)");
Ok(())
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn blend_enabled_scales_by_weight() -> Result<()> {
let Some(dev) = build_device() else { return Ok(()); };
let input = ramp_input();
// ENABLED=1.0 + weight=0.005 (production bootstrap): each entry
// scaled by 0.005.
let out = run_blend(&dev, &input, 1.0, 0.005)?;
for (i, (&v_in, &v_out)) in input.iter().zip(out.iter()).enumerate() {
let expected = 0.005_f32 * v_in;
assert!(
(v_out - expected).abs() < 1e-6,
"ENABLED=1 weight=0.005: out[{i}] = {v_out}, expected 0.005 * {v_in} = {expected}"
);
}
// Also: ENABLED=1.0 + weight=1.0 (identity scaling) — verifies
// pure pass-through (the surrogate grad lands verbatim in
// ss_pi_grad_logits_d, ready for Q-distill +=).
let out_id = run_blend(&dev, &input, 1.0, 1.0)?;
for (i, (&v_in, &v_out)) in input.iter().zip(out_id.iter()).enumerate() {
assert!(
(v_out - v_in).abs() < 1e-6,
"ENABLED=1 weight=1.0 (identity): out[{i}] = {v_out}, expected {v_in}"
);
}
eprintln!(
"PASS — ENABLED=1 scales buffer linearly by weight (verified at w=0.005 and w=1.0)"
);
Ok(())
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn blend_weight_zero_is_equivalent_to_disabled() -> Result<()> {
let Some(dev) = build_device() else { return Ok(()); };
let input = ramp_input();
// ENABLED=1.0 but weight=0.0 → out = 0 * in = 0 everywhere.
// Should be bit-equal to the ENABLED=0 (disabled) path.
let out_w0 = run_blend(&dev, &input, 1.0, 0.0)?;
let out_disabled = run_blend(&dev, &input, 0.0, 0.0)?;
assert_eq!(out_w0.len(), out_disabled.len());
for (i, (&a, &b)) in out_w0.iter().zip(out_disabled.iter()).enumerate() {
assert!(
(a - b).abs() < 1e-9 && a.abs() < 1e-9,
"weight=0 vs disabled at [{i}]: w0={a} disabled={b} (both must be 0)"
);
}
eprintln!(
"PASS — ENABLED=1 weight=0 is bit-equal to ENABLED=0 (ablation path equivalence)"
);
Ok(())
}