fix(tests): repair 14 pre-existing test failures across ml crate
Lib test suite was at 14 failures from accumulated layout/contract drift.
Fixed each by tracing root cause; lib suite now 1016 passing / 0 failing.
Failures fixed (test → root cause → fix):
1. sp14_isv_slots::sp20_isv_slots_reserved_510_to_520 — ISV_TOTAL_DIM
pin drifted from SP20-era 520 to current 538 via SP21/SP22 H6 bus
growth. Slot positions 510-519 still pinned. Fix: relax total-dim
check to >= 520; keep slot-position asserts tight.
2-3. gradient_budget::test_spectral_norm_all_heads_no_panic +
test_spectral_norm_constrains_operator_norm — test config used
cfg.state_dim=16 but trainer uses STATE_DIM=128 when bottleneck
off; also DuelingWeightBacking slices [2]/[3] used pre-GRN w_s2
shape, but post-GRN it's w_b_h_s1 [2*SH1, SH1] = 2048. Fix: add
s1_input_dim_for_test helper; correct slice sizes to GRN layout.
4-9. dqn::trainer::tests::test_feature_vector_to_state +
test_single_sample_batch + test_batched_action_selection +
test_batched_vs_sequential_action_selection_consistency +
test_batch_size_mismatch_larger/smaller_than_configured —
feature_vector_to_state wrapper falsely advertised "no OFI" by
signature but body required strict OFI. Contract violation —
broke 6 unit tests + hyperopt's public convert_to_state APIs.
Fix: wrapper now genuinely produces 45-dim no-OFI state; OFI-
strict callers use _with_ofi with Some(idx).
10. state_kl_monitor::observe_tracks_fire_rate_on_change — last_amp
initialized to 1.0 coincidentally equaled first observation value,
silently suppressing first fire. Test expected first observation
always fires (cold-start semantic). Fix: Option<f32> sentinel —
None means "no prior baseline" → first observe ALWAYS fires.
11. cuda_pipeline::tests::test_eval_action_select_thompson_picks_
proportionally — test launched experience_action_select kernel
with 1 missing arg (v_logits_dir from SP17 Commit C). Kernel
read garbage pointer → SIGSEGV. Also threshold 0.70 calibrated
for pre-SP17 raw-A distribution; post-SP17 sampler uses mean-zero
softmax(V + A_centered), Long still dominates ~4× but P(Long)
drops to ~0.66. Fix: add zero-filled v_logits_buf at correct
arg position; recalibrate threshold 0.70 → 0.60.
12. cuda_pipeline::tests::test_ppo_gpu_data_upload — flaked in parallel
test run with CUDA_ERROR_INVALID_CONTEXT from MappedF32Buffer's
cudaHostAlloc. cuda_stream() test helper used OnceLock to share
a CudaStream but didn't bind_to_thread on subsequent calls. CUDA
contexts are per-thread state. Fix: helper now calls bind_to_
thread() on every invocation (idempotent — mirrors trainer/
constructor.rs:111's pattern).
13. ppo::tests::test_reward_computation — test created hold action
with ExposureLevel::Flat but is_hold() matches only Hold (Flat
= close position = transaction with cost). So hold and buy got
same transaction-cost reduction; reward_hold > reward_buy assert
failed. Fix: use ExposureLevel::Hold for no-cost hold semantic.
14. training_profile::tests::test_production_profile_applies_all_
sections — pinned n_steps==5 and tau==0.005 (pre-TD(0)); production
flipped to n_steps=1, tau=0.01 per dqn-production.toml ("dense
micro-rewards cancel over n>1 bars; faster target tracking for
TD(0)"). Fix: update pins to current production values.
All 14 fixes target root causes — no #[ignore] masking. Verified:
- Lib-only run: cargo test -p ml --lib → 1016/0 (passed/failed)
- Stash-test pre-changes: 1001/15 (confirms 14 fixed atomically)
Remaining flake under `cargo test -p ml --tests`:
- ensemble::adapters::dqn::tests::test_dqn_checkpoint_round_trip —
CUDA-context-race-adjacent flake under parallel `--tests` mode;
passes in isolation and in `--lib` mode (single binary). Predates
this commit; deferred as separate triage.
Audit: docs/dqn-wire-up-audit.md "Fix sweep" section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -928,12 +928,26 @@ mod tests {
|
||||
static SHARED_STREAM: OnceLock<Arc<CudaStream>> = OnceLock::new();
|
||||
|
||||
fn cuda_stream() -> Arc<CudaStream> {
|
||||
Arc::clone(SHARED_STREAM.get_or_init(|| {
|
||||
let stream = Arc::clone(SHARED_STREAM.get_or_init(|| {
|
||||
let ctx = cudarc::driver::CudaContext::new(0)
|
||||
.expect("CUDA context required for cuda_pipeline tests");
|
||||
ctx.new_stream()
|
||||
.expect("CUDA stream required for cuda_pipeline tests")
|
||||
}))
|
||||
}));
|
||||
// Push the CUDA context onto the CURRENT thread. CUDA contexts
|
||||
// are per-thread state; when `cargo test` runs tests in parallel,
|
||||
// each test thread that calls `cuda_stream()` after the initial
|
||||
// `OnceLock` init lands without the context current → any CUDA
|
||||
// call (notably `MappedF32Buffer::new`'s `cudaHostAlloc`) fails
|
||||
// with `CUDA_ERROR_INVALID_CONTEXT`. Pattern mirrors
|
||||
// `trainers/dqn/trainer/constructor.rs::111` which calls
|
||||
// `bind_to_thread` on the trainer construction path for the same
|
||||
// reason. Idempotent — calling on a thread that already has the
|
||||
// context current is a no-op.
|
||||
stream.context()
|
||||
.bind_to_thread()
|
||||
.expect("bind CUDA context to test thread");
|
||||
stream
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1365,6 +1379,20 @@ mod tests {
|
||||
}
|
||||
let support_buf = stream.memcpy_stod(&support_host).expect("upload support");
|
||||
|
||||
// SP17 Commit C (2026-05-08): v_logits_dir [batch, n_atoms] — V-head
|
||||
// logits row required by the Thompson direction selector. Required
|
||||
// by the kernel (no NULL fallback) because the SP17 mean-zero
|
||||
// contract samples from `softmax(V[z] + A_centered[a, z])`. Without
|
||||
// V, the sampler would respond to a different distribution than
|
||||
// E[Q] / the Bellman target. Zero-fill is fine for this test: the
|
||||
// C51 logits already encode the desired direction-conditional Q
|
||||
// distribution; V=0 just means the sampler reads the bare A
|
||||
// distribution (which IS what the historical test expected
|
||||
// pre-SP17). Without this buffer the kernel reads stale stack
|
||||
// memory for v_logits_dir → SIGSEGV.
|
||||
let v_logits_host = vec![0.0_f32; batch * (n_atoms as usize)];
|
||||
let v_logits_buf = stream.memcpy_stod(&v_logits_host).expect("upload v_logits");
|
||||
|
||||
// SP10: ISV buffer with EVAL_THOMPSON_TEMP_INDEX set to 1.0
|
||||
// (pure Thompson). All other slots zero — kernel only reads
|
||||
// ISV_EVAL_THOMPSON_TEMP_IDX in the SP10 path; the conviction
|
||||
@@ -1416,6 +1444,8 @@ mod tests {
|
||||
.arg(&mut mag_conv_buf)
|
||||
// Plan C T2 amendment buffers (direction-branch Thompson):
|
||||
.arg(&b_logits_buf) // b_logits_dir [batch, b0, n_atoms]
|
||||
// SP17 Commit C: v_logits_dir [batch, n_atoms] — required.
|
||||
.arg(&v_logits_buf)
|
||||
.arg(&support_buf) // per_sample_support [batch, b0, 3]
|
||||
.arg(&null_ptr) // atom_positions = NULL → linear support
|
||||
.arg(&n_atoms) // n_atoms
|
||||
@@ -1441,22 +1471,30 @@ mod tests {
|
||||
let p_long = dir_hist[2] as f32 / total;
|
||||
let p_flat = dir_hist[3] as f32 / total;
|
||||
eprintln!(
|
||||
"eval_action_select Thompson-eval histogram (τ=1.0): short={:.3} hold={:.3} long={:.3} flat={:.3} (expected: P(Long) ≥ 0.70 — clear Q gap)",
|
||||
"eval_action_select Thompson-eval histogram (τ=1.0): short={:.3} hold={:.3} long={:.3} flat={:.3} (expected: P(Long) ≥ 0.60 — clear Q gap post-SP17 centering)",
|
||||
p_short, p_hold, p_long, p_flat,
|
||||
);
|
||||
|
||||
// Pure Thompson (τ=1.0) with a clear Q gap (E[Q_long]=+0.5 vs
|
||||
// second-best E[Q_flat]=+0.1, gap = 0.4): Long should dominate
|
||||
// proportional to its posterior overlap. Single-atom peak with
|
||||
// logit=+5 vs zeros gives near-deterministic q_sample = target_v
|
||||
// for each direction; the per-direction blend `q_eff = E[Q] +
|
||||
// τ·(q_sample − E[Q])` collapses to q_sample when τ=1.0, so
|
||||
// argmax(q_sample) ≈ argmax(target_v) = Long. Allow some
|
||||
// stochastic noise from finite-precision Philox draws at atom
|
||||
// tail bins (≥ 70% threshold leaves room for tie-break drift).
|
||||
// proportional to its posterior overlap.
|
||||
//
|
||||
// SP17 Commit C (2026-05-08) re-calibration: the Thompson direction
|
||||
// sampler now reads `softmax(V[z] + A_centered[a, z])` where
|
||||
// A_centered subtracts the per-z mean across actions. With V=0
|
||||
// (zero-init test buffer) and one-hot logits at per-direction
|
||||
// peaks, A_centered's relative gap is smaller than raw A's (each
|
||||
// peak contributes 1/4 to the mean it subtracts), so the post-
|
||||
// softmax mass at Long's peak is correspondingly diluted. The
|
||||
// pre-SP17 threshold of 0.70 reflected the un-centered raw-A
|
||||
// distribution; post-SP17 the observed P(Long) ≈ 0.66, still
|
||||
// ~4× the next-highest direction (Flat ≈ 0.17). Threshold
|
||||
// lowered to 0.60 to track the new contract — Long must still
|
||||
// CLEARLY dominate; the test's invariant is "argmax respects
|
||||
// the Q-gap", not "argmax is near-deterministic".
|
||||
assert!(
|
||||
p_long >= 0.70,
|
||||
"P(Long) = {p_long:.3} should be ≥ 0.70 at τ=1.0 with clear Q gap. Hist: short={p_short:.3} hold={p_hold:.3} long={p_long:.3} flat={p_flat:.3}",
|
||||
p_long >= 0.60,
|
||||
"P(Long) = {p_long:.3} should be ≥ 0.60 at τ=1.0 with clear Q gap (post-SP17 mean-zero contract). Hist: short={p_short:.3} hold={p_hold:.3} long={p_long:.3} flat={p_flat:.3}",
|
||||
);
|
||||
// Permanent stochasticity: even with τ=1.0 and a clear gap, no
|
||||
// single direction wins all samples — the kernel is sampling.
|
||||
|
||||
@@ -1390,8 +1390,27 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sp20_isv_slots_reserved_510_to_520() {
|
||||
// SP20-era slot positions [510..520) MUST stay pinned at their
|
||||
// original indices — every consumer reads these by const name but
|
||||
// accidental renumbering during ISV bus growth would silently
|
||||
// re-aim every consumer at the wrong slot. The pin tests below
|
||||
// catch that drift; if any slot moves, that's a `feedback_no_partial
|
||||
// _refactor` violation (all consumers must migrate atomically).
|
||||
//
|
||||
// The ISV_TOTAL_DIM bound check uses `>=` instead of `==` because
|
||||
// subsequent phases legitimately grow the bus (SP21 added slots
|
||||
// [520..528), SP22 H6 added [536..538) — see `gpu_dqn_trainer.rs`
|
||||
// ISV_TOTAL_DIM comment for the full growth log). The invariant
|
||||
// this test enforces is "SP20 slots fit within whatever current
|
||||
// ISV_TOTAL_DIM is", not "ISV bus is exactly 520".
|
||||
use crate::cuda_pipeline::gpu_dqn_trainer::ISV_TOTAL_DIM;
|
||||
assert_eq!(ISV_TOTAL_DIM, 520);
|
||||
assert!(
|
||||
ISV_TOTAL_DIM >= 520,
|
||||
"ISV bus shrank below SP20 reservation — \
|
||||
ISV_TOTAL_DIM={ISV_TOTAL_DIM} < 520 means an SP20 slot is \
|
||||
out-of-bounds. Bus growth log: see gpu_dqn_trainer.rs \
|
||||
ISV_TOTAL_DIM comment."
|
||||
);
|
||||
assert_eq!(LOSS_CAP_INDEX, 510);
|
||||
assert_eq!(ALPHA_EMA_INDEX, 511);
|
||||
assert_eq!(WR_EMA_INDEX, 512);
|
||||
|
||||
@@ -20,13 +20,20 @@ use crate::trainers::dqn::adaptive_monitor::{
|
||||
};
|
||||
|
||||
pub(crate) struct StateKLMonitor {
|
||||
last_amp: f32,
|
||||
/// `None` BEFORE the first observation — encoded as a sentinel so the
|
||||
/// first `observe()` call ALWAYS fires (cold-start "we have nothing
|
||||
/// to compare against, so call this a fire" semantic). Previously
|
||||
/// initialized to `1.0` which coincidentally equals the typical
|
||||
/// initial amplification value and silently suppressed the first
|
||||
/// fire — the `observe_tracks_fire_rate_on_change` test caught
|
||||
/// this regression once exercised.
|
||||
last_amp: Option<f32>,
|
||||
fire_rate: FireRateStats,
|
||||
}
|
||||
|
||||
impl StateKLMonitor {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { last_amp: 1.0, fire_rate: FireRateStats::default() }
|
||||
Self { last_amp: None, fire_rate: FireRateStats::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +52,12 @@ impl AdaptiveMonitor for StateKLMonitor {
|
||||
}
|
||||
|
||||
fn observe(&mut self, current: f32) {
|
||||
let fired = (current - self.last_amp).abs() > 1e-7;
|
||||
let fired = match self.last_amp {
|
||||
None => true, // cold-start fire
|
||||
Some(prev) => (current - prev).abs() > 1e-7,
|
||||
};
|
||||
self.fire_rate.record_fire(fired);
|
||||
self.last_amp = current;
|
||||
self.last_amp = Some(current);
|
||||
}
|
||||
|
||||
fn fire_rate(&self) -> &FireRateStats {
|
||||
|
||||
@@ -37,20 +37,40 @@ fn test_config() -> GpuDqnTrainConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of `compute_param_sizes`' `s1_input_dim` logic — when bottleneck
|
||||
/// is off, w_s1's input dim is `STATE_DIM` (not `cfg.state_dim`, which is
|
||||
/// vestigial in the bottleneck-off path). The test_config sets
|
||||
/// `bottleneck_dim: 0` so this always returns STATE_DIM.
|
||||
fn s1_input_dim_for_test(_cfg: &GpuDqnTrainConfig) -> usize {
|
||||
ml_core::state_layout::STATE_DIM
|
||||
}
|
||||
|
||||
/// Allocate a DuelingWeightSet with random values (not zero — spectral norm
|
||||
/// needs non-degenerate matrices for power iteration to converge).
|
||||
/// Returns both the backing storage (keeps GPU memory alive) and the pointer view.
|
||||
fn alloc_dueling(stream: &Arc<cudarc::driver::CudaStream>, cfg: &GpuDqnTrainConfig) -> (DuelingWeightBacking, DuelingWeightSet) {
|
||||
let na = cfg.num_atoms;
|
||||
let s1_in = s1_input_dim_for_test(cfg);
|
||||
let alloc = |n: usize| -> cudarc::driver::CudaSlice<f32> {
|
||||
// Fill with 0.1 (non-zero, non-degenerate for spectral norm)
|
||||
let bf16_data: Vec<f32> = vec![0.1; n];
|
||||
stream.clone_htod(&bf16_data).expect("alloc dueling weight")
|
||||
};
|
||||
// DuelingWeightBacking slices [0..12) map to flat-buffer indices via
|
||||
// `DUELING_FLAT_INDICES`. After the GRN refactor:
|
||||
// slice[0] → flat[0] w_a_h_s1 [SH1, s1_in]
|
||||
// slice[1] → flat[1] b_a_h_s1 [SH1]
|
||||
// slice[2] → flat[2] w_b_h_s1 [2*SH1, SH1] (Linear_b → GLU split)
|
||||
// slice[3] → flat[3] b_b_h_s1 [2*SH1]
|
||||
// slice[4..8) → flat[13..17) value head (post-GRN reshuffle)
|
||||
// slice[8..12) → flat[17..21) branch 0 head
|
||||
// Sizes MUST match `compute_param_sizes` exactly or flatten_online_weights
|
||||
// rejects with "size mismatch" because flat-buf copies use the flat
|
||||
// sizes, not the slice sizes.
|
||||
let backing = DuelingWeightBacking {
|
||||
slices: [
|
||||
alloc(cfg.shared_h1 * cfg.state_dim), alloc(cfg.shared_h1),
|
||||
alloc(cfg.shared_h2 * cfg.shared_h1), alloc(cfg.shared_h2),
|
||||
alloc(cfg.shared_h1 * s1_in), alloc(cfg.shared_h1),
|
||||
alloc((2 * cfg.shared_h1) * cfg.shared_h1), alloc(2 * cfg.shared_h1),
|
||||
alloc(cfg.value_h * cfg.shared_h2), alloc(cfg.value_h),
|
||||
alloc(na * cfg.value_h), alloc(na),
|
||||
// w_b0fc input dim grew to SH2+1 in SP14 (aux→Q wire); bias unchanged.
|
||||
@@ -106,7 +126,7 @@ fn test_spectral_norm_all_heads_no_panic() -> anyhow::Result<()> {
|
||||
trainer.unflatten_online_weights(&dueling, &branching)?;
|
||||
|
||||
// Verify a few weights are still finite after normalization (f32 -> f32 readback)
|
||||
let mut w_s1_bf16 = vec![0.0_f32; cfg.shared_h1 * cfg.state_dim];
|
||||
let mut w_s1_bf16 = vec![0.0_f32; cfg.shared_h1 * s1_input_dim_for_test(&cfg)];
|
||||
stream.memcpy_dtoh(&d_backing.slices[0], &mut w_s1_bf16)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let w_s1_host: Vec<f32> = w_s1_bf16.to_vec();
|
||||
@@ -152,10 +172,14 @@ fn test_spectral_norm_constrains_operator_norm() -> anyhow::Result<()> {
|
||||
stream.clone_htod(&bf16_data).expect("alloc large weight")
|
||||
};
|
||||
let na = cfg.num_atoms;
|
||||
let s1_in = s1_input_dim_for_test(&cfg);
|
||||
// See alloc_dueling above for the DUELING_FLAT_INDICES slot → flat-
|
||||
// buffer layout mapping. slices[2]/[3] are w_b_h_s1/b_b_h_s1 [2*SH1,
|
||||
// SH1] / [2*SH1] in the GRN-expanded layout, not w_s2/b_s2.
|
||||
let d_backing = DuelingWeightBacking {
|
||||
slices: [
|
||||
alloc_large(cfg.shared_h1 * cfg.state_dim), alloc_large(cfg.shared_h1),
|
||||
alloc_large(cfg.shared_h2 * cfg.shared_h1), alloc_large(cfg.shared_h2),
|
||||
alloc_large(cfg.shared_h1 * s1_in), alloc_large(cfg.shared_h1),
|
||||
alloc_large((2 * cfg.shared_h1) * cfg.shared_h1), alloc_large(2 * cfg.shared_h1),
|
||||
alloc_large(cfg.value_h * cfg.shared_h2), alloc_large(cfg.value_h),
|
||||
alloc_large(na * cfg.value_h), alloc_large(na),
|
||||
// w_b0fc input dim grew to SH2+1 in SP14 (aux→Q wire); bias unchanged.
|
||||
@@ -189,12 +213,16 @@ fn test_spectral_norm_constrains_operator_norm() -> anyhow::Result<()> {
|
||||
}
|
||||
trainer.unflatten_online_weights(&dueling, &branching)?;
|
||||
|
||||
// Check W_s1 [32, 16]: Frobenius norm of a rank-1 matrix with σ_max=3.0
|
||||
// would be 3.0. A uniform matrix of 5.0 has σ = 5*sqrt(rows*cols),
|
||||
// after spectral norm it should have σ ≤ 3.0.
|
||||
// Check W_s1 [shared_h1, s1_input_dim]: Frobenius norm of a rank-1
|
||||
// matrix with σ_max=3.0 would be 3.0. A uniform matrix of 5.0 has
|
||||
// σ = 5*sqrt(rows*cols), after spectral norm it should have σ ≤ 3.0.
|
||||
// Proxy check: Frobenius norm should be significantly reduced from initial.
|
||||
//
|
||||
// s1_input_dim is STATE_DIM (128) when bottleneck is off — NOT
|
||||
// cfg.state_dim (which is vestigial in the bottleneck-off path).
|
||||
// See `s1_input_dim_for_test` for the canonical formula.
|
||||
let rows = cfg.shared_h1;
|
||||
let cols = cfg.state_dim;
|
||||
let cols = s1_input_dim_for_test(&cfg);
|
||||
let mut w_bf16 = vec![0.0_f32; rows * cols];
|
||||
stream.memcpy_dtoh(&d_backing.slices[0], &mut w_bf16)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
@@ -28,12 +28,66 @@ impl DQNTrainer {
|
||||
/// # Bug #4 Fix
|
||||
///
|
||||
/// Added close_price parameter to enable portfolio feature population from PortfolioTracker.
|
||||
/// No-OFI variant: produces a 45-dim TradingState (4 price + 38 market
|
||||
/// + 3 portfolio) with empty regime_features. Used by hyperopt backtest
|
||||
/// integration and unit tests where OFI features aren't loaded into
|
||||
/// the trainer.
|
||||
///
|
||||
/// **OFI-strict callers** must use `feature_vector_to_state_with_ofi`
|
||||
/// with a non-None `ofi_index`; that path enforces the production
|
||||
/// "OFI features required" invariant per
|
||||
/// `training_loop.rs::2162`'s collector init check.
|
||||
///
|
||||
/// The previous implementation of this wrapper delegated to
|
||||
/// `_with_ofi(..., None)` which always errored because the strict
|
||||
/// variant unconditionally requires both `ofi_features` AND
|
||||
/// `ofi_index` to be present. That broke every caller of this
|
||||
/// wrapper (hyperopt + 6 unit tests) and was a silent contract
|
||||
/// violation: the wrapper's signature advertises "no OFI" but the
|
||||
/// body required OFI. Fixed by genuinely producing a no-OFI state
|
||||
/// here.
|
||||
pub(crate) fn feature_vector_to_state(
|
||||
&self,
|
||||
feature_vec: &FeatureVector,
|
||||
close_price: Option<rust_decimal::Decimal>,
|
||||
) -> Result<TradingState> {
|
||||
self.feature_vector_to_state_with_ofi(feature_vec, close_price, None)
|
||||
let normalized_features: Vec<f32> = feature_vec.iter().map(|&v| v as f32).collect();
|
||||
|
||||
assert_eq!(
|
||||
normalized_features.len(),
|
||||
42,
|
||||
"Expected 42 market features (got {})",
|
||||
normalized_features.len()
|
||||
);
|
||||
|
||||
let price_features: Vec<f32> = vec![
|
||||
normalized_features[0],
|
||||
normalized_features[1],
|
||||
normalized_features[2],
|
||||
normalized_features[3],
|
||||
];
|
||||
let market_features: Vec<f32> = normalized_features[4..42].to_vec();
|
||||
let technical_indicators = vec![];
|
||||
|
||||
let portfolio_features = if let Some(price) = close_price {
|
||||
let price_f32 = price.to_f32().expect("trading price fits f32");
|
||||
self.portfolio_tracker
|
||||
.get_portfolio_features(price_f32)
|
||||
.to_vec()
|
||||
} else {
|
||||
vec![0.0, 0.0, 0.0]
|
||||
};
|
||||
|
||||
// No-OFI path: empty regime_features → state.dimension() = 45.
|
||||
let regime_features: Vec<f32> = vec![];
|
||||
|
||||
Ok(TradingState::from_normalized(
|
||||
price_features,
|
||||
technical_indicators,
|
||||
market_features,
|
||||
portfolio_features,
|
||||
regime_features,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn feature_vector_to_state_with_ofi(
|
||||
|
||||
@@ -115,16 +115,18 @@ async fn test_feature_vector_to_state() {
|
||||
);
|
||||
|
||||
let state = state.unwrap();
|
||||
// State dimension depends on whether OFI (MBP-10) data dirs are configured:
|
||||
// - Without OFI: 45 = 42 market + 3 portfolio
|
||||
// - With OFI: 65 = 42 market + 20 OFI + 3 portfolio
|
||||
// Smoketest TOML sets mbp10_data_dir, so OFI is enabled.
|
||||
let expected_dim = if trainer.hyperparams.mbp10_data_dir.is_empty() { 45 } else { 65 };
|
||||
// `feature_vector_to_state` is the no-OFI wrapper — it produces a
|
||||
// 45-dim state (4 price + 38 market + 3 portfolio) with empty
|
||||
// regime_features. The `mbp10_data_dir` config bit is independent
|
||||
// of actual OFI loading: the trainer's `ofi_features` field is None
|
||||
// in this test setup regardless of the config (test scaffolding
|
||||
// doesn't load MBP-10 data). The OFI-strict variant is
|
||||
// `feature_vector_to_state_with_ofi(..., Some(idx))` which is what
|
||||
// production training_loop uses.
|
||||
assert_eq!(
|
||||
state.dimension(),
|
||||
expected_dim,
|
||||
"State dimension mismatch (OFI enabled={})",
|
||||
!trainer.hyperparams.mbp10_data_dir.is_empty()
|
||||
45,
|
||||
"no-OFI wrapper must produce 45-dim state (4 price + 38 market + 3 portfolio)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1228,7 +1228,13 @@ mod tests {
|
||||
let params = create_test_params();
|
||||
let trainer = PpoTrainer::new(params, 64, "/tmp/ppo_checkpoints", true, None).unwrap();
|
||||
|
||||
let hold = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
|
||||
// SEMANTIC NOTE: ExposureLevel::Hold (keep position) is the true
|
||||
// "no transaction" signal — ExposureLevel::Flat (close position)
|
||||
// counts as a transaction and incurs the order-type cost via
|
||||
// `FactoredAction::is_hold()`'s `matches!(self, Hold)` check. The
|
||||
// test asserts `reward_hold > reward_buy` so the hold action must
|
||||
// use the no-cost variant.
|
||||
let hold = FactoredAction::new(ExposureLevel::Hold, OrderType::Market, Urgency::Normal);
|
||||
let buy = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||||
let sell = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
|
||||
|
||||
|
||||
@@ -1478,8 +1478,14 @@ mod tests {
|
||||
assert!((hp.reward_scale - 1.0).abs() < 0.01);
|
||||
|
||||
// [advanced] section — new fields
|
||||
assert_eq!(hp.n_steps, 5);
|
||||
assert!((hp.tau - 0.005).abs() < 0.0001);
|
||||
// n_steps = 1 (TD(0)): dense micro-rewards cancel over n>1 bars
|
||||
// per config/training/dqn-production.toml. Previously this test
|
||||
// pinned 5, drifted out of sync when the profile flipped to TD(0).
|
||||
assert_eq!(hp.n_steps, 1);
|
||||
// tau = 0.01: faster target tracking for TD(0). Previously 0.007
|
||||
// (and earlier 0.005); the profile comment notes the flip when
|
||||
// TD(0) was adopted.
|
||||
assert!((hp.tau - 0.01).abs() < 0.0001);
|
||||
assert_eq!(hp.c51_warmup_epochs, 0);
|
||||
assert!((hp.her_ratio - 0.2).abs() < 0.01);
|
||||
assert!((hp.iqn_lambda - 0.25).abs() < 0.01);
|
||||
|
||||
@@ -17840,3 +17840,25 @@ Third Rust-side commit of Phase B. Adds 11 buffer fields + 2 orchestrator ops ha
|
||||
**Phase B3 next**: collector-side rollout buffers (forward + label producer launches on every step alongside the existing K=2 head's launches). Also wires the per-env softmax → per-(i, t) softmax fan-out scatter so the trainer's `aux_to_softmax_buf` gets populated from the rollout's `exp_aux_to_softmax_buf`.
|
||||
|
||||
Cargo check clean (21 warnings, none new on the new fields).
|
||||
|
||||
#### Fix sweep — 14 pre-existing lib test failures (2026-05-14)
|
||||
|
||||
Before continuing the SP22 vNext B-series work, repaired 14 accumulated test failures across the ml crate that pre-dated the vNext effort. Each was traced to a specific root cause (layout drift, contract bug, or CUDA-threading issue) rather than masked with `#[ignore]`. Lib test suite now 1016 passing / 0 failing.
|
||||
|
||||
**Test → root cause → fix table**:
|
||||
|
||||
| Test | Category | Root cause | Fix |
|
||||
|------|----------|-----------|-----|
|
||||
| `sp14_isv_slots::sp20_isv_slots_reserved_510_to_520` | Pin drift | `ISV_TOTAL_DIM == 520` (SP20 era), bus grew to 538 via SP21/SP22 H6 | Relax to `>= 520`; keep slot-position pins tight |
|
||||
| `gradient_budget::test_spectral_norm_all_heads_no_panic` | Layout drift | Used `cfg.state_dim=16` for w_s1; trainer uses STATE_DIM=128 when bottleneck off. Also slice[2]/[3] used pre-GRN `w_s2` shape; post-GRN is `w_b_h_s1 [2*SH1, SH1]` | `s1_input_dim_for_test` helper; corrected slice sizes |
|
||||
| `gradient_budget::test_spectral_norm_constrains_operator_norm` | Layout drift | Same as above | Same |
|
||||
| `dqn::trainer::tests::test_feature_vector_to_state` (+ 5 sibling tests) | Contract bug | Wrapper `feature_vector_to_state` advertised "no OFI" by signature but body required strict OFI — wrapper unconditionally errored | Wrapper now produces genuine 45-dim no-OFI state; OFI-strict callers use `_with_ofi` |
|
||||
| `state_kl_monitor::observe_tracks_fire_rate_on_change` | Initialization sentinel | `last_amp: 1.0` coincidentally equaled first observation; first fire silently suppressed | `Option<f32>` cold-start sentinel — None means "first observe always fires" |
|
||||
| `cuda_pipeline::tests::test_eval_action_select_thompson_picks_proportionally` | Kernel ABI drift + threshold drift | Missing `v_logits_dir` arg from SP17 Commit C → SIGSEGV; threshold 0.70 calibrated for raw-A distribution, post-SP17 sampler uses mean-zero `V + A_centered` | Add `v_logits_buf` zero-fill alloc + arg; recalibrate to 0.60 |
|
||||
| `cuda_pipeline::tests::test_ppo_gpu_data_upload` | CUDA threading | `cuda_stream()` test helper didn't `bind_to_thread` per-test; parallel test threads got `CUDA_ERROR_INVALID_CONTEXT` from `MappedF32Buffer::new`'s `cudaHostAlloc` | Helper now calls `bind_to_thread()` on every invocation (idempotent) |
|
||||
| `ppo::tests::test_reward_computation` | Semantic mismatch | Test used `ExposureLevel::Flat` for "hold" but `is_hold()` matches only `ExposureLevel::Hold` (Flat = close = transaction) | Use `Hold` for no-cost hold semantic |
|
||||
| `training_profile::tests::test_production_profile_applies_all_sections` | Config drift | Pinned `n_steps==5` and `tau==0.005`; production flipped to TD(0): `n_steps=1`, `tau=0.01` per `dqn-production.toml` | Update pins to current production values |
|
||||
|
||||
**Result**: `cargo test -p ml --lib` → 1016 passing, 0 failing, 55 ignored (unchanged data-missing / hardware-gated). The remaining single flake in `--tests` mode (`test_dqn_checkpoint_round_trip`) pre-dates this commit and is a parallel-test CUDA-context-race adjacent issue — deferred for separate triage.
|
||||
|
||||
This fix sweep restores green-baseline before continuing Phase B3 wireup.
|
||||
|
||||
Reference in New Issue
Block a user