Skip to content

Orion Delivery — fix/anchor-idle-guard-semantics

To: Vesper From: Orion Date: 2026-04-22

Per Atlas ruling 2026-04-22 and Vesper's execution note "Guard Architecture Fixes Pre-S53", this branch implements both required structural changes:

  1. Change 1 (FLAG-050): Reset drift-C fill history on ANCHOR_IDLE entry.
  2. Change 2: Split idle-sourced vs active-sourced DEGRADED episode accounting.

Single branch as Vesper recommended — both fixes touch the same ANCHOR_IDLE boundary semantics and share test fixtures.


Branch commits

# SHA Subject
1 19a8855 feat(main_loop): FLAG-050 — reset drift-C fill history on ANCHOR_IDLE entry
2 0a03465 feat(main_loop,state): Atlas 2026-04-22 — split idle-vs-active DEGRADED episode accounting
3 866f6fe test(anchor_idle_guard_semantics): 14 tests for FLAG-050 reset + episode routing

Splitting Change 1 and Change 2 into separate commits keeps bisect clean and lets reviewers read the FLAG-050 reset without the 135-line Change 2 diff on top.


Change 1 — FLAG-050 reset details

What I reset

Variable Reset value Why
self._drift_ticks_since_opposing_fill 0 Condition C primary counter. Only meaningful while engine is actively quoting. Idle time is not active exposure; carrying the counter across the boundary deterministically re-fires C on idle exit.

What I preserved (Atlas: "Do not reset unrelated system state casually")

Variable Why preserved
self._drift_fills_seen_this_session Cumulative session metric, not continuity-dependent. Surfaced in session summary.
self._drift_fill_events (A's burst deque) Condition A fires on rapid same-side fills in active quoting. Not relevant during idle, but preserving lets burst detection remain accurate post-idle if fills were near the session/idle boundary. Shared structure; no C-specific timer inside.
self._drift_net_notional_events (B's net-notional deque) Same rationale as A — cumulative window, not continuity-dependent.
_anchor_guard_triggered_this_session (unrelated) One-shot for anchor guard re-fire. Already handled in _exit_anchor_idle_mode. Intentionally not touched here — Change 1 is drift-C-only.

Reset is fresh-entry only

The reset is gated by if not already_idle: so an idempotent re-entry (ANCHOR_IDLE already set, reason updated) does NOT zero the counter. This matters because a tick's counter increment may run between _current_truth_mode() returning MODE_ANCHOR_IDLE and the next re-entry — double-zeroing could mask a genuine signal. Fresh entry is the only semantic transition point.

Code change

neo_engine/main_loop.py_enter_anchor_idle_mode(self, reason): - Added a bullet to the docstring explaining the reset. - Inside the if not already_idle: block, before the [ANCHOR_IDLE_ENTER] log line, added:

self._drift_ticks_since_opposing_fill = 0
with the multi-line comment explaining the rationale and the preserve-list for future readers.

Nothing else in _enter_anchor_idle_mode was touched.


Change 2 — Episode accounting split details

Routing rule

At DEGRADED entry in _enter_degraded_mode:

prior_mode = self._current_truth_mode()  # captured before any mode writes
...
if prior_mode == MODE_ANCHOR_IDLE:
    new_idle = self._bump_idle_episode_count(source)
else:
    # FLAG-044 existing path — episode count + cool-down arm
    new_active = self._bump_episode_count(source)
    ...

degraded_episode_limit_halt gates on the active counter only. The idle counter is persisted and observable but does not cap — per Atlas, the idle-side cap is TBD.

Engine-state surface (added)

Key Type Semantics
degraded_recovery.drift.idle_episode_count int Drift DEGRADED entries whose prior mode was ANCHOR_IDLE
degraded_recovery.corridor.idle_episode_count int Corridor DEGRADED entries whose prior mode was ANCHOR_IDLE

Engine-state surface (unchanged — now active-only)

Key Reclassification
degraded_recovery.drift.episode_count Was: all drift DEGRADED entries. Now: active-sourced only.
degraded_recovery.corridor.episode_count Was: all corridor DEGRADED entries. Now: active-sourced only.

Why no migration

On-disk keys for active are preserved because: 1. The 30+ test references in tests/test_flag_044_recovery_cooldown.py keep validating without change. 2. Pre-ruling engines wrote episodes under these keys with semantics that — post FLAG-046 retiring anchor from RECOVERY_CAPPED_SOURCES — were effectively "active" in every case. So historic counts in resumed-session state rows are semantically correct after the split.

Startup reset

Fresh-session startup reset already zeroed both active keys. I extended that block to also write blanks for KEY_IDLE_EPISODE_COUNT_DRIFT and KEY_IDLE_EPISODE_COUNT_CORRIDOR, so the split accounting is honoured uniformly on fresh sessions. Recovery restarts (parent_session_id != None) skip the block and carry both counters forward (same as existing FLAG-044 behavior).

New helper

def _bump_idle_episode_count(self, source: str) -> int:
    ...
Mirrors _bump_episode_count: atomic read / increment / persist against _IDLE_EPISODE_COUNT_KEY_BY_SOURCE[source]. Labels all logs with label="idle_episode_count" so circuit_breaker_events rows are distinguishable from active.

Cool-down not armed on idle-sourced

Idle-sourced DEGRADED entries do NOT call _arm_recovery_cooldown. Per Atlas, idle-sourced episodes are a separate class and should not be rate-limited the same way; cool-down is a FLAG-044 construct that belongs to the active counter. Tests assert _arm_recovery_cooldown.assert_not_called() for idle-sourced paths.


Test coverage

New file — tests/test_anchor_idle_guard_semantics.py (14 tests)

Part A — FLAG-050 drift-C reset (3) | # | Test | Confirms | |---|---|---| | 1 | test_fresh_idle_entry_zeros_drift_c_counter | Reset runs on fresh entry; C counter goes from 17 → 0 | | 2 | test_fresh_idle_entry_preserves_ab_state_and_session_fills | Preserve list: A burst deque, B net-notional deque, session fill count all intact | | 3 | test_idempotent_reentry_does_not_rezero_counter | Re-entry while already idle (reason update only) does not zero the counter |

Part B — Idle-vs-active episode routing (7) | # | Test | Confirms | |---|---|---| | 4 | test_idle_sourced_drift_bumps_idle_only | Drift DEGRADED from ANCHOR_IDLE → idle_episode_count++, active unchanged | | 5 | test_active_sourced_drift_bumps_active_only | Drift DEGRADED from MODE_OK → active_episode_count++, idle unchanged | | 6 | test_idle_sourced_does_not_arm_cooldown | Idle route does not call _arm_recovery_cooldown | | 7 | test_active_cap_breach_fires_halt | At active cap 3, next active-sourced drift still fires degraded_episode_limit_halt | | 8 | test_idle_count_never_halts_at_active_threshold | 10+ idle-sourced episodes never trigger degraded_episode_limit_halt | | 9 | test_corridor_source_symmetry | Same routing applies to SOURCE_CORRIDOR (both directions) | | 10 | test_already_degraded_reentry_is_not_fresh_bump | Second call while already DEGRADED does not bump either counter |

Part C — Idle-active budget isolation (2) | # | Test | Confirms | |---|---|---| | 11 | test_many_idle_cycles_never_halt | Session can cycle through N idle-sourced DEGRADED entries without termination | | 12 | test_active_breach_after_idle_cycles_still_halts | FLAG-048 warm-up scenario: N idle cycles + 3 active-sourced → halt fires correctly |

Structural invariants (2) | # | Test | Confirms | |---|---|---| | 13 | test_idle_episode_count_keys_defined | KEY_IDLE_EPISODE_COUNT_* module constants exist and are distinct from active keys | | 14 | test_idle_episode_count_map_covers_capped_sources | _IDLE_EPISODE_COUNT_KEY_BY_SOURCE covers every source in RECOVERY_CAPPED_SOURCES |

Updated file — tests/test_anchor_idle_state.py

Two pre-existing tests were asserting pre-ruling behavior (idle-sourced escalations bumping _bump_episode_count). Under Atlas Change 2 those assertions are incorrect — updated to the new contract:

Test Old assertion New assertion
test_drift_fires_while_anchor_idle_escalates_and_counts_episode _bump_episode_count.assert_called_once_with(SOURCE_DRIFT) _bump_idle_episode_count.assert_called_once_with(SOURCE_DRIFT) + _bump_episode_count.assert_not_called() + _arm_recovery_cooldown.assert_not_called()
test_corridor_fires_while_anchor_idle_also_escalates _bump_episode_count.assert_called_once_with(SOURCE_CORRIDOR) _bump_idle_episode_count.assert_called_once_with(SOURCE_CORRIDOR) + _bump_episode_count.assert_not_called() + _arm_recovery_cooldown.assert_not_called()

The _escalation_engine fixture gains eng._bump_idle_episode_count = MagicMock(return_value=1). Class and test docstrings point to Atlas 2026-04-22 ruling and to test_anchor_idle_guard_semantics.py for the full semantics.


Regression

Adjacent suite

pytest tests/test_anchor_idle_guard_semantics.py \
       tests/test_anchor_idle_state.py \
       tests/test_directional_drift_guard.py \
       tests/test_flag_042_degraded_recovery.py \
       tests/test_flag_044_recovery_cooldown.py \
       tests/test_flag_047_cancel_fill_race.py \
       tests/test_flag_048_dual_signal.py \
       tests/test_wallet_truth_reconciliation.py
→ 158 passed

Delta-vs-clean-tree

  • Clean tree (HEAD before branch): 337 failed, 672 passed.
  • This branch: 337 failed, 686 passed.
  • Delta = +14 passed, zero new failures — exactly matches the 14 new tests added. The 337 pre-existing failures are unrelated (xrpl_gateway mocking, test_task5, etc. — all were failing before this branch and continue to fail for the same unrelated reasons).

PowerShell apply instructions for Katja

Repo path: C:\Users\Katja\Documents\NEO GitHub\neo-2026\ Workspace patch path: C:\Users\Katja\Documents\Claude Homebase Neo\02 Projects\NEO Trading Engine\03 Branches\fix-anchor-idle-guard-semantics\patches\

cd "C:\Users\Katja\Documents\NEO GitHub\neo-2026"

# Defensive cleanup — drop any pre-existing branch of the same name.
git branch -D fix/anchor-idle-guard-semantics 2>$null

# Branch from current main at commit time (no pre-creation during investigation).
git checkout main
git pull
git checkout -b fix/anchor-idle-guard-semantics

# Apply the 3-commit bundle in order (PowerShell-safe — no *.patch glob).
Get-ChildItem "C:\Users\Katja\Documents\Claude Homebase Neo\02 Projects\NEO Trading Engine\03 Branches\fix-anchor-idle-guard-semantics\patches" -Filter "*.patch" | Sort-Object Name | ForEach-Object { git am $_.FullName }

# Sanity check
git log --oneline main..HEAD
# Expect exactly 3 commits:
#   test(anchor_idle_guard_semantics): 14 tests for FLAG-050 reset + episode routing
#   feat(main_loop,state): Atlas 2026-04-22 — split idle-vs-active DEGRADED episode accounting
#   feat(main_loop): FLAG-050 — reset drift-C fill history on ANCHOR_IDLE entry

# Run the focused suite
pytest tests/test_anchor_idle_guard_semantics.py tests/test_anchor_idle_state.py tests/test_directional_drift_guard.py tests/test_flag_044_recovery_cooldown.py -q

Expected: 158 passed on the adjacent suite (the 7 listed above plus the new file). If git am fails on any patch, do NOT --amend; run git am --abort and ping Orion with the failing patch name.


Success criteria mapping (Vesper execution note §3)

Criterion Addressed by
Engine survives past 50 ticks Change 2 — idle-sourced episodes no longer burn active budget, so FLAG-048 warm-up can complete in hostile anchor
No degraded_episode_limit_halt from idle-sourced episodes Change 2 — halt gates on active counter only
Engine exits ANCHOR_IDLE and places orders post-idle Change 1 — drift-C no longer re-fires on first post-idle tick (FLAG-048 C-counter reset confirms the path)
No immediate drift C re-fire on first tick post-idle Change 1 — counter reset to 0 on idle entry; C threshold cannot be crossed on a freshly-zeroed counter
Halt reason (if any) is duration_elapsed or active-sourced episode limit only Both changes — idle path is separated from all halt gating

Scope hygiene (Standing rules compliance)

  • No pre-created branches during investigation — branched from current HEAD at commit time. No ghost fix/anchor-idle-guard-semantics existed on remote before this delivery.
  • No *.patch glob in PowerShell — apply instructions use the Get-ChildItem ... Sort-Object Name | ForEach-Object form.
  • Defensive git branch -D — included before git checkout -b in the apply snippet.

— Orion 2026-04-22