# Funding Revocation on Sustained Decay (Sub-project B3) — Design

**Date:** 2026-07-10
**Status:** Approved (brainstorming) — ready for implementation plan
**Sub-project:** B3 — the edge-health / decay plane of the allocation layer of the "unified data-driven
pipeline" north-star. Scoped down during brainstorming from a general per-edge health layer to the specific,
real hole it needed to close.
**Builds on:** A (deterministic forward state — the immutable `forward_record` SSOT, merged) + B1 (gate /
promotion policy — the reconciliation gate verdict is B3's signal, merged) + B2a (allocation policy — B3
filters its input set, merged).

## North-star context (the allocation layer's health plane)

The allocation layer turns `{live + healthy}` deployed strategies into target capital (B2a). Today "healthy"
defaults to *all* of the effective deploy set, so a strategy that was validated once and later decays keeps
its capital forever. B3 makes "healthy" real:

```
{effective deploy set}  =  registry tier=="deploy"  ∪  promoted_strategy_ids()
  → FUNDING FILTER (B3): drop any edge in SUSTAINED reconciliation-decay      ← THIS sub-project
  → {live + healthy} → B2a compute_allocation (unchanged) → target capital (≤ ceiling)
```

Guiding principle (mirror A/B1/B2a): funding status = `f(committed policy, A's immutable record, B1's gate)`.
Pure, recomputable, versioned, human-gated by the committed policy + the capital ceiling. B3 moves no capital
(that is C); it only narrows B2a's input set.

## The problem B3 solves

Promotion is deliberately **one-way and persistent** (`forward_ingest.evaluate_promotions`: *"never reverts
if the gate later dips … not flickering with the daily gate"*). That anti-flicker choice is correct, but it
leaves a hole: a **promoted (or config-deployed) edge that later decays keeps its capital** even after its
reconciliation gate has reverted to not-PASS. Closing that hole naively — de-fund the instant the gate isn't
PASS — would reintroduce exactly the daily flicker the one-way design avoids, and daily flicker on a
drawdown *is* the reactive-de-sizing pattern rejected 3× ([[pearl_fxhnt_reactive_risk_controllers_rejected]],
[[pearl_fxhnt_voltarget_overlay_is_reactive_desizing_rejected]]). So the fix must be **sustained + hysteretic
+ structural**, keyed off the right signal.

**Why the reconciliation gate is the right signal (not an absolute-drift detector).** An earlier design used
`WindowedEdgeHealth` (a trailing t-stat). It only fires on sustained *strongly-negative* returns (t < ~−1.6):
a quietly-dead edge whose forward mean drifts to ~0 scores health ≈ 0.8 and is **never** retired — it catches
*bleeding*, not *stopping*. B1's reconciliation gate already asks the exact right question — *"is the forward
edge still tracking what its backtest predicted?"* — and flips to a divergence WAIT when the forward return
falls materially below the backtest's expectation. That is precisely "the edge stopped paying." B3 reuses it.

## Goal

De-fund an effective-deploy edge whose reconciliation gate has shown **sustained divergence** from its backtest
(hysteresis, no flicker), and re-fund it when it sustainedly reconciles again — as a pure recompute over A's
immutable record, with **no new mutable state**, scoped to edges the data once validated (that ever PASSed).

## Components

### 1. Machine-readable gate-verdict category (small additive B1 change)

`evaluate_reconciliation_gate` returns a `GateVerdict(status, reason)`. Add a machine-readable **category** so
funding classification never string-matches the human reason. Category ∈:

- `PASS` — reconciles (status PASS).
- `DIVERGENCE` — the decay signal: forward cumulative materially below the backtest band, or forward daily
  correlation negative (the two mirage/decay sub-checks).
- `IMMATURE` — building (`< min_forward_days`) or no backtest reference (nothing to reconcile *yet*).
- `EXECUTION_GAP` — a hole/duplicate in the daily record (a pipeline/ops issue, **not** edge decay).

Only `DIVERGENCE` counts as a decay day; only `PASS` counts as a healthy day; `IMMATURE`/`EXECUTION_GAP` are
**neutral** (they neither de-fund nor re-fund — immaturity and ops gaps are not edge mortality). This keeps
the funding decision honest: an edge is de-funded for *decaying*, never for being young or for a pipeline
hiccup.

**Non-reconciliation gates.** An effective-deploy edge whose `gate_spec.gate_type` is not `reconciliation`
(evaluated by `evaluate_gate`, not `evaluate_reconciliation_gate`) has no divergence signal to decay against.
Its `GateVerdict.category` maps `PASS → PASS` and any non-PASS → `IMMATURE` (neutral), so B3 **never**
de-funds a non-reconciliation edge — funding revocation applies only where a backtest reconciliation exists.
The `category` field is set on **every** `GateVerdict` return path in both gate functions (default-safe).

### 2. Funding policy (folded into B1's versioned `GatePolicy`)

The funding decision is a direct function of gate verdicts, so its knobs live in the existing versioned
`GatePolicy` (no 4th policy module):

- `defund_window: int = 10` — consecutive `DIVERGENCE` days that flip `funded → de-funded`.
- `refund_window: int = 15` — consecutive `PASS` days that flip `de-funded → re-funded`.

Asymmetric by design (quicker to de-risk, slower to re-arm real capital — "err toward not deploying"). These
are **debounce** windows over an already-*cumulative* gate signal (each day's verdict reflects the whole
forward record to date), so they are modest, not a primary sustained-detector. Adding these fields bumps
`GATE_POLICY_VERSION` and changes `gate_policy_hash` (the self-check test forces the bump — same discipline as
A's definition_hash).

### 3. The funding engine (pure, recompute-from-record — `funding_status.py`)

`compute_funding(rows, backtest_ref, policy) -> FundingStatus` per edge, where `rows` is A's ascending
`forward_record` for the edge and `backtest_ref` is the per-edge reconciliation reference (the same
`BacktestRefProvider` B1 uses; the executed-track alias from C1 applies). Steps, **replayed from inception
each night** (the proven `paper_book._retirement_active_stateless` pattern — deterministic, no persisted
latch):

1. For each record-day `d` (ascending), reconstruct the as-of `ForwardSummary` (`days`, `total_return`) from
   `rows[:d]` and evaluate `evaluate_reconciliation_gate(summary_d, rows[:d], ref_d, policy)`, where `ref_d`
   is the backtest reference pro-rated to `max(days_d, min_forward_days)`. Take its **category** (§1).
2. Replay a funding state machine over the category sequence, starting `funded`, tracking whether the edge has
   **ever** had a `PASS` (`has_passed`):
   - `funded`: a `DIVERGENCE` day increments the decay run **only if `has_passed`** (a DIVERGENCE before the
     edge's first PASS is pre-validation, not decay → neutral); **any** non-`DIVERGENCE` day resets the decay
     run to 0. `decay_run >= defund_window` → `de-funded` (reset runs).
   - `de-funded`: a `PASS` day increments the recovery run; any non-`PASS` day resets it. `recovery_run >=
     refund_window` → `funded` (reset runs).
3. `FundingStatus` = the terminal state + a display label (`funded` / `decaying` (funded but a decay run is
   building) / `de-funded`) + the current run lengths (for the cockpit).

**Scope guard — only edges that ever PASSed are watchable.** The state machine begins de-fund-watching only
*after* the edge's first `PASS` day; before that the edge is `IMMATURE`/pre-validation and its decay run
cannot advance. So an edge that has never PASSed is **never** de-funded — a promoted edge always qualifies
(it PASSed to get promoted); a hardcoded `tier=="deploy"` edge qualifies only once it earns a PASS, so a
config-deploy-by-fiat edge that is merely still building is never auto-defunded, while one the data validated
and then invalidated **is** (capital safety > a stale config), loudly logged.

### 4. Wiring — the funding filter in front of B2a (Approach A)

`allocation_ingest.record_allocation` filters the effective deploy set through `compute_funding` **before**
`compute_allocation`:

```
deploy_sids = {tier=="deploy"} ∪ promoted_strategy_ids()
funded_sids = {sid for sid in deploy_sids if compute_funding(...).state != "de-funded"}
returns = {sid: {r.date: r.ret} for sid in funded_sids}
weights = compute_allocation(returns, ALLOCATION_POLICY)   # B2a UNCHANGED
```

A de-funded edge is simply *absent* from the input → gets $0. B2a's `max_strategy_weight` cap redistributes
to survivors up to the cap and holds the remainder as cash; the aggregate killswitch is unchanged. Promotion
is **not** touched — `promoted_strategy_ids()` still returns the historical promotion (audit: "was
validated"); funding is now the conditional layer on top.

### 5. Auditability (+ cockpit surfacing deferred)

Record the per-edge funding snapshot each nightly to a `strategy_funding` table (`strategy_id`, `state`,
`decay_run`, `recovery_run`, `first_pass_date`, `policy_version`, `policy_hash`, `as_of`, `computed_at`) —
the auditable SSOT. "De-funded under gate-policy vN" is queryable, and it's the read-model a later cockpit
pass will surface from.

**Cockpit VISUAL surfacing is deferred** (not in this sub-project): B2a's `strategy_allocation` is itself not
yet surfaced in the cockpit (its only consumer today is C1's envelope read), so a funding-state view would be
premature and inconsistent — the confusing "`$0` with no explanation" case cannot arise until allocation is
surfaced. Funding + allocation should be surfaced together in a future cockpit pass (with the mandated local
browser verification). B3 delivers the durable, queryable funding state; the visual is a clean follow-up.

### 6. Where the human stays in the loop

The human sets the `GatePolicy` (now including `defund_window`/`refund_window`, versioned in git — reviewed)
and the B2a capital ceiling, and arms venue keys. B3 de-funds/re-funds automatically per the committed policy
and the immutable record. No capital is moved (that is C); B3 only narrows B2a's input set. Re-arming a
recovered edge is automatic (hysteresis), but slower than de-risking, and always bounded by the ceiling.

## Error handling

- **Edge never PASSed** → never de-funded (pre-validation; not decay). Safe.
- **All effective-deploy edges de-funded** → empty allocation (deploy nothing). Safe.
- **Record has a hole** → `EXECUTION_GAP` category = neutral (resets the decay run); an ops gap never de-funds
  an edge. The gap still surfaces via B1's gate.
- **No backtest reference** → `IMMATURE` = neutral (cannot reconcile → cannot claim decay). Never de-funds.
- **Transient dip** shorter than `defund_window` → decay run resets on the first non-divergence day; funding
  does **not** flicker (the anti-whipsaw guarantee).
- **NO reactive de-sizing:** de-funding is binary + structural (sustained divergence over the window), never a
  continuous per-day throttle. Re-funding is symmetric + slower.

## Testing

- **Load-bearing (anti-whipsaw):** a transient `DIVERGENCE` run shorter than `defund_window`, then a `PASS`,
  leaves the edge `funded` — funding does not flicker on a normal dip.
- **Sustained decay de-funds:** `defund_window` consecutive `DIVERGENCE` days flip `funded → de-funded`; the
  edge is then absent from `record_allocation`'s input and gets `$0`.
- **Recovery re-funds:** after de-funding, `refund_window` consecutive `PASS` days flip back to `funded`;
  asymmetry holds (`refund_window > defund_window`).
- **Never-PASSed is never de-funded:** an edge whose whole record is `IMMATURE`/`DIVERGENCE` but that never
  PASSed stays fundable (state never leaves the pre-validation guard).
- **Neutral days don't de-fund:** an `EXECUTION_GAP` or `IMMATURE` run of any length never flips funding.
- **Verdict categorisation:** each reconciliation-gate reason maps to the correct category
  (PASS/DIVERGENCE/IMMATURE/EXECUTION_GAP).
- **Recompute determinism:** the same record + policy → the same `FundingStatus` (no persisted state).
- **Policy-hash self-check:** adding `defund_window`/`refund_window` without a `GATE_POLICY_VERSION` bump fails
  the hash self-check; the recorded snapshot carries the version + hash.
- **B2a redistribution:** with one of two funded edges de-funded, the survivor is equal-weighted up to the
  `max_strategy_weight` cap and the remainder is held as cash (B2a unchanged).

## Out of scope (YAGNI)

- **No `WindowedEdgeHealth` / absolute-drift layer** — the reconciliation gate is the correct "stopped paying"
  signal; WEH misses quiet death and is redundant with B2a's marginal gate for the bleeding case. `RetirementGate`
  / `WindowedEdgeHealth` stay in `domain/edge_decay.py` (used by the `combined` paper controller), untouched.
- **No change to promotion** — it stays one-way + persistent (the historical "was validated" fact). Funding is
  the new conditional layer; the two are deliberately separate.
- **No continuous health throttle / per-day de-sizing** (the rejected reactive path).
- **No capital movement** (C). B3 only narrows B2a's input set.
- **No per-fill / execution-decay signal** — that is the executed-vs-modeled reconciliation the C1
  `multistrat_exec` track already carries via its own gate.

## Affected code (initial map — the plan refines this)

- Modify: `src/fxhnt/application/reconciliation_gate.py` — add the machine-readable `category` to `GateVerdict`
  (PASS/DIVERGENCE/IMMATURE/EXECUTION_GAP); each existing return path sets its category. Verdicts unchanged.
- Modify: `src/fxhnt/application/gate_policy.py` — add `defund_window=10`, `refund_window=15` to `GatePolicy`;
  bump `GATE_POLICY_VERSION`; the hash self-check pins the new value.
- New: `src/fxhnt/application/funding_status.py` — `compute_funding(rows, backtest_ref, policy) ->
  FundingStatus`: the per-day gate recompute + the inception-replayed funding state machine (pure).
- Modify: `src/fxhnt/application/allocation_ingest.py` `record_allocation` — filter the effective deploy set
  through `compute_funding` before `compute_allocation`; record the `strategy_funding` snapshot.
- New: `strategy_funding` table (`cockpit_models.py`) + repo write/read (`forward_nav.py`).
- Deferred (NOT this sub-project): cockpit visual surfacing of funding `state` — deferred to a future cockpit
  pass alongside allocation surfacing (which is itself not yet surfaced). The `strategy_funding` table is the
  read-model that pass will consume.
- Reuse: `evaluate_reconciliation_gate` + `resolve_policy` (B1), the `BacktestRefProvider` incl. C1's
  `multistrat_exec → multistrat` alias, `ForwardNavRepo.nav_history` / `all_backtest_summaries` (A),
  `deploy_book`/`compute_allocation` (B2a, unchanged).
```
