diff --git a/codex/economy/flip-candidates.yaml b/codex/economy/flip-candidates.yaml index 807bbcc..d25e87c 100644 --- a/codex/economy/flip-candidates.yaml +++ b/codex/economy/flip-candidates.yaml @@ -3,7 +3,7 @@ title: GE Flipping (margin trading) domain: economy tags: [flipping, merchanting, ge, money-making, hybrid] status: verified -version: 5 # 5: bounce-fix 6 2026-08-28 (governor comment 10390) — the 4h buy-limit reset NEVER cancels an offer (offer lifetime = independent quote_age_policy: stale edge or strategy reallocation only); explicit buy-filled-awaiting-collect / sell-filled-awaiting-collect states so completed offers must pass through the GE collection box (ge_collect) before reuse or closing; SIDE-AWARE per-slot ledgers (buy: quoted units = filled + still_open + cancelled_unfilled, acquired items = filled ONLY, cash = spent + still_committed + refunded; sell: quoted = sold + still_open + returned_unsold) with committed negative fixtures for phantom refunded buy units, a full buy sold before collection, and a full sell closed before proceeds collection. Capped/floored tax model + its 4 selftest fixtures and the asynchronous scheduler behavior preserved (owner decisions 2026-08-28). +version: 6 # 6: bounce-fix 7 2026-08-29 (governor comment 10471) — 'closed' is no longer a sink: after a completed sell's proceeds are collected and the ledger is reconciled + ARCHIVED, the physical GE slot is released to free via the explicit archival transition `closed -> free` (a deliberately closed cancelled sell releases the same way), and the committed two-rotation regression proves one slot completes buy/collect/sell/collect, archives the first ledger, and opens a SECOND buy in the same slot without losing, duplicating, or mutating the archived record. Previous (v5, bounce-fix 6): the 4h buy-limit reset NEVER cancels an offer (offer lifetime = independent quote_age_policy: stale edge or strategy reallocation only); explicit buy-filled-awaiting-collect / sell-filled-awaiting-collect states so completed offers must pass through the GE collection box (ge_collect) before reuse or closing; SIDE-AWARE per-slot ledgers (buy: quoted units = filled + still_open + cancelled_unfilled, acquired items = filled ONLY, cash = spent + still_committed + refunded; sell: quoted = sold + still_open + returned_unsold) with committed negative fixtures for phantom refunded buy units, a full buy sold before collection, and a full sell closed before proceeds collection. Capped/floored tax model + its 4 selftest fixtures and the asynchronous scheduler behavior preserved (owner decisions 2026-08-28). updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date summary: >- @@ -21,7 +21,11 @@ summary: >- global scheduler pass that acts only on dispatchable slots and never closes or advances unrelated ones; completed offers (full buys and full sells) land in the GE COLLECTION BOX and must pass through ge_collect + - ledger reconciliation before assets are reused or the slot closes; the + ledger reconciliation before assets are reused or the slot closes; + 'closed' is NOT a sink — the physical GE slot is released to free via + the archival transition `closed -> free` after the completed cycle's + ledger is reconciled + archived, so every slot runs flip after flip + through rotations and breaks (two-rotation reuse regression); the 4h buy-limit ledger (limit_used / first_fill_ts / limit_resets_at / remaining) is DISTINCT from offer expiry — a limit-window reset NEVER cancels an offer and cancellation NEVER renews the limit; ge_cancel @@ -85,12 +89,16 @@ best_practice: why: "A slot's offer fills on its own timeline (partial fills keep resting). The global scheduler pass acts only on dispatchable slots (free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> - requote) and leaves resting buy-open / sell-open / closed slots untouched — one - slot's cancel or close never advances an unrelated slot. A FULLY FILLED offer - never sells or closes directly: it first enters an explicit - buy-/sell-filled-awaiting-collect state because its assets sit in the GE - collection box until ge_collect (finding 2, governor 10390)." - source: "governor review comment 10323 + 10390 (2026-08-28); OSRS wiki — Grand Exchange / Bank (collection box); docs/action-grammar.md" + requote, closed -> free archival release) and leaves resting buy-open / + sell-open slots untouched — one slot's cancel or close never advances an + unrelated slot. A FULLY FILLED offer never sells or closes directly: it + first enters an explicit buy-/sell-filled-awaiting-collect state because + its assets sit in the GE collection box until ge_collect (finding 2, + governor 10390). A 'closed' slot is only ever transient: the completed + cycle's ledger is archived at close and the pass releases the physical + slot to free (closed -> free), so the same slot runs the next flip — + never one flip and done (governor 10471)." + source: "governor review comments 10323 + 10390 + 10471 (2026-08-28/29); OSRS wiki — Grand Exchange / Bank (collection box); docs/action-grammar.md" - name: keep a per-slot offer ledger; ge_cancel -> collection box -> ge_collect -> reconcile, never strand units why: "Every slot tracks its side-aware ledger: a BUY quote partitions units into filled + still_open + cancelled_unfilled with acquired items = filled ONLY (the @@ -176,7 +184,7 @@ slot_state_machine: sell-filled-awaiting-collect: [closed] sell-cancelled-awaiting-collect: [ready-to-requote, closed] ready-to-requote: [buy-open, sell-open, free] - closed: [] + closed: [free] # archival release — the physical slot is reusable for the next flip (governor 10471) slot_transition_whens: - "free -> buy-open: scheduler pass quotes a resting buy at the low edge (qty = min(remaining 4h limit, slot capital, capital available))" - "buy-open -> buy-filled-awaiting-collect: offer FULLY filled (filled == quoted) — the units sit in the GE COLLECTION BOX until ge_collect; the slot may NOT sell them directly (governor 10390 finding 2)" @@ -189,9 +197,10 @@ slot_state_machine: - "ready-to-sell -> sell-open: scheduler pass quotes a resting sell at the high edge for the units in hand" - "sell-open -> sell-filled-awaiting-collect: sell offer FULLY filled (sold == quoted) — the proceeds sit in the GE COLLECTION BOX until ge_collect; the slot may NOT close directly (governor 10390 finding 2)" - "sell-open -> sell-cancelled-awaiting-collect: quote_age_policy abort — scanned edge stale or strategy reallocation; ge_cancel: unsold units + proceeds received so far move to the collection box (never to inventory)" - - "sell-filled-awaiting-collect -> closed: ge_collect withdrawn the proceeds; ledger reconciled — slot freed" + - "sell-filled-awaiting-collect -> closed: ge_collect withdrawn the proceeds; ledger reconciled and ARCHIVED — the archived cycle record is immutable; the physical slot is released at the next dispatch by the closed-to-free archival transition (governor 10471)" - "sell-cancelled-awaiting-collect -> ready-to-requote: ge_collect withdrawn; requote the sell at the fresh high edge or the current band" - - "sell-cancelled-awaiting-collect -> closed: ge_collect withdrawn; slot closed (exit taken)" + - "sell-cancelled-awaiting-collect -> closed: ge_collect withdrawn; ledger archived, deliberately closed (exit taken) — the physical slot releases to free exactly like a completed sell (governor 10471)" + - "closed -> free: ARCHIVAL RELEASE — the completed cycle's ledger stays archived (immutable, conserved), and the physical GE slot returns to free to place the next flip's buy (two-rotation reuse regression)" - "ready-to-requote -> buy-open: requoted buy placed (buy_capacity_ok: limit_used + qty <= limit until limit_resets_at)" - "ready-to-requote -> sell-open: requoted sell placed at the fresh high edge" - "ready-to-requote -> free: slot abandoned and released" @@ -201,11 +210,15 @@ slot_state_machine: awaiting-collect / buy-cancelled-awaiting-collect / sell-filled-awaiting-collect / sell-cancelled-awaiting-collect -> ge_collect, ready-to-sell -> sell (ge_sell), ready-to-requote -> - requote. Resting buy-open / sell-open / closed slots produce NO action - and are never closed, advanced, re-quoted or touched by the pass. A - pass therefore advances only the slots whose sub-state is dispatchable, - and a completed offer always passes through its *-filled-awaiting- - collect state (ge_collect) before its assets are reused. + requote, closed -> free (archival release: the completed cycle's + ledger was archived at close; the pass returns the physical slot to + free so the next flip can begin). Resting buy-open / sell-open slots + produce NO action and are never closed, advanced, re-quoted or touched + by the pass. A pass therefore advances only the slots whose sub-state + is dispatchable, a completed offer always passes through its + *-filled-awaiting-collect state (ge_collect) before its assets are + reused, and a closed slot is only ever transient — released to free + rather than left to strand after one flip (governor 10471). quote_age_policy: >- Offer lifetime is INDEPENDENT of the 4h buy-limit ledger. Resting offers are re-checked at the 600 s cadence and kept resting while the @@ -249,14 +262,22 @@ slot_state_machine: gates: a buy-filled-awaiting-collect slot has NOTHING sold yet (a full buy cannot sell before ge_collect) and a sell-filled-awaiting-collect slot holds its proceeds in the box; a 'closed' slot has no uncollected - proceeds or cancelled/returned units. Committed validator fixtures — + proceeds or cancelled/returned units — it holds ONLY the archived + (immutable) cycle record, and the physical slot is released to free + via the closed -> free archival transition so the SAME slot can run + the next flip (governor 10471; two-rotation reuse regression: the + archived rotation-1 ledger survives rotation 2 bit-for-bit). + Committed validator fixtures — including the negative fixtures (phantom refunded buy units; a full buy sold before collection; a full sell closed before proceeds - collection) — prove the invariants and the scheduler dispatch. + collection; a 'closed' sink that strands the slot after one flip) — + prove the invariants and the scheduler dispatch. break_resume: >- Breaks are taken only when no slot is dispatchable: every slot is - resting (buy-open / sell-open), free, or collected (closed) — never - mid-collect, mid-place, or in an *-awaiting-collect state. Full state + resting (buy-open / sell-open) or free — never mid-collect, mid-place, + or in an *-awaiting-collect state. A closed slot is only ever + transient (archived and released to free by the pass), so a break + never catches the activity between archive and release; full state (offers server-side + the persisted per-slot record) survives the pause; resume by re-opening the GE, re-checking offers, re-running the scheduler pass and continuing each slot from its persisted sub-state. @@ -268,7 +289,7 @@ state_machine: terminal: [complete, failed] states: idle: - goal: "be logged in at a bank with a GE booth, capital >= floor, every slot's ledger closed (sub-state free or closed, all buckets zero)" + goal: "be logged in at a bank with a GE booth, capital >= floor, every slot's ledger closed (sub-state free — a closed slot is transient and released to free by the pass — all live buckets zero)" next: [scan, complete] scan: goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax (a3)" @@ -312,7 +333,7 @@ state_machine: when: "a slot filled and proceeds/results are ready to collect" to: collect - from: collect - when: "only resting or closed slots remain; cadence continues" + when: "only resting slots remain (closed slots are released to free by the same pass); cadence continues" to: wait - from: collect when: "session GP target reached" @@ -424,6 +445,7 @@ actions: withdraw: "from the GE collection box: bought (filled) items, refunded CASH, unsold units, sale proceeds — per slot" reconcile: "SIDE-AWARE per-slot ledger conservation check BEFORE any slot reuse — buy: quoted == filled + still_open + cancelled_unfilled and cash_quoted == spent + still_committed + refunded; sell: quoted == sold + still_open + returned_unsold" decide: "buy-filled/buy-cancelled -> ready-to-sell (sell edge positive) | ready-to-requote (buy margin positive and remaining limit > 0) | free (release); sell-filled/sell-cancelled -> closed | ready-to-requote" + archive: "at 'closed' the completed cycle's ledger is reconciled and ARCHIVED (immutable); the pass then releases the physical slot to free (closed -> free) so the SAME slot places the next flip's buy — the two-rotation reuse regression proves the archived record survives rotation 2 (governor 10471)" log: "RESULT item, qty, net gp per slot (floored+capped tax)" on_fail: alert anti_ban: @@ -435,7 +457,7 @@ actions: target: "current location" params: duration: "[60, 240] seconds of idle AFK per break (matches run_control.break.for)" - safe: "only when every slot is resting (buy-open / sell-open) or collected (closed) — resting offers persist server-side at their quoted edges and the per-slot record persists locally; never break mid-collect (coins in hand) or mid-place (offer half-configured)" + safe: "only when every slot is resting (buy-open / sell-open) or free — resting offers persist server-side at their quoted edges and the per-slot record persists locally; a closed slot is transient (archived + released to free by the pass), so breaks never catch the activity between archive and release; never break mid-collect (coins in hand) or mid-place (offer half-configured)" resume: "re-open GE (a2), re-check offer state (a6), re-run the scheduler pass (schedule), continue each slot from its persisted sub-state (a4/a7/a8/a9)" on_fail: skip @@ -496,8 +518,12 @@ run_control: # # t5 pass 4: iron sell-filled-awaiting-collect -> ge_collect (a9): withdraw # 1,570,000; tax floor(157 x 0.02) = 3/item -> net (157 - 3 - 145) x 10,000 - # = 90,000. proceeds_in_box -> cash_collected; ledger reconciled; - # sub-state -> closed, slot freed. Conservation: quoted 10,000 == sold 10,000. + # = 90,000. proceeds_in_box -> cash_collected; ledger reconciled and + # ARCHIVED (the immutable cycle record: quoted 10,000 == sold 10,000); + # sub-state -> closed, and the SAME pass's archival dispatch releases the + # physical slot: closed -> free (governor 10471). The slot can place the + # next flip's buy immediately — it is never stranded after one flip. + # Conservation: quoted 10,000 == sold 10,000, zero proceeds left uncollected. # # t6 LIMIT RESET, NOT AN OFFER EVENT (finding 1): arrowtips reaches # limit_resets_at with 6,000 still open on the resting offer. The window @@ -516,7 +542,19 @@ run_control: # t8 pass 6: arrowtips ready-to-sell -> sell-open 10,000 @ 244 -> full fill -> # sell-filled-awaiting-collect -> pass 7 ge_collect: proceeds 2,440,000; # tax floor(244 x 0.02) = 4/item -> net (244 - 4 - 223) x 10,000 = 170,000 - # (matches the 170,000 4h/slot table row) -> closed. + # (matches the 170,000 4h/slot table row) -> closed (ledger archived) -> + # closed -> free: the arrowtips physical slot is released for the next flip. + # + # t8b SECOND ROTATION in the SAME physical slot (governor 10471 regression): + # the iron slot released at t5 and the arrowtips slot released at t8 are + # free and dispatchable again — pass 8 quotes a NEW arrowtips buy in the + # iron slot (free -> buy-open, a4; buy_capacity_ok re-checked against the + # CURRENT window's limit_ledger). Rotation 1's archived ledgers (iron + # closed record, arrowtips closed record) remain archived and bit-for-bit + # untouched — no loss, duplication, or mutation — while the live ledger is + # a fresh rotation-2 record (quoted 10,000, cash_quoted 2,230,000, + # still_open 10,000). This is the committed slot_reuse regression: a slot + # completes one flip, is released, and runs the next one. # # t9 STALE-EDGE recovery (cosmic, finding 1 cancel trigger): at a cadence check # the cosmic LOW edge has risen 122 -> 126: net 128 - floor(128 x 0.02)=2 - @@ -566,6 +604,7 @@ risk: - "ge_cancel NEVER returns items/coins directly to inventory: everything lands in the GE collection box. BUY cancels return filled items + refunded CASH (the unfilled quantity is cash, never 'refunded units'); SELL cancels return unsold units + proceeds received. Reuse a slot only after ge_collect + side-aware ledger reconciliation (conservation invariants)." - "The 4h buy limit is independent of offer life: cancelling does not renew it and the reset does not cancel offers. A slot at limit_used == limit cannot place a new/requoted BUY until limit_resets_at (first_fill_ts + 4h); its resting offer is unaffected." - "Breaks are safe only when no slot is dispatchable (resting offers or post-collect points, see a10 and break_resume); state = GE offers + the persisted per-slot record, which survive the pause." + - "A completed cycle NEVER strands a slot: after ge_collect the ledger is reconciled + archived at 'closed' and the scheduler pass releases the physical slot to free (closed -> free archival release), so every slot keeps running flip after flip. Never model a slot as one-flip-then-done (governor 10471)." sources: - "OSRS wiki — Grand Exchange (2% convenience fee since 2025-05-29, capped 5M/item, sub-50gp effectively untaxed, tax-exempt low-value items; buying rate restricted per 4h window starting on the FIRST purchase of the window; connected limits; verified 2026-08-27)" @@ -576,7 +615,8 @@ sources: - "prices.runescape.wiki/api/v1/osrs/latest (high/low price band snapshot 2026-08-27)" - "governor review comment 10323 on PR #7 (2026-08-28): per-slot asynchronous sub-state machine + scheduler pass (finding 3); ge_cancel -> collection box + ge_collect + reconciliation (finding 2); buy-limit ledger distinct from offer expiry, cancellation never renews (finding 1); conservation invariants (finding 4); typed ceiling vs documented realized scenarios (finding 5); explicit scheduler iteration + persisted break/resume, no slot downgrade (finding 6)" - "governor review comment 10390 on PR #7 (2026-08-28): the 4h limit reset never cancels offers — resting offers remain open while the margin is positive (finding 1); filled offers must pass through explicit *-filled-awaiting-collect states and ge_collect before reuse/close (finding 2); side-aware ledgers kill the phantom-units error (finding 3); side-aware conservation with negative fixtures, including sale-before-collection and close-before-proceeds-collection (finding 4)" - - "owner decisions 2026-08-28 (guidance comments 10264, 10271, 10334): capped/floored global tax formula; complete lifecycle contract; authorisation to keep repairing this same branch/PR" + - "governor review comment 10471 on PR #7 (2026-08-29): a completed flip must NOT permanently strand the GE slot — after proceeds are collected and the completed ledger is archived/reconciled the physical slot returns to free via the archival transition closed -> free (also for a deliberately closed cancelled sell); committed two-rotation regression (buy/collect/sell/collect -> archive -> second buy in the same slot) with the archived ledger preserved" + - "owner authorisations 2026-08-28/29 (guidance comments 10264, 10271, 10334, 10527/10528): capped/floored global tax formula; complete lifecycle contract; continued full-lifecycle repair on the same branch/PR (retry-budget superseded)" - "docs/action-grammar.md (ge_cancel/ge_collect rows, 2026-08-28) + schemas/entry.schema.json (slot_state_machine, ceiling_gp_per_hour)" verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27 diff --git a/docs/action-grammar.md b/docs/action-grammar.md index ef23cd6..09cd594 100644 --- a/docs/action-grammar.md +++ b/docs/action-grammar.md @@ -70,7 +70,7 @@ Legend for the mapping table: | `ge_sell` | sell `qty` of `item`, confirm | — | — | — | | `ge_cancel` | abort an offer (quote_age_policy only — stale edge or strategy reallocation, never a 4h limit-window reset): BUY — filled items + refunded CASH (unspent remainder) enter the GE COLLECTION BOX; SELL — unsold units + proceeds received so far enter the box; NEVER directly to inventory; the 4h buy limit is NOT renewed | — | — | — | | `ge_offers_done` | predicate: no pending buy/sell offers | — | — | — | -| `ge_collect` | withdraw from the GE collection box: bought (filled) items, refunded CASH, unsold units, sale proceeds; reconcile the SIDE-AWARE per-slot ledger (buy: quoted = filled + still_open + cancelled_unfilled; sell: quoted = sold + still_open + returned_unsold) before any slot reuse | — | — | — | +| `ge_collect` | withdraw from the GE collection box: bought (filled) items, refunded CASH, unsold units, sale proceeds; reconcile the SIDE-AWARE per-slot ledger (buy: quoted = filled + still_open + cancelled_unfilled; sell: quoted = sold + still_open + returned_unsold) before any slot reuse; a completed cycle closes with the ledger reconciled + ARCHIVED and the same pass releases the physical slot to free (`closed -> free` archival transition), so the slot immediately runs the next flip (governor 10471) | — | — | — | | `ge_check_price` | read current buy/sell price of an item | — | — | — | ## F. Combat diff --git a/tools/validate.py b/tools/validate.py index c11d0a4..6b8dab8 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -38,6 +38,13 @@ Runs: refunded units; sell: quoted = sold + still_open + returned_unsold) with phase gates (a full buy cannot sell before collection, a full sell cannot close before proceeds collection); + closed has ONE outgoing transition — the archival release + ``closed -> free`` — so a completed cycle's physical GE slot + returns to free for the next flip instead of stranding forever + (governor 10471); the two-rotation reuse regression proves a slot + completes buy/collect/sell/collect, archives the first ledger, and + places a second buy in the same slot without loss, duplication, or + mutation of the archived record; economy.ceiling_gp_per_hour is the TYPED economic ceiling and 0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling. - Regression fixtures (--selftest): a fractional-tax flipping @@ -133,7 +140,7 @@ SLOT_SUB_STATES = [ "sell-filled-awaiting-collect", # sell FULLY filled — proceeds sit in the box until ge_collect "sell-cancelled-awaiting-collect", # aborted sell: proceeds + unsold units in the box "ready-to-requote", # collected units/refunds; requote or abandon - "closed", # slot finished, ledger reconciled, nothing held + "closed", # completed cycle: ledger reconciled + ARCHIVED; the physical slot is released to free via the closed -> free archival transition (governor 10471) ] # Dispatch table: the pure action a slot in a given sub-state needs this pass. SLOT_DISPATCH = { @@ -146,7 +153,7 @@ SLOT_DISPATCH = { "sell-filled-awaiting-collect": "collect", "sell-cancelled-awaiting-collect": "collect", "ready-to-requote": "requote", - "closed": None, + "closed": "release", # archival release: closed -> free (governor 10471) } @@ -155,7 +162,12 @@ def scheduler_dispatch(sub_state): A None (resting) slot is left completely untouched — the pass must not close, advance, or mutate it. This is the property the worked-example - regression proves. + regression proves. A 'closed' slot is NOT resting: its completed + cycle's ledger was archived at ge_collect time and the pass releases + the physical slot to free ('release' = the closed -> free archival + transition), so the slot can run another flip instead of stranding + forever (governor 10471). Resting states are buy-open / sell-open + only. """ return SLOT_DISPATCH.get(sub_state) @@ -334,6 +346,120 @@ def buy_capacity_ok(limit_used, limit, qty_requested): return limit_used + qty_requested <= limit +# Deterministic ledger frames for ONE physical GE slot across TWO full +# flips (governor 10471): the fixture walks a complete buy/collect/ +# sell/collect cycle, archives the first ledger at `closed`, releases the +# physical slot to `free`, and opens a SECOND buy in the SAME slot. Every +# frame must satisfy both conservation checks; the archived first-cycle +# record must survive rotation 2 untouched (no loss, duplication, or +# mutation). Prices: Rune arrowtips buy 223 / sell 244 / tax +# min(floor(244*0.02), 5M) = 4 / proceeds 2,440,000 (snapshot 2026-08-27, +# matches the worked example). +_CYCLE_ITEM = "Rune arrowtips" + +_CYCLE_FRAMES = [ + # (sub_state, ledger) — ledger is side-aware and conservation-clean. + ("free", {"side": "buy", "quoted": {}, "filled": {}, "still_open": {}, + "cancelled_unfilled": {}, "cash_quoted": 0, "spent": 0, + "still_committed": 0, "refunded": 0}), + ("buy-open", {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000}, + "filled": {}, "still_open": {_CYCLE_ITEM: 10_000}, + "cancelled_unfilled": {}, "cash_quoted": 2_230_000, + "spent": 0, "still_committed": 2_230_000, "refunded": 0}), + ("buy-filled-awaiting-collect", + {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000}, + "filled": {_CYCLE_ITEM: 10_000}, "still_open": {}, + "cancelled_unfilled": {}, "cash_quoted": 2_230_000, + "spent": 2_230_000, "still_committed": 0, "refunded": 0}), + ("ready-to-sell", + {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000}, + "filled": {_CYCLE_ITEM: 10_000}, "still_open": {}, + "cancelled_unfilled": {}, "cash_quoted": 2_230_000, + "spent": 2_230_000, "still_committed": 0, "refunded": 0}), + ("sell-open", {"side": "sell", "quoted": {_CYCLE_ITEM: 10_000}, + "sold": {}, "still_open": {_CYCLE_ITEM: 10_000}, + "returned_unsold": {}, "proceeds_in_box": 0, + "cash_collected": 0}), + ("sell-filled-awaiting-collect", + {"side": "sell", "quoted": {_CYCLE_ITEM: 10_000}, + "sold": {_CYCLE_ITEM: 10_000}, "still_open": {}, + "returned_unsold": {}, "proceeds_in_box": 2_440_000, + "cash_collected": 0}), + ("closed", {"side": "sell", "quoted": {_CYCLE_ITEM: 10_000}, + "sold": {_CYCLE_ITEM: 10_000}, "still_open": {}, + "returned_unsold": {}, "proceeds_in_box": 0, + "cash_collected": 2_440_000}), # ARCHIVED here + ("free", {"side": "buy", "quoted": {}, "filled": {}, "still_open": {}, + "cancelled_unfilled": {}, "cash_quoted": 0, "spent": 0, + "still_committed": 0, "refunded": 0}), # released: physical slot reusable + # rotation 2: the SAME physical slot opens a second buy (ledger reset; + # the archived rotation-1 record above must remain untouched). + ("buy-open", {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000}, + "filled": {}, "still_open": {_CYCLE_ITEM: 10_000}, + "cancelled_unfilled": {}, "cash_quoted": 2_230_000, + "spent": 0, "still_committed": 2_230_000, "refunded": 0}), +] + +# The canonical complete flip cycle: rotation 1 (buy/collect/sell/collect) +# plus the archival release and the start of rotation 2. +_CYCLE_PATH = [f[0] for f in _CYCLE_FRAMES] + +# Index of the `closed` frame (archived ledger) and the frames around it. +_CLOSED_IDX = _CYCLE_PATH.index("closed") + + +def slot_reuse_violations(transitions): + """Two-rotation slot-reuse regression (governor 10471). + + Walks ``_CYCLE_PATH`` through the entry's ``slot_transitions`` and + validates every ledger frame against both conservation gates. A + ``closed`` state with no outgoing transition (or one that does not + release to ``free``) strands the physical GE slot after one flip — + the exact defect this regression exists to forbid. Errors are + returned as a list of strings. + """ + out = [] + for a, b in zip(_CYCLE_PATH, _CYCLE_PATH[1:]): + if b not in (transitions.get(a) or []): + out.append( + f"slot cycle: transition {a} -> {b} is missing — a completed " + f"flip must end by releasing the physical slot to free " + f"(archival release closed -> free), otherwise the slot " + f"performs one flip and then becomes permanently unusable") + for state, record in _CYCLE_FRAMES: + rec = dict(record, sub_state=state) + con = conservation_violations(rec) + if con: + out.append(f"slot cycle: conservation fails at '{state}': {con}") + ph = slot_phase_violations(rec) + if ph: + out.append(f"slot cycle: phase gate fails at '{state}': {ph}") + return out + + +def archived_ledger_preserved_after_second_buy(): + """The rotation-1 archived ledger survives rotation 2 intact. + + ``closed`` archives a deep copy of the completed cycle's ledger; when + the same physical slot is released to free and opens a second buy, the + archived record must be bit-for-bit unchanged (no loss, duplication, + or mutation) while the live ledger is a fresh empty record. + """ + archived = json.loads(json.dumps(_CYCLE_FRAMES[_CLOSED_IDX][1])) + archived_state = _CYCLE_FRAMES[_CLOSED_IDX][0] + live = {k: (dict(v) if isinstance(v, dict) else v) + for k, v in _CYCLE_FRAMES[-1][1].items()} # rotation-2 buy-open ledger + if archived != _CYCLE_FRAMES[_CLOSED_IDX][1]: + return ["slot cycle: archived rotation-1 ledger was MUTATED by rotation 2"] + if archived_state != "closed": + return [f"slot cycle: unexpected archived state {archived_state!r}"] + # The live ledger at rotation-2 buy-open must be a fresh record, not a + # copy of the archived one: quoted cash/units belong to rotation 2. + if live.get("cash_quoted") != 2_230_000 or live.get("proceeds_in_box"): + return ["slot cycle: rotation-2 live ledger is not a fresh record"] + return [] + + def ge_lifecycle_errors(rel, data): """GE-flip lifecycle gates (flipping/merchanting entries only): @@ -342,6 +468,9 @@ def ge_lifecycle_errors(rel, data): - the slot model is per-slot asynchronous: slot_state_machine must declare exactly the canonical sub-states, valid transitions, a scheduler_iteration, persisted_state, conservation and break_resume; + 'closed' is NOT a sink: it must release the physical slot via the + archival transition closed -> free, and the committed two-rotation + reuse regression (slot_reuse_violations) must pass; - rates: ceiling_gp_per_hour is the typed economic ceiling and 0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling. """ @@ -392,11 +521,16 @@ def ge_lifecycle_errors(rel, data): errors.append(f"{rel}: slot transition '{from_s}' -> unknown " f"sub-state '{to_s}'") for state in SLOT_SUB_STATES: - if state != "closed" and not trans.get(state): + if not trans.get(state): errors.append(f"{rel}: slot sub-state '{state}' has no transitions " - f"(closed is the only sink)") - if trans.get("closed"): - errors.append(f"{rel}: 'closed' is a sink — it must have no outgoing transitions") + f"— every state must be able to advance; a closed " + f"slot must release the physical slot via the " + f"archival closed -> free transition (governor 10471)") + if list(trans.get("closed") or []) != ["free"]: + errors.append(f"{rel}: 'closed' must have exactly the archival release " + f"['free'] — a closed slot with no outgoing transition " + f"performs one flip and then becomes permanently " + f"unusable (governor 10471)") whens = [str(w) for w in ssm.get("slot_transition_whens", []) or []] for w in whens: arrow = w.split("->") @@ -408,6 +542,10 @@ def ge_lifecycle_errors(rel, data): "limit_ledger", "conservation", "break_resume"): if not str(ssm.get(field, "")).strip(): errors.append(f"{rel}: slot_state_machine.{field} must be declared") + # Two-rotation reuse regression (governor 10471): every flipping entry's + # encoded machine must complete buy/collect/sell/collect, release the + # physical slot to free, and support a second buy in the same slot. + errors += [f"{rel}: {e}" for e in slot_reuse_violations(trans)] return errors @@ -574,7 +712,7 @@ def run_selftest(verbose=False): "sell-filled-awaiting-collect": ["closed"], "sell-cancelled-awaiting-collect": ["ready-to-requote", "closed"], "ready-to-requote": ["buy-open", "sell-open", "free"], - "closed": [], + "closed": ["free"], # archival release: physical slot reusable (governor 10471) }, "slot_transition_whens": [ "free -> buy-open: quote placed", @@ -588,9 +726,10 @@ def run_selftest(verbose=False): "ready-to-sell -> sell-open: sell offer placed", "sell-open -> sell-filled-awaiting-collect: sell FULLY filled — proceeds sit in the box until ge_collect", "sell-open -> sell-cancelled-awaiting-collect: abort on stale edge or strategy reallocation", - "sell-filled-awaiting-collect -> closed: proceeds collected and ledger reconciled", + "sell-filled-awaiting-collect -> closed: proceeds collected and ledger reconciled + ARCHIVED", "sell-cancelled-awaiting-collect -> ready-to-requote: collected, requote", - "sell-cancelled-awaiting-collect -> closed: collected, slot closed", + "sell-cancelled-awaiting-collect -> closed: collected, ledger archived, slot closed (exit taken)", + "closed -> free: archival release — completed cycle's ledger stays archived; the physical GE slot is reusable for the next flip", "ready-to-requote -> buy-open: requoted buy", "ready-to-requote -> sell-open: requoted sell", "ready-to-requote -> free: abandoned", @@ -630,6 +769,26 @@ def run_selftest(verbose=False): slot_transition_whens=ssm_ok["slot_transition_whens"][:4]))) check("flip lifecycle: a sub-state without transitions (ready-to-sell missing) is rejected", any("no transitions" in e for e in errs)) + errs = ge_lifecycle_errors("bad5.yaml", dict(flip_ok, slot_state_machine=dict( + ssm_ok, slot_transitions=dict(ssm_ok["slot_transitions"], closed=[])))) + check("flip lifecycle: a 'closed' sink (no archival release) is rejected — the physical slot would perform one flip and strand", + any("archival release" in e for e in errs)) + + # --- slot reuse through rotations (governor 10471) --- + check("slot cycle: the archival release closed -> free is a dispatched scheduler action", + scheduler_dispatch("closed") == "release") + check("slot reuse: one full buy/collect/sell/collect lifecycle completes and the SAME physical slot opens a second buy (two-rotation walk passes)", + slot_reuse_violations(ssm_ok["slot_transitions"]) == []) + check("slot reuse: the rotation-1 archived ledger survives rotation 2 intact (no loss, duplication, or mutation)", + archived_ledger_preserved_after_second_buy() == []) + sink_trans = dict(ssm_ok["slot_transitions"], closed=[]) + check("slot reuse: a machine whose closed has no outgoing transition FAILS the two-rotation regression (one flip, then permanently unusable)", + any("permanently unusable" in v + for v in slot_reuse_violations(sink_trans))) + closed_no_free = dict(ssm_ok["slot_transitions"], closed=["ready-to-sell"]) + check("slot reuse: a closed that does not release to free FAILS the two-rotation regression", + any("closed -> free" in v + for v in slot_reuse_violations(closed_no_free))) return errors