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:
- Change 1 (FLAG-050): Reset drift-C fill history on ANCHOR_IDLE entry.
- 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:
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¶
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-semanticsexisted on remote before this delivery. - No
*.patchglob in PowerShell — apply instructions use theGet-ChildItem ... Sort-Object Name | ForEach-Objectform. - Defensive
git branch -D— included beforegit checkout -bin the apply snippet.
— Orion 2026-04-22