# Edge-Decay Detector — Phase 1: Diagnostic Only

**Date:** 2026-06-01 (v3, post-sp-critical-reviewer second pass)
**Status:** Draft (pre-implementation)
**Branch target:** `ml-alpha-regime-observer`
**Predecessors:** alpha-rl-glv6n (CMDP fleet-fraction diag, commit `a5e0d0079`)

**Pearl this spec implements:**
[[pearl_edge_decay_detection_is_a_missing_abstraction_layer]]

**v2 changes vs v1** (per sp-critical-reviewer first pass 2026-06-01):
- BLOCKER #1: PH buffers ARE reset at fold/eval boundary (predictive-reset discipline).
- BLOCKER #2: PH input is σ-normalized. δ, λ are unit-less.
- BLOCKER #3: warmup_min lowered from 50 to 10 (math fix).
- HIGH #3: `ph_max` removed (fleet-aggregate anti-pattern).
- HIGH #5: Welford pre-update mean convention stated explicitly.
- HIGH #6: `frac_ph_alerted` denominator = `n_active` not `b_size`.
- HIGH #8: Phase 2 resurrection details specified.
- MEDIUM/LOW: launch config, local smoke caveat, 3-strike, windowed-M, determinism wording.

**v3 changes vs v2** (per sp-critical-reviewer second pass 2026-06-01):
- BLOCKER (new): use `RL_POPART_SIGMA_WELFORD_INDEX = 725` (NOT slot 555) as σ denominator. CMDP kernel fires BEFORE popart_normalize in step_with_lobsim_gpu_body, so slot 555 is the PRIOR step's already-envelope-shocked σ. Slot 725 is the pre-envelope-floor Welford σ — stable, normalization-discipline-correct.
- HIGH (new): m_b cumulative gated behind `ph_count >= warmup_min` to avoid cold-start Welford bias accumulating into permanent offset. PH `m` and `M` only start accumulating AFTER warmup completes.
- HIGH (new): §G5 falsification threshold for `frac_ph_warmup` relaxed from <0.1 to <0.4 to acknowledge ~35% cooldown-suppressed batches (per `pearl_fleet_fraction_not_aggregate` alpha-rl-glv6n peak).
- MEDIUM: §3.3 explicit note that rewards[b] is RAW at CMDP-kernel entry, NOT popart-scaled.
- MEDIUM: ph_count_per_batch buffer comment unit error fixed (was "47k closes/batch" — that's fleet total, mean is ~46/batch).
- LOW: frac_ph_warmup monotone-non-increasing-within-session note in §G4.

---

## 0. Discipline boundary

Phase 1 is **observability only**. No behavior change. No throttle wiring. No interaction with Kelly / CMDP / actions_to_market_targets.

Phase 2 (separate spec, NOT in this document) wires the detector's output as a continuous throttle θ. Phase 1 must pass falsification before Phase 2 begins.

This phasing is the explicit response to [[feedback_going_in_circles_pattern]] — measure first, predetermine criteria, then build.

---

## 1. Motivation

`alpha-rl-glv6n` fleet-fraction diag (2026-06-01) decisively falsified the CMDP cooldown-trap hypothesis (mean `frac_in_cooldown` = 16.6%, max 34.7%, far below the 50% threshold). All existing risk-stack layers fire as designed; DD trips at 0.1% of eval steps, consec-loss near limit ≤1%. **Yet eval pnl is -$24.85M.**

Mechanism: the policy makes 95 closes/step in eval (vs 0.046/step in train) at wr=0.256 and pf=0.874. The architecture has no signal for "my per-trade EV is degrading, throttle execution". Every existing controller targets tail events; slow-bleed via overtrading passes through every filter.

Pearl `pearl_edge_decay_detection_is_a_missing_abstraction_layer` articulates this as a missing layer between Kelly (long-run sizing) and CMDP (tail kill). The canonical machinery (omnisearch 2026-06-01) is per-trade Page-Hinkley change-point detection.

This spec adds the **detector** as a diagnostic. It does NOT add the throttle. If the detector fires as designed (PH crosses threshold in eval, fleet fraction climbs from <0.1 to >0.3), Phase 2 wires it as a multiplicative position-size throttle.

## 2. Goals

### G1 — Per-batch Page-Hinkley state, σ-normalized

For each batch `b` in the fleet, maintain:
- `ph_mu_b`     — running mean of σ-normalized per-trade PnL (`y_t = x_t / σ_pop`)
- `ph_count_b`  — number of closed trades observed
- `ph_m_b`      — cumulative deviation `Σ(y_i − μ̄_pre − δ)` over close events
- `ph_M_b`      — running minimum of `ph_m_b`
- `ph_stat_b`   — current PH statistic = `ph_m_b − ph_M_b`

**Critical: input signal is normalized by popart σ_welford** (`isv[RL_POPART_SIGMA_WELFORD_INDEX = 725]`, NOT `RL_POPART_SIGMA_INDEX = 555`). The CMDP kernel fires **before** popart_normalize in `step_with_lobsim_gpu_body`, so slot 555 (σ_effective = max(σ_welford, max_abs_envelope)) holds the PRIOR step's value with the envelope spike already baked in. At the eval shock window the envelope can be 1500× larger than typical, blinding the detector. Slot 725 is the pre-envelope-floor Welford σ — bounded, slow-moving, and the canonical "normalization-preserved" value per [[pearl_popart_reset_at_eval_boundary_shocks_normalization]].

This makes δ and λ unit-less and invariant to popart envelope drift across the train→eval boundary. Without σ_welford normalization, σ shift would create false alerts in eval purely from scale change.

**Welford convention**: μ̄ is the **pre-update** running mean. The sequence is: (i) read `mu_prev`, (ii) compute `m += (y - mu_prev - δ)` **ONLY if `ph_count >= warmup_min`**, (iii) Welford-update `mu` from `(mu_prev, y, cnt+1)` and increment `cnt`. The `m` and `M` cumulatives DO NOT accumulate during warmup — this prevents cold-start Welford bias from being baked into a permanent offset in `m_b`. During warmup, only `mu_b` and `ph_count_b` advance; `m_b` and `M_b` stay at zero. When `ph_count` crosses `warmup_min`, PH cumulative starts from a clean μ̄ estimate.

### G2 — Update gate on done events only

PH updates ONLY on close events (`dones[b] >= 0.5`), matching the existing `consec_loss_per_batch` gate. Per-step rewards without a closing trade do NOT advance the PH state — the detector measures realized per-trade PnL, not per-step shaping noise.

### G3 — Reset at fold/eval boundary (predictive discipline)

Per [[pearl_adaptive_carryover_discipline]] + [[pearl_popart_reset_at_eval_boundary_shocks_normalization]]: PH state is **predictive** (predicts future edge degradation from recent trajectory), not normalization. It must reset at the train→eval boundary.

All 5 per-batch PH buffers (`ph_mu_per_batch`, `ph_count_per_batch`, `ph_m_per_batch`, `ph_M_per_batch`, `ph_stat_per_batch`) are added to the bulk memset block in `reset_session_state` (integrated.rs:3329-3348), alongside the existing `session_pnl_per_batch`, `consec_loss_per_batch`, `cooldown_remaining_per_batch`, `session_dd_triggered_per_batch`.

This means: eval phase starts with `ph_count_b = 0` for all batches. Warmup runs in eval. Detection fires only when eval-side trades drive PH stat above threshold.

### G4 — Fleet aggregates (3 emits, no `ph_max`)

Per [[pearl_fleet_fraction_not_aggregate]], emit only **fleet-fraction** and **mean-of-active** signals. The `ph_max` worst-aggregate from v1 is **removed** — it's the exact anti-pattern the spec invoked the pearl to avoid.

- `ph_mean`         — mean PH stat across active batches (n_active > 0; 0.0 if all warmup)
- `frac_ph_alerted` — `n_alerted / max(n_active, 1)` — alert rate AMONG ACTIVE batches
- `frac_ph_warmup`  — `n_warmup / b_size` — fleet fraction still in warmup

`frac_ph_alerted` denominator is `n_active` (not `b_size`) because before warmup completes, no batch can alert; including warmup-batches in the denominator structurally caps `frac_ph_alerted` at `(1 - frac_warmup)` for trivial reasons.

### G5 — Predetermined falsification (cluster smoke)

The detector is "designed correctly" iff at b=1024 fold-1 2k+500 H100 smoke:

| Signal | Train phase | Eval phase | Verdict |
|---|---|---|---|
| `frac_ph_warmup` | drops to < 0.4 by step 1000 | re-warms; drops to < 0.4 by eval step 200 | ✅ warmup converges |
| `ph_mean` (active batches) | < 1.0 throughout | rises from <1 to >3 over first 200 eval trades | ✅ detector firing |
| `frac_ph_alerted` (of active) | < 0.05 throughout | > 0.3 by eval step 300 | ✅ fleet detection working |

**Math check** (v3 second-pass reviewer correction — use distribution, not fleet-mean):
- alpha-rl-glv6n eval: 47,453 closes / 500 steps / 1024 batches = **0.093 closes per batch per step (FLEET MEAN)**
- But cooldown peak engagement is 34.7% of batches per `pearl_fleet_fraction_not_aggregate`, and those batches contribute ZERO closes during cooldown — so the cooldown-suppressed tail (~35%) may take much longer to warm up than the active majority.
- Fleet-mean by eval step 200: 18.5 closes/batch ⇒ majority of batches well past warmup_min=10
- But ~35% of cooldown-suppressed batches may have 0-5 closes by step 200 ⇒ frac_ph_warmup floor ~ 0.35
- Therefore §G5 threshold for `frac_ph_warmup` is relaxed from <0.1 to **<0.4** (acknowledges the cooldown-suppressed tail).
- `warmup_min = 10` keeps the active-majority warmup fast; the ~35% cooldown tail will not warm up in the smoke window but that's acceptable — they're already suppressed by cooldown and not contributing trades anyway.

**Threshold values** (unit-less under σ-normalization):
- `δ = 0.1` (10% of σ — typical noise tolerance for change detection)
- `λ = 5.0` (5 cumulative-σ above minimum — corresponds to ARL₀ ≈ 200 under Gaussian null)
- `warmup_min = 10` (sufficient for Welford mean to converge under bounded variance)

These are bootstrap calibration values, NOT controller-driven (Phase 1 is diagnostic). Phase 1 V2 cluster smoke validates them empirically. **3-strike abandonment**: if 3 consecutive V2 iterations fail the §G5 table for 3 different parameter reasons (e.g., δ too small → too high alert rate; then λ too low → too late; then warmup too long → no warmup), STOP and audit whether per-trade PnL is the right signal source at all (per [[feedback_going_in_circles_pattern]]).

### G5b — frac_ph_warmup semantics within a session

`frac_ph_warmup` is **monotone-non-increasing within a single session** (train or eval): once a batch crosses `warmup_min` closes its `ph_count_b` only grows. The signal re-rises only at the fold/eval boundary via the `reset_session_state` extension, which zeroes `ph_count_per_batch` for all batches.

This means: in V2 cluster smoke the metric goes 1.0 → ~0.35 over train, snaps back to 1.0 at the eval boundary (via reset), then drops again over the first ~200 eval steps. Verifying both transitions in the diag JSONL is the test that the reset hooked correctly.

### G6 — Non-goals

- **No throttle wiring.** PH state is read-only diagnostic.
- **No interaction with existing controllers.** PH state does not feed any current control loop.
- **No adaptive δ/λ in Phase 1.** Calibration constants; Phase 2 may make them adaptive.
- **No reward kernel changes.** Detector reads existing `rewards[b]`, `dones[b]`, plus `isv[RL_POPART_SIGMA_INDEX]` from the CMDP kernel's existing inputs (+1 ISV read).

## 3. Design

### 3.1 ISV slots (6 new, was 7 in v1)

```rust
// crates/ml-alpha/src/rl/isv_slots.rs
// Contiguous after slot 746 (CMDP fleet-fraction).

// Edge-decay detector (Phase 1) — Page-Hinkley on σ-normalized per-trade PnL.
// See spec docs/superpowers/specs/2026-06-01-edge-decay-detector-phase1-diagnostic.md
// and pearl pearl_edge_decay_detection_is_a_missing_abstraction_layer.
//
// Detector is scale-invariant: kernel divides rewards[b] by popart σ before
// feeding PH. δ and λ are unit-less. This is the canonical fix for popart
// envelope drift across the train→eval boundary
// (per pearl_popart_reset_at_eval_boundary_shocks_normalization).
//
// Configuration constants (bootstrap; not controller-driven in Phase 1):
pub const RL_EDGE_PH_TOLERANCE_INDEX:       usize = 747;  // δ unit-less (σ-relative)
pub const RL_EDGE_PH_THRESHOLD_INDEX:       usize = 748;  // λ unit-less alert level
pub const RL_EDGE_PH_WARMUP_MIN_INDEX:      usize = 749;  // min trades before PH active
// Fleet aggregates (kernel writes every step):
pub const RL_EDGE_PH_MEAN_INDEX:            usize = 750;  // mean ph_stat across active
pub const RL_EDGE_PH_FRAC_ALERTED_INDEX:    usize = 751;  // n_alerted / n_active
pub const RL_EDGE_PH_FRAC_WARMUP_INDEX:     usize = 752;  // n_in_warmup / b_size

/// Last RL-allocated slot index (exclusive).
/// ... (existing chain) ...
/// Post-CMDP-fleet-fraction diagnostic: 747.
/// Post-edge-decay-detector Phase 1: 753.
pub const RL_SLOTS_END: usize = 753;
```

Bootstrap entries (6 new) appended to `with_controllers_bootstrapped` array `[(usize, f32); 234]` → `[..; 240]`:

```rust
// Edge-decay Phase 1: δ, λ in σ-relative unit-less terms (input is
// rewards[b]/popart.σ). δ=0.1 means "ignore drifts smaller than 0.1σ
// per trade as noise"; λ=5.0 means "alert when cumulative deviation
// climbs 5σ above its lowest recent point"; warmup_min=10 means "PH
// inactive until 10 closes accumulated for that batch."
//
// These are calibration constants tuned via cluster ARL₀ simulation
// in v2 falsification (§G5). Phase 2 may make them ISV-driven.
(RL_EDGE_PH_TOLERANCE_INDEX,    0.1_f32),
(RL_EDGE_PH_THRESHOLD_INDEX,    5.0_f32),
(RL_EDGE_PH_WARMUP_MIN_INDEX,  10.0_f32),
// Aggregates: 0.0 sentinel; kernel overwrites every step.
(RL_EDGE_PH_MEAN_INDEX,         0.0_f32),
(RL_EDGE_PH_FRAC_ALERTED_INDEX, 0.0_f32),
(RL_EDGE_PH_FRAC_WARMUP_INDEX,  0.0_f32),
```

### 3.2 Per-batch device buffers (5 new) + reset_session_state extension

Add to `IntegratedTrainer` struct:

```rust
// Edge-decay PH per-batch state. Allocated [b_size] f32; zero-init at
// trainer construction AND on every fold/eval boundary via
// reset_session_state (predictive-reset per
// pearl_popart_reset_at_eval_boundary_shocks_normalization).
ph_mu_per_batch:    CudaSlice<f32>,    // pre-update Welford mean per batch
ph_count_per_batch: CudaSlice<f32>,    // n trades observed (f32; ~50-200 closes/batch in 500-step eval, fleet mean ~46; f32 precision ample)
ph_m_per_batch:     CudaSlice<f32>,    // Σ(y_i − μ̄_pre − δ)
ph_M_per_batch:     CudaSlice<f32>,    // min(ph_m) so far
ph_stat_per_batch:  CudaSlice<f32>,    // current PH stat = ph_m − ph_M
```

**Critical (BLOCKER #1 fix)**: extend `reset_session_state` (integrated.rs:3329-3348) to memset all 5 PH buffers to zero alongside the existing `session_pnl_per_batch_d` / `consec_loss_per_batch_d` / `cooldown_remaining_per_batch_d` / `session_dd_triggered_per_batch_d` block. This implements the predictive-reset discipline.

### 3.3 Kernel modification (rl_cmdp_constraints_check.cu)

**Launch config (unchanged from existing kernel)**: `grid=(1,1,1) block=(1,1,1)`, smem=0, single-thread sequential. Adding 5 ISV reads + 5 buffer reads + 5 buffer writes per batch × b_size doubles per-batch arithmetic. At b=1024 this is ~80µs additional, negligible vs RL pipeline (~100ms/step).

**Kernel signature** gets 5 new pointer params after the existing per-batch CMDP arg block:

```c
extern "C" __global__ void rl_cmdp_constraints_check(
    float* __restrict__ isv,
    const float* __restrict__ rewards,
    const float* __restrict__ dones,
    float* __restrict__ session_pnl_per_batch,
    float* __restrict__ consec_loss_per_batch,
    float* __restrict__ session_dd_triggered_per_batch,
    float* __restrict__ cooldown_remaining_per_batch,
    float* __restrict__ ph_mu_per_batch,        // NEW
    float* __restrict__ ph_count_per_batch,     // NEW
    float* __restrict__ ph_m_per_batch,         // NEW
    float* __restrict__ ph_M_per_batch,         // NEW
    float* __restrict__ ph_stat_per_batch,      // NEW
    int b_size
) { ... }
```

**IMPORTANT — kernel ordering**: `rl_cmdp_constraints_check` fires in `step_with_lobsim_gpu_body` BEFORE `apply_reward_scale` and `rl_popart_normalize`. So `rewards[b]` at kernel entry is RAW per-step PnL (USD), NOT popart-scaled. The σ slot read is the PRIOR step's σ_welford (one-step lag). σ_welford (slot 725) is the pre-envelope-floor Welford σ — chosen over σ_effective (slot 555) because slot 555 is reactive to envelope spikes (B-8 disaggregation; see [[pearl_popart_reset_at_eval_boundary_shocks_normalization]]).

**Constants read before the loop** (single-thread sequential gate already in place):

```c
#define RL_POPART_SIGMA_WELFORD_INDEX        725
#define RL_EDGE_PH_TOLERANCE_INDEX           747
#define RL_EDGE_PH_THRESHOLD_INDEX           748
#define RL_EDGE_PH_WARMUP_MIN_INDEX          749
#define RL_EDGE_PH_MEAN_INDEX                750
#define RL_EDGE_PH_FRAC_ALERTED_INDEX        751
#define RL_EDGE_PH_FRAC_WARMUP_INDEX         752

const float ph_delta      = isv[RL_EDGE_PH_TOLERANCE_INDEX];
const float ph_lambda     = isv[RL_EDGE_PH_THRESHOLD_INDEX];
const float ph_warmup_min = isv[RL_EDGE_PH_WARMUP_MIN_INDEX];
// σ_welford is the pre-envelope-floor Welford σ (slot 725 from B-8 work).
// NOT slot 555 (σ_effective = max(σ_welford, max_abs_envelope)) because
// slot 555 spikes 1000× at the eval shock window, blinding the detector.
// One-step lag because CMDP fires before popart_normalize.
const float pop_sigma     = fmaxf(isv[RL_POPART_SIGMA_WELFORD_INDEX], 1e-6f);

// Fleet aggregate counters:
float ph_active_sum = 0.0f;
int   n_active   = 0;
int   n_alerted  = 0;
int   n_warmup   = 0;
```

**Inside the existing per-batch loop, AFTER the consec_loss update**:

```c
if (dones[b] >= 0.5f) {
    // σ-normalize the per-trade PnL signal. Makes detector scale-invariant.
    const float y = rewards[b] / pop_sigma;

    // Welford with PRE-update mean for PH cumulative. The m/M cumulatives
    // ONLY update AFTER warmup completes (HIGH-new #2 fix v3): cold-start
    // Welford bias would otherwise be baked into permanent m offset.
    const float mu_prev = ph_mu_per_batch[b];
    const float cnt_prev = ph_count_per_batch[b];
    const float cnt = cnt_prev + 1.0f;
    // Welford mean update always runs (post-update value stored for next iter)
    ph_mu_per_batch[b] = mu_prev + (y - mu_prev) / cnt;
    ph_count_per_batch[b] = cnt;
    // PH cumulative + M gated behind warmup completion. cnt_prev is the
    // pre-update count, so this gate fires only once cnt_prev >= warmup_min
    // (i.e., after this batch has already accumulated warmup_min trades).
    if (cnt_prev >= ph_warmup_min) {
        const float m = ph_m_per_batch[b] + (y - mu_prev - ph_delta);
        ph_m_per_batch[b] = m;
        float M = ph_M_per_batch[b];
        if (m < M) {
            ph_M_per_batch[b] = m;
            M = m;
        }
        ph_stat_per_batch[b] = m - M;
    }
    // Note: m, M, ph_stat all stay at 0.0 (their construction-time init) during warmup.
}

// Fleet aggregates (always, even if no done this step):
const float ph_stat_b = ph_stat_per_batch[b];
const float ph_cnt_b  = ph_count_per_batch[b];
if (ph_cnt_b < ph_warmup_min) {
    n_warmup += 1;
} else {
    n_active        += 1;
    ph_active_sum   += ph_stat_b;
    if (ph_stat_b > ph_lambda) n_alerted += 1;
}
```

**After the loop, emit aggregates**:

```c
const float inv_b = 1.0f / (float)b_size;
const float inv_active = (n_active > 0) ? (1.0f / (float)n_active) : 0.0f;
isv[RL_EDGE_PH_MEAN_INDEX]         = (n_active > 0) ? (ph_active_sum * inv_active) : 0.0f;
isv[RL_EDGE_PH_FRAC_ALERTED_INDEX] = (n_active > 0) ? ((float)n_alerted * inv_active) : 0.0f;
isv[RL_EDGE_PH_FRAC_WARMUP_INDEX]  = (float)n_warmup * inv_b;
```

Note: `frac_ph_alerted` denominator is `n_active` (fix HIGH #6). `frac_ph_warmup` denominator is `b_size` (fraction of fleet that is in warmup is a fleet-fraction signal proper).

### 3.4 Trainer wiring (integrated.rs)

Allocate 5 new device buffers in constructor; add 5 `args.push_ptr` calls in the CMDP kernel launch site (`step_with_lobsim_gpu_body`).

**Critical (BLOCKER #1)**: in `reset_session_state` (integrated.rs:3329-3348), extend the existing memset block to include the 5 new PH buffers. This means the SAME atomic refactor zero-init covers all per-batch CMDP-adjacent state at every fold/eval boundary.

No new launches. No new kernels.

### 3.5 Diag emission

Add to `risk_stack.cmdp` block in `IntegratedTrainer::build_diag_value`:

```rust
"cmdp": {
    // ... existing fields ...
    // Edge-decay detector (Phase 1, 2026-06-01).
    // See pearl pearl_edge_decay_detection_is_a_missing_abstraction_layer.
    // Phase 1 is observability-only; no behavior change.
    "edge_ph_mean":         isv[crate::rl::isv_slots::RL_EDGE_PH_MEAN_INDEX],
    "edge_ph_frac_alerted": isv[crate::rl::isv_slots::RL_EDGE_PH_FRAC_ALERTED_INDEX],
    "edge_ph_frac_warmup":  isv[crate::rl::isv_slots::RL_EDGE_PH_FRAC_WARMUP_INDEX],
},
```

`EXPECTED_LEAVES`: 675 → **678** (3 new leaves, down from v1's 4 since `ph_max` was removed).

### 3.6 Invariant test

`crates/ml-alpha/tests/edge_decay_ph_invariants.rs`:

1. **Bounds**: `ph_stat_b >= 0` for all rows, all batches (non-decreasing by construction).
2. **Aggregate consistency**: `ph_mean >= 0`; `frac_ph_alerted ∈ [0, 1]`; `frac_ph_warmup ∈ [0, 1]`.
3. **Reset at boundary**: at first eval row, ALL `ph_count_b = 0` (verified via reset_session_state contract — buffers zeroed at boundary).
4. **σ-source contract**: kernel divides by `isv[RL_POPART_SIGMA_WELFORD_INDEX = 725]`, NOT `isv[RL_POPART_SIGMA_INDEX = 555]`. Test reads both slots from the diag JSONL across the eval-boundary envelope shock window — slot 555 should spike ≥10× at eval[0..10] (envelope-reactive) while slot 725 should remain bounded. PH detector activity (`ph_mean`, `frac_ph_alerted`) should track slot 725 behavior, not slot 555. Falsifies any future refactor that silently swaps the slot.
5. **Active-only aggregates**: `ph_mean` and `frac_ph_alerted` are 0.0 when `frac_ph_warmup = 1.0` (no active batches).

Local 200+50 b=16 fold-1 smoke validates **wiring + invariants only**. Detector *behavior* validation requires cluster scale; the local smoke is too short for warmup to complete across the fleet (acknowledgment per [[feedback_local_smoke_before_cluster]]).

## 4. Validation

### V1 — Local smoke
Build release; verify 5 invariants. Does NOT validate detector behavior (insufficient scale). Validates: kernel ABI, ISV slot range, reset_session_state extension, schema parity.

### V2 — Cluster smoke (2k+500 b=1024 fold-1 H100)
Run, dump diag.jsonl + eval_diag.jsonl, check the §G5 falsification criteria:
- `frac_ph_warmup` drops to <0.4 in train by step 1000 ✓? (matches §G5 table; relaxed from <0.1 v2 to acknowledge ~35% cooldown-suppressed tail)
- `frac_ph_warmup` snaps to ≥0.95 at first eval row ✓? (verifies reset_session_state extension wired all 5 PH buffers; if <0.95, a buffer was missed in the memset block)
- `frac_ph_warmup` drops to <0.4 in eval by step 200 ✓?
- `ph_mean` (active) train <1.0 throughout ✓?
- `ph_mean` eval rises monotonically <1 → >3 over first 200 trades ✓?
- `frac_ph_alerted` (of active) train <0.05 throughout ✓?
- `frac_ph_alerted` eval > 0.3 by eval step 300 ✓?

All ✓ → detector is correctly shaped → write Phase 2 spec.
Any ✗ on first iteration → tune δ, λ, or warmup_min; iterate Phase 1.
**3-strike abandonment**: if 3 consecutive V2 iterations fail for 3 different parameter reasons → audit signal source (per [[feedback_going_in_circles_pattern]]).

### V3 — Determinism
Verify `rewards.sum` and `dones.sum` trajectories match alpha-rl-glv6n byte-for-byte at every step. Determinism is preserved IF AND ONLY IF (a) no kernel other than this CMDP extension reads the new PH buffers (true by design — verifiable via grep), AND (b) the new kernel ABI does not alter the kernel's numeric output for the original-signature consumers (verifiable via side-by-side first-100-step diff).

## 5. Implementation phases

| Phase | Step | LOC |
|------|------|-----|
| 1 | Add 6 ISV slots; bump RL_SLOTS_END | 12 |
| 2 | Add 6 bootstrap entries; bump fixed-size array 234 → 240 | 10 |
| 3 | Allocate 5 per-batch device buffers in IntegratedTrainer constructor | 20 |
| 4 | **Extend `reset_session_state` to memset 5 PH buffers** (BLOCKER #1) | 12 |
| 5 | Modify `rl_cmdp_constraints_check.cu`: 5 new params, σ-norm read, PH update on done events, 3 fleet emits | ~50 |
| 6 | Wire 5 new ptrs into CMDP kernel launch in step_with_lobsim_gpu_body | 8 |
| 7 | Add 3 diag leaves in `build_diag_value` | 4 |
| 8 | Bump `EXPECTED_LEAVES` to 678 | 1 |
| 9 | Write `edge_decay_ph_invariants.rs` (5 invariants) | ~140 |
| 10 | Build release; run local 200+50 smoke + invariant test | — |
| 11 | Single atomic commit on `ml-alpha-regime-observer` | — |

Total source delta: ~260 LOC across 5 files (v1 was 220; v2 adds reset_session_state extension + σ-norm logic + larger invariant test).

## 6. Risks

- **Risk A (low, was MEDIUM in v1)**: scale-invariance now built into kernel via σ-normalization. Risk reduced to: if `popart.σ` itself drifts AT THE TRADE TIMESCALE (faster than the EMA can track), per-trade σ-normalized y may have non-stationary variance. Mitigation: `popart.σ` is bounded by `RL_POPART_MAX_ABS_REWARD_EMA_INDEX` envelope; rapid σ swings are dampened. V2 smoke validates.
- **Risk B (low)**: predictive-reset at fold/eval boundary loses train-phase PH context. Mitigation: this is INTENDED. Eval-side PH state must be earned from eval-side trades. Detection of train→eval distribution shift comes from eval-side PH crossing λ on eval-side trades.
- **Risk C (low)**: Kernel function signature changes (5 new ptr params). Existing callers in `integrated.rs` must update atomically. Same pattern as B-9 / B-10 / fleet-fraction commits.
- **Risk D (medium)**: Non-windowed `M` means in 20k+5k full runs, `ph_stat` may saturate due to long history. V2 smoke (2k+500) is too short to expose. If Phase 1 advances to Phase 2 or full-run validation, switch to EMA-discounted M (m_M = ρ × m_M + (1-ρ) × m, ρ = 0.99). Mark as Phase 1.1 future work; do NOT defer in current Phase 1 implementation.
- **Risk E (low)**: extra per-batch arithmetic. At b=1024: ~80µs/step. Negligible vs ~100ms RL pipeline.

## 7. Done means

- 6 ISV slots allocated (747-752); `RL_SLOTS_END = 753`
- 5 per-batch device buffers allocated, zeroed at construction, zeroed at every fold/eval boundary via reset_session_state
- `rl_cmdp_constraints_check.cu` extended with σ-normalized PH update + 3 fleet aggregates
- 3 new diag leaves `risk_stack.cmdp.edge_ph_{mean, frac_alerted, frac_warmup}`
- `EXPECTED_LEAVES = 678`
- 5 invariant tests pass on RTX 3050 Ti
- 200+50 b=16 fold-1 local smoke runs clean (wiring + invariants validated; behavior NOT validated at local scale)
- Single atomic commit on `ml-alpha-regime-observer`

## 8. What Phase 1 does NOT promise

- Does not fix eval pnl. Phase 1 is detector-only.
- Does not provide ISV-driven controllers for δ / λ / warmup_min. Bootstrap constants for now.
- Does not interact with any existing controller.
- Does not validate at local scale — V2 cluster is the verdict.

## 9. After Phase 1: Phase 2 outline (NOT in this spec)

Per the pearl, Phase 2 adds the **throttle**:

- New ISV slot `RL_EDGE_PH_THROTTLE_PER_BATCH_INDEX` (or per-batch device buffer `ph_throttle_per_batch`).
- Compute `θ_b = sigmoid((λ − ph_stat_b) / scale)` clamped to **[θ_min, 1.0]** with **θ_min = 0.05** per [[pearl_bootstrap_must_respect_clamp_range]] (NOT 0.0 — the bootstrap floor must lie within the clamp range to avoid snap-to-MIN-on-first-signal).
- Wire θ into `actions_to_market_targets.cu` as multiplicative position-size scalar: `effective_size_b = Kelly_frac × θ_b × base`.

**Resurrection discipline (HIGH #8 fix; resolves [[pearl_dead_signal_resurrection_discipline]] absorbing-state)**: Phase 2 throttle gates its own input stream (θ → 0 → fewer trades → no new closes → frozen PH). Two complementary mechanisms required:

1. **ε-floor on θ** (θ_min = 0.05): guaranteed minimum trade rate so PH state continues to receive new observations even after a degradation alert.
2. **EMA decay on cumulative deviation** (input-side staleness detection): when no done event arrives for K steps for a batch, decay `m_b` toward zero (half-life ~100 steps): `m_b *= 0.9931` per stale step. This is the "EMA decay without data" pattern from the resurrection pearl. Prevents `m` from being pinned at a high value indefinitely when the throttle has reduced trade frequency.

- Phase 2 falsification: eval pnl improves materially vs Phase 1 baseline; eval n_trades drops (47K → 5K-15K); `frac_in_cooldown` stays low or decreases.

Phase 2 spec written ONLY after Phase 1 V2 cluster smoke confirms detector fires per §G5.
