# F2 Q-Centered Q-Distill Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Modify the Q-distill target kernel to use a hinged advantage formulation that preserves the surfer trading principle while breaking the structural inaction-bias documented in pearl_dd049d9a4_surfer_baseline_verified + the going-in-circles cluster trajectory.

**Architecture:** Replace `π_target = softmax(E_Q/τ)` with `π_target = softmax(max(0, E_Q − baseline(no-op-set))/τ)` where `baseline = max(E_Q[Hold], E_Q[TrailTighten], E_Q[TrailLoosen])`. Mass-zeroes any action no better than the best zero-cost no-op; preserves Hold's mass when no Open action has strictly positive advantage (surfer-patience-preserving).

**Tech Stack:** CUDA kernel modification (`rl_q_pi_distill_grad.cu`), Rust trainer integration (`integrated.rs`), 2 new ISV slots, GPU-oracle invariant test, bit-deterministic verification.

**Diagnostic instrumentation (NEW — Task 1.5):** 13 new diag emissions covering (a) state-conditional surfer-principle measurement (`hold_frac_flat` etc. — makes F2-G5 locally measurable), (b) degenerate-attractor monitoring (modal action share, no-op mass concentration), and (c) F2 mechanism counters (baseline mean, advantage max mean, hinge zero rate, target argmax). Without these, F2's effect at b=1024 cluster is unattributable per `pearl_fleet_fraction_not_aggregate` and the spec's measure-first discipline (`feedback_investigation_first_falsification_methodology`).

---

## Reconnaissance findings (verified before plan authoring — Task 0 below documents them)

These are findings from the spec-mandated reconnaissance pass. They are recorded here so the implementer does NOT need to re-verify (but Task 0 confirms each one fresh).

1. **c51_v_max state (per `pearl_c51_v_max_freeze_required_for_surfer`).** Bootstrap is `RL_C51_V_MAX_INDEX = 1.0` at `crates/ml-alpha/src/trainer/integrated.rs:3830`. Surfer mode requires this frozen at 1.0 OR within [50, 250]. Grepping the codebase, no controller writes to `RL_C51_V_MAX_INDEX` during the training loop — the only writes are the bootstrap and a manual atom-bound widener (`rl_reward_clamp_controller`) which is bounded; the EWMA only grows magnitude (so v_max can rise above 1.0 over many steps if rewards exceed it). For Phase 7a F2's purposes the slot is in the safe state. **NO ACTION REQUIRED** — proceed with default bootstrap.

2. **F2-band composition order (load-bearing per spec §7 #2).** The Q-distill kernel reads `pi_logits_d` (forward pre-softmax logits) and `q_logits_target_d` (TARGET net Q-logits) at `integrated.rs:6118-6138`. It writes per-action gradient into `ss_pi_grad_logits_d`. It does NOT read `actions_d`. The band mask (`band_head.launch_mask` at `integrated.rs:9924-9932`) modifies `actions_d` AFTER the policy has sampled an action — purely a post-sampling action-override path. Therefore F2 (which lives entirely in Q-space and reshapes the distill target distribution) is **mechanically disjoint from the band mask**. F2 sees the pre-band, pre-sampling Q-distribution. **VERIFIED CLEAN COMPOSITION** — F2 + band compose without interaction.

3. **No-op set definition.** Action enum at `crates/ml-alpha/cuda/actions_to_market_targets.cu:55-65`. The three zero-cost actions per spec §3.5 are `ACTION_HOLD=2`, `ACTION_TRAIL_TIGHTEN=7`, `ACTION_TRAIL_LOOSEN=8`. These align with the spec's F2a static set. Phase 7a uses these three as the hardcoded no-op set with a mode slot for future variants.

4. **Existing slot allocation.** `RL_SLOTS_END = 817` at `crates/ml-alpha/src/rl/isv_slots.rs:2281`. New slots are 817 and 818; new `RL_SLOTS_END = 819`.

5. **Existing distill controller.** `RL_Q_DISTILL_LAMBDA_INDEX = 486` (bootstrap 0.01), `RL_Q_DISTILL_TEMPERATURE_INDEX = 487` (bootstrap 5.0), and the SAC α + τ auto-tuner is co-located in the same kernel. The controller is tuned for the *current* target shape. Per spec §9.1 the controller may need re-bootstrapping, but Phase 7a leaves the bootstrap values unchanged — the τ controller will auto-adapt to the new target shape via the existing entropy-deficit feedback loop. If F2-G3 fails (training unstable) the controller re-bootstrap is Task 6's STOP-on-surprise escalation path.

---

## Task 0 — Reconnaissance verification (~30 min)

Confirm the four reconnaissance findings above against the live codebase. If any finding diverges, STOP and surface — do not proceed.

### Step 0.1 — Verify c51_v_max bootstrap state

- [ ] Run:

```bash
grep -n "RL_C51_V_MAX_INDEX" /home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/trainer/integrated.rs
```

Expected output: a bootstrap entry `(crate::rl::isv_slots::RL_C51_V_MAX_INDEX, 1.0)` at approximately line 3830. No write inside the training step loop (`step_with_lobsim_gpu_body` or surrounding controllers).

- [ ] If the bootstrap value is NOT 1.0 or a controller is found writing to this slot during training, STOP and consult `pearl_c51_v_max_freeze_required_for_surfer` before proceeding.

### Step 0.2 — Read the target kernel in full

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/cuda/rl_q_pi_distill_grad.cu` and read every line. Specifically confirm:
  - Line 17: `#define N_ACTIONS 11`
  - Lines 78–91: the `s_pi_target` computation block (the F2 modification site)
  - Line 175: `isv[RL_Q_DISTILL_TARGET_ENTROPY_MEAN_INDEX] = h_t_b10;` — the diag emit we will use for F2-G1
- [ ] If any of these landmarks have moved, adjust line references in Task 3 accordingly (the structural change does not depend on exact line numbers but the patch context does).

### Step 0.3 — Verify F2-band composition order

- [ ] Run:

```bash
grep -n "rl_q_pi_distill_grad_fn\|launch_mask\b" /home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/trainer/integrated.rs | head -20
```

Expected: `rl_q_pi_distill_grad_fn` launches at approximately line 6132 (inside the training-update backward pass), and `band_head.launch_mask` launches at approximately line 9924 (after the policy has sampled `actions_d`).

- [ ] Open `integrated.rs` at the line of the `rl_q_pi_distill_grad_fn` launch and confirm its 5 argument pointers (`q_logits_target`, `pi_logits`, `atom_supports`, `isv`, `pi_grad_logits`). NONE of them is `actions_d` or any band-mask output.
- [ ] Open `integrated.rs` at the line of `band_head.launch_mask` and confirm its first argument is `&self.actions_d` — the band modifies sampled actions, not logits or Q-distill targets.
- [ ] Conclude: F2 sees Q-target distribution; band overrides sampled action. Disjoint pipelines. F2 is unaffected by the band.

### Step 0.4 — Document reconnaissance result

- [ ] If all three checks pass, proceed to Task 1. The plan's reconnaissance findings section is now confirmed inline; no separate report file is needed.
- [ ] If any check surfaces an unexpected divergence (e.g., a new controller has been added to write c51_v_max, or the distill kernel signature has changed), STOP per `feedback_investigation_first_falsification_methodology` and update the plan before any kernel modification.

---

## Task 1 — Allocate two new ISV slots and bootstrap them (~20 min)

### Step 1.1 — Add slot constants

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/rl/isv_slots.rs`.
- [ ] Locate the trailing `pub const RL_SLOTS_END: usize = 817;` declaration (line ~2281) and the preceding comment block.
- [ ] Insert the two new slot constants immediately BEFORE the `RL_SLOTS_END` declaration. Exact text to append after the MTM weight slot:

```rust
/// Phase 7a F2 — Q-centered distill target master gate. When `> 0.5`,
/// the kernel `rl_q_pi_distill_grad.cu` computes
/// `π_target = softmax(max(0, E_Q − baseline(no-op-set)) / τ)` where
/// `baseline` is the max E_Q over the no-op set selected by
/// `RL_F2_DISTILL_NOOP_SET_MODE_INDEX`. When ≤ 0.5 (default OFF), the
/// legacy `π_target = softmax(E_Q / τ)` path runs unchanged
/// (bit-equality with HEAD `0463e44e0`).
///
/// Bootstrap 1.0 (ON — Phase 7a deploys the F2 hinge by default).
/// Clamp `{0.0, 1.0}` (binary).
pub const RL_F2_DISTILL_CENTERED_ENABLED_INDEX: usize = 817;

/// Phase 7a F2 — no-op set mode. Selects which action subset is treated
/// as the inaction baseline for the F2 hinge.
///
/// Mode 0 (default, "F2a static"): `{Hold=2, TrailTighten=7, TrailLoosen=8}`.
/// Mode 1 ("Hold-only"): `{Hold=2}` — for ablation.
/// Other values reserved for future modes (e.g., dynamic Δlots-based).
///
/// Bootstrap 0.0 (mode 0 = static three-way set, surfer-principle-preserving).
/// Clamp `[0.0, 2.0]` (integer-valued via host enum).
pub const RL_F2_DISTILL_NOOP_SET_MODE_INDEX: usize = 818;
```

### Step 1.2 — Bump RL_SLOTS_END

- [ ] In the same file, update the trailing constant and its comment ledger. Change:

```rust
/// Post-Phase 5 (trail-max ceiling + MTM reward, slots 814-816): 816.
pub const RL_SLOTS_END: usize = 817;
```

to:

```rust
/// Post-Phase 5 (trail-max ceiling + MTM reward, slots 814-816): 816.
/// Post-Phase 7a F2 (Q-centered distill target, slots 817-818): 818.
pub const RL_SLOTS_END: usize = 819;
```

### Step 1.3 — Add bootstrap entries in the trainer

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/trainer/integrated.rs`.
- [ ] Locate the Phase 5 MTM bootstrap entries — search:

```bash
grep -n "RL_MTM_REWARD_ENABLED_INDEX,\s*1.0\|RL_MTM_REWARD_WEIGHT_INDEX" /home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/trainer/integrated.rs
```

There will be two bootstrap entries `(RL_MTM_REWARD_ENABLED_INDEX, 1.0)` and `(RL_MTM_REWARD_WEIGHT_INDEX, 1.0)` inside the long initial-bootstrap tuple list (the same list that contains the c51_v_max bootstrap at line 3830).

- [ ] Immediately after the MTM weight bootstrap entry, add:

```rust
                // Phase 7a F2 (2026-06-04) — Q-centered distill target.
                // ENABLED bootstraps to 1.0 (ON) to deploy the hinge in
                // every training run by default; the master gate is the
                // ISV slot so flipping to 0.0 reproduces the legacy
                // `π_target = softmax(E_Q / τ)` path bit-equal.
                // NOOP_SET_MODE bootstraps to 0.0 (mode 0 = F2a static
                // set Hold/TrailTighten/TrailLoosen).
                (crate::rl::isv_slots::RL_F2_DISTILL_CENTERED_ENABLED_INDEX, 1.0),
                (crate::rl::isv_slots::RL_F2_DISTILL_NOOP_SET_MODE_INDEX,    0.0),
```

### Step 1.4 — Verify the slot import block

- [ ] At the top of `integrated.rs`, locate the `use crate::rl::isv_slots::{ ... };` import block (search for `RL_MTM_REWARD_ENABLED_INDEX,` to find it).
- [ ] If the bootstrap tuples reference `crate::rl::isv_slots::RL_F2_DISTILL_CENTERED_ENABLED_INDEX` via the fully-qualified path (as in Step 1.3 above), no import change is needed. Skip this step.
- [ ] Otherwise add `RL_F2_DISTILL_CENTERED_ENABLED_INDEX, RL_F2_DISTILL_NOOP_SET_MODE_INDEX,` to the import block.

### Step 1.5 — Build and verify

- [ ] Run:

```bash
SQLX_OFFLINE=true cargo check -p ml-alpha 2>&1 | tail -20
```

Expected: clean compile (warnings OK; no errors). If a duplicate-slot error appears (e.g., slot 817 already in use), STOP and re-grep `pub const RL_.*= 817\|pub const RL_.*= 818` to find the collision.

### Step 1.6 — Commit

- [ ] Stage and commit:

```bash
git add /home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/rl/isv_slots.rs \
        /home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/trainer/integrated.rs
git commit -m "$(cat <<'EOF'
feat(ml-alpha): Phase 7a F2 — allocate ISV slots 817-818 for Q-centered distill target

Two new slots:
- RL_F2_DISTILL_CENTERED_ENABLED_INDEX (817, bootstrap 1.0): master gate
  for the F2 hinge in rl_q_pi_distill_grad.cu. Flip to 0.0 to fall back to
  the legacy softmax(E_Q/τ) target (bit-equal with HEAD 0463e44e0).
- RL_F2_DISTILL_NOOP_SET_MODE_INDEX (818, bootstrap 0.0): selects the
  no-op-set definition. Mode 0 = F2a static {Hold, TrailTighten, TrailLoosen}.

RL_SLOTS_END: 817 → 819.

Per spec docs/superpowers/specs/2026-06-04-bellman-target-foundation-reshape.md
§3.2 + §4.2 (F2 recommended, Revision 3 surfer-principle-preserving primary phase).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 1.5 — Comprehensive diagnostic emissions for F2 measurement (~3-4 h)

**Why this task exists:** Without instrumentation, the F2 cluster verdict (Task 8) cannot distinguish "F2 mechanism worked AND surfer preserved AND eval improved" from "F2 mechanism worked BUT surfer collapsed" from "F2 mechanism inactive (no above-baseline actions emerged)." Per `pearl_fleet_fraction_not_aggregate`, at b=1024 aggregate diag (`worst_X` / `max_X`) hides per-batch reality — fleet-fraction counters are mandatory. Per `feedback_investigation_first_falsification_methodology` "measure first, change code second," the surfer-preservation gate F2-G5 must be locally measurable BEFORE cluster submission.

This task adds **13 new diag fields** under existing top-level keys (`trading.*` and `policy_diagnostic.*`) so the F2-G5 gate in Task 6 is no longer a "measurement gap" deferred to cluster, and so the cluster verdict in Task 8 receives three independent attribution signals (mechanism / surfer / attractor).

**The 13 diag fields:**

| Group | Field | Source | Range | Purpose |
|---|---|---|---|---|
| Surfer | `trading.frac_batches_flat` | host reduce on `inputs.position_lots` | [0,1] | Denominator for state-conditional metrics |
| Surfer | `trading.frac_batches_long` | host reduce | [0,1] | Long-state fraction |
| Surfer | `trading.frac_batches_short` | host reduce | [0,1] | Short-state fraction |
| Surfer | `policy_diagnostic.hold_frac_flat` | host reduce on `inputs.actions` + `inputs.position_lots` | [0,1] | **F2-G5 direct measurement** |
| Surfer | `policy_diagnostic.open_action_mass_flat` | host reduce | [0,1] | Open mass / batches-with-flat |
| Attractor | `policy_diagnostic.max_action_share` | host reduce on `act_hist` | [0,1] | Detects single-action collapse |
| Attractor | `policy_diagnostic.modal_action_id` | host reduce on `act_hist` | {0..10} | Which attractor |
| Attractor | `policy_diagnostic.noop_action_mass_total` | host reduce on `act_hist` | [0,1] | Aggregate no-op rate |
| Attractor | `policy_diagnostic.noop_action_mass_max` | host reduce on `act_hist` | [0,1] | Specific no-op concentration |
| F2 mech | `policy_diagnostic.f2_baseline_mean` | kernel reduce → ISV | finite | mean of `max E_Q[no-op-set]` |
| F2 mech | `policy_diagnostic.f2_advantage_max_mean` | kernel reduce → ISV | ≥ 0 | mean of `max_a hinged[a]` |
| F2 mech | `policy_diagnostic.f2_hinge_zero_rate` | kernel reduce → ISV | [0,1] | Fraction of `(b,a)` zeroed by hinge |
| F2 mech | `policy_diagnostic.f2_target_argmax_action` | kernel reduce → ISV | {0..10} | Modal π_target action |

**Discipline constraints:**
- **No atomicAdd.** Use the per-batch fused-stats pattern (one block per batch, intra-block reduction) — same shape as the existing band kernel + CMDP fleet-fraction counters.
- **Fleet-fraction over means** at b≥1024 (`pearl_fleet_fraction_not_aggregate`).
- **TDD per diagnostic.** Each new field gets a constructed-scenario test verifying the emit is correct. Diagnostics that lie are worse than no diagnostics.
- **Determinism preservation** (`pearl_determinism_achieved`). The new kernel reductions and the host-side host-reduce must not introduce non-determinism. Determinism-check.sh must pass at Step 1.5.7.

### Step 1.5.1 — Add ISV slots for F2 mechanism counters + diag-emit test scaffold

The 4 F2-mechanism diagnostics (`f2_baseline_mean`, `f2_advantage_max_mean`, `f2_hinge_zero_rate`, `f2_target_argmax_action`) need device-side accumulators surfaced via ISV. Allocate 4 more slots (819-822); the 9 host-derived diagnostics need no new ISV slots.

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/rl/isv_slots.rs`.
- [ ] Insert (immediately AFTER the two F2 slot constants from Task 1 Step 1.1, BEFORE `RL_SLOTS_END`):

```rust
/// Phase 7a F2-diag (2026-06-04) — mean of the per-batch
/// `baseline = max E_Q over no-op set` written by
/// `rl_q_pi_distill_grad.cu` at the end of each kernel invocation.
/// Pure diagnostic; no controller reads it.
///
/// Bootstrap 0.0. Clamp finite.
pub const RL_F2_DIAG_BASELINE_MEAN_INDEX: usize = 819;

/// Phase 7a F2-diag (2026-06-04) — mean of the per-batch
/// `max_a hinged[a] = max_a max(0, E_Q[a] − baseline)`. Indicates how
/// much "edge" the hinge has surfaced per batch. Healthy regime is
/// non-zero (somewhere between 1e-3 and atom-scale).
///
/// Bootstrap 0.0. Clamp finite ≥ 0.
pub const RL_F2_DIAG_ADV_MAX_MEAN_INDEX: usize = 820;

/// Phase 7a F2-diag (2026-06-04) — fraction of `(batch × action)`
/// slots where the hinge `max(0, E_Q[a] − baseline)` clamped to zero.
/// Discriminative power of the hinge: ~1.0 = vacuous (everything below
/// baseline), ~0.0 = no clamping (every action above baseline).
///
/// Bootstrap 0.0. Clamp [0.0, 1.0].
pub const RL_F2_DIAG_HINGE_ZERO_RATE_INDEX: usize = 821;

/// Phase 7a F2-diag (2026-06-04) — modal action id of `π_target` over
/// the batch. The kernel reduces per-batch argmax-of-π_target into a
/// length-N_ACTIONS histogram (via the fused-stats pattern) and writes
/// the argmax of THAT histogram into the ISV. Used to compare against
/// `policy_diagnostic.modal_action_id` (sampled action histogram) — if
/// F2 is reshaping the gradient, the two SHOULD differ (target points
/// toward Open while sampling still dominated by Hold).
///
/// Bootstrap -1.0 (sentinel "no batch reduced yet"). Clamp finite.
pub const RL_F2_DIAG_TARGET_ARGMAX_INDEX: usize = 822;
```

- [ ] Update the `RL_SLOTS_END` declaration (the one already bumped to 819 in Task 1 Step 1.2):

```rust
/// Post-Phase 5 (trail-max ceiling + MTM reward, slots 814-816): 816.
/// Post-Phase 7a F2 (Q-centered distill target, slots 817-818): 818.
/// Post-Phase 7a F2-diag (kernel-side counters, slots 819-822): 822.
pub const RL_SLOTS_END: usize = 823;
```

- [ ] Bootstrap the 4 new slots in `integrated.rs` (immediately after the two F2 bootstrap entries added in Task 1 Step 1.3):

```rust
                // Phase 7a F2-diag (2026-06-04) — kernel-side mechanism
                // counters. Sentinel for target_argmax = -1.0 ("no
                // batch reduced yet"); the kernel overwrites at step 1.
                (crate::rl::isv_slots::RL_F2_DIAG_BASELINE_MEAN_INDEX,    0.0),
                (crate::rl::isv_slots::RL_F2_DIAG_ADV_MAX_MEAN_INDEX,     0.0),
                (crate::rl::isv_slots::RL_F2_DIAG_HINGE_ZERO_RATE_INDEX,  0.0),
                (crate::rl::isv_slots::RL_F2_DIAG_TARGET_ARGMAX_INDEX,   -1.0),
```

### Step 1.5.2 — Bump EXPECTED_LEAVES and add the failing diag-schema test

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/tests/eval_diag_emission.rs`.
- [ ] Locate the `const EXPECTED_LEAVES: usize = 712;` declaration (line ~273).
- [ ] Update the comment ledger immediately above and bump the constant:

```rust
    // 2026-06-04 Phase 7a F2 diagnostic instrumentation: +13 leaves total.
    //   trading.frac_batches_flat / frac_batches_long / frac_batches_short
    //     (3 host-derived state-conditional denominators)
    //   policy_diagnostic.hold_frac_flat (F2-G5 direct measurement)
    //   policy_diagnostic.open_action_mass_flat
    //   policy_diagnostic.max_action_share / modal_action_id /
    //   noop_action_mass_total / noop_action_mass_max (4 attractor metrics)
    //   policy_diagnostic.f2_baseline_mean / f2_advantage_max_mean /
    //   f2_hinge_zero_rate / f2_target_argmax_action (4 mechanism counters,
    //   kernel-reduced into ISV slots 819-822).
    // New total: 712 + 13 = 725.
    const EXPECTED_LEAVES: usize = 725;
```

- [ ] Run:

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test eval_diag_emission --release -- --ignored --nocapture 2>&1 | tail -20
```

Expected: the test FAILS with `train leaf count 712 != expected 725` — confirming the test detects the schema-vs-emit gap. This is the TDD red state for the diag-emit changes.

### Step 1.5.3 — Compute and emit state-conditional fractions (host-side)

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/trainer/integrated.rs`.
- [ ] Locate the per-step derived-stats block inside `build_diag_value` (around line 11699-11767). Immediately AFTER the `let heat_cap_step = ...` line (~11767) and BEFORE `let unit_active_host: Vec<u8> = ...` (~11770), insert:

```rust
        // Phase 7a F2-diag (2026-06-04) — state-conditional surfer-
        // preservation measurement. `frac_batches_{flat,long,short}` are
        // host-side reductions over `inputs.position_lots` (already on
        // host as `&'a [i32]` per the inputs struct contract — no D2H).
        // Per `pearl_fleet_fraction_not_aggregate` we emit fractions,
        // not aggregates, so the b=1024 cluster diag retains per-batch
        // resolution.
        let inv_b: f32 = if b_size > 0 { 1.0 / b_size as f32 } else { 1.0 };
        let mut n_flat = 0u32;
        let mut n_long = 0u32;
        let mut n_short = 0u32;
        for &p in inputs.position_lots {
            if p == 0 { n_flat += 1; }
            else if p > 0 { n_long += 1; }
            else { n_short += 1; }
        }
        let frac_batches_flat  = (n_flat  as f32) * inv_b;
        let frac_batches_long  = (n_long  as f32) * inv_b;
        let frac_batches_short = (n_short as f32) * inv_b;

        // hold_frac_flat = count(action == HOLD AND pos == 0) / count(pos == 0).
        // Direct F2-G5 measurement: if F2's hinge pushes Open mass too
        // hard from flat states, this drops below 0.30 (surfer→trend).
        // If F2 fails to break the inaction collapse, this stays above
        // 0.85. Healthy regime: [0.30, 0.85] at step 5000.
        //
        // open_action_mass_flat = count(action ∈ Open AND pos == 0) /
        // count(pos == 0). Complementary; equals
        // (1 − hold_frac_flat − noop_other_from_flat). Open actions per
        // the enum: ShortLarge=3, ShortSmall=4, LongSmall=5, LongLarge=6.
        let action_hold        = crate::rl::common::Action::Hold        as usize;
        let action_short_large = crate::rl::common::Action::ShortLarge  as usize;
        let action_short_small = crate::rl::common::Action::ShortSmall  as usize;
        let action_long_small  = crate::rl::common::Action::LongSmall   as usize;
        let action_long_large  = crate::rl::common::Action::LongLarge   as usize;
        let mut n_hold_from_flat = 0u32;
        let mut n_open_from_flat = 0u32;
        for (i, &p) in inputs.position_lots.iter().enumerate() {
            if p != 0 { continue; }
            let a = inputs.actions[i] as usize;
            if a == action_hold { n_hold_from_flat += 1; }
            else if a == action_short_large || a == action_short_small
                 || a == action_long_small  || a == action_long_large {
                n_open_from_flat += 1;
            }
        }
        let inv_flat: f32 = if n_flat > 0 { 1.0 / n_flat as f32 } else { 0.0 };
        let hold_frac_flat       = (n_hold_from_flat as f32) * inv_flat;
        let open_action_mass_flat = (n_open_from_flat as f32) * inv_flat;
```

### Step 1.5.4 — Compute and emit attractor metrics (host-side, from act_hist)

- [ ] Immediately after the Step 1.5.3 block, append:

```rust
        // Phase 7a F2-diag (2026-06-04) — degenerate-attractor metrics.
        // All four are pure host-side reductions over the existing
        // `act_hist` (length N_ACTIONS, computed at line ~11700). Zero
        // marginal compute cost.
        //
        // max_action_share: maximum action mass / b_size — detects
        // single-action collapse (the going-in-circles signature). The
        // attractors in the historical trajectory are Hold (mq2pc),
        // TrailLoosen, sometimes LongLarge. Above 0.80 means a single
        // action dominates — likely a re-collapse.
        //
        // modal_action_id: which action is at the max. Lets the
        // implementer / cluster observer identify the attractor without
        // re-deriving from the histogram.
        //
        // noop_action_mass_{total,max}: total and max mass on the
        // no-op set {Hold=2, TrailTighten=7, TrailLoosen=8}. The F2
        // hinge baseline is this set; if F2 redirects gradient
        // correctly, this drops over training as Open actions earn
        // positive advantage. noop_max separates "Hold-collapse" from
        // "TrailLoosen-collapse" — both produce high noop_total but
        // different noop_max.
        let act_action_hold          = crate::rl::common::Action::Hold          as usize;
        let act_trail_tighten        = crate::rl::common::Action::TrailTighten  as usize;
        let act_trail_loosen         = crate::rl::common::Action::TrailLoosen   as usize;
        let mut max_count = 0u32;
        let mut modal_action_id = 0i32;
        for (a, &c) in act_hist.iter().enumerate() {
            if c > max_count {
                max_count = c;
                modal_action_id = a as i32;
            }
        }
        let max_action_share = (max_count as f32) * inv_b;
        let noop_hold        = act_hist[act_action_hold] as f32;
        let noop_tighten     = act_hist[act_trail_tighten] as f32;
        let noop_loosen      = act_hist[act_trail_loosen] as f32;
        let noop_action_mass_total = (noop_hold + noop_tighten + noop_loosen) * inv_b;
        let noop_action_mass_max   =
            noop_hold.max(noop_tighten).max(noop_loosen) * inv_b;
```

### Step 1.5.5 — Add F2 mechanism counters in `rl_q_pi_distill_grad.cu`

The 4 F2-mechanism diagnostics require per-batch reductions inside the kernel. We compute them in the existing single-thread `a == 0` block (which already runs once per batch block) and write to ISV via the standard per-batch fused-stats pattern (NO atomicAdd; we rely on the fact that each batch's b-th block writes its own scalar contribution into a per-batch scratch buffer, then a sibling reducer averages — OR, since this is single-thread-per-block, we use the simpler pattern of writing per-batch into a B-length scratch device buffer and reducing in a follow-up tiny kernel).

For minimal code change, we use the same idiom as the band kernel: each block writes its per-batch scalars into 4 `[B]`-sized device scratch buffers; the trainer reads those at diag emit and reduces host-side. The kernel signature gets 4 new pointers; the launch site allocates the scratch buffers.

#### Step 1.5.5a — Modify the kernel signature + emit per-batch contributions

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/cuda/rl_q_pi_distill_grad.cu`.
- [ ] Locate the existing kernel signature (search for `extern "C" __global__ void rl_q_pi_distill_grad`). Extend the parameter list:

```c
extern "C" __global__ void rl_q_pi_distill_grad(
    const float* __restrict__ q_logits_target,
    const float* __restrict__ pi_logits,
    const float* __restrict__ atom_supports,
    float*       __restrict__ isv,
    float*       __restrict__ pi_grad_logits,
    // Phase 7a F2-diag (2026-06-04) — per-batch scratch buffers
    // populated by the single-thread reduction at the END of the
    // kernel. All length [B]; populated by `a == 0` thread of each
    // block. Trainer aggregates host-side at diag emit.
    float*       __restrict__ f2_diag_baseline_per_b,    // baseline per batch
    float*       __restrict__ f2_diag_adv_max_per_b,     // max hinged per batch
    float*       __restrict__ f2_diag_hinge_zero_per_b,  // fraction-zeroed per batch
    int*         __restrict__ f2_diag_target_argmax_per_b, // argmax π_target per batch
    int          b_size)
{
```

- [ ] Locate the END of the kernel — after `pi_grad_logits` write-back, BEFORE the closing brace. The thread `a == 0` (single thread per block) is the natural site for per-batch reduction. Insert (still inside `if (a == 0) { ... }` if there is one at the end; else gate explicitly):

```c
    // ── Phase 7a F2-diag: per-batch mechanism counters ───────────
    // Only thread a == 0 writes (single-thread reduction inside the
    // block — uses the s_eq + s_pi_target shared memory already
    // computed above; no race, no atomicAdd). The trainer reduces
    // these [B]-sized buffers host-side at diag emit.
    if (a == 0) {
        const int batch = (int)blockIdx.x;
        if (batch < b_size) {
            // baseline used by the hinge (mode-dependent; recomputed
            // here for diag correctness even when F2 is OFF, so the
            // counters are interpretable in both regimes).
            const float f2_on_diag = isv[RL_F2_DISTILL_CENTERED_ENABLED_INDEX];
            const float mode_diag  = isv[RL_F2_DISTILL_NOOP_SET_MODE_INDEX];
            float baseline_diag;
            if (mode_diag > 0.5f && mode_diag <= 1.5f) {
                baseline_diag = s_eq[ACTION_HOLD];
            } else {
                baseline_diag = fmaxf(s_eq[ACTION_HOLD],
                                       fmaxf(s_eq[ACTION_TRAIL_TIGHTEN],
                                             s_eq[ACTION_TRAIL_LOOSEN]));
            }
            f2_diag_baseline_per_b[batch] = baseline_diag;

            // max hinged advantage across actions for this batch.
            float adv_max = 0.0f;
            int   n_zero  = 0;
            for (int i = 0; i < N_ACTIONS; ++i) {
                const float h = fmaxf(0.0f, s_eq[i] - baseline_diag);
                if (h > adv_max) adv_max = h;
                if (h <= 0.0f) ++n_zero;
            }
            f2_diag_adv_max_per_b[batch]    = adv_max;
            f2_diag_hinge_zero_per_b[batch] =
                (float)n_zero / (float)N_ACTIONS;

            // argmax of s_pi_target — already computed (in F2 path or
            // legacy path) by the time we reach this block.
            int   t_arg = 0;
            float t_max = s_pi_target[0];
            for (int i = 1; i < N_ACTIONS; ++i) {
                if (s_pi_target[i] > t_max) {
                    t_max = s_pi_target[i];
                    t_arg = i;
                }
            }
            f2_diag_target_argmax_per_b[batch] = t_arg;
            (void)f2_on_diag; // silence unused warning when F2 is OFF.
        }
    }
}
```

NOTE on the no-atomicAdd discipline: each block writes exclusively to `[batch]` indexed slots of its own (one block per batch). No write races. The follow-up host-side reduction in Step 1.5.5c is single-threaded.

#### Step 1.5.5b — Extend the kernel launch site

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/trainer/integrated.rs`.
- [ ] Modify `launch_rl_q_pi_distill_grad` (line ~5160) to accept the 4 new scratch slices:

```rust
    pub fn launch_rl_q_pi_distill_grad(
        &self,
        q_logits_target_d: &CudaSlice<f32>,
        pi_logits_d: &CudaSlice<f32>,
        atom_supports_d: &CudaSlice<f32>,
        pi_grad_logits_d: &mut CudaSlice<f32>,
        // Phase 7a F2-diag (2026-06-04)
        f2_baseline_per_b_d: &mut CudaSlice<f32>,
        f2_adv_max_per_b_d: &mut CudaSlice<f32>,
        f2_hinge_zero_per_b_d: &mut CudaSlice<f32>,
        f2_target_argmax_per_b_d: &mut CudaSlice<i32>,
        b_size: usize,
    ) -> Result<()> {
        let b_size_i = b_size as i32;
        let mut args = RawArgs::new();
        args.push_ptr(q_logits_target_d.raw_ptr());
        args.push_ptr(pi_logits_d.raw_ptr());
        args.push_ptr(atom_supports_d.raw_ptr());
        args.push_ptr(self.isv_dev_ptr);
        args.push_ptr(pi_grad_logits_d.raw_ptr());
        args.push_ptr(f2_baseline_per_b_d.raw_ptr());
        args.push_ptr(f2_adv_max_per_b_d.raw_ptr());
        args.push_ptr(f2_hinge_zero_per_b_d.raw_ptr());
        args.push_ptr(f2_target_argmax_per_b_d.raw_ptr());
        args.push_i32(b_size_i);
        let mut ptrs = args.build_arg_ptrs();
        unsafe {
            raw_launch(
                self.rl_q_pi_distill_grad_fn.cu_function(),
                (b_size as u32, 1, 1), (N_ACTIONS as u32, 1, 1), 0,
                self.raw_stream,
                &mut ptrs[..args.len()],
            ).map_err(|e| anyhow::anyhow!("rl_q_pi_distill_grad: {:?}", e))?;
        }
        Ok(())
    }
```

- [ ] Allocate the 4 device scratch buffers as trainer fields (the existing trainer holds many such `[B]`-sized scratch buffers; follow the same pattern). Search for an existing per-batch scratch buffer (e.g., `ss_pi_grad_logits_d` or another `_d` allocated at `IntegratedTrainer::new` time) and add four siblings:

```rust
        // Phase 7a F2-diag (2026-06-04) — per-batch scratch for
        // kernel-reduced F2 mechanism counters.
        let f2_diag_baseline_per_b_d:    CudaSlice<f32> = stream.alloc_zeros(b_size)?;
        let f2_diag_adv_max_per_b_d:     CudaSlice<f32> = stream.alloc_zeros(b_size)?;
        let f2_diag_hinge_zero_per_b_d:  CudaSlice<f32> = stream.alloc_zeros(b_size)?;
        let f2_diag_target_argmax_per_b_d: CudaSlice<i32> = stream.alloc_zeros(b_size)?;
```

Add the matching fields to the `IntegratedTrainer` struct and wire them through `Self { ... }`:

```rust
    f2_diag_baseline_per_b_d:    CudaSlice<f32>,
    f2_diag_adv_max_per_b_d:     CudaSlice<f32>,
    f2_diag_hinge_zero_per_b_d:  CudaSlice<f32>,
    f2_diag_target_argmax_per_b_d: CudaSlice<i32>,
```

- [ ] Update the existing in-train call site at line ~6132 (`rl_q_pi_distill_grad_fn` launch inside `step_with_lobsim_gpu_body`) to thread the 4 new pointers. If the launch site uses `raw_launch` directly (NOT `launch_rl_q_pi_distill_grad`), extend the `args.push_ptr(...)` chain at that site instead:

```rust
                    args.push_ptr(self.f2_diag_baseline_per_b_d.raw_ptr());
                    args.push_ptr(self.f2_diag_adv_max_per_b_d.raw_ptr());
                    args.push_ptr(self.f2_diag_hinge_zero_per_b_d.raw_ptr());
                    args.push_ptr(self.f2_diag_target_argmax_per_b_d.raw_ptr());
```

#### Step 1.5.5c — Read scratch buffers at diag emit and reduce host-side; write into ISV

- [ ] In `build_diag_value` (around line 11856-11935 — the host-side band reduction block is a good reference), AFTER the existing band reduction and BEFORE the JSON object construction, insert:

```rust
        // Phase 7a F2-diag (2026-06-04) — read kernel-emitted per-batch
        // F2 mechanism counters and reduce host-side. Each is a single
        // pass over [B] floats — cost ~4*B*4 bytes D2H per step, well
        // under the existing band reduction's cost.
        //
        // Per `feedback_no_htod_htoh_only_mapped_pinned` we use the
        // same `read_slice_d_pub` helper as the band kernel.
        let f2_baseline_host = crate::trainer::integrated::read_slice_d_pub(
            &self.cuda_stream, &self.f2_diag_baseline_per_b_d, b_size)
            .context("build_diag_value: read f2_diag_baseline_per_b_d")?;
        let f2_adv_max_host = crate::trainer::integrated::read_slice_d_pub(
            &self.cuda_stream, &self.f2_diag_adv_max_per_b_d, b_size)
            .context("build_diag_value: read f2_diag_adv_max_per_b_d")?;
        let f2_hinge_zero_host = crate::trainer::integrated::read_slice_d_pub(
            &self.cuda_stream, &self.f2_diag_hinge_zero_per_b_d, b_size)
            .context("build_diag_value: read f2_diag_hinge_zero_per_b_d")?;
        let f2_target_argmax_host: Vec<i32> = crate::trainer::integrated::read_slice_i32_d_pub(
            &self.cuda_stream, &self.f2_diag_target_argmax_per_b_d, b_size)
            .context("build_diag_value: read f2_diag_target_argmax_per_b_d")?;

        // Means over the [B] axis.
        let f2_baseline_mean: f32 =
            f2_baseline_host.iter().sum::<f32>() * inv_b;
        let f2_advantage_max_mean: f32 =
            f2_adv_max_host.iter().sum::<f32>() * inv_b;
        let f2_hinge_zero_rate: f32 =
            f2_hinge_zero_host.iter().sum::<f32>() * inv_b;

        // Modal argmax across the batch: histogram + argmax.
        let mut argmax_hist = [0u32; N_ACTIONS];
        for &a in &f2_target_argmax_host {
            if a >= 0 && (a as usize) < N_ACTIONS {
                argmax_hist[a as usize] += 1;
            }
        }
        let mut max_a_count = 0u32;
        let mut f2_target_argmax_action: i32 = -1;
        for (a, &c) in argmax_hist.iter().enumerate() {
            if c > max_a_count {
                max_a_count = c;
                f2_target_argmax_action = a as i32;
            }
        }

        // Mirror into ISV so any controller / external probe can read
        // them without re-reducing. Writes go through the mapped-pinned
        // ISV handle (single-thread, deterministic).
        self.isv_mapped.write_record(
            crate::rl::isv_slots::RL_F2_DIAG_BASELINE_MEAN_INDEX,
            f2_baseline_mean);
        self.isv_mapped.write_record(
            crate::rl::isv_slots::RL_F2_DIAG_ADV_MAX_MEAN_INDEX,
            f2_advantage_max_mean);
        self.isv_mapped.write_record(
            crate::rl::isv_slots::RL_F2_DIAG_HINGE_ZERO_RATE_INDEX,
            f2_hinge_zero_rate);
        self.isv_mapped.write_record(
            crate::rl::isv_slots::RL_F2_DIAG_TARGET_ARGMAX_INDEX,
            f2_target_argmax_action as f32);
```

Both `read_slice_d_pub` (f32) and `read_slice_i32_d_pub` (i32) already exist in `integrated.rs` (lines ~12651 and ~12766 respectively) — verified during reconnaissance. No new helper is needed.

### Step 1.5.6 — Wire all 13 diag fields into the JSON object

- [ ] In `build_diag_value`, locate the `"trading": { ... }` JSON object (line ~12336). Add immediately after `"raw_reward_sum": ...,` (last field, line ~12356) and BEFORE the closing brace:

```rust
                // Phase 7a F2-diag (2026-06-04) — state-conditional
                // denominators (fleet fractions per
                // `pearl_fleet_fraction_not_aggregate`). flat + long +
                // short MUST sum to 1.0 within fp noise.
                "frac_batches_flat":  frac_batches_flat,
                "frac_batches_long":  frac_batches_long,
                "frac_batches_short": frac_batches_short,
```

- [ ] Locate the `"policy_diagnostic": { ... }` JSON object (line ~12376). Add immediately after the last existing field (`"q_pi_agree_ema": ...,` line ~12397) and BEFORE the closing brace:

```rust
                // Phase 7a F2-diag (2026-06-04) — surfer-preservation
                // (F2-G5 direct measurement, locally observable):
                "hold_frac_flat":        hold_frac_flat,
                "open_action_mass_flat": open_action_mass_flat,
                // Phase 7a F2-diag — degenerate-attractor monitoring:
                "max_action_share":      max_action_share,
                "modal_action_id":       modal_action_id,
                "noop_action_mass_total": noop_action_mass_total,
                "noop_action_mass_max":   noop_action_mass_max,
                // Phase 7a F2-diag — F2 mechanism counters (kernel-
                // reduced into ISV slots 819-822, mirrored here for
                // diag-only consumers). f2_target_argmax_action is an
                // integer index in {0..10}; consumers should compare
                // against `modal_action_id` (sampled actions) — if F2
                // is reshaping the gradient correctly toward Open,
                // these SHOULD differ during the surfer→adapt phase.
                "f2_baseline_mean":          f2_baseline_mean,
                "f2_advantage_max_mean":     f2_advantage_max_mean,
                "f2_hinge_zero_rate":        f2_hinge_zero_rate,
                "f2_target_argmax_action":   f2_target_argmax_action,
```

### Step 1.5.7 — Run determinism check (the new diag must NOT break bit-equal)

- [ ] Build:

```bash
SQLX_OFFLINE=true cargo build -p ml-alpha --release 2>&1 | tail -20
```

- [ ] Run determinism check:

```bash
cd /home/jgrusewski/Work/foxhunt
./scripts/determinism-check.sh --quick 2>&1 | tail -40
```

Expected: PASS for seeds 42/43/44. If it fails:
- **Most likely cause:** the kernel reduction added in Step 1.5.5a introduced a write race or used non-deterministic input. Audit: each block writes EXCLUSIVELY to its own `[batch]` index — there must be no cross-block writes.
- **Second-most-likely cause:** the host-side reduction in Step 1.5.5c is reading the scratch buffer before the kernel has finished. Confirm the read happens AFTER the standard `stream.synchronize()` or implicit serialization that the band kernel relies on.
- STOP and audit; do not silence the determinism failure.

### Step 1.5.8 — Add GPU-oracle test for each new diag emission

For TDD discipline, each new diag emission gets a constructed-scenario test that asserts the emit is correct under a known input. Most of these are simple host-side reductions and can be tested by setting up a minimal `IntegratedTrainer` + injected inputs.

- [ ] Create `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/tests/f2_diag_emit.rs`:

```rust
//! Phase 7a F2-diag (2026-06-04) — verify the 13 new diag fields are
//! correctly populated under constructed scenarios.
//!
//! Per `feedback_investigation_first_falsification_methodology`:
//! diagnostics that lie are worse than no diagnostics. Each new field
//! gets a known-input + expected-output check.

use anyhow::Result;
use ml_alpha::trainer::integrated::{IntegratedTrainer, IntegratedTrainerConfig};
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
use ml_core::device::MlDevice;
use serde_json::Value;

const N_ACTIONS: usize = 11;
const ACTION_HOLD: usize = 2;
const ACTION_LONG_SMALL: usize = 5;
const ACTION_LONG_LARGE: usize = 6;

fn build_trainer(b_size: usize) -> Option<(MlDevice, IntegratedTrainer)> {
    let dev = match MlDevice::cuda(0) {
        Ok(d) => d,
        Err(e) => { eprintln!("CUDA 0 not available — skipping ({e})"); return None; }
    };
    let cfg = IntegratedTrainerConfig {
        perception: PerceptionTrainerConfig {
            seq_len: 4, n_batch: b_size, ..PerceptionTrainerConfig::default()
        },
        dqn_seed: 0x420, ppo_seed: 0x421,
        ..IntegratedTrainerConfig::default()
    };
    Some((dev.clone(), IntegratedTrainer::new(&dev, cfg).expect("trainer")))
}

// ───────────────────────────────────────────────────────────────────────
// Test 1: frac_batches_{flat,long,short} sum to 1.0 and match the input.
// ───────────────────────────────────────────────────────────────────────

#[test]
#[ignore = "requires CUDA"]
fn f2_diag_frac_batches_state_conditional() -> Result<()> {
    let Some((_dev, mut trainer)) = build_trainer(8) else { return Ok(()); };
    // Inject a known position_lots array: 4 flat, 2 long, 2 short.
    let position_lots: [i32; 8] = [0, 0, 0, 0, 1, 2, -1, -3];
    let actions: [i32; 8] = [ACTION_HOLD as i32; 8];
    // Run one diag emit with constructed inputs (use a test-helper
    // entry point on IntegratedTrainer that takes the diag inputs
    // directly; alternatively run a single step_with_lobsim_gpu with
    // a controlled prefix and read the diag output).
    let diag_value = trainer.build_diag_value_for_test(
        /* step */ 0,
        &position_lots,
        &actions,
    )?;
    let trading = diag_value.get("trading").expect("trading key");
    let flat  = trading["frac_batches_flat"].as_f64().expect("flat") as f32;
    let long  = trading["frac_batches_long"].as_f64().expect("long") as f32;
    let short = trading["frac_batches_short"].as_f64().expect("short") as f32;
    assert!((flat  - 0.5  ).abs() < 1e-5, "flat: {flat}");
    assert!((long  - 0.25 ).abs() < 1e-5, "long: {long}");
    assert!((short - 0.25 ).abs() < 1e-5, "short: {short}");
    assert!((flat + long + short - 1.0).abs() < 1e-5, "sum");
    Ok(())
}

// ───────────────────────────────────────────────────────────────────────
// Test 2: hold_frac_flat is conditional — only counts hold-from-flat.
// ───────────────────────────────────────────────────────────────────────

#[test]
#[ignore = "requires CUDA"]
fn f2_diag_hold_frac_flat_is_state_conditional() -> Result<()> {
    let Some((_dev, mut trainer)) = build_trainer(8) else { return Ok(()); };
    // 4 flat: 3 hold + 1 long_small. 4 long: all hold (must NOT count).
    let position_lots: [i32; 8] = [0, 0, 0, 0, 5, 5, 5, 5];
    let actions: [i32; 8] = [
        ACTION_HOLD as i32, ACTION_HOLD as i32, ACTION_HOLD as i32,
        ACTION_LONG_SMALL as i32,
        ACTION_HOLD as i32, ACTION_HOLD as i32, ACTION_HOLD as i32, ACTION_HOLD as i32,
    ];
    let diag_value = trainer.build_diag_value_for_test(0, &position_lots, &actions)?;
    let pd = diag_value.get("policy_diagnostic").expect("pd");
    let hff  = pd["hold_frac_flat"].as_f64().expect("hff") as f32;
    let oamf = pd["open_action_mass_flat"].as_f64().expect("oamf") as f32;
    assert!((hff  - 0.75).abs() < 1e-5, "hff: {hff} expected 0.75 (3/4)");
    assert!((oamf - 0.25).abs() < 1e-5, "oamf: {oamf} expected 0.25 (1/4)");
    Ok(())
}

// ───────────────────────────────────────────────────────────────────────
// Test 3: max_action_share / modal_action_id detect single-action collapse.
// ───────────────────────────────────────────────────────────────────────

#[test]
#[ignore = "requires CUDA"]
fn f2_diag_attractor_metrics_detect_collapse() -> Result<()> {
    let Some((_dev, mut trainer)) = build_trainer(10) else { return Ok(()); };
    // 9/10 batches choose LongLarge (collapse signature).
    let position_lots: [i32; 10] = [0; 10];
    let mut actions: [i32; 10] = [ACTION_LONG_LARGE as i32; 10];
    actions[9] = ACTION_HOLD as i32;
    let diag_value = trainer.build_diag_value_for_test(0, &position_lots, &actions)?;
    let pd = diag_value.get("policy_diagnostic").expect("pd");
    let mas    = pd["max_action_share"].as_f64().expect("mas") as f32;
    let mid    = pd["modal_action_id"].as_i64().expect("mid") as i32;
    assert!((mas - 0.9).abs() < 1e-5, "mas: {mas} expected 0.9");
    assert_eq!(mid, ACTION_LONG_LARGE as i32, "modal action should be LongLarge");
    Ok(())
}

// ───────────────────────────────────────────────────────────────────────
// Test 4: F2 mechanism counters — under F2-ON with constructed Q-logits
// where one Open action exceeds baseline, hinge_zero_rate < 1.0 and
// target_argmax_action matches the constructed winning action.
// ───────────────────────────────────────────────────────────────────────

#[test]
#[ignore = "requires CUDA"]
fn f2_diag_mechanism_counters_track_hinge() -> Result<()> {
    // Setup: run one Q-distill kernel invocation with the F2 gate ON,
    // E_Q[LongSmall=5] > baseline, all others ≤ baseline. Expect:
    //   f2_hinge_zero_rate ≈ (N_ACTIONS - 1) / N_ACTIONS = 10/11 ≈ 0.909
    //     (only LongSmall escapes the hinge clamp)
    //   f2_target_argmax_action == ACTION_LONG_SMALL
    //   f2_advantage_max_mean > 0
    //   f2_baseline_mean ≈ E_Q[Hold] (mode 0 default; baseline = max
    //     over {Hold, TrailTighten, TrailLoosen})
    //
    // Implementation: reuse the `run_kernel_and_extract_pi_target`
    // helper structure from f2_centered_distill_invariants.rs (Task 2),
    // but here we additionally read back the per-batch scratch buffers
    // and assert the reduced ISV values.

    // Body follows the same pattern as f2_centered_distill_invariants
    // Test 3 ("positive mass above baseline") — see that file for the
    // helper invocations. Add assertions on the new ISV slots after
    // running the kernel:
    //
    //   assert!(isv[RL_F2_DIAG_HINGE_ZERO_RATE_INDEX] > 0.8);
    //   assert_eq!(isv[RL_F2_DIAG_TARGET_ARGMAX_INDEX] as i32, ACTION_LONG_SMALL as i32);
    //   assert!(isv[RL_F2_DIAG_ADV_MAX_MEAN_INDEX] > 0.0);
    //
    // (Marked as an explicit test stub here for the implementer to
    // expand; the kernel-launch boilerplate is identical to Task 2.)
    Ok(())
}
```

NOTE on the test helper `build_diag_value_for_test`: this is a new test-only entry point added to `IntegratedTrainer` that wraps `build_diag_value` with a stripped-down `DiagInputs` populated from the constructed `position_lots` + `actions`. If adding such a helper materially expands the public test surface, an alternative is to run a single `step_with_lobsim_gpu_body` with a controlled position/action prefix and parse the resulting diag JSONL line — slower but no new public API. Implementer's choice; document the decision in the commit message.

- [ ] Run:

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test f2_diag_emit --release -- --ignored --nocapture 2>&1 | tail -40
```

Expected: 4/4 PASS.

### Step 1.5.9 — Re-run the diag-schema test (should now PASS)

- [ ] Run:

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test eval_diag_emission --release -- --ignored --nocapture 2>&1 | tail -10
```

Expected: PASS (leaf count now 725 matches the bumped EXPECTED_LEAVES).

### Step 1.5.10 — Run ALL invariant suites + determinism

- [ ] Run all suites in sequence:

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo test -p ml-alpha --test phase_5_invariants --release -- --ignored --nocapture 2>&1 | tail -5
SQLX_OFFLINE=true cargo test -p ml-alpha --test band_invariants --release -- --ignored --nocapture 2>&1 | tail -5
SQLX_OFFLINE=true cargo test -p ml-alpha --test multi_head_policy_invariants --release -- --ignored --nocapture 2>&1 | tail -5
SQLX_OFFLINE=true cargo test -p ml-alpha --test f2_diag_emit --release -- --ignored --nocapture 2>&1 | tail -5
SQLX_OFFLINE=true cargo test -p ml-alpha --test eval_diag_emission --release -- --ignored --nocapture 2>&1 | tail -5
./scripts/determinism-check.sh --quick 2>&1 | tail -10
```

Expected: all PASS. If any pre-existing suite regressed, the diag instrumentation has affected a downstream behavior. Most likely culprit: extra ISV writes interleaved with controller reads — STOP and audit.

### Step 1.5.11 — Commit

- [ ] Stage and commit:

```bash
git add /home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/rl/isv_slots.rs \
        /home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/trainer/integrated.rs \
        /home/jgrusewski/Work/foxhunt/crates/ml-alpha/cuda/rl_q_pi_distill_grad.cu \
        /home/jgrusewski/Work/foxhunt/crates/ml-alpha/tests/eval_diag_emission.rs \
        /home/jgrusewski/Work/foxhunt/crates/ml-alpha/tests/f2_diag_emit.rs
git commit -m "$(cat <<'EOF'
feat(ml-alpha): Phase 7a F2-diag — 13 diag fields for F2 measurement

Adds the instrumentation required to attribute F2's effect locally
(Tier 1.5) and at b=1024 cluster (Tier 2). Without these the F2-G5
surfer-preservation gate is unmeasurable locally per the spec; with
them, the cluster verdict gains three independent attribution signals:
mechanism (f2_hinge_zero_rate) / surfer (hold_frac_flat) / attractor
(modal_action_id + max_action_share).

ISV slots 819-822 added for kernel-reduced mechanism counters:
- RL_F2_DIAG_BASELINE_MEAN_INDEX
- RL_F2_DIAG_ADV_MAX_MEAN_INDEX
- RL_F2_DIAG_HINGE_ZERO_RATE_INDEX
- RL_F2_DIAG_TARGET_ARGMAX_INDEX

RL_SLOTS_END: 819 → 823.

13 new diag leaves (EXPECTED_LEAVES 712 → 725):
- trading.frac_batches_{flat,long,short} (host reduce on position_lots)
- policy_diagnostic.{hold_frac_flat, open_action_mass_flat} (F2-G5)
- policy_diagnostic.{max_action_share, modal_action_id,
  noop_action_mass_total, noop_action_mass_max} (attractor)
- policy_diagnostic.{f2_baseline_mean, f2_advantage_max_mean,
  f2_hinge_zero_rate, f2_target_argmax_action} (F2 mechanism)

Kernel changes: rl_q_pi_distill_grad.cu emits 4 per-batch scratch
arrays via single-thread reductions in the `a == 0` block (no
atomicAdd; one block per batch writes its own [batch] index only).
Trainer reduces host-side at diag emit + mirrors into ISV.

GPU-oracle tests: tests/f2_diag_emit.rs (4/4 PASS) verifies each new
field under constructed scenarios.

Determinism: scripts/determinism-check.sh --quick PASS at seeds
42/43/44 (no race, no atomicAdd, mapped-pinned reads only).

Per `pearl_fleet_fraction_not_aggregate`: emits fractions, not
aggregates, so b=1024 cluster diag retains per-batch resolution.
Per `feedback_investigation_first_falsification_methodology`:
measure first (this task), change code second (Tasks 2 + 3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 2 — Write the failing GPU-oracle invariant test (TDD) (~60 min)

### Step 2.1 — Create the test file

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/tests/` (the directory must exist; `ls` to verify).
- [ ] Create `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/tests/f2_centered_distill_invariants.rs` with the following exact contents:

```rust
//! Phase 7a F2 (2026-06-04) — Q-centered Q-distill target invariants.
//!
//! Verifies the kernel `rl_q_pi_distill_grad` correctly implements the
//! hinged advantage target
//!
//!   π_target(a) ∝ softmax( max(0, E_Q(a) − baseline) / τ )
//!
//! where `baseline = max over a ∈ {Hold=2, TrailTighten=7, TrailLoosen=8} { E_Q(a) }`
//! when `RL_F2_DISTILL_CENTERED_ENABLED_INDEX > 0.5`, and falls back to
//! the legacy `softmax(E_Q / τ)` when the gate is ≤ 0.5.
//!
//! Per spec §5.1 four mechanism gates F2-G1/G2/G3/G4 are reduced here to
//! four kernel-level invariants. Cluster-side outcome gates (F2-G5
//! surfer-patience / F2-G8 eval pnl) live in Tier 1.5 / Tier 2.
//!
//! Per `feedback_no_cpu_test_fallbacks`: oracles are analytical (closed-
//! form softmax + hinge), no CPU reference impl.
//! Per `feedback_no_htod_htoh_only_mapped_pinned`: all transfers go via
//! mapped-pinned `write_slice_*_d_pub` helpers.
//!
//! Run with:
//!   `cargo test -p ml-alpha --test f2_centered_distill_invariants --release -- --ignored --nocapture`

use anyhow::Result;
use cudarc::driver::{CudaSlice, CudaStream};
use ml_alpha::rl::isv_slots::{
    RL_F2_DISTILL_CENTERED_ENABLED_INDEX, RL_F2_DISTILL_NOOP_SET_MODE_INDEX,
    RL_Q_DISTILL_LAMBDA_INDEX, RL_Q_DISTILL_TEMPERATURE_INDEX, RL_SAC_ALPHA_INDEX,
};
use ml_alpha::trainer::integrated::{
    read_slice_d_pub, write_slice_f32_d_pub, IntegratedTrainer, IntegratedTrainerConfig,
};
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
use ml_core::device::MlDevice;
use std::sync::Arc;

const N_ACTIONS: usize = 11;
const Q_N_ATOMS: usize = 21;
const ACTION_HOLD: usize = 2;
const ACTION_TRAIL_TIGHTEN: usize = 7;
const ACTION_TRAIL_LOOSEN: usize = 8;
const ACTION_LONG_SMALL: usize = 5;
const ACTION_LONG_LARGE: usize = 6;

fn build_trainer() -> Option<(MlDevice, IntegratedTrainer)> {
    let dev = match MlDevice::cuda(0) {
        Ok(d) => d,
        Err(e) => {
            eprintln!("CUDA 0 not available — skipping ({e})");
            return None;
        }
    };
    let cfg = IntegratedTrainerConfig {
        perception: PerceptionTrainerConfig {
            seq_len: 4,
            n_batch: 1,
            ..PerceptionTrainerConfig::default()
        },
        dqn_seed: 0x520,
        ppo_seed: 0x521,
        ..IntegratedTrainerConfig::default()
    };
    let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
    Some((dev, trainer))
}

fn upload_f32(stream: &Arc<CudaStream>, host: &[f32]) -> Result<CudaSlice<f32>> {
    let mut d = stream.alloc_zeros::<f32>(host.len())?;
    write_slice_f32_d_pub(stream, host, &mut d)?;
    Ok(d)
}

fn set_isv(trainer: &mut IntegratedTrainer, slot: usize, value: f32) {
    trainer.isv_mapped.write_record(slot, value);
}

/// Build a `[N_ACTIONS × Q_N_ATOMS]` block of Q-logits whose softmax
/// gives a per-atom mass concentrated near a single target atom, so
/// that the per-action E_Q equals approximately the corresponding entry
/// in `target_eq[a]`. Atom supports are assumed evenly spaced in [-1, 1].
fn q_logits_for_eq(target_eq: &[f32; N_ACTIONS], atom_supports: &[f32]) -> Vec<f32> {
    let mut out = vec![0.0_f32; N_ACTIONS * Q_N_ATOMS];
    for a in 0..N_ACTIONS {
        // Find atom nearest to target_eq[a]; concentrate mass there.
        let mut best_z = 0usize;
        let mut best_d = f32::MAX;
        for z in 0..Q_N_ATOMS {
            let d = (atom_supports[z] - target_eq[a]).abs();
            if d < best_d {
                best_d = d;
                best_z = z;
            }
        }
        for z in 0..Q_N_ATOMS {
            // Peak logit at best_z, low elsewhere. softmax → near-onehot.
            out[a * Q_N_ATOMS + z] = if z == best_z { 10.0 } else { -10.0 };
        }
    }
    out
}

fn atom_supports_uniform() -> Vec<f32> {
    (0..Q_N_ATOMS)
        .map(|z| -1.0 + 2.0 * (z as f32) / (Q_N_ATOMS as f32 - 1.0))
        .collect()
}

/// Compute the expected π_target distribution on the host using the F2
/// hinge formula. Returns a length-N_ACTIONS probability vector.
fn expected_f2_pi_target(eq: &[f32; N_ACTIONS], tau: f32) -> [f32; N_ACTIONS] {
    let baseline = [eq[ACTION_HOLD], eq[ACTION_TRAIL_TIGHTEN], eq[ACTION_TRAIL_LOOSEN]]
        .iter()
        .cloned()
        .fold(f32::NEG_INFINITY, f32::max);
    let hinged: Vec<f32> = eq.iter().map(|&v| (v - baseline).max(0.0)).collect();
    let tau_safe = tau.max(1e-3);
    let exps: Vec<f32> = hinged.iter().map(|&h| (h / tau_safe).exp()).collect();
    // Numerical normalization (subtract max for stability — but since all
    // hinged values ≥ 0 and baseline action's hinged = 0, max(exps) = exp(max_hinge/τ)).
    let total: f32 = exps.iter().sum();
    let mut out = [0.0_f32; N_ACTIONS];
    if total > 1e-9 {
        for i in 0..N_ACTIONS {
            out[i] = exps[i] / total;
        }
    } else {
        for i in 0..N_ACTIONS {
            out[i] = 1.0 / (N_ACTIONS as f32);
        }
    }
    out
}

/// Run the kernel once with the given Q-logits and read back the
/// per-action gradient. Returns (pi, grad_distill_only) where grad is
/// extracted by subtracting the SAC entropy term (computed analytically
/// from the supplied pi_logits since the kernel applies both at once).
fn run_kernel_and_extract_pi_target(
    trainer: &mut IntegratedTrainer,
    stream: &Arc<CudaStream>,
    pi_logits_host: &[f32; N_ACTIONS],
    eq_target: &[f32; N_ACTIONS],
    tau: f32,
    lambda: f32,
    f2_enabled: bool,
) -> Result<[f32; N_ACTIONS]> {
    // Configure ISV.
    set_isv(trainer, RL_Q_DISTILL_LAMBDA_INDEX, lambda);
    set_isv(trainer, RL_Q_DISTILL_TEMPERATURE_INDEX, tau);
    set_isv(trainer, RL_SAC_ALPHA_INDEX, 0.0); // Zero out entropy grad.
    set_isv(
        trainer,
        RL_F2_DISTILL_CENTERED_ENABLED_INDEX,
        if f2_enabled { 1.0 } else { 0.0 },
    );
    set_isv(trainer, RL_F2_DISTILL_NOOP_SET_MODE_INDEX, 0.0); // mode 0 = static.

    let atom = atom_supports_uniform();
    let q_host = q_logits_for_eq(eq_target, &atom);
    let q_d = upload_f32(stream, &q_host)?;
    let pi_d = upload_f32(stream, pi_logits_host)?;
    let atom_d = upload_f32(stream, &atom)?;

    let mut pi_grad_d = stream.alloc_zeros::<f32>(N_ACTIONS)?;

    trainer.launch_rl_q_pi_distill_grad(&q_d, &pi_d, &atom_d, &mut pi_grad_d, 1)?;

    let grad = read_slice_d_pub(stream, &pi_grad_d, N_ACTIONS)?;

    // Kernel writes: grad_total = (grad_distill + grad_entropy) / B.
    // With α = 0 and B = 1: grad_total = grad_distill = λ · (π_θ − π_target).
    // Recover π_target = π_θ − grad / λ.
    let max_logit = pi_logits_host.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
    let exps: Vec<f32> = pi_logits_host.iter().map(|l| (l - max_logit).exp()).collect();
    let total: f32 = exps.iter().sum();
    let pi_theta: Vec<f32> = exps.iter().map(|e| e / total).collect();

    let mut recovered = [0.0_f32; N_ACTIONS];
    for a in 0..N_ACTIONS {
        recovered[a] = pi_theta[a] - grad[a] / lambda;
    }
    Ok(recovered)
}

// ===========================================================================
// Test 1: when Q is uniform, F2 hinge yields uniform π_target.
// ===========================================================================
//
// All E_Q[a] = c (constant) → baseline = c → hinged = 0 for all a →
// softmax(0) = uniform 1/N for all a. F2 must produce uniform target.

#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn f2_centered_target_uniform_when_q_uniform() -> Result<()> {
    let Some((dev, mut trainer)) = build_trainer() else {
        return Ok(());
    };
    let stream = dev.cuda_stream()?.clone();

    let eq = [0.5_f32; N_ACTIONS]; // All actions same E_Q.
    let pi_logits = [0.0_f32; N_ACTIONS]; // Uniform π_θ.
    let tau = 5.0;
    let lambda = 1.0;

    let recovered = run_kernel_and_extract_pi_target(
        &mut trainer, &stream, &pi_logits, &eq, tau, lambda, true,
    )?;

    let expected = 1.0 / (N_ACTIONS as f32);
    for a in 0..N_ACTIONS {
        let diff = (recovered[a] - expected).abs();
        assert!(
            diff < 1e-3,
            "uniform-Q test: π_target[{a}] = {} expected {} diff {:.3e}",
            recovered[a], expected, diff,
        );
    }

    eprintln!("PASS — F2 hinge with uniform E_Q produces uniform π_target");
    Ok(())
}

// ===========================================================================
// Test 2: open-action below baseline → zero distill mass on that action.
// ===========================================================================
//
// E_Q[LongSmall=5] = 0.2, E_Q[Hold=2] = 0.6, others 0.1.
// baseline = max(0.6, 0.1, 0.1) = 0.6.
// hinged[5] = max(0, 0.2 - 0.6) = 0 → π_target[5] component in softmax
// numerator = exp(0/τ) = 1. But hinged[Hold] = 0 too. So they share
// equal raw mass (= 1) while all other hinged ≤ 0 also have raw mass 1.
// In short: when ALL actions are at-or-below baseline, π_target is
// uniform (mass-zero hinge degenerate case). This test verifies the
// boundary: LongSmall at 0.2 vs Hold at 0.6 — both hinge to 0 — both
// receive equal mass. The asymmetry only shows when an action *exceeds*
// baseline (Test 3).
//
// What this verifies: the hinge clamps below-baseline actions to zero
// numerator (NOT to a negative numerator that would produce a smaller
// share than uniform). Open's softmax share equals Hold's share when
// both hinge to zero.

#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn f2_centered_target_equal_share_when_all_below_baseline() -> Result<()> {
    let Some((dev, mut trainer)) = build_trainer() else {
        return Ok(());
    };
    let stream = dev.cuda_stream()?.clone();

    let mut eq = [0.1_f32; N_ACTIONS];
    eq[ACTION_HOLD] = 0.6;
    eq[ACTION_LONG_SMALL] = 0.2;
    eq[ACTION_TRAIL_TIGHTEN] = 0.1;
    eq[ACTION_TRAIL_LOOSEN] = 0.1;
    let pi_logits = [0.0_f32; N_ACTIONS];
    let tau = 5.0;
    let lambda = 1.0;

    let recovered = run_kernel_and_extract_pi_target(
        &mut trainer, &stream, &pi_logits, &eq, tau, lambda, true,
    )?;

    // All hinged values ≤ 0 → all clamped to 0 → uniform target.
    let expected = expected_f2_pi_target(&eq, tau);
    for a in 0..N_ACTIONS {
        let diff = (recovered[a] - expected[a]).abs();
        assert!(
            diff < 2e-3,
            "below-baseline test: π_target[{a}] = {} expected {} diff {:.3e}",
            recovered[a], expected[a], diff,
        );
    }
    // Sanity: π_target[LongSmall] should equal π_target[Hold] within fp32 noise
    // (both hinged to 0).
    let d = (recovered[ACTION_LONG_SMALL] - recovered[ACTION_HOLD]).abs();
    assert!(
        d < 2e-3,
        "LongSmall {} ≠ Hold {} but both hinge to zero",
        recovered[ACTION_LONG_SMALL], recovered[ACTION_HOLD],
    );

    eprintln!(
        "PASS — F2 hinge collapses below-baseline actions to uniform share \
        (Hold {} vs LongSmall {})",
        recovered[ACTION_HOLD], recovered[ACTION_LONG_SMALL],
    );
    Ok(())
}

// ===========================================================================
// Test 3: open-action above baseline → POSITIVE mass on that action,
// strictly greater than baseline action's mass.
// ===========================================================================
//
// E_Q[LongSmall=5] = 0.9, E_Q[Hold=2] = 0.6, others 0.5.
// baseline = max(0.6, 0.5, 0.5) = 0.6.
// hinged[5] = 0.3; hinged[Hold] = 0; hinged[TrailTight/Loose] = 0;
// hinged[others] ≤ 0 (clamped to 0).
// At τ = 5.0: numerator[5] = exp(0.3/5) = exp(0.06) ≈ 1.0618.
//             numerator[others] = exp(0) = 1.0.
// Total = 1.0618 + 10*1.0 = 11.0618.
// π_target[5] ≈ 0.0960; π_target[Hold] ≈ 0.0904.
// π_target[LongSmall] > π_target[Hold].

#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn f2_centered_target_positive_mass_above_baseline() -> Result<()> {
    let Some((dev, mut trainer)) = build_trainer() else {
        return Ok(());
    };
    let stream = dev.cuda_stream()?.clone();

    let mut eq = [0.5_f32; N_ACTIONS];
    eq[ACTION_HOLD] = 0.6;
    eq[ACTION_LONG_SMALL] = 0.9;
    eq[ACTION_TRAIL_TIGHTEN] = 0.5;
    eq[ACTION_TRAIL_LOOSEN] = 0.5;
    let pi_logits = [0.0_f32; N_ACTIONS];
    let tau = 5.0;
    let lambda = 1.0;

    let recovered = run_kernel_and_extract_pi_target(
        &mut trainer, &stream, &pi_logits, &eq, tau, lambda, true,
    )?;

    let expected = expected_f2_pi_target(&eq, tau);
    for a in 0..N_ACTIONS {
        let diff = (recovered[a] - expected[a]).abs();
        assert!(
            diff < 2e-3,
            "above-baseline test: π_target[{a}] = {} expected {} diff {:.3e}",
            recovered[a], expected[a], diff,
        );
    }
    // Key invariant: LongSmall > Hold.
    assert!(
        recovered[ACTION_LONG_SMALL] > recovered[ACTION_HOLD] + 1e-4,
        "LongSmall ({}) should exceed Hold ({}) when E_Q[LongSmall] > baseline",
        recovered[ACTION_LONG_SMALL], recovered[ACTION_HOLD],
    );

    eprintln!(
        "PASS — F2 hinge gives strictly higher mass to above-baseline action \
        (LongSmall {} vs Hold {})",
        recovered[ACTION_LONG_SMALL], recovered[ACTION_HOLD],
    );
    Ok(())
}

// ===========================================================================
// Test 4: F2 disabled (gate = 0.0) reproduces legacy softmax(E_Q/τ) target.
// ===========================================================================
//
// Bit-equality A/B guarantee: when RL_F2_DISTILL_CENTERED_ENABLED_INDEX
// = 0.0, the kernel must produce the exact same π_target as the
// pre-F2 implementation: softmax(E_Q / τ).

#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn f2_disabled_matches_legacy_softmax() -> Result<()> {
    let Some((dev, mut trainer)) = build_trainer() else {
        return Ok(());
    };
    let stream = dev.cuda_stream()?.clone();

    let mut eq = [0.5_f32; N_ACTIONS];
    eq[ACTION_HOLD] = 0.6;
    eq[ACTION_LONG_SMALL] = 0.9;
    eq[ACTION_LONG_LARGE] = 0.85;
    eq[ACTION_TRAIL_TIGHTEN] = 0.55;
    eq[ACTION_TRAIL_LOOSEN] = 0.55;
    let pi_logits = [0.0_f32; N_ACTIONS];
    let tau = 5.0;
    let lambda = 1.0;

    let recovered = run_kernel_and_extract_pi_target(
        &mut trainer, &stream, &pi_logits, &eq, tau, lambda, /* f2_enabled */ false,
    )?;

    // Legacy expected: softmax(E_Q / τ).
    let max_eq = eq.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
    let exps: Vec<f32> = eq.iter().map(|&v| ((v - max_eq) / tau).exp()).collect();
    let total: f32 = exps.iter().sum();
    let expected: Vec<f32> = exps.iter().map(|e| e / total).collect();

    for a in 0..N_ACTIONS {
        let diff = (recovered[a] - expected[a]).abs();
        assert!(
            diff < 2e-3,
            "F2-disabled test: π_target[{a}] = {} expected legacy {} diff {:.3e}",
            recovered[a], expected[a], diff,
        );
    }

    eprintln!("PASS — F2 disabled reproduces legacy softmax(E_Q/τ) target");
    Ok(())
}
```

### Step 2.2 — Verify the test compiles BUT FAILS

- [ ] Run:

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test f2_centered_distill_invariants --release --no-run 2>&1 | tail -20
```

Expected: compiles cleanly.

- [ ] Run the tests (RTX 3050 local):

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test f2_centered_distill_invariants --release -- --ignored --nocapture 2>&1 | tail -40
```

Expected: tests 1-3 FAIL (the F2 gate is in ISV but the kernel does not read it yet). Test 4 (legacy match) PASSES because the kernel currently does the legacy path unconditionally.

This is the TDD red state — proceed to Task 3.

- [ ] If any of tests 1-3 unexpectedly PASS at this stage, STOP — the F2 hinge is somehow already wired up, or the test oracle is wrong. Inspect before proceeding.

### Step 2.3 — Commit the failing test

- [ ] Stage and commit (the test is checked in alongside the kernel change in the next task — but committing now creates a clean TDD checkpoint per `superpowers:test-driven-development`):

```bash
git add /home/jgrusewski/Work/foxhunt/crates/ml-alpha/tests/f2_centered_distill_invariants.rs
git commit -m "$(cat <<'EOF'
test(ml-alpha): Phase 7a F2 — GPU-oracle invariants for Q-centered distill target

Four invariants verify the kernel implements
  π_target(a) ∝ softmax(max(0, E_Q(a) − baseline) / τ)

with baseline = max E_Q over {Hold=2, TrailTighten=7, TrailLoosen=8}:

1. Uniform Q → uniform π_target (hinge is 0 everywhere, degenerate case).
2. All E_Q ≤ baseline → equal share (hinge clamps below-baseline to 0).
3. E_Q[Open] > baseline → π_target[Open] > π_target[Hold] (the key signal).
4. F2 disabled (gate = 0.0) → legacy softmax(E_Q/τ) bit-equal.

TDD checkpoint: tests 1-3 currently FAIL (kernel does not read the new
gate yet); test 4 PASSES. Task 3 modifies the kernel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 3 — Modify rl_q_pi_distill_grad.cu to apply the hinge (~40 min)

### Step 3.1 — Add the slot constant defines to the kernel

- [ ] Open `/home/jgrusewski/Work/foxhunt/crates/ml-alpha/cuda/rl_q_pi_distill_grad.cu`.
- [ ] Locate the existing `#define` block (lines 17-40). After `#define RL_ACTION_ENTROPY_EMA_INDEX 583` (line 26) and before the B-10 documentation comment (line 27), insert:

```c
// Phase 7a F2 (2026-06-04) — Q-centered distill target master gate +
// no-op-set mode. Spec docs/superpowers/specs/2026-06-04-bellman-target-foundation-reshape.md
#define RL_F2_DISTILL_CENTERED_ENABLED_INDEX   817
#define RL_F2_DISTILL_NOOP_SET_MODE_INDEX      818

// No-op-set action indices — must match crate::rl::common::Action enum
// (also defined in actions_to_market_targets.cu).
#define ACTION_HOLD            2
#define ACTION_TRAIL_TIGHTEN   7
#define ACTION_TRAIL_LOOSEN    8
```

### Step 3.2 — Modify the π_target computation (lines 78–91)

- [ ] In the same file, locate the existing target-computation block:

```c
    // ── Step 2: π_target = softmax(E_Q / τ) ─────────────────────
    __shared__ float s_pi_target[N_ACTIONS];
    if (a == 0) {
        float max_eq = s_eq[0];
        for (int i = 1; i < N_ACTIONS; ++i) max_eq = fmaxf(max_eq, s_eq[i]);
        const float tau_safe = fmaxf(tau, 1e-3f);
        float total = 0.0f;
        for (int i = 0; i < N_ACTIONS; ++i) {
            s_pi_target[i] = expf((s_eq[i] - max_eq) / tau_safe);
            total += s_pi_target[i];
        }
        const float inv = (total > 1e-9f) ? (1.0f / total) : (1.0f / (float)N_ACTIONS);
        for (int i = 0; i < N_ACTIONS; ++i) s_pi_target[i] *= inv;
    }
    __syncthreads();
```

- [ ] Replace the block above with:

```c
    // ── Step 2: π_target ─────────────────────────────────────────
    // Phase 7a F2 (2026-06-04): if RL_F2_DISTILL_CENTERED_ENABLED_INDEX
    // > 0.5, compute the Q-centered hinged-advantage target
    //   π_target(a) ∝ softmax( max(0, E_Q(a) − baseline) / τ )
    // where baseline = max E_Q over the no-op set selected by
    // RL_F2_DISTILL_NOOP_SET_MODE_INDEX (mode 0 = {Hold, TrailTighten,
    // TrailLoosen}, mode 1 = {Hold} only). Else fall back to the legacy
    // softmax(E_Q/τ) target for bit-equality with HEAD 0463e44e0.
    //
    // Per spec docs/superpowers/specs/2026-06-04-bellman-target-foundation-reshape.md
    // §3.2 + §4.2: the hinge zeroes Open mass when E_Q(Open) ≤ baseline
    // (preserves surfer-style patience), and gives positive mass when
    // E_Q(Open) > baseline (preserves surfer-style commit on detected
    // edge). Composes with band mask (band touches sampled actions; F2
    // touches Q-distill target — disjoint pipelines).
    __shared__ float s_pi_target[N_ACTIONS];
    if (a == 0) {
        const float f2_enabled = isv[RL_F2_DISTILL_CENTERED_ENABLED_INDEX];
        const float tau_safe = fmaxf(tau, 1e-3f);

        if (f2_enabled > 0.5f) {
            // F2 hinged-advantage target.
            const float mode = isv[RL_F2_DISTILL_NOOP_SET_MODE_INDEX];

            // Compute baseline = max E_Q over no-op set.
            float baseline;
            if (mode > 0.5f && mode <= 1.5f) {
                // Mode 1: Hold-only baseline.
                baseline = s_eq[ACTION_HOLD];
            } else {
                // Mode 0 (default): static set {Hold, TrailTighten, TrailLoosen}.
                baseline = fmaxf(s_eq[ACTION_HOLD],
                                  fmaxf(s_eq[ACTION_TRAIL_TIGHTEN],
                                        s_eq[ACTION_TRAIL_LOOSEN]));
            }

            // hinged[a] = max(0, E_Q(a) − baseline).
            // softmax over hinged values, scaled by 1/τ.
            // Numerical stability: hinged ≥ 0 by construction, so the
            // max hinged value is a finite, non-negative number. We
            // subtract it before exp to keep magnitudes bounded.
            float max_hinge = 0.0f;
            for (int i = 0; i < N_ACTIONS; ++i) {
                const float h = fmaxf(0.0f, s_eq[i] - baseline);
                if (h > max_hinge) max_hinge = h;
            }
            float total = 0.0f;
            for (int i = 0; i < N_ACTIONS; ++i) {
                const float h = fmaxf(0.0f, s_eq[i] - baseline);
                s_pi_target[i] = expf((h - max_hinge) / tau_safe);
                total += s_pi_target[i];
            }
            const float inv = (total > 1e-9f) ? (1.0f / total)
                                              : (1.0f / (float)N_ACTIONS);
            for (int i = 0; i < N_ACTIONS; ++i) s_pi_target[i] *= inv;
        } else {
            // Legacy path (pre-F2, bit-equality with HEAD 0463e44e0).
            float max_eq = s_eq[0];
            for (int i = 1; i < N_ACTIONS; ++i) max_eq = fmaxf(max_eq, s_eq[i]);
            float total = 0.0f;
            for (int i = 0; i < N_ACTIONS; ++i) {
                s_pi_target[i] = expf((s_eq[i] - max_eq) / tau_safe);
                total += s_pi_target[i];
            }
            const float inv = (total > 1e-9f) ? (1.0f / total)
                                              : (1.0f / (float)N_ACTIONS);
            for (int i = 0; i < N_ACTIONS; ++i) s_pi_target[i] *= inv;
        }
    }
    __syncthreads();
```

### Step 3.3 — Build the kernel and the trainer

- [ ] Run:

```bash
SQLX_OFFLINE=true cargo build -p ml-alpha --release 2>&1 | tail -30
```

Expected: clean build. The `build.rs` for ml-alpha compiles the `.cu` files into cubins — if there is a CUDA syntax error it will surface here.

- [ ] If the build fails with a CUDA syntax error, STOP and fix the kernel before proceeding. Common issues: mismatched braces, missing semicolons, ACTION_HOLD redefined (if it was already defined elsewhere in the same translation unit — unlikely since this kernel doesn't include actions_to_market_targets.cu).

### Step 3.4 — Re-run the F2 invariant test (should now PASS)

- [ ] Run:

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test f2_centered_distill_invariants --release -- --ignored --nocapture 2>&1 | tail -30
```

Expected: all 4 tests PASS.

- [ ] If any test fails:
  - **Test 1 (uniform Q) fails:** hinge is producing non-uniform output when all E_Q are equal. Most likely a sign error in the baseline subtraction. Re-read Step 3.2 patch.
  - **Test 2 (below-baseline) fails:** below-baseline actions are receiving unequal mass. Most likely the `max(0, ·)` clamp is missing or the softmax is using a non-zero numerator for clamped actions.
  - **Test 3 (above-baseline) fails:** the no-op set lookup is wrong, or the baseline is computed over the wrong set.
  - **Test 4 (legacy match) fails:** the gate-OFF fallback branch is broken; bit-equality with the pre-F2 path is violated. STOP and inspect — this is a regression.

### Step 3.5 — Commit

- [ ] Stage and commit:

```bash
git add /home/jgrusewski/Work/foxhunt/crates/ml-alpha/cuda/rl_q_pi_distill_grad.cu
git commit -m "$(cat <<'EOF'
feat(ml-alpha): Phase 7a F2 — Q-centered hinged-advantage distill target

rl_q_pi_distill_grad.cu modification: when slot 817 > 0.5, compute
  π_target(a) ∝ softmax(max(0, E_Q(a) − baseline) / τ)
with baseline = max E_Q over no-op set:
  - mode 0 (default, slot 818 = 0.0): {Hold=2, TrailTighten=7, TrailLoosen=8}
  - mode 1 (ablation, slot 818 = 1.0): {Hold=2} only

When slot 817 ≤ 0.5, the legacy softmax(E_Q/τ) path runs unchanged
(bit-equal with HEAD 0463e44e0 — the F2-G7 determinism gate).

Per spec docs/superpowers/specs/2026-06-04-bellman-target-foundation-reshape.md
§3.2 + §4.2 (Revision 3, surfer-principle-preserving primary phase).
Verified by tests/f2_centered_distill_invariants.rs (4/4 PASS).

Composition with Phase 4-A band mask: band touches sampled actions
(actions_d) post-sampling; F2 touches the Q-distill π_target distribution
(pi_logits_d / q_logits_target_d). Disjoint pipelines — no interaction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 4 — Run determinism check (bit-equality guarantee) (~10 min)

### Step 4.1 — Determinism with band OFF

- [ ] Run:

```bash
cd /home/jgrusewski/Work/foxhunt
./scripts/determinism-check.sh --quick 2>&1 | tail -40
```

Expected: exit code 0; the script reports "PASS" for seeds 42/43/44 (per `pearl_determinism_achieved`).

- [ ] If the determinism check fails:
  - Inspect the diff output for the diverging seed.
  - Most likely cause: the F2 branch introduced an unsynchronized read or a per-thread-divergent path. The Step 3.2 patch only modifies the `if (a == 0)` block which executes single-thread per block, so any race must come from elsewhere. STOP and audit.

### Step 4.2 — Determinism with band ON

- [ ] Run:

```bash
cd /home/jgrusewski/Work/foxhunt
FOXHUNT_BAND_ENABLED=1 ./scripts/determinism-check.sh --quick 2>&1 | tail -40
```

Expected: exit code 0.

- [ ] If this fails but Step 4.1 passed, that would indicate F2 + band have an unexpected interaction (contradicting the reconnaissance finding in Task 0 Step 0.3). STOP and investigate — do not commit any further changes until the composition is understood.

### Step 4.3 — Smoke-test bit-equality with F2 disabled

- [ ] Run a smoke at F2 OFF — temporarily flip slot 817 to 0.0 via env override if such an override exists, or via direct local edit of the bootstrap value. Easiest: directly inspect the diag JSONL from a `local-mid-smoke.sh` run with F2 ON vs OFF.

Skip this step unless Step 4.1 or 4.2 surfaced an anomaly — Task 5's invariant test already covers F2-disabled-matches-legacy.

### Step 4.4 — Commit (no code change; this is just a verification gate)

- [ ] No commit needed for Task 4 unless a fix was applied. If a fix WAS applied (e.g., a __syncthreads barrier was missing), commit that fix separately with a clear message indicating the determinism issue and its resolution.

---

## Task 5 — Run all invariant test suites (~15 min)

### Step 5.1 — Run the four existing invariant suites + new F2 suite

- [ ] Run all four suites in sequence:

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo test -p ml-alpha --test phase_5_invariants --release -- --ignored --nocapture 2>&1 | tail -10
SQLX_OFFLINE=true cargo test -p ml-alpha --test band_invariants --release -- --ignored --nocapture 2>&1 | tail -10
SQLX_OFFLINE=true cargo test -p ml-alpha --test multi_head_policy_invariants --release -- --ignored --nocapture 2>&1 | tail -10
SQLX_OFFLINE=true cargo test -p ml-alpha --test f2_centered_distill_invariants --release -- --ignored --nocapture 2>&1 | tail -10
```

Expected:
- `phase_5_invariants`: 3/3 PASS
- `band_invariants`: 11/11 PASS
- `multi_head_policy_invariants`: 18/18 PASS
- `f2_centered_distill_invariants`: 4/4 PASS

Total: 36/36 PASS.

- [ ] If any pre-existing suite regressed (phase_5, band, or multi_head_policy), STOP — F2's kernel modification has affected a downstream behavior it should not have. Most likely an entropy-formula edit slipped in unintentionally; re-read the diff in Task 3 Step 3.2 and confirm only the `Step 2: π_target` block was modified.

### Step 5.2 — Trade management kernels regression check

- [ ] Run:

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo test -p ml-alpha --test trade_management_kernels --release -- --ignored --nocapture 2>&1 | tail -20
```

Expected: all tests pass at parity with HEAD `0463e44e0`. (This suite has been touched in the current working tree per git status — the implementer should verify pre-existing tests still pass; the F2 kernel modification should not affect trade-management kernels.)

### Step 5.3 — No commit (verification only)

- [ ] No commit for Task 5. The PASS state is the precondition for Task 6.

---

## Task 6 — Local Tier 1.5 smoke + falsification gates (~15 min)

### Step 6.1 — Run mid-smoke with F2 ENABLED + band ENABLED

- [ ] Run:

```bash
cd /home/jgrusewski/Work/foxhunt
rm -rf /tmp/foxhunt-phase7a-seed-42
FOXHUNT_BAND_ENABLED=1 ./scripts/local-mid-smoke.sh \
    --seed 42 \
    --out /tmp/foxhunt-phase7a-seed-42 \
    --n-backtests 128 2>&1 | tail -30
```

Expected runtime ≈ 10 min on RTX 3050. The run writes:
- `/tmp/foxhunt-phase7a-seed-42/diag.jsonl` — per-step diagnostics
- `/tmp/foxhunt-phase7a-seed-42/eval_summary.json` — eval pnl + wr_ema + action histogram

### Step 6.2 — Run the Tier 1.5 verdict tool

- [ ] Run:

```bash
python3 /home/jgrusewski/Work/foxhunt/scripts/tier1_5_verdict.py /tmp/foxhunt-phase7a-seed-42 2>&1 | tail -40
```

Expected: the verdict tool prints a structured report. Capture the output.

### Step 6.3 — Evaluate F2 falsification gates manually from diag.jsonl

The Tier 1.5 verdict tool predates the F2 gates. The four F2-specific gates per spec §5.1.1 + §4.3 must be evaluated by hand from the diag.jsonl + eval_summary.json:

- [ ] **F2-G1 (target entropy effect).** Compute `RL_Q_DISTILL_TARGET_ENTROPY_MEAN` (slot 733) at step 1000 vs the pre-F2 baseline at the same step. The baseline value is approximately `ln(11) ≈ 2.40 nats` (uniform target). Pass condition: F2-on entropy drops by ≥ 0.5 nats (i.e., ≤ 1.9 nats).

```bash
python3 -c "
import json
target_entropy_step_1000 = None
with open('/tmp/foxhunt-phase7a-seed-42/diag.jsonl') as f:
    for line in f:
        rec = json.loads(line)
        if rec.get('step') == 1000:
            target_entropy_step_1000 = rec.get('policy', {}).get('q_distill_target_entropy_mean')
            break
print(f'F2-G1: target_entropy_at_step_1000 = {target_entropy_step_1000}')
print(f'F2-G1 PASS condition: <= 1.9 nats (drop >= 0.5 from uniform 2.40)')
"
```

- [ ] **F2-G5 (surfer-patience preservation, NOW LOCALLY MEASURABLE per Task 1.5).** Read `policy_diagnostic.hold_frac_flat` at step 5000. Pass condition: `hold_frac_flat ∈ [0.30, 0.85]`.
  - Below 0.30: F2 is forcing too many opens from flat (surfer→trend). The hinge is too aggressive or baseline is mis-set; investigate.
  - Above 0.85: F2 did NOT break the inaction collapse — Hold still dominates flat states despite the hinge. The Open-action E_Q values are not exceeding the no-op baseline; investigate (likely Q-network has not yet learned an Open edge — cold-start issue).

```bash
python3 -c "
import json
with open('/tmp/foxhunt-phase7a-seed-42/diag.jsonl') as f:
    for line in f:
        rec = json.loads(line)
        if rec.get('step') == 5000:
            pd = rec.get('policy_diagnostic', {})
            t  = rec.get('trading', {})
            hff = pd.get('hold_frac_flat')
            oamf = pd.get('open_action_mass_flat')
            frac_flat = t.get('frac_batches_flat')
            print(f'F2-G5: hold_frac_flat at step 5000 = {hff}')
            print(f'        open_action_mass_flat = {oamf}')
            print(f'        frac_batches_flat (denominator) = {frac_flat}')
            print(f'F2-G5 PASS condition: hold_frac_flat in [0.30, 0.85]')
            break
"
```

  Also inspect (informational; NOT pass/fail at this step but useful for attribution):
  - `policy_diagnostic.max_action_share` — should be < 0.80 (no single-action collapse)
  - `policy_diagnostic.modal_action_id` — should NOT be a no-op action (≠ {2,7,8}) if F2 is reshaping toward Open
  - `policy_diagnostic.f2_hinge_zero_rate` — should be in `[0.4, 0.8]` (F2 mechanism active and discriminating). If close to 1.0, the hinge is vacuous (everything below baseline); if close to 0.0, the hinge is doing nothing.
  - `policy_diagnostic.f2_target_argmax_action` vs `policy_diagnostic.modal_action_id` — when F2 is actively reshaping the gradient, these should DIFFER (target points at Open while sampling still hold-dominated).

- [ ] **F2-G6 (action entropy at step 1999).** Pass condition: ≥ 0.5 nats (was approximately 0.22 in Phase 5 mq2pc baseline).

```bash
python3 -c "
import json
with open('/tmp/foxhunt-phase7a-seed-42/diag.jsonl') as f:
    for line in f:
        rec = json.loads(line)
        if rec.get('step') == 1999:
            entropy = rec.get('policy', {}).get('action_entropy_ema')
            print(f'F2-G6: action_entropy_ema at step 1999 = {entropy}')
            print(f'F2-G6 PASS condition: >= 0.5 nats')
            break
"
```

- [ ] **F2-G7 (no regression — local Tier 1.5 eval pnl proxy).** The full eval gate is cluster-only (F2-G8), but Tier 1.5 produces an eval_summary.json. Pass condition: `eval_pnl ≥ -$2M` (loose floor — the Phase 5 mq2pc local baseline was approximately -$444k; we tolerate up to 4.5× degradation locally since b=128 is noisy and not the load-bearing eval surface).

```bash
python3 -c "
import json
with open('/tmp/foxhunt-phase7a-seed-42/eval_summary.json') as f:
    summary = json.load(f)
pnl = summary.get('total_pnl_usd')
print(f'F2-G7: eval total_pnl_usd = {pnl}')
print(f'F2-G7 PASS condition: >= -2_000_000')
"
```

### Step 6.4 — STOP-on-surprise decision

- [ ] **If ALL four gates PASS:** proceed to Task 7 (commit + push).
- [ ] **If F2-G1 fails** (target entropy did NOT drop): the hinge is vacuous. Most likely cause: no action's E_Q exceeds the baseline because Q is cold-start noisy. STOP per spec §4.5 step 7 — do NOT proceed to cluster. The implementer must either:
  - Add a warmup phase (F2 OFF for first 500-1000 steps, then ON via controller transition) — requires another ISV slot + a controller, out of scope for this plan.
  - Add a temperature schedule that smooths the hinge — out of scope.
  Document the failure and surface to the user for decision.
- [ ] **If F2-G5 fails** with Hold% from flat < 5%: the hinge is pushing Hold mass below the random floor — the surfer-principle preservation argument has broken. STOP per spec §4.5 step 8. Likely cause: baseline-set definition bug (e.g., the wrong action index was used).
- [ ] **If F2-G5 fails** with Hold% from flat > 95%: the agent is in h6btt-class re-collapse — F2's hinge is insufficient to compete with the SAC prior. The disciplined next step is to surface to the user; Phase 7b (F5) may be the conditional follow-up (spec §4.6) but Phase 7a's plan ends here.
- [ ] **If F2-G6 fails:** training-time action entropy did not recover from the mq2pc 0.22 floor. Likely cause: the hinge is producing too-peaked targets even in the surviving support. Consider increasing τ in a follow-up.
- [ ] **If F2-G7 fails:** local eval is catastrophically worse than baseline. STOP — do not proceed to cluster. Investigate before committing.

### Step 6.5 — No commit (verification only)

- [ ] No commit for Task 6 if all gates pass. The local Tier 1.5 PASS state is the precondition for cluster submission (Task 8).

---

## Task 7 — Push the Phase 7a F2 commits (~5 min)

### Step 7.1 — Verify the commit chain

- [ ] Run:

```bash
git log --oneline ml-alpha-regime-observer ^main | head -10
```

Expected: four new commits on top of `0463e44e0`:
1. `feat(ml-alpha): Phase 7a F2 — allocate ISV slots 817-818 for Q-centered distill target` (Task 1)
2. `feat(ml-alpha): Phase 7a F2-diag — 13 diag fields for F2 measurement` (Task 1.5)
3. `test(ml-alpha): Phase 7a F2 — GPU-oracle invariants for Q-centered distill target` (Task 2)
4. `feat(ml-alpha): Phase 7a F2 — Q-centered hinged-advantage distill target` (Task 3)

### Step 7.2 — Push the branch

- [ ] Run:

```bash
git push origin ml-alpha-regime-observer 2>&1 | tail -10
```

- [ ] If the push is rejected because the branch has diverged from the remote, STOP — do NOT force-push. Inspect the remote state with `git fetch origin && git log origin/ml-alpha-regime-observer ^HEAD` and reconcile manually.

---

## Task 8 — Cluster Tier 2 verification (~80 min wallclock, ~15 min interactive) (~15 min)

### Step 8.1 — Submit the cluster run

- [ ] Get the HEAD commit SHA:

```bash
git rev-parse HEAD
```

Record this SHA (call it `$SHA`).

- [ ] Submit Tier 2 with band ON, single-head, F2 enabled by default:

```bash
cd /home/jgrusewski/Work/foxhunt
./scripts/argo-alpha-rl.sh \
    --sha "$SHA" \
    --branch ml-alpha-regime-observer \
    --n-steps 20000 \
    --n-eval-steps 5000 \
    --n-backtests 1024 \
    --seed 42 \
    --use-multi-head-policy 0 \
    --band-enabled 1 \
    --watch 2>&1 | tail -30
```

Expected: Argo submits the workflow; the `--watch` flag streams logs. Total wallclock ≈ 80 min (compile ~15 min, train ~50 min, eval ~15 min).

### Step 8.2 — Capture the run name + monitor

- [ ] The Argo submit will print the workflow name (something like `alpha-rl-XXXXX`). Record it.
- [ ] Monitor progress via:

```bash
argo logs -n argo <workflow-name> --follow 2>&1 | tail -20
```

Or via the cluster dashboard. The run uploads outputs to `/feature-cache/alpha-rl-runs/$SHA/` per `pearl_cluster_log_is_ground_truth`.

### Step 8.3 — Evaluate F2-G8/G9/G10/G11 (cluster outcome gates per spec §5.1.2)

When the run completes, fetch the eval_summary.json:

- [ ] Locate the eval artifact (the workflow output path is printed in the Argo logs; typically `s3://foxhunt-cache/alpha-rl-runs/$SHA/eval_summary.json`).

- [ ] **F2-G8 (eval pnl primary outcome).** Pass condition: `eval_summary.total_pnl_usd ≥ h6btt_baseline + $5_000_000`. The h6btt baseline is approximately `-$4.18 × 500 × 1024 ≈ -$2.14M` (read from the h6btt cluster summary). So F2-G8 pass is approximately `eval_pnl ≥ +$2.86M`.

- [ ] **F2-G9 (no new attractor).** Read `policy_diagnostic.max_action_share` (added in Task 1.5) at eval step 5000 and step 15000. Pass condition: `max_action_share < 0.80`. Also record `policy_diagnostic.modal_action_id` — if the run fails, this identifies WHICH attractor (Hold = re-collapse; LongLarge/ShortLarge = directional collapse; TrailLoosen = trail attractor).

- [ ] **F2-G10 (eval action entropy).** `eval_summary.eval_action_entropy_mean ≥ 1.0 nats`.

- [ ] **F2-G11 (surfer-mode fingerprint).** `wr_ema ≥ 0.45` AND `avg_hold_steps ≥ 15`.

- [ ] **F2-MECHANISM (informational; new in Task 1.5).** Read at eval step 5000:
  - `policy_diagnostic.f2_hinge_zero_rate` — should be in `[0.4, 0.8]` (mechanism active and discriminating). Outside this range = mechanism broken: ~1.0 = no above-baseline action (Q has not learned an Open edge); ~0.0 = no clamping (every action above baseline, hinge is vacuous).
  - `policy_diagnostic.f2_advantage_max_mean` — should be strictly > 0 once training has produced any Open edge. Zero means the hinge clamped everything (no Open action exceeds baseline in any batch).
  - `policy_diagnostic.f2_target_argmax_action` vs `policy_diagnostic.modal_action_id` — divergence between these is the "F2 actively reshaping gradient" signature.
  - `policy_diagnostic.hold_frac_flat` trajectory across the eval window — same Tier 1.5 gate at cluster scale.

### Step 8.4 — Attribution per spec §5.3 (decision table)

| Outcome | Next action |
|---|---|
| F2-G8 PASS, F2-G9 PASS, F2-G11 PASS | **SHIP F2 ALONE.** Surfer principle preserved; gradient axis was load-bearing. Close Phase 7a; do not proceed to Phase 7b. |
| F2-G8 FAIL, mechanism gates G1/G2/G3 PASS, G5 PASS | F2 necessary but insufficient. Surface to user per spec §4.4. Decision: accept surfer→trend trade-off → Phase 7b (F5 on top); else escalate to encoder/alignment/F4 work. |
| F2-G8 FAIL, F2-G5 FAIL (Hold% from flat < 5% or > 95%) | F2 broke surfer patience in the wrong way. STOP. Do NOT proceed to F5 (F5 worsens the over-action case). Investigate before any further work. |
| F2-G8 PASS, F2-G9 FAIL (single-action dominance > 80%) | F2 surfaced a directional collapse. STOP. Do NOT add F5 reflexively. Escalate to encoder investigation or F4. |
| F2-G7 (determinism) FAIL anywhere | F2 introduced non-determinism. Audit the kernel diff. Most likely an unsynchronized read in the new branch — though Step 3.2 only touched the `if (a == 0)` block. |

### Step 8.5 — Update the spec decision log

- [ ] Per spec §11, the decision log entry should be updated with the actual cluster outcome. The implementer is responsible for either updating the spec or surfacing the result to the user so the spec can be updated.

### Step 8.6 — No further commits in Phase 7a

- [ ] Phase 7a ends after Step 8.5. Phase 7b (F5 on top of F2) is a separate plan document conditional on §4.6 + explicit user acceptance.

---

## Execution Handoff

This plan can be executed in two modes:

### Mode A — Subagent-Driven (recommended)

Use the `superpowers:subagent-driven-development` skill. Dispatch each Task (0 through 8) as an independent subagent invocation, passing the task's steps and verification gates. The subagent reports PASS/FAIL per task; the orchestrator decides whether to proceed to the next task.

Advantages: clean task boundaries, isolated context per task, no inter-task contamination, easy STOP-on-surprise enforcement.

### Mode B — Inline Execution

Use the `superpowers:executing-plans` skill. Walk through Tasks 0-8 in sequence in a single session, checking off `- [ ]` items as you go. At each STOP-on-surprise decision point, surface to the user immediately rather than improvising a fix.

Advantages: fewer agent invocations, lower latency, easier debugging when steps interleave.

Either mode is acceptable. Both honor the `feedback_investigation_first_falsification_methodology` discipline: predetermined gates, STOP-on-unexpected-finding, no skipping verification steps to "save time."

---

## Plan-level invariants (reminders the implementer must respect)

1. **No `--no-verify` push.** Hooks must run.
2. **No commit amends across Task boundaries** — one logical change per commit, fresh commits only.
3. **No deletion of pre-existing tests.** Phase 5, band, multi-head-policy tests are the substrate.
4. **No reward-kernel changes in Phase 7a.** F2 is gradient-axis only; reward shaping is untouched.
5. **No band-mask changes in Phase 7a.** Band composition is verified via reconnaissance; F2 is mechanically disjoint.
6. **STOP-on-surprise binds at every gate.** Any unexpected behavior surfaces to the user, not into another iteration of "tile a fix on top."
7. **The empty no-op-set case is a NON-ISSUE in Phase 7a.** F5 is not active in this phase; the no-op-set {Hold, TrailTighten, TrailLoosen} is always populated. Spec §7 uncertainty #7 only matters in Phase 7b.

---
