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>
195 lines
7.2 KiB
Rust
195 lines
7.2 KiB
Rust
//! 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(())
|
||
}
|