Files
foxhunt/crates/ml/tests/sp20_is_win_producer_test.rs
jgrusewski 13ce783853 test(sp20): post-fix runtime diagnostic — read-back accessors + producer test scaffold
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>
2026-05-10 02:22:15 +02:00

290 lines
12 KiB
Rust
Raw Permalink 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.
//! 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,
);
}
}
}