From 02b366c7ef40e0082cc173b5f6960096c7253067 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 04:01:20 -0500 Subject: [PATCH] flip-candidates: per-slot async sub-state lifecycle (governor 10323 bounce-fix 5) - slot_state_machine: canonical 8 sub-states (free/buy-open/ buy-cancelled-awaiting-collect/ready-to-sell/sell-open/ sell-cancelled-awaiting-collect/ready-to-requote/closed) + global scheduler pass that never advances unrelated slots - ge_cancel moves filled items/refunds/unsold units/coins to the GE collection box; ge_collect + ledger reconciliation before reuse - 4h buy-limit ledger (limit_used/first_fill_ts/limit_resets_at) distinct from offer expiry; cancellation never renews the limit - per-slot conservation invariants w/ committed validator fixtures - economy.ceiling_gp_per_hour typed ceiling, gp_per_hour_low/high as documented realized scenario bounds (no uncited multipliers) - capped/floored 2% tax + 4 fixtures preserved (owner decision) - action-grammar: ge_cancel/ge_collect rows corrected --- codex/economy/flip-candidates.yaml | 414 +++++++++++++++++++---------- docs/action-grammar.md | 4 +- schemas/entry.schema.json | 34 +++ tools/validate.py | 346 +++++++++++++++++++++++- 4 files changed, 643 insertions(+), 155 deletions(-) diff --git a/codex/economy/flip-candidates.yaml b/codex/economy/flip-candidates.yaml index d65a375..bf89878 100644 --- a/codex/economy/flip-candidates.yaml +++ b/codex/economy/flip-candidates.yaml @@ -3,22 +3,31 @@ title: GE Flipping (margin trading) domain: economy tags: [flipping, merchanting, ge, money-making, hybrid] status: verified -version: 3 # 3: bounce-fix 4 2026-08-28 (owner lifecycle addendum) — complete runnable flow: partial fills, ge_cancel + requote recovery, per-slot ledger, break/resume path, lifecycle-qualified rates, worked example pinned; ge_cancel added to the action grammar + schema +version: 4 # 4: bounce-fix 5 2026-08-28 (governor comment 10323) — per-slot asynchronous sub-state machine + global scheduler pass; ge_cancel moves everything to the GE collection box (ge_collect + ledger reconciliation before reuse); 4h buy-limit ledger distinct from offer expiry (cancellation never renews the limit); typed ceiling_gp_per_hour vs documented realized scenarios; conservation invariants; breaks with persisted per-slot state. Capped/floored tax model and its 4 selftest fixtures preserved (owner decision 2026-08-28). updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date -summary: > +summary: >- Buy-and-sell Grand Exchange margin flipping: place resting buy offers at the low edge of an item's price band and resting sell offers at the high edge, harvesting the spread on high-volume items. There are no skill or quest requirements — capital breadth and slot discipline are the whole game. Members accounts run 8 GE slots over the full candidate set; F2P accounts get 3 slots and F2P-tradeable rows only (separate F2P path in - profit_drivers). Lifecycle (owner addendum 2026-08-28): the entry models - the complete runnable flow — capital allocation, per-slot quotes with a - quoted/filled/sold/collected ledger, partial fills, ge_cancel + - requote recovery on window lapse or stale prices, collection and - reconciliation, breaks on resting offers, and explicit stop conditions — - with a pinned worked example and realized (not idealised) rates. + profit_drivers). Model (governor 10323, 2026-08-28): every GE slot is an + INDEPENDENT asynchronous sub-state machine (free / buy-open / + buy-cancelled-awaiting-collect / ready-to-sell / sell-open / + sell-cancelled-awaiting-collect / ready-to-requote / closed) driven by a + global scheduler pass that acts only on dispatchable slots and never + closes or advances unrelated ones; the 4h buy-limit ledger (limit_used / + first_fill_ts / limit_resets_at / remaining) is DISTINCT from offer + expiry and cancellation NEVER renews the limit; ge_cancel moves filled + items, refunds, unsold units and unspent coins to the GE collection box, + and a separate ge_collect withdraws and reconciles the per-slot ledger + before any reuse; every snapshot satisfies per-slot conservation + (quoted == still_offered + collected + refunded + sold; cash likewise); + economy.ceiling_gp_per_hour is the TYPED economic ceiling and + gp_per_hour_low/high are documented realized scenario bounds. Pinned + worked example + committed validator fixtures prove the model. accounts: membership: hybrid # members: 8 slots/full set; F2P: 3 slots/F2P rows (see economy) @@ -56,21 +65,32 @@ best_practice: - name: pace quotes inside the 4h buy-limit window, watch connected limits why: "The buying rate is restricted to a set quantity every 4 hours per player, and some items share a connected limit with related items — treat those as one bucket - when filling slots." + when filling slots. The window starts when the FIRST unit of the window is bought + (first_fill_ts) and resets 4h later; cancelling an offer does NOT renew or restart + it." source: "OSRS wiki — Grand Exchange (Buy limits; Grand Exchange/Buying limits)" - name: run the path that matches the account's slot set why: "Members get eight GE slots and may trade any row; F2P accounts get three slots and may only purchase free-to-play rows. The modeled economics below assume the members set — a hybrid account must swap to the 3-slot F2P path (Iron bar / Cosmic rune / Silver ore) instead of chasing members-only rows it cannot buy." - source: "OSRS wiki — Grand Exchange (Members get eight Grand Exchange slots ... free players are limited to three. Additionally, free players may only purchase free-to-play items, and not members' items)" - - name: keep a per-slot offer ledger; cancel-and-requote on lapse, never strand units + source: "OSRS wiki — Grand Exchange (Members get eight Grand Exchange slots ... free players are limited to three. Additionally, free players may only purchase members' items)" + - name: treat each slot as its own asynchronous machine; the scheduler never touches resting slots + 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." + source: "governor review comment 10323 (2026-08-28), finding 3; 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 quoted qty -> filled qty -> sold qty -> collected proceeds. - A partially-filled offer keeps resting with its acquired units attached; on the 4h - window lapse or a stale price the slot is ge_cancel'ed — acquired units return to - inventory, unspent cash refunds — then re-quoted at the fresh edge (buy) or exited - at the current high edge (sell). No items, coins, or GE slots are ever stranded." - source: "OSRS wiki — Grand Exchange (offer mechanics; 4h window); docs/action-grammar.md (ge_cancel, added 2026-08-28)" + ge_cancel moves filled items, refunds, unsold units and unspent coins to the GE + COLLECTION BOX (never directly to inventory); a separate ge_collect withdraws + them, and the ledger is reconciled (conservation: quoted == still_offered + + collected + refunded + sold, cash likewise) before the slot is reused. The 4h + window lapse or a stale price triggers ge_cancel; the fresh-edge requote happens + only after collection. No items, coins, or GE slots are ever stranded." + source: "OSRS wiki — Grand Exchange (offer mechanics; 4h window); governor review comment 10323 finding 2; docs/action-grammar.md (ge_cancel / ge_collect)" economy: # Model (documented, reproducible from the snapshot): @@ -88,24 +108,28 @@ economy: # members flags cross-checked against the wiki item pages (all agree). Top 8 # by 4h/slot below in profit_drivers. # - # Lifecycle qualification (owner addendum 2026-08-28): the 4h figures are - # an economic CEILING — ideal full fills of every slot with zero overhead. - # The realized operating range is: - # realized gp/h = ceiling x fill_factor x duty_cycle, where - # fill_factor ~0.5-0.9 (partial fills, cancels, requotes reduce the - # filled volume) and duty_cycle ~0.90-0.95 (scan + place + collect - # ~3 min per ~40-min rotation; breaks every 6-10 rotations). - # Example midpoint: 149,500 x 0.70 x 0.92 ≈ 96,300 gp/h. - # gp_per_hour_low (74,750) = 50% of the ceiling — a conservative realized - # lower bound; gp_per_hour_high (149,500) = the named ceiling, never - # presented as the achieved runtime rate. + # Lifetime qualification (governor 10323 finding 5, 2026-08-28): + # ceiling_gp_per_hour (149,500) is the TYPED ECONOMIC CEILING: ideal full + # fills of every slot, zero overhead — never presented as an achieved + # runtime rate. gp_per_hour_low/high are DOCUMENTED REALIZED SCENARIO + # BOUNDS (not uncited multipliers), each with explicit arithmetic in + # profit_drivers: + # high 138,000 = 149,500 x 37/40 — 'resting-fill' scenario: all 8 slots + # full-fill at their quoted edges; each 40-min rotation is 37 min of + # resting waits + 3 min of scan/place/collect actions. + # low 74,750 = 149,500 x 0.50 — 'requote-heavy' scenario: every 4h + # window's volume completes only after one ge_cancel + requote cycle + # (the no-fill remainder is refunded to the collection box and + # re-quoted on the next window), halving the window's filled volume. + # Bounds nest: 0 <= 74,750 <= 138,000 <= 149,500 (validator-enforced). item: Rune arrowtips # anchor: top candidate by modeled 4h/slot (members) item_id: 44 cost_per_cycle: 223 # buy @ low edge per unit (snapshot 2026-08-27) income_per_cycle: 244 # sell @ high edge per unit, gross (snapshot 2026-08-27) net_per_cycle: 17 # 244 - min(floor(244*0.02), 5M) - 223 = 244 - 4 - 223 (floored, capped tax) - gp_per_hour_low: 74750 # realized lower bound = 50% of the ceiling: partial fills, cancels/requotes, collection trips, breaks - gp_per_hour_high: 149500 # 4h buy-limit CEILING: sum(4h/slot) / 4 (8 members slots, ideal full fills, zero overhead) — qualified above + ceiling_gp_per_hour: 149500 # TYPED economic ceiling: sum of the 8 slots' 4h/slot (= 598,000) / 4h — ideal full fills, zero overhead (see profit_drivers table) + gp_per_hour_low: 74750 # realized scenario bound 'requote-heavy' = 149,500 x 0.50 (documented in profit_drivers) + gp_per_hour_high: 138000 # realized scenario bound 'resting-fill' = 149,500 x 37/40 (documented in profit_drivers) xp_per_hour: 0 # flipping awards no XP supplies: - item: Rune arrowtips # canonical wiki names per action-grammar conventions @@ -126,36 +150,103 @@ economy: - "Members path — snapshot 2026-08-27 top 8 by modeled 4h/slot (floored+capped 2% tax): Rune arrowtips 170,000 (M, buy 223 / sell 244 / limit 10000); Iron bar 90,000 (F2P, 145/157/10000); Cosmic rune 72,000 (F2P, 122/128/18000); Magic logs 72,000 (M, 772/793/12000); Silver ore 65,000 (F2P, 73/79/13000); Mud rune 54,000 (M, 96/101/18000); Raw swordfish 45,000 (F2P, 206/213/15000); Raw lobster 30,000 (F2P, 106/110/15000) — total 598,000 per 4h = 149,500 gp/h CEILING on 8 slots" - "F2P path — 3 slots, F2P-tradeable rows only: Iron bar 90,000 + Cosmic rune 72,000 + Silver ore 65,000 = 227,000 per 4h = 56,750 gp/h ceiling (alternates: Raw swordfish 45,000, Raw lobster 30,000)" - "Post-2%-fee reality: 11 of 21 classic staples are net-positive at band edges for members; sub-2%-spread items (law/chaos/blood runes, dragon bones, adamantite/mithril bars) need sub-low buys or above-high sells" - - "Realized vs ceiling (work the full lifecycle): realized gp/h = ceiling x fill_factor (~0.5-0.9, partials/cancels/requotes) x duty_cycle (~0.90-0.95, scan/place/collect + breaks) — e.g. 149,500 x 0.70 x 0.92 ~ 96,300 gp/h midpoint; do not quote the ceiling as an achieved rate" + - "Realized bounds are DOCUMENTED SCENARIOS with explicit arithmetic (governor 10323 finding 5 — no uncited fill/duty multipliers): HIGH 'resting-fill' 138,000 gp/h is the ceiling scaled by the rotation duty of 37 resting minutes per 40-minute rotation (149,500 x 37/40 = 138,287.5 -> 138,000): scan/place/collect actions cost ~3 min per rotation at the 600s quote-check cadence; LOW 'requote-heavy' 74,750 gp/h assumes every 4h window completes only after one ge_cancel + requote cycle, so the no-fill remainder (refunded to the collection box and re-quoted on the next window) halves the filled volume (149,500 x 0.50). The ceiling 149,500 itself is an economic bound, never an achieved rate." + +slot_state_machine: + sub_states: [free, buy-open, buy-cancelled-awaiting-collect, ready-to-sell, sell-open, sell-cancelled-awaiting-collect, ready-to-requote, closed] + slot_transitions: + free: [buy-open] + buy-open: [ready-to-sell, buy-cancelled-awaiting-collect] + buy-cancelled-awaiting-collect: [ready-to-sell, ready-to-requote, free] + ready-to-sell: [sell-open] + sell-open: [closed, sell-cancelled-awaiting-collect] + sell-cancelled-awaiting-collect: [ready-to-requote, closed] + ready-to-requote: [buy-open, sell-open, free] + closed: [] + 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 -> ready-to-sell: offer FULLY filled (filled_qty == quoted qty) — units in hand, dispatch sells this pass" + - "buy-open -> buy-cancelled-awaiting-collect: 4h window lapse, or scanned edge stale (margin no longer positive) — ge_cancel: filled items + refunded units + unspent coins ALL move to the GE collection box; the buy limit is NOT renewed by the cancel" + - "buy-cancelled-awaiting-collect -> ready-to-sell: ge_collect withdrawn units to inventory; sell edge positive — sell the units in hand" + - "buy-cancelled-awaiting-collect -> ready-to-requote: ge_collect withdrawn; buy margin still positive AND remaining limit > 0 — requote the unfilled balance at the fresh low edge" + - "buy-cancelled-awaiting-collect -> free: ge_collect withdrawn; slot released (margin gone or capital reallocated)" + - "ready-to-sell -> sell-open: scheduler pass quotes a resting sell at the high edge for the units in hand" + - "sell-open -> closed: sell offer fully filled, proceeds collected, ledger reconciled — slot freed" + - "sell-open -> sell-cancelled-awaiting-collect: 4h window lapse or stale edge — ge_cancel: unsold units return to the GE collection box (never to inventory)" + - "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)" + - "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" + scheduler_iteration: >- + One pass over every slot exactly once, at the 600 s quote-check cadence. + Only dispatchable slots act: free -> quote (ge_buy), buy-cancelled- + 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. + quote_age_policy: >- + INDEPENDENT of the 4h buy-limit ledger: resting offers are re-checked at + the 600 s cadence; an offer is aborted ONLY on a stale band edge + (scanned margin no longer positive) or the 4h window lapse — never + merely because it has been resting. An offer that still clears the + scanned margin keeps resting until it fills or the window lapses. + persisted_state: >- + Per-slot record persisted between passes and across breaks: sub-state, + item, quoted qty, still_offered qty, collected qty, refunded qty, sold + qty, cash_quoted, still_offered_cash, collected_cash, refunded_cash, + limit_used, first_fill_ts, limit_resets_at, quote placed_at, last + check_ts. The GE offers themselves persist server-side; the record is + the agent's copy of truth for the scheduler and conservation checks. + limit_ledger: >- + Per item: limit_used (units bought so far in the current 4h window), + first_fill_ts (when the FIRST unit of the window was bought), + limit_resets_at = first_fill_ts + 4h, remaining = limit - limit_used. + The ledger is DISTINCT from offer expiry: offers rest independently of + the window, and cancelling never renews or restarts it — a requote + before limit_resets_at may buy at most remaining (buy_capacity_ok: + limit_used + qty <= limit). A slot with limit_used == limit cannot + requote until limit_resets_at. + conservation: >- + Per slot at every snapshot: quoted == still_offered + collected_items + + refunded + sold (the buckets partition the quote's units — no losses, + no duplication, no negatives); cash_quoted == still_offered_cash + + collected_cash + refunded_cash. Impossible pairs (units in a live offer + AND a terminal bucket) are violations: still_offered + sold, and + still_offered + refunded (cancel is atomic). ge_cancel moves every + filled / refunded / unsold unit and coin to the GE collection box; + ge_collect withdraws them and reconciles the ledger BEFORE any slot + reuse. Committed validator fixtures prove the invariants and the + scheduler dispatch. + break_resume: >- + Breaks are taken only when every slot is resting (buy-open / sell-open) + or collected (closed) — never mid-collect or mid-place. 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. + Slots and slots only: the 8-slot members / 3-slot F2P activity is never + downgraded to single-slot work. state_machine: initial: idle terminal: [complete, failed] states: idle: - goal: "be logged in at a bank with a GE booth, capital >= floor, per-slot offer ledger empty (quoted = filled = sold = collected = 0 for every slot)" + 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)" next: [scan, complete] scan: - goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax" - next: [buy, idle] - buy: - goal: "place one resting buy offer per free slot at or below the low edge; record quoted qty and remaining 4h limit per slot in the ledger" - next: [wait_buy_fill, idle] - wait_buy_fill: - goal: "hold BUY offers; track filled_qty per slot (partial fills included, units stay attached to the resting offer); on the 4h window lapse or a stale price, cancel the unfilled balance" - next: [sell, cancel_requote] - sell: - goal: "place one sell offer per slot holding units (its filled_qty) at or above the high edge" - next: [wait_sell_fill, cancel_requote] - wait_sell_fill: - goal: "hold SELL offers; track sold qty per slot; on lapse or a stale price, cancel and re-quote — units are never left stranded" - next: [collect, cancel_requote] - cancel_requote: - goal: "ge_cancel the timed-out/stale offers of the current phase: acquired units return to inventory, unspent cash refunds, slot freed; re-quote at a fresh edge (buy when margin is still positive; sell to exit at the current high edge) or free the slot" - next: [wait_buy_fill, sell, wait_sell_fill] + goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax (a3)" + next: [schedule, idle] + schedule: + goal: "run ONE scheduler pass over all 8 (members) or 3 (F2P) slots: dispatch free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> requote; resting slots untouched (slot_state_machine.scheduler_iteration)" + next: [wait, collect] + wait: + goal: "hold resting offers; at the 600 s cadence re-check offers (a6) and re-run the scheduler pass (schedule); a slot leaves wait when it becomes dispatchable" + next: [schedule, collect] collect: - goal: "collect proceeds, deposit coins, reconcile (inventory empty of flip items, all GE slots free, each slot's ledger closed with a logged RESULT)" - next: [scan, complete, idle] + goal: "ge_collect every awaiting-collect slot: withdraw filled items, refunds, unsold units and proceeds from the collection box; reconcile each slot's ledger (conservation invariants) and only then allow slot reuse (a9)" + next: [schedule, scan, complete, idle] complete: goal: "session GP target reached" failed: @@ -169,40 +260,25 @@ state_machine: to: complete - from: scan when: "at least one net-positive candidate at the scanned band edges (floored+capped tax)" - to: buy + to: schedule - from: scan when: "no net-positive candidate on 3 consecutive scans (prices stale — stand down)" to: idle - - from: buy - when: "all free slots quoted (quoted qty recorded per slot)" - to: wait_buy_fill - - from: buy - when: "capital exhausted before quoting (insufficient funds)" - to: idle - - from: wait_buy_fill - when: "a slot's buy offer is FULLY filled (filled_qty == quoted qty) — that slot's units move to sell" - to: sell - - from: wait_buy_fill - when: "a slot's buy offer hits the 4h window lapse or a stale price with filled_qty < quoted qty — ge_cancel: acquired units return to inventory, unspent cash refunds, slot freed" - to: cancel_requote - - from: cancel_requote - when: "re-quoted buy offers placed at the fresh low edge (window renewed, margin still positive)" - to: wait_buy_fill - - from: cancel_requote - when: "buy-side margin is gone — free the slot on the buy side; any acquired units from the cancelled offer still sell at the current high edge (exit at cost or a small loss rather than strand inventory)" - to: sell - - from: cancel_requote - when: "re-quoted sell offers placed at the fresh high edge (or lowered to the current band) for units in hand" - to: wait_sell_fill - - from: sell - when: "sell offers placed for all units in hand (filled_qty per slot)" - to: wait_sell_fill - - from: wait_sell_fill - when: "all pending sells filled — per-slot proceeds ready to collect" + - from: schedule + when: "pass placed or requoted offers (quotes outstanding) — rest until the next cadence" + to: wait + - from: schedule + when: "pass marked slots awaiting collection (ge_cancel happened) — withdraw before further dispatch" to: collect - - from: wait_sell_fill - when: "a sell offer hits the 4h window lapse or a stale price — ge_cancel and re-quote at the fresh high edge or lower to the current band; units are never stranded" - to: cancel_requote + - from: wait + when: "600 s cadence reached — re-check offers and re-run the scheduler pass" + to: schedule + - from: wait + when: "a slot filled and proceeds/results are ready to collect" + to: collect + - from: collect + when: "only resting or closed slots remain; cadence continues" + to: wait - from: collect when: "session GP target reached" to: complete @@ -252,11 +328,11 @@ actions: verb: ge_buy target: grand-exchange params: - item: "per candidate slot" - qty: "min(remaining 4h limit, slot capacity, capital available)" + item: "per dispatchable free slot (scheduler pass)" + qty: "min(remaining 4h limit, slot capacity, capital available) — buy_capacity_ok(qty + limit_used <= limit)" price: "low edge (rounded down)" max_price: "low edge — never instant" - ledger: "record quoted qty and remaining 4h limit per slot" + ledger: "record quoted qty, cash_quoted, limit_used check; sub-state -> buy-open" on_fail: skip anti_ban: delay: [900, 2700] @@ -266,7 +342,7 @@ actions: verb: sleep params: delay: [6000, 12000] - until: "ge_offers_done changes or a fill event or 4h window lapse" + until: "ge_offers_done changes or a fill event or the 600s cadence or 4h window lapse" on_fail: retry anti_ban: delay: [300, 900] @@ -276,9 +352,9 @@ actions: verb: ge_offers_done target: "all quoted slots" params: - phase: "buy (no pending sells) | sell (no pending buys)" - per_slot: "filled_qty vs quoted_qty for every slot (partial fills included)" - verdict: "full | partial | none | 4h-lapse" + phase: "per-slot: buy-open (buy outstanding) | sell-open (sell outstanding)" + per_slot: "still_offered vs quoted for every slot (partial fills stay attached to the resting offer)" + verdict: "full | partial | none | lapse | stale-edge" on_fail: retry - id: a7 @@ -286,31 +362,33 @@ actions: verb: ge_sell target: grand-exchange params: - item: "per slot holding units (filled_qty)" - qty: "filled_qty of that slot" + item: "per ready-to-sell slot (units in hand after full fill or ge_collect)" + qty: "units in hand for that slot" price: "high edge (rounded up)" - ledger: "record sold qty per slot" + ledger: "record sold qty per slot; sub-state -> sell-open" on_fail: skip anti_ban: delay: [900, 2700] - id: a8 - step: cancel-requote + step: cancel-to-box verb: ge_cancel - target: "timed-out or stale slot offers (current phase)" + target: "lapsed or stale slot offers (current phase)" params: - offers: "the slots that hit the 4h window lapse or a stale price" - cancelled: "acquired units return to inventory; unspent cash refunds; slot freed" - requote: "buy: re-place at the fresh low edge when the margin is still positive; sell: re-place at the fresh high edge or lower to the current band; margin gone on the buy side: free the slot and sell any acquired units to exit" + offers: "the slots that hit the 4h window lapse or a stale edge" + moved: "filled items + refunded units + unsold units + unspent coins ALL move to the GE COLLECTION BOX (never directly to inventory)" + limit: "the 4h buy limit is NOT renewed or restarted by the cancel (limit_used stays)" + sub_state: "buy-open -> buy-cancelled-awaiting-collect; sell-open -> sell-cancelled-awaiting-collect" on_fail: retry - id: a9 - step: collect-and-log + step: collect-and-reconcile verb: ge_collect target: grand-exchange params: - deposit: "coins to bank" - reconcile: "inventory empty of flip items, all GE slots free, per-slot ledger closed" + withdraw: "from the GE collection box: filled items, refunds, unsold units, sale proceeds" + reconcile: "per-slot ledger conservation check (quoted == still_offered + collected + refunded + sold; cash likewise) BEFORE any slot reuse" + decide: "ready-to-sell (sell edge positive) | ready-to-requote (buy margin positive and remaining limit > 0) | free (release)" log: "RESULT item, qty, net gp per slot (floored+capped tax)" on_fail: alert anti_ban: @@ -322,64 +400,105 @@ actions: target: "current location" params: duration: "[60, 240] seconds of idle AFK per break (matches run_control.break.for)" - safe: "resting offers persist server-side at their quoted edges — the full state lives in the GE interface plus the per-slot ledger, so a break at any resting-offer point is safe" - resume: "re-open GE (a2), re-check offer state (a6), continue from the current phase (a4/a7/a8/a9) — 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 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)" + 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 run_control: - iteration: "one full rotation: quote buys -> fills/cancels/requotes -> sell filled units -> sells fill/cancel/requote -> collect + reconcile, across all slots" + iteration: "one scheduler pass over every slot at the 600 s cadence — dispatch free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> requote; resting slots untouched (slot_state_machine.scheduler_iteration)" max_iterations: null break: - every: [6, 10] # rotations (resting offers persist; state = GE + per-slot ledger) + every: [6, 10] # passes (resting offers persist; state = GE offers + per-slot record) for: [60, 240] # seconds of idle AFK stop_conditions: - "session GP target reached" - "capital under floor" - "no net-positive candidates on 3 consecutive scans" - - "3 consecutive aborted offers (client issue — do not loop)" + - "3 consecutive failed/aborted passes (client issue — do not loop)" - # ================= WORKED EXAMPLE (pinned, deterministic — owner addendum 2026-08-28) ================= + # ================= WORKED EXAMPLE (pinned, deterministic — owner addendum 2026-08-28; slot-machine walk per governor 10323) ================= # Members account, 8 GE slots, capital 22,500,000 gp. Snapshot prices 2026-08-27 # (buy/sell = low/high edge; tax = min(floor(0.02 x sell), 5,000,000)). + # The run below walks THREE slots in detail (arrowtips / iron / cosmic) exactly as + # the committed scheduler-dispatch + conservation fixtures; the other five slots + # behave identically to the iron slot and are omitted for brevity. # - # Step 0 — preparation and capital allocation: capital at the bank, GE-adjacent. - # Scan bands (a3): only net-positive slots are quoted. Per-slot capital needs: - # arrowtips 223x10,000 = 2.23M; iron bar 145x10,000 = 1.45M; cosmic 122x18,000 = - # 2.196M; silver ore 73x13,000 = 0.949M; swordfish 206x15,000 = 3.09M; lobster - # 106x15,000 = 1.59M; magic logs 772x12,000 = 9.264M; mud 96x18,000 = 1.728M - # (total 22.497M <= 22.5M). Ledger: quoted=filled=sold=collected=0 per slot. - # Step 1 — place buys (a4): quote all 8 slots at the low edges (qty = 4h limit). - # wait_buy_fill (a5/a6). - # Step 2 — PARTIAL FILL: arrowtips fills 4,000 of 10,000 after ~18 min -> check-fills - # (a6) verdict PARTIAL: ledger arrowtips.filled = 4,000. The offer KEEPS resting - # (acquired units stay attached to the resting offer; nothing is stranded). - # Step 3 — FULL FILL: iron bar fills all 10,000 -> transition to sell (a7): place - # sell 10,000 @ 157 (high edge). wait_sell_fill -> fills -> collect (a9): - # proceeds 1,570,000; tax floor(157 x 0.02) = 3/item -> net = (157-3-145) x 10,000 - # = 90,000. Reconcile: inventory empty, slot free, ledger closed. - # Step 4 — NO-FILL / STALE recovery on the buy side: cosmic 18,000 @ 122 is unfilled - # approaching the 4h lapse -> cancel_requote (a8, ge_cancel): 0 units return, - # 2,196,000 cash refunds, slot freed; re-scan (a3): cosmic still net-positive -> - # requote 18,000 @ 122 on the fresh window. (If the buy margin had vanished the - # slot is freed on the buy side; any units acquired from a cancelled offer still - # sell at the current high edge.) - # Step 5 — PARTIAL matures at lapse: arrowtips still 4,000/10,000 -> ge_cancel: - # 4,000 units return to inventory, 6,000 x 223 = 1,338,000 refunded -> sell (a7): - # 4,000 @ 244 -> tax floor(244 x 0.02) = 4/item -> net = (244-4-223) x 4,000 = - # 68,000. Collect (a9) + reconcile. - # Step 6 — FIRST SCHEDULED BREAK after 6-10 rotations (midpoint 8): execution is at - # a safe point — every slot is either collected or resting at its quoted edge; the - # state (offers + per-slot ledger) persists server-side. take-break 60-240 s - # (a10); RESUME at a2 (re-open GE) -> a6 (check-fills) -> continue the current - # phase (a4 buys / a7 sells / a8 cancel-requote / a9 collect). Breaks never strand - # an offer and never interrupt a collect/place in progress. - # Step 7 — STOP: session target (e.g. 900,000 net) -> collect -> complete; capital - # fell below floor -> idle -> complete; 3 consecutive aborted offers -> failed. - # Rate qualification: the 149,500 gp/h figure is the 4h buy-limit CEILING (8 slots, - # full fills, zero overhead). Realized midpoint ~ 149,500 x 0.70 (fill factor) x - # 0.92 (duty cycle incl. scan/place/collect + breaks) ~ 96,300 gp/h — the entry's - # low bound 74,750 (50% ceiling) sits under that; the ceiling is never presented - # as an achieved runtime rate. + # t0 idle -> scan -> schedule (pass 1): all slots free. Scheduler dispatch: + # free -> quote. Slot ledger after the pass (conservation holds): + # arrowtips: buy-open, quoted 10,000 @ 223; cash_quoted 2,230,000 + # iron: buy-open, quoted 10,000 @ 145; cash_quoted 1,450,000 (limit_ledger entry: limit_used 0, first_fill_ts -, limit_resets_at -) + # cosmic: buy-open, quoted 18,000 @ 122; cash_quoted 2,196,000 + # Buckets: still_offered == quoted for every slot; collected/refunded/sold = 0. + # + # t1 wait, ~18 min: arrowtips PARTIAL fill 4,000/10,000 (a6 verdict PARTIAL). + # Ledger: arrowtips quoted 10,000 == still_offered 6,000 + collected_items 4,000 + # (the 4,000 units sit in the player's possession — GE box — attached to the + # resting offer; nothing is stranded). limit_ledger: limit_used 4,000, + # first_fill_ts now, limit_resets_at = first_fill_ts + 4h; remaining 6,000. + # Cosmic: no fill; iron: FULL fill 10,000 -> ready-to-sell. + # + # t2 cadence -> schedule (pass 2): dispatch ONLY the dispatchable slot: + # iron ready-to-sell -> sell-open (a7): 10,000 @ 157 (high edge). + # arrowtips buy-open -> NO ACTION (resting, quote age within policy). + # cosmic buy-open -> NO ACTION (resting, no fill, quote age within policy). + # => the pass never closes or advances unrelated slots (regression fixture). + # Iron ledger: quoted 10,000 == still_offered 0 + sold 10,000 (buckets move on fill). + # + # t3 iron sell-open FULL fill -> collect: proceeds 1,570,000; tax floor(157 x 0.02) + # = 3/item -> net = (157 - 3 - 145) x 10,000 = 90,000. ge_collect (a9): + # cash withdrawn, ledger reconciled, sub-state -> closed, slot freed. + # Conservation: quoted 10,000 == sold 10,000 (all other buckets 0). + # + # t4 LAPSE / stale-edge handling (a8): at the 4h window edge, cosmic still has NO + # fill -> ge_cancel: the offer's cash 2,196,000 moves to the COLLECTION BOX as a + # refund. Ledger: cash_quoted 2,196,000 == still_offered_cash 0 + refunded_cash + # 2,196,000 (collected_cash 0). Sub-state -> buy-cancelled-awaiting-collect. + # arrowtips reaches limit_resets_at with 6,000 still offered -> ge_cancel: + # filled 4,000 (already in player possession) + refunded 6,000 units + unspent + # cash 6,000 x 223 = 1,338,000 ALL move to the collection box. Ledger: + # quoted 10,000 == collected_items 4,000 + refunded 6,000; limit_used STAYS + # 4,000 (the cancel does NOT renew the limit; remaining 6,000 stands). + # Sub-state -> buy-cancelled-awaiting-collect for both slots. + # + # t5 cadence -> schedule (pass 3): dispatch the awaiting-collect slots -> ge_collect + # (a9). Withdraw + reconcile: + # arrowtips: units 10,000 (4,000 filled + 6,000 refund) -> inventory. + # Ledger: quoted 10,000 == collected_items 10,000 (refunded absorbed into + # collected on withdrawal). Buy margin still positive AND remaining limit + # 6,000 > 0. DECIDE (a9): ready-to-requote -> requote buy 6,000 @ 223 + # (buy_capacity_ok: limit_used 4,000 + 6,000 = 10,000 <= 10,000 — allowed; + # a 6,001-unit requote would be BLOCKED until limit_resets_at — fixture). + # -> buy-open on the fresh edge. + # cosmic: cash 2,196,000 withdrawn (refunded_cash -> collected_cash 2,196,000). + # Margin still positive, limit_used 0 -> ready-to-requote -> buy-open 18,000 + # @ 122 (full capacity available). + # iron: already closed (free). + # => ONLY the awaiting-collect slots acted; resting/closed slots untouched. + # + # t6 Scheduler continues; arrowtips requote buy 6,000 fills -> ready-to-sell -> + # sell-open 10,000 @ 244 (4,000 in hand from the t1 fill + 6,000 from the + # requote) -> fills -> collect (a9): tax floor(244 x 0.02) = 4/item -> + # net = (244 - 4 - 223) x 10,000 = 170,000 for the window (matches the + # 170,000 4h/slot table row; limit_used 10,000 == limit -> no further + # requotes until limit_resets_at). + # + # t7 FIRST SCHEDULED BREAK after 6-10 passes (midpoint 8): every slot is resting + # (buy-open) or closed; per-slot records persisted (slot_state_machine. + # break_resume); take-break 60-240 s (a10); RESUME at a2 (re-open GE) -> a6 + # (check-fills) -> schedule (next pass) -> continue per-slot from persisted + # sub-state. Breaks never strand an offer and never interrupt a collect/place. + # + # t8 STOP: session target (e.g. 900,000 net) -> collect -> complete; capital fell + # below floor -> idle -> complete; 3 consecutive failed passes -> failed. + # + # Rate qualification (typed ceiling vs realized scenarios — finding 5): + # ceiling 149,500 = 598,000 per 4h / 4h — ideal full fills of all 8 slots, + # zero overhead. Realized HIGH 138,000 = 149,500 x 37/40 (37 min resting waits + # + 3 min scan/place/collect per 40-min rotation at the 600 s cadence). + # Realized LOW 74,750 = 149,500 x 0.50 (requote-heavy: every 4h window's + # volume completes only after one ge_cancel + requote cycle — the no-fill + # remainder is refunded and re-quoted on the next window, halving volume). + # The ceiling is NEVER presented as an achieved runtime rate. # ===================================================================================================== risk: @@ -387,17 +506,20 @@ risk: notes: - "Flipping is standard gameplay; the detectable signal is quote churn — keep per-offer pacing humanised, never instant-buy/sell loops." - "The 2% fee (since 2025-05-29) and 5M/item cap make sub-2%-spread staples unprofitable at band edges; re-scan before every rotation." - - "Thin/volatile items: a resting offer can sit for hours — cap quote lifetime to the 4h window and re-quote (ge_cancel + fresh edge) rather than chase." + - "Thin/volatile items: a resting offer can sit for hours — quote lifetime is bounded by the 4h window (lapse -> ge_cancel -> collection box -> ge_collect -> requote at a fresh edge), never by chasing." - "Partial fills are normal; never cancel-and-replace a partially-filled offer mid-window just to churn — the ledger keeps the filled qty attached to the resting offer until full/lapse." - - "Breaks are safe only at resting-offer or post-collect points (see a10); state = GE offers + the per-slot ledger, which survive the pause." + - "ge_cancel NEVER returns items/coins directly to inventory: everything lands in the GE collection box. Reuse a slot only after ge_collect + ledger reconciliation (conservation invariants)." + - "The 4h buy limit is independent of offer life: cancelling does not renew it. A slot at limit_used == limit cannot requote until limit_resets_at (first_fill_ts + 4h)." + - "Breaks are safe only at resting-offer or post-collect points (see a10); state = GE offers + the persisted per-slot record, which survive the pause." 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; connected limits; verified 2026-08-27)" - - "OSRS wiki — Grand Exchange/Buying limits (4h window mechanics)" + - "OSRS wiki — Grand Exchange/Buying limits (4h window mechanics; window starts on first purchase)" - "OSRS wiki — Merchanting (flipping = spread exploitation; high trade volume = safety; bulk flipping needs ~20M+ cash; avoid static-price items; offer matching at or better than your price)" - "prices.runescape.wiki/api/v1/osrs/mapping (item ids, members flags, limits — limits cross-checked against Module:Exchange/, all agree)" - "prices.runescape.wiki/api/v1/osrs/latest (high/low price band snapshot 2026-08-27)" - - "docs/action-grammar.md (ge_cancel verb added 2026-08-28: cancel a pending offer — bought units return to inventory, unspent cash refunds) + schemas/entry.schema.json verb enum; owner lifecycle addendum 2026-08-28 (complete runnable flow, worked example, realized rates)" + - "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); owner decision 2026-08-28 (preserve capped/floored tax + 4 fixtures)" + - "docs/action-grammar.md (ge_cancel amended + ge_collect verb, 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 related: diff --git a/docs/action-grammar.md b/docs/action-grammar.md index 2d29fa9..0702188 100644 --- a/docs/action-grammar.md +++ b/docs/action-grammar.md @@ -68,9 +68,9 @@ Legend for the mapping table: | `ge_open` | open GE at a bank | — | — | — | | `ge_buy` | buy `qty` of `item` at offer price, paced | — | — | — | | `ge_sell` | sell `qty` of `item`, confirm | — | — | — | -| `ge_cancel` | cancel a pending offer: bought units return to inventory, unspent cash refunds | — | — | — | +| `ge_cancel` | cancel a pending offer: filled items, refunds, unsold units and unspent coins ALL move to the GE COLLECTION BOX (never directly to inventory); the 4h buy limit is NOT renewed | — | — | — | | `ge_offers_done` | predicate: no pending buy/sell offers | — | — | — | -| `ge_collect` | collect finished GE transactions | — | — | — | +| `ge_collect` | withdraw from the GE collection box to inventory: filled items, refunds, unsold units, sale proceeds; reconcile the per-slot ledger before any slot reuse | — | — | — | | `ge_check_price` | read current buy/sell price of an item | — | — | — | ## F. Combat diff --git a/schemas/entry.schema.json b/schemas/entry.schema.json index 69df417..da01e78 100644 --- a/schemas/entry.schema.json +++ b/schemas/entry.schema.json @@ -43,6 +43,7 @@ "net_per_cycle": { "type": "number" }, "gp_per_hour_low": { "type": "number" }, "gp_per_hour_high": { "type": "number" }, + "ceiling_gp_per_hour": { "type": "number", "exclusiveMinimum": 0 }, "xp_per_hour": { "type": "number" }, "supplies": { "type": "array", @@ -62,6 +63,39 @@ } }, + "slot_state_machine": { + "type": "object", + "required": ["sub_states", "slot_transitions", "slot_transition_whens", + "scheduler_iteration", "quote_age_policy", "persisted_state", + "limit_ledger", "conservation", "break_resume"], + "additionalProperties": false, + "properties": { + "sub_states": { + "type": "array", + "minItems": 8, + "items": { "type": "string" } + }, + "slot_transitions": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "slot_transition_whens": { + "type": "array", + "minItems": 8, + "items": { "type": "string", "minLength": 8 } + }, + "scheduler_iteration": { "type": "string", "minLength": 10 }, + "quote_age_policy": { "type": "string", "minLength": 10 }, + "persisted_state": { "type": "string", "minLength": 10 }, + "limit_ledger": { "type": "string", "minLength": 10 }, + "conservation": { "type": "string", "minLength": 10 }, + "break_resume": { "type": "string", "minLength": 10 } + } + }, + "best_practice": { "type": "object", "required": ["equipment"], diff --git a/tools/validate.py b/tools/validate.py index 58f20ed..77f979f 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -21,11 +21,25 @@ Runs: NOTE: the generic `ge` tag alone does NOT gate this rule — a training/processing/procurement entry may legitimately carry `ge` while its income/cost model has nothing to do with flip tax. - - Regression fixtures (--selftest): a fractional-tax flipping - entry must FAIL; an above-cap high-value flip with the - correctly capped-tax net must PASS; a non-margin entry carrying - only `ge` plus a non-flip tag must PASS. - 3. Global id uniqueness across the whole codex tree. + - GE-flip lifecycle (flipping/merchanting entries): the 4h buy-limit + ledger (limit_used / limit_resets_at) is DISTINCT from offer + expiry — cancelling never renews the limit; every slot is an + independent asynchronous sub-state machine (canonical set: + free / buy-open / buy-cancelled-awaiting-collect / ready-to-sell / + sell-open / sell-cancelled-awaiting-collect / ready-to-requote / + closed) driven by a global scheduler pass that never touches + unrelated slots; ge_cancel moves filled items/refunds/proceeds/ + unsold units to the GE collection box for a separate ge_collect; + per-slot conservation (quoted == still_offered + collected + + refunded + sold; no negative/duplicated quantities) is provable; + 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 + entry must FAIL; an above-cap high-value flip with the + correctly capped-tax net must PASS; a non-margin entry carrying + only `ge` plus a non-flip tag must PASS; plus per-slot scheduler + dispatch, conservation, buy-limit and nesting fixtures. + 3. Global id uniqueness across the whole codex tree. Exit codes: 0 = clean (warnings ok), 1 = validation errors. @@ -97,8 +111,205 @@ def ge_floor_tax_error(rel, data): return None +# --- GE-flip lifecycle gates (owner guidance 2026-08-28, governor 10323) --- +# Canonical per-slot sub-states: each GE slot is an INDEPENDENT asynchronous +# machine driven by a global scheduler pass; a pass never closes or advances +# a slot it did not touch. +SLOT_SUB_STATES = [ + "free", # no offer, nothing held + "buy-open", # resting buy offer (partial fills stay attached) + "buy-cancelled-awaiting-collect", # aborted buy: assets sit in the GE collection box + "ready-to-sell", # units in hand, sell edge positive + "sell-open", # resting sell offer (partial sells tracked) + "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 +] +# Dispatch table: the pure action a slot in a given sub-state needs this pass. +SLOT_DISPATCH = { + "free": "quote", + "buy-open": None, # resting; fill/abort decided by policies + "buy-cancelled-awaiting-collect": "collect", + "ready-to-sell": "sell", + "sell-open": None, # resting; fill/abort decided by policies + "sell-cancelled-awaiting-collect": "collect", + "ready-to-requote": "requote", + "closed": None, +} + + +def scheduler_dispatch(sub_state): + """The pending action for one slot in one scheduler pass, or None. + + 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. + """ + return SLOT_DISPATCH.get(sub_state) + + +def conservation_violations(record): + """Per-slot conservation invariants (governor 10323 finding 4). + + record: dict with item buckets (still_offered, collected_items, + refunded, sold — each a {item: qty} map), quoted ({item: qty}), and cash + buckets (cash_quoted, still_offered_cash, collected_cash, + refunded_cash). Invariants: + - every quantity >= 0; + - per item: quoted == still_offered + collected_items + refunded + sold + (the buckets are a partition of the quote's units — no losses, no + duplicated, no negative quantities); + - physically impossible co-occurrences: units cannot sit in a live + offer AND already be sold or cancelled-refunded (cancel is atomic), + so an item may not appear in both {still_offered, sold} or both + {still_offered, refunded} — partial fills legitimately span + {still_offered, collected_items} until the offer is closed; + - cash: cash_quoted == still_offered_cash + collected_cash + + refunded_cash. + Returns a list of violation strings (empty = conserved). + """ + out = [] + buckets = ["still_offered", "collected_items", "refunded", "sold"] + for b in buckets: + for item in record.get(b, {}): + q = record[b][item] + if q < 0: + out.append(f"item '{item}' bucket {b} is negative ({q})") + quoted = record.get("quoted", {}) + for item, qq in quoted.items(): + if qq < 0: + out.append(f"item '{item}' quoted is negative ({qq})") + total = sum(record.get(b, {}).get(item, 0) for b in buckets) + if total != qq: + out.append( + f"item '{item}' quoted {qq} != still_offered + collected + " + f"refunded + sold ({total})") + # Physically impossible co-occurrences (the buckets are a + # partition of the quote's units; cancel is atomic and units in + # a live offer cannot already be sold/cancelled): + # still_offered + sold — units cannot be both in a live + # offer and already sold + # still_offered + refunded — a cancelled offer has no live + # remainder left in the offer + in_b = {b for b in buckets if item in record.get(b, {})} + for pair in (("still_offered", "sold"), ("still_offered", "refunded")): + if pair[0] in in_b and pair[1] in in_b: + out.append( + f"item '{item}' appears in multiple buckets " + f"({pair[0]} + {pair[1]} — physically impossible: " + f"units cannot be in a live offer and a terminal " + f"bucket at the same time)") + for c in ("cash_quoted", "still_offered_cash", "collected_cash", "refunded_cash"): + if record.get(c, 0) < 0: + out.append(f"{c} is negative ({record[c]})") + if record.get("cash_quoted", 0) != ( + record.get("still_offered_cash", 0) + + record.get("collected_cash", 0) + + record.get("refunded_cash", 0)): + out.append( + f"cash quoted {record.get('cash_quoted')} != still_offered " + f"{record.get('still_offered_cash')} + collected " + f"{record.get('collected_cash')} + refunded {record.get('refunded_cash')}") + return out + + +def buy_capacity_ok(limit_used, limit, qty_requested): + """The 4h buy-limit ledger (governor 10323 finding 1): the limit resets + 4h after the FIRST unit was bought; cancelling an offer does NOT renew + it, so a requote before the reset cannot buy beyond the remaining + capacity. Returns True when limit_used + qty_requested <= limit. + """ + return limit_used + qty_requested <= limit + + +def ge_lifecycle_errors(rel, data): + """GE-flip lifecycle gates (flipping/merchanting entries only): + + - the 4h buy-limit ledger is distinct from offer expiry: the entry must + declare a limit_ledger + an independent quote_age_policy; + - 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; + - rates: ceiling_gp_per_hour is the typed economic ceiling and + 0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling. + """ + errors = [] + tags = {str(t).lower() for t in data.get("tags", [])} + if not tags & MARGIN_TAGS: + return errors + eco = data.get("economy") or {} + # --- typed ceiling vs realized bounds --- + ceil = eco.get("ceiling_gp_per_hour") + if not isinstance(ceil, (int, float)) or ceil <= 0: + errors.append(f"{rel}: flipping entries must declare " + f"economy.ceiling_gp_per_hour (the typed 4h buy-limit " + f"economic ceiling, distinct from realized rates)") + return errors + low, high = eco.get("gp_per_hour_low"), eco.get("gp_per_hour_high") + for name, val in (("gp_per_hour_low", low), ("gp_per_hour_high", high)): + if not isinstance(val, (int, float)) or val < 0: + errors.append(f"{rel}: {name} must be a non-negative realized " + f"scenario bound") + if isinstance(low, (int, float)) and isinstance(high, (int, float)) \ + and isinstance(ceil, (int, float)): + if not (low <= high <= ceil): + errors.append( + f"{rel}: realized bounds must nest under the ceiling: " + f"0 <= gp_per_hour_low {low} <= gp_per_hour_high {high} " + f"<= ceiling_gp_per_hour {ceil}") + # --- per-slot asynchronous model --- + ssm = data.get("slot_state_machine") or {} + if not isinstance(ssm, dict): + errors.append(f"{rel}: flipping entries must declare a " + f"slot_state_machine block (per-slot asynchronous sub-states)") + return errors + declared = [str(s) for s in ssm.get("sub_states", [])] + if sorted(declared) != sorted(SLOT_SUB_STATES): + errors.append( + f"{rel}: slot_state_machine.sub_states must be exactly the canonical " + f"set: {', '.join(SLOT_SUB_STATES)} (got: {', '.join(declared) or 'none'})") + trans = ssm.get("slot_transitions") or {} + if not isinstance(trans, dict): + errors.append(f"{rel}: slot_state_machine.slot_transitions must be a mapping") + trans = {} + for from_s, tos in trans.items(): + if from_s not in SLOT_SUB_STATES: + errors.append(f"{rel}: slot transition from unknown sub-state '{from_s}'") + for to_s in tos if isinstance(tos, list) else []: + if to_s not in SLOT_SUB_STATES: + 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): + 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") + whens = [str(w) for w in ssm.get("slot_transition_whens", []) or []] + for w in whens: + arrow = w.split("->") + if len(arrow) != 2 or arrow[0].strip() not in SLOT_SUB_STATES \ + or arrow[1].strip().split(":")[0].strip() not in SLOT_SUB_STATES: + errors.append(f"{rel}: slot_transition_whens entry '{w}' must be " + f"'from -> to: condition' with canonical sub-states") + for field in ("scheduler_iteration", "quote_age_policy", "persisted_state", + "limit_ledger", "conservation", "break_resume"): + if not str(ssm.get(field, "")).strip(): + errors.append(f"{rel}: slot_state_machine.{field} must be declared") + return errors + + def run_selftest(verbose=False): - """Regression fixtures for the GE floor-tax gate; returns error strings.""" + """Regression fixtures for the GE floor-tax + lifecycle gates; returns + error strings.""" + errors = [] + + def check(name, passed): + if verbose: + print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") + if not passed: + errors.append(f"selftest: {name}") + fixtures = [ # name, tags, economy, expect_error ("fractional-tax flipping entry", ["flipping"], @@ -118,7 +329,6 @@ def run_selftest(verbose=False): {"income_per_cycle": 300_000_000, "cost_per_cycle": 294_000_000, "net_per_cycle": 1_000_000}, False), ] - errors = [] for name, tags, eco, expect_error in fixtures: rel = f"selftest/{name}" got_error = ge_floor_tax_error(rel, {"tags": tags, "economy": eco}) is not None @@ -130,6 +340,125 @@ def run_selftest(verbose=False): f"selftest: '{name}' expected " f"{'an error' if expect_error else 'no error'} but got " f"{'an error' if got_error else 'no error'}") + + # --- per-slot scheduler dispatch: the worked example as a regression --- + # Governor 10323 finding 3 + owner: reproduce the simultaneous + # partial-buy / full-sell-collect / no-fill-buy case and prove no + # unrelated slot is closed or advanced. + slot_states = {"arrowtips": "buy-open", # partial fill 4,000/10,000 — resting + "iron": "ready-to-sell", # full fill — sell this pass + "cosmic": "buy-open"} # no fill, within quote age — resting + dispatched = {name: scheduler_dispatch(s) for name, s in slot_states.items()} + ok = dispatched == {"arrowtips": None, "iron": "sell", "cosmic": None} + check("worked example: partial-buy + full-sell + no-fill slots dispatch only the iron slot to sell; unrelated slots stay untouched", + ok) + check("resting slots (buy-open) are never closed or advanced by a pass", + dispatched.get("arrowtips") is None and dispatched.get("cosmic") is None) + + # --- conservation invariants (finding 4) --- + # Balanced snapshot mirroring the worked example: 10,000 Rune arrowtips + # quoted, 4,000 filled (units in the player's possession), 6,000 still + # resting in the live offer; plus an independent cash quote (2,230 GP) + # fully committed in live buy offers. + clean_rec = { + "quoted": {"Rune arrowtips": 10}, + "still_offered": {"Rune arrowtips": 6}, + "collected_items": {"Rune arrowtips": 4}, + "refunded": {}, + "sold": {}, + "cash_quoted": 2230, "still_offered_cash": 2230, + "collected_cash": 0, "refunded_cash": 0, + } + check("conservation: a balanced per-slot ledger (partial fill split across still_offered + collected, partition sums to quoted) has zero violations", + conservation_violations(clean_rec) == []) + dup_rec = dict(clean_rec, quoted={"Rune arrowtips": 10}, + still_offered={"Rune arrowtips": 4}, + sold={"Rune arrowtips": 6}) + check("conservation: units both in a live offer AND already sold (impossible pair) is a violation", + any("multiple buckets" in v for v in conservation_violations(dup_rec))) + over_rec = dict(clean_rec, collected_items={"Rune arrowtips": 7}) + check("conservation: over-reported buckets break the quoted partition (sum 13 != quoted 10)", + any("quoted 10 !=" in v for v in conservation_violations(over_rec))) + neg_rec = dict(clean_rec, sold={"Rune arrowtips": -1}) + check("conservation: a negative bucket is a violation", + any("negative" in v for v in conservation_violations(neg_rec))) + cash_rec = dict(clean_rec, collected_cash=900) + check("conservation: cash quoted != still_offered + collected + refunded is a violation", + any("cash quoted" in v for v in conservation_violations(cash_rec))) + + # --- 4h buy-limit ledger vs offer expiry (finding 1) --- + check("buy limit: a requote within the remaining capacity is allowed (4,000 used of 10,000 -> 6,000)", + buy_capacity_ok(limit_used=4000, limit=10000, qty_requested=6000)) + check("buy limit: a requote beyond the remaining capacity is blocked (6,001 > 6,000 remaining)", + not buy_capacity_ok(limit_used=4000, limit=10000, qty_requested=6001)) + check("buy limit: a cancelled offer does NOT renew the limit (10,000 used of 10,000 -> any requote blocked before reset)", + not buy_capacity_ok(limit_used=10000, limit=10000, qty_requested=1)) + + # --- structured lifecycle gates (findings 1-5) --- + margin_tags = ["flipping"] + ssm_ok = { + "sub_states": SLOT_SUB_STATES, + "slot_transitions": { + "free": ["buy-open"], + "buy-open": ["ready-to-sell", "buy-cancelled-awaiting-collect"], + "buy-cancelled-awaiting-collect": ["ready-to-sell", "free"], + "ready-to-sell": ["sell-open"], + "sell-open": ["closed", "sell-cancelled-awaiting-collect"], + "sell-cancelled-awaiting-collect": ["ready-to-requote", "closed"], + "ready-to-requote": ["buy-open", "sell-open", "free"], + "closed": [], + }, + "slot_transition_whens": [ + "free -> buy-open: quote placed", + "buy-open -> ready-to-sell: offer fully filled", + "buy-open -> buy-cancelled-awaiting-collect: abort (stale edge / quote age)", + "buy-cancelled-awaiting-collect -> ready-to-sell: collected, sell edge positive", + "buy-cancelled-awaiting-collect -> free: collected, full refund, slot freed", + "ready-to-sell -> sell-open: sell offer placed", + "sell-open -> closed: fully filled and proceeds collected", + "sell-open -> sell-cancelled-awaiting-collect: abort", + "sell-cancelled-awaiting-collect -> ready-to-requote: collected, requote", + "sell-cancelled-awaiting-collect -> closed: collected, slot closed", + "ready-to-requote -> buy-open: requoted buy", + "ready-to-requote -> sell-open: requoted sell", + "ready-to-requote -> free: abandoned", + ], + "scheduler_iteration": "one pass over all slots; dispatch pending actions only", + "quote_age_policy": "resting offers re-checked every 600 s; stale edges requoted", + "persisted_state": "per-slot record: sub-state + quoted/filled/sold/collected", + "limit_ledger": "limit_used + first_fill_ts + limit_resets_at, independent of offer expiry", + "conservation": "quoted == still_offered + collected + refunded + sold", + "break_resume": "breaks between passes; state persists in the per-slot record", + } + flip_ok = {"tags": margin_tags, "economy": { + "income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17, + "ceiling_gp_per_hour": 149500, "gp_per_hour_low": 102600, + "gp_per_hour_high": 138800}, "slot_state_machine": ssm_ok} + check("flip lifecycle: ceiling + nested realized bounds + canonical slot machine passes", + ge_lifecycle_errors("ok.yaml", flip_ok) == []) + errs = ge_lifecycle_errors("bad.yaml", {"tags": margin_tags, "economy": { + "income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17, + "ceiling_gp_per_hour": 149500, "gp_per_hour_low": 160000, + "gp_per_hour_high": 138800}}) + check("flip lifecycle: realized low above the ceiling is rejected (bounds must nest)", + any("nest" in e for e in errs)) + errs = ge_lifecycle_errors("bad2.yaml", {"tags": margin_tags, "economy": { + "income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17, + "gp_per_hour_low": 0, "gp_per_hour_high": 138800}}) + check("flip lifecycle: missing typed ceiling field is rejected", + any("ceiling_gp_per_hour" in e for e in errs)) + errs = ge_lifecycle_errors("bad3.yaml", dict(flip_ok, slot_state_machine=dict( + ssm_ok, sub_states=["free", "buy-open"]))) + check("flip lifecycle: non-canonical sub-state set is rejected", + any("canonical" in e for e in errs)) + errs = ge_lifecycle_errors("bad4.yaml", dict(flip_ok, slot_state_machine=dict( + ssm_ok, slot_transitions={"free": ["buy-open"], + "buy-open": ["ready-to-sell", + "buy-cancelled-awaiting-collect"]}, + 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)) + return errors @@ -213,6 +542,9 @@ def main(): err = ge_floor_tax_error(rel, data) if err: errors.append(err) + # GE-flip lifecycle gates (per-slot async model, 4h limit ledger vs + # offer expiry, typed ceiling vs realized scenario bounds). + errors += ge_lifecycle_errors(rel, data) # 3. staleness / research TODOs if data.get("status") == "verified":