diff --git a/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs b/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs index 3f090fafb..a7a8e3d87 100644 --- a/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs +++ b/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs @@ -4977,6 +4977,19 @@ pub struct GpuDqnTrainer { /// at HEALTH_DIAG emit); read by `q_magnitude_bucket_means`. Initialised /// to zeros; the first epoch's emit sees the post-epoch-0 values. q_mag_means_cached: [f32; 3], + /// SP16 Phase 0 — cached direction-branch Q-value means in HEALTH_DIAG + /// field order `[q_hold, q_long, q_short, q_flat]`. + /// + /// Refreshed by `update_q_dir_means_cached` (cold-path dtoh at + /// HEALTH_DIAG emit); read by `q_direction_action_means`. Initialised + /// to zeros; the first epoch's emit sees the post-epoch-0 values. + /// + /// Diagnostic-only: feeds the structural-Q-bias-toward-Hold falsifier + /// (per train-multi-seed-pfh9n post-mortem). Field order matches the + /// emit format `q_by_action [hold long short flat]`; the kernel-side + /// indexing is the canonical state_layout.cuh ordering DIR_SHORT=0, + /// DIR_HOLD=1, DIR_LONG=2, DIR_FLAT=3 (see state_layout.cuh:123-126). + q_dir_means_cached: [f32; 4], /// EMA of atom utilization [0,1] for adaptive entropy regularization. /// SP4 Layer C close-out C3 (2026-05-01) replaced the host-resident `f32` /// field with `utilization_ema_pinned` (mapped-pinned scalar updated GPU- @@ -8179,6 +8192,63 @@ impl Drop for GpuDqnTrainer { } } +/// SP16 Phase 0 — pure-host helper that averages the four direction-branch +/// columns of a `[B, total_actions]` row-major Q-out buffer and returns the +/// per-action means in HEALTH_DIAG emit order `[q_hold, q_long, q_short, q_flat]`. +/// +/// Factored out of `update_q_dir_means_cached` so the averaging logic can be +/// exercised by an oracle test (`sp14_oracle_tests::sp16_phase0_*`) without +/// constructing a full `GpuDqnTrainer` + dtoh harness. The production updater +/// calls this helper after the dtoh — the test seeds `host` directly with +/// known values and asserts the returned slot order matches the canonical +/// action enum. +/// +/// Action-index mapping (canonical, see `state_layout.cuh:123-126`): +/// DIR_SHORT=0, DIR_HOLD=1, DIR_LONG=2, DIR_FLAT=3. +/// +/// Returned slot order: `[hold, long, short, flat]` (same as the emit format). +/// +/// Empty / degenerate input yields `[0.0; 4]`. `b0 < 4` is treated honestly: +/// out-of-range action indices fall back to slot 0 (matches the +/// `update_q_mag_means_cached` `b1.min(3)` precedent — preserves layout +/// invariants when the runtime config narrows a branch). Production always +/// has `b0 = NUM_DIRECTIONS = 4`. +pub fn compute_q_dir_means_from_host_buf( + host: &[f32], + b: usize, + b0: usize, + total_actions: usize, +) -> [f32; 4] { + if b == 0 || b0 == 0 || total_actions == 0 { + return [0.0_f32; 4]; + } + if host.len() < b * total_actions { + return [0.0_f32; 4]; + } + // q_out_buf layout is [B, total_actions] row-major. Direction-branch + // columns live at [0 .. b0]. Accumulate per-column in f64 to keep the + // mean numerically clean across large batches, then cast to f32. + let mut sums = [0.0_f64; 4]; + let cap = b0.min(4); + for i in 0..b { + let row_off = i * total_actions; + for k in 0..cap { + sums[k] += host[row_off + k] as f64; + } + } + let inv_b = 1.0_f64 / b as f64; + // Map kernel-side dir_idx → HEALTH_DIAG slot. + // dir_idx 0 = DIR_SHORT → slot 2 + // dir_idx 1 = DIR_HOLD → slot 0 + // dir_idx 2 = DIR_LONG → slot 1 + // dir_idx 3 = DIR_FLAT → slot 3 + let mean_short = (sums[0] * inv_b) as f32; + let mean_hold = (sums[1.min(b0 - 1)] * inv_b) as f32; + let mean_long = (sums[2.min(b0 - 1)] * inv_b) as f32; + let mean_flat = (sums[3.min(b0 - 1)] * inv_b) as f32; + [mean_hold, mean_long, mean_short, mean_flat] +} + impl GpuDqnTrainer { /// Configured batch size (fixed at construction for CUDA Graph compatibility). pub fn batch_size(&self) -> usize { @@ -8722,6 +8792,70 @@ impl GpuDqnTrainer { Ok(()) } + /// SP16 Phase 0 — per-direction-action Q-value mean read accessor. + /// + /// Returns `[q_hold, q_long, q_short, q_flat]` = the mean Q-value + /// predicted for the four direction-branch actions across the + /// most-recent batch whose `q_out_buf` contents are live. + /// + /// Action-index mapping (canonical, see `state_layout.cuh:123-126`): + /// DIR_SHORT=0, DIR_HOLD=1, DIR_LONG=2, DIR_FLAT=3. The returned slot + /// order matches the HEALTH_DIAG `q_by_action [hold long short flat]` + /// emit format (Hold first because the structural-Q-bias-toward-Hold + /// hypothesis being falsified is about Hold's ascent). + /// + /// * `[0] q_hold` = mean(q_out_buf[:, DIR_HOLD ]) = col 1 + /// * `[1] q_long` = mean(q_out_buf[:, DIR_LONG ]) = col 2 + /// * `[2] q_short` = mean(q_out_buf[:, DIR_SHORT]) = col 0 + /// * `[3] q_flat` = mean(q_out_buf[:, DIR_FLAT ]) = col 3 + /// + /// Read from the cached value populated by `update_q_dir_means_cached` + /// (cold-path dtoh invoked once per epoch at HEALTH_DIAG emit). + pub fn q_direction_action_means(&self) -> [f32; 4] { + self.q_dir_means_cached + } + + /// SP16 Phase 0 — refresh the cached direction-branch Q-value means. + /// + /// Cold-path (epoch boundary) dtoh of the current `q_out_buf` contents. + /// Reads `B * total_actions` floats, extracts the 4 columns belonging + /// to the direction branch at offsets `[0 .. b0]`, averages each + /// column over the batch, and stores into `q_dir_means_cached` as + /// `[q_hold, q_long, q_short, q_flat]` (see `q_direction_action_means` + /// for the action-idx ↔ slot mapping). + /// + /// Mirrors `update_q_mag_means_cached` for the magnitude branch — both + /// share the same dtoh of `q_out_buf` per epoch (each is invoked + /// independently at HEALTH_DIAG emit time; the two transfers can be + /// batched in a future refactor but the diagnostic-path cost is + /// acceptable). + /// + /// Called at HEALTH_DIAG emit time only. Diagnostic path — dtoh cost + /// is acceptable here but would be untenable per-step. + pub fn update_q_dir_means_cached(&mut self) -> Result<(), MLError> { + let b = self.config.batch_size; + let b0 = self.config.branch_0_size; + let total_actions = self.total_actions(); + if b == 0 || b0 == 0 || total_actions == 0 { + self.q_dir_means_cached = [0.0_f32; 4]; + return Ok(()); + } + let n_floats = b * total_actions; + if self.q_out_buf.len() < n_floats { + return Err(MLError::ModelError(format!( + "update_q_dir_means_cached: q_out_buf len {} < B*total_actions {}", + self.q_out_buf.len(), + n_floats + ))); + } + let mut host = vec![0.0_f32; n_floats]; + self.stream + .memcpy_dtoh(&self.q_out_buf.slice(0..n_floats), &mut host) + .map_err(|e| MLError::ModelError(format!("dtoh q_out_buf for dir means: {e}")))?; + self.q_dir_means_cached = compute_q_dir_means_from_host_buf(&host, b, b0, total_actions); + Ok(()) + } + /// Update per-branch liquid tau modulation — GPU kernel (RK4/Euler adaptive ODE). /// /// Reads per_branch_q_gaps (pinned device-mapped) and qlstm_context from device. @@ -23501,6 +23635,7 @@ impl GpuDqnTrainer { per_branch_q_gap_ema_buf, last_per_branch_q_gaps: [0.0; 4], q_mag_means_cached: [0.0_f32; 3], + q_dir_means_cached: [0.0_f32; 4], utilization_ema_pinned, utilization_ema_dev_ptr, vsn_masked_buf, diff --git a/crates/ml/src/trainers/dqn/fused_training.rs b/crates/ml/src/trainers/dqn/fused_training.rs index ac13e2603..acd13e248 100644 --- a/crates/ml/src/trainers/dqn/fused_training.rs +++ b/crates/ml/src/trainers/dqn/fused_training.rs @@ -1459,6 +1459,28 @@ impl FusedTrainingCtx { self.trainer.update_q_mag_means_cached() } + /// SP16 Phase 0 — per-direction-action Q-value mean. + /// + /// Returns `[q_hold, q_long, q_short, q_flat]` read from the cached + /// values populated by `update_q_dir_means_cached` (cold-path, epoch + /// boundary). Callers should invoke `update_q_dir_means_cached` before + /// reading. Action-index ordering: canonical, see + /// `state_layout.cuh:123-126` (DIR_SHORT=0, DIR_HOLD=1, DIR_LONG=2, + /// DIR_FLAT=3). + pub(crate) fn q_direction_action_means(&self) -> [f32; 4] { + self.trainer.q_direction_action_means() + } + + /// SP16 Phase 0 — refresh the cached direction-branch Q-value means. + /// + /// Cold-path dtoh of `q_out_buf` contents. Should be called at + /// HEALTH_DIAG emit time (epoch boundary) immediately before + /// `q_direction_action_means`. Mirrors `update_q_mag_means_cached` for + /// the direction-branch columns `[0..b0]`. + pub(crate) fn update_q_dir_means_cached(&mut self) -> Result<(), crate::MLError> { + self.trainer.update_q_dir_means_cached() + } + /// Flush the last in-flight async readback from the training step. /// Called once at epoch end to retrieve the final step's scalars. pub(crate) fn flush_readback(&mut self) -> Result { diff --git a/crates/ml/src/trainers/dqn/trainer/training_loop.rs b/crates/ml/src/trainers/dqn/trainer/training_loop.rs index 663e7e30f..958ca7ba0 100644 --- a/crates/ml/src/trainers/dqn/trainer/training_loop.rs +++ b/crates/ml/src/trainers/dqn/trainer/training_loop.rs @@ -5064,6 +5064,43 @@ impl DQNTrainer { ); } + // SP16 Phase 0 — per-direction-action Q-value mean. Falsifier for + // the structural-Q-bias-toward-Hold hypothesis surfaced by + // train-multi-seed-pfh9n: WR plateaued at ~0.46 across both folds + // while Hold% climbed 15% → 52%. Without per-action Q observations + // we cannot distinguish "Adam's m/sqrt(v) prefers low-variance + // Hold targets" from "explicit reward terms keep electing Hold". + // + // Cold path: a single dtoh of B*total_actions floats at epoch + // boundary (~786 KB at B=16384, total_actions=12) via + // `update_q_dir_means_cached`. Read order matches the emit + // format (Hold first because the hypothesis is about Hold's + // ascent dominating direction Q-magnitudes). + // + // Action ordering: canonical, see `state_layout.cuh:123-126` — + // DIR_SHORT=0, DIR_HOLD=1, DIR_LONG=2, DIR_FLAT=3. The slot order + // returned by `q_direction_action_means` is HEALTH_DIAG-native + // `[hold, long, short, flat]`. + { + let q_by_action = if let Some(fused) = self.fused_ctx.as_mut() { + match fused.update_q_dir_means_cached() { + Ok(()) => fused.q_direction_action_means(), + Err(e) => { + tracing::warn!( + "HEALTH_DIAG q_direction_action_means refresh failed: {e}" + ); + fused.q_direction_action_means() + } + } + } else { + [0.0_f32; 4] + }; + tracing::info!( + "HEALTH_DIAG[{}]: q_by_action [hold={:.4} long={:.4} short={:.4} flat={:.4}]", + epoch, q_by_action[0], q_by_action[1], q_by_action[2], q_by_action[3], + ); + } + // SP7 observability (grad_decomp_pinned): surface the exact contents of // grad_decomp_result_pinned[0..3, 12..15, 36..48] (iqn/cql_raw/c51 × // [mag, dir, trunk]) that the SP7 loss-balance controller read in the diff --git a/crates/ml/tests/sp14_oracle_tests.rs b/crates/ml/tests/sp14_oracle_tests.rs index 14886bba4..1e383c9e7 100644 --- a/crates/ml/tests/sp14_oracle_tests.rs +++ b/crates/ml/tests/sp14_oracle_tests.rs @@ -2034,3 +2034,141 @@ mod sp14_audit_4b_min_hold_temperature_gpu { ); } } + +// ═══════════════════════════════════════════════════════════════════════════ +// SP16 Phase 0 (2026-05-08) — Q-by-action HEALTH_DIAG diagnostic. +// +// Per train-multi-seed-pfh9n post-mortem: structural-Q-bias hypothesis (Adam's +// `m/sqrt(v)` favours zero-variance Hold over noisy direction Q-targets) needs +// direct per-action Q observations to verify or refute. The production path is: +// +// 1. `update_q_dir_means_cached`: dtoh of `q_out_buf [B, total_actions]`, +// then per-action averaging via `compute_q_dir_means_from_host_buf`. +// 2. `q_direction_action_means`: returns `[hold, long, short, flat]` cached +// array (HEALTH_DIAG emit order). +// 3. training_loop.rs HEALTH_DIAG emit: +// `q_by_action [hold=… long=… short=… flat=…]`. +// +// Action-index ordering verified at `state_layout.cuh:123-126`: +// DIR_SHORT=0, DIR_HOLD=1, DIR_LONG=2, DIR_FLAT=3. +// +// This test exercises the actual averaging-path helper with known per-column +// values seeded into a [B, total_actions] row-major buffer. The slot order +// returned must match the HEALTH_DIAG `[hold, long, short, flat]` contract, +// independently of whichever dir_idx a given column corresponds to. +// ═══════════════════════════════════════════════════════════════════════════ + +/// SP16 Phase 0: verify the q_by_action diagnostic correctly extracts +/// per-direction-action Q-means from a `[B, total_actions]` row-major Q-out +/// buffer and emits in the contracted `[hold, long, short, flat]` slot order +/// matching the HEALTH_DIAG format. Validates the action-index ↔ slot +/// mapping against `state_layout.cuh:123-126` (DIR_SHORT=0, DIR_HOLD=1, +/// DIR_LONG=2, DIR_FLAT=3) so we can observe Q(Hold) trajectory vs +/// Q(direction) and falsify the structural-Q-bias hypothesis. +/// +/// Construction: +/// * B=8, total_actions=12 (production layout: 4 dir + 3 mag + 3 ord + 3 urg). +/// * Each direction column i is filled with constant `dir_target[i]` +/// across all batch rows so the per-column mean equals the seeded +/// constant (no float averaging error). The remaining 8 mag/ord/urg +/// columns are filled with sentinel 99.0 to confirm the helper does +/// not bleed any non-direction column into the dir-action means. +/// * Targets chosen with 4 distinct values so a wrong index→slot mapping +/// is detected with 1e-3 tolerance. +/// +/// CPU-only test — exercises pure-host averaging logic. The dtoh half of +/// the production updater is tested implicitly by smoke (HEALTH_DIAG +/// emits each epoch); the bug surface this test guards is the index→slot +/// mapping, which is data-independent. +#[test] +fn sp16_phase0_q_by_action_diagnostic_reads_four_action_means() { + use ml::cuda_pipeline::gpu_dqn_trainer::compute_q_dir_means_from_host_buf; + + // Production direction-branch ordering, see state_layout.cuh:123-126. + // dir_idx 0 → DIR_SHORT, 1 → DIR_HOLD, 2 → DIR_LONG, 3 → DIR_FLAT. + const DIR_SHORT: usize = 0; + const DIR_HOLD: usize = 1; + const DIR_LONG: usize = 2; + const DIR_FLAT: usize = 3; + + // Returned-slot ordering, see compute_q_dir_means_from_host_buf docs. + // HEALTH_DIAG `q_by_action [hold long short flat]`. + const SLOT_HOLD: usize = 0; + const SLOT_LONG: usize = 1; + const SLOT_SHORT: usize = 2; + const SLOT_FLAT: usize = 3; + + const B: usize = 8; + const B0: usize = 4; // NUM_DIRECTIONS + const TOTAL_ACTIONS: usize = 12; // 4 + 3 + 3 + 3 (production layout) + + // Distinct target Q-values per direction action. + // Index by canonical dir_idx (matches the column the writer uses). + let mut dir_target = [0.0_f32; 4]; + dir_target[DIR_SHORT] = 0.10; + dir_target[DIR_HOLD ] = 0.50; + dir_target[DIR_LONG ] = 0.20; + dir_target[DIR_FLAT ] = 0.30; + + // Build a synthetic q_out_buf [B, total_actions] row-major. Direction + // columns [0..4] are seeded with dir_target[col]; non-direction columns + // [4..12] are sentinel 99.0 so any mis-indexing leaks visibly into the + // returned means. + let mut host = vec![0.0_f32; B * TOTAL_ACTIONS]; + for i in 0..B { + let row_off = i * TOTAL_ACTIONS; + for k in 0..B0 { + host[row_off + k] = dir_target[k]; + } + for k in B0..TOTAL_ACTIONS { + host[row_off + k] = 99.0; + } + } + + let q_by_action = compute_q_dir_means_from_host_buf(&host, B, B0, TOTAL_ACTIONS); + + // Slot-order assertions: each slot must hold the canonical-dir-idx target. + assert!( + (q_by_action[SLOT_HOLD ] - dir_target[DIR_HOLD ]).abs() < 1e-3, + "slot 0 (hold) must hold mean of DIR_HOLD column. got {:.6} expected {:.6}", + q_by_action[SLOT_HOLD], dir_target[DIR_HOLD] + ); + assert!( + (q_by_action[SLOT_LONG ] - dir_target[DIR_LONG ]).abs() < 1e-3, + "slot 1 (long) must hold mean of DIR_LONG column. got {:.6} expected {:.6}", + q_by_action[SLOT_LONG], dir_target[DIR_LONG] + ); + assert!( + (q_by_action[SLOT_SHORT] - dir_target[DIR_SHORT]).abs() < 1e-3, + "slot 2 (short) must hold mean of DIR_SHORT column. got {:.6} expected {:.6}", + q_by_action[SLOT_SHORT], dir_target[DIR_SHORT] + ); + assert!( + (q_by_action[SLOT_FLAT ] - dir_target[DIR_FLAT ]).abs() < 1e-3, + "slot 3 (flat) must hold mean of DIR_FLAT column. got {:.6} expected {:.6}", + q_by_action[SLOT_FLAT], dir_target[DIR_FLAT] + ); + + // Defensive: any non-direction sentinel leakage would push the dir slot + // past its dir_target. With sentinel=99.0 a one-column mis-index would + // shift the slot by (99.0 - target) / B per leaked row — at least 12.4 + // for SLOT_HOLD's 0.50 target, far above the 1e-3 tolerance. + for (slot, value) in q_by_action.iter().enumerate() { + assert!( + *value < 10.0, + "slot {slot} contaminated by non-direction sentinel column \ + (got {value:.6}); index→slot mapping is wrong" + ); + } + + // Empty-input guards: helper must return [0; 4] for degenerate cases. + let empty = compute_q_dir_means_from_host_buf(&host, 0, B0, TOTAL_ACTIONS); + assert_eq!(empty, [0.0_f32; 4], "B=0 must yield all zeros"); + let empty_b0 = compute_q_dir_means_from_host_buf(&host, B, 0, TOTAL_ACTIONS); + assert_eq!(empty_b0, [0.0_f32; 4], "b0=0 must yield all zeros"); + // Truncated host (under-sized) must also yield zeros — defensive guard + // that mirrors the production-path ModelError on under-sized q_out_buf. + let small = vec![0.0_f32; 4]; + let truncated = compute_q_dir_means_from_host_buf(&small, B, B0, TOTAL_ACTIONS); + assert_eq!(truncated, [0.0_f32; 4], "truncated host must yield all zeros"); +} diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index edd7ce00c..d9d9c009b 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -8480,3 +8480,65 @@ The ratio assertion makes the bug regression-proof: any future kernel edit that 1. **Magnitude of the upstream effect**: with α=0.6 default and the typical TD-error span of 3-4 orders of magnitude in trade-close vs no-trade transitions, the effective sampling lift on high-TD events drops from ~10× (single α) to ~2.5× (double α). That's ~4× attenuation of PER's prioritization power — a meaningful but not catastrophic miscalibration. The plateau-causation hypothesis (alongside Class B #1's fold-boundary contamination) is plausible but not proven by audit alone; smoke run will quantify. 2. **`max_priority` semantic shift**: pre-fix `max_priority` tracked the running max of `|td|^α + ε`. Post-fix it tracks max of `|td| + ε`. The downstream `per_insert_pa` broadcasts this value as `raw_priorities` for new inserts and computes `effective^α` for `priorities_pa` — so the shift is internally consistent (raw priority in, α applied once on insert). External callers of `apply_max_priority_scalar` (currently none in production paths, only test/seg_tree paths) would need to pass raw priority values — no caller change required because no production caller exists. 3. **`priority_update_f32` left in place despite being dead**: the kernel compiles but has no Rust caller. Per `feedback_no_legacy_aliases` it should be deleted, but doing so requires removing the kernel registration in the loader (`crates/ml-dqn/src/replay_buffer_kernels.rs` or similar) and that's outside the surface of this fix. Filed as a follow-up cleanup; the kernel is harmless idle code (no execution path). + +## SP16 Phase 0: q_by_action diagnostic (2026-05-08) + +### Why + +`train-multi-seed-pfh9n` post-mortem: WR plateau at ~0.46 across both folds while Hold% climbed 15% → 52%. Leading hypothesis is structural Q-learning bias toward low-variance Hold action — Adam's `m/sqrt(v)` updates favor zero-variance Hold over noisy direction Q-targets, so Q(Hold) climbs steadily until it dominates regardless of penalty/temperature/PER fixes. + +Without per-action Q observations we cannot verify or refute this hypothesis. SP16 Phase 0 adds a dedicated HEALTH_DIAG diagnostic so subsequent phases (e.g., Phase 2 Hold-cost adjustment) have a falsifier metric. + +### What + +New HEALTH_DIAG line emitted each epoch: + +``` +HEALTH_DIAG[N]: q_by_action [hold=0.5234 long=0.1102 short=0.0987 flat=0.2145] +``` + +Slot order is `[hold, long, short, flat]` (Hold first because the hypothesis is about Hold's ascent dominating direction Q-magnitudes). Action-index ↔ slot mapping uses the canonical state_layout.cuh ordering: + +| dir_idx | enum constant | column in q_out_buf | emit slot | +|---------|-----------------|----------------------|-----------| +| 0 | DIR_SHORT | 0 | 2 (short) | +| 1 | DIR_HOLD | 1 | 0 (hold) | +| 2 | DIR_LONG | 2 | 1 (long) | +| 3 | DIR_FLAT | 3 | 3 (flat) | + +Source: `crates/ml/src/cuda_pipeline/state_layout.cuh:123-126`. + +### How + +Modeled directly on the existing Task 0.3 magnitude-bucket diagnostic. No new kernel needed — `q_out_buf [B, total_actions]` is already populated row-major by `compute_expected_q` after each training step, and the direction-branch columns live at `[0..b0]` (b0 = NUM_DIRECTIONS = 4). The new methods reuse the same dtoh + per-column averaging pattern as `update_q_mag_means_cached`. + +| File | Δ | Notes | +|------|---|-------| +| `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` | +97 / -1 | New cached field `q_dir_means_cached`, public methods `update_q_dir_means_cached` + `q_direction_action_means`, free pure-host helper `compute_q_dir_means_from_host_buf` (factored for testability) | +| `crates/ml/src/trainers/dqn/fused_training.rs` | +21 / -0 | FusedTrainingCtx wrappers `update_q_dir_means_cached` + `q_direction_action_means` (parallels existing q_mag wrappers) | +| `crates/ml/src/trainers/dqn/trainer/training_loop.rs` | +35 / -0 | HEALTH_DIAG emit block right after `q_var_per_branch` (both are per-direction-action diagnostics, kept adjacent for log readability) | +| `crates/ml/tests/sp14_oracle_tests.rs` | +132 / -0 | Behavioral test `sp16_phase0_q_by_action_diagnostic_reads_four_action_means` exercises the index→slot mapping + sentinel-leak detection + degenerate-input guards | + +### Behavioral test + +`sp14_oracle_tests::sp16_phase0_q_by_action_diagnostic_reads_four_action_means` (CPU-only, runs in default cargo test): + +- Builds a synthetic `q_out_buf [8, 12]` row-major buffer, seeds direction columns `[0..4]` with four distinct constants `(short=0.10, hold=0.50, long=0.20, flat=0.30)`, fills non-direction columns with sentinel 99.0. +- Calls `compute_q_dir_means_from_host_buf` and asserts each output slot holds the canonical-dir-idx target within 1e-3. +- Sentinel-leak guard: any slot value ≥ 10.0 means a non-direction column bled in (mis-indexed walk over `total_actions`). +- Degenerate-input guards: `B=0`, `b0=0`, and undersized host all return `[0.0; 4]`. + +The bug surface this test guards is the index→slot mapping — data-independent — so a CPU-only test covers it. The dtoh half of the production updater is exercised every epoch by the smoke pipeline, the same way Task 0.3's magnitude variant is tested. + +### Verification + +- `SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check -p ml --tests --all-targets` — clean (warnings unchanged). +- `cargo test -p ml --test sp14_oracle_tests --release sp16_phase0_q_by_action_diagnostic_reads_four_action_means -- --nocapture` — 1/1 pass. +- `cargo test -p ml --test sp14_oracle_tests --release` — 3/3 non-ignored pass, 24 GPU-gated tests ignored unchanged. +- `cargo test -p ml --test sp15_phase1_oracle_tests --release` — 5/5 non-ignored pass, 36 GPU-gated tests ignored unchanged. + +### Concerns + +1. **Diagnostic-only — does not modify reward/loss/Q dynamics**. This task adds an observation channel; Phase 2 will use the observed Q(Hold) trajectory to decide whether to lift Hold cost / pay Hold a structural penalty / leave Hold alone. The hypothesis that Q(Hold)/Q(direction) ratio climbs faster than Hold% can be answered after one smoke run with this diagnostic on. +2. **dtoh cost on cold path**: the magnitude variant already pays `B × total_actions × 4 bytes ≈ 786 KB` per epoch; the direction variant adds an identical second transfer. Two transfers can be coalesced into one shared helper in a future pass — the `update_q_mag_means_cached` and `update_q_dir_means_cached` fan out the same `q_out_buf` to two cached arrays. Deferred: the existing diagnostic-path cost is already accepted, doubling it remains negligible vs hot-path overhead and the readability gain of two independent methods outweighs the saving for now. +3. **No new kernel**: per `feedback_no_atomicadd` and `feedback_no_stubs` we explicitly avoided adding a producer kernel. The existing `q_out_buf [B, total_actions]` row-major contract is the producer; we read it. If a future refactor moves the per-action means onto a GPU buffer (e.g., to fuse with `reduce_current_q_stats_per_branch`) the host helper is replaced by a 4-slot mapped-pinned read — straightforward extension.