# Deployability-Sweep Parallelism — Design

**Author:** brainstormed live 2026-05-19 with claude-opus-4-7
**Status:** Draft (awaiting review)
**Supersedes:** none — this is a follow-up to `docs/superpowers/specs/2026-05-19-ml-alpha-v2-trunk-grows-and-deployability-design.md` (commit `da1dd92bf`)
**Depends on:** `c7fdc617d` and earlier (cold-start floor + variance-cap gate landed; smoke validated end-to-end with kernel fixes)

---

## 1. Problem

The deployability sweep grid (per the v2 design spec §3.3, Pass 2) is `7 cost × 4 latency × 5 threshold × 4 window = 560 cells`. At the smoke's measured rate of **1988 events/s ≈ 2 ms per forward** on L40S with `n_parallel = 1`, one cell takes ~83 min per quarter. Naive Argo fan-out at 1 cell per pod = **~750 GPU-hours per sweep**.

We have 1 L40S GPU pod, scaling to 3 if a 1-pod proof-of-life passes. The budget is **≤ 1 hour wall-clock per sweep** because the development loop iterates on multiple sweeps per day (hyperparam search, A/B candidates). At today's per-cell rate this is impossible.

Three architectural facts make the goal achievable:

1. **Forward sharing across cells, sim runs parallel.** Of the 560-cell grid, only the `data window` (4 quarters) varies the forward pass. The other three axes — `cost`, `latency`, `threshold` — are sim parameters downstream of the forward. All `7 × 4 × 5 = 140` cells per window can share ONE forward pass; the *forward* work amortizes across 140 cells. Sim work itself does NOT amortize — it scales linearly with `n_backtests`, but runs in parallel across thread blocks (L40S has ~142 SMs so n=140 fits in one wave with bandwidth contention as the only cost). The win comes from sharing the ~2 ms/forward across 140 cells; not from somehow making sim work disappear.
2. **Existing sim kernels already loop over `n_backtests`.** The decision/pnl/order kernels were written for `n_backtests > 1`. What's missing is per-backtest parameter arrays — current kernels take scalar params and broadcast.
3. **Three new pieces of functionality** (not just refactor): a threshold gate (currently no kernel-level threshold), per-fill cost deduction (currently `fees_usd_fp` is a literal-zero placeholder in `pnl_track.cu:92`), and per-backtest variants of every existing sim parameter.

Spec covers the refactor + new functionality + rate-side improvements to land a sweep under the 1-hour budget.

---

## 2. Goal and verdict criteria

**Falsifiable goal.** After this spec lands:

- A single 1-pod `lob-backtest-sweep` invocation runs **1 quarter × 140 sim variants** end-to-end in ≤ 30 min wall-clock at decision_stride=4.
- 4-quarter sweep across 3 pods completes in **≤ 1 h wall-clock target** (firm bound: ≤ 2 h). The 1-h target depends on Graph capture delivering ≥ 1.5× on the forward; if it under-delivers, the explicit Plan-B is `decision_stride 4 → 8` (gate criterion in §9, automatically activated based on P5 measurement).
- All 140 per-backtest summary.json files differentiate by their cost/latency/threshold configs (no copy-paste failures from indexing bugs).
- The existing 100k-event smoke (n_parallel=1, uniform config) reproduces bit-identically to the pre-refactor result (proves the refactor doesn't change single-backtest behaviour).

**Out of scope.** bf16 mixed precision (deferred — see §10 risk register; would be a separate follow-up spec only if both Graph capture AND stride=8 fallback miss the target). seq_len reduction (would require retraining). n_batch>1 in forward across windows (would require multi-window batched data loader; revisit if budget is still missed after both levers).

---

## 3. Architecture — three multiplicative levers

### 3.1 Per-backtest matrix (the dominant lever, ~140×)

Every sim parameter becomes a per-backtest f32/u32 array. Single `BatchedSimConfig` is the new API:

```rust
pub struct BatchedSimConfig {
    pub target_annual_vol_units: Vec<f32>,  // len = n_backtests
    pub annualisation_factor:    Vec<f32>,
    pub max_lots:                Vec<u16>,
    pub latency_ns:              Vec<u32>,
    pub kelly_frac_floor:        Vec<f32>,
    pub sharpe_weight_floor:     Vec<f32>,
    pub threshold:               Vec<f32>,   // NEW
    pub cost_per_lot_per_side:   Vec<f32>,   // NEW
}

impl BatchedSimConfig {
    /// Used by smoke / fixture tests where every cell shares the same config.
    pub fn from_uniform(n: usize, cfg: &UniformSimParams) -> Self;
    /// Used by the sweep runner to pack a 140-variant grid into one pod.
    pub fn from_grid(cells: &[SweepCellResolved]) -> Self;
}
```

The previous scalar `BacktestHarnessConfig` is the legacy API; **dropped** per `feedback_no_legacy_aliases` and `feedback_single_source_of_truth_no_duplicates`. `BacktestHarness` constructs a `BatchedSimConfig` from its inputs (uniform broadcast at n=1 for smoke, grid pack at n=140 for sweep).

**Kernel ABI changes** (all in one atomic commit per `feedback_no_partial_refactor`):

```cuda
// decision_policy_default + decision_policy_program:
//   was: float target_annual_vol_units, float annualisation_factor, int max_lots,
//        float kelly_frac_floor, float sharpe_weight_floor
//   new: const float* target_annual_vol_units_per_b,
//        const float* annualisation_factor_per_b,
//        const int*   max_lots_per_b,
//        const float* kelly_frac_floor_per_b,
//        const float* sharpe_weight_floor_per_b,
//        const float* threshold_per_b           // NEW

// pnl_track_step + apply_fill_to_pos:
//   const float* cost_per_lot_per_side_per_b   // NEW

// step_resting_orders + the latency-path dispatch:
//   const u32* latency_ns_per_b
```

**Host roundtrip removal — TWO new kernels** (verified during scoping; see §6 for the verified host loops):

| Kernel | Replaces | LOC | Notes |
|---|---|---|---|
| `seed_inflight_limits_batched` | `dispatch_latent_market_orders` host loop (`sim.rs:706`) — reads market_targets to host, loops over backtests, calls `seed_limit_order` per non-noop | ~80 | One kernel launch per decision; per-backtest scans MAX_LIMITS slots for a free one. Slot allocation is per-backtest so no cross-backtest atomics needed. Overflow path writes to a per-backtest counter on Pos. |
| `detect_close_transitions_batched` | host loop at `sim.rs:658-674` — compares prev_pos vs current pos per backtest, calls read_pos per close-eligible backtest, builds closed_mask + realised_return vectors host-side | ~70 | Writes `closed_horizon_mask_d` and `realised_return_d` directly on GPU. `isv_kelly_update_on_close` then dispatched without host round-trip. |

Total: **~150 LOC of CUDA**, plus existing kernel signature updates (~50 LOC) and Rust side (~200 LOC).

### 3.2 CUDA Graph capture for the forward pass (~2-3× per-forward speedup)

**Verified:** `forward_only` → `evaluate_batched` uses `launch_builder`, NOT `launch_captured`. The X11 plan said "capture_graph_a covers full v2 forward" but the actual X11 commit (`4f888abbf`) shipped only `forward_only + from_checkpoint`. **Graph capture is new work, not plumbing.**

Per `pearl_no_host_branches_in_captured_graph` + `pearl_cudarc_disable_event_tracking_for_graph_capture`:

- Disable cudarc 0.19 event tracking around the capture (bracket with `disable_event_tracking()` + `enable_event_tracking()` per the pearl)
- No host branches inside the capture region
- No host-malloc / scalar-arg changes / pointer swaps inside capture
- No per-call kernel parameter changes (use pre-bound device pointers; bind once at trunk construction)

**Capture scope.** Wrap the entire forward chain into one captured graph that takes `(window_d, h_init)` device pointers and writes `probs_d`. Components:

```
VSN → Mamba2 stack 1 → LN_a → Mamba2 stack 2 → LN_b → attn_pool → CfC step → heads (×N_HORIZONS)
```

Snapshot-assemble (`snap_feature_assemble_batched`) stays OUTSIDE the captured graph because it consumes a fresh `Mbp10RawInput` per call (its inputs change per decision; the captured forward consumes the assembled tensor instead).

Capture is built ONCE in `PerceptionTrainer::new` (or `from_checkpoint`), replayed per decision via `launch_captured`. Verify with a `forward_captured_matches_uncaptured` bit-equivalence test that doesn't ship to production but blocks merge.

Realistic estimate: ~150 LOC of plumbing + 1 careful test. Expected impact: **~2× on the forward pass** on L40S (the kernel-launch latency overhead is a real chunk of the 2 ms/forward; on Ampere/Ada a captured graph drops it by 60-70% per official benchmarks).

### 3.3 Pod-level parallelism (1 → 3 pods, ~3× wall reduction)

`argo-lob-sweep.sh` already fans cells out as separate WorkflowTasks. Change WHAT a cell is:

- **Old:** cell = `(cost, latency, threshold)`, 140 cells per window × 4 windows = 560 cells
- **New:** cell = `(window)`, 4 cells per sweep. Each cell's `BatchedSimConfig` packs the 140 sim variants for that window.

Sweep YAML schema:

```yaml
base:
  data_template: /mnt/training-data/futures-baseline-mbp10/ES.FUT/ES.FUT_{window}.dbn.zst
  predecoded_dir: /feature-cache/predecoded
  decision_stride: 4
  n_parallel: 140                    # = len(sim_variants)
  checkpoint: /feature-cache/alpha-perception-runs/<sha>/trunk_best_h6000.bin
  sim_variants:                      # 140 entries
    - { name: c0_l0_t0, cost: 0.0,    latency_ns: 100000000, threshold: 0.30 }
    - { name: c0_l0_t1, cost: 0.0,    latency_ns: 100000000, threshold: 0.35 }
    # ... 140 total
cells:
  - { name: W1, window: 2025-Q2 }
  - { name: W2, window: 2025-Q3 }
  - { name: W3, window: 2025-Q4 }
  - { name: W4, window: 2026-Q1 }
```

The sweep runner expands `sim_variants` into a `BatchedSimConfig` for each cell. The aggregate step reads all 4 cells' per-backtest summary.json files (140 each = 560 total) and writes the standard aggregate.parquet + pareto_frontier.json.

**Output schema for n=140.** Each cell writes one directory per sim-variant, named after the variant's `name` field (which has to be unique within `sim_variants`):

```
<sweep-root>/<sweep-tag>/
  W1/
    sim_c0_l0_t0/{summary.json, trades.csv, pnl_curve.bin}
    sim_c0_l0_t1/{summary.json, trades.csv, pnl_curve.bin}
    ...
    sim_c6_l3_t4/{...}
  W2/
    sim_c0_l0_t0/{...}
    ...
  W3/
  W4/
```

Aggregator + verdict emitter parse `sim_<variant_name>/` to extract (cost, latency, threshold) from the variant name (or, more robustly, write the resolved sim params into each `summary.json` as a `sim_config` block — verdict emitter prefers reading that to parsing the name). The verdict emitter then groups by `(threshold_value, anchor)` across windows for the median Sharpe / max-dd math from the original v2 spec §3.5.

**3-pod scaling gate.** Run a single-cell `lob-backtest-sweep` (1 window, 140 sim variants) on 1 L40S. Verify:

- All 140 backtests complete cleanly
- Per-backtest summary.json files have distinct metrics (no indexing copy-paste)
- Wall-clock ≤ 30 min

Only then scale to 3-pod parallel. Per cluster autoscaler cap (3 GPU nodes), the 4-cell sweep gets 3 cells parallel + 1 queued. Wall = max(2 × per-cell, 1 × per-cell) ≈ 2× per-cell = ≤ 60 min total.

---

## 4. Threshold gate (kernel-side absolute cutoff)

Insertion at the top of `decision_policy_default` and `decision_policy_program`, before the per-horizon ss[] loop:

```cuda
float max_conviction = 0.0f;
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
    max_conviction = fmaxf(max_conviction,
                           fabsf(alpha_probs[h] - 0.5f) * 2.0f);
}
if (max_conviction < threshold_per_b[b]) {
    market_targets[b * 2 + 0] = 2;   // noop
    market_targets[b * 2 + 1] = 0;
    open_horizon_masks[b]    = 0u;
    return;
}
```

Pre-Kelly placement keeps the gate deterministic from alpha alone, so the threshold pre-registration step (compute p60–p95 absolute values from observed convictions on a validation window) reflects exactly what gets gated in deployment.

**Why max-over-horizons** (vs. per-horizon thresholding or aggregate-conviction gate after Kelly): the threshold's role is "is the model confident enough on ANY horizon to consider trading at all". A signal strong on h=4 (long-horizon) but neutral on h=0 (short-horizon) should still be allowed to trade — the downstream Kelly aggregation already weights horizons by `recent_sharpe`, which gives the model agency over which horizon's view actually drives sizing. Per-horizon thresholding would over-prune (gating out a horizon's contribution before Kelly even sees it) and would require a 5×n_backtests threshold array. Aggregate-conviction gating (after Kelly) couples threshold semantics to Kelly state, defeating the pre-registration step's purpose (the threshold value calibrated at p60 of CONVICTION wouldn't map to p60 of post-Kelly sizing).

Max-over-horizons is the simplest interpretation of "p60–p95 of conviction" that keeps the gate decoupled from sim-side state. Future tweak: if the dilution pathway turns out to matter (verdict shows threshold is selecting trades dominated by a single horizon's strong signal that gets washed out in the aggregate), we revisit per-horizon thresholding as a follow-up.

**Pre-registration step** (in `sweep_threshold_tuning.yaml`):

- Run 1 cell with `threshold = 0.0` (never gates) on the validation window
- Harness logs every observed `max_conviction` per decision into a side-channel `Vec<f32>` (host-side, computed from the SAME probs that get broadcast — no extra GPU readback)
- After run: compute empirical p60/p70/p80/p90/p95 → write to `config/ml/v2_prod_thresholds.json`
- Full deployability sweep then uses those 5 absolute values

Side-channel logging is ~20 LOC in the harness.

**Regression tests:**

- `threshold_gate_skips_low_conviction`: alpha p_h = 0.51 (max_conviction = 0.02), threshold = 0.10 → market_target = noop
- `threshold_gate_allows_high_conviction`: alpha p_h = 0.7 (max_conviction = 0.4), threshold = 0.10 → market_target buy, size > 0

---

## 5. Per-fill cost (per-backtest f32 array, kernel-side deduction)

**Verified:** `pnl_track.cu:92` writes a literal `0` to `fees_usd_fp` as a placeholder. The cost system is unimplemented.

**Insertion point** (single location): `apply_fill_to_pos` in `resting_orders.cu:90`. This function is called from both the in-flight fill path (`step_resting_orders`) and the immediate-fill path (`submit_market_immediate`).

```cuda
// existing:
//   pos.realized_pnl += sgn * unwind * (vwap_entry - avg_px);  // close-leg P&L
//
// add (BEFORE the existing realized_pnl math, to keep gross-vs-net clearly separated):
const float fill_cost = filled_lots * cost_per_lot_per_side_per_b[b];
pos.realized_pnl -= fill_cost;        // deducted from realized P&L
// Plus: accumulate fill_cost into a per-backtest device array
// `total_fees_per_b_d` (NEW). pnl_track_step's trade-log write then
// reads the running total and writes `fees_usd_fp` per trade record
// (currently a placeholder zero at pnl_track.cu:92).
//
// Plain `+=` — single-writer-per-block convention is already enforced
// across the sim kernels (`if (threadIdx.x != 0) return;` at the top
// of every block). No race; no atomic needed.
total_fees_per_b_d[b] += fill_cost;
```

(`Pos` struct currently has no `total_fees` field — verified by reading `lob_state.cuh:22-29`. We add a separate per-backtest device array `total_fees_per_b_d` rather than growing `Pos`, to keep the existing Pos<->PosFlat Rust mirror layout-stable and to keep fee accumulation orthogonal to position tracking. `artifacts.rs::write_summary_json` reads `total_fees_per_b_d[b]` to populate `summary.json::total_fees_usd`.)

**Net-of-cost semantics.** Both entry-fill and exit-fill incur cost. A round-trip 1-lot trade with `cost_per_lot_per_side = 2.0` produces `total_fees = 4.0` and `realized_pnl_net = realized_pnl_gross - 4.0`. `isv_kelly_update_on_close` reads `realised_return = (pos.realized_pnl_now - pos.realized_pnl_at_open) / abs_size`, which is net of cost — Kelly state learns from realistic return distribution. **This is the correct architecture** per the user's preference for "per-fill in pnl_track kernel" (over "per-close in summary stats" which would mismatch Kelly's view from reported metrics).

The trade-log record's `fees_usd_fp` field (currently placeholder zero at pnl_track.cu:92) gets the running total at close-time, written by `pnl_track_step` when it emits the close record.

**Regression tests:**

- `cost_deducted_at_each_fill`: round-trip 1-lot trade with cost=2.0 should produce net_pnl = gross_pnl - 4.0
- `kelly_state_sees_net_return`: assert `isv_kelly_update_on_close`'s realised_return arg = (gross - 2*cost) / abs_size

---

## 6. Verified host-roundtrip locations (the GPU-ization targets)

This section documents the two host loops verified by code reading during spec scoping. Both must be replaced for the per-backtest matrix to actually pay off — without them, host roundtrip overhead at n_parallel=140 cancels the speedup.

**Loop 1** — `dispatch_latent_market_orders` at `crates/ml-backtesting/src/sim.rs:706`:

```rust
let mut targets = vec![0i32; n * 2];
self.stream.memcpy_dtoh(&self.market_targets_d, targets.as_mut_slice())?;
for b in 0..n {
    let side = targets[b * 2];
    let size = targets[b * 2 + 1];
    if side == 2 || size <= 0 { continue; }
    for slot in 0..MAX_LIMITS {
        if self.seed_limit_order(b, slot, side as u8, 1, 2, 0xFF,
            if side == 0 { 1.0e9 } else { 0.0 }, size as f32, 0.0,
            current_ts_ns + latency_ns as u64).is_ok() { break; }
    }
}
```

Cost at n=140 × 2.5M decisions × 60% non-noop = **~210M host roundtrips per quarter** (assuming worst case: every backtest non-noop on most decisions). Real cost is lower if many backtests stay noop, but at threshold-tuning-time when threshold=0 it's near the worst case.

**Replaces with:** `seed_inflight_limits_batched` kernel. Inputs: market_targets_d, limit_slots_d (existing per-backtest array), per-backtest latency_ns_per_b. For each backtest with non-noop target, atomically scans its own MAX_LIMITS slots for `active=0`, writes an in-flight slot record. Slot allocation is per-backtest (each backtest has its own MAX_LIMITS slots) so no cross-backtest atomics. Overflow path increments a per-Pos overflow counter (already exists).

**Loop 2** — close-detection at `crates/ml-backtesting/src/sim.rs:658-674`:

```rust
let mut closed_masks = vec![0u32; self.n_backtests];
let mut realised_returns = vec![0.0f32; self.n_backtests];
for b in 0..self.n_backtests {
    if prev_pos_per_block[b] != 0 {
        let now_pos = self.read_pos(b)?;          // host roundtrip per b
        if now_pos.position_lots == 0 {
            let abs_size = prev_pos_per_block[b].unsigned_abs() as f32;
            let pnl_delta = now_pos.realized_pnl - prev_pnl_per_block[b];
            let ret_per_lot = if abs_size > 0.0 { pnl_delta / abs_size } else { 0.0 };
            closed_masks[b] = prev_mask_per_block[b];
            realised_returns[b] = ret_per_lot;
        }
    }
}
```

`read_pos(b)` issues a `memcpy_dtoh` for the full PosFlat array per call, indexed at offset b. At n=140, every close-eligible backtest triggers a full 140-PosFlat readback. Cost: 140 × 2.5M = **~350M small dtoh transfers per quarter**.

**Replaces with:** `detect_close_transitions_batched` kernel. Inputs: pos_d, prev_pos_lots_d, prev_pnl_d, prev_open_mask_d. Per backtest: detect close transition (prev != 0, now == 0), compute realised_return_per_lot, write to closed_horizon_mask_d + realised_return_d. The subsequent `isv_kelly_update_on_close` then dispatches without host involvement.

---

## 7. Migration plan (atomic commit boundaries)

Per `feedback_no_partial_refactor`: every contract change with all consumers in one commit. Per `feedback_wire_everything_up`: every kernel/module wired in the same commit as introduced.

| Commit | Subject | Files touched | Test gate |
|---|---|---|---|
| P1 | `feat(ml-backtesting): per-backtest sim parameter arrays (kernel ABI + Rust BatchedSimConfig)` | decision_policy.cu, pnl_track.cu, order_match.cu, resting_orders.cu, sim.rs, harness.rs, fixture/fuzz tests, main.rs CLI, sweep YAML schema | All existing decision_floor_coldstart + fuzz + fixture tests pass with `BatchedSimConfig::from_uniform`. NEW: `parallel_sim_equivalence_with_uniform_config` (n=8 with same config → 8 bit-identical results). NEW: `parallel_sim_independence_per_backtest` (different latencies → different fill timing). |
| P2 | `feat(ml-backtesting): seed_inflight_limits_batched kernel (replaces host roundtrip loop)` | resting_orders.cu (or new cu), sim.rs (drop dispatch_latent_market_orders) | New `seed_inflight_limits_batched_smoke` test: 4 backtests, 2 non-noop with different latencies → 2 in-flight slots populated, 2 untouched. |
| P3 | `feat(ml-backtesting): detect_close_transitions_batched kernel (replaces close-detection host loop)` | pnl_track.cu (or new cu), sim.rs (drop close-detection host loop) | New `detect_close_smoke` test: 4 backtests, 1 close, 3 unchanged → closed_mask[1]==prev_mask[1], rest = 0. |
| P4 | `feat(ml-backtesting): threshold gate + cost integration` | **decision_policy.cu** (add threshold gate pre-Kelly in BOTH `decision_policy_default` and `decision_policy_program`). **resting_orders.cu** (`apply_fill_to_pos` gains `cost_per_lot_per_side_per_b` arg; both call sites updated — line ~185 in the StopMarket fill path and line ~214 in the in-flight arrival fill path). **order_match.cu / submit_market_immediate** (if it has its own fill path, also update — verify during P4 implementation). **pnl_track.cu** (drop `fees_usd_fp` literal-zero placeholder; read from `total_fees_per_b_d` at close). **harness.rs** (side-channel `max_conviction` logger for threshold tuning pre-registration step). | `threshold_gate_*` tests, `cost_deducted_at_each_fill`, `kelly_state_sees_net_return`. ALSO: search-and-verify before commit `grep -rn 'apply_fill_to_pos' crates/ml-backtesting/cuda/` — every call site must pass the new arg, otherwise some fill paths lose cost silently. |
| P5 | `feat(ml-alpha): CUDA Graph capture of forward_only` | cfc/trunk.rs (new `capture_graph_a` + bracketing per `pearl_cudarc_disable_event_tracking_for_graph_capture`), trainer/perception.rs (forward_only uses launch_captured), data/loader.rs (drop misleading comment that references the not-yet-existing `capture_graph_a`). | `forward_captured_matches_uncaptured` — captured vs uncaptured forward must agree **within 1e-5 relative tolerance** (NOT strict bit-identity; CUDA Graph capture can reorder kernel launches in ways that flip f32 reduction sums by 1 ULP without changing semantics). Strict bit-identity would be a false-positive trigger from harmless ordering effects. Tolerance picked at 1e-5 because the heads' sigmoid output is the only test target, and 1e-5 is below probability-rounding precision (3 digits effective). |
| P6 | `config(ml-alpha): sweep YAML schema for batched cells + sweep runner` | bin/fxt-backtest/src/main.rs (SweepCellResolved → BatchedSimConfig::from_grid), scripts/argo-lob-sweep.sh (cell = window, not sim-variant), config/ml/sweep_threshold_tuning.yaml + sweep_deployability.yaml (new schema) | 1-pod end-to-end smoke at n_parallel=140 against the trained checkpoint, validates rate target. |
| P7 | `docs(ml-alpha): scale to 3-pod sweep` | argo-lob-sweep.sh (no code change; verify 3-pod fan-out behaves) | Run full 4-quarter sweep on 3 pods, verify ≤ 1h wall-clock and 560 differentiable summary.json files. |

Each commit gates the next. If any test fails, fix in the same commit (no follow-up debt).

---

## 8. Test coverage

All CUDA tests stay `#[ignore = "requires CUDA"]` and run locally via `cargo test -- --ignored` on RTX 3050 Ti. No CI gating for CUDA tests; convention is documented in CLAUDE.md and developers run them before commits.

**New tests landing alongside their commit:**

> **Important — equivalence + independence pair.** The two P1 tests below MUST both ship in the same commit. The equivalence test alone is necessary but NOT sufficient: a per-backtest indexing bug where `backtest[0]`'s result is copied to all slots (e.g., `b` index dropped, or stride math broken) still produces 8 identical results, so the equivalence test passes despite the bug. The independence test catches that pathway: with different latencies, two backtests MUST produce different fill timing, so any "shadow backtest[0] everywhere" bug shows up as identical timings where they should differ. Neither test substitutes for the other.

| Test | Purpose | Commit |
|---|---|---|
| `parallel_sim_equivalence_with_uniform_config` | n=8, all-equal configs → 8 bit-identical results. Necessary but NOT sufficient — also see independence test below. | P1 |
| `parallel_sim_independence_per_backtest` | n=2, different latencies → different fill timing. Catches the "shadow backtest[0] everywhere" bug that equivalence alone misses. Required alongside the equivalence test. | P1 |
| `seed_inflight_limits_batched_smoke` | 4 backtests, 2 non-noop → exactly those slots populated. | P2 |
| `detect_close_smoke` | 4 backtests, 1 close → closed_mask written correctly. | P3 |
| `threshold_gate_skips_low_conviction` | max_conviction < threshold → noop. | P4 |
| `threshold_gate_allows_high_conviction` | max_conviction ≥ threshold → buy. | P4 |
| `cost_deducted_at_each_fill` | Round-trip 1-lot with cost=2.0 → net_pnl = gross - 4.0. | P4 |
| `kelly_state_sees_net_return` | isv_kelly_update_on_close receives net-of-cost realised_return. | P4 |
| `forward_captured_matches_uncaptured` | Captured graph reproduces uncaptured forward bit-identically. | P5 |
| `lob_sweep_140_variants_smoke` | 1-pod 1-window with n_parallel=140 distinct configs, all summary.json files differentiate. | P6 |

**Existing tests that must continue to pass unchanged:**

- All `decision_floor_coldstart::*` tests (P1 migrates them to `BatchedSimConfig::from_uniform`; behaviour unchanged)
- All `lob_sim_fuzz / lob_sim_fixtures / lob_sim_integrated_fuzz` tests
- `save_load_roundtrip_preserves_all_weights` (unchanged)
- `cold_start_with_zero_floor_reproduces_old_bug` (unchanged; tests the kernel pre-gate behaviour with floors=0)

---

## 9. Rate target validation

Measurements are TIGHT (lower bound a known-passing config would hit, not a generous envelope). Misses trigger an investigation per `feedback_stop_on_anomaly` AND may activate the stride=8 fallback (see below).

| Gate | Measurement | Threshold |
|---|---|---|
| P2 done | 100k-event smoke with n_parallel=140, all-uniform config, no graph capture, stride=4 | **≤ 90 s wall**. Reference: 100k events at n=1 takes ~30 s today (measured). At n=140 with fixed roundtrip-loop kernels, throughput should be approximately unchanged because sim runs parallel and forward is amortized. >90s = roundtrip-loop fix didn't pay off (investigate before proceeding to P3). |
| P5 done | Same smoke + graph capture | **≤ 60 s wall** (≥ 1.3× speedup vs P2). If captured-vs-uncaptured matches but speedup < 1.3×, the captured graph isn't engaging properly (likely a host-branch or stale-pointer issue trapping launch into eager mode). |
| P6 done | 1-quarter smoke with n_parallel=140, full sim-variant grid, stride=4 | **≤ 30 min wall** (1-pod proof-of-life). If miss, activate stride=8 fallback (see below) and re-run; new threshold ≤ 16 min. |
| P7 done | 4-quarter sweep on 3 pods, stride=4 (or 8 if fallback activated) | **≤ 60 min wall target** (firm bound ≤ 120 min). |

### 9.1 Stride=8 fallback (the Plan-B lever)

Activation criterion: **if P6 measurement at stride=4 misses the 30-min gate by > 20% (i.e., > 36 min)**, the spec's primary plan has insufficient headroom for the 1-h sweep target. In that case, the implementation plan automatically:

1. Reruns P6 measurement at `decision_stride = 8` (half the decisions, ~2× wins on the forward-dominated portion).
2. If P6@stride=8 hits the ≤ 16 min gate, commits `config(ml-alpha): bump deployability sweep decision_stride 4 → 8` and proceeds to P7 with stride=8.
3. Documents the decision in `config/ml/v2_prod_thresholds.json` notes so the verdict-emitter knows which stride generated the input.

Rationale for stride=8 as the cleanest Plan-B (vs bf16): stride=8 is a 1-line YAML change, no kernel work, no precision-validation gate. It buys ~2× wall reduction at the cost of slightly coarser decision cadence — model was trained per-snapshot, but inference cadence is independent of the model's intrinsic timescale (h6000 ≈ 6000 snapshots forward). Down-sampling decisions by 2× has minimal effect on a multi-thousand-snapshot horizon.

If P6@stride=8 ALSO misses ≤ 16 min, the spec is in unrecoverable territory and we open a follow-up spec for bf16. Don't paper over it by accepting > 1h.

---

## 10. Risk register

| Risk | Likelihood | Mitigation |
|---|---|---|
| Graph capture has a pearl violation (host branch / scalar arg change) we don't catch until P5 ships | Medium | Captured-vs-uncaptured equivalence test (`forward_captured_matches_uncaptured`) gates P5 with **1e-5 relative tolerance** (not strict bit-identity — captured graphs can reorder kernel launches and flip f32 reductions by 1 ULP harmlessly). If divergence exceeds 1e-5, the capture is semantically broken (or carries a host-branch / stale-pointer / scalar-arg-change pearl violation). |
| Per-backtest indexing bug at n=140 (off-by-one in stride math) | Medium | `parallel_sim_equivalence_with_uniform_config` test catches any per-backtest path that doesn't reduce to broadcast at uniform config. |
| `seed_inflight_limits_batched` slot allocation has a race | Low | Each backtest's MAX_LIMITS slots are PER-backtest (different memory ranges), no cross-backtest atomics needed. Per-backtest scan is single-threaded inside `if (threadIdx.x != 0) return;` pattern (same convention as `decision_policy_default`). |
| Cost deduction breaks Kelly behaviour vs trained distribution | Low | The model was trained without cost in the loss (forward inference doesn't see cost). Kelly state ON the backtest side learns from net returns, which adapts to the cost regime. If a specific cost regime trades degenerately at gate P6, that's information about the deployability question — NOT a refactor bug. |
| 1-h target missed at P5 (Graph capture under-delivers) | **High** — Graph capture's realistic L40S speedup is 1.2-1.5× on a GEMM-dominated forward, not 2×. Plan numbers assumed 2× headroom. | **Explicit Plan-B in §9.1**: stride=4 → stride=8 if P6 misses by > 20%. 1-line YAML change. No kernel work needed. If stride=8 ALSO misses: open follow-up bf16 spec. Don't accept > 1h silently. |
| Misleading X11 comment in loader.rs:274 references `capture_graph_a` that doesn't exist | Trivial | Drop the comment in commit P5 when capture is implemented. |

---

## 11. Out-of-scope / explicit deferrals

- bf16 mixed precision — separate follow-up spec, only if 1-h target missed. Stability validation gate (bit-near-equivalence on a 100k window) must precede ship.
- n_batch > 1 in forward across windows — would need a multi-window batched data loader; revisit only if 3-pod budget misses.
- Per-cell variation of model checkpoint (A/B candidates) — requires per-backtest forward, which breaks the forward-sharing premise. Treated as a separate sweep mode in a future spec.
- decision_stride reduction (4 → 8) — easy escape hatch flagged in risk register but not pursued here.

---

## 12. Definition of done

- Commits P1–P7 landed with all gating tests green (per §8 and §9 measurements).
- Full 4-quarter deployability sweep runs at **≤ 1 h wall-clock target** (firm bound ≤ 2 h) on 3 pods, produces `deployability_verdict.json` per the original v2 spec §3.5.
- 1-pod proof-of-life (§9 P6) passes before scaling to 3 pods.
- If stride=8 fallback (§9.1) was activated, the decision is documented in `config/ml/v2_prod_thresholds.json` notes.
- Memory pearls hold: no host branches in captured graph, max-with-floor pattern preserved for both cold-start floors and the new cost-net Kelly path, no version suffixes in code identifiers, no legacy aliases, plain += in single-writer-per-block contexts (not atomicAdd).
- The vestigial loader.rs:274 `capture_graph_a` comment is fixed in P5.
- `apply_fill_to_pos` call-site verification (`grep -rn 'apply_fill_to_pos' crates/ml-backtesting/cuda/`) confirms every call passes the new `cost_per_lot_per_side_per_b` arg — no silent fee loss on any fill path.
