Skip to content

Orion Delivery — feat/inventory-corridor-guard

To: Vesper From: Orion Date: 2026-04-19 Branch: feat/inventory-corridor-guard Status: READY FOR REVIEW

Phase 7.3 gate 4 — composition-based DEGRADED trigger. Four commits, 24 new tests, 111/111 green across the Phase 7.3 regression surface.


Summary

Third and final layer of the Phase 7.3 protection stack. Complements the anchor saturation guard (price regime, Step 8.5) and directional drift guard (fill flow, Step 8.5b) by catching composition drift — the accumulated inventory STATE — when it leaves the operating envelope.

Two DEGRADED paths, same guard:

  1. Hard RLUSD floor (immediate, no lookback). If rlusd_balance < min_rlusd_floor the guard fires on the current tick. Mid-independent — evaluates before any mid-price-dependent math. Reason string: inventory_corridor_guard_rlusd_floor.
  2. Composition corridor (debounced). If xrp_pct (XRP value as a percentage of total portfolio) leaves [min_xrp_pct, max_xrp_pct] for corridor_lookback_ticks consecutive ticks, guard fires. Single-tick price spikes are debounced. Reason string: inventory_corridor_guard_composition.

Fail-open on missing/zero mid_price: the lookback counter is neither incremented nor reset — the tick is treated as a no-observation. Activation gated below min_portfolio_rlusd (degenerate near-empty state).

One-shot per session: the first trigger writes the circuit_breaker_events row and transitions DEGRADED; subsequent ticks do not re-persist or re-log. _enter_degraded_mode is idempotent. Both paths share the same one-shot flag — whichever fires first wins the persistence slot for that session.

Defaults shipped per Vesper's Q1–Q5 ruling + Option 1 confirmation 2026-04-19: enabled: true, min_xrp_pct: 25.0, max_xrp_pct: 75.0, corridor_lookback_ticks: 3, min_rlusd_floor: 30.0, min_portfolio_rlusd: 50.0.


Commits (4)

d724710 feat(config): InventoryCorridorGuardConfig dataclass + YAML defaults (Phase 7.3)
86bc35b feat(main_loop): inventory corridor guard state + evaluation (Step 8.5c)
1bfdb9d feat(db,main_loop): corridor guard circuit_breaker_events persistence + WARNING log
d7c84cc test(guard): inventory corridor guard — 24 tests (Phase 7.3)

Branch base: 0114101 (last commit on fix/reconciler-disappeared-order-conservative, which is itself the tip of the in-flight Phase 7.3 protection stack). Patch bundle attached as patches/feat-inventory-corridor-guard/000{1,2,3,4}-*.patch.

C1 — d724710 — InventoryCorridorGuardConfig + YAML defaults

neo_engine/config.py: - New frozen @dataclass InventoryCorridorGuardConfig with the 6 fields listed above, each with the Vesper-locked default. - Top-level Config field, NOT nested under StrategyConfig — per your Option 1 confirmation ("Build to Option 1. Green light stands on everything else. Start C1."). Mirrors anchor_saturation_guard, directional_drift_guard, reconciler_conservative which are all top-level in the live codebase. - Loader block reads top-level YAML key inventory_corridor_guard. - _validate_inventory_corridor_guard() raises ConfigError on: min_xrp_pct or max_xrp_pct out of [0, 100], min_xrp_pct >= max_xrp_pct, corridor_lookback_ticks < 1, min_rlusd_floor < 0, min_portfolio_rlusd < 0. - __repr__ entry added for observability parity with sibling guards.

YAML blocks added to config/config.yaml, config/config.example.yaml, config/config_live_stage1.yaml at the top level, with a comment block citing the 2026-04-19 ruling. Each block explains: composition bounds, lookback debounce, RLUSD hard floor (fires immediately), activation gate, fail-open behavior on missing mid, and the sibling-guard architecture (anchor = price, drift = flow, corridor = composition).

C2 — 86bc35b — State + evaluation at Step 8.5c

neo_engine/main_loop.py: - InventorySnapshot added to the neo_engine.models imports (direct type reference for the new evaluator signature). - Engine state init (after drift-guard state):

self._corridor_ticks_outside: int = 0
self._corridor_guard_triggered_this_session: bool = False
- New method _evaluate_inventory_corridor_guard(intents, inventory, mid_price) -> tuple[list, bool]: - Guard-disabled short-circuit (not cfg.enabled → return intents unchanged). - Path 1 (RLUSD floor, mid-independent). Evaluates FIRST so a degenerate wallet always trips even if mid is stale. On trigger: set one-shot flag BEFORE side effects, call _enter_degraded_mode("inventory_corridor_guard_rlusd_floor"), return []. - Fail-open on mid. mid_price is None or mid_price <= 0.0 → return intents unchanged; counter preserved (no increment, no reset). - Activation gate. total_value_rlusd < cfg.min_portfolio_rlusd → reset counter, return intents unchanged. (Vesper Q4 note: resume from fresh state when the wallet rebuilds past the gate.) - Path 2 (composition corridor). Compute xrp_pct, increment _corridor_ticks_outside if outside [min_xrp_pct, max_xrp_pct], reset if inside. If counter reaches corridor_lookback_ticks: set one-shot flag, call _enter_degraded_mode("inventory_corridor_guard_composition"), return []. - Call site inserted at Step 8.5c in _tick — AFTER the drift guard (8.5b) and BEFORE the "no intents" no-fill log. Matches Vesper Q3 ordering confirmation (anchor → drift → composition).

Independent one-shot flag from the other two guards. All three guards can fire in the same tick; each writes its own row. _enter_degraded_mode preserves degraded_since on re-entry so repeat-tick triggering is safe.

C3 — 1bfdb9d — Persistence + WARNING log

neo_engine/main_loop.py: - On first trigger of either path, a circuit_breaker_events row is written via StateManager.record_circuit_breaker_event: - breaker="inventory_corridor_guard" - session_id=self._current_session_id - manual_reset_required=True (consistent with anchor / drift guards) - context JSON includes condition_triggered discriminator ("rlusd_floor" vs "composition") plus the full state payload — xrp_balance, rlusd_balance, mid_price, xrp_pct (composition path only), min_xrp_pct, max_xrp_pct, min_rlusd_floor, ticks_outside + corridor_lookback_ticks (composition path only). - Best-effort persistence: record_circuit_breaker_event failure is caught, an ERROR-level log written with the full context, and the DEGRADED transition proceeds. Matches the "persistence must not block the safety gate" pattern from the anchor guard. - WARNING log emitted on first trigger only (one-shot). Distinct messages per path so log grep is unambiguous: - [CORRIDOR_GUARD] RLUSD floor breach — entering DEGRADED (rlusd=<x> < floor=<y>) - [CORRIDOR_GUARD] composition corridor breach — entering DEGRADED (xrp_pct=<x> outside [<min>, <max>] for <ticks>/<lookback> ticks) - Structured extra fields attached to both log messages for downstream parsing.

C4 — d7c84cc — 24-test suite

tests/test_inventory_corridor_guard.py (543 lines, 4 test classes):

Part A — Config validation (6 tests)

# Test What it proves
1 test_defaults_validate_ok Shipped defaults pass validation unchanged.
2 test_validator_rejects_min_above_max min_xrp_pct=80, max_xrp_pct=20ConfigError.
3 test_validator_rejects_equal_min_max min==maxConfigError (empty corridor is a spec violation).
4 test_validator_rejects_pct_out_of_range Negative min_xrp_pct, max_xrp_pct > 100 both rejected.
5 test_validator_rejects_nonpositive_lookback corridor_lookback_ticks 0 and -1 rejected.
6 test_validator_rejects_negative_balance_thresholds Negative min_rlusd_floor, min_portfolio_rlusd both rejected.

Part B — Evaluation (16 tests)

# Test What it proves
7 test_enabled_false_disables_guard enabled=False short-circuits before any evaluation even at S39-shape composition.
8 test_guard_inactive_below_min_portfolio Portfolio < min_portfolio_rlusd → return unchanged, counter reset.
9 test_inside_corridor_no_trigger 50/50 composition, counter stays 0.
10 test_outside_below_lookback_no_trigger Outside corridor but ticks_outside < lookback → no trigger, counter increments.
11 test_outside_at_lookback_triggers_over_high XRP% > max for lookback consecutive ticks → DEGRADED, composition reason.
12 test_outside_at_lookback_triggers_under_low XRP% < min for lookback consecutive ticks → DEGRADED (S39-shape case).
13 test_back_inside_resets_counter Leaving the corridor then returning resets _corridor_ticks_outside to 0.
14 test_rlusd_floor_triggers_immediately rlusd < min_rlusd_floor on tick 1 → DEGRADED, no lookback, floor reason.
15 test_rlusd_floor_fires_even_when_mid_missing Path 1 is mid-independent — mid_price=None does not block floor.
16 test_mid_none_is_fail_open_for_composition mid_price=None with healthy RLUSD → counter preserved across the missing-data tick.
17 test_mid_zero_is_fail_open_for_composition mid_price=0.0 same as None — counter preserved.
18 test_one_shot_no_double_persist_on_subsequent_ticks RLUSD floor fires once; subsequent stuck-below ticks do not re-persist or re-log.
19 test_one_shot_composition_dedup Composition breach fires once; tick lookback+1, +2, ... in the same breach do not re-persist.
20 test_emits_corridor_guard_warning_log WARNING log assertion on first composition trigger — distinct message format.
21 test_context_payload_rlusd_floor RLUSD-floor context JSON contains condition_triggered="rlusd_floor" + full state.
22 test_context_payload_composition_breach Composition context JSON contains condition_triggered="composition" + xrp_pct + ticks_outside + bounds.

Part C — Integration with real StateManager (1 test)

# Test What it proves
23 test_trigger_writes_circuit_breaker_row_with_session_id End-to-end: real StateManager, real row persisted. Asserts breaker="inventory_corridor_guard", correct session_id, manual_reset_required=True, context_json parses to the expected payload. Verifies the schema/wire contract, not just the call.

Part D — Best-effort persistence (1 test)

# Test What it proves
24 test_persist_failure_does_not_block_degraded_transition record_circuit_breaker_event.side_effect = RuntimeError must not prevent _enter_degraded_mode or the one-shot flag flip. ERROR log recorded; safety gate fires regardless.

Windows teardown pattern applied — same LIFO fixture pattern established on feat/anchor-saturation-guard C5a and re-used on FLAG-037 C4:

self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)   # registered FIRST — runs LAST
self.sm = StateManager(os.path.join(self.tmpdir.name, "test.db"))
...
self.addCleanup(self.sm.close)         # registered LAST — runs FIRST

LIFO: sm.close() runs before rmtree so SQLite releases the .db file handle before the tmpdir is removed. POSIX-equivalent ordering; Windows-correct.


Test Results

New suite

$ python -m pytest tests/test_inventory_corridor_guard.py -v
============================= 24 passed in 0.43s ==============================

Phase 7.3 regression surface

$ python -m pytest \
    tests/test_inventory_corridor_guard.py \
    tests/test_config.py \
    tests/test_anchor_saturation_guard.py \
    tests/test_directional_drift_guard.py \
    tests/test_reconciler_conservative.py \
    tests/test_reconciler_anomaly_log.py \
    tests/test_ledger_reconciler.py -q
111 passed in 2.40s

Full suite

Full-suite pre-existing FLAG-016 debt is unchanged by this branch (stashing confirms the same OrderSizeConfig.__init__() missing 1 required positional argument: 'max_size_pct_of_portfolio' failures present without my changes). No new failures introduced.


Q1–Q5 + Option 1 confirmation

All Vesper rulings applied as written:

  • Q1 — Inventory state. inventory local at main_loop.py:2292 used directly. inventory.xrp_balance, inventory.rlusd_balance, inventory.total_value_in_rlusd consumed; xrp_pct computed inline from the balances so the guard is not coupled to InventorySnapshot.xrp_pct's computation semantics. (Same numerical result; explicit locally.)
  • Q2 — Mid price source. mid_price local at main_loop.py:2291 (snapshot.mid_price or 0.0) passed through unchanged.
  • Q3 — Insertion point (Step 8.5c). After drift guard (8.5b), before the no-intents log. Ordering (anchor → drift → composition) locked in a section header comment at the call site.
  • Q4 — Fail-open on missing mid. Counter neither incremented nor reset. Floor path evaluates first and is mid-independent, so the Atlas invariant holds on all paths. Covered by tests 15, 16, 17.
  • Q5 — No double-fire with existing exposure caps. Corridor guard occupies an empty slot between the existing absolute HALT caps and the strategy-layer intent advisories. No evaluator overlap. Verified.
  • Option 1 placement (top-level Config field). Applied per "Build to Option 1. Green light stands on everything else. Start C1." All three production config files carry the new top-level inventory_corridor_guard: block. config_live_session1.yaml skipped — see Deviations below.

Deviations from spec — flagged explicitly

1. config/config_live_session1.yaml not updated.

This file is referenced neither by run_live_session.py nor by any test. It also lacks the existing Phase 7.3 guards (anchor_saturation_guard, directional_drift_guard, reconciler_conservative) — the pattern of stale/unreferenced has been established by prior deliveries. I treated it as stale and did not add the new block. If you want it wired in as a follow-up hygiene task, I'll add it in a one-line config-only PR.

2. xrp_pct computed inline rather than read from InventorySnapshot.xrp_pct.

Minor semantic difference — the guard computes 100.0 * (xrp_balance * mid_price) / total_value_in_rlusd directly from the balances + mid rather than reading snapshot.xrp_pct. Reason: InventorySnapshot.xrp_pct is computed using its own mid-price field which may differ (by hydration order) from the mid_price local the guard is currently evaluating. Computing inline keeps both paths of the guard referring to the exact same mid, which is the mid_price used by the surrounding tick logic (and by the fail-open check). Same numerical answer in the common path; safer during any mid-update race.

3. max_size_pct_of_portfolio pre-existing FLAG-016 debt not addressed.

277 unrelated failures in test_execution_engine.py / test_xrpl_gateway.py are out of scope for this branch per the prior rulings. All Phase 7.3 suites green.

No other deviations.


Risk surface

  • Production behavior when guard is OFF: byte-for-byte identical to current behavior. All new logic is behind if cfg.enabled:.
  • Production behavior when guard is ON and corridor/floor not breached: one extra dict-lookup + one extra integer comparison per tick (_corridor_ticks_outside). No persistence, no log, no state change.
  • Production behavior on first breach: one row written to circuit_breaker_events, one WARNING log, transition to DEGRADED via existing idempotent path. Quoting stops; reconciliation continues; recovery requires operator action (same shape as anchor and drift guards).
  • Composition-path false positives from price spikes: debounced by corridor_lookback_ticks=3 (12s at 4s cadence). A single 10%+ price spike does not trigger.
  • Floor-path false positives: none practical — the floor is absolute balance, not a computed quantity. rlusd < 30 during normal operation is itself the anomaly this guard is built to catch.
  • Calibration caveat: defaults are Vesper-locked "reasonable first pass" values. Flagged for Atlas calibration post-first-live-session with the full guard stack active.

Files in the patch bundle

patches/feat-inventory-corridor-guard/
  0001-feat-config-InventoryCorridorGuardConfig-dataclass-Y.patch
  0002-feat-main_loop-inventory-corridor-guard-state-evalua.patch
  0003-feat-db-main_loop-corridor-guard-circuit_breaker_eve.patch
  0004-test-guard-inventory-corridor-guard-24-tests-Phase-7.patch

Apply on Katja's Windows repo root C:\Users\Katja\Documents\NEO GitHub\neo-2026\ (PowerShell):

git checkout main
git pull
git branch -D feat/inventory-corridor-guard 2>$null
git checkout -b feat/inventory-corridor-guard
Get-ChildItem "C:\Users\Katja\Documents\Claude Homebase Neo\02 Projects\NEO Trading Engine\patches\feat-inventory-corridor-guard" -Filter "*.patch" | Sort-Object Name | ForEach-Object { git am $_.FullName }
python -m pytest tests/test_inventory_corridor_guard.py tests/test_config.py tests/test_anchor_saturation_guard.py tests/test_directional_drift_guard.py tests/test_reconciler_conservative.py tests/test_reconciler_anomaly_log.py tests/test_ledger_reconciler.py -q

Rule compliance (standing Orion apply-instruction rules): - git branch -D runs before git checkout -b so a pre-existing local branch is silently cleared. - Get-ChildItem ... | Sort-Object Name | ForEach-Object { git am $_.FullName } — no *.patch glob (does not expand in PowerShell). - Expected result on Linux/Windows: 111 passed. (No pre-existing teardown debt on these suites.)

Diffstat:

 config/config.example.yaml             |  21 ++
 config/config.yaml                     |  21 ++
 config/config_live_stage1.yaml         |  18 ++
 neo_engine/config.py                   | 139 ++++++++-
 neo_engine/main_loop.py                | 231 +++++++++++++-
 tests/test_inventory_corridor_guard.py | 543 +++++++++++++++++++++++++++++++++
 6 files changed, 971 insertions(+), 2 deletions(-)


Phase 7.3 protection stack — status after this branch

Layer Branch Status
Truth reconciliation feat/wallet-truth-reconciliation (D2.2) MERGED
Reconciler anomaly audit log feat/reconciler-disappeared-order-audit-log MERGED
Price regime (anchor) feat/anchor-saturation-guard MERGED
Fill flow (drift) feat/directional-drift-guard MERGED
Disappeared-order age gate (FLAG-037) fix/reconciler-disappeared-order-conservative MERGED
Composition (corridor) feat/inventory-corridor-guard READY FOR REVIEW
Anchor error telemetry feat/anchor-error-per-tick-telemetry NEXT
Session-close cancellation invariant TBD QUEUED

After this merge, 3 of the Atlas priority 3–5 guards + FLAG-036 + FLAG-037 are live. Remaining blockers for Phase 7.4 (SR-AUDIT) are the telemetry branch and the two clean live sessions with guards active (Katja-agreed precondition).

Standing by for review.

— Orion