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
tags: [flipping, merchanting, ge, money-making, hybrid]
status: verified
version: 3 # 3: bounce-fix 4 2026-08-28 (owner lifecycle addendum) — complete runnable flow: partial fills, ge_cancel + requote recovery, per-slot ledger, break/resume path, lifecycle-qualified rates, worked example pinned; ge_cancel added to the action grammar + schema
version: 4 # 4: bounce-fix 5 2026-08-28 (governor comment 10323) — per-slot asynchronous sub-state machine + global scheduler pass; ge_cancel moves everything to the GE collection box (ge_collect + ledger reconciliation before reuse); 4h buy-limit ledger distinct from offer expiry (cancellation never renews the limit); typed ceiling_gp_per_hour vs documented realized scenarios; conservation invariants; breaks with persisted per-slot state. Capped/floored tax model and its 4 selftest fixtures preserved (owner decision 2026-08-28).
updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date
summary: >
summary: >-
Buy-and-sell Grand Exchange margin flipping: place resting buy offers at
the low edge of an item's price band and resting sell offers at the high
edge, harvesting the spread on high-volume items. There are no skill or
quest requirements — capital breadth and slot discipline are the whole
game. Members accounts run 8 GE slots over the full candidate set; F2P
accounts get 3 slots and F2P-tradeable rows only (separate F2P path in
profit_drivers). Lifecycle (owner addendum 2026-08-28): the entry models
the complete runnable flow — capital allocation, per-slot quotes with a
quoted/filled/sold/collected ledger, partial fills, ge_cancel +
requote recovery on window lapse or stale prices, collection and
reconciliation, breaks on resting offers, and explicit stop conditions —
with a pinned worked example and realized (not idealised) rates.
profit_drivers). Model (governor 10323, 2026-08-28): every GE slot is an
INDEPENDENT asynchronous sub-state machine (free / buy-open /
buy-cancelled-awaiting-collect / ready-to-sell / sell-open /
sell-cancelled-awaiting-collect / ready-to-requote / closed) driven by a
global scheduler pass that acts only on dispatchable slots and never
closes or advances unrelated ones; the 4h buy-limit ledger (limit_used /
first_fill_ts / limit_resets_at / remaining) is DISTINCT from offer
expiry and cancellation NEVER renews the limit; ge_cancel moves filled
items, refunds, unsold units and unspent coins to the GE collection box,
and a separate ge_collect withdraws and reconciles the per-slot ledger
before any reuse; every snapshot satisfies per-slot conservation
(quoted == still_offered + collected + refunded + sold; cash likewise);
economy.ceiling_gp_per_hour is the TYPED economic ceiling and
gp_per_hour_low/high are documented realized scenario bounds. Pinned
worked example + committed validator fixtures prove the model.
accounts:
membership: hybrid # members: 8 slots/full set; F2P: 3 slots/F2P rows (see economy)
@ -56,21 +65,32 @@ best_practice:
- name: pace quotes inside the 4h buy-limit window, watch connected limits
why: "The buying rate is restricted to a set quantity every 4 hours per player, and
some items share a connected limit with related items — treat those as one bucket
when filling slots."
when filling slots. The window starts when the FIRST unit of the window is bought
(first_fill_ts) and resets 4h later; cancelling an offer does NOT renew or restart
it."
source: "OSRS wiki — Grand Exchange (Buy limits; Grand Exchange/Buying limits)"
- name: run the path that matches the account's slot set
why: "Members get eight GE slots and may trade any row; F2P accounts get three slots
and may only purchase free-to-play rows. The modeled economics below assume the
members set — a hybrid account must swap to the 3-slot F2P path (Iron bar /
Cosmic rune / Silver ore) instead of chasing members-only rows it cannot buy."
source: "OSRS wiki — Grand Exchange (Members get eight Grand Exchange slots ... free players are limited to three. Additionally, free players may only purchase free-to-play items, and not members' items)"
- name: keep a per-slot offer ledger; cancel-and-requote on lapse, never strand units
source: "OSRS wiki — Grand Exchange (Members get eight Grand Exchange slots ... free players are limited to three. Additionally, free players may only purchase members' items)"
- name: treat each slot as its own asynchronous machine; the scheduler never touches resting slots
why: "A slot's offer fills on its own timeline (partial fills keep resting). The
global scheduler pass acts only on dispatchable slots (free -> quote,
*-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote ->
requote) and leaves resting buy-open / sell-open / closed slots untouched — one
slot's cancel or close never advances an unrelated slot."
source: "governor review comment 10323 (2026-08-28), finding 3; docs/action-grammar.md"
- name: keep a per-slot offer ledger; ge_cancel -> collection box -> ge_collect -> reconcile, never strand units
why: "Every slot tracks quoted qty -> filled qty -> sold qty -> collected proceeds.
A partially-filled offer keeps resting with its acquired units attached; on the 4h
window lapse or a stale price the slot is ge_cancel'ed — acquired units return to
inventory, unspent cash refunds — then re-quoted at the fresh edge (buy) or exited
at the current high edge (sell). No items, coins, or GE slots are ever stranded."
source: "OSRS wiki — Grand Exchange (offer mechanics; 4h window); docs/action-grammar.md (ge_cancel, added 2026-08-28)"
ge_cancel moves filled items, refunds, unsold units and unspent coins to the GE
COLLECTION BOX (never directly to inventory); a separate ge_collect withdraws
them, and the ledger is reconciled (conservation: quoted == still_offered +
collected + refunded + sold, cash likewise) before the slot is reused. The 4h
window lapse or a stale price triggers ge_cancel; the fresh-edge requote happens
only after collection. No items, coins, or GE slots are ever stranded."
source: "OSRS wiki — Grand Exchange (offer mechanics; 4h window); governor review comment 10323 finding 2; docs/action-grammar.md (ge_cancel / ge_collect)"
economy:
# Model (documented, reproducible from the snapshot):
@ -88,24 +108,28 @@ economy:
# members flags cross-checked against the wiki item pages (all agree). Top 8
# by 4h/slot below in profit_drivers.
#
# Lifecycle qualification (owner addendum 2026-08-28): the 4h figures are
# an economic CEILING — ideal full fills of every slot with zero overhead.
# The realized operating range is:
# realized gp/h = ceiling x fill_factor x duty_cycle, where
# fill_factor ~0.5-0.9 (partial fills, cancels, requotes reduce the
# filled volume) and duty_cycle ~0.90-0.95 (scan + place + collect
# ~3 min per ~40-min rotation; breaks every 6-10 rotations).
# Example midpoint: 149,500 x 0.70 x 0.92 ≈ 96,300 gp/h.
# gp_per_hour_low (74,750) = 50% of the ceiling — a conservative realized
# lower bound; gp_per_hour_high (149,500) = the named ceiling, never
# presented as the achieved runtime rate.
# Lifetime qualification (governor 10323 finding 5, 2026-08-28):
# ceiling_gp_per_hour (149,500) is the TYPED ECONOMIC CEILING: ideal full
# fills of every slot, zero overhead — never presented as an achieved
# runtime rate. gp_per_hour_low/high are DOCUMENTED REALIZED SCENARIO
# BOUNDS (not uncited multipliers), each with explicit arithmetic in
# profit_drivers:
# high 138,000 = 149,500 x 37/40 — 'resting-fill' scenario: all 8 slots
# full-fill at their quoted edges; each 40-min rotation is 37 min of
# resting waits + 3 min of scan/place/collect actions.
# low 74,750 = 149,500 x 0.50 — 'requote-heavy' scenario: every 4h
# window's volume completes only after one ge_cancel + requote cycle
# (the no-fill remainder is refunded to the collection box and
# re-quoted on the next window), halving the window's filled volume.
# Bounds nest: 0 <= 74,750 <= 138,000 <= 149,500 (validator-enforced).
item: Rune arrowtips # anchor: top candidate by modeled 4h/slot (members)
item_id: 44
cost_per_cycle: 223 # buy @ low edge per unit (snapshot 2026-08-27)
income_per_cycle: 244 # sell @ high edge per unit, gross (snapshot 2026-08-27)
net_per_cycle: 17 # 244 - min(floor(244*0.02), 5M) - 223 = 244 - 4 - 223 (floored, capped tax)
gp_per_hour_low: 74750 # realized lower bound = 50% of the ceiling: partial fills, cancels/requotes, collection trips, breaks
gp_per_hour_high: 149500 # 4h buy-limit CEILING: sum(4h/slot) / 4 (8 members slots, ideal full fills, zero overhead) — qualified above
ceiling_gp_per_hour: 149500 # TYPED economic ceiling: sum of the 8 slots' 4h/slot (= 598,000) / 4h — ideal full fills, zero overhead (see profit_drivers table)
gp_per_hour_low: 74750 # realized scenario bound 'requote-heavy' = 149,500 x 0.50 (documented in profit_drivers)
gp_per_hour_high: 138000 # realized scenario bound 'resting-fill' = 149,500 x 37/40 (documented in profit_drivers)
xp_per_hour: 0 # flipping awards no XP
supplies:
- item: Rune arrowtips # canonical wiki names per action-grammar conventions
@ -126,36 +150,103 @@ economy:
- "Members path — snapshot 2026-08-27 top 8 by modeled 4h/slot (floored+capped 2% tax): Rune arrowtips 170,000 (M, buy 223 / sell 244 / limit 10000); Iron bar 90,000 (F2P, 145/157/10000); Cosmic rune 72,000 (F2P, 122/128/18000); Magic logs 72,000 (M, 772/793/12000); Silver ore 65,000 (F2P, 73/79/13000); Mud rune 54,000 (M, 96/101/18000); Raw swordfish 45,000 (F2P, 206/213/15000); Raw lobster 30,000 (F2P, 106/110/15000) — total 598,000 per 4h = 149,500 gp/h CEILING on 8 slots"
- "F2P path — 3 slots, F2P-tradeable rows only: Iron bar 90,000 + Cosmic rune 72,000 + Silver ore 65,000 = 227,000 per 4h = 56,750 gp/h ceiling (alternates: Raw swordfish 45,000, Raw lobster 30,000)"
- "Post-2%-fee reality: 11 of 21 classic staples are net-positive at band edges for members; sub-2%-spread items (law/chaos/blood runes, dragon bones, adamantite/mithril bars) need sub-low buys or above-high sells"
- "Realized vs ceiling (work the full lifecycle): realized gp/h = ceiling x fill_factor (~0.5-0.9, partials/cancels/requotes) x duty_cycle (~0.90-0.95, scan/place/collect + breaks) — e.g. 149,500 x 0.70 x 0.92 ~ 96,300 gp/h midpoint; do not quote the ceiling as an achieved rate"
- "Realized bounds are DOCUMENTED SCENARIOS with explicit arithmetic (governor 10323 finding 5 — no uncited fill/duty multipliers): HIGH 'resting-fill' 138,000 gp/h is the ceiling scaled by the rotation duty of 37 resting minutes per 40-minute rotation (149,500 x 37/40 = 138,287.5 -> 138,000): scan/place/collect actions cost ~3 min per rotation at the 600s quote-check cadence; LOW 'requote-heavy' 74,750 gp/h assumes every 4h window completes only after one ge_cancel + requote cycle, so the no-fill remainder (refunded to the collection box and re-quoted on the next window) halves the filled volume (149,500 x 0.50). The ceiling 149,500 itself is an economic bound, never an achieved rate."
slot_state_machine:
sub_states: [free, buy-open, buy-cancelled-awaiting-collect, ready-to-sell, sell-open, sell-cancelled-awaiting-collect, ready-to-requote, closed]
slot_transitions:
free: [buy-open]
buy-open: [ready-to-sell, buy-cancelled-awaiting-collect]
buy-cancelled-awaiting-collect: [ready-to-sell, ready-to-requote, free]
ready-to-sell: [sell-open]
sell-open: [closed, sell-cancelled-awaiting-collect]
sell-cancelled-awaiting-collect: [ready-to-requote, closed]
ready-to-requote: [buy-open, sell-open, free]
closed: []
slot_transition_whens:
- "free -> buy-open: scheduler pass quotes a resting buy at the low edge (qty = min(remaining 4h limit, slot capital, capital available))"
- "buy-open -> ready-to-sell: offer FULLY filled (filled_qty == quoted qty) — units in hand, dispatch sells this pass"
- "buy-open -> buy-cancelled-awaiting-collect: 4h window lapse, or scanned edge stale (margin no longer positive) — ge_cancel: filled items + refunded units + unspent coins ALL move to the GE collection box; the buy limit is NOT renewed by the cancel"
- "buy-cancelled-awaiting-collect -> ready-to-sell: ge_collect withdrawn units to inventory; sell edge positive — sell the units in hand"
- "buy-cancelled-awaiting-collect -> ready-to-requote: ge_collect withdrawn; buy margin still positive AND remaining limit > 0 — requote the unfilled balance at the fresh low edge"
- "buy-cancelled-awaiting-collect -> free: ge_collect withdrawn; slot released (margin gone or capital reallocated)"
- "ready-to-sell -> sell-open: scheduler pass quotes a resting sell at the high edge for the units in hand"
- "sell-open -> closed: sell offer fully filled, proceeds collected, ledger reconciled — slot freed"
- "sell-open -> sell-cancelled-awaiting-collect: 4h window lapse or stale edge — ge_cancel: unsold units return to the GE collection box (never to inventory)"
- "sell-cancelled-awaiting-collect -> ready-to-requote: ge_collect withdrawn; requote the sell at the fresh high edge or the current band"
- "sell-cancelled-awaiting-collect -> closed: ge_collect withdrawn; slot closed (exit taken)"
- "ready-to-requote -> buy-open: requoted buy placed (buy_capacity_ok: limit_used + qty <= limit until limit_resets_at)"
- "ready-to-requote -> sell-open: requoted sell placed at the fresh high edge"
- "ready-to-requote -> free: slot abandoned and released"
scheduler_iteration: >-
One pass over every slot exactly once, at the 600 s quote-check cadence.
Only dispatchable slots act: free -> quote (ge_buy), buy-cancelled-
awaiting-collect / sell-cancelled-awaiting-collect -> ge_collect,
ready-to-sell -> sell (ge_sell), ready-to-requote -> requote. Resting
buy-open / sell-open / closed slots produce NO action and are never
closed, advanced, re-quoted or touched by the pass. A pass therefore
advances only the slots whose sub-state is dispatchable.
quote_age_policy: >-
INDEPENDENT of the 4h buy-limit ledger: resting offers are re-checked at
the 600 s cadence; an offer is aborted ONLY on a stale band edge
(scanned margin no longer positive) or the 4h window lapse — never
merely because it has been resting. An offer that still clears the
scanned margin keeps resting until it fills or the window lapses.
persisted_state: >-
Per-slot record persisted between passes and across breaks: sub-state,
item, quoted qty, still_offered qty, collected qty, refunded qty, sold
qty, cash_quoted, still_offered_cash, collected_cash, refunded_cash,
limit_used, first_fill_ts, limit_resets_at, quote placed_at, last
check_ts. The GE offers themselves persist server-side; the record is
the agent's copy of truth for the scheduler and conservation checks.
limit_ledger: >-
Per item: limit_used (units bought so far in the current 4h window),
first_fill_ts (when the FIRST unit of the window was bought),
limit_resets_at = first_fill_ts + 4h, remaining = limit - limit_used.
The ledger is DISTINCT from offer expiry: offers rest independently of
the window, and cancelling never renews or restarts it — a requote
before limit_resets_at may buy at most remaining (buy_capacity_ok:
limit_used + qty <= limit). A slot with limit_used == limit cannot
requote until limit_resets_at.
conservation: >-
Per slot at every snapshot: quoted == still_offered + collected_items +
refunded + sold (the buckets partition the quote's units — no losses,
no duplication, no negatives); cash_quoted == still_offered_cash +
collected_cash + refunded_cash. Impossible pairs (units in a live offer
AND a terminal bucket) are violations: still_offered + sold, and
still_offered + refunded (cancel is atomic). ge_cancel moves every
filled / refunded / unsold unit and coin to the GE collection box;
ge_collect withdraws them and reconciles the ledger BEFORE any slot
reuse. Committed validator fixtures prove the invariants and the
scheduler dispatch.
break_resume: >-
Breaks are taken only when every slot is resting (buy-open / sell-open)
or collected (closed) — never mid-collect or mid-place. Full state
(offers server-side + the persisted per-slot record) survives the
pause; resume by re-opening the GE, re-checking offers, re-running the
scheduler pass and continuing each slot from its persisted sub-state.
Slots and slots only: the 8-slot members / 3-slot F2P activity is never
downgraded to single-slot work.
state_machine:
initial: idle
terminal: [complete, failed]
states:
idle:
goal: "be logged in at a bank with a GE booth, capital >= floor, per-slot offer ledger empty (quoted = filled = sold = collected = 0 for every slot)"
goal: "be logged in at a bank with a GE booth, capital >= floor, every slot's ledger closed (sub-state free or closed, all buckets zero)"
next: [scan, complete]
scan:
goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax"
next: [buy, idle]
buy:
goal: "place one resting buy offer per free slot at or below the low edge; record quoted qty and remaining 4h limit per slot in the ledger"
next: [wait_buy_fill, idle]
wait_buy_fill:
goal: "hold BUY offers; track filled_qty per slot (partial fills included, units stay attached to the resting offer); on the 4h window lapse or a stale price, cancel the unfilled balance"
next: [sell, cancel_requote]
sell:
goal: "place one sell offer per slot holding units (its filled_qty) at or above the high edge"
next: [wait_sell_fill, cancel_requote]
wait_sell_fill:
goal: "hold SELL offers; track sold qty per slot; on lapse or a stale price, cancel and re-quote — units are never left stranded"
next: [collect, cancel_requote]
cancel_requote:
goal: "ge_cancel the timed-out/stale offers of the current phase: acquired units return to inventory, unspent cash refunds, slot freed; re-quote at a fresh edge (buy when margin is still positive; sell to exit at the current high edge) or free the slot"
next: [wait_buy_fill, sell, wait_sell_fill]
goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax (a3)"
next: [schedule, idle]
schedule:
goal: "run ONE scheduler pass over all 8 (members) or 3 (F2P) slots: dispatch free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> requote; resting slots untouched (slot_state_machine.scheduler_iteration)"
next: [wait, collect]
wait:
goal: "hold resting offers; at the 600 s cadence re-check offers (a6) and re-run the scheduler pass (schedule); a slot leaves wait when it becomes dispatchable"
next: [schedule, collect]
collect:
goal: "collect proceeds, deposit coins, reconcile (inventory empty of flip items, all GE slots free, each slot's ledger closed with a logged RESULT)"
next: [scan, complete, idle]
goal: "ge_collect every awaiting-collect slot: withdraw filled items, refunds, unsold units and proceeds from the collection box; reconcile each slot's ledger (conservation invariants) and only then allow slot reuse (a9)"
next: [schedule, scan, complete, idle]
complete:
goal: "session GP target reached"
failed:
@ -169,40 +260,25 @@ state_machine:
to: complete
- from: scan
when: "at least one net-positive candidate at the scanned band edges (floored+capped tax)"
to: buy
to: schedule
- from: scan
when: "no net-positive candidate on 3 consecutive scans (prices stale — stand down)"
to: idle
- from: buy
when: "all free slots quoted (quoted qty recorded per slot)"
to: wait_buy_fill
- from: buy
when: "capital exhausted before quoting (insufficient funds)"
to: idle
- from: wait_buy_fill
when: "a slot's buy offer is FULLY filled (filled_qty == quoted qty) — that slot's units move to sell"
to: sell
- from: wait_buy_fill
when: "a slot's buy offer hits the 4h window lapse or a stale price with filled_qty < quoted qty — ge_cancel: acquired units return to inventory, unspent cash refunds, slot freed"
to: cancel_requote
- from: cancel_requote
when: "re-quoted buy offers placed at the fresh low edge (window renewed, margin still positive)"
to: wait_buy_fill
- from: cancel_requote
when: "buy-side margin is gone — free the slot on the buy side; any acquired units from the cancelled offer still sell at the current high edge (exit at cost or a small loss rather than strand inventory)"
to: sell
- from: cancel_requote
when: "re-quoted sell offers placed at the fresh high edge (or lowered to the current band) for units in hand"
to: wait_sell_fill
- from: sell
when: "sell offers placed for all units in hand (filled_qty per slot)"
to: wait_sell_fill
- from: wait_sell_fill
when: "all pending sells filled — per-slot proceeds ready to collect"
- from: schedule
when: "pass placed or requoted offers (quotes outstanding) — rest until the next cadence"
to: wait
- from: schedule
when: "pass marked slots awaiting collection (ge_cancel happened) — withdraw before further dispatch"
to: collect
- from: wait_sell_fill
when: "a sell offer hits the 4h window lapse or a stale price — ge_cancel and re-quote at the fresh high edge or lower to the current band; units are never stranded"
to: cancel_requote
- from: wait
when: "600 s cadence reached — re-check offers and re-run the scheduler pass"
to: schedule
- from: wait
when: "a slot filled and proceeds/results are ready to collect"
to: collect
- from: collect
when: "only resting or closed slots remain; cadence continues"
to: wait
- from: collect
when: "session GP target reached"
to: complete
@ -252,11 +328,11 @@ actions:
verb: ge_buy
target: grand-exchange
params:
item: "per candidate slot"
qty: "min(remaining 4h limit, slot capacity, capital available)"
item: "per dispatchable free slot (scheduler pass)"
qty: "min(remaining 4h limit, slot capacity, capital available) — buy_capacity_ok(qty + limit_used <= limit)"
price: "low edge (rounded down)"
max_price: "low edge — never instant"
ledger: "record quoted qty and remaining 4h limit per slot"
ledger: "record quoted qty, cash_quoted, limit_used check; sub-state -> buy-open"
on_fail: skip
anti_ban:
delay: [900, 2700]
@ -266,7 +342,7 @@ actions:
verb: sleep
params:
delay: [6000, 12000]
until: "ge_offers_done changes or a fill event or 4h window lapse"
until: "ge_offers_done changes or a fill event or the 600s cadence or 4h window lapse"
on_fail: retry
anti_ban:
delay: [300, 900]
@ -276,9 +352,9 @@ actions:
verb: ge_offers_done
target: "all quoted slots"
params:
phase: "buy (no pending sells) | sell (no pending buys)"
per_slot: "filled_qty vs quoted_qty for every slot (partial fills included)"
verdict: "full | partial | none | 4h-lapse"
phase: "per-slot: buy-open (buy outstanding) | sell-open (sell outstanding)"
per_slot: "still_offered vs quoted for every slot (partial fills stay attached to the resting offer)"
verdict: "full | partial | none | lapse | stale-edge"
on_fail: retry
- id: a7
@ -286,31 +362,33 @@ actions:
verb: ge_sell
target: grand-exchange
params:
item: "per slot holding units (filled_qty)"
qty: "filled_qty of that slot"
item: "per ready-to-sell slot (units in hand after full fill or ge_collect)"
qty: "units in hand for that slot"
price: "high edge (rounded up)"
ledger: "record sold qty per slot"
ledger: "record sold qty per slot; sub-state -> sell-open"
on_fail: skip
anti_ban:
delay: [900, 2700]
- id: a8
step: cancel-requote
step: cancel-to-box
verb: ge_cancel
target: "timed-out or stale slot offers (current phase)"
target: "lapsed or stale slot offers (current phase)"
params:
offers: "the slots that hit the 4h window lapse or a stale price"
cancelled: "acquired units return to inventory; unspent cash refunds; slot freed"
requote: "buy: re-place at the fresh low edge when the margin is still positive; sell: re-place at the fresh high edge or lower to the current band; margin gone on the buy side: free the slot and sell any acquired units to exit"
offers: "the slots that hit the 4h window lapse or a stale edge"
moved: "filled items + refunded units + unsold units + unspent coins ALL move to the GE COLLECTION BOX (never directly to inventory)"
limit: "the 4h buy limit is NOT renewed or restarted by the cancel (limit_used stays)"
sub_state: "buy-open -> buy-cancelled-awaiting-collect; sell-open -> sell-cancelled-awaiting-collect"
on_fail: retry
- id: a9
step: collect-and-log
step: collect-and-reconcile
verb: ge_collect
target: grand-exchange
params:
deposit: "coins to bank"
reconcile: "inventory empty of flip items, all GE slots free, per-slot ledger closed"
withdraw: "from the GE collection box: filled items, refunds, unsold units, sale proceeds"
reconcile: "per-slot ledger conservation check (quoted == still_offered + collected + refunded + sold; cash likewise) BEFORE any slot reuse"
decide: "ready-to-sell (sell edge positive) | ready-to-requote (buy margin positive and remaining limit > 0) | free (release)"
log: "RESULT item, qty, net gp per slot (floored+capped tax)"
on_fail: alert
anti_ban:
@ -322,64 +400,105 @@ actions:
target: "current location"
params:
duration: "[60, 240] seconds of idle AFK per break (matches run_control.break.for)"
safe: "resting offers persist server-side at their quoted edges — the full state lives in the GE interface plus the per-slot ledger, so a break at any resting-offer point is safe"
resume: "re-open GE (a2), re-check offer state (a6), continue from the current phase (a4/a7/a8/a9) — never break mid-collect (coins in hand) or mid-place (offer half-configured)"
safe: "only when every slot is resting (buy-open / sell-open) or collected (closed) — resting offers persist server-side at their quoted edges and the per-slot record persists locally; never break mid-collect (coins in hand) or mid-place (offer half-configured)"
resume: "re-open GE (a2), re-check offer state (a6), re-run the scheduler pass (schedule), continue each slot from its persisted sub-state (a4/a7/a8/a9)"
on_fail: skip
run_control:
iteration: "one full rotation: quote buys -> fills/cancels/requotes -> sell filled units -> sells fill/cancel/requote -> collect + reconcile, across all slots"
iteration: "one scheduler pass over every slot at the 600 s cadence — dispatch free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> requote; resting slots untouched (slot_state_machine.scheduler_iteration)"
max_iterations: null
break:
every: [6, 10] # rotations (resting offers persist; state = GE + per-slot ledger)
every: [6, 10] # passes (resting offers persist; state = GE offers + per-slot record)
for: [60, 240] # seconds of idle AFK
stop_conditions:
- "session GP target reached"
- "capital under floor"
- "no net-positive candidates on 3 consecutive scans"
- "3 consecutive aborted offers (client issue — do not loop)"
- "3 consecutive failed/aborted passes (client issue — do not loop)"
# ================= WORKED EXAMPLE (pinned, deterministic — owner addendum 2026-08-28) =================
# ================= WORKED EXAMPLE (pinned, deterministic — owner addendum 2026-08-28; slot-machine walk per governor 10323) =================
# Members account, 8 GE slots, capital 22,500,000 gp. Snapshot prices 2026-08-27
# (buy/sell = low/high edge; tax = min(floor(0.02 x sell), 5,000,000)).
# The run below walks THREE slots in detail (arrowtips / iron / cosmic) exactly as
# the committed scheduler-dispatch + conservation fixtures; the other five slots
# behave identically to the iron slot and are omitted for brevity.
#
# Step 0 — preparation and capital allocation: capital at the bank, GE-adjacent.
# Scan bands (a3): only net-positive slots are quoted. Per-slot capital needs:
# arrowtips 223x10,000 = 2.23M; iron bar 145x10,000 = 1.45M; cosmic 122x18,000 =
# 2.196M; silver ore 73x13,000 = 0.949M; swordfish 206x15,000 = 3.09M; lobster
# 106x15,000 = 1.59M; magic logs 772x12,000 = 9.264M; mud 96x18,000 = 1.728M
# (total 22.497M <= 22.5M). Ledger: quoted=filled=sold=collected=0 per slot.
# Step 1 — place buys (a4): quote all 8 slots at the low edges (qty = 4h limit).
# wait_buy_fill (a5/a6).
# Step 2 — PARTIAL FILL: arrowtips fills 4,000 of 10,000 after ~18 min -> check-fills
# (a6) verdict PARTIAL: ledger arrowtips.filled = 4,000. The offer KEEPS resting
# (acquired units stay attached to the resting offer; nothing is stranded).
# Step 3 — FULL FILL: iron bar fills all 10,000 -> transition to sell (a7): place
# sell 10,000 @ 157 (high edge). wait_sell_fill -> fills -> collect (a9):
# proceeds 1,570,000; tax floor(157 x 0.02) = 3/item -> net = (157-3-145) x 10,000
# = 90,000. Reconcile: inventory empty, slot free, ledger closed.
# Step 4 — NO-FILL / STALE recovery on the buy side: cosmic 18,000 @ 122 is unfilled
# approaching the 4h lapse -> cancel_requote (a8, ge_cancel): 0 units return,
# 2,196,000 cash refunds, slot freed; re-scan (a3): cosmic still net-positive ->
# requote 18,000 @ 122 on the fresh window. (If the buy margin had vanished the
# slot is freed on the buy side; any units acquired from a cancelled offer still
# sell at the current high edge.)
# Step 5 — PARTIAL matures at lapse: arrowtips still 4,000/10,000 -> ge_cancel:
# 4,000 units return to inventory, 6,000 x 223 = 1,338,000 refunded -> sell (a7):
# 4,000 @ 244 -> tax floor(244 x 0.02) = 4/item -> net = (244-4-223) x 4,000 =
# 68,000. Collect (a9) + reconcile.
# Step 6 — FIRST SCHEDULED BREAK after 6-10 rotations (midpoint 8): execution is at
# a safe point — every slot is either collected or resting at its quoted edge; the
# state (offers + per-slot ledger) persists server-side. take-break 60-240 s
# (a10); RESUME at a2 (re-open GE) -> a6 (check-fills) -> continue the current
# phase (a4 buys / a7 sells / a8 cancel-requote / a9 collect). Breaks never strand
# an offer and never interrupt a collect/place in progress.
# Step 7 — STOP: session target (e.g. 900,000 net) -> collect -> complete; capital
# fell below floor -> idle -> complete; 3 consecutive aborted offers -> failed.
# Rate qualification: the 149,500 gp/h figure is the 4h buy-limit CEILING (8 slots,
# full fills, zero overhead). Realized midpoint ~ 149,500 x 0.70 (fill factor) x
# 0.92 (duty cycle incl. scan/place/collect + breaks) ~ 96,300 gp/h — the entry's
# low bound 74,750 (50% ceiling) sits under that; the ceiling is never presented
# as an achieved runtime rate.
# t0 idle -> scan -> schedule (pass 1): all slots free. Scheduler dispatch:
# free -> quote. Slot ledger after the pass (conservation holds):
# arrowtips: buy-open, quoted 10,000 @ 223; cash_quoted 2,230,000
# iron: buy-open, quoted 10,000 @ 145; cash_quoted 1,450,000 (limit_ledger entry: limit_used 0, first_fill_ts -, limit_resets_at -)
# cosmic: buy-open, quoted 18,000 @ 122; cash_quoted 2,196,000
# Buckets: still_offered == quoted for every slot; collected/refunded/sold = 0.
#
# t1 wait, ~18 min: arrowtips PARTIAL fill 4,000/10,000 (a6 verdict PARTIAL).
# Ledger: arrowtips quoted 10,000 == still_offered 6,000 + collected_items 4,000
# (the 4,000 units sit in the player's possession — GE box — attached to the
# resting offer; nothing is stranded). limit_ledger: limit_used 4,000,
# first_fill_ts now, limit_resets_at = first_fill_ts + 4h; remaining 6,000.
# Cosmic: no fill; iron: FULL fill 10,000 -> ready-to-sell.
#
# t2 cadence -> schedule (pass 2): dispatch ONLY the dispatchable slot:
# iron ready-to-sell -> sell-open (a7): 10,000 @ 157 (high edge).
# arrowtips buy-open -> NO ACTION (resting, quote age within policy).
# cosmic buy-open -> NO ACTION (resting, no fill, quote age within policy).
# => the pass never closes or advances unrelated slots (regression fixture).
# Iron ledger: quoted 10,000 == still_offered 0 + sold 10,000 (buckets move on fill).
#
# t3 iron sell-open FULL fill -> collect: proceeds 1,570,000; tax floor(157 x 0.02)
# = 3/item -> net = (157 - 3 - 145) x 10,000 = 90,000. ge_collect (a9):
# cash withdrawn, ledger reconciled, sub-state -> closed, slot freed.
# Conservation: quoted 10,000 == sold 10,000 (all other buckets 0).
#
# t4 LAPSE / stale-edge handling (a8): at the 4h window edge, cosmic still has NO
# fill -> ge_cancel: the offer's cash 2,196,000 moves to the COLLECTION BOX as a
# refund. Ledger: cash_quoted 2,196,000 == still_offered_cash 0 + refunded_cash
# 2,196,000 (collected_cash 0). Sub-state -> buy-cancelled-awaiting-collect.
# arrowtips reaches limit_resets_at with 6,000 still offered -> ge_cancel:
# filled 4,000 (already in player possession) + refunded 6,000 units + unspent
# cash 6,000 x 223 = 1,338,000 ALL move to the collection box. Ledger:
# quoted 10,000 == collected_items 4,000 + refunded 6,000; limit_used STAYS
# 4,000 (the cancel does NOT renew the limit; remaining 6,000 stands).
# Sub-state -> buy-cancelled-awaiting-collect for both slots.
#
# t5 cadence -> schedule (pass 3): dispatch the awaiting-collect slots -> ge_collect
# (a9). Withdraw + reconcile:
# arrowtips: units 10,000 (4,000 filled + 6,000 refund) -> inventory.
# Ledger: quoted 10,000 == collected_items 10,000 (refunded absorbed into
# collected on withdrawal). Buy margin still positive AND remaining limit
# 6,000 > 0. DECIDE (a9): ready-to-requote -> requote buy 6,000 @ 223
# (buy_capacity_ok: limit_used 4,000 + 6,000 = 10,000 <= 10,000 — allowed;
# a 6,001-unit requote would be BLOCKED until limit_resets_at — fixture).
# -> buy-open on the fresh edge.
# cosmic: cash 2,196,000 withdrawn (refunded_cash -> collected_cash 2,196,000).
# Margin still positive, limit_used 0 -> ready-to-requote -> buy-open 18,000
# @ 122 (full capacity available).
# iron: already closed (free).
# => ONLY the awaiting-collect slots acted; resting/closed slots untouched.
#
# t6 Scheduler continues; arrowtips requote buy 6,000 fills -> ready-to-sell ->
# sell-open 10,000 @ 244 (4,000 in hand from the t1 fill + 6,000 from the
# requote) -> fills -> collect (a9): tax floor(244 x 0.02) = 4/item ->
# net = (244 - 4 - 223) x 10,000 = 170,000 for the window (matches the
# 170,000 4h/slot table row; limit_used 10,000 == limit -> no further
# requotes until limit_resets_at).
#
# t7 FIRST SCHEDULED BREAK after 6-10 passes (midpoint 8): every slot is resting
# (buy-open) or closed; per-slot records persisted (slot_state_machine.
# break_resume); take-break 60-240 s (a10); RESUME at a2 (re-open GE) -> a6
# (check-fills) -> schedule (next pass) -> continue per-slot from persisted
# sub-state. Breaks never strand an offer and never interrupt a collect/place.
#
# t8 STOP: session target (e.g. 900,000 net) -> collect -> complete; capital fell
# below floor -> idle -> complete; 3 consecutive failed passes -> failed.
#
# Rate qualification (typed ceiling vs realized scenarios — finding 5):
# ceiling 149,500 = 598,000 per 4h / 4h — ideal full fills of all 8 slots,
# zero overhead. Realized HIGH 138,000 = 149,500 x 37/40 (37 min resting waits
# + 3 min scan/place/collect per 40-min rotation at the 600 s cadence).
# Realized LOW 74,750 = 149,500 x 0.50 (requote-heavy: every 4h window's
# volume completes only after one ge_cancel + requote cycle — the no-fill
# remainder is refunded and re-quoted on the next window, halving volume).
# The ceiling is NEVER presented as an achieved runtime rate.
# =====================================================================================================
risk:
@ -387,17 +506,20 @@ risk:
notes:
- "Flipping is standard gameplay; the detectable signal is quote churn — keep per-offer pacing humanised, never instant-buy/sell loops."
- "The 2% fee (since 2025-05-29) and 5M/item cap make sub-2%-spread staples unprofitable at band edges; re-scan before every rotation."
- "Thin/volatile items: a resting offer can sit for hours — cap quote lifetime to the 4h window and re-quote (ge_cancel + fresh edge) rather than chase."
- "Thin/volatile items: a resting offer can sit for hours — quote lifetime is bounded by the 4h window (lapse -> ge_cancel -> collection box -> ge_collect -> requote at a fresh edge), never by chasing."
- "Partial fills are normal; never cancel-and-replace a partially-filled offer mid-window just to churn — the ledger keeps the filled qty attached to the resting offer until full/lapse."
- "Breaks are safe only at resting-offer or post-collect points (see a10); state = GE offers + the per-slot ledger, which survive the pause."
- "ge_cancel NEVER returns items/coins directly to inventory: everything lands in the GE collection box. Reuse a slot only after ge_collect + ledger reconciliation (conservation invariants)."
- "The 4h buy limit is independent of offer life: cancelling does not renew it. A slot at limit_used == limit cannot requote until limit_resets_at (first_fill_ts + 4h)."
- "Breaks are safe only at resting-offer or post-collect points (see a10); state = GE offers + the persisted per-slot record, which survive the pause."
sources:
- "OSRS wiki — Grand Exchange (2% convenience fee since 2025-05-29, capped 5M/item, sub-50gp effectively untaxed, tax-exempt low-value items; buying rate restricted per 4h; connected limits; verified 2026-08-27)"
- "OSRS wiki — Grand Exchange/Buying limits (4h window mechanics)"
- "OSRS wiki — Grand Exchange/Buying limits (4h window mechanics; window starts on first purchase)"
- "OSRS wiki — Merchanting (flipping = spread exploitation; high trade volume = safety; bulk flipping needs ~20M+ cash; avoid static-price items; offer matching at or better than your price)"
- "prices.runescape.wiki/api/v1/osrs/mapping (item ids, members flags, limits — limits cross-checked against Module:Exchange/<Item>, all agree)"
- "prices.runescape.wiki/api/v1/osrs/latest (high/low price band snapshot 2026-08-27)"
- "docs/action-grammar.md (ge_cancel verb added 2026-08-28: cancel a pending offer — bought units return to inventory, unspent cash refunds) + schemas/entry.schema.json verb enum; owner lifecycle addendum 2026-08-28 (complete runnable flow, worked example, realized rates)"
- "governor review comment 10323 on PR #7 (2026-08-28): per-slot asynchronous sub-state machine + scheduler pass (finding 3); ge_cancel -> collection box + ge_collect + reconciliation (finding 2); buy-limit ledger distinct from offer expiry, cancellation never renews (finding 1); conservation invariants (finding 4); typed ceiling vs documented realized scenarios (finding 5); explicit scheduler iteration + persisted break/resume, no slot downgrade (finding 6); owner decision 2026-08-28 (preserve capped/floored tax + 4 fixtures)"
- "docs/action-grammar.md (ge_cancel amended + ge_collect verb, 2026-08-28) + schemas/entry.schema.json (slot_state_machine, ceiling_gp_per_hour)"
verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27
related:

View File

@ -68,9 +68,9 @@ Legend for the mapping table:
| `ge_open` | open GE at a bank | — | — | — |
| `ge_buy` | buy `qty` of `item` at offer price, paced | — | — | — |
| `ge_sell` | sell `qty` of `item`, confirm | — | — | — |
| `ge_cancel` | cancel a pending offer: bought units return to inventory, unspent cash refunds | — | — | — |
| `ge_cancel` | cancel a pending offer: filled items, refunds, unsold units and unspent coins ALL move to the GE COLLECTION BOX (never directly to inventory); the 4h buy limit is NOT renewed | — | — | — |
| `ge_offers_done` | predicate: no pending buy/sell offers | — | — | — |
| `ge_collect` | collect finished GE transactions | — | — | — |
| `ge_collect` | withdraw from the GE collection box to inventory: filled items, refunds, unsold units, sale proceeds; reconcile the per-slot ledger before any slot reuse | — | — | — |
| `ge_check_price` | read current buy/sell price of an item | — | — | — |
## F. Combat

View File

@ -43,6 +43,7 @@
"net_per_cycle": { "type": "number" },
"gp_per_hour_low": { "type": "number" },
"gp_per_hour_high": { "type": "number" },
"ceiling_gp_per_hour": { "type": "number", "exclusiveMinimum": 0 },
"xp_per_hour": { "type": "number" },
"supplies": {
"type": "array",
@ -62,6 +63,39 @@
}
},
"slot_state_machine": {
"type": "object",
"required": ["sub_states", "slot_transitions", "slot_transition_whens",
"scheduler_iteration", "quote_age_policy", "persisted_state",
"limit_ledger", "conservation", "break_resume"],
"additionalProperties": false,
"properties": {
"sub_states": {
"type": "array",
"minItems": 8,
"items": { "type": "string" }
},
"slot_transitions": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": { "type": "string" }
}
},
"slot_transition_whens": {
"type": "array",
"minItems": 8,
"items": { "type": "string", "minLength": 8 }
},
"scheduler_iteration": { "type": "string", "minLength": 10 },
"quote_age_policy": { "type": "string", "minLength": 10 },
"persisted_state": { "type": "string", "minLength": 10 },
"limit_ledger": { "type": "string", "minLength": 10 },
"conservation": { "type": "string", "minLength": 10 },
"break_resume": { "type": "string", "minLength": 10 }
}
},
"best_practice": {
"type": "object",
"required": ["equipment"],

View File

@ -21,11 +21,25 @@ Runs:
NOTE: the generic `ge` tag alone does NOT gate this rule — a
training/processing/procurement entry may legitimately carry
`ge` while its income/cost model has nothing to do with flip tax.
- Regression fixtures (--selftest): a fractional-tax flipping
entry must FAIL; an above-cap high-value flip with the
correctly capped-tax net must PASS; a non-margin entry carrying
only `ge` plus a non-flip tag must PASS.
3. Global id uniqueness across the whole codex tree.
- GE-flip lifecycle (flipping/merchanting entries): the 4h buy-limit
ledger (limit_used / limit_resets_at) is DISTINCT from offer
expiry — cancelling never renews the limit; every slot is an
independent asynchronous sub-state machine (canonical set:
free / buy-open / buy-cancelled-awaiting-collect / ready-to-sell /
sell-open / sell-cancelled-awaiting-collect / ready-to-requote /
closed) driven by a global scheduler pass that never touches
unrelated slots; ge_cancel moves filled items/refunds/proceeds/
unsold units to the GE collection box for a separate ge_collect;
per-slot conservation (quoted == still_offered + collected +
refunded + sold; no negative/duplicated quantities) is provable;
economy.ceiling_gp_per_hour is the TYPED economic ceiling and
0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling.
- Regression fixtures (--selftest): a fractional-tax flipping
entry must FAIL; an above-cap high-value flip with the
correctly capped-tax net must PASS; a non-margin entry carrying
only `ge` plus a non-flip tag must PASS; plus per-slot scheduler
dispatch, conservation, buy-limit and nesting fixtures.
3. Global id uniqueness across the whole codex tree.
Exit codes: 0 = clean (warnings ok), 1 = validation errors.
@ -97,8 +111,205 @@ def ge_floor_tax_error(rel, data):
return None
# --- GE-flip lifecycle gates (owner guidance 2026-08-28, governor 10323) ---
# Canonical per-slot sub-states: each GE slot is an INDEPENDENT asynchronous
# machine driven by a global scheduler pass; a pass never closes or advances
# a slot it did not touch.
SLOT_SUB_STATES = [
"free", # no offer, nothing held
"buy-open", # resting buy offer (partial fills stay attached)
"buy-cancelled-awaiting-collect", # aborted buy: assets sit in the GE collection box
"ready-to-sell", # units in hand, sell edge positive
"sell-open", # resting sell offer (partial sells tracked)
"sell-cancelled-awaiting-collect", # aborted sell: proceeds + unsold units in the box
"ready-to-requote", # collected units/refunds; requote or abandon
"closed", # slot finished, ledger reconciled, nothing held
]
# Dispatch table: the pure action a slot in a given sub-state needs this pass.
SLOT_DISPATCH = {
"free": "quote",
"buy-open": None, # resting; fill/abort decided by policies
"buy-cancelled-awaiting-collect": "collect",
"ready-to-sell": "sell",
"sell-open": None, # resting; fill/abort decided by policies
"sell-cancelled-awaiting-collect": "collect",
"ready-to-requote": "requote",
"closed": None,
}
def scheduler_dispatch(sub_state):
"""The pending action for one slot in one scheduler pass, or None.
A None (resting) slot is left completely untouched — the pass must not
close, advance, or mutate it. This is the property the worked-example
regression proves.
"""
return SLOT_DISPATCH.get(sub_state)
def conservation_violations(record):
"""Per-slot conservation invariants (governor 10323 finding 4).
record: dict with item buckets (still_offered, collected_items,
refunded, sold — each a {item: qty} map), quoted ({item: qty}), and cash
buckets (cash_quoted, still_offered_cash, collected_cash,
refunded_cash). Invariants:
- every quantity >= 0;
- per item: quoted == still_offered + collected_items + refunded + sold
(the buckets are a partition of the quote's units — no losses, no
duplicated, no negative quantities);
- physically impossible co-occurrences: units cannot sit in a live
offer AND already be sold or cancelled-refunded (cancel is atomic),
so an item may not appear in both {still_offered, sold} or both
{still_offered, refunded} — partial fills legitimately span
{still_offered, collected_items} until the offer is closed;
- cash: cash_quoted == still_offered_cash + collected_cash +
refunded_cash.
Returns a list of violation strings (empty = conserved).
"""
out = []
buckets = ["still_offered", "collected_items", "refunded", "sold"]
for b in buckets:
for item in record.get(b, {}):
q = record[b][item]
if q < 0:
out.append(f"item '{item}' bucket {b} is negative ({q})")
quoted = record.get("quoted", {})
for item, qq in quoted.items():
if qq < 0:
out.append(f"item '{item}' quoted is negative ({qq})")
total = sum(record.get(b, {}).get(item, 0) for b in buckets)
if total != qq:
out.append(
f"item '{item}' quoted {qq} != still_offered + collected + "
f"refunded + sold ({total})")
# Physically impossible co-occurrences (the buckets are a
# partition of the quote's units; cancel is atomic and units in
# a live offer cannot already be sold/cancelled):
# still_offered + sold — units cannot be both in a live
# offer and already sold
# still_offered + refunded — a cancelled offer has no live
# remainder left in the offer
in_b = {b for b in buckets if item in record.get(b, {})}
for pair in (("still_offered", "sold"), ("still_offered", "refunded")):
if pair[0] in in_b and pair[1] in in_b:
out.append(
f"item '{item}' appears in multiple buckets "
f"({pair[0]} + {pair[1]} — physically impossible: "
f"units cannot be in a live offer and a terminal "
f"bucket at the same time)")
for c in ("cash_quoted", "still_offered_cash", "collected_cash", "refunded_cash"):
if record.get(c, 0) < 0:
out.append(f"{c} is negative ({record[c]})")
if record.get("cash_quoted", 0) != (
record.get("still_offered_cash", 0)
+ record.get("collected_cash", 0)
+ record.get("refunded_cash", 0)):
out.append(
f"cash quoted {record.get('cash_quoted')} != still_offered "
f"{record.get('still_offered_cash')} + collected "
f"{record.get('collected_cash')} + refunded {record.get('refunded_cash')}")
return out
def buy_capacity_ok(limit_used, limit, qty_requested):
"""The 4h buy-limit ledger (governor 10323 finding 1): the limit resets
4h after the FIRST unit was bought; cancelling an offer does NOT renew
it, so a requote before the reset cannot buy beyond the remaining
capacity. Returns True when limit_used + qty_requested <= limit.
"""
return limit_used + qty_requested <= limit
def ge_lifecycle_errors(rel, data):
"""GE-flip lifecycle gates (flipping/merchanting entries only):
- the 4h buy-limit ledger is distinct from offer expiry: the entry must
declare a limit_ledger + an independent quote_age_policy;
- the slot model is per-slot asynchronous: slot_state_machine must
declare exactly the canonical sub-states, valid transitions, a
scheduler_iteration, persisted_state, conservation and break_resume;
- rates: ceiling_gp_per_hour is the typed economic ceiling and
0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling.
"""
errors = []
tags = {str(t).lower() for t in data.get("tags", [])}
if not tags & MARGIN_TAGS:
return errors
eco = data.get("economy") or {}
# --- typed ceiling vs realized bounds ---
ceil = eco.get("ceiling_gp_per_hour")
if not isinstance(ceil, (int, float)) or ceil <= 0:
errors.append(f"{rel}: flipping entries must declare "
f"economy.ceiling_gp_per_hour (the typed 4h buy-limit "
f"economic ceiling, distinct from realized rates)")
return errors
low, high = eco.get("gp_per_hour_low"), eco.get("gp_per_hour_high")
for name, val in (("gp_per_hour_low", low), ("gp_per_hour_high", high)):
if not isinstance(val, (int, float)) or val < 0:
errors.append(f"{rel}: {name} must be a non-negative realized "
f"scenario bound")
if isinstance(low, (int, float)) and isinstance(high, (int, float)) \
and isinstance(ceil, (int, float)):
if not (low <= high <= ceil):
errors.append(
f"{rel}: realized bounds must nest under the ceiling: "
f"0 <= gp_per_hour_low {low} <= gp_per_hour_high {high} "
f"<= ceiling_gp_per_hour {ceil}")
# --- per-slot asynchronous model ---
ssm = data.get("slot_state_machine") or {}
if not isinstance(ssm, dict):
errors.append(f"{rel}: flipping entries must declare a "
f"slot_state_machine block (per-slot asynchronous sub-states)")
return errors
declared = [str(s) for s in ssm.get("sub_states", [])]
if sorted(declared) != sorted(SLOT_SUB_STATES):
errors.append(
f"{rel}: slot_state_machine.sub_states must be exactly the canonical "
f"set: {', '.join(SLOT_SUB_STATES)} (got: {', '.join(declared) or 'none'})")
trans = ssm.get("slot_transitions") or {}
if not isinstance(trans, dict):
errors.append(f"{rel}: slot_state_machine.slot_transitions must be a mapping")
trans = {}
for from_s, tos in trans.items():
if from_s not in SLOT_SUB_STATES:
errors.append(f"{rel}: slot transition from unknown sub-state '{from_s}'")
for to_s in tos if isinstance(tos, list) else []:
if to_s not in SLOT_SUB_STATES:
errors.append(f"{rel}: slot transition '{from_s}' -> unknown "
f"sub-state '{to_s}'")
for state in SLOT_SUB_STATES:
if state != "closed" and not trans.get(state):
errors.append(f"{rel}: slot sub-state '{state}' has no transitions "
f"(closed is the only sink)")
if trans.get("closed"):
errors.append(f"{rel}: 'closed' is a sink — it must have no outgoing transitions")
whens = [str(w) for w in ssm.get("slot_transition_whens", []) or []]
for w in whens:
arrow = w.split("->")
if len(arrow) != 2 or arrow[0].strip() not in SLOT_SUB_STATES \
or arrow[1].strip().split(":")[0].strip() not in SLOT_SUB_STATES:
errors.append(f"{rel}: slot_transition_whens entry '{w}' must be "
f"'from -> to: condition' with canonical sub-states")
for field in ("scheduler_iteration", "quote_age_policy", "persisted_state",
"limit_ledger", "conservation", "break_resume"):
if not str(ssm.get(field, "")).strip():
errors.append(f"{rel}: slot_state_machine.{field} must be declared")
return errors
def run_selftest(verbose=False):
"""Regression fixtures for the GE floor-tax gate; returns error strings."""
"""Regression fixtures for the GE floor-tax + lifecycle gates; returns
error strings."""
errors = []
def check(name, passed):
if verbose:
print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}")
if not passed:
errors.append(f"selftest: {name}")
fixtures = [
# name, tags, economy, expect_error
("fractional-tax flipping entry", ["flipping"],
@ -118,7 +329,6 @@ def run_selftest(verbose=False):
{"income_per_cycle": 300_000_000, "cost_per_cycle": 294_000_000,
"net_per_cycle": 1_000_000}, False),
]
errors = []
for name, tags, eco, expect_error in fixtures:
rel = f"selftest/{name}"
got_error = ge_floor_tax_error(rel, {"tags": tags, "economy": eco}) is not None
@ -130,6 +340,125 @@ def run_selftest(verbose=False):
f"selftest: '{name}' expected "
f"{'an error' if expect_error else 'no error'} but got "
f"{'an error' if got_error else 'no error'}")
# --- per-slot scheduler dispatch: the worked example as a regression ---
# Governor 10323 finding 3 + owner: reproduce the simultaneous
# partial-buy / full-sell-collect / no-fill-buy case and prove no
# unrelated slot is closed or advanced.
slot_states = {"arrowtips": "buy-open", # partial fill 4,000/10,000 — resting
"iron": "ready-to-sell", # full fill — sell this pass
"cosmic": "buy-open"} # no fill, within quote age — resting
dispatched = {name: scheduler_dispatch(s) for name, s in slot_states.items()}
ok = dispatched == {"arrowtips": None, "iron": "sell", "cosmic": None}
check("worked example: partial-buy + full-sell + no-fill slots dispatch only the iron slot to sell; unrelated slots stay untouched",
ok)
check("resting slots (buy-open) are never closed or advanced by a pass",
dispatched.get("arrowtips") is None and dispatched.get("cosmic") is None)
# --- conservation invariants (finding 4) ---
# Balanced snapshot mirroring the worked example: 10,000 Rune arrowtips
# quoted, 4,000 filled (units in the player's possession), 6,000 still
# resting in the live offer; plus an independent cash quote (2,230 GP)
# fully committed in live buy offers.
clean_rec = {
"quoted": {"Rune arrowtips": 10},
"still_offered": {"Rune arrowtips": 6},
"collected_items": {"Rune arrowtips": 4},
"refunded": {},
"sold": {},
"cash_quoted": 2230, "still_offered_cash": 2230,
"collected_cash": 0, "refunded_cash": 0,
}
check("conservation: a balanced per-slot ledger (partial fill split across still_offered + collected, partition sums to quoted) has zero violations",
conservation_violations(clean_rec) == [])
dup_rec = dict(clean_rec, quoted={"Rune arrowtips": 10},
still_offered={"Rune arrowtips": 4},
sold={"Rune arrowtips": 6})
check("conservation: units both in a live offer AND already sold (impossible pair) is a violation",
any("multiple buckets" in v for v in conservation_violations(dup_rec)))
over_rec = dict(clean_rec, collected_items={"Rune arrowtips": 7})
check("conservation: over-reported buckets break the quoted partition (sum 13 != quoted 10)",
any("quoted 10 !=" in v for v in conservation_violations(over_rec)))
neg_rec = dict(clean_rec, sold={"Rune arrowtips": -1})
check("conservation: a negative bucket is a violation",
any("negative" in v for v in conservation_violations(neg_rec)))
cash_rec = dict(clean_rec, collected_cash=900)
check("conservation: cash quoted != still_offered + collected + refunded is a violation",
any("cash quoted" in v for v in conservation_violations(cash_rec)))
# --- 4h buy-limit ledger vs offer expiry (finding 1) ---
check("buy limit: a requote within the remaining capacity is allowed (4,000 used of 10,000 -> 6,000)",
buy_capacity_ok(limit_used=4000, limit=10000, qty_requested=6000))
check("buy limit: a requote beyond the remaining capacity is blocked (6,001 > 6,000 remaining)",
not buy_capacity_ok(limit_used=4000, limit=10000, qty_requested=6001))
check("buy limit: a cancelled offer does NOT renew the limit (10,000 used of 10,000 -> any requote blocked before reset)",
not buy_capacity_ok(limit_used=10000, limit=10000, qty_requested=1))
# --- structured lifecycle gates (findings 1-5) ---
margin_tags = ["flipping"]
ssm_ok = {
"sub_states": SLOT_SUB_STATES,
"slot_transitions": {
"free": ["buy-open"],
"buy-open": ["ready-to-sell", "buy-cancelled-awaiting-collect"],
"buy-cancelled-awaiting-collect": ["ready-to-sell", "free"],
"ready-to-sell": ["sell-open"],
"sell-open": ["closed", "sell-cancelled-awaiting-collect"],
"sell-cancelled-awaiting-collect": ["ready-to-requote", "closed"],
"ready-to-requote": ["buy-open", "sell-open", "free"],
"closed": [],
},
"slot_transition_whens": [
"free -> buy-open: quote placed",
"buy-open -> ready-to-sell: offer fully filled",
"buy-open -> buy-cancelled-awaiting-collect: abort (stale edge / quote age)",
"buy-cancelled-awaiting-collect -> ready-to-sell: collected, sell edge positive",
"buy-cancelled-awaiting-collect -> free: collected, full refund, slot freed",
"ready-to-sell -> sell-open: sell offer placed",
"sell-open -> closed: fully filled and proceeds collected",
"sell-open -> sell-cancelled-awaiting-collect: abort",
"sell-cancelled-awaiting-collect -> ready-to-requote: collected, requote",
"sell-cancelled-awaiting-collect -> closed: collected, slot closed",
"ready-to-requote -> buy-open: requoted buy",
"ready-to-requote -> sell-open: requoted sell",
"ready-to-requote -> free: abandoned",
],
"scheduler_iteration": "one pass over all slots; dispatch pending actions only",
"quote_age_policy": "resting offers re-checked every 600 s; stale edges requoted",
"persisted_state": "per-slot record: sub-state + quoted/filled/sold/collected",
"limit_ledger": "limit_used + first_fill_ts + limit_resets_at, independent of offer expiry",
"conservation": "quoted == still_offered + collected + refunded + sold",
"break_resume": "breaks between passes; state persists in the per-slot record",
}
flip_ok = {"tags": margin_tags, "economy": {
"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17,
"ceiling_gp_per_hour": 149500, "gp_per_hour_low": 102600,
"gp_per_hour_high": 138800}, "slot_state_machine": ssm_ok}
check("flip lifecycle: ceiling + nested realized bounds + canonical slot machine passes",
ge_lifecycle_errors("ok.yaml", flip_ok) == [])
errs = ge_lifecycle_errors("bad.yaml", {"tags": margin_tags, "economy": {
"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17,
"ceiling_gp_per_hour": 149500, "gp_per_hour_low": 160000,
"gp_per_hour_high": 138800}})
check("flip lifecycle: realized low above the ceiling is rejected (bounds must nest)",
any("nest" in e for e in errs))
errs = ge_lifecycle_errors("bad2.yaml", {"tags": margin_tags, "economy": {
"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17,
"gp_per_hour_low": 0, "gp_per_hour_high": 138800}})
check("flip lifecycle: missing typed ceiling field is rejected",
any("ceiling_gp_per_hour" in e for e in errs))
errs = ge_lifecycle_errors("bad3.yaml", dict(flip_ok, slot_state_machine=dict(
ssm_ok, sub_states=["free", "buy-open"])))
check("flip lifecycle: non-canonical sub-state set is rejected",
any("canonical" in e for e in errs))
errs = ge_lifecycle_errors("bad4.yaml", dict(flip_ok, slot_state_machine=dict(
ssm_ok, slot_transitions={"free": ["buy-open"],
"buy-open": ["ready-to-sell",
"buy-cancelled-awaiting-collect"]},
slot_transition_whens=ssm_ok["slot_transition_whens"][:4])))
check("flip lifecycle: a sub-state without transitions (ready-to-sell missing) is rejected",
any("no transitions" in e for e in errs))
return errors
@ -213,6 +542,9 @@ def main():
err = ge_floor_tax_error(rel, data)
if err:
errors.append(err)
# GE-flip lifecycle gates (per-slot async model, 4h limit ledger vs
# offer expiry, typed ceiling vs realized scenario bounds).
errors += ge_lifecycle_errors(rel, data)
# 3. staleness / research TODOs
if data.get("status") == "verified":