# Regime Observer — Unified Risk-Stack State Machine (v3)

**Date**: 2026-05-31
**Branch**: ml-alpha-adaptive-controller-floors (next: ml-alpha-regime-observer)
**Status**: v3 — addresses 7 issues from v2 critical review (Pass 3 indexing, boundary resets, missing Goal 6, missing diag structure, etc.)
**v2 status**: superseded; v3 corrects critical F4 Pass 3 indexing bug + adds explicit regime_observer boundary reset policy + adds diag JSON structure + minor polish (Goal 6, Theorem 6 phrasing, baseline clarification)
**v1 status**: superseded; v2 redesigned F4 (envelope-detector replaces per-done observations), added safety timeout breaker, explicit launch order, synthetic harness, drift-free diag, refined math theorems

## Summary

A meta-controller (`regime_observer`) emits shared regime-state signals consumed by risk controllers, normalizers, and the v9 boundary-warmup kernel. Replaces ad-hoc per-controller detection with unified architecture. Closes ALL 3 interlocking failure modes identified in alpha-rl-zwj68 (2026-05-31):

1. **Kelly trade-stream-death** — train-phase absorbing state when `kelly_f=0 ∧ all-flat` → guaranteed trap eventually ([[pearl_kelly_trade_stream_death]]). **Solved by F2 (Theorem 1).**
2. **v9 eval-boundary regime shift** — v9 already ships the fix; this spec preserves it via slot rename ([[pearl_adaptive_carryover_discipline]]). **Architectural cleanup by F3 (Theorem 2).**
3. **Popart blindness to session signals** — V regression's variance estimator dilutes single-account tails via batch averaging ([[pearl_popart_blind_to_session_signals]]). **Solved by F4 (Theorem 6) via per-account max-magnitude envelope detector floor on σ.**

All three are instances of the same architectural gap ([[pearl_dead_signal_resurrection_discipline]]).

## Scope and deferrals

### In scope (this spec)

- **F1**: regime_observer kernel + flat_count helper + 20 ISV slots + diag emit (foundation, zero behavior change)
- **F2**: Kelly resurrection (Problem 1 fix)
- **F3**: v9 slot renaming refactor (Problem 2 architectural cleanup)
- **F4**: popart per-account max-magnitude envelope (Problem 3 fix)
- **F5**: IQN τ tail-recency consumer (defense-in-depth)
- **F6**: local + cluster validation

### F4 (popart per-account max envelope) — RE-INCLUDED after kernel investigation

After reading `rl_popart_normalize.cu`, the mechanism is now precisely understood:

```c
// Lines 92-93 of rl_popart_normalize.cu:
float batch_mean = v_sum / b;
float batch_var  = v_ssq / b - batch_mean * batch_mean;
```

This computes variance **across batch at each step**, then EMA's with α=0.001. A single -$19 (post-scale) reward among 1023 normal rewards yields `batch_var ≈ 0.37`, which is SMALLER than steady-state ~5.4 (because the 1023 other rewards cluster near 0, depressing the variance estimate).

**Math verification for Run #8 step 7377**:
- 1 account reward = -$19.45 (post-scale: $6484 × 0.003)
- 1023 accounts reward ≈ $0
- v_sum = -19.45; batch_mean = -19.45/1024 = -0.019
- v_ssq = 19.45² + ~0 = 378.3; batch_var = 378.3/1024 - 0.019² = 0.369
- old_var = 5.4 → new_var = 0.999×5.4 + 0.001×0.369 = 5.395
- Δσ ≈ 0 (matches empirical 0.4% change)

**Critical realization**: `rl_popart_v_correct.cu` exists and affine-corrects V_pred when sigma changes:
```c
v_pred ← (σ_old / σ_new) · v_pred + (μ_old − μ_new) / σ_new
```
So sigma can change discontinuously WITHOUT destabilizing V regression — the V-correct kernel makes V_pred follow the new normalization. My v1 concern about destabilization is moot.

**The fix shape**: envelope-detector on per-step max-magnitude reward, used as a floor on popart_sigma:
```c
max_r_this_step = max(|r_i|) for i in batch
if (max_r_this_step > max_r_ema)
    max_r_ema = max_r_this_step           // instant capture
else
    max_r_ema = (1−α_d) · max_r_ema + α_d · max_r_this_step   // slow decay
sigma_effective = max(popart_sigma_existing, max_r_ema)
```

This captures per-account tail magnitudes regardless of how many other accounts are quiet. The asymmetric envelope (fast up, slow down) is standard signal-processing — peak-detector pattern.

## Motivation

### Empirical evidence (alpha-rl-zwj68, fold-1, terminated step 9237)

8 zero-Kelly runs during training. 7 escaped via in-flight closeouts updating EMAs through the break-even threshold; 1 trapped permanently when all positions closed before recovery.

| Run | open_positions @ exit | escape? |
|-----|------------------------|---------|
| #1  | 156 | ✓ |
| #2  | 128 | ✓ |
| #3  | 171 | ✓ |
| #4  | 109 | ✓ |
| #5  | 131 | ✓ |
| #6  |  60 | ✓ |
| #7  |   7 | ✓ barely |
| #8  |   0 | ✗ TRAPPED at step 7500 |

Trap continued for 1,737 steps with zero closed trades. The system is irrecoverable once `kelly_f = 0 ∧ N_open = 0 ∧ cooldown = 0`, because:
- Kelly=0 zeroes new position sizes
- N_open=0 means no positions can close
- No closes means no EMA updates
- EMA frozen with wr_ema < L/(W+L) → analytic Kelly stays negative
- No transition out of state

This is a graph-theoretic absorbing state, not a stochastic edge case.

### Architectural pattern

Foxhunt currently has 6 adaptive systems with private regime inference:

| System | Signal it tracks | What it can't see |
|--------|------------------|---------------------|
| Layer 1 CMDP | session_pnl mean+worst | per-event tail magnitude after closeout |
| Layer 2 IQN τ | session DD relative to peak | trade outcome distribution |
| Layer 3 Inventory β | position variance | unrealized vs realized pnl |
| Layer 4 Kelly | wr / W / L | per-step volatility, trade-stream death |
| popart | per-step reward variance | session-level pnl (batch-averaged out) |
| c51 atom span | popart_sigma | session-level shocks |

Each maintains its own implicit regime model. They diverge at regime boundaries and produce inconsistent behavior. Regime observer provides ONE shared state model.

## Goals & Non-goals

### Goals

1. Eliminate the Kelly absorbing state (Theorem 1)
2. Provide unified state-machine ISV signals for adaptive risk controllers
3. Preserve v9's eval-boundary behavior exactly (Theorem 2 — refactor only)
4. Add a session-level safety net so dead-zone-recovery losses are bounded (Theorem 3)
5. Establish architecture for future controllers to consume regime state
6. Eliminate popart's blindness to per-account tail events (Theorem 6)

### Non-goals

- Redesign IQN action selection (only τ_min minor adjustment for tail-recency)
- Modify CMDP semantics (Layer 1 stays as-is)
- Change action space, replay sampling, gradient flow
- Affect any code path when no tail events / dead-zones occur (no perf regression)
- Modify popart's mean tracking — only its σ via the envelope-detector floor

### Orthogonality of F2 and F4

F2 fixes the Kelly absorbing state (Problem 1). F4 fixes popart's blindness to per-account tails (Problem 3). They operate on different signals:
- F2 reads DEAD_ZONE_FLAG (from regime_observer) and overrides Kelly's output
- F4 reads per-step batch rewards (existing popart input) and computes max-magnitude envelope as σ floor

F4 does NOT directly prevent the Kelly trap — the avg_loss_ema feeding Kelly is a per-event EMA separate from popart. F4 prevents V regression from missing tail-event variance; F2 prevents the position-sizing absorbing state. Both fixes are necessary for the full holistic solution.

## Architectural design

### Component overview

**Per-step pipeline insertion point (LAUNCH ORDER, addressing v1 issue #5):**

```
Existing step pipeline (abridged):
  1. Encoder forward + heads forward
  2. Action selection
  3. actions_to_market_targets (uses kelly_f from PRIOR step's risk stack)
  4. LobSim step (executes trades, updates position lots, emits rewards)
  5. Reward processing (apply_reward_scale)
  6. popart_normalize

NEW INSERTION (between step 5 and step 7):
  6a. rl_regime_flat_count kernel
      Reads:  lots[b] device buffer (updated at step 4)
      Writes: flat_count_d (single int, device)
  6b. rl_regime_observer kernel
      Reads:  RL_KELLY_FRACTION_INDEX (PRIOR step's value — one-step lag accepted)
              RL_COOLDOWN_REMAINING_STEPS_INDEX (just updated at step 7's prior pass)
              RL_SESSION_PNL_WORST_INDEX (current)
              flat_count_d
              + internal Welford state slots
      Writes: 4 surface signals + recovery_factor + eps_recovery_live + timeout_flag
              + internal Welford state updates

Then continues:
  7. Risk-stack controllers (CMDP → IQN τ → Inventory β → Kelly)
     Kelly resurrection (NEW) reads DEAD_ZONE_FLAG just computed by 6b, overrides kelly_f
  8. rl_fused_controllers (mirrors single-controller behavior)
  9. V backward, PPO backward, Q backward
 10. Optimizer step
```

**One-step lag on kelly_f**: regime_observer at step T reads kelly_f from step T-1. This is acceptable because the absorbing state `kelly_f=0 ∧ all-flat` persists across steps — detection at T (one step late) still fires before any harm escalates. At training start, all bootstrap values are well-defined (kelly_f=1.0, lots all zero) so no spurious detection.

### ISV slot allocation (20 new, RL_SLOTS_END: 696 → 716)

```rust
// regime_observer outputs (8 surface signals)
pub const RL_REGIME_DEAD_ZONE_FLAG_INDEX:           usize = 696;  // 0/1
pub const RL_REGIME_DEAD_ZONE_DURATION_INDEX:       usize = 697;  // counter
pub const RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX:   usize = 698;  // 0/1 (safety net)
pub const RL_REGIME_RECOVERY_FACTOR_INDEX:          usize = 699;  // [0,1] (drift-free diag)
pub const RL_REGIME_SESSION_PNL_VARIANCE_EMA_INDEX: usize = 700;  // σ²
pub const RL_REGIME_TAIL_EVENT_RECENCY_INDEX:       usize = 701;  // counter

// regime_observer internal Welford state (kept on device)
pub const RL_REGIME_SESSION_PNL_VAR_M2_INDEX:       usize = 702;  // Welford
pub const RL_REGIME_SESSION_PNL_VAR_MEAN_INDEX:     usize = 703;  // Welford
pub const RL_REGIME_SESSION_PNL_VAR_COUNT_INDEX:    usize = 704;  // Welford
pub const RL_REGIME_PREV_WORST_PNL_INDEX:           usize = 705;  // Δ source

// Kelly resurrection (Kelly kernel reads these)
pub const RL_KELLY_EPS_RECOVERY_LIVE_INDEX:         usize = 706;  // current ε value (drift-free)
pub const RL_KELLY_EPS_RECOVERY_MIN_INDEX:          usize = 707;  // config 0.05
pub const RL_KELLY_EPS_RECOVERY_MAX_INDEX:          usize = 708;  // config 0.50
pub const RL_KELLY_EPS_RECOVERY_N_RECOVERY_INDEX:   usize = 709;  // config 100

// Safety net: dead-zone timeout (issue #4 — bounded loss during prolonged adverse regime)
pub const RL_REGIME_DEAD_ZONE_MAX_DURATION_INDEX:   usize = 710;  // config 1000 steps

// IQN τ tail-boost
pub const RL_IQN_TAU_TAIL_BOOST_FACTOR_INDEX:       usize = 711;  // config 1.5
pub const RL_IQN_TAU_TAIL_BOOST_N_WINDOW_INDEX:     usize = 712;  // config 100

// regime_observer config
pub const RL_REGIME_TAIL_SIGMA_THRESHOLD_INDEX:     usize = 713;  // config 3.0

// Popart per-account max envelope (F4)
pub const RL_POPART_MAX_ABS_REWARD_EMA_INDEX:       usize = 714;  // envelope-detector state
pub const RL_POPART_MAX_DECAY_ALPHA_INDEX:          usize = 715;  // config 0.01

pub const RL_SLOTS_END: usize = 716;
```

### v9 slot renames (no physical migration — Theorem 2)

These keep their existing slot numbers; only the constant identifier changes:

| Old name | New name | Slot |
|----------|----------|------|
| `RL_EVAL_WARMUP_REMAINING_INDEX` | `RL_REGIME_TRANSITION_REMAINING_INDEX` | 685 |
| `RL_EVAL_WARMUP_STEPS_CONFIG_INDEX` | `RL_REGIME_TRANSITION_STEPS_CONFIG_INDEX` | 686 |
| `RL_EVAL_WARMUP_DECAY_STEPS_CONFIG_INDEX` | `RL_REGIME_TRANSITION_DECAY_STEPS_CONFIG_INDEX` | 687 |

Slots 688-695 (v9's defensive/normal target values) remain v9-owned and unchanged.

## Algorithm specifications

### rl_regime_observer.cu (single-thread single-block, ~80 LOC)

Per `feedback_no_atomicadd`, `feedback_cpu_is_read_only`, `feedback_nvidia_grade_perf_for_kernels`: pure device kernel, no atomics, no synchronization, no host branches in capture.

```c
extern "C" __global__ void rl_regime_observer(
    float* __restrict__ isv,
    const int* __restrict__ flat_count_d,
    int b_size
) {
    if (threadIdx.x != 0 || blockIdx.x != 0) return;

    // ─── Phase 1: dead-zone detection ──────────────────────
    const float kelly        = isv[RL_KELLY_FRACTION_INDEX];           // prior-step value
    const float cooldown     = isv[RL_COOLDOWN_REMAINING_STEPS_INDEX];
    const int   flat_count   = flat_count_d[0];

    const int dead_zone = (kelly == 0.0f)
                       && (flat_count == b_size)
                       && (cooldown == 0.0f) ? 1 : 0;

    isv[RL_REGIME_DEAD_ZONE_FLAG_INDEX] = (float)dead_zone;

    // Duration counter
    float duration = isv[RL_REGIME_DEAD_ZONE_DURATION_INDEX];
    if (dead_zone) duration += 1.0f; else duration = 0.0f;
    isv[RL_REGIME_DEAD_ZONE_DURATION_INDEX] = duration;

    // Safety net: timeout flag fires if dead-zone persists past MAX_DURATION (issue #4)
    const float max_dur = isv[RL_REGIME_DEAD_ZONE_MAX_DURATION_INDEX];
    const int timeout = (duration > max_dur) ? 1 : 0;
    isv[RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX] = (float)timeout;

    // ─── Phase 2: session_pnl_change Welford variance ──────
    const float worst_now  = isv[RL_SESSION_PNL_WORST_INDEX];
    const float worst_prev = isv[RL_REGIME_PREV_WORST_PNL_INDEX];
    const float dx         = worst_now - worst_prev;
    isv[RL_REGIME_PREV_WORST_PNL_INDEX] = worst_now;

    float mean   = isv[RL_REGIME_SESSION_PNL_VAR_MEAN_INDEX];
    float m2     = isv[RL_REGIME_SESSION_PNL_VAR_M2_INDEX];
    float count  = isv[RL_REGIME_SESSION_PNL_VAR_COUNT_INDEX];

    // Skip update on bootstrap (both zero)
    if (worst_now != 0.0f || worst_prev != 0.0f) {
        count += 1.0f;
        const float delta  = dx - mean;
        mean += delta / count;
        const float delta2 = dx - mean;
        m2 += delta * delta2;
        isv[RL_REGIME_SESSION_PNL_VAR_MEAN_INDEX]  = mean;
        isv[RL_REGIME_SESSION_PNL_VAR_M2_INDEX]    = m2;
        isv[RL_REGIME_SESSION_PNL_VAR_COUNT_INDEX] = count;
        const float variance = (count > 1.0f) ? (m2 / (count - 1.0f)) : 0.0f;
        isv[RL_REGIME_SESSION_PNL_VARIANCE_EMA_INDEX] = variance;
    }

    // ─── Phase 3: tail-event detection (3σ threshold) ──────
    const float var       = isv[RL_REGIME_SESSION_PNL_VARIANCE_EMA_INDEX];
    const float sigma_thr = isv[RL_REGIME_TAIL_SIGMA_THRESHOLD_INDEX];
    const float sigma     = sqrtf(var);

    // Require count >= 10 before tail detection (Welford bootstrap)
    const int sufficient_count = (count >= 10.0f) ? 1 : 0;
    const int is_tail = sufficient_count && (sigma > 0.0f) && (fabsf(dx) > sigma_thr * sigma);

    float recency = isv[RL_REGIME_TAIL_EVENT_RECENCY_INDEX];
    if (is_tail) recency = 0.0f; else recency += 1.0f;
    isv[RL_REGIME_TAIL_EVENT_RECENCY_INDEX] = recency;

    // ─── Phase 4: emit recovery_factor and eps_recovery_live (issue #9, drift-free) ──
    const float n_recovery = isv[RL_KELLY_EPS_RECOVERY_N_RECOVERY_INDEX];
    const float eps_min    = isv[RL_KELLY_EPS_RECOVERY_MIN_INDEX];
    const float eps_max    = isv[RL_KELLY_EPS_RECOVERY_MAX_INDEX];

    const float recovery_factor = fminf(1.0f, recency / fmaxf(n_recovery, 1.0f));
    const float eps_live        = eps_min + (eps_max - eps_min) * recovery_factor;

    isv[RL_REGIME_RECOVERY_FACTOR_INDEX]      = recovery_factor;
    isv[RL_KELLY_EPS_RECOVERY_LIVE_INDEX]     = eps_live;
}
```

**Bootstrap state** (set in `with_controllers_bootstrapped`):
- `RL_REGIME_DEAD_ZONE_FLAG` = 0.0
- `RL_REGIME_DEAD_ZONE_DURATION` = 0.0
- `RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG` = 0.0
- `RL_REGIME_RECOVERY_FACTOR` = 1.0 (no tail seen yet — start at max recovery)
- `RL_REGIME_SESSION_PNL_VARIANCE_EMA` = 0.0
- `RL_REGIME_TAIL_EVENT_RECENCY` = 1.0e6 (sentinel "no tails seen")
- `RL_REGIME_SESSION_PNL_VAR_*` = 0.0 (Welford bootstrap, gated by count >= 10)
- `RL_REGIME_PREV_WORST_PNL` = 0.0 (sentinel)
- `RL_KELLY_EPS_RECOVERY_LIVE` = 0.50 (= ε_max, no override needed)
- `RL_REGIME_DEAD_ZONE_MAX_DURATION` = 1000.0
- `RL_KELLY_EPS_RECOVERY_MIN` = 0.05
- `RL_KELLY_EPS_RECOVERY_MAX` = 0.50
- `RL_KELLY_EPS_RECOVERY_N_RECOVERY` = 100.0
- `RL_IQN_TAU_TAIL_BOOST_FACTOR` = 1.5
- `RL_IQN_TAU_TAIL_BOOST_N_WINDOW` = 100.0
- `RL_REGIME_TAIL_SIGMA_THRESHOLD` = 3.0
- `RL_POPART_MAX_ABS_REWARD_EMA` = 0.0 (sentinel; fast-up envelope captures first observation)
- `RL_POPART_MAX_DECAY_ALPHA` = 0.01 (~69-step half-life)

### rl_regime_flat_count.cu (block-reduce, ~25 LOC)

```c
extern "C" __global__ void rl_regime_flat_count(
    const int* __restrict__ lots,
    int* __restrict__ flat_count_out,
    int b_size
) {
    extern __shared__ int s_count[];
    int tid = threadIdx.x;
    int sum = 0;
    for (int b = tid; b < b_size; b += blockDim.x) {
        sum += (lots[b] == 0) ? 1 : 0;
    }
    s_count[tid] = sum;
    __syncthreads();
    for (int s = blockDim.x / 2; s > 0; s >>= 1) {
        if (tid < s) s_count[tid] += s_count[tid + s];
        __syncthreads();
    }
    if (tid == 0) flat_count_out[0] = s_count[0];
}
```

Launch: `<<<1, 256, 256*sizeof(int)>>>`. Per `feedback_no_atomicadd`: tree-reduce only.

### Kelly resurrection (modification to rl_kelly_fraction_controller.cu)

Append to END of existing kernel (after analytic Kelly clamp):

```c
// Kelly resurrection (Theorem 1): override analytic kelly if DEAD_ZONE_FLAG fires
//
// Two safety checks:
//   1. DEAD_ZONE_TIMEOUT_FLAG: if dead-zone has persisted > MAX_DURATION steps,
//      stop trying to resurrect (let kelly stay at 0; halt the bleed).
//      The trainer monitors TIMEOUT_FLAG as a halt-training signal.
//   2. Otherwise: kelly_f overridden with ε_recovery_live (computed by regime_observer).
const int dead_zone = (int)isv[RL_REGIME_DEAD_ZONE_FLAG_INDEX];
const int timeout   = (int)isv[RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX];
if (dead_zone && !timeout) {
    isv[RL_KELLY_FRACTION_INDEX] = isv[RL_KELLY_EPS_RECOVERY_LIVE_INDEX];
}
```

Mirror same logic in `rl_fused_controllers.cu` Layer-4 branch.

**Interaction with v9 boundary overrides**: v9's `eval_warmup_decay.cu` overrides `RL_KELLY_SAFETY_FRAC_INDEX` (the FLOOR feeding analytic Kelly), not `RL_KELLY_FRACTION_INDEX` directly. So:
- Pure v9 (boundary, no dead-zone): analytic Kelly computed with v9-defensive safety_frac; resurrection NOT triggered (flag = 0); analytic value retained.
- Pure dead-zone (no boundary): analytic Kelly = 0 (no edge); resurrection overrides with ε_recovery_live.
- Both fire simultaneously (boundary + dead-zone): analytic Kelly computed with v9 safety_frac (likely still 0 if regime is adverse); resurrection then overrides with ε_recovery_live; **resurrection wins last-write**.

This last-write semantic is correct intent: when dead-zone is active, we want the absorbing-state escape behavior to dominate. v9's safety floor is just a defensive Kelly bias for normal operation; it's not designed for absorbing-state recovery.

### Popart per-account max envelope (modification to rl_popart_normalize.cu)

Modify the existing kernel by (a) extending Pass 1 to also track per-thread `local_max_abs`, (b) extending the warp/shared-mem reductions to compute `max_abs` alongside sum/sum_sq, and (c) inserting envelope-detector logic inside the `if (tid == 0)` block.

**Pass 1 extension (inside `for (int i = tid; ...)` loop):**

```c
// Pass 1 (existing): local_sum + local_sum_sq accumulation
// ADD: per-thread max |r_i|
float local_max_abs = 0.0f;  // neutral element (|r| ≥ 0)
for (int i = tid; i < b_size; i += block_dim) {
    float r = rewards[i];
    local_sum += r;
    local_sum_sq += r * r;
    local_max_abs = fmaxf(local_max_abs, fabsf(r));  // NEW
}
```

**Warp-level max reduction (add alongside warp_reduce_sum):**

```c
__device__ __forceinline__ float warp_reduce_max(float val) {
    for (int offset = 16; offset > 0; offset >>= 1) {
        val = fmaxf(val, __shfl_down_sync(0xFFFFFFFF, val, offset));
    }
    return val;
}
```

**Warp-shuffle + shared-mem reduction (mirrors existing sum/sum_sq pattern):**

```c
// After existing warp_reduce_sum calls:
float warp_max_abs = warp_reduce_max(local_max_abs);  // NEW

// Lane 0 of each warp writes to shared mem (need a 3rd shared bank).
// Extend shared mem layout: sdata = [block_dim] sum + [block_dim] sum_sq + [block_dim] max_abs + 3 broadcast slots
// Updated allocation: extern __shared__ float sdata[block_dim * 3 + 3];
float* s_max_abs = sdata + block_dim * 2;  // NEW third bank

if (lane_id == 0) {
    s_sum[warp_id]     = warp_sum;       // existing
    s_sum_sq[warp_id]  = warp_sum_sq;    // existing
    s_max_abs[warp_id] = warp_max_abs;   // NEW
}
__syncthreads();

// Final reduction across warps (first warp only):
if (tid < 32) {
    float v_sum = (tid < n_warps) ? s_sum[tid] : 0.0f;
    float v_ssq = (tid < n_warps) ? s_sum_sq[tid] : 0.0f;
    float v_max = (tid < n_warps) ? s_max_abs[tid] : 0.0f;
    v_sum = warp_reduce_sum(v_sum);
    v_ssq = warp_reduce_sum(v_ssq);
    v_max = warp_reduce_max(v_max);     // NEW

    if (tid == 0) {
        // ── Existing: compute batch_mean, batch_var, Welford-EMA update,
        //    write old/new sigma+mean, etc. ──
        // ... (existing code unchanged through new_sigma computation) ...

        // ── NEW: per-account max-magnitude envelope (F4) ──────────────
        const float max_r_this_step = v_max;
        const float decay_alpha = isv[RL_POPART_MAX_DECAY_ALPHA_INDEX];
        float max_r_ema = isv[RL_POPART_MAX_ABS_REWARD_EMA_INDEX];
        if (max_r_this_step > max_r_ema) {
            max_r_ema = max_r_this_step;     // instant capture (fast-up)
        } else {
            max_r_ema = (1.0f - decay_alpha) * max_r_ema
                      + decay_alpha * max_r_this_step;   // slow decay
        }
        isv[RL_POPART_MAX_ABS_REWARD_EMA_INDEX] = max_r_ema;

        // Apply floor: σ_effective = max(σ_existing, max_r_ema)
        new_sigma = fmaxf(new_sigma, max_r_ema);
        // ── END NEW ────────────────────────────────────────────────────

        isv[POPART_SIGMA_INDEX] = new_sigma;  // existing write

        // Broadcast to all threads via shared mem.
        // Existing code wrote at `sdata[block_dim * 2 + 0/1]`; broadcast slots
        // moved to `sdata[block_dim * 3 + 0/1]` because the 3rd bank (max_abs)
        // now occupies the previous broadcast region.
        sdata[block_dim * 3 + 0] = new_mean;
        sdata[block_dim * 3 + 1] = new_sigma;

        __threadfence_system();
    }
}
__syncthreads();

// ── Pass 3 (CRITICAL UPDATE): normalize rewards in place ──────────
// MUST update broadcast slot indices from block_dim*2 to block_dim*3
// to match the new shared mem layout (existing code reads from
// block_dim*2 which now contains max_abs bank instead of broadcast).
float mean  = sdata[block_dim * 3 + 0];  // WAS: sdata[block_dim * 2 + 0]
float sigma = sdata[block_dim * 3 + 1];  // WAS: sdata[block_dim * 2 + 1]
float inv_sigma = 1.0f / sigma;
for (int i = tid; i < b_size; i += block_dim) {
    rewards[i] = (rewards[i] - mean) * inv_sigma;
}
```

**Shared-memory size update**: kernel launch must allocate `(block_dim * 3 + 3) * sizeof(float)` bytes (up from `block_dim * 2 + 2`). Update the trainer's launch call in `integrated.rs` where `rl_popart_normalize` is launched — find the `smem_bytes` expression `(block_x * 2 + 2) * sizeof::<f32>()` and change to `(block_x * 3 + 3) * sizeof::<f32>()`.

**Kernel arg signature unchanged**: still `(float* rewards, float* isv, int b_size)`. The new logic uses ISV slots for state.

The `rl_popart_v_correct.cu` kernel runs immediately after `rl_popart_normalize` and affine-corrects `V_pred` for any sigma change, so the discontinuous sigma jump is mathematically smooth from V regression's perspective (see Theorem 6).

**Bootstrap**:
- `RL_POPART_MAX_ABS_REWARD_EMA = 0.0` (sentinel — first observation captures via fast-up path)
- `RL_POPART_MAX_DECAY_ALPHA = 0.01` (configurable; ~69-step half-life)

**Reset at regime boundary**: `reset_session_state` zeroes `RL_POPART_MAX_ABS_REWARD_EMA_INDEX` so eval regime starts with fresh envelope (per [[pearl_adaptive_carryover_discipline]]).

### Regime observer reset policy at fold/eval boundaries

Per `[[pearl_adaptive_carryover_discipline]]`, every adaptive signal must explicitly reset or re-bootstrap at regime boundaries. `reset_session_state` in `trainer/integrated.rs` must be extended to handle regime_observer slots:

```rust
// In reset_session_state (called at every fold transition):
// Set these slots BEFORE the next regime_observer step:

// Cleared (transient state):
write_isv(RL_REGIME_DEAD_ZONE_FLAG_INDEX,         0.0);
write_isv(RL_REGIME_DEAD_ZONE_DURATION_INDEX,     0.0);
write_isv(RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX, 0.0);

// Re-bootstrapped to sentinel (TAIL_EVENT_RECENCY back to "no tails seen"):
write_isv(RL_REGIME_TAIL_EVENT_RECENCY_INDEX,     1.0e6);
write_isv(RL_REGIME_RECOVERY_FACTOR_INDEX,        1.0);   // implies ε = ε_max post-reset
write_isv(RL_KELLY_EPS_RECOVERY_LIVE_INDEX,       0.50);  // ε_max default

// CRITICAL (Issue γ): align PREV_WORST_PNL with the just-reset session_pnl_worst
// to prevent spurious tail event from |Δworst| = |0 - prior_train_worst| ≈ $15k
write_isv(RL_REGIME_PREV_WORST_PNL_INDEX, current_worst_pnl_value);  // = 0 after session reset

// F4: popart envelope reset for new regime
write_isv(RL_POPART_MAX_ABS_REWARD_EMA_INDEX,     0.0);

// KEPT (cross-regime statistics — Welford state continues accumulating):
// RL_REGIME_SESSION_PNL_VAR_M2_INDEX:    keep (Welford variance estimate stays)
// RL_REGIME_SESSION_PNL_VAR_MEAN_INDEX:  keep
// RL_REGIME_SESSION_PNL_VAR_COUNT_INDEX: keep
//
// Rationale: the variance of session_pnl_change is a regime-invariant
// distributional property of the market (not the agent's policy state).
// Resetting would discard prior-regime variance information needed for
// 3σ detection in the next regime.
```

**Justification per [[pearl_adaptive_carryover_discipline]]**:
- Transient signals (DEAD_ZONE_*, RECOVERY_FACTOR, TIMEOUT) reset to neutral
- Counter-style signals (TAIL_EVENT_RECENCY) re-bootstrap to sentinel
- Δ-source slot (PREV_WORST_PNL) MUST align with the reset session value
- Welford distributional state (M2, MEAN, COUNT) is regime-invariant — keep
- F4 envelope (MAX_ABS_REWARD_EMA) resets per the dead_signal_resurrection pattern

### IQN τ tail-boost (modification to rl_iqn_action_tau_controller.cu)

Insert before final τ clamp:

```c
// Tail-recency τ_min boost (defense in depth)
const float recency     = isv[RL_REGIME_TAIL_EVENT_RECENCY_INDEX];
const float tail_window = isv[RL_IQN_TAU_TAIL_BOOST_N_WINDOW_INDEX];
const float boost       = isv[RL_IQN_TAU_TAIL_BOOST_FACTOR_INDEX];

float tau_min_eff = isv[RL_IQN_ACTION_TAU_MIN_INDEX];
if (recency < tail_window) {
    tau_min_eff *= boost;
}
tau_action = fmaxf(tau_action, tau_min_eff);
```

Mirror in `rl_fused_controllers.cu` Layer-2 branch.

## Mathematical proofs

### Theorem 1 — Kelly trap eliminated

**Claim**: Under new design, the state space contains no absorbing states (except the explicitly-armed `TIMEOUT_FLAG=1` terminal state, which is a safety stop, not a stuck training state).

**Proof**: Define state $s_t = (\text{kelly}_t, N_{open,t}, p_t, W_t, L_t, \text{duration}_t)$. Old design absorbing region:
$$A_{old} = \{s : \text{kelly} = 0 \land N_{open} = 0 \land p < L/(W+L)\}$$

From any $s \in A_{old}$, next-step state is deterministically $s_{t+1} = s_t$ — no exit.

New design: when $s_t$ enters dead-zone (composite $\text{kelly}_t=0 \land N_{open,t}=0 \land \text{cooldown}_t=0$):
1. Regime_observer sets `DEAD_ZONE_FLAG = 1` and increments `DURATION`
2. If `DURATION ≤ MAX_DURATION`, Kelly kernel reads flag, sets $\text{kelly}_{t+1} \leftarrow \epsilon(T_t) \in [0.05, 0.50]$
3. Position sizing kernel computes $\lceil \text{base} \cdot \epsilon \rceil \geq 1$
4. Conditional on agent action $a \in \{\text{OpenLong*}, \text{OpenShort*}\}$ with probability $\pi(a|s) > 0$ (conservatively bound $\pi(\text{any Open}) \geq 0.05$):

$$P(N_{open,t+1} \geq 1) \geq 1 - (1 - 0.05)^{1024} > 1 - 10^{-23}$$

For all practical purposes, exit from dead-zone occurs deterministically on step $t+1$. The 0.05 bound is intentionally conservative; empirical observation gave $\pi(\text{any Open}) \approx 0.55$.

If `DURATION > MAX_DURATION`, `TIMEOUT_FLAG = 1`, Kelly stays at 0 — this is the safety-net terminal state, intended for operator intervention rather than self-recovery. ∎

### Theorem 2 — v9 behavior preserved

**Claim**: After slot renaming, v9 eval-boundary behavior is bit-identical to pre-refactor.

**Proof**: Slot renaming changes only constant identifiers in source code:
$$\text{addr}(\text{RL\_EVAL\_WARMUP\_REMAINING\_INDEX}) = 685 = \text{addr}(\text{RL\_REGIME\_TRANSITION\_REMAINING\_INDEX})$$

All writers (`reset_session_state` in `trainer/integrated.rs`) and readers (`eval_warmup_decay.cu` kernel) reference the same memory address before and after the rename. The diff is purely syntactic. Same machine code emitted by the compiler. Verifiable by comparing pre/post-refactor diag.jsonl: must be byte-identical for fixed seed. ∎

### Theorem 3 — Bounded loss during dead-zone recovery (issue #4 safety)

**Claim**: Under new design, the total cumulative loss during any continuous dead-zone period is bounded by:
$$\text{Loss}_{\text{recovery}} \leq \text{MAX\_DURATION} \cdot \epsilon_{\max} \cdot \overline{d}_{step} \cdot \overline{L}_{trade}$$
where $\overline{d}_{step}$ is the per-step done rate during recovery and $\overline{L}_{trade}$ is the average loss per closed trade.

**Proof**:
- Each step in dead-zone, Kelly is overridden with $\epsilon(T) \leq \epsilon_{\max}$
- Position sizing is $\lceil \text{base} \cdot \epsilon \rceil$, so per-account position size $\leq \text{base} \cdot \epsilon_{\max}$
- Expected loss per step during dead-zone:
  $$E[\text{loss}_{step}] \leq \epsilon_{\max} \cdot \overline{d}_{step} \cdot \overline{L}_{trade}$$
- After `MAX_DURATION` steps, `TIMEOUT_FLAG` fires, Kelly returns to 0, no further opens (Theorem 1's escape blocked by safety net)
- Total loss bounded by sum over at most `MAX_DURATION` steps

With MAX_DURATION = 1000, $\epsilon_{\max} = 0.5$, $\overline{d}_{step} = 7$, $\overline{L}_{trade} = \$2000$:
$$\text{Loss}_{\text{recovery}} \leq 1000 \cdot 0.5 \cdot 7 \cdot 2000 = \$7M \text{ per dead-zone episode}$$

This is a worst-case bound assuming every probe loses; realistic loss is much smaller because:
- ε ramps from ε_min, so early-episode loss rate is 10× smaller
- Real regimes have some wins; loss rate is $\overline{L}_{trade} \cdot (1 - p_{win})$
- Once analytic Kelly recovers, dead-zone exits before MAX_DURATION

The bound is an upper envelope, not an expected value. ∎

### Theorem 4 — No absorbing states (refined from v1's overstated claim)

**Claim**: The state graph under the new design has no absorbing states (except `TIMEOUT_FLAG=1`, which is an operator-intended safety stop).

**Proof**:
- Old absorbing region $A_{old}$ (Kelly=0 ∧ flat ∧ p < threshold): from any $s \in A_{old}$, Theorem 1 gives $P(\text{exit at next step}) > 1 - 10^{-23}$
- No other absorbing state existed in the old design (verified by exhaustive case analysis of risk-stack controllers)
- New design adds no new absorbing region except `TIMEOUT_FLAG=1` which is operator-intended

**What this does NOT claim**: that the system has bounded expected return time to "healthy" states. The Markov chain could wander in low-reward regions for arbitrary times. Theorem 3 bounds the LOSS during these wanderings to $\leq \$7M$ per dead-zone episode (worst case), which is the operational safety guarantee.

∎ (no claim about return-time distribution beyond non-absorbing)

### Theorem 5 — Linear ramp is a qualitative approximation to Bayes-optimal sigmoid (refined from v1)

**Claim**: The linear ramp $f(T) = \min(1, T/N)$ is monotone increasing, satisfies $f(0) = 0$, $f(N) = 1$, and approximates the Bayes-optimal posterior-of-recovery under a Poisson shock model.

**Proof**: Under Poisson shock prior with rates $\lambda_H$ (hostile) and $\lambda_F$ (friendly), the posterior of "hostile" after $T$ shock-free steps:
$$P(H | T) = \frac{e^{-\lambda_H T} P_0(H)}{e^{-\lambda_H T} P_0(H) + e^{-\lambda_F T} (1-P_0(H))}$$

The optimal exploration rate $\epsilon^*(T) \propto 1 - P(H|T)$ is a sigmoid in $T$ with two parameters (midpoint and slope).

The linear ramp is a one-parameter approximation: it preserves the qualitative properties (monotone, saturates at 1) but doesn't preserve the sigmoid's S-shape. Specifically:
- Both share $f(0) = 0$, $\lim_{T\to\infty} f(T) = 1$, monotone increasing
- Linear ramp has bounded support $[0, N]$; sigmoid is asymptotic
- Linear ramp has constant slope $1/N$; sigmoid has variable slope

The linear ramp is selected for simplicity (1 parameter vs 2) and operational interpretability ("fully recovered after N steps"). It's not a numerical best-fit to the sigmoid in any specific norm — it's an engineering simplification that preserves the qualitative shape. ∎

### Theorem 6 — Popart now responds to per-account tail shocks within one step

**Claim**: After the envelope-detector floor is added, `popart_sigma_effective` responds to single-account tail events within one step, and the affine V-correct kernel handles the discontinuity without destabilizing V regression.

**Proof**:

Let $r_{tail}$ be the reward magnitude of the worst-case account at step $t$ (post reward_scale). Pre-fix:
$$\sigma_{popart}(t) = \sqrt{(1-\alpha) \cdot \sigma^2(t-1) + \alpha \cdot \text{batch\_var}(t)}$$
where $\text{batch\_var}(t)$ is dominated by the bulk distribution (1023 quiet accounts), making the tail invisible. Empirical: σ went 2.327 → 2.337 at Run #8 step 7377 despite $r_{tail} \approx -19.45$ (post-scale estimate based on `reward_scale ≈ 0.003` observed earlier in training).

Post-fix, with envelope detector:
$$\text{max\_r\_ema}(t) = \begin{cases}
r_{tail} & \text{if } r_{tail} > \text{max\_r\_ema}(t-1) \quad \text{[fast-up]} \\
(1-\alpha_d) \cdot \text{max\_r\_ema}(t-1) + \alpha_d \cdot r_{tail} & \text{otherwise [slow-down]}
\end{cases}$$
$$\sigma_{effective}(t) = \max(\sigma_{popart}(t), \text{max\_r\_ema}(t))$$

At Run #8 step 7377 with prior `max_r_ema ≈ 1` and $r_{tail} = 19.45 > 1$:
$$\text{max\_r\_ema}(7377) = 19.45$$
$$\sigma_{effective}(7377) = \max(2.337, 19.45) = 19.45$$

V regression normalization uses $\sigma_{effective} = 19.45$ (8.3× existing). The `rl_popart_v_correct.cu` kernel runs after `rl_popart_normalize` and applies the affine correction:
$$V_{pred}^{new} = \frac{\sigma_{old}}{\sigma_{new}} V_{pred}^{old} + \frac{\mu_{old} - \mu_{new}}{\sigma_{new}} = \frac{2.337}{19.45} V_{pred}^{old} + \ldots \approx 0.12 \cdot V_{pred}^{old}$$

After the affine correction, normalized V_pred matches the new normalization scale. V loss = $(V_{pred} - V_{target})^2$ stays small because both are now in the new normalized scale.

Decay phase: after K steps with no new shocks of similar magnitude, `max_r_ema` decays exponentially toward the typical BATCH-MAX |r_i| per step (which is bigger than the typical individual reward; for a 1024-account batch this is the top-of-distribution value). With $\alpha_d = 0.01$ (~69-step half-life), `max_r_ema` returns to typical-batch-max baseline within ~3 half-lives (~200 steps). The floor stops being active once `max_r_ema < σ_popart`. ∎

## Synthetic test harness (issue #8)

GPU-oracle invariant tests follow the pattern from `risk_stack_invariants.rs`. Each test:
1. Allocates an ISV buffer
2. Writes specific input values via `rl_isv_write` direct ISV write helper
3. Sets `lots_d` to specific position state
4. Launches `rl_regime_flat_count` + `rl_regime_observer` + (consumer kernel)
5. Reads result via `rl_isv_read` helper
6. Asserts result matches oracle

Example fixture (G15 — DEAD_ZONE_FLAG fires correctly):

```rust
#[test]
#[ignore = "Requires CUDA (MlDevice::cuda(0))"]
fn g15_dead_zone_flag_fires_on_composite() {
    let device = MlDevice::cuda(0).expect("CUDA required");
    let stream = device.default_stream();

    let b_size = 4_usize; // small for unit test
    let mut isv = device.alloc_zeros::<f32>(RL_SLOTS_END).unwrap();
    let mut lots = device.alloc_zeros::<i32>(b_size).unwrap();
    let mut flat_count = device.alloc_zeros::<i32>(1).unwrap();

    // Bootstrap regime config slots
    write_isv(&stream, &mut isv, RL_REGIME_DEAD_ZONE_MAX_DURATION_INDEX, 1000.0);
    write_isv(&stream, &mut isv, RL_REGIME_TAIL_SIGMA_THRESHOLD_INDEX, 3.0);

    // Compose dead-zone state:
    write_isv(&stream, &mut isv, RL_KELLY_FRACTION_INDEX, 0.0);        // kelly = 0
    write_isv(&stream, &mut isv, RL_COOLDOWN_REMAINING_STEPS_INDEX, 0.0); // no cooldown
    // lots all zeros (already from alloc_zeros) → all flat

    // Launch flat_count helper + regime_observer
    launch_regime_flat_count(&stream, &lots, &mut flat_count, b_size).unwrap();
    launch_regime_observer(&stream, &mut isv, &flat_count, b_size).unwrap();
    stream.synchronize().unwrap();

    // Assert DEAD_ZONE_FLAG = 1
    let flag = read_isv(&stream, &isv, RL_REGIME_DEAD_ZONE_FLAG_INDEX);
    assert_eq!(flag, 1.0, "expected dead_zone_flag=1, got {}", flag);

    let duration = read_isv(&stream, &isv, RL_REGIME_DEAD_ZONE_DURATION_INDEX);
    assert_eq!(duration, 1.0, "expected duration=1 (first step), got {}", duration);
}
```

Similar fixtures for:
- **G16**: DEAD_ZONE_FLAG = 0 when any of (kelly>0, flat<n, cooldown>0)
- **G17**: Kelly resurrection sets kelly_f = ε_recovery_live when flag set
- **G18**: Kelly resurrection holds kelly_f at analytic when flag NOT set
- **G19**: ε_recovery_live = ε_min at T=0; ε_max at T≥N_recovery; linear ramp in between
- **G20**: TIMEOUT_FLAG fires when DURATION > MAX_DURATION
- **G21**: Kelly resurrection NOT triggered when TIMEOUT_FLAG = 1
- **G22**: Tail event (Δworst > 3σ) resets TAIL_EVENT_RECENCY to 0
- **G23**: Welford count < 10 → tail detection gated off (no false positives during bootstrap)
- **G24**: IQN τ_min boost when TAIL_EVENT_RECENCY < N_window
- **G25**: IQN τ_min unchanged when TAIL_EVENT_RECENCY ≥ N_window

## Existing test back-compat (issue #7)

Pre-existing tests `g10_kelly_at_known_edge_returns_half_kelly`, `g11_kelly_at_negative_edge_clamps_to_zero`, `g12_kelly_held_at_bootstrap_during_warmup` need explicit precondition setup to avoid dead-zone trigger:

```rust
// In each existing G10/G11/G12 setup, BEFORE running Kelly kernel:
write_isv(&stream, &mut isv, RL_REGIME_DEAD_ZONE_FLAG_INDEX, 0.0);
write_isv(&stream, &mut isv, RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX, 0.0);
```

This explicitly disables resurrection during analytic-Kelly behavior testing. Tests verify analytic path; new G15-G25 verify resurrection path; both paths covered.

## Implementation phases

### Diag emit structure

F1 task #8 adds this block to `risk_stack` section of `alpha_rl_train.rs` diag emit:

```rust
"regime": {
    "dead_zone": {
        "flag":            isv[RL_REGIME_DEAD_ZONE_FLAG_INDEX],
        "duration":        isv[RL_REGIME_DEAD_ZONE_DURATION_INDEX],
        "timeout_flag":    isv[RL_REGIME_DEAD_ZONE_TIMEOUT_FLAG_INDEX],
        "max_duration":    isv[RL_REGIME_DEAD_ZONE_MAX_DURATION_INDEX],
    },
    "transition": {
        // F3-renamed v9 slots (existing diag fields stay the same value;
        // only the constant identifier changes in source).
        "remaining":       isv[RL_REGIME_TRANSITION_REMAINING_INDEX],
        "steps_config":    isv[RL_REGIME_TRANSITION_STEPS_CONFIG_INDEX],
        "decay_steps_config": isv[RL_REGIME_TRANSITION_DECAY_STEPS_CONFIG_INDEX],
    },
    "tail": {
        "recency":         isv[RL_REGIME_TAIL_EVENT_RECENCY_INDEX],
        "session_pnl_variance_ema": isv[RL_REGIME_SESSION_PNL_VARIANCE_EMA_INDEX],
        "sigma_threshold": isv[RL_REGIME_TAIL_SIGMA_THRESHOLD_INDEX],
        "welford_count":   isv[RL_REGIME_SESSION_PNL_VAR_COUNT_INDEX],
    },
    "kelly_eps_recovery": {
        "factor":          isv[RL_REGIME_RECOVERY_FACTOR_INDEX],
        "live":            isv[RL_KELLY_EPS_RECOVERY_LIVE_INDEX],
        "min":             isv[RL_KELLY_EPS_RECOVERY_MIN_INDEX],
        "max":             isv[RL_KELLY_EPS_RECOVERY_MAX_INDEX],
        "n_recovery":      isv[RL_KELLY_EPS_RECOVERY_N_RECOVERY_INDEX],
    },
    "popart_envelope": {
        // F4 — popart per-account max-magnitude floor
        "max_abs_reward_ema": isv[RL_POPART_MAX_ABS_REWARD_EMA_INDEX],
        "decay_alpha":        isv[RL_POPART_MAX_DECAY_ALPHA_INDEX],
    },
    "iqn_tau_boost": {
        "factor":   isv[RL_IQN_TAU_TAIL_BOOST_FACTOR_INDEX],
        "n_window": isv[RL_IQN_TAU_TAIL_BOOST_N_WINDOW_INDEX],
    },
},
```

All values are read directly from ISV slots (no inline computation) per drift-free principle (issue #9 from v1).

### Phase F1 — Foundation (zero behavior change)

**Goal**: regime_observer kernel emits signals; consumers don't read them yet.

Tasks:
1. Add 20 ISV slot constants to `isv_slots.rs`; bump `RL_SLOTS_END` to 716
2. Rename v9 constants in place: 685, 686, 687 (3-name rename, no physical migration)
3. Implement `rl_regime_flat_count.cu` kernel
4. Implement `rl_regime_observer.cu` kernel
5. Register both in `build.rs`
6. Trainer wires both kernels between LobSim (step 5) and risk-stack controllers (step 7)
7. Bootstrap entries (per spec)
8. Diag emit surfaces `risk_stack.regime.*` for observability

Validation:
- All existing tests pass (no behavior changes)
- Local 1k smoke: regime_observer emits sane values, DEAD_ZONE_FLAG=0 throughout, TAIL_EVENT_RECENCY grows linearly
- Diag fields appear and are read-only diagnostic

Estimated: 1 day

### Phase F2 — Kelly resurrection (Problem 1 fix)

**Goal**: Kelly reads DEAD_ZONE_FLAG and overrides with ε_recovery_live, respecting TIMEOUT_FLAG safety net.

Tasks:
1. Audit `rl_kelly_fraction_controller.cu` for existing rule violations (per `feedback_extending_existing_code_audits_for_existing_violations`)
2. Append ε_recovery override block (5 lines)
3. Mirror in `rl_fused_controllers.cu` Layer-4 branch
4. Update existing tests G10, G11, G12 with explicit dead-zone disable
5. Add new tests G15-G21 per harness sketches
6. Local 1k smoke; synthetic dead-zone smoke (inject state, verify cycle)

Validation:
- All G10-G25 pass on RTX 3050
- Synthetic dead-zone: cycle observed (enters → ε opens → exits → analytic returns); no permanent freeze
- 1k smoke unaffected (no real dead-zones)

Estimated: 1 day

### Phase F3 — v9 slot rename refactor

**Goal**: v9 reads `RL_REGIME_TRANSITION_REMAINING_INDEX`; bit-identical behavior.

Tasks:
1. Update `eval_warmup_decay.cu` to use new constant names (find/replace, 3 names)
2. Update `reset_session_state` in `trainer/integrated.rs`
3. Update v9 diag emit
4. Bit-equivalence test: run existing v9 smoke before/after refactor, diff diag.jsonl — must be byte-identical for fixed seed

Validation: byte-identical diag JSONL pre/post-refactor

Estimated: 0.5 day

### Phase F4 — Popart per-account max envelope (Problem 3 fix)

**Goal**: popart_sigma responds to per-account tail shocks within one step; V regression appropriately scales.

Tasks:
1. Audit `rl_popart_normalize.cu` for existing rule violations
2. Add second tree-reduce phase for `max |r_i|` across batch (use warp shuffle with `fmaxf(fabsf(...))`, ~15 LOC)
3. Add envelope-detector update for `RL_POPART_MAX_ABS_REWARD_EMA_INDEX`
4. Apply floor: `new_sigma = fmaxf(new_sigma, max_r_ema)` before write
5. Update `reset_session_state` to zero `RL_POPART_MAX_ABS_REWARD_EMA_INDEX` at regime boundary
6. Add tests:
   - **G26**: Inject batch with one r_i = -100, rest = small → popart_sigma jumps to ≥100 in one step
   - **G27**: After spike, decay observed over ~70 steps (consistent with α=0.01 half-life)
   - **G28**: V loss remains bounded (no spike) thanks to V-correct affine fixup

Validation: G26-G28 pass; existing popart tests still pass; no V regression NaN in synthetic shock smoke

Estimated: 0.5 day

### Phase F5 — IQN τ tail-recency consumer

**Goal**: τ_min temporarily raised when TAIL_EVENT_RECENCY < N_window.

Tasks:
1. Audit `rl_iqn_action_tau_controller.cu` for existing rule violations
2. Add tail-boost block (4 lines)
3. Mirror in `rl_fused_controllers.cu` Layer-2 branch
4. Add tests G24, G25

Validation: G24, G25 pass

Estimated: 0.5 day

### Phase F6 — Cluster validation

**Goal**: Full fold-1 walk-forward with F1+F2+F3+F4+F5 all landed.

**Note**: F2/F3/F4/F5 are independent after F1 lands. They can be implemented and committed in parallel for time savings; F6 requires all four landed.

Tasks:
1. Local b=128 smoke (~30s)
2. Cluster b=1024 smoke (5k steps) for graph stability
3. Full fold-1 walk-forward (20k train + 5k eval) with diag analysis
4. Validate: regime signals fire correctly; no anomalies; eval pnl meets baseline

Validation:
- Cluster run completes without trapping (no permanent kelly_f=0 OR TIMEOUT_FLAG observed)
- If dead-zone fires during training, ε-recovery cycle observed in diag (DURATION oscillates, doesn't monotonically grow toward MAX_DURATION)
- v9 eval-boundary mechanics still fire correctly (TRANSITION_REMAINING ramps 500→0→-1)
- Eval pnl ≥ -$507k baseline (v9 fix validated for first time end-to-end)
- Per-step throughput within 5% of v9 baseline (~5-7 sps on L40S)

Estimated: 2 hours wall + 1 hour GPU

## Total estimate

| Phase | Estimate | Can parallelize with |
|-------|----------|---------------------|
| F1 | 1 day | (none — foundation) |
| F2 | 1 day | F3, F4, F5 |
| F3 | 0.5 day | F2, F4, F5 |
| F4 | 0.5 day | F2, F3, F5 |
| F5 | 0.5 day | F2, F3, F4 |
| F6 | 0.5 day | (after F1+F2+F3+F4+F5) |

Serial: 4 days. Parallel (F2+F3+F4+F5 concurrent): 2.5 days. Cluster runs (F6) wall time additional.

## Risk analysis

### What could go wrong

1. **flat_count computation race** — if regime_observer reads flat_count before per-batch buffer populated this step, sees stale values. **Mitigation**: explicit launch order in trainer; regime_observer runs AFTER LobSim's position update.

2. **Welford bootstrap with small count** — first ~10 observations have noisy σ. **Mitigation**: tail detection gated on `count >= 10`. Bootstrap recency = 1e6 (sentinel) → recovery_factor = 1.0 → ε = ε_max during warmup, which is fine since Kelly is in warmup-gate anyway.

3. **Cycle bleed in adverse regime** — bounded by Theorem 3 ($7M worst case for MAX_DURATION=1000). **Mitigation**: TIMEOUT_FLAG safety net + operator monitors via diag.

4. **Last-write semantic with v9** — if eval-boundary AND dead-zone fire simultaneously, resurrection wins. **Mitigation**: explicit semantic documented; intent confirmed in spec.

5. **Tail detection too sensitive early** — if σ estimate is low during early training, 3σ might catch normal moves. **Mitigation**: count >= 10 gate + initial bootstrap to 1.0e6 recency means early-training is "fully recovered" by default.

6. **MAX_DURATION too short** — if recovery legitimately takes >1000 steps, TIMEOUT_FLAG fires prematurely. **Mitigation**: ISV-tunable; raise via runtime config if needed. Default 1000 chosen as 10× typical zero-run length (longest observed: 109 steps).

### What we explicitly accept

- One-step lag on dead-zone detection. Mathematically: irrelevant; absorbing state persists across step boundaries.
- TIMEOUT_FLAG terminates self-recovery in adverse regimes (operator-intervention required). This is intentional: the system shouldn't bleed unbounded.
- Slight added per-step latency from 2 new kernels (~10μs each on L40S, single-thread/single-block + 256-thread block-reduce) plus minor popart kernel extension. Negligible vs ~150ms host orchestration per step.
- Envelope decay tail: a single large shock keeps popart_σ conservatively high for ~500 steps (3 half-lives at α_d=0.01) before returning to baseline. This is intentional — sustained-recovery V normalization.

## Success criteria

A cluster fold-1 walk-forward with this design must:
1. Run to completion without Kelly trap (no kelly_f == 0 for > 200 consecutive steps with dones-frozen, OR if so, TIMEOUT_FLAG fires and is observed)
2. Eval pnl ≥ -$507k (v8 fold-1 baseline; v9 has no validated baseline since the v9 cluster run hit the Kelly trap before reaching the eval phase. This spec's success bar is matching v8 OR exceeding it, with the path forward being v9 mechanics + this spec's fixes producing a positive eval result over future folds.)
3. All risk_stack invariant tests G1-G28 pass on local GPU
4. Diag emits regime signals throughout; no NaN/anomaly in regime fields
5. Per-step throughput within 5% of v9 baseline (~5-7 sps on L40S b=1024)
6. F3 byte-identical bit-equivalence test passes (v9 behavior preserved)
7. F4 popart_sigma_effective tracks per-account tail magnitudes (visible in diag during any tail event)

## Open questions for review

- **MAX_DURATION = 1000**: based on 10× longest observed zero-run. If cluster reveals 200+-step recovery is sometimes legitimate, raise. ISV-tunable.
- **ε_max = 0.50**: matches Kelly safety_frac normal value. Could go lower (0.25 quarter-Kelly) for more conservative recovery. ISV-tunable.
- **TIMEOUT_FLAG operator handling**: spec just emits the flag. Trainer-level halt logic deferred (operator can kill workflow manually based on diag).

## Pearls referenced

- [[pearl_kelly_trade_stream_death]] — empirical motivation
- [[pearl_dead_signal_resurrection_discipline]] — meta-principle
- [[pearl_popart_blind_to_session_signals]] — Problem 3 fixed by F4 (Theorem 6)
- [[pearl_adaptive_carryover_discipline]] — v9's domain, preserved
- [[pearl_first_observation_bootstrap]] — Welford bootstrap discipline (count >= 10 gate)
- [[pearl_pi_actor_collapses_without_entropy_floor]] — policy-layer analog (precedent for floor-based escape)
- [[pearl_controller_anchors_isv_driven]] — every config value is ISV-driven
- [[feedback_isv_for_adaptive_bounds]] — adaptive bounds via ISV
- [[feedback_adaptive_not_tuned]] — signal-driven not constant-tuned
- [[feedback_smoke_validation_before_structural_priors]] — F1 ships as W=0 wiring before F2 priors
- [[feedback_no_atomicadd]] — regime_observer single-thread; flat_count tree-reduce
- [[feedback_cpu_is_read_only]] — kernels read via mapped-pinned ISV only
- [[feedback_extending_existing_code_audits_for_existing_violations]] — audit Kelly + IQN τ kernels before modifying
- [[feedback_no_partial_refactor]] — F1 lands ISV slots + kernels + bootstrap + diag atomically

## Changes from v1 to v2

| # | Issue | v1 state | v2 resolution |
|---|-------|----------|---------------|
| 1 | Popart fix mechanism wrong | F4 included with per-done observations | F4 redesigned: investigated kernel; mechanism is BATCH-AVERAGING not per-step-vs-per-event. New design: envelope-detector on per-step max |r_i|, used as floor on σ. V-correct kernel handles affine correction. Theorem 6 added. |
| 2 | Theorem 4 overstated | "E[T_trap] = ∞" claim | Refined: "no absorbing states" only; no return-time claim |
| 3 | Theorem 5 hand-waves | "best approximation" | Refined: "qualitative approximation" with explicit caveats |
| 4 | Cycle bleed unbounded | DD breaker per-account only | Added TIMEOUT_FLAG safety net + Theorem 3 loss bound |
| 5 | Launch order ambiguous | "after risk-stack" unclear | Explicit pipeline insertion: between LobSim (step 5) and risk-stack (step 7) |
| 6 | α_done hardcoded | 0.01 inline in popart | α_d now ISV-driven via `RL_POPART_MAX_DECAY_ALPHA_INDEX` (slot 715) |
| 7 | Test back-compat | not addressed | G10/G11/G12 setup updated with explicit dead-zone disable |
| 8 | Synthetic harness undocumented | G15-G21 listed only | G15 fixture code sketched; G15-G25 enumerated |
| 9 | Diag drift risk | recovery_factor computed inline | Emitted by regime_observer to ISV slots 699, 706 |

## Changes from v2 to v3

| # | Issue | v2 state | v3 resolution |
|---|-------|----------|---------------|
| α | Missing Goal #6 | F4 in scope but goal list incomplete | Added Goal 6: "Eliminate popart's blindness (Theorem 6)" |
| β | F4 Pass 3 broken | Pass 3 reads still at `sdata[block_dim*2+0/1]` (now max_abs bank) | Pass 3 reads updated to `sdata[block_dim*3+0/1]`; explicit code shown; trainer launch `smem_bytes` calc specified |
| γ | Boundary PREV_WORST_PNL not reset | False tail event at every fold boundary | `reset_session_state` aligns PREV_WORST_PNL with current worst_pnl post-reset |
| δ | regime_observer slot reset policy | unclear which slots reset/keep | Explicit reset policy section added: transient → reset, counters → re-bootstrap, Welford state → keep |
| ε | Diag JSON structure missing | "diag surfaces risk_stack.regime.*" but no shape | Concrete JSON structure added with all 6 nested groups |
| ζ | Theorem 6 decay phrasing | "decays toward typical r" | Refined: "decays toward typical batch-max \|r_i\|" |
| η | Success criterion baseline ambiguity | "-$507k (v9 baseline)" | Clarified: v8 fold-1 baseline; v9 has no validated baseline |

## Spec self-review v3

- Placeholders: none. Every algorithm has concrete formulas, slot numbers, parameter values, including the F4 popart reduction code AND Pass 3 read updates.
- Internal consistency: phases F1, F2, F3, F4, F5 reference the same ISV slot map. F2's resurrection logic uses RL_KELLY_EPS_RECOVERY_LIVE_INDEX which regime_observer writes (F1). F4 introduces RL_POPART_MAX_ABS_REWARD_EMA + RL_POPART_MAX_DECAY_ALPHA slots, bootstrapped and reset at regime boundary.
- Goals consistent: all 6 goals (T1-T6) map to specific phases. F1 → architecture; F2 → T1; F3 → T2; F4 → T6; safety net → T3; theorems T4-T5 are math-grounding for T1.
- Scope: focused on all 3 confirmed problems (Kelly trap, v9 cleanup, popart blindness) + defense-in-depth IQN τ. Orthogonality of F2 (Kelly) and F4 (popart) explicit; neither subsumes the other.
- Ambiguity resolved: ε_recovery formula explicitly linear; popart fix has concrete reduction code AND Pass 3 update; launch order explicit; test back-compat explicit; synthetic harness sketched; theorems in numerical order T1-T6; diag JSON structure concrete.
- Boundary correctness: reset_session_state policy explicit per regime_observer signal; PREV_WORST_PNL alignment prevents spurious tail event at fold transitions.
- Math: Theorem 1 conservative bound (10^-23) survives even minimal action diversity. Theorem 3 provides operational loss envelope (~$7M worst case, ~$6.7M tighter integral). Theorem 4 refined. Theorem 5 honest about being qualitative. Theorem 6 verified with empirical Run #8 numbers; decay phrasing precise.
- Safety: TIMEOUT_FLAG bounds dead-zone losses. Welford count gate prevents early false-tails. Last-write semantics with v9 explicit. popart_v_correct handles affine σ-discontinuity automatically.
