# Paper book: marginal-gate edge pruning + A3c/A3d cleanup — Design (Phase 2F-A3e)

**Date:** 2026-06-22
**Status:** Design (approved)
**Repo:** fxhnt
**Follows:** A3d vol-target + killswitch ([[project_fxhnt_a3d_vol_target_killswitch]]). Adds the leave-one-out
edge-quality gate and clears the two logged A3c/A3d follow-ups.

## Motivation

The paper book sizes every active sleeve by inverse-vol regardless of whether it still contributes. The live
`book_allocator` has a `marginal_gate` (leave-one-out marginal-Sharpe pruning) for exactly this — bench a
sleeve only when **removing** it raises the trailing book Sharpe, which keeps a legitimate anticorrelated
hedge while benching a dead, non-diversifying sleeve (a plain Sharpe floor can't tell them apart). The user
asked to **set the marginal_gate** on the paper book. Two small follow-ups logged during A3c/A3d are cleared
in the same change: dead `equal_weights`, and a zero-fill bias in `_regime_lookback`.

## Decisions (from brainstorming, approved)

- **marginal_gate is a faithful reuse**, not a reinvention: `book_allocator.align_edges` +
  `book_allocator._marginal_prune` (which itself uses `_inverse_vol_weights` + `per_period_sharpe`). Trusted
  live code.
- **Cold sleeves pass through.** Sleeves with `<2` returns can't be Sharpe-scored — they bypass the gate
  (benefit of the doubt; the isv cold-start floor still sizes them), mirroring the mixed-history rule in
  `isv_adaptive_weights`. Only has-history sleeves are eligible for pruning.
- **Applied in BOTH paths** (`paper_risk_overlay` sizing AND `paper_book_returns` shadow-replay) so the actual
  book and the killswitch regime signal agree → live `/paper` == `/paper/replay` is preserved.
- **Causal + always-on.** Uses the strictly-prior `returns_by_sleeve` already passed in; no flag (the user
  asked to set it on for the paper book).
- **Cleanup:** remove dead `equal_weights` (+ its dedicated test); make `_regime_lookback` NaN-aware so
  disjoint sleeve coverage no longer biases the regime ratio.

## Components

1. **`marginal_gate_active(returns_by_sleeve, active_sleeves, *, vol_lookback=_VOL_LOOKBACK,
   max_sleeve_weight=_PHI) -> list[str]`** (pure, `paper_book.py`):
   - `has = [s in active with len(returns)>=2]`; `cold = [s in active not in has]`.
   - If `len(has) < 2` → return `active` unchanged (nothing to score / shrink).
   - `window = align_edges({s: returns[s] for s in has})[1]`, truncated to the last `vol_lookback` rows
     per sleeve.
   - `kept_has = set(_marginal_prune(window, max_sleeve_weight))`.
   - Return `[s for s in active_sleeves if s in cold or s in kept_has]` (preserve input order).
   Imports `align_edges`, `_marginal_prune` from `fxhnt.application.book_allocator`.
2. **`paper_risk_overlay`** (`paper_book.py`): before computing weights, `active =
   marginal_gate_active(returns_by_sleeve, active_sleeves)`; then `weights =
   isv_adaptive_weights(returns_by_sleeve, active)` (rest unchanged: leverage, killswitch replay).
3. **`paper_book_returns`** (`paper_book.py`): inside the per-date loop, after computing `before_d`/`active`,
   `active = marginal_gate_active(before_d, active)` before `isv_adaptive_weights(before_d, active)` — so the
   shadow regime signal reflects the same pruned book.
4. **`_regime_lookback`** (`paper_book.py`): build the matrix with `np.nan` for missing cells (`np.full(...,
   np.nan)`), and use `np.nanmean(axis=0)` for the representative series; guard a fully-NaN slice (→ hold
   `L_max`). Replaces the `np.zeros` zero-fill so disjoint coverage doesn't depress `ρ`.
5. **Remove** `equal_weights` from `paper_book.py` and delete `tests/unit/test_equal_weights.py`. The
   `*_equal_weights_*` tests in backfill/snapshot stay (they assert cold-start sizing, not the function).

## Data flow

Per date (both paths): `returns_by_sleeve` (strictly-prior) + active set → `marginal_gate_active` benches any
dead/non-diversifying has-history sleeve → `isv_adaptive_weights` over the kept set → leverage + killswitch as
in A3d. Identical helper in both paths keeps live == replay.

## Risks / safety

- **Read-only/additive.** No change to `book_allocator` (only imports from it), sleeve `run()`/`advance()`,
  `combine_book`, the gate record, or any broker/live path. No new tables.
- **Causality:** `marginal_gate_active` consumes only the strictly-prior `returns_by_sleeve`; the leave-one-out
  Sharpe is over the trailing window; no look-ahead.
- **Never empties the book:** `_marginal_prune` shrinks to at least one sleeve; cold sleeves always pass
  through; `<2` has-history → unchanged.
- **Consistency:** same `marginal_gate_active` in `paper_risk_overlay` + `paper_book_returns` → live == replay.
- **3-sleeve reality:** usually keeps all three (each diversifies); benches one only when genuinely dead in a
  window. The gate cannot make the book worse than the un-gated inverse-vol on the trailing window by
  construction (it only removes a sleeve when removal raises trailing book Sharpe).
- **Cleanup safety:** `equal_weights` has no production caller (verified — only its own test); removing it +
  its test leaves the suite green. `_regime_lookback` NaN-aware change only affects `L_t` (bounded
  `[L_min, L_max]`), never crashes.

## Out of scope

`min_sleeve_sharpe` raw Sharpe-floor gate (inferior to marginal — skip); making marginal_gate a tunable flag
(YAGNI — always-on for the paper book); the live-cache vs warehouse price-source coupling and the
`_live_sleeves`↔backfill dedup (separate tech debt).

## Acceptance criteria

- `marginal_gate_active`: benches a dead/non-diversifying has-history sleeve (removal raises trailing book
  Sharpe) while keeping a diversifying one; cold (`<2`) sleeves pass through; `<2` has-history → unchanged;
  never returns empty when active non-empty; unit-tested.
- `paper_risk_overlay` + `paper_book_returns` both apply it (same pruned active set); unit-tested that a
  clearly-dead sleeve is dropped from the overlay weights.
- `_regime_lookback` NaN-aware: disjoint-coverage sleeves no longer bias `ρ` toward `L_max` via zero-fill;
  unit-tested (a sleeve with a late start doesn't zero-depress the ratio); existing A3c regime tests stay green.
- `equal_weights` removed; `tests/unit/test_equal_weights.py` deleted; full suite green (the backfill/snapshot
  cold-start `*_equal_weights_*` tests still pass).
- Re-run backfill in-cluster → book still populated (gate didn't empty it); `/paper` + `/paper/replay` render;
  report whether any sleeve gets benched in any window. Read-only/additive; no `*.dbn` staged.
