diff --git a/crates/ml/src/cuda_pipeline/mod.rs b/crates/ml/src/cuda_pipeline/mod.rs index bd3382311..4d02933f4 100644 --- a/crates/ml/src/cuda_pipeline/mod.rs +++ b/crates/ml/src/cuda_pipeline/mod.rs @@ -928,12 +928,26 @@ mod tests { static SHARED_STREAM: OnceLock> = OnceLock::new(); fn cuda_stream() -> Arc { - 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. diff --git a/crates/ml/src/cuda_pipeline/sp14_isv_slots.rs b/crates/ml/src/cuda_pipeline/sp14_isv_slots.rs index cc523f8bc..d98e758a8 100644 --- a/crates/ml/src/cuda_pipeline/sp14_isv_slots.rs +++ b/crates/ml/src/cuda_pipeline/sp14_isv_slots.rs @@ -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); diff --git a/crates/ml/src/trainers/dqn/monitors/state_kl_monitor.rs b/crates/ml/src/trainers/dqn/monitors/state_kl_monitor.rs index 24f8ea50f..c83003684 100644 --- a/crates/ml/src/trainers/dqn/monitors/state_kl_monitor.rs +++ b/crates/ml/src/trainers/dqn/monitors/state_kl_monitor.rs @@ -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, 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 { diff --git a/crates/ml/src/trainers/dqn/smoke_tests/gradient_budget.rs b/crates/ml/src/trainers/dqn/smoke_tests/gradient_budget.rs index e74124c68..17694929e 100644 --- a/crates/ml/src/trainers/dqn/smoke_tests/gradient_budget.rs +++ b/crates/ml/src/trainers/dqn/smoke_tests/gradient_budget.rs @@ -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, 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 { // Fill with 0.1 (non-zero, non-degenerate for spectral norm) let bf16_data: Vec = 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 = 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}"))?; diff --git a/crates/ml/src/trainers/dqn/trainer/state.rs b/crates/ml/src/trainers/dqn/trainer/state.rs index 4874ad539..25502f9c9 100644 --- a/crates/ml/src/trainers/dqn/trainer/state.rs +++ b/crates/ml/src/trainers/dqn/trainer/state.rs @@ -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, ) -> Result { - self.feature_vector_to_state_with_ofi(feature_vec, close_price, None) + let normalized_features: Vec = 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 = vec![ + normalized_features[0], + normalized_features[1], + normalized_features[2], + normalized_features[3], + ]; + let market_features: Vec = 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 = vec![]; + + Ok(TradingState::from_normalized( + price_features, + technical_indicators, + market_features, + portfolio_features, + regime_features, + )) } pub(crate) fn feature_vector_to_state_with_ofi( diff --git a/crates/ml/src/trainers/dqn/trainer/tests.rs b/crates/ml/src/trainers/dqn/trainer/tests.rs index 74cb974cf..b37539e2b 100644 --- a/crates/ml/src/trainers/dqn/trainer/tests.rs +++ b/crates/ml/src/trainers/dqn/trainer/tests.rs @@ -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)" ); } diff --git a/crates/ml/src/trainers/ppo.rs b/crates/ml/src/trainers/ppo.rs index 456a7774e..f3543f7a6 100644 --- a/crates/ml/src/trainers/ppo.rs +++ b/crates/ml/src/trainers/ppo.rs @@ -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); diff --git a/crates/ml/src/training_profile.rs b/crates/ml/src/training_profile.rs index eda064098..6e6f3be99 100644 --- a/crates/ml/src/training_profile.rs +++ b/crates/ml/src/training_profile.rs @@ -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); diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index cff65fb5e..721b745e1 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -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` 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.