diff --git a/codex/economy/flip-candidates.yaml b/codex/economy/flip-candidates.yaml new file mode 100644 index 0000000..d25e87c --- /dev/null +++ b/codex/economy/flip-candidates.yaml @@ -0,0 +1,626 @@ +id: flip-candidates +title: GE Flipping (margin trading) +domain: economy +tags: [flipping, merchanting, ge, money-making, hybrid] +status: verified +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: >- + 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). Model (governor 10323 + 10390, 2026-08-28): every GE + slot is an INDEPENDENT asynchronous sub-state machine (free / buy-open / + buy-filled-awaiting-collect / buy-cancelled-awaiting-collect / + ready-to-sell / sell-open / sell-filled-awaiting-collect / + 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; 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; + '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 + moves filled items, refunded CASH, unsold units and proceeds to the box, + and every snapshot satisfies SIDE-AWARE per-slot conservation (buy: + quoted units == filled + still_open + cancelled_unfilled with acquired + items == filled only, cash == spent + still_committed + refunded; sell: + quoted == sold + still_open + returned_unsold); + 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) + min_level: 3 # post-Tutorial Island baseline; no skill requirement + requires_quest: [] + requires_other: + - "Post-Tutorial Island account with Grand Exchange access (no skill or quest requirement)" + - "Members account: 8 GE slots, full candidate set (the modeled path below). F2P account: 3 GE slots and F2P-tradeable rows only — run the F2P path in profit_drivers." + - "Minimum starting capital: per slot = buy edge x 4h limit (arrowtips 2.23M, iron bar 1.45M, cosmic 2.196M, silver ore 0.949M, swordfish 3.09M, lobster 1.59M, magic logs 9.264M, mud 1.728M at the 2026-08-27 snapshot) — full 8-slot members coverage ~22.5M; the 3-slot F2P path needs ~5.5M (iron 1.45M + cosmic 2.196M + silver 0.949M). Capital below one slot's requirement takes that slot off the table." + +best_practice: + equipment: + - item: cash stack (flip capital) + why: "Gross margin per item is a few gp, so profit scales with quantity and the 4h + buy-limit window caps per-item volume — capital breadth (more candidates/slots + covered) matters more than any gear. Bulk flipping at scale needs ~20M+ cash." + source: "OSRS wiki — Merchanting (Bulk quantity flipping)" + techniques: + - name: flip the spread, not the trend + why: "Flipping profit is the buy/sell spread between pending offers (the impatience + premium), NOT price movement; it is safer when the item has high trade volume." + source: "OSRS wiki — Merchanting (Flipping)" + - name: prefer dynamic-price items, avoid static-price items + why: "Static-price items have no spread to harvest; only dynamic-price items carry + an exploitable margin." + source: "OSRS wiki — Merchanting (flipping guidance: 'strongly recommended to not + flip static-price items')" + - name: quote at the band edges, never instant-buy/sell + why: "Instant matching pays the standing offer's price (the market's impatience + price); a resting offer fills when a matching offer appears at or better than your + price. The 2% convenience fee makes sub-2%-spread staples unprofitable at band + edges outright, so the edges are where the margin lives." + source: "OSRS wiki — Merchanting (How the Grand Exchange works); OSRS wiki — Grand Exchange + (2% tax since 2025-05-29, capped 5M/item)" + - 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. The window starts when the FIRST unit of the window is bought + (first_fill_ts) and resets 4h later. The reset ONLY restores buying capacity — + it never cancels, expires, or otherwise affects a resting offer — and cancelling + an offer does NOT renew or restart the window." + 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 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, 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 + unfilled/cancelled quantity is CASH: spent / still_committed / refunded), and a + SELL quote partitions sold + still_open + returned_unsold. ge_cancel moves + filled items, refunded CASH, unsold units and sale proceeds to the GE COLLECTION + BOX (never directly to inventory); a separate ge_collect withdraws them and the + ledger is reconciled before the slot is reused. The ONLY cancel triggers are a + stale scanned edge (margin no longer positive) or an explicit strategy + reallocation — never the 4h buy-limit reset, which merely restores capacity." + source: "OSRS wiki — Grand Exchange (offer mechanics; collection); Bank (collection interface); governor review comment 10390 findings 1-4; docs/action-grammar.md (ge_cancel / ge_collect)" + +economy: + # Model (documented, reproducible from the snapshot): + # Members path (8 GE slots — the modeled economics below): + # per slot: buy `limit` units at the low edge, sell at the high edge. + # tax_pu = min(floor(0.02 * sell), 5_000_000) # 2% convenience fee, + # # rounded DOWN to whole coins per item, + # # capped 5M/item (owner decision + # # 2026-08-28; cap never hit here) + # net_pu = sell - tax_pu - buy # integer per-item net (floored tax) + # 4h/slot = net_pu * limit # buy-limit window caps per-4h volume + # F2P path (3 GE slots, F2P-tradeable rows only): same arithmetic, top-3 + # F2P rows — see profit_drivers for the table and rate. + # Snapshots 2026-08-27: prices.runescape.wiki mapping + latest; limits and + # members flags cross-checked against the wiki item pages (all agree). Top 8 + # by 4h/slot below in profit_drivers. + # + # 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 stale-edge ge_cancel + + # requote cycle (the no-fill remainder is refunded to the collection + # box; the slot re-quotes when the margin clears again or capacity + # allows), 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) + 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 + qty_per_cycle: 1 + source: ge + ge_limit: 10000 # mapping + Module:Exchange agree + - item: Iron bar + qty_per_cycle: 1 + source: ge + ge_limit: 10000 + - item: Cosmic rune + qty_per_cycle: 1 + source: ge + ge_limit: 18000 + profit_drivers: + - "Spread per unit x trade volume; volume is the safety factor (high-volume items fill faster and drift less)" + - "Slot breadth: cover as many net-positive candidates as capital allows (members 8 slots / F2P 3 slots)" + - "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 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's volume completes only after one stale-edge ge_cancel + requote cycle, so the no-fill remainder (refunded to the collection box and re-quoted when the margin clears or capacity allows) halves the filled volume (149,500 x 0.50). The ceiling 149,500 itself is an economic bound, never an achieved rate. Cancelling never renews the buy limit and the limit reset never cancels an offer (governor 10390 finding 1)." + +slot_state_machine: + sub_states: [free, buy-open, buy-filled-awaiting-collect, buy-cancelled-awaiting-collect, ready-to-sell, sell-open, sell-filled-awaiting-collect, sell-cancelled-awaiting-collect, ready-to-requote, closed] + slot_transitions: + free: [buy-open] + buy-open: [buy-filled-awaiting-collect, buy-cancelled-awaiting-collect] + buy-filled-awaiting-collect: [ready-to-sell, free] + buy-cancelled-awaiting-collect: [ready-to-sell, ready-to-requote, free] + ready-to-sell: [sell-open] + sell-open: [sell-filled-awaiting-collect, sell-cancelled-awaiting-collect] + sell-filled-awaiting-collect: [closed] + sell-cancelled-awaiting-collect: [ready-to-requote, closed] + ready-to-requote: [buy-open, sell-open, free] + 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)" + - "buy-open -> buy-cancelled-awaiting-collect: quote_age_policy abort — scanned edge stale (margin no longer positive) or explicit strategy reallocation; NEVER the 4h limit reset. ge_cancel moves filled items + refunded CASH (the unspent remainder) to the collection box; the buy limit is NOT renewed" + - "buy-filled-awaiting-collect -> ready-to-sell: ge_collect withdrawn the units; sell edge positive — dispatch sells" + - "buy-filled-awaiting-collect -> free: ge_collect withdrawn; margin gone or capital reallocated — bank the units, release the slot" + - "buy-cancelled-awaiting-collect -> ready-to-sell: ge_collect withdrawn the bought units; 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 at the fresh low edge" + - "buy-cancelled-awaiting-collect -> free: ge_collect withdrawn (full cash refund); 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 -> 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 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; 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" + 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-filled- + 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, 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 + scanned margin stays positive — a profitable resting offer remains + open regardless of limit_resets_at (a limit-window reset only restores + buy capacity; it never cancels, expires, or implies cancelling an + offer). An offer is aborted ONLY on a stale band edge (scanned margin + no longer positive) or an explicit strategy reallocation (slot freed + for a better candidate / stop condition). No-fill alone never aborts an + offer whose edge still clears the margin. + persisted_state: >- + Per-slot record persisted between passes and across breaks: sub-state, + item, quoted qty, and SIDE-AWARE buckets — buy side: filled, still_open, + cancelled_unfilled, cash_quoted, spent, still_committed, refunded; + sell side: sold, still_open, returned_unsold, proceeds_in_box, + cash_collected; plus 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 controls ONLY the buying rate: it is DISTINCT from offer + expiry (offers rest independently of the window), cancelling never + renews or restarts it, and the reset never cancels an offer — 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 + place a new or requoted buy until limit_resets_at; its resting offer + is unaffected. + conservation: >- + SIDE-AWARE per-slot invariants at every snapshot (governor 10390 + finding 4). BUY side: quoted units == filled + still_open + + cancelled_unfilled per item, and ACQUIRED ITEMS == filled ONLY — the + unfilled/cancelled quantity is a CASH refund, never refunded units; + cash_quoted == spent + still_committed + refunded. SELL side: quoted + units == sold + still_open + returned_unsold per item (a partial sell + legitimately spans sold + still_open). No negative or duplicated + quantities; impossible pairs (still_open + cancelled_unfilled on a + buy; still_open + returned_unsold on a sell) are violations. Phase + 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 — 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; 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) 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. + 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, 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)" + 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: "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: + goal: "hard failure — stop, log ERROR sentinel, alert" + transitions: + - from: idle + when: "GE access available AND capital >= floor AND session target not reached" + to: scan + - from: idle + when: "capital below floor OR session target already reached" + to: complete + - from: scan + when: "at least one net-positive candidate at the scanned band edges (floored+capped tax)" + to: schedule + - from: scan + when: "no net-positive candidate on 3 consecutive scans (prices stale — stand down)" + to: idle + - 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 + 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 slots remain (closed slots are released to free by the same pass); cadence continues" + to: wait + - from: collect + when: "session GP target reached" + to: complete + - from: collect + when: "rotation done and window open — start the next rotation" + to: scan + - from: collect + when: "capital fell below floor after reconciliation" + to: idle + +actions: + - id: a1 + step: reach-ge + verb: move_to + target: "bank with Grand Exchange booth" + params: + location: "Grand Exchange (Varrock) area bank" + method: [teleport, walk] + on_fail: retry + anti_ban: + delay: [400, 1600] + + - id: a2 + step: open-ge + verb: ge_open + target: grand-exchange + params: + booth: "GE counter or bank booth" + on_fail: retry + anti_ban: + delay: [800, 1800] + + - id: a3 + step: scan-bands + verb: ge_check_price + target: "current candidates" + params: + items: "top-N candidates from the entry table" + band: "high and low edge per item" + margin: "net-positive = sell - min(floor(0.02 x sell), 5,000,000) - buy > 0" + on_fail: skip + anti_ban: + delay: [1200, 2800] + + - id: a4 + step: place-buys + verb: ge_buy + target: grand-exchange + params: + 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, cash_quoted, limit_used check; sub-state -> buy-open" + on_fail: skip + anti_ban: + delay: [900, 2700] + + - id: a5 + step: hold-fills + verb: sleep + params: + delay: [6000, 12000] + until: "ge_offers_done changes or a fill event or the 600s cadence (a resting offer keeps resting; the 4h limit reset plays no role in offer lifetime)" + on_fail: retry + anti_ban: + delay: [300, 900] + + - id: a6 + step: check-fills + verb: ge_offers_done + target: "all quoted slots" + params: + phase: "per-slot: buy-open (buy outstanding) | sell-open (sell outstanding)" + per_slot: "side-aware buckets per slot — buy: filled (units in the collection box) / still_open (resting offer) / cancelled_unfilled; sell: sold (proceeds in the box) / still_open / returned_unsold — partial fills keep the offer resting with filled units held in the box" + verdict: "full | partial | none | stale-edge # 'lapse' is NOT a verdict: the 4h window is a capacity clock, not an offer deadline" + on_fail: retry + + - id: a7 + step: place-sells + verb: ge_sell + target: grand-exchange + params: + item: "per ready-to-sell slot (units in hand AFTER ge_collect — a fully filled buy must first pass through buy-filled-awaiting-collect -> ge_collect)" + qty: "units in hand for that slot" + price: "high edge (rounded up)" + ledger: "record sold qty per slot; sub-state -> sell-open" + on_fail: skip + anti_ban: + delay: [900, 2700] + + - id: a8 + step: cancel-to-box + verb: ge_cancel + target: "slot offers aborted by quote_age_policy (stale edge / strategy reallocation)" + params: + offers: "slots whose scanned band edge no longer clears the margin (stale), or slots explicitly reallocated by the strategy — NEVER offers whose 4h limit-window merely reset (a reset restores buy capacity only; quote_age_policy)" + moved: "BUY cancel: filled items + refunded CASH (the unspent remainder) -> GE COLLECTION BOX; SELL cancel: unsold units + proceeds received so far -> GE COLLECTION BOX (never directly to inventory)" + limit: "the 4h buy limit is NOT renewed or restarted by the cancel (limit_used stays); the next reset does not cancel anything either" + sub_state: "buy-open -> buy-cancelled-awaiting-collect; sell-open -> sell-cancelled-awaiting-collect" + on_fail: retry + + - id: a9 + step: collect-and-reconcile + verb: ge_collect + target: grand-exchange + params: + 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: + delay: [600, 1400] + + - id: a10 + step: take-break + verb: take_break + 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 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 + +run_control: + 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] # 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 failed/aborted passes (client issue — do not loop)" + + # ================= WORKED EXAMPLE (pinned, deterministic — owner addendum 2026-08-28; slot-machine walk per governor 10323, corrected per 10390) ================= + # 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. + # Ledgers are SIDE-AWARE throughout: a BUY's acquired items are `filled` ONLY; + # unfilled/cancelled quantity is CASH (spent / still_committed / refunded). + # + # 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 + # = spent 0 + still_committed 2,230,000 + refunded 0 + # iron: buy-open, quoted 10,000 @ 145; cash_quoted 1,450,000 + # cosmic: buy-open, quoted 18,000 @ 122; cash_quoted 2,196,000 + # Unit buckets: quoted == still_open for every slot; filled/cancelled = 0. + # + # t1 wait, ~18 min: arrowtips PARTIAL fill 4,000/10,000 (a6 verdict PARTIAL). + # BUY ledger: quoted 10,000 == filled 4,000 + still_open 6,000 + cancelled 0. + # Acquired items = 4,000 ONLY; they sit in the GE collection box (collectible + # at any bank), while the offer keeps resting with 6,000 still open. Cash: + # cash_quoted 2,230,000 == spent 892,000 + still_committed 1,338,000. + # limit_ledger: first_fill_ts now, limit_used 4,000, limit_resets_at = +4h, + # remaining 6,000 (window CAPACITY — not an offer deadline). + # Iron: FULL fill 10,000/10,000 -> buy-filled-awaiting-collect (units sit in + # the box until ge_collect; the slot cannot sell them directly — finding 2). + # Iron cash: spent 1,450,000. Cosmic: no fill; offer keeps resting (edge + # positive, quote age within policy — finding 1: no-fill alone never aborts). + # + # t2 cadence -> schedule (pass 2): dispatch ONLY the dispatchable slot: + # iron buy-filled-awaiting-collect -> ge_collect (a9): withdraw 10,000 units; + # ledger reconciled (quoted 10,000 == filled 10,000; cash spent 1,450,000, + # no refund); sell edge positive -> ready-to-sell. + # arrowtips buy-open -> NO ACTION (resting, partial fill still open). + # cosmic buy-open -> NO ACTION (resting, no fill, edge positive). + # => the pass never closes or advances unrelated slots (regression fixture). + # + # t3 pass 3: iron ready-to-sell -> sell-open (a7): 10,000 @ 157 (high edge). + # + # t4 iron sell-open FULL fill -> sell-filled-awaiting-collect: proceeds + # 1,570,000 sit in the GE collection box until ge_collect. SELL ledger: + # quoted 10,000 == sold 10,000 + still_open 0 + returned_unsold 0. + # + # 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 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 + # reset restores buying capacity ONLY — the offer KEEPS RESTING at its edge; + # nothing is cancelled and the 4,000 filled units stay collectible in the + # box. Cosmic likewise unaffected. + # + # t7 arrowtips offer FULLY fills the remaining 6,000 -> buy-filled-awaiting- + # collect -> pass 5 ge_collect: withdraw 10,000 units (4,000 from the t1 + # partial + 6,000 from the t7 fills — the units are what the offer actually + # bought; NO phantom "refunded units"). BUY ledger: quoted 10,000 == filled + # 10,000; cash 2,230,000 == spent 2,230,000 (still_committed 0 — a full fill + # spends its whole quote). Sell edge positive -> ready-to-sell. + # limit_ledger: window 1 used 4,000 (t1); window 2 (post-reset) used 6,000. + # + # 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 (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 - + # 126 = 0 -> margin no longer positive -> quote_age_policy aborts the offer + # (ge_cancel, a8): ZERO units were filled, so the collection box receives + # refunded CASH 2,196,000 and NO items. BUY ledger: quoted 18,000 == filled 0 + # + still_open 0 + cancelled_unfilled 18,000; cash 2,196,000 == spent 0 + + # still_committed 0 + refunded 2,196,000. Sub-state -> buy-cancelled- + # awaiting-collect. (The 18,000 never-purchased units are cash, never + # "refunded units" — the phantom-items fixture.) + # + # t10 pass 8: cosmic buy-cancelled-awaiting-collect -> ge_collect: withdraw + # refunded CASH 2,196,000; ledger reconciled (refunded -> collected). The + # fresh-edge margin is still 0, so the slot is NOT re-quoted: ready-to- + # requote requires a positive margin — decide free, capital reallocated to a + # candidate that still clears (e.g. magic logs); a requote in a later window + # is governed by buy_capacity_ok as usual (the cancel did NOT renew the + # window, and the next reset does not cancel anything). + # + # t11 FIRST SCHEDULED BREAK after 6-10 passes (midpoint 8): every slot is + # resting (buy-open / sell-open), free, or collected (closed) — nothing + # dispatchable; 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. + # + # t12 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: + ban_level: low + 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 — offer lifetime is governed by quote_age_policy (abort only on a stale scanned edge or strategy reallocation), NEVER by the 4h buy-limit window. A limit reset merely restores buy capacity; it cancels nothing." + - "Partial fills are normal: filled units of a partial buy sit in the GE collection box (collectible at any bank) while the offer keeps resting; never cancel-and-replace a partially-filled offer mid-window just to churn — the side-aware ledger keeps the filled qty attached until the offer fully fills or is aborted per quote_age_policy." + - "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)" + - "OSRS wiki — Grand Exchange/Buying limits (4h window mechanics; the limit controls how many units may be bought and resets 4h after the first unit is bought; outstanding offers remain active until filled or explicitly aborted — a limit reset is NOT offer expiry)" + - "OSRS wiki — Grand Exchange + Bank (collection interface): items/coins from full or partial trades — including aborted offers — remain in the collection box until collected; partially completed trades can be collected at any bank" + - "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)" + - "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)" + - "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 + +related: + - low-alch-f2p + - f2p-alch-catalog + - mule-topup diff --git a/docs/action-grammar.md b/docs/action-grammar.md index 234e520..09cd594 100644 --- a/docs/action-grammar.md +++ b/docs/action-grammar.md @@ -68,8 +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` | 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` | collect finished GE transactions | — | — | — | +| `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/schemas/entry.schema.json b/schemas/entry.schema.json index d19b2d3..28eab82 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", @@ -175,6 +176,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": 10, + "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"], @@ -268,7 +302,7 @@ "click_button", "menu_option", "bank_inventory", "deposit_all", "withdraw", "has_item", "count_item", "move_to_inv", "sort_inventory", - "ge_open", "ge_buy", "ge_sell", "ge_offers_done", "ge_collect", + "ge_open", "ge_buy", "ge_sell", "ge_cancel", "ge_offers_done", "ge_collect", "ge_check_price", "attack", "prayer", "buff", "eat", "retreat", "is_dead", "respawn", "is_at", "item_count", "level_is", "xp_is", "hp_is", diff --git a/tools/validate.py b/tools/validate.py index 252d3dd..5323f8d 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -37,16 +37,59 @@ Runs: * the entry's economy.xp_per_hour (fast-xp headline) must sit within 2% of the fastest declared fast-xp site's modelled ores/hr x its ore xp. + - GE-margin entries (tags include flipping and/or merchanting — + the unambiguous margin signals): the 2% GE convenience fee is + rounded DOWN to whole coins per item and capped at 5,000,000 GP + per item, so net_per_cycle MUST equal + income - min(floor(0.02*income), 5_000_000) - cost. + Fractional-tax arithmetic (0.98*sell - buy) and uncapped tax + (6M on a 300M sale) are errors. + 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. + - GE-flip lifecycle (flipping/merchanting entries): the 4h buy-limit + ledger (limit_used / limit_resets_at) is DISTINCT from offer + expiry — a limit-window reset never cancels an offer and cancelling + never renews the limit; every slot is an independent asynchronous + sub-state machine (canonical set: free / buy-open / + buy-filled-awaiting-collect / buy-cancelled-awaiting-collect / + ready-to-sell / sell-open / sell-filled-awaiting-collect / + sell-cancelled-awaiting-collect / ready-to-requote / closed) driven + by a global scheduler pass that never touches unrelated slots; + ge_cancel moves filled items, refunded CASH, unsold units and + proceeds to the GE collection box for a separate ge_collect; + per-slot conservation is SIDE-AWARE (buy: quoted units = + filled + still_open + cancelled_unfilled and acquired items = + filled ONLY — the unfilled/cancelled quantity is cash, never + 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 + 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. Usage: python3 tools/validate.py [--stale-days 30] [--selftest] - --selftest: run the committed regression fixtures only. + --selftest: run the committed regression fixtures only (no tree scan) """ import datetime import json +import math +import re import sys from pathlib import Path @@ -621,12 +664,50 @@ def run_selftest(verbose=False): any("ge_open" in e for e in errs)) return errors +MARGIN_TAGS = {"flipping", "merchanting"} + + +def ge_floor_tax_error(rel, data): + """GE floor-tax gate for a single entry. + + Applies ONLY to the unambiguous margin tags (flipping/merchanting). + The generic `ge` tag is not proof that income_per_cycle is a taxed + GE resale: a training/processing/procurement entry may carry `ge` + while modeling unrelated income/cost, so it must not be gated by + this rule. Returns an error message, or None when the entry is not + gated or its floored-tax arithmetic is correct. + + Wiki: "The 2% tax rounds down to the nearest whole number, so items + sold for below 50 coins have a tax of less than one coin" — per-item + net MUST use the floored tax, never 0.98*sell - buy. The fee is also + capped at 5,000,000 GP per item (per-item cap, documented in the + flip-candidates best_practice/risk), so tax = min(floor(0.02*sell), + 5_000_000). Owner decision 2026-08-28: one global formula for all + entries (option A), no per-entry tax-model discriminator. + """ + tags = {str(t).lower() for t in data.get("tags", [])} + if not tags & MARGIN_TAGS: + return None + eco = data.get("economy") or {} + if not all(k in eco for k in ("income_per_cycle", "cost_per_cycle", "net_per_cycle")): + return None + sell, buy = eco["income_per_cycle"], eco["cost_per_cycle"] + tax = min(math.floor(sell * 0.02), 5_000_000) + expected = sell - tax - buy + if eco["net_per_cycle"] != expected: + return ( + f"{rel}: net_per_cycle {eco['net_per_cycle']} != capped floored-tax " + f"model {expected} (sell {sell} - min(floor({sell}*0.02), 5_000_000)" + f"={tax} - buy {buy}); " + f"GE tax rounds down per item and is capped at 5,000,000 GP per item " + f"— fractional 0.98*sell - buy and uncapped tax are invalid") + return None def alch_worked_example_regression(rel, data, raw_text): """One-cast model worked-example regression (owner guidance 2026-08-28, - PR #8 repair, merged via origin/main into issue-4): entries with the - owner-selected one-cast iteration model must pin a worked example with + PR #8 repair, merged via origin/main during branch reconciliation): + entries with the MONOTONIC, INDEPENDENT counters — breaks every 550 casts (1,100, 1,650, ...) regardless of restocks, and restocks every 901 casts (1,802, ...), so restocking never resets or shifts the break counter. Scoped to @@ -727,6 +808,675 @@ def alch_accounting_regression(rel, data, raw_text): return errs +# --- GE-flip lifecycle gates (owner guidance 2026-08-28, governor 10323 + 10390) --- +# 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. A COMPLETED offer leaves its assets in the GE +# collection box until ge_collect: fully filled buys and sells each get an +# explicit *-filled-awaiting-collect state (governor 10390 finding 2). +SLOT_SUB_STATES = [ + "free", # no offer, nothing held + "buy-open", # resting buy offer (partial fills keep resting) + "buy-filled-awaiting-collect", # buy FULLY filled — units sit in the GE collection box until ge_collect + "buy-cancelled-awaiting-collect", # aborted buy: bought units + refunded CASH in the box + "ready-to-sell", # units in hand (ge_collect'ed), sell edge positive + "sell-open", # resting sell offer (partial sells tracked) + "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", # 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 = { + "free": "quote", + "buy-open": None, # resting; fill/abort decided by policies + "buy-filled-awaiting-collect": "collect", + "buy-cancelled-awaiting-collect": "collect", + "ready-to-sell": "sell", + "sell-open": None, # resting; fill/abort decided by policies + "sell-filled-awaiting-collect": "collect", + "sell-cancelled-awaiting-collect": "collect", + "ready-to-requote": "requote", + "closed": "release", # archival release: closed -> free (governor 10471) +} + + +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. 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) + + +# Side-aware GE bucket names (governor 10390 finding 4). +# BUY: the player commits CASH; acquired items are `filled` ONLY — the +# unfilled/cancelled quantity is an unspent-CASH refund, never "refunded +# units". SELL: the player holds units; sold units yield proceeds. +BUY_UNIT_BUCKETS = ["filled", "still_open", "cancelled_unfilled"] +SELL_UNIT_BUCKETS = ["sold", "still_open", "returned_unsold"] +# Physically impossible co-occurrences per side (cancel is atomic: a live +# offer has no remainder once cancelled). +IMPOSSIBLE_UNIT_PAIRS = { + "buy": [("still_open", "cancelled_unfilled")], + "sell": [("still_open", "returned_unsold")], +} + + +def conservation_violations(record): + """Side-aware per-slot GE conservation (governor 10390 finding 4). + + record: { + 'side': 'buy'|'sell', # REQUIRED — the ledger is + 'quoted': {item: qty}, # side-aware + **unit buckets (side-specific): # {item: qty} + 'cash_quoted': int, # BUY: cash committed + 'spent'/'still_committed'/'refunded': int, # BUY cash partition + 'proceeds_in_box'/'cash_collected': int, # SELL proceeds + 'sub_state': str, # phase gate input + } + + BUY invariants: + - unit partition: quoted == filled + still_open + cancelled_unfilled + per item; + - ACQUIRED ITEMS == filled ONLY: any sell-side unit bucket (sold / + returned_unsold) or a 'refunded' unit bucket on a buy record is a + violation — the unfilled/cancelled quantity is CASH (phantom + "refunded units" guard); + - cash partition: cash_quoted == spent + still_committed + refunded + (the unspent remainder of a buy is the cash refund); + - no item may be both still_open and cancelled_unfilled (cancel is + atomic). + SELL invariants: + - unit partition: quoted == sold + still_open + returned_unsold per + item; + - no item may be both still_open and returned_unsold (cancel is + atomic); sold + still_open IS legal (partial sell rest). + Shared: no negative quantities. + """ + out = [] + side = record.get("side", "buy") + if side not in ("buy", "sell"): + out.append(f"record side {side!r} must be 'buy' or 'sell'") + return out + buckets = BUY_UNIT_BUCKETS if side == "buy" else SELL_UNIT_BUCKETS + foreign = {"filled", "still_open", "cancelled_unfilled", "sold", + "returned_unsold"} - set(buckets) + for b in sorted(foreign): + if record.get(b): + out.append( + f"{side} ledger uses {b} units — invalid for side {side}: " + f"buy-side unfilled/cancelled quantity is CASH " + f"(refunded), never {b} units; sell-side units partition " + f"into sold/still_open/returned_unsold") + if side == "buy": + # A unit map under a CASH field is the phantom-items guard: a buy's + # unfilled/cancelled quantity is an unspent-CASH refund, never + # "refunded units" (governor 10390 finding 4). + for c in ("cash_quoted", "spent", "still_committed", "refunded"): + if isinstance(record.get(c), dict): + out.append( + f"buy ledger carries {c} as units — the unfilled/" + f"cancelled quantity of a buy is CASH (refunded), " + f"never {c} units (phantom items)") + 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} != " + f"{' + '.join(buckets)} ({total})") + in_b = {b for b in buckets if item in record.get(b, {})} + for a, b in IMPOSSIBLE_UNIT_PAIRS[side]: + if (a in in_b and b in in_b + and record.get(a, {}).get(item) + and record.get(b, {}).get(item)): + out.append( + f"item '{item}' appears in both {a} and {b} — " + f"physically impossible (a cancelled offer has no " + f"live remainder)") + if side == "buy": + cash_fields = ["cash_quoted", "spent", "still_committed", "refunded"] + for c in cash_fields: + if isinstance(record.get(c), (int, float)) and record[c] < 0: + out.append(f"{c} is negative ({record[c]})") + cash_vals = {c: record.get(c) for c in cash_fields} + if all(isinstance(v, (int, float)) for v in cash_vals.values()): + if cash_vals["cash_quoted"] != ( + cash_vals["spent"] + cash_vals["still_committed"] + + cash_vals["refunded"]): + out.append( + f"buy cash quoted {cash_vals['cash_quoted']} != spent " + f"{cash_vals['spent']} + still_committed " + f"{cash_vals['still_committed']} + refunded " + f"{cash_vals['refunded']} — the unspent remainder of a " + f"buy is the CASH refund") + else: + for c in ("proceeds_in_box", "cash_collected"): + if record.get(c, 0) < 0: + out.append(f"{c} is negative ({record[c]})") + return out + + +def slot_phase_violations(record): + """Phase gates (governor 10390 finding 4): assets may not be reused or + a slot closed before the GE collection box is withdrawn. + + - 'buy-filled-awaiting-collect': filled must equal quoted (fully + filled) and NOTHING may have been sold yet — a full buy that sells + before ge_collect is a violation (owned units cannot leave the + collection box early). + - 'sell-filled-awaiting-collect': sold units require proceeds_in_box + > 0 — proceeds arrive in the box as units fill and only leave via + ge_collect. + - 'closed': nothing may remain uncollected — proceeds_in_box must be + 0 and no cancelled/returned units may still be in the box (a full + sell may not close before proceeds collection). + """ + out = [] + sub = record.get("sub_state") + side = record.get("side", "buy") + sold = sum((record.get("sold") or {}).values()) + filled = sum((record.get("filled") or {}).values()) + quoted = sum((record.get("quoted") or {}).values()) + if side == "buy" and sub == "buy-filled-awaiting-collect": + if sold: + out.append( + "buy-filled-awaiting-collect slot has sold units — a full " + "buy cannot sell before ge_collect") + if filled != quoted: + out.append( + f"buy-filled-awaiting-collect slot is not fully filled " + f"(filled {filled} != quoted {quoted})") + if sub == "sell-filled-awaiting-collect" and sold and not record.get("proceeds_in_box"): + out.append( + "sell-filled-awaiting-collect slot has sold units but no " + "proceeds_in_box — proceeds arrive in the collection box on " + "fill and leave only via ge_collect") + if sub == "closed": + if record.get("proceeds_in_box"): + out.append( + "closed slot still has proceeds_in_box — a full sell may " + "not close before proceeds collection (ge_collect first)") + cancelled = sum((record.get("cancelled_unfilled") or {}).values()) + returned = sum((record.get("returned_unsold") or {}).values()) + if cancelled or returned: + out.append( + "closed slot still holds cancelled/returned units — " + "collect them before closing the slot") + 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 + + +# 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): + + - 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; + '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. + """ + 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 not trans.get(state): + errors.append(f"{rel}: slot sub-state '{state}' has no 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("->") + 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") + # 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 + + +def run_flip_selftest(verbose=False): + """Regression fixtures for the GE floor-tax + GE-flip lifecycle gates + (merged from PR #7 / main reconciliation); 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"], + {"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 16.12}, True), + # 5000 - floor(5000*0.02) - 3000 = 1900 != 2000: this fixture's + # economics deliberately do NOT satisfy the flip equation, so the + # old `ge`-tag scoping would false-positive on it (2000 != 1900). + ("non-margin entry with only 'ge' + 'processing'", ["ge", "processing"], + {"income_per_cycle": 5000, "cost_per_cycle": 3000, "net_per_cycle": 2000}, False), + ("correctly-floored flipping entry", ["flipping", "merchanting"], + {"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17}, False), + # sell 300,000,000 -> uncapped floor tax is 6,000,000; the per-item + # cap makes it 5,000,000, so the correct net is 1,000,000. The old + # uncapped rule demanded net=0 and rejected this valid entry. + ("high-value flip above the 5,000,000 GP per-item tax cap", + ["flipping"], + {"income_per_cycle": 300_000_000, "cost_per_cycle": 294_000_000, + "net_per_cycle": 1_000_000}, False), + ] + 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 + passed = got_error == expect_error + if verbose: + print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") + if not passed: + errors.append( + 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-buy-fill / no-fill-buy case and prove no + # unrelated slot is closed or advanced. Under governor 10390 finding 2 + # a FULL buy fill goes to buy-filled-awaiting-collect (assets sit in + # the GE collection box) — the pass dispatches ge_collect, it never + # lets the slot sell directly. + slot_states = {"arrowtips": "buy-open", # partial fill 4,000/10,000 — resting + "iron": "buy-filled-awaiting-collect", # full fill — ge_collect this pass + "cosmic": "buy-open"} # no fill, edge positive — resting + dispatched = {name: scheduler_dispatch(s) for name, s in slot_states.items()} + ok = dispatched == {"arrowtips": None, "iron": "collect", "cosmic": None} + check("worked example: partial-buy + full-buy-fill + no-fill slots dispatch only the iron slot to ge_collect; 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) + check("a fully filled buy must pass through buy-filled-awaiting-collect (ge_collect) before any sell", + scheduler_dispatch("buy-filled-awaiting-collect") == "collect" + and scheduler_dispatch("sell-filled-awaiting-collect") == "collect" + and scheduler_dispatch("ready-to-sell") == "sell") + + # --- side-aware conservation invariants (finding 4) --- + # BUY balanced snapshot mirroring the worked example t1: 10,000 Rune + # arrowtips quoted @ 223; 4,000 filled (units in the GE collection + # box), 6,000 still open on the resting offer; cash 2,230,000 = + # spent 892,000 + still_committed 1,338,000 + refunded 0. + buy_partial = { + "side": "buy", + "quoted": {"Rune arrowtips": 10_000}, + "filled": {"Rune arrowtips": 4_000}, + "still_open": {"Rune arrowtips": 6_000}, + "cancelled_unfilled": {}, + "cash_quoted": 2_230_000, "spent": 892_000, + "still_committed": 1_338_000, "refunded": 0, + } + check("conservation: BUY side — partial fill splits units into filled + still_open and cash into spent + still_committed (partition sums hold, zero violations)", + conservation_violations(buy_partial) == []) + buy_cancelled = dict(buy_partial, still_open={}, + cancelled_unfilled={"Rune arrowtips": 6_000}, + still_committed=0, refunded=1_338_000) + check("conservation: BUY side — a cancelled partial buy carries filled units + refunded CASH (still_open -> cancelled_unfilled + refunded), zero violations", + conservation_violations(buy_cancelled) == []) + phantom_rec = dict(buy_cancelled, refunded={"Rune arrowtips": 6_000}) + check("conservation: BUY side — 'refunded UNITS' on a buy is a phantom (unfilled/cancelled quantity is CASH): violation", + any("CASH" in v or "invalid for side" in v + for v in conservation_violations(phantom_rec))) + sold_on_buy = dict(buy_partial, sold={"Rune arrowtips": 4_000}) + check("conservation: BUY side — a full buy that sells before ge_collect (sold units on a buy ledger) is a violation", + any("invalid for side" in v for v in conservation_violations(sold_on_buy))) + buy_cash_bad = dict(buy_partial, still_committed=1_380_000) + check("conservation: BUY side — cash partition mismatch (spent + still_committed + refunded != cash_quoted) is a violation", + any("cash quoted" in v for v in conservation_violations(buy_cash_bad))) + # SELL balanced: 10,000 sold @ 157, proceeds 1,570,000 in the box. + sell_full = { + "side": "sell", + "quoted": {"Iron bar": 10_000}, + "sold": {"Iron bar": 10_000}, + "still_open": {}, "returned_unsold": {}, + "proceeds_in_box": 1_570_000, "cash_collected": 0, + } + check("conservation: SELL side — full sell partitions sold + still_open + returned_unsold, proceeds in the box (zero violations)", + conservation_violations(sell_full) == []) + sell_partial = dict(sell_full, sold={"Iron bar": 4_000}, + still_open={"Iron bar": 6_000}, + proceeds_in_box=628_000) + check("conservation: SELL side — a partial sell legitimately spans sold + still_open (zero violations)", + conservation_violations(sell_partial) == []) + sell_dup = dict(sell_full, sold={"Iron bar": 6_000}, + still_open={"Iron bar": 3_000}, + returned_unsold={"Iron bar": 1_000}) + check("conservation: SELL side — units both in a live offer AND returned (impossible pair) is a violation", + any("both still_open and returned_unsold" in v + for v in conservation_violations(sell_dup))) + over_rec = dict(buy_partial, filled={"Rune arrowtips": 7_000}) + check("conservation: over-reported buckets break the quoted partition (sum 13,000 != quoted 10,000)", + any("quoted 10000 !=" in v for v in conservation_violations(over_rec))) + neg_rec = dict(sell_full, sold={"Iron bar": -1}) + check("conservation: a negative bucket is a violation", + any("negative" in v for v in conservation_violations(neg_rec))) + + # --- phase gates (finding 4: assets cannot be reused/closed pre-collect) --- + check("phase gate: a full buy that sells before ge_collect (buy-filled-awaiting-collect with sold units) is rejected", + any("cannot sell before ge_collect" in v + for v in slot_phase_violations(dict(buy_partial, sub_state="buy-filled-awaiting-collect", + filled={"Rune arrowtips": 10_000}, still_open={}, + sold={"Rune arrowtips": 10_000})))) + check("phase gate: a full sell that closes before proceeds collection (closed with proceeds_in_box > 0) is rejected", + any("before proceeds collection" in v + for v in slot_phase_violations(dict(sell_full, sub_state="closed")))) + check("phase gate: a sell-filled-awaiting-collect slot with sold units but no proceeds_in_box is rejected", + any("no proceeds_in_box" in v + for v in slot_phase_violations(dict(sell_full, sub_state="sell-filled-awaiting-collect", + proceeds_in_box=0)))) + check("phase gate: a closed slot that has been fully collected (no proceeds_in_box, no cancelled/returned units) passes", + slot_phase_violations(dict(sell_full, sub_state="closed", + proceeds_in_box=0, cash_collected=1_570_000)) == []) + + # --- 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 + governor 10390) --- + margin_tags = ["flipping"] + ssm_ok = { + "sub_states": SLOT_SUB_STATES, + "slot_transitions": { + "free": ["buy-open"], + "buy-open": ["buy-filled-awaiting-collect", + "buy-cancelled-awaiting-collect"], + "buy-filled-awaiting-collect": ["ready-to-sell", "free"], + "buy-cancelled-awaiting-collect": ["ready-to-sell", "ready-to-requote", "free"], + "ready-to-sell": ["sell-open"], + "sell-open": ["sell-filled-awaiting-collect", + "sell-cancelled-awaiting-collect"], + "sell-filled-awaiting-collect": ["closed"], + "sell-cancelled-awaiting-collect": ["ready-to-requote", "closed"], + "ready-to-requote": ["buy-open", "sell-open", "free"], + "closed": ["free"], # archival release: physical slot reusable (governor 10471) + }, + "slot_transition_whens": [ + "free -> buy-open: quote placed", + "buy-open -> buy-filled-awaiting-collect: offer FULLY filled — units sit in the GE collection box until ge_collect", + "buy-open -> buy-cancelled-awaiting-collect: abort on stale edge (margin no longer positive) or strategy reallocation — never on the 4h limit reset", + "buy-filled-awaiting-collect -> ready-to-sell: ge_collect withdrawn units; sell edge positive", + "buy-filled-awaiting-collect -> free: ge_collect withdrawn; margin gone, slot released", + "buy-cancelled-awaiting-collect -> ready-to-sell: collected, sell edge positive", + "buy-cancelled-awaiting-collect -> ready-to-requote: collected; margin positive and capacity remains", + "buy-cancelled-awaiting-collect -> free: collected, full cash refund, slot freed", + "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 + ARCHIVED", + "sell-cancelled-awaiting-collect -> ready-to-requote: collected, requote", + "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", + ], + "scheduler_iteration": "one pass over all slots; dispatch pending actions only", + "quote_age_policy": "offers rest while the scanned margin stays positive; aborted only on a stale edge or strategy reallocation — the 4h limit reset never cancels an offer", + "persisted_state": "per-slot record: sub-state + side-aware quoted/filled/sold/collected buckets", + "limit_ledger": "limit_used + first_fill_ts + limit_resets_at, independent of offer expiry; cancellation never renews it", + "conservation": "side-aware: buy quoted units = filled + still_open + cancelled_unfilled and acquired items = filled only; sell quoted = sold + still_open + returned_unsold", + "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 10-state 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)) + 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 + + def main(): global STALE_DAYS if "--stale-days" in sys.argv: @@ -735,8 +1485,14 @@ def main(): errors, warnings = [], [] if "--selftest" in sys.argv: errors += run_selftest(verbose=True) + errors += run_flip_selftest(verbose=True) print(f"RESULT: selftest, {len(errors)} errors, 0 warnings") return 1 if errors else 0 + + # The fixes themselves are gated: both regression suites run on every + # invocation (mining model + GE flip lifecycle), not just --selftest. + errors += run_selftest(verbose=False) + errors += run_flip_selftest(verbose=False) entries = load_entries() if not entries: print("ERROR: no codex entries found under codex/") @@ -809,6 +1565,17 @@ def main(): # 2b. structured mining-model gates (pickaxe gates + run-mode contract) errors += mining_model_errors(rel, data) + # GE-margin floor-tax arithmetic (see ge_floor_tax_error): scoped to + # the unambiguous margin tags flipping/merchanting. The generic `ge` + # tag alone must not gate this rule — alchemy/processing/procurement + # entries can carry `ge` with income unrelated to a taxed GE resale. + 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": try: