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:
jgrusewski
2026-05-14 01:08:16 +02:00
parent b98dc2730d
commit ebc1b15023
9 changed files with 223 additions and 38 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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}"))?;

View File

@@ -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(

View File

@@ -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)"
);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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.