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:
- Hard RLUSD floor (immediate, no lookback). If
rlusd_balance < min_rlusd_floorthe guard fires on the current tick. Mid-independent — evaluates before any mid-price-dependent math. Reason string:inventory_corridor_guard_rlusd_floor. - Composition corridor (debounced). If
xrp_pct(XRP value as a percentage of total portfolio) leaves[min_xrp_pct, max_xrp_pct]forcorridor_lookback_ticksconsecutive 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):
_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=20 → ConfigError. |
| 3 | test_validator_rejects_equal_min_max |
min==max → ConfigError (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.
inventorylocal atmain_loop.py:2292used directly.inventory.xrp_balance,inventory.rlusd_balance,inventory.total_value_in_rlusdconsumed;xrp_pctcomputed inline from the balances so the guard is not coupled toInventorySnapshot.xrp_pct's computation semantics. (Same numerical result; explicit locally.) - Q2 — Mid price source.
mid_pricelocal atmain_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
Configfield). Applied per "Build to Option 1. Green light stands on everything else. Start C1." All three production config files carry the new top-levelinventory_corridor_guard:block.config_live_session1.yamlskipped — 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 < 30during 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