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
This commit is contained in:
Claude 2026-08-28 04:01:20 -05:00
parent 3572255a11
commit 02b366c7ef
4 changed files with 643 additions and 155 deletions

View File

@ -3,22 +3,31 @@ title: GE Flipping (margin trading)
domain: economy domain: economy
tags: [flipping, merchanting, ge, money-making, hybrid] tags: [flipping, merchanting, ge, money-making, hybrid]
status: verified 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 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 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 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 edge, harvesting the spread on high-volume items. There are no skill or
quest requirements — capital breadth and slot discipline are the whole quest requirements — capital breadth and slot discipline are the whole
game. Members accounts run 8 GE slots over the full candidate set; F2P 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 accounts get 3 slots and F2P-tradeable rows only (separate F2P path in
profit_drivers). Lifecycle (owner addendum 2026-08-28): the entry models profit_drivers). Model (governor 10323, 2026-08-28): every GE slot is an
the complete runnable flow — capital allocation, per-slot quotes with a INDEPENDENT asynchronous sub-state machine (free / buy-open /
quoted/filled/sold/collected ledger, partial fills, ge_cancel + buy-cancelled-awaiting-collect / ready-to-sell / sell-open /
requote recovery on window lapse or stale prices, collection and sell-cancelled-awaiting-collect / ready-to-requote / closed) driven by a
reconciliation, breaks on resting offers, and explicit stop conditions — global scheduler pass that acts only on dispatchable slots and never
with a pinned worked example and realized (not idealised) rates. 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: accounts:
membership: hybrid # members: 8 slots/full set; F2P: 3 slots/F2P rows (see economy) 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 - 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 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 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)" source: "OSRS wiki — Grand Exchange (Buy limits; Grand Exchange/Buying limits)"
- name: run the path that matches the account's slot set - 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 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 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 / 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." 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)" 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: keep a per-slot offer ledger; cancel-and-requote on lapse, never strand units - 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. 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 ge_cancel moves filled items, refunds, unsold units and unspent coins to the GE
window lapse or a stale price the slot is ge_cancel'ed — acquired units return to COLLECTION BOX (never directly to inventory); a separate ge_collect withdraws
inventory, unspent cash refunds — then re-quoted at the fresh edge (buy) or exited them, and the ledger is reconciled (conservation: quoted == still_offered +
at the current high edge (sell). No items, coins, or GE slots are ever stranded." collected + refunded + sold, cash likewise) before the slot is reused. The 4h
source: "OSRS wiki — Grand Exchange (offer mechanics; 4h window); docs/action-grammar.md (ge_cancel, added 2026-08-28)" 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: economy:
# Model (documented, reproducible from the snapshot): # Model (documented, reproducible from the snapshot):
@ -88,24 +108,28 @@ economy:
# members flags cross-checked against the wiki item pages (all agree). Top 8 # members flags cross-checked against the wiki item pages (all agree). Top 8
# by 4h/slot below in profit_drivers. # by 4h/slot below in profit_drivers.
# #
# Lifecycle qualification (owner addendum 2026-08-28): the 4h figures are # Lifetime qualification (governor 10323 finding 5, 2026-08-28):
# an economic CEILING — ideal full fills of every slot with zero overhead. # ceiling_gp_per_hour (149,500) is the TYPED ECONOMIC CEILING: ideal full
# The realized operating range is: # fills of every slot, zero overhead — never presented as an achieved
# realized gp/h = ceiling x fill_factor x duty_cycle, where # runtime rate. gp_per_hour_low/high are DOCUMENTED REALIZED SCENARIO
# fill_factor ~0.5-0.9 (partial fills, cancels, requotes reduce the # BOUNDS (not uncited multipliers), each with explicit arithmetic in
# filled volume) and duty_cycle ~0.90-0.95 (scan + place + collect # profit_drivers:
# ~3 min per ~40-min rotation; breaks every 6-10 rotations). # high 138,000 = 149,500 x 37/40 — 'resting-fill' scenario: all 8 slots
# Example midpoint: 149,500 x 0.70 x 0.92 ≈ 96,300 gp/h. # full-fill at their quoted edges; each 40-min rotation is 37 min of
# gp_per_hour_low (74,750) = 50% of the ceiling — a conservative realized # resting waits + 3 min of scan/place/collect actions.
# lower bound; gp_per_hour_high (149,500) = the named ceiling, never # low 74,750 = 149,500 x 0.50 — 'requote-heavy' scenario: every 4h
# presented as the achieved runtime rate. # 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: Rune arrowtips # anchor: top candidate by modeled 4h/slot (members)
item_id: 44 item_id: 44
cost_per_cycle: 223 # buy @ low edge per unit (snapshot 2026-08-27) 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) 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) 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 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_high: 149500 # 4h buy-limit CEILING: sum(4h/slot) / 4 (8 members slots, ideal full fills, zero overhead) — qualified above 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 xp_per_hour: 0 # flipping awards no XP
supplies: supplies:
- item: Rune arrowtips # canonical wiki names per action-grammar conventions - 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" - "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)" - "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" - "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: state_machine:
initial: idle initial: idle
terminal: [complete, failed] terminal: [complete, failed]
states: states:
idle: 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] next: [scan, complete]
scan: scan:
goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax" goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax (a3)"
next: [buy, idle] next: [schedule, idle]
buy: schedule:
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" 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_buy_fill, idle] next: [wait, collect]
wait_buy_fill: wait:
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" 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: [sell, cancel_requote] next: [schedule, collect]
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]
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)" 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: [scan, complete, idle] next: [schedule, scan, complete, idle]
complete: complete:
goal: "session GP target reached" goal: "session GP target reached"
failed: failed:
@ -169,40 +260,25 @@ state_machine:
to: complete to: complete
- from: scan - from: scan
when: "at least one net-positive candidate at the scanned band edges (floored+capped tax)" when: "at least one net-positive candidate at the scanned band edges (floored+capped tax)"
to: buy to: schedule
- from: scan - from: scan
when: "no net-positive candidate on 3 consecutive scans (prices stale — stand down)" when: "no net-positive candidate on 3 consecutive scans (prices stale — stand down)"
to: idle to: idle
- from: buy - from: schedule
when: "all free slots quoted (quoted qty recorded per slot)" when: "pass placed or requoted offers (quotes outstanding) — rest until the next cadence"
to: wait_buy_fill to: wait
- from: buy - from: schedule
when: "capital exhausted before quoting (insufficient funds)" when: "pass marked slots awaiting collection (ge_cancel happened) — withdraw before further dispatch"
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"
to: collect to: collect
- from: wait_sell_fill - from: wait
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" when: "600 s cadence reached — re-check offers and re-run the scheduler pass"
to: cancel_requote 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 - from: collect
when: "session GP target reached" when: "session GP target reached"
to: complete to: complete
@ -252,11 +328,11 @@ actions:
verb: ge_buy verb: ge_buy
target: grand-exchange target: grand-exchange
params: params:
item: "per candidate slot" item: "per dispatchable free slot (scheduler pass)"
qty: "min(remaining 4h limit, slot capacity, capital available)" qty: "min(remaining 4h limit, slot capacity, capital available) — buy_capacity_ok(qty + limit_used <= limit)"
price: "low edge (rounded down)" price: "low edge (rounded down)"
max_price: "low edge — never instant" 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 on_fail: skip
anti_ban: anti_ban:
delay: [900, 2700] delay: [900, 2700]
@ -266,7 +342,7 @@ actions:
verb: sleep verb: sleep
params: params:
delay: [6000, 12000] 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 on_fail: retry
anti_ban: anti_ban:
delay: [300, 900] delay: [300, 900]
@ -276,9 +352,9 @@ actions:
verb: ge_offers_done verb: ge_offers_done
target: "all quoted slots" target: "all quoted slots"
params: params:
phase: "buy (no pending sells) | sell (no pending buys)" phase: "per-slot: buy-open (buy outstanding) | sell-open (sell outstanding)"
per_slot: "filled_qty vs quoted_qty for every slot (partial fills included)" per_slot: "still_offered vs quoted for every slot (partial fills stay attached to the resting offer)"
verdict: "full | partial | none | 4h-lapse" verdict: "full | partial | none | lapse | stale-edge"
on_fail: retry on_fail: retry
- id: a7 - id: a7
@ -286,31 +362,33 @@ actions:
verb: ge_sell verb: ge_sell
target: grand-exchange target: grand-exchange
params: params:
item: "per slot holding units (filled_qty)" item: "per ready-to-sell slot (units in hand after full fill or ge_collect)"
qty: "filled_qty of that slot" qty: "units in hand for that slot"
price: "high edge (rounded up)" price: "high edge (rounded up)"
ledger: "record sold qty per slot" ledger: "record sold qty per slot; sub-state -> sell-open"
on_fail: skip on_fail: skip
anti_ban: anti_ban:
delay: [900, 2700] delay: [900, 2700]
- id: a8 - id: a8
step: cancel-requote step: cancel-to-box
verb: ge_cancel verb: ge_cancel
target: "timed-out or stale slot offers (current phase)" target: "lapsed or stale slot offers (current phase)"
params: params:
offers: "the slots that hit the 4h window lapse or a stale price" offers: "the slots that hit the 4h window lapse or a stale edge"
cancelled: "acquired units return to inventory; unspent cash refunds; slot freed" moved: "filled items + refunded units + unsold units + unspent coins ALL move to the GE COLLECTION BOX (never directly to inventory)"
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" 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 on_fail: retry
- id: a9 - id: a9
step: collect-and-log step: collect-and-reconcile
verb: ge_collect verb: ge_collect
target: grand-exchange target: grand-exchange
params: params:
deposit: "coins to bank" withdraw: "from the GE collection box: filled items, refunds, unsold units, sale proceeds"
reconcile: "inventory empty of flip items, all GE slots free, per-slot ledger closed" 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)" log: "RESULT item, qty, net gp per slot (floored+capped tax)"
on_fail: alert on_fail: alert
anti_ban: anti_ban:
@ -322,64 +400,105 @@ actions:
target: "current location" target: "current location"
params: params:
duration: "[60, 240] seconds of idle AFK per break (matches run_control.break.for)" 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" 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), continue from the current phase (a4/a7/a8/a9) — 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 on_fail: skip
run_control: 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 max_iterations: null
break: 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 for: [60, 240] # seconds of idle AFK
stop_conditions: stop_conditions:
- "session GP target reached" - "session GP target reached"
- "capital under floor" - "capital under floor"
- "no net-positive candidates on 3 consecutive scans" - "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 # 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)). # (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. # t0 idle -> scan -> schedule (pass 1): all slots free. Scheduler dispatch:
# Scan bands (a3): only net-positive slots are quoted. Per-slot capital needs: # free -> quote. Slot ledger after the pass (conservation holds):
# arrowtips 223x10,000 = 2.23M; iron bar 145x10,000 = 1.45M; cosmic 122x18,000 = # arrowtips: buy-open, quoted 10,000 @ 223; cash_quoted 2,230,000
# 2.196M; silver ore 73x13,000 = 0.949M; swordfish 206x15,000 = 3.09M; lobster # iron: buy-open, quoted 10,000 @ 145; cash_quoted 1,450,000 (limit_ledger entry: limit_used 0, first_fill_ts -, limit_resets_at -)
# 106x15,000 = 1.59M; magic logs 772x12,000 = 9.264M; mud 96x18,000 = 1.728M # cosmic: buy-open, quoted 18,000 @ 122; cash_quoted 2,196,000
# (total 22.497M <= 22.5M). Ledger: quoted=filled=sold=collected=0 per slot. # Buckets: still_offered == quoted for every slot; collected/refunded/sold = 0.
# Step 1 — place buys (a4): quote all 8 slots at the low edges (qty = 4h limit). #
# wait_buy_fill (a5/a6). # t1 wait, ~18 min: arrowtips PARTIAL fill 4,000/10,000 (a6 verdict PARTIAL).
# Step 2 — PARTIAL FILL: arrowtips fills 4,000 of 10,000 after ~18 min -> check-fills # Ledger: arrowtips quoted 10,000 == still_offered 6,000 + collected_items 4,000
# (a6) verdict PARTIAL: ledger arrowtips.filled = 4,000. The offer KEEPS resting # (the 4,000 units sit in the player's possession — GE box — attached to the
# (acquired units stay attached to the resting offer; nothing is stranded). # resting offer; nothing is stranded). limit_ledger: limit_used 4,000,
# Step 3 — FULL FILL: iron bar fills all 10,000 -> transition to sell (a7): place # first_fill_ts now, limit_resets_at = first_fill_ts + 4h; remaining 6,000.
# sell 10,000 @ 157 (high edge). wait_sell_fill -> fills -> collect (a9): # Cosmic: no fill; iron: FULL fill 10,000 -> ready-to-sell.
# 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. # t2 cadence -> schedule (pass 2): dispatch ONLY the dispatchable slot:
# Step 4 — NO-FILL / STALE recovery on the buy side: cosmic 18,000 @ 122 is unfilled # iron ready-to-sell -> sell-open (a7): 10,000 @ 157 (high edge).
# approaching the 4h lapse -> cancel_requote (a8, ge_cancel): 0 units return, # arrowtips buy-open -> NO ACTION (resting, quote age within policy).
# 2,196,000 cash refunds, slot freed; re-scan (a3): cosmic still net-positive -> # cosmic buy-open -> NO ACTION (resting, no fill, quote age within policy).
# requote 18,000 @ 122 on the fresh window. (If the buy margin had vanished the # => the pass never closes or advances unrelated slots (regression fixture).
# slot is freed on the buy side; any units acquired from a cancelled offer still # Iron ledger: quoted 10,000 == still_offered 0 + sold 10,000 (buckets move on fill).
# sell at the current high edge.) #
# Step 5 — PARTIAL matures at lapse: arrowtips still 4,000/10,000 -> ge_cancel: # t3 iron sell-open FULL fill -> collect: proceeds 1,570,000; tax floor(157 x 0.02)
# 4,000 units return to inventory, 6,000 x 223 = 1,338,000 refunded -> sell (a7): # = 3/item -> net = (157 - 3 - 145) x 10,000 = 90,000. ge_collect (a9):
# 4,000 @ 244 -> tax floor(244 x 0.02) = 4/item -> net = (244-4-223) x 4,000 = # cash withdrawn, ledger reconciled, sub-state -> closed, slot freed.
# 68,000. Collect (a9) + reconcile. # Conservation: quoted 10,000 == sold 10,000 (all other buckets 0).
# 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 # t4 LAPSE / stale-edge handling (a8): at the 4h window edge, cosmic still has NO
# state (offers + per-slot ledger) persists server-side. take-break 60-240 s # fill -> ge_cancel: the offer's cash 2,196,000 moves to the COLLECTION BOX as a
# (a10); RESUME at a2 (re-open GE) -> a6 (check-fills) -> continue the current # refund. Ledger: cash_quoted 2,196,000 == still_offered_cash 0 + refunded_cash
# phase (a4 buys / a7 sells / a8 cancel-requote / a9 collect). Breaks never strand # 2,196,000 (collected_cash 0). Sub-state -> buy-cancelled-awaiting-collect.
# an offer and never interrupt a collect/place in progress. # arrowtips reaches limit_resets_at with 6,000 still offered -> ge_cancel:
# Step 7 — STOP: session target (e.g. 900,000 net) -> collect -> complete; capital # filled 4,000 (already in player possession) + refunded 6,000 units + unspent
# fell below floor -> idle -> complete; 3 consecutive aborted offers -> failed. # cash 6,000 x 223 = 1,338,000 ALL move to the collection box. Ledger:
# Rate qualification: the 149,500 gp/h figure is the 4h buy-limit CEILING (8 slots, # quoted 10,000 == collected_items 4,000 + refunded 6,000; limit_used STAYS
# full fills, zero overhead). Realized midpoint ~ 149,500 x 0.70 (fill factor) x # 4,000 (the cancel does NOT renew the limit; remaining 6,000 stands).
# 0.92 (duty cycle incl. scan/place/collect + breaks) ~ 96,300 gp/h — the entry's # Sub-state -> buy-cancelled-awaiting-collect for both slots.
# low bound 74,750 (50% ceiling) sits under that; the ceiling is never presented #
# as an achieved runtime rate. # 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: risk:
@ -387,17 +506,20 @@ risk:
notes: notes:
- "Flipping is standard gameplay; the detectable signal is quote churn — keep per-offer pacing humanised, never instant-buy/sell loops." - "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." - "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." - "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: 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 (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)" - "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/<Item>, all agree)" - "prices.runescape.wiki/api/v1/osrs/mapping (item ids, members flags, limits — limits cross-checked against Module:Exchange/<Item>, all agree)"
- "prices.runescape.wiki/api/v1/osrs/latest (high/low price band snapshot 2026-08-27)" - "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 verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27
related: related:

View File

@ -68,9 +68,9 @@ Legend for the mapping table:
| `ge_open` | open GE at a bank | — | — | — | | `ge_open` | open GE at a bank | — | — | — |
| `ge_buy` | buy `qty` of `item` at offer price, paced | — | — | — | | `ge_buy` | buy `qty` of `item` at offer price, paced | — | — | — |
| `ge_sell` | sell `qty` of `item`, confirm | — | — | — | | `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_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 | — | — | — | | `ge_check_price` | read current buy/sell price of an item | — | — | — |
## F. Combat ## F. Combat

View File

@ -43,6 +43,7 @@
"net_per_cycle": { "type": "number" }, "net_per_cycle": { "type": "number" },
"gp_per_hour_low": { "type": "number" }, "gp_per_hour_low": { "type": "number" },
"gp_per_hour_high": { "type": "number" }, "gp_per_hour_high": { "type": "number" },
"ceiling_gp_per_hour": { "type": "number", "exclusiveMinimum": 0 },
"xp_per_hour": { "type": "number" }, "xp_per_hour": { "type": "number" },
"supplies": { "supplies": {
"type": "array", "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": { "best_practice": {
"type": "object", "type": "object",
"required": ["equipment"], "required": ["equipment"],

View File

@ -21,11 +21,25 @@ Runs:
NOTE: the generic `ge` tag alone does NOT gate this rule — a NOTE: the generic `ge` tag alone does NOT gate this rule — a
training/processing/procurement entry may legitimately carry training/processing/procurement entry may legitimately carry
`ge` while its income/cost model has nothing to do with flip tax. `ge` while its income/cost model has nothing to do with flip tax.
- Regression fixtures (--selftest): a fractional-tax flipping - GE-flip lifecycle (flipping/merchanting entries): the 4h buy-limit
entry must FAIL; an above-cap high-value flip with the ledger (limit_used / limit_resets_at) is DISTINCT from offer
correctly capped-tax net must PASS; a non-margin entry carrying expiry — cancelling never renews the limit; every slot is an
only `ge` plus a non-flip tag must PASS. independent asynchronous sub-state machine (canonical set:
3. Global id uniqueness across the whole codex tree. 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. Exit codes: 0 = clean (warnings ok), 1 = validation errors.
@ -97,8 +111,205 @@ def ge_floor_tax_error(rel, data):
return None 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): 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 = [ fixtures = [
# name, tags, economy, expect_error # name, tags, economy, expect_error
("fractional-tax flipping entry", ["flipping"], ("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, {"income_per_cycle": 300_000_000, "cost_per_cycle": 294_000_000,
"net_per_cycle": 1_000_000}, False), "net_per_cycle": 1_000_000}, False),
] ]
errors = []
for name, tags, eco, expect_error in fixtures: for name, tags, eco, expect_error in fixtures:
rel = f"selftest/{name}" rel = f"selftest/{name}"
got_error = ge_floor_tax_error(rel, {"tags": tags, "economy": eco}) is not None 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"selftest: '{name}' expected "
f"{'an error' if expect_error else 'no error'} but got " f"{'an error' if expect_error else 'no error'} but got "
f"{'an error' if got_error else 'no error'}") 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 return errors
@ -213,6 +542,9 @@ def main():
err = ge_floor_tax_error(rel, data) err = ge_floor_tax_error(rel, data)
if err: if err:
errors.append(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 # 3. staleness / research TODOs
if data.get("status") == "verified": if data.get("status") == "verified":