Files
foxhunt/docs/superpowers/specs/2026-05-31-regime-observer-design.md
jgrusewski 629ebd667c feat(ml-alpha): deterministic same-seed training + Tier 1.5 fast-dev-cycle
Two same-seed runs now produce bit-equal eval_summary.json, alpha_rl_train_summary.json,
and diag.jsonl (modulo wall-clock elapsed_s). The 5-phase falsification chain landed:

  Phase 2   PER tree-rebuild: __threadfence is NOT a grid-wide barrier; multiple blocks
            raced across sum-tree levels. Fix: Grid=(1) Block=(1024) + __syncthreads
            in rl_per_tree_rebuild.cu.

  Phase 2.3 cuBLAS GEMM_DFALT + TF32 default-math allowed split-K non-deterministic
            accumulation at 3 sites. New crates/ml-alpha/src/cublas_determinism.rs
            applies CUBLAS_PEDANTIC_MATH via FOXHUNT_DETERMINISTIC env toggle
            (0=TF32 prod, 1=PEDANTIC dev default, 2=DEFAULT_MATH control).

  Phase 2.6 Two bugs surfaced sequentially in the backward kernel chain:
            (1) rl_iqn_tau_cos_features had a multi-block r/w race on prng_state[batch]
                — all N_TAU=32 blocks read seed; only tau_idx==0 wrote back; no
                inter-block barrier. Fix: split into READ-ONLY rl_iqn_tau_cos_features
                + new sibling rl_iqn_advance_prng_state launched on same stream
                (kernel-launch ordering = grid-wide barrier).
            (2) OutcomeHead::new called near_zero_xavier without scoped_init_seed,
                falling back to time+thread-id RNG. Stayed dormant until first done
                event activated non-sentinel labels and divergent weights flowed via
                grad_h_t_outcome into encoder gradient. Fix: add seed param + install
                scoped_init_seed(dqn_seed.wrapping_add(0x0CE0)) guard.

Validation (./scripts/determinism-check.sh --quick, RTX 3050, b=128, 200+50 steps):
  - All 200 rows of checksums.* leaves match (rel-tol 1e-5, abs-tol 1e-7)
  - eval_summary.json, alpha_rl_train_summary.json byte-equal between runs
  - diag.jsonl byte-equal modulo elapsed_s
  - Eval pnl identical run-A vs run-B at seed 42

Pre-fix baseline (Phase 2.5 measurement): same-seed eval pnl spread $450k
($187k vs -$261k). Post-fix: $0 spread.

Speed cost: ~1.5ms/step amortised; ~10-15% slower than TF32 production
(PEDANTIC tax — acceptable in dev, toggle to FOXHUNT_DETERMINISTIC=0 for prod).

Mapped-pinned discipline: all 11 NEW memcpy_dtoh sites in diagnostic dump methods
+ per-step checksum readback use a new pub(crate) helper
read_slice_d_into<T: Copy>(stream, src, dst) — MappedRecordBuffer + raw
memcpy_dtod_async + raw_stream_sync + volatile read. Generic over T (f32, f64,
i32, u32, u8). Satisfies feedback_no_htod_htoh_only_mapped_pinned + hook guard.

Bundled Tier 1.5 fast-dev-cycle infrastructure (spec
docs/superpowers/specs/2026-06-02-fast-dev-cycle.md):
  - scripts/local-mid-smoke.sh        b=128, 2000+500, ~10min on RTX 3050
  - scripts/determinism-check.sh      runs mid-smoke twice, diffs checksums
  - scripts/tier1_5_verdict.py        behavioral kill verdict
  - AdamW checkpoint save/load (crates/ml-alpha/src/trainer/optim.rs)
  - IntegratedTrainer checkpoint save/load (resume from checkpoint)
  - 15 Phase 1 checksum leaves in build_diag_value
  - Env-gated dump methods (FOXHUNT_DETERMINISM_DEBUG_PER/MAMBA2/RL/BACKWARD)
    for future divergence-chasing — never run in production

Documentation:
  - docs/superpowers/specs/2026-06-02-determinism-foundation.md
  - docs/superpowers/specs/2026-06-02-fast-dev-cycle.md
  - docs/superpowers/plans/2026-06-02-determinism-foundation-implementation.md
  - docs/superpowers/notes/2026-06-02-determinism-phase{1,2,2.2,2.5,2.6}-*.md
  - Adjacent specs/plans/notes from the analytical chain that surfaced determinism
    as the load-bearing blocker (eval-summary, eval-boundary, regime-observer,
    multi-head policy, regime-invariance, Phase 3 IQN-complement post-mortem)

Unlocks: every controller / architecture / reward-shaping A/B from this commit
onward attributes outcome differences to the change, not random-init kernel-race
drift cascading through training x eval LOB-sim trajectories. The eval-collapse
investigation (pearl_reward_signal_anti_aligned_with_pnl, multi-head spec,
regime-invariance spec) is now testable with trustworthy verdicts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:56:00 +02:00

54 KiB
Raw Blame History

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:

// 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:

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:

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)

// 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.

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)

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):

// 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):

// 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):

__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):

// 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:

// 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:

// 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):

#[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:

// 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:

"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

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
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.