Adds infrastructure for verifying the SP20 Phase 2 Task 2.2-fix
(`is_win_per_env`) producer side at runtime. The consumer-side oracle
test passes; the producer-side wire-up has not been independently
verified end-to-end. Production HEALTH_DIAG observed `wr_ema=0.0000`
across all training epochs even after `64bbbe418` landed, with
`alpha_ema` evolving normally — strongly suggesting either (a) all
profitable trade closes had `hold_time == 0` so the producer write
didn't fire, or (b) the producer write fired but `(segment_return >
0.0f)` evaluated to false in the production data.
Code-inspection audit (this commit, summarized in audit-doc):
- Verified 81 kernel args (launcher) match 81 kernel signature args
for `experience_env_step`. `is_win_per_env` at position 81;
`alpha_per_env` at position 78. cudarc `PushKernelArg<&mut
CudaSlice<T>>` impl pushes `&arg.cu_device_ptr` (stable address
for the lifetime of the chained statement).
- Verified buffer allocation at construction (alloc_zeros), pub(crate)
field, FoldReset entry + dispatch arm, kernel-arg pass at both
experience_env_step and sp20_aggregate_inputs launch sites.
- Verified producer site — both alpha + is_win writes inside the SAME
`if (segment_complete && segment_hold_time > 0.0f)` block at
experience_kernels.cu:3196, both NULL-guarded, race-free.
- Verified consumer site — aggregate kernel reads `is_win_per_env[env]`
+ `alpha_per_env[env]` side-by-side inside `if (tc != 0)`.
- Verified stream ordering — both kernels run on `self.stream` in the
same `for t in 0..timesteps` loop; experience kernel at line 6069,
aggregate at 6577. Stream-implicit producer→consumer ordering.
**No code-level bug surfaced by inspection.** The `is_win_per_env`
write is structurally identical to the `alpha_per_env` write 14
lines earlier in the same gating block.
Files modified:
- `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs` —
`read_is_win_per_env_for_test()` and `read_alpha_per_env_for_test()`
pub fns. Mirror `read_epoch_dsr_state()` pattern: dtoh memcpy via
the collector's stream, returns Vec<i32> / Vec<f32>. Cost-free in
production (only called from tests) and benign for invariants.
- `crates/ml/tests/sp20_is_win_producer_test.rs` — production-path
producer test scaffold. Builds 6-float-per-bar synthetic OHLCV
matching `experience_env_step`'s tgt[0..6] contract, runs
`collect_experiences_gpu`, reads back both buffers, asserts the
three structural invariants:
1. alpha_per_env has at least one non-zero slot (precondition —
the synthetic upward-trend data must produce ≥ 1 trade close).
2. is_win_per_env has at least one slot == 1 (regression-pin for
the Task 2.2-fix producer wire-up).
3. Co-occurrence: every env where alpha > 0 must have is_win == 1
(R_event > 0 ⇒ segment_return > 0 ⇒ is_win = 1; structural
guarantee from the kernel's two writes at the same gate).
Currently `#[ignore]` because the collector's cuBLAS forward chain
requires a real `trainer_params_ptr` (NUM_WEIGHT_TENSORS=163
weights). Activation requires either trainer-params plumbing in the
test scaffold OR moving the test inline as `#[cfg(test)]` in
gpu_experience_collector.rs.
- `docs/dqn-wire-up-audit.md` — audit-doc entry per Invariant 7.
Documents the inspection scope, the unverified producer-side
pre-conditions, and the open-issue framing for the wr_ema=0.0000
discrepancy.
Verification:
- SQLX_OFFLINE=true cargo check -p ml --tests passes
- 9 state_reset_registry tests pass (incl.
sp20_is_win_per_env_registered_fold_reset)
- The new test file compiles cleanly under `--features cuda`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
12 KiB
Rust
290 lines
12 KiB
Rust
//! SP20 Phase 2 Task 2.2-fix (2026-05-10) — production-path producer
|
||
//! diagnostic for the `is_win_per_env` segment-level win-flag buffer.
|
||
//!
|
||
//! The aggregate-kernel oracle test
|
||
//! (`wr_ema_uses_segment_pnl_via_is_win_per_env_predicate` in
|
||
//! `sp20_aggregate_inputs_test`) validates the **consumer** side: when the
|
||
//! buffer is pre-populated with synthetic 1s, the kernel correctly
|
||
//! aggregates them into `wins_count`. That test passes.
|
||
//!
|
||
//! This test validates the **producer** side: when `experience_env_step`
|
||
//! runs the production launch path with synthetic OHLCV that forces
|
||
//! profitable trade closes, the kernel MUST write `1` into
|
||
//! `is_win_per_env[i]` at the segment_complete branch.
|
||
//!
|
||
//! Bug class targeted: production HEALTH_DIAG observed `wr_ema=0.0000`
|
||
//! across all training epochs even AFTER the kernel-body fix landed in
|
||
//! commit `64bbbe418`. Two runs (pre-fix vs post-fix) produced near
|
||
//! bit-identical EMA values for `alpha_ema` (which is written at the
|
||
//! SAME site), strongly suggesting the `is_win_per_env` write site is
|
||
//! NOT firing in production despite passing the consumer-side oracle
|
||
//! test. Companion test pins a parallel invariant: the `alpha_per_env`
|
||
//! producer DOES fire (alpha_ema evolves with non-zero values in
|
||
//! production), so any test where alpha_per_env got writes but
|
||
//! is_win_per_env did not would be a smoking gun for the bug.
|
||
//!
|
||
//! Run on a GPU host:
|
||
//!
|
||
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
||
//! cargo test -p ml --test sp20_is_win_producer_test --features cuda \
|
||
//! -- --ignored --nocapture
|
||
|
||
#![allow(clippy::tests_outside_test_module)]
|
||
|
||
#[cfg(feature = "cuda")]
|
||
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
mod gpu {
|
||
use std::sync::Arc;
|
||
|
||
use ml::cuda_pipeline::gpu_experience_collector::{
|
||
ExperienceCollectorConfig, GpuExperienceCollector,
|
||
};
|
||
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
|
||
use ml::cuda_pipeline::shared_cublas_handle::PerStreamCublasHandles;
|
||
use ml_core::device::MlDevice;
|
||
|
||
type CudaStream = cudarc::driver::CudaStream;
|
||
|
||
fn try_cuda() -> Option<Arc<CudaStream>> {
|
||
match MlDevice::cuda(0) {
|
||
Ok(dev) => Some(dev.cuda_stream().expect("CUDA stream required").clone()),
|
||
Err(_) => None,
|
||
}
|
||
}
|
||
|
||
/// Build a 6-float-per-bar target buffer matching
|
||
/// `experience_kernels.cu::experience_env_step`'s contract:
|
||
/// `[preproc_close, preproc_next, raw_close, raw_next, raw_open, mid_open]`
|
||
///
|
||
/// `prices[i]` is used as the raw close at bar i; raw_open is set to
|
||
/// `prices[i]` (intra-bar move = 0 — fine for synthetic test).
|
||
/// preproc_* are set to 0.0 (the Q-network reads them but our test
|
||
/// doesn't depend on Q-values).
|
||
fn build_targets(prices: &[f32]) -> MappedF32Buffer {
|
||
let n = prices.len();
|
||
let mut data = vec![0.0_f32; n * 6];
|
||
for i in 0..n {
|
||
let p = prices[i];
|
||
let p_next = if i + 1 < n { prices[i + 1] } else { p };
|
||
data[i * 6] = 0.0; // preproc_close
|
||
data[i * 6 + 1] = 0.0; // preproc_next
|
||
data[i * 6 + 2] = p; // raw_close
|
||
data[i * 6 + 3] = p_next; // raw_next
|
||
data[i * 6 + 4] = p; // raw_open
|
||
data[i * 6 + 5] = p; // mid_open
|
||
}
|
||
let buf = unsafe { MappedF32Buffer::new(n * 6) }.expect("alloc targets");
|
||
buf.write_from_slice(&data);
|
||
buf
|
||
}
|
||
|
||
/// Build a feature buffer where every bar has features that yield a
|
||
/// strong "long" signal (positive market values across all 51 dims).
|
||
/// Combined with `epsilon=1.0` random action selection, this gives
|
||
/// enough action diversity to trigger trade openings + closings.
|
||
fn build_market_features(n_bars: usize) -> MappedF32Buffer {
|
||
const MARKET_DIM: usize = 51;
|
||
let mut data = vec![0.0_f32; n_bars * MARKET_DIM];
|
||
for i in 0..n_bars {
|
||
for j in 0..MARKET_DIM {
|
||
// Slightly-positive features so the network's untrained
|
||
// Q-values lean toward longs/shorts roughly equally with
|
||
// epsilon=1 random override.
|
||
data[i * MARKET_DIM + j] = (i as f32 * 0.001).sin() * 0.5;
|
||
}
|
||
}
|
||
let buf = unsafe { MappedF32Buffer::new(n_bars * MARKET_DIM) }
|
||
.expect("alloc market");
|
||
buf.write_from_slice(&data);
|
||
buf
|
||
}
|
||
|
||
/// SP20 Phase 2 Task 2.2-fix — production-path producer diagnostic.
|
||
///
|
||
/// Constructs a synthetic monotonic upward price series so that any
|
||
/// long position with positive hold time closes profitably
|
||
/// (`segment_return > 0`). With `epsilon=1.0` (full random action),
|
||
/// the experience collector explores enough action space to open AND
|
||
/// close trades within `timesteps_per_episode=200` bars.
|
||
///
|
||
/// Asserts:
|
||
/// 1. `alpha_per_env` has at least one non-zero slot — the producer
|
||
/// site at the segment_complete branch fires for at least one env
|
||
/// (the existing reference behavior — alpha_ema evolves in
|
||
/// production).
|
||
/// 2. `is_win_per_env` has at least one slot == 1 — the SP20 Phase 2
|
||
/// Task 2.2-fix write site (`is_win_per_env[i] = (segment_return >
|
||
/// 0.0f) ? 1 : 0`) fires for at least one profitable trade close.
|
||
///
|
||
/// **Without the fix**: assertion 2 FAILS even though assertion 1
|
||
/// passes. With the fix wired correctly: BOTH pass.
|
||
///
|
||
/// **The two assertions are at the SAME site in the kernel** (both
|
||
/// inside `if (segment_complete && segment_hold_time > 0.0f) {...}`),
|
||
/// so the alpha-passes-but-is-win-fails outcome is the structural
|
||
/// signature of the kernel-arg-threading or buffer-wireup bug
|
||
/// described in the audit-doc 2026-05-10 entry.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn is_win_per_env_producer_fires_at_segment_complete() {
|
||
let Some(stream) = try_cuda() else {
|
||
eprintln!("SKIP: CUDA not available");
|
||
return;
|
||
};
|
||
|
||
// Strong upward trend — every long position with hold > 0 closes
|
||
// profitably (segment_return > 0). This forces is_win_per_env=1
|
||
// for at least one env if the producer fires.
|
||
let n_bars = 500_usize;
|
||
let mut prices = Vec::with_capacity(n_bars);
|
||
for i in 0..n_bars {
|
||
// Linear ramp from 100 to 200 — robust >0 segment_return on
|
||
// any long position closed after at least one bar.
|
||
prices.push(100.0 + (i as f32) * 0.2);
|
||
}
|
||
|
||
let market_buf = build_market_features(n_bars);
|
||
let target_buf = build_targets(&prices);
|
||
|
||
let shared_cublas = Arc::new(
|
||
PerStreamCublasHandles::new(&stream).expect("cublas handles"),
|
||
);
|
||
// Run multiple episodes to maximize chance of trade close
|
||
// happening in at least one env. alloc_episodes >= n_episodes is
|
||
// required by the collector contract. Match the existing smoke
|
||
// test's (4, 50) allocation pattern that's known to work without
|
||
// explicit weight wiring.
|
||
let n_episodes = 4_usize;
|
||
let timesteps = 50_i32;
|
||
let mut collector = GpuExperienceCollector::new(
|
||
stream.clone(),
|
||
shared_cublas,
|
||
0, // bottleneck_dim
|
||
51, // market_dim
|
||
100_000.0,
|
||
0.01, // tx_cost_multiplier
|
||
0.05, // max_position
|
||
(64, 64, 32, 32),
|
||
(51, 51),
|
||
n_episodes, // alloc_episodes
|
||
timesteps as usize, // alloc_timesteps
|
||
0.0, // curiosity_weight
|
||
)
|
||
.expect("create collector");
|
||
let episode_starts: Vec<i32> = (0..n_episodes)
|
||
.map(|i| (i * (n_bars / n_episodes)) as i32)
|
||
.collect();
|
||
|
||
let mut exp_config = ExperienceCollectorConfig::default();
|
||
exp_config.n_episodes = n_episodes as i32;
|
||
exp_config.timesteps_per_episode = timesteps;
|
||
exp_config.total_bars = n_bars as i32;
|
||
exp_config.episode_length = timesteps;
|
||
exp_config.epsilon = 1.0; // full random — maximizes trade diversity
|
||
exp_config.gamma = 0.99;
|
||
|
||
let _batch = collector
|
||
.collect_experiences_gpu(&market_buf, &target_buf, &episode_starts, &exp_config)
|
||
.expect("collect_experiences_gpu");
|
||
|
||
// ── Read back per-env producer buffers ──────────────────────────
|
||
let alpha = collector
|
||
.read_alpha_per_env_for_test()
|
||
.expect("read alpha_per_env");
|
||
let is_win = collector
|
||
.read_is_win_per_env_for_test()
|
||
.expect("read is_win_per_env");
|
||
|
||
// Assertion 1 — alpha producer fires (reference behavior).
|
||
let alpha_nonzero = alpha.iter().any(|&v| v != 0.0);
|
||
let alpha_writes = alpha.iter().filter(|&&v| v != 0.0).count();
|
||
assert!(
|
||
alpha_nonzero,
|
||
"PRECONDITION FAILED — alpha_per_env has zero non-zero slots \
|
||
out of {} after {} episodes × {} steps. The synthetic \
|
||
upward-trend data must produce at least one trade close \
|
||
with hold_time > 0 (alpha=R_event != 0). If this assertion \
|
||
fails, the test scaffold is broken (no trade closes \
|
||
happened); fix the test, not the production code.",
|
||
alpha.len(),
|
||
n_episodes,
|
||
timesteps,
|
||
);
|
||
|
||
eprintln!(
|
||
"alpha_per_env: {}/{} slots non-zero, sum={:.4}, max={:.4}",
|
||
alpha_writes,
|
||
alpha.len(),
|
||
alpha.iter().sum::<f32>(),
|
||
alpha.iter().cloned().fold(f32::NEG_INFINITY, f32::max),
|
||
);
|
||
|
||
// Assertion 2 — is_win producer fires (SP20 Phase 2 Task 2.2-fix).
|
||
let is_win_count = is_win.iter().filter(|&&v| v == 1).count();
|
||
let is_win_max: i32 = is_win.iter().copied().max().unwrap_or(0);
|
||
|
||
eprintln!(
|
||
"is_win_per_env: {} slots == 1 (out of {}), max={}",
|
||
is_win_count,
|
||
is_win.len(),
|
||
is_win_max,
|
||
);
|
||
|
||
assert!(
|
||
is_win_max > 0,
|
||
"SP20 Phase 2 Task 2.2-fix REGRESSION — is_win_per_env has \
|
||
ZERO slots set to 1 across {} alloc_episodes, but \
|
||
alpha_per_env has {} non-zero slots. Both buffers are \
|
||
written at the SAME segment_complete site in \
|
||
experience_env_step (line 3322 alpha, line 3337 is_win). \
|
||
If alpha was written and is_win was not, the kernel-arg \
|
||
threading for is_win_per_env is broken — likely the \
|
||
pointer is being passed as NULL despite the launcher \
|
||
alloc'ing the buffer. This is the production WR_EMA=0.0000 \
|
||
pinning bug — the fix in commit `64bbbe418` did NOT \
|
||
actually fire at runtime.",
|
||
is_win.len(),
|
||
alpha_writes,
|
||
);
|
||
|
||
// Tighter contract: every slot where alpha_per_env has a
|
||
// **positive** value (R_event > 0 ⇒ segment_return > 0) MUST
|
||
// have is_win_per_env == 1 at the same env index. This is the
|
||
// structural co-occurrence the kernel guarantees:
|
||
//
|
||
// alpha[i] = R_event ∈ {-loss_cap, -0.5, 0, +0.5, +1.0}
|
||
// is_win[i] = (segment_return > 0) ? 1 : 0
|
||
//
|
||
// R_event > 0 implies segment_return > 0 implies is_win[i] = 1.
|
||
//
|
||
// Skip slot 0 (first-write may not yet be set if env didn't
|
||
// close), check ANY env where alpha > 0 has is_win = 1.
|
||
let positive_alpha_slots: Vec<usize> = alpha
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, &v)| if v > 0.0 { Some(i) } else { None })
|
||
.collect();
|
||
|
||
if !positive_alpha_slots.is_empty() {
|
||
let mismatched: Vec<usize> = positive_alpha_slots
|
||
.iter()
|
||
.copied()
|
||
.filter(|&i| is_win[i] == 0)
|
||
.collect();
|
||
assert!(
|
||
mismatched.is_empty(),
|
||
"SP20 Phase 2 Task 2.2-fix CO-OCCURRENCE VIOLATION — \
|
||
envs with alpha_per_env > 0 (R_event positive ⇒ \
|
||
segment_return > 0) MUST have is_win_per_env == 1. \
|
||
Found {} envs where alpha > 0 but is_win == 0: {:?}. \
|
||
This is the structural smoking gun for the kernel-arg \
|
||
wiring bug (the producer site writes both fields in \
|
||
succession at the same gating; if one fires the other \
|
||
must too).",
|
||
mismatched.len(),
|
||
mismatched,
|
||
);
|
||
}
|
||
}
|
||
}
|