# B-8 — PopArt σ_welford Disaggregation (Observability Only)

**Date:** 2026-06-01
**Status:** Draft (pre-implementation)
**Branch target:** `ml-alpha-adaptive-controller-floors`
**Predecessor:** B-7 (clamp-disabled default — must be committed first)
**Pearls referenced:**
- [[pearl_popart_reset_at_eval_boundary_shocks_normalization]] — origin of envelope-vs-Welford concern
- [[feedback_isv_for_adaptive_bounds]] — all bounds ISV-driven
- [[feedback_no_partial_refactor]] — kernel write + diag emit + test ship together
- [[feedback_no_stubs]] — no dormant "ready to enable" mechanisms

---

## 1. Motivation

`crates/ml-alpha/cuda/rl_popart_normalize.cu:141` computes Welford σ:

```c
float new_sigma = sqrtf(fmaxf(new_var, 1e-6f));    // line 141 — σ_welford
// ... F4 envelope update ...
new_sigma = fmaxf(new_sigma, max_r_ema);            // line 156 — overwrite with effective σ
isv[POPART_SIGMA_INDEX] = new_sigma;                // line 160 — final σ_effective
```

`new_sigma` at line 141 is the Welford-only σ; line 156 collapses it into σ_effective = `max(σ_welford, env.max)`. **σ_welford is discarded.** Diag emits `popart.sigma` (= σ_effective) and `popart.max_abs_reward_ema` (= env.max), but the pure-Welford component is invisible.

Under B-7 (clamp default-disabled), popart's Welford state now updates against unclamped magnitudes. To diagnose any future σ behavior (collapse, divergence, stale calibration), we need σ_welford as a first-class observable. Currently, if σ_effective drops, we cannot tell whether σ_welford collapsed, env.max decayed, or both.

This spec is **pure observability**. No floor, no behavior change, no dormant toggle. Per `feedback_no_stubs`: if a floor mechanism is needed later, motivate it from B-9 / cluster evidence and ship a separate spec.

## 2. Goals

### G1 — Publish σ_welford as a first-class ISV
Add 1 new slot `RL_POPART_SIGMA_WELFORD_INDEX`. Write `new_sigma` to it BEFORE line 156's `fmaxf` with env.max.

### G2 — Emit σ_welford in diag
Add 1 new diag leaf `popart.sigma_welford`. Existing leaves (`popart.sigma`, `popart.max_abs_reward_ema`) unchanged — together with the new leaf they reconstruct the full `σ_welford / env.max / σ_effective = max(σ_welford, env.max)` story.

### G3 — Invariant test
Local GPU-oracle test asserts `σ_effective == max(σ_welford, env.max)` per step under B-7 default config. Validates the algebraic identity the kernel implements (defends against future refactor regressions).

### G4 — Non-goals
- **Not adding a σ-floor.** Prior spec draft proposed `max(σ_welford, env.max, ratio·env.max)` — that expression is dominated by `env.max` for `ratio ≤ 1` and is mathematically a no-op. A useful floor would track σ_{t-1} or use a separately-tuned slow envelope; both are out of scope until cluster evidence motivates them.
- **Not changing popart computation.** Lines 141 / 156 / 160 are read-only besides the inserted ISV write.
- **Not introducing dormant toggles.** Per `feedback_no_stubs`.

## 3. Design

### 3.1 ISV slot

```rust
// crates/ml-alpha/src/rl/isv_slots.rs
// Sits immediately after B-7's slot 724.

/// B-8 (2026-06-01): Welford-only popart σ, BEFORE the `max(..., env.max)`
/// floor in rl_popart_normalize.cu:156. Together with
/// RL_POPART_MAX_ABS_REWARD_EMA_INDEX (env.max) and
/// RL_POPART_SIGMA_INDEX (σ_effective), this lets diag reconstruct
/// the full Welford / envelope / effective triple. Pure observability.
pub const RL_POPART_SIGMA_WELFORD_INDEX: usize = 725;
pub const RL_SLOTS_END: usize = 726;
```

Bootstrap entry in `with_controllers_bootstrapped`:
```rust
// Sentinel 0.0. popart_normalize overwrites this every step (line ~141.5
// post-insertion). At step 0 — IF popart_normalize hasn't yet fired
// (depends on trainer launch ordering, see step_with_lobsim_gpu) — diag
// will read the bootstrap value 0.0 for one row. Acceptable; the
// identity invariant test in §3.4 starts validation at step 1 to skip
// the bootstrap row.
(RL_POPART_SIGMA_WELFORD_INDEX, 0.0_f32),
```

The bootstrap is a **fixed-size const array** declared `[(usize, f32); 212]` at `integrated.rs:3372`. Both edits must land in the same commit per `feedback_wire_everything_up.md`:

1. `crates/ml-alpha/src/rl/isv_slots.rs`: bump `RL_SLOTS_END` to 726.
2. `crates/ml-alpha/src/trainer/integrated.rs:3372`: change the array type annotation `[(usize, f32); 212]` → `[(usize, f32); 213]` AND append the new bootstrap tuple.

Verification step: `cargo check -p ml-alpha` will hard-error if the count and the literal length mismatch.

### 3.2 Kernel change

`crates/ml-alpha/cuda/rl_popart_normalize.cu` — insert exactly one write between current lines 141 and 156:

```c
#define RL_POPART_SIGMA_WELFORD_INDEX  725

// Line 141 (existing):
float new_sigma = sqrtf(fmaxf(new_var, 1e-6f));

// NEW (B-8): publish Welford-only σ before F4 envelope floor.
// Captures the Welford-projected magnitude estimate independent of
// env.max so diag can attribute σ_effective shocks correctly.
isv[RL_POPART_SIGMA_WELFORD_INDEX] = new_sigma;

// ── F4 envelope update + max-floor (existing, unchanged) ──
const float max_r_this_step = v_max;
// ... lines 147-154 ...
new_sigma = fmaxf(new_sigma, max_r_ema);  // line 156 — unchanged

// ... lines 158-160 unchanged ...
```

Only thread 0 of the popart kernel reaches this code path (existing `if (tid == 0)` block); no race risk, no extra synchronisation.

### 3.3 Diag emission

`crates/ml-alpha/src/trainer/integrated.rs`, `build_diag_value` (popart block ~line 9662):

```rust
"popart": {
    "sigma":               isv[RL_POPART_SIGMA_INDEX],
    // B-8 (2026-06-01): Welford-only σ, pre-envelope-floor. Together
    // with max_abs_reward_ema this attributes σ shocks to either
    // Welford drift or F4 envelope spikes.
    "sigma_welford":       isv[RL_POPART_SIGMA_WELFORD_INDEX],
    "max_abs_reward_ema":  isv[RL_POPART_MAX_ABS_REWARD_EMA_INDEX],
    "max_decay_alpha":     isv[RL_POPART_MAX_DECAY_ALPHA_INDEX],
    // ... existing leaves unchanged ...
},
```

Schema leaf bump: **652 → 653** (1 new leaf). Baseline 652 is post-B-7 (B-7 added one leaf `trading.realized_pnl_cum_usd` and already bumped the test from 651 → 652 — this WIP must commit before B-8 lands so the chain holds). Update `EXPECTED_LEAVES` in `crates/ml-alpha/tests/eval_diag_emission.rs`:

```rust
// 2026-06-01 B-8: +1 leaf for popart.sigma_welford (Welford-only σ,
// pre-envelope-floor; attribution of σ shocks). New total: 653.
const EXPECTED_LEAVES: usize = 653;
```

### 3.4 Invariant test

`crates/ml-alpha/tests/popart_disaggregation_invariants.rs` (domain-named — no version suffix per `feedback_no_sp_or_version_prefixes_in_file_names`):

```rust
//! B-8 invariant: σ_effective = max(σ_welford, env.max) per step.
//!
//! Validates the algebraic identity the popart normalize kernel implements
//! after B-8's disaggregation. If this drifts, either the kernel was
//! refactored or one of the three ISV slots is stale.

#[test]
#[ignore = "requires CUDA + pre-built release binary + MBP-10 test data"]
fn popart_sigma_disaggregation_identity() -> Result<()> {
    // Run 200 train + 50 eval at b=16 fold-1, B-7 clamp disabled (default).
    // Start validation at row index 1 (skip step-0 bootstrap row — if popart_normalize
    // hadn't fired yet at snapshot time, popart.sigma_welford reads the 0.0 sentinel
    // while popart.sigma reads its own bootstrap; identity holds vacuously).
    // For every diag row from index 1 onward (train + eval):
    //   ε = 1e-5
    //   assert |popart.sigma - max(popart.sigma_welford, popart.max_abs_reward_ema)| < ε
    // Fail on first violation with the row index, the three values, and the difference.
}
```

No b=1024 cluster validation needed for B-8 alone — observability only, identity test verifies correctness locally.

## 4. Validation

### V1 — Local smoke (10 min)
Build release. Run 200+100 b=16 fold-1. Inspect last diag row:
- `popart.sigma`, `popart.sigma_welford`, `popart.max_abs_reward_ema` all non-zero
- Identity `popart.sigma == max(popart.sigma_welford, popart.max_abs_reward_ema)` holds (within float ε)

### V2 — Identity test
`cargo test -p ml-alpha --test popart_disaggregation_invariants -- --ignored --nocapture` passes.

### V3 — Schema parity test
`cargo test -p ml-alpha --test eval_diag_emission -- --ignored --nocapture` passes with `EXPECTED_LEAVES = 653`.

## 5. Implementation phases

| Phase | Step | LOC |
|------|------|-----|
| 1 | Add `RL_POPART_SIGMA_WELFORD_INDEX = 725` to `isv_slots.rs`, bump `RL_SLOTS_END` to 726 | 4 |
| 2 | Add bootstrap entry in `with_controllers_bootstrapped` | 1 |
| 3 | Add ISV write in `rl_popart_normalize.cu` between line 141 and 156 | 4 |
| 4 | Add `popart.sigma_welford` leaf in `build_diag_value` | 3 |
| 5 | Bump `EXPECTED_LEAVES` to 653 in `eval_diag_emission.rs` | 1 |
| 6 | Write `popart_disaggregation_invariants.rs` test | ~80 |
| 7 | Build release, run both tests, run 200+100 smoke | — |
| 8 | Single commit on `ml-alpha-adaptive-controller-floors` | — |

Total source-file delta: ~95 LOC across 5 files. One commit. No cluster work for B-8 alone — disaggregation rides into the next cluster run that goes (whichever spec earns it).

## 6. Done means

- 1 ISV slot allocated (725); `RL_SLOTS_END = 726`
- 1 ISV write added in `rl_popart_normalize.cu`
- 1 diag leaf emitted (`popart.sigma_welford`)
- `EXPECTED_LEAVES = 653`; schema-parity test passes
- Identity invariant test passes
- 200+100 b=16 fold-1 smoke shows all three popart values reconstruct correctly
- Commit message: `feat(rl): B-8 publish σ_welford for popart shock attribution`

## 7. Risks

- **Risk A (low):** Editing rl_popart_normalize.cu — single insertion in the existing `if (tid == 0)` block; cannot race. Mitigation: GPU-oracle identity test catches algebraic drift.
- **Risk B (low):** Slot 725 collision if B-7 isn't committed before B-8 lands. Mitigation: explicit predecessor in this spec; verify `RL_SLOTS_END == 725` before adding B-8.
- **Risk C (negligible):** Diag leaf count drift. Mitigation: `EXPECTED_LEAVES` bump in same commit.
- **Risk D (low / grandfathered):** Slot index 725 is `#define`'d both in `rl_popart_normalize.cu` and the Rust `isv_slots.rs` const. Per existing kernel discipline (see `POPART_SIGMA_INDEX 555` at line 26), each kernel keeps its own `#define`s — single source of truth lives in the Rust const, kernel `#define`s are local mirrors. Drift would be caught by either (a) the identity test in §3.4 producing wrong values, or (b) the schema-parity test reading from the Rust-side slot. Per `feedback_use_consts_not_literals_for_structural_dims.md` (spirit): treat the Rust const as canonical; the `#define` matches by manual copy-paste discipline within the same commit.

## 8. What B-8 does NOT promise

- It does not fix eval-pnl. It is diagnostic infrastructure that makes the next cluster run more attributable.
- It does not change σ_effective, kernel outputs, RNG, or gradients. Determinism is preserved.
- It does not pre-stage a σ-floor mechanism. If cluster B-7 data shows σ_welford collapse, write a separate spec then.
