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:
parent
3572255a11
commit
02b366c7ef
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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":
|
||||
|
||||
Loading…
Reference in New Issue
Block a user