Merge remote-tracking branch 'origin/main' into issue-4
# Conflicts: # schemas/entry.schema.json # tools/validate.py
This commit is contained in:
commit
7ee36da445
626
codex/economy/flip-candidates.yaml
Normal file
626
codex/economy/flip-candidates.yaml
Normal file
@ -0,0 +1,626 @@
|
||||
id: flip-candidates
|
||||
title: GE Flipping (margin trading)
|
||||
domain: economy
|
||||
tags: [flipping, merchanting, ge, money-making, hybrid]
|
||||
status: verified
|
||||
version: 6 # 6: bounce-fix 7 2026-08-29 (governor comment 10471) — 'closed' is no longer a sink: after a completed sell's proceeds are collected and the ledger is reconciled + ARCHIVED, the physical GE slot is released to free via the explicit archival transition `closed -> free` (a deliberately closed cancelled sell releases the same way), and the committed two-rotation regression proves one slot completes buy/collect/sell/collect, archives the first ledger, and opens a SECOND buy in the same slot without losing, duplicating, or mutating the archived record. Previous (v5, bounce-fix 6): the 4h buy-limit reset NEVER cancels an offer (offer lifetime = independent quote_age_policy: stale edge or strategy reallocation only); explicit buy-filled-awaiting-collect / sell-filled-awaiting-collect states so completed offers must pass through the GE collection box (ge_collect) before reuse or closing; SIDE-AWARE per-slot ledgers (buy: quoted units = filled + still_open + cancelled_unfilled, acquired items = filled ONLY, cash = spent + still_committed + refunded; sell: quoted = sold + still_open + returned_unsold) with committed negative fixtures for phantom refunded buy units, a full buy sold before collection, and a full sell closed before proceeds collection. Capped/floored tax model + its 4 selftest fixtures and the asynchronous scheduler behavior preserved (owner decisions 2026-08-28).
|
||||
updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date
|
||||
|
||||
summary: >-
|
||||
Buy-and-sell Grand Exchange margin flipping: place resting buy offers at
|
||||
the low edge of an item's price band and resting sell offers at the high
|
||||
edge, harvesting the spread on high-volume items. There are no skill or
|
||||
quest requirements — capital breadth and slot discipline are the whole
|
||||
game. Members accounts run 8 GE slots over the full candidate set; F2P
|
||||
accounts get 3 slots and F2P-tradeable rows only (separate F2P path in
|
||||
profit_drivers). Model (governor 10323 + 10390, 2026-08-28): every GE
|
||||
slot is an INDEPENDENT asynchronous sub-state machine (free / buy-open /
|
||||
buy-filled-awaiting-collect / buy-cancelled-awaiting-collect /
|
||||
ready-to-sell / sell-open / sell-filled-awaiting-collect /
|
||||
sell-cancelled-awaiting-collect / ready-to-requote / closed) driven by a
|
||||
global scheduler pass that acts only on dispatchable slots and never
|
||||
closes or advances unrelated ones; completed offers (full buys and full
|
||||
sells) land in the GE COLLECTION BOX and must pass through ge_collect +
|
||||
ledger reconciliation before assets are reused or the slot closes;
|
||||
'closed' is NOT a sink — the physical GE slot is released to free via
|
||||
the archival transition `closed -> free` after the completed cycle's
|
||||
ledger is reconciled + archived, so every slot runs flip after flip
|
||||
through rotations and breaks (two-rotation reuse regression); the
|
||||
4h buy-limit ledger (limit_used / first_fill_ts / limit_resets_at /
|
||||
remaining) is DISTINCT from offer expiry — a limit-window reset NEVER
|
||||
cancels an offer and cancellation NEVER renews the limit; ge_cancel
|
||||
moves filled items, refunded CASH, unsold units and proceeds to the box,
|
||||
and every snapshot satisfies SIDE-AWARE per-slot conservation (buy:
|
||||
quoted units == filled + still_open + cancelled_unfilled with acquired
|
||||
items == filled only, cash == spent + still_committed + refunded; sell:
|
||||
quoted == sold + still_open + returned_unsold);
|
||||
economy.ceiling_gp_per_hour is the TYPED economic ceiling and
|
||||
gp_per_hour_low/high are documented realized scenario bounds. Pinned
|
||||
worked example + committed validator fixtures prove the model.
|
||||
|
||||
accounts:
|
||||
membership: hybrid # members: 8 slots/full set; F2P: 3 slots/F2P rows (see economy)
|
||||
min_level: 3 # post-Tutorial Island baseline; no skill requirement
|
||||
requires_quest: []
|
||||
requires_other:
|
||||
- "Post-Tutorial Island account with Grand Exchange access (no skill or quest requirement)"
|
||||
- "Members account: 8 GE slots, full candidate set (the modeled path below). F2P account: 3 GE slots and F2P-tradeable rows only — run the F2P path in profit_drivers."
|
||||
- "Minimum starting capital: per slot = buy edge x 4h limit (arrowtips 2.23M, iron bar 1.45M, cosmic 2.196M, silver ore 0.949M, swordfish 3.09M, lobster 1.59M, magic logs 9.264M, mud 1.728M at the 2026-08-27 snapshot) — full 8-slot members coverage ~22.5M; the 3-slot F2P path needs ~5.5M (iron 1.45M + cosmic 2.196M + silver 0.949M). Capital below one slot's requirement takes that slot off the table."
|
||||
|
||||
best_practice:
|
||||
equipment:
|
||||
- item: cash stack (flip capital)
|
||||
why: "Gross margin per item is a few gp, so profit scales with quantity and the 4h
|
||||
buy-limit window caps per-item volume — capital breadth (more candidates/slots
|
||||
covered) matters more than any gear. Bulk flipping at scale needs ~20M+ cash."
|
||||
source: "OSRS wiki — Merchanting (Bulk quantity flipping)"
|
||||
techniques:
|
||||
- name: flip the spread, not the trend
|
||||
why: "Flipping profit is the buy/sell spread between pending offers (the impatience
|
||||
premium), NOT price movement; it is safer when the item has high trade volume."
|
||||
source: "OSRS wiki — Merchanting (Flipping)"
|
||||
- name: prefer dynamic-price items, avoid static-price items
|
||||
why: "Static-price items have no spread to harvest; only dynamic-price items carry
|
||||
an exploitable margin."
|
||||
source: "OSRS wiki — Merchanting (flipping guidance: 'strongly recommended to not
|
||||
flip static-price items')"
|
||||
- name: quote at the band edges, never instant-buy/sell
|
||||
why: "Instant matching pays the standing offer's price (the market's impatience
|
||||
price); a resting offer fills when a matching offer appears at or better than your
|
||||
price. The 2% convenience fee makes sub-2%-spread staples unprofitable at band
|
||||
edges outright, so the edges are where the margin lives."
|
||||
source: "OSRS wiki — Merchanting (How the Grand Exchange works); OSRS wiki — Grand Exchange
|
||||
(2% tax since 2025-05-29, capped 5M/item)"
|
||||
- name: pace quotes inside the 4h buy-limit window, watch connected limits
|
||||
why: "The buying rate is restricted to a set quantity every 4 hours per player, and
|
||||
some items share a connected limit with related items — treat those as one bucket
|
||||
when filling slots. The window starts when the FIRST unit of the window is bought
|
||||
(first_fill_ts) and resets 4h later. The reset ONLY restores buying capacity —
|
||||
it never cancels, expires, or otherwise affects a resting offer — and cancelling
|
||||
an offer does NOT renew or restart the window."
|
||||
source: "OSRS wiki — Grand Exchange (Buy limits; Grand Exchange/Buying limits)"
|
||||
- name: run the path that matches the account's slot set
|
||||
why: "Members get eight GE slots and may trade any row; F2P accounts get three slots
|
||||
and may only purchase free-to-play rows. The modeled economics below assume the
|
||||
members set — a hybrid account must swap to the 3-slot F2P path (Iron bar /
|
||||
Cosmic rune / Silver ore) instead of chasing members-only rows it cannot buy."
|
||||
source: "OSRS wiki — Grand Exchange (Members get eight Grand Exchange slots ... free players are limited to three. Additionally, free players may only purchase members' items)"
|
||||
- name: treat each slot as its own asynchronous machine; the scheduler never touches resting slots
|
||||
why: "A slot's offer fills on its own timeline (partial fills keep resting). The
|
||||
global scheduler pass acts only on dispatchable slots (free -> quote,
|
||||
*-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote ->
|
||||
requote, closed -> free archival release) and leaves resting buy-open /
|
||||
sell-open slots untouched — one slot's cancel or close never advances an
|
||||
unrelated slot. A FULLY FILLED offer never sells or closes directly: it
|
||||
first enters an explicit buy-/sell-filled-awaiting-collect state because
|
||||
its assets sit in the GE collection box until ge_collect (finding 2,
|
||||
governor 10390). A 'closed' slot is only ever transient: the completed
|
||||
cycle's ledger is archived at close and the pass releases the physical
|
||||
slot to free (closed -> free), so the same slot runs the next flip —
|
||||
never one flip and done (governor 10471)."
|
||||
source: "governor review comments 10323 + 10390 + 10471 (2026-08-28/29); OSRS wiki — Grand Exchange / Bank (collection box); docs/action-grammar.md"
|
||||
- name: keep a per-slot offer ledger; ge_cancel -> collection box -> ge_collect -> reconcile, never strand units
|
||||
why: "Every slot tracks its side-aware ledger: a BUY quote partitions units into
|
||||
filled + still_open + cancelled_unfilled with acquired items = filled ONLY (the
|
||||
unfilled/cancelled quantity is CASH: spent / still_committed / refunded), and a
|
||||
SELL quote partitions sold + still_open + returned_unsold. ge_cancel moves
|
||||
filled items, refunded CASH, unsold units and sale proceeds to the GE COLLECTION
|
||||
BOX (never directly to inventory); a separate ge_collect withdraws them and the
|
||||
ledger is reconciled before the slot is reused. The ONLY cancel triggers are a
|
||||
stale scanned edge (margin no longer positive) or an explicit strategy
|
||||
reallocation — never the 4h buy-limit reset, which merely restores capacity."
|
||||
source: "OSRS wiki — Grand Exchange (offer mechanics; collection); Bank (collection interface); governor review comment 10390 findings 1-4; docs/action-grammar.md (ge_cancel / ge_collect)"
|
||||
|
||||
economy:
|
||||
# Model (documented, reproducible from the snapshot):
|
||||
# Members path (8 GE slots — the modeled economics below):
|
||||
# per slot: buy `limit` units at the low edge, sell at the high edge.
|
||||
# tax_pu = min(floor(0.02 * sell), 5_000_000) # 2% convenience fee,
|
||||
# # rounded DOWN to whole coins per item,
|
||||
# # capped 5M/item (owner decision
|
||||
# # 2026-08-28; cap never hit here)
|
||||
# net_pu = sell - tax_pu - buy # integer per-item net (floored tax)
|
||||
# 4h/slot = net_pu * limit # buy-limit window caps per-4h volume
|
||||
# F2P path (3 GE slots, F2P-tradeable rows only): same arithmetic, top-3
|
||||
# F2P rows — see profit_drivers for the table and rate.
|
||||
# Snapshots 2026-08-27: prices.runescape.wiki mapping + latest; limits and
|
||||
# members flags cross-checked against the wiki item pages (all agree). Top 8
|
||||
# by 4h/slot below in profit_drivers.
|
||||
#
|
||||
# Lifetime qualification (governor 10323 finding 5, 2026-08-28):
|
||||
# ceiling_gp_per_hour (149,500) is the TYPED ECONOMIC CEILING: ideal full
|
||||
# fills of every slot, zero overhead — never presented as an achieved
|
||||
# runtime rate. gp_per_hour_low/high are DOCUMENTED REALIZED SCENARIO
|
||||
# BOUNDS (not uncited multipliers), each with explicit arithmetic in
|
||||
# profit_drivers:
|
||||
# high 138,000 = 149,500 x 37/40 — 'resting-fill' scenario: all 8 slots
|
||||
# full-fill at their quoted edges; each 40-min rotation is 37 min of
|
||||
# resting waits + 3 min of scan/place/collect actions.
|
||||
# low 74,750 = 149,500 x 0.50 — 'requote-heavy' scenario: every 4h
|
||||
# window's volume completes only after one stale-edge ge_cancel +
|
||||
# requote cycle (the no-fill remainder is refunded to the collection
|
||||
# box; the slot re-quotes when the margin clears again or capacity
|
||||
# allows), halving the window's filled volume.
|
||||
# Bounds nest: 0 <= 74,750 <= 138,000 <= 149,500 (validator-enforced).
|
||||
item: Rune arrowtips # anchor: top candidate by modeled 4h/slot (members)
|
||||
item_id: 44
|
||||
cost_per_cycle: 223 # buy @ low edge per unit (snapshot 2026-08-27)
|
||||
income_per_cycle: 244 # sell @ high edge per unit, gross (snapshot 2026-08-27)
|
||||
net_per_cycle: 17 # 244 - min(floor(244*0.02), 5M) - 223 = 244 - 4 - 223 (floored, capped tax)
|
||||
ceiling_gp_per_hour: 149500 # TYPED economic ceiling: sum of the 8 slots' 4h/slot (= 598,000) / 4h — ideal full fills, zero overhead (see profit_drivers table)
|
||||
gp_per_hour_low: 74750 # realized scenario bound 'requote-heavy' = 149,500 x 0.50 (documented in profit_drivers)
|
||||
gp_per_hour_high: 138000 # realized scenario bound 'resting-fill' = 149,500 x 37/40 (documented in profit_drivers)
|
||||
xp_per_hour: 0 # flipping awards no XP
|
||||
supplies:
|
||||
- item: Rune arrowtips # canonical wiki names per action-grammar conventions
|
||||
qty_per_cycle: 1
|
||||
source: ge
|
||||
ge_limit: 10000 # mapping + Module:Exchange agree
|
||||
- item: Iron bar
|
||||
qty_per_cycle: 1
|
||||
source: ge
|
||||
ge_limit: 10000
|
||||
- item: Cosmic rune
|
||||
qty_per_cycle: 1
|
||||
source: ge
|
||||
ge_limit: 18000
|
||||
profit_drivers:
|
||||
- "Spread per unit x trade volume; volume is the safety factor (high-volume items fill faster and drift less)"
|
||||
- "Slot breadth: cover as many net-positive candidates as capital allows (members 8 slots / F2P 3 slots)"
|
||||
- "Members path — snapshot 2026-08-27 top 8 by modeled 4h/slot (floored+capped 2% tax): Rune arrowtips 170,000 (M, buy 223 / sell 244 / limit 10000); Iron bar 90,000 (F2P, 145/157/10000); Cosmic rune 72,000 (F2P, 122/128/18000); Magic logs 72,000 (M, 772/793/12000); Silver ore 65,000 (F2P, 73/79/13000); Mud rune 54,000 (M, 96/101/18000); Raw swordfish 45,000 (F2P, 206/213/15000); Raw lobster 30,000 (F2P, 106/110/15000) — total 598,000 per 4h = 149,500 gp/h CEILING on 8 slots"
|
||||
- "F2P path — 3 slots, F2P-tradeable rows only: Iron bar 90,000 + Cosmic rune 72,000 + Silver ore 65,000 = 227,000 per 4h = 56,750 gp/h ceiling (alternates: Raw swordfish 45,000, Raw lobster 30,000)"
|
||||
- "Post-2%-fee reality: 11 of 21 classic staples are net-positive at band edges for members; sub-2%-spread items (law/chaos/blood runes, dragon bones, adamantite/mithril bars) need sub-low buys or above-high sells"
|
||||
- "Realized bounds are DOCUMENTED SCENARIOS with explicit arithmetic (governor 10323 finding 5 — no uncited fill/duty multipliers): HIGH 'resting-fill' 138,000 gp/h is the ceiling scaled by the rotation duty of 37 resting minutes per 40-minute rotation (149,500 x 37/40 = 138,287.5 -> 138,000): scan/place/collect actions cost ~3 min per rotation at the 600s quote-check cadence; LOW 'requote-heavy' 74,750 gp/h assumes every 4h window's volume completes only after one stale-edge ge_cancel + requote cycle, so the no-fill remainder (refunded to the collection box and re-quoted when the margin clears or capacity allows) halves the filled volume (149,500 x 0.50). The ceiling 149,500 itself is an economic bound, never an achieved rate. Cancelling never renews the buy limit and the limit reset never cancels an offer (governor 10390 finding 1)."
|
||||
|
||||
slot_state_machine:
|
||||
sub_states: [free, buy-open, buy-filled-awaiting-collect, buy-cancelled-awaiting-collect, ready-to-sell, sell-open, sell-filled-awaiting-collect, sell-cancelled-awaiting-collect, ready-to-requote, closed]
|
||||
slot_transitions:
|
||||
free: [buy-open]
|
||||
buy-open: [buy-filled-awaiting-collect, buy-cancelled-awaiting-collect]
|
||||
buy-filled-awaiting-collect: [ready-to-sell, free]
|
||||
buy-cancelled-awaiting-collect: [ready-to-sell, ready-to-requote, free]
|
||||
ready-to-sell: [sell-open]
|
||||
sell-open: [sell-filled-awaiting-collect, sell-cancelled-awaiting-collect]
|
||||
sell-filled-awaiting-collect: [closed]
|
||||
sell-cancelled-awaiting-collect: [ready-to-requote, closed]
|
||||
ready-to-requote: [buy-open, sell-open, free]
|
||||
closed: [free] # archival release — the physical slot is reusable for the next flip (governor 10471)
|
||||
slot_transition_whens:
|
||||
- "free -> buy-open: scheduler pass quotes a resting buy at the low edge (qty = min(remaining 4h limit, slot capital, capital available))"
|
||||
- "buy-open -> buy-filled-awaiting-collect: offer FULLY filled (filled == quoted) — the units sit in the GE COLLECTION BOX until ge_collect; the slot may NOT sell them directly (governor 10390 finding 2)"
|
||||
- "buy-open -> buy-cancelled-awaiting-collect: quote_age_policy abort — scanned edge stale (margin no longer positive) or explicit strategy reallocation; NEVER the 4h limit reset. ge_cancel moves filled items + refunded CASH (the unspent remainder) to the collection box; the buy limit is NOT renewed"
|
||||
- "buy-filled-awaiting-collect -> ready-to-sell: ge_collect withdrawn the units; sell edge positive — dispatch sells"
|
||||
- "buy-filled-awaiting-collect -> free: ge_collect withdrawn; margin gone or capital reallocated — bank the units, release the slot"
|
||||
- "buy-cancelled-awaiting-collect -> ready-to-sell: ge_collect withdrawn the bought units; sell edge positive — sell the units in hand"
|
||||
- "buy-cancelled-awaiting-collect -> ready-to-requote: ge_collect withdrawn; buy margin still positive AND remaining limit > 0 — requote at the fresh low edge"
|
||||
- "buy-cancelled-awaiting-collect -> free: ge_collect withdrawn (full cash refund); slot released (margin gone or capital reallocated)"
|
||||
- "ready-to-sell -> sell-open: scheduler pass quotes a resting sell at the high edge for the units in hand"
|
||||
- "sell-open -> sell-filled-awaiting-collect: sell offer FULLY filled (sold == quoted) — the proceeds sit in the GE COLLECTION BOX until ge_collect; the slot may NOT close directly (governor 10390 finding 2)"
|
||||
- "sell-open -> sell-cancelled-awaiting-collect: quote_age_policy abort — scanned edge stale or strategy reallocation; ge_cancel: unsold units + proceeds received so far move to the collection box (never to inventory)"
|
||||
- "sell-filled-awaiting-collect -> closed: ge_collect withdrawn the proceeds; ledger reconciled and ARCHIVED — the archived cycle record is immutable; the physical slot is released at the next dispatch by the closed-to-free archival transition (governor 10471)"
|
||||
- "sell-cancelled-awaiting-collect -> ready-to-requote: ge_collect withdrawn; requote the sell at the fresh high edge or the current band"
|
||||
- "sell-cancelled-awaiting-collect -> closed: ge_collect withdrawn; ledger archived, deliberately closed (exit taken) — the physical slot releases to free exactly like a completed sell (governor 10471)"
|
||||
- "closed -> free: ARCHIVAL RELEASE — the completed cycle's ledger stays archived (immutable, conserved), and the physical GE slot returns to free to place the next flip's buy (two-rotation reuse regression)"
|
||||
- "ready-to-requote -> buy-open: requoted buy placed (buy_capacity_ok: limit_used + qty <= limit until limit_resets_at)"
|
||||
- "ready-to-requote -> sell-open: requoted sell placed at the fresh high edge"
|
||||
- "ready-to-requote -> free: slot abandoned and released"
|
||||
scheduler_iteration: >-
|
||||
One pass over every slot exactly once, at the 600 s quote-check cadence.
|
||||
Only dispatchable slots act: free -> quote (ge_buy), buy-filled-
|
||||
awaiting-collect / buy-cancelled-awaiting-collect /
|
||||
sell-filled-awaiting-collect / sell-cancelled-awaiting-collect ->
|
||||
ge_collect, ready-to-sell -> sell (ge_sell), ready-to-requote ->
|
||||
requote, closed -> free (archival release: the completed cycle's
|
||||
ledger was archived at close; the pass returns the physical slot to
|
||||
free so the next flip can begin). Resting buy-open / sell-open slots
|
||||
produce NO action and are never closed, advanced, re-quoted or touched
|
||||
by the pass. A pass therefore advances only the slots whose sub-state
|
||||
is dispatchable, a completed offer always passes through its
|
||||
*-filled-awaiting-collect state (ge_collect) before its assets are
|
||||
reused, and a closed slot is only ever transient — released to free
|
||||
rather than left to strand after one flip (governor 10471).
|
||||
quote_age_policy: >-
|
||||
Offer lifetime is INDEPENDENT of the 4h buy-limit ledger. Resting
|
||||
offers are re-checked at the 600 s cadence and kept resting while the
|
||||
scanned margin stays positive — a profitable resting offer remains
|
||||
open regardless of limit_resets_at (a limit-window reset only restores
|
||||
buy capacity; it never cancels, expires, or implies cancelling an
|
||||
offer). An offer is aborted ONLY on a stale band edge (scanned margin
|
||||
no longer positive) or an explicit strategy reallocation (slot freed
|
||||
for a better candidate / stop condition). No-fill alone never aborts an
|
||||
offer whose edge still clears the margin.
|
||||
persisted_state: >-
|
||||
Per-slot record persisted between passes and across breaks: sub-state,
|
||||
item, quoted qty, and SIDE-AWARE buckets — buy side: filled, still_open,
|
||||
cancelled_unfilled, cash_quoted, spent, still_committed, refunded;
|
||||
sell side: sold, still_open, returned_unsold, proceeds_in_box,
|
||||
cash_collected; plus limit_used, first_fill_ts, limit_resets_at,
|
||||
quote placed_at, last check_ts. The GE offers themselves persist
|
||||
server-side; the record is the agent's copy of truth for the scheduler
|
||||
and conservation checks.
|
||||
limit_ledger: >-
|
||||
Per item: limit_used (units bought so far in the current 4h window),
|
||||
first_fill_ts (when the FIRST unit of the window was bought),
|
||||
limit_resets_at = first_fill_ts + 4h, remaining = limit - limit_used.
|
||||
The ledger controls ONLY the buying rate: it is DISTINCT from offer
|
||||
expiry (offers rest independently of the window), cancelling never
|
||||
renews or restarts it, and the reset never cancels an offer — a requote
|
||||
before limit_resets_at may buy at most remaining (buy_capacity_ok:
|
||||
limit_used + qty <= limit). A slot with limit_used == limit cannot
|
||||
place a new or requoted buy until limit_resets_at; its resting offer
|
||||
is unaffected.
|
||||
conservation: >-
|
||||
SIDE-AWARE per-slot invariants at every snapshot (governor 10390
|
||||
finding 4). BUY side: quoted units == filled + still_open +
|
||||
cancelled_unfilled per item, and ACQUIRED ITEMS == filled ONLY — the
|
||||
unfilled/cancelled quantity is a CASH refund, never refunded units;
|
||||
cash_quoted == spent + still_committed + refunded. SELL side: quoted
|
||||
units == sold + still_open + returned_unsold per item (a partial sell
|
||||
legitimately spans sold + still_open). No negative or duplicated
|
||||
quantities; impossible pairs (still_open + cancelled_unfilled on a
|
||||
buy; still_open + returned_unsold on a sell) are violations. Phase
|
||||
gates: a buy-filled-awaiting-collect slot has NOTHING sold yet (a full
|
||||
buy cannot sell before ge_collect) and a sell-filled-awaiting-collect
|
||||
slot holds its proceeds in the box; a 'closed' slot has no uncollected
|
||||
proceeds or cancelled/returned units — it holds ONLY the archived
|
||||
(immutable) cycle record, and the physical slot is released to free
|
||||
via the closed -> free archival transition so the SAME slot can run
|
||||
the next flip (governor 10471; two-rotation reuse regression: the
|
||||
archived rotation-1 ledger survives rotation 2 bit-for-bit).
|
||||
Committed validator fixtures —
|
||||
including the negative fixtures (phantom refunded buy units; a full
|
||||
buy sold before collection; a full sell closed before proceeds
|
||||
collection; a 'closed' sink that strands the slot after one flip) —
|
||||
prove the invariants and the scheduler dispatch.
|
||||
break_resume: >-
|
||||
Breaks are taken only when no slot is dispatchable: every slot is
|
||||
resting (buy-open / sell-open) or free — never mid-collect, mid-place,
|
||||
or in an *-awaiting-collect state. A closed slot is only ever
|
||||
transient (archived and released to free by the pass), so a break
|
||||
never catches the activity between archive and release; full state
|
||||
(offers server-side + the persisted per-slot record) survives the
|
||||
pause; resume by re-opening the GE, re-checking offers, re-running the
|
||||
scheduler pass and continuing each slot from its persisted sub-state.
|
||||
Slots and slots only: the 8-slot members / 3-slot F2P activity is
|
||||
never downgraded to single-slot work.
|
||||
|
||||
state_machine:
|
||||
initial: idle
|
||||
terminal: [complete, failed]
|
||||
states:
|
||||
idle:
|
||||
goal: "be logged in at a bank with a GE booth, capital >= floor, every slot's ledger closed (sub-state free — a closed slot is transient and released to free by the pass — all live buckets zero)"
|
||||
next: [scan, complete]
|
||||
scan:
|
||||
goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax (a3)"
|
||||
next: [schedule, idle]
|
||||
schedule:
|
||||
goal: "run ONE scheduler pass over all 8 (members) or 3 (F2P) slots: dispatch free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> requote; resting slots untouched (slot_state_machine.scheduler_iteration)"
|
||||
next: [wait, collect]
|
||||
wait:
|
||||
goal: "hold resting offers; at the 600 s cadence re-check offers (a6) and re-run the scheduler pass (schedule); a slot leaves wait when it becomes dispatchable"
|
||||
next: [schedule, collect]
|
||||
collect:
|
||||
goal: "ge_collect every awaiting-collect slot: withdraw filled items, refunds, unsold units and proceeds from the collection box; reconcile each slot's ledger (conservation invariants) and only then allow slot reuse (a9)"
|
||||
next: [schedule, scan, complete, idle]
|
||||
complete:
|
||||
goal: "session GP target reached"
|
||||
failed:
|
||||
goal: "hard failure — stop, log ERROR sentinel, alert"
|
||||
transitions:
|
||||
- from: idle
|
||||
when: "GE access available AND capital >= floor AND session target not reached"
|
||||
to: scan
|
||||
- from: idle
|
||||
when: "capital below floor OR session target already reached"
|
||||
to: complete
|
||||
- from: scan
|
||||
when: "at least one net-positive candidate at the scanned band edges (floored+capped tax)"
|
||||
to: schedule
|
||||
- from: scan
|
||||
when: "no net-positive candidate on 3 consecutive scans (prices stale — stand down)"
|
||||
to: idle
|
||||
- from: schedule
|
||||
when: "pass placed or requoted offers (quotes outstanding) — rest until the next cadence"
|
||||
to: wait
|
||||
- from: schedule
|
||||
when: "pass marked slots awaiting collection (ge_cancel happened) — withdraw before further dispatch"
|
||||
to: collect
|
||||
- from: wait
|
||||
when: "600 s cadence reached — re-check offers and re-run the scheduler pass"
|
||||
to: schedule
|
||||
- from: wait
|
||||
when: "a slot filled and proceeds/results are ready to collect"
|
||||
to: collect
|
||||
- from: collect
|
||||
when: "only resting slots remain (closed slots are released to free by the same pass); cadence continues"
|
||||
to: wait
|
||||
- from: collect
|
||||
when: "session GP target reached"
|
||||
to: complete
|
||||
- from: collect
|
||||
when: "rotation done and window open — start the next rotation"
|
||||
to: scan
|
||||
- from: collect
|
||||
when: "capital fell below floor after reconciliation"
|
||||
to: idle
|
||||
|
||||
actions:
|
||||
- id: a1
|
||||
step: reach-ge
|
||||
verb: move_to
|
||||
target: "bank with Grand Exchange booth"
|
||||
params:
|
||||
location: "Grand Exchange (Varrock) area bank"
|
||||
method: [teleport, walk]
|
||||
on_fail: retry
|
||||
anti_ban:
|
||||
delay: [400, 1600]
|
||||
|
||||
- id: a2
|
||||
step: open-ge
|
||||
verb: ge_open
|
||||
target: grand-exchange
|
||||
params:
|
||||
booth: "GE counter or bank booth"
|
||||
on_fail: retry
|
||||
anti_ban:
|
||||
delay: [800, 1800]
|
||||
|
||||
- id: a3
|
||||
step: scan-bands
|
||||
verb: ge_check_price
|
||||
target: "current candidates"
|
||||
params:
|
||||
items: "top-N candidates from the entry table"
|
||||
band: "high and low edge per item"
|
||||
margin: "net-positive = sell - min(floor(0.02 x sell), 5,000,000) - buy > 0"
|
||||
on_fail: skip
|
||||
anti_ban:
|
||||
delay: [1200, 2800]
|
||||
|
||||
- id: a4
|
||||
step: place-buys
|
||||
verb: ge_buy
|
||||
target: grand-exchange
|
||||
params:
|
||||
item: "per dispatchable free slot (scheduler pass)"
|
||||
qty: "min(remaining 4h limit, slot capacity, capital available) — buy_capacity_ok(qty + limit_used <= limit)"
|
||||
price: "low edge (rounded down)"
|
||||
max_price: "low edge — never instant"
|
||||
ledger: "record quoted qty, cash_quoted, limit_used check; sub-state -> buy-open"
|
||||
on_fail: skip
|
||||
anti_ban:
|
||||
delay: [900, 2700]
|
||||
|
||||
- id: a5
|
||||
step: hold-fills
|
||||
verb: sleep
|
||||
params:
|
||||
delay: [6000, 12000]
|
||||
until: "ge_offers_done changes or a fill event or the 600s cadence (a resting offer keeps resting; the 4h limit reset plays no role in offer lifetime)"
|
||||
on_fail: retry
|
||||
anti_ban:
|
||||
delay: [300, 900]
|
||||
|
||||
- id: a6
|
||||
step: check-fills
|
||||
verb: ge_offers_done
|
||||
target: "all quoted slots"
|
||||
params:
|
||||
phase: "per-slot: buy-open (buy outstanding) | sell-open (sell outstanding)"
|
||||
per_slot: "side-aware buckets per slot — buy: filled (units in the collection box) / still_open (resting offer) / cancelled_unfilled; sell: sold (proceeds in the box) / still_open / returned_unsold — partial fills keep the offer resting with filled units held in the box"
|
||||
verdict: "full | partial | none | stale-edge # 'lapse' is NOT a verdict: the 4h window is a capacity clock, not an offer deadline"
|
||||
on_fail: retry
|
||||
|
||||
- id: a7
|
||||
step: place-sells
|
||||
verb: ge_sell
|
||||
target: grand-exchange
|
||||
params:
|
||||
item: "per ready-to-sell slot (units in hand AFTER ge_collect — a fully filled buy must first pass through buy-filled-awaiting-collect -> ge_collect)"
|
||||
qty: "units in hand for that slot"
|
||||
price: "high edge (rounded up)"
|
||||
ledger: "record sold qty per slot; sub-state -> sell-open"
|
||||
on_fail: skip
|
||||
anti_ban:
|
||||
delay: [900, 2700]
|
||||
|
||||
- id: a8
|
||||
step: cancel-to-box
|
||||
verb: ge_cancel
|
||||
target: "slot offers aborted by quote_age_policy (stale edge / strategy reallocation)"
|
||||
params:
|
||||
offers: "slots whose scanned band edge no longer clears the margin (stale), or slots explicitly reallocated by the strategy — NEVER offers whose 4h limit-window merely reset (a reset restores buy capacity only; quote_age_policy)"
|
||||
moved: "BUY cancel: filled items + refunded CASH (the unspent remainder) -> GE COLLECTION BOX; SELL cancel: unsold units + proceeds received so far -> GE COLLECTION BOX (never directly to inventory)"
|
||||
limit: "the 4h buy limit is NOT renewed or restarted by the cancel (limit_used stays); the next reset does not cancel anything either"
|
||||
sub_state: "buy-open -> buy-cancelled-awaiting-collect; sell-open -> sell-cancelled-awaiting-collect"
|
||||
on_fail: retry
|
||||
|
||||
- id: a9
|
||||
step: collect-and-reconcile
|
||||
verb: ge_collect
|
||||
target: grand-exchange
|
||||
params:
|
||||
withdraw: "from the GE collection box: bought (filled) items, refunded CASH, unsold units, sale proceeds — per slot"
|
||||
reconcile: "SIDE-AWARE per-slot ledger conservation check BEFORE any slot reuse — buy: quoted == filled + still_open + cancelled_unfilled and cash_quoted == spent + still_committed + refunded; sell: quoted == sold + still_open + returned_unsold"
|
||||
decide: "buy-filled/buy-cancelled -> ready-to-sell (sell edge positive) | ready-to-requote (buy margin positive and remaining limit > 0) | free (release); sell-filled/sell-cancelled -> closed | ready-to-requote"
|
||||
archive: "at 'closed' the completed cycle's ledger is reconciled and ARCHIVED (immutable); the pass then releases the physical slot to free (closed -> free) so the SAME slot places the next flip's buy — the two-rotation reuse regression proves the archived record survives rotation 2 (governor 10471)"
|
||||
log: "RESULT item, qty, net gp per slot (floored+capped tax)"
|
||||
on_fail: alert
|
||||
anti_ban:
|
||||
delay: [600, 1400]
|
||||
|
||||
- id: a10
|
||||
step: take-break
|
||||
verb: take_break
|
||||
target: "current location"
|
||||
params:
|
||||
duration: "[60, 240] seconds of idle AFK per break (matches run_control.break.for)"
|
||||
safe: "only when every slot is resting (buy-open / sell-open) or free — resting offers persist server-side at their quoted edges and the per-slot record persists locally; a closed slot is transient (archived + released to free by the pass), so breaks never catch the activity between archive and release; never break mid-collect (coins in hand) or mid-place (offer half-configured)"
|
||||
resume: "re-open GE (a2), re-check offer state (a6), re-run the scheduler pass (schedule), continue each slot from its persisted sub-state (a4/a7/a8/a9)"
|
||||
on_fail: skip
|
||||
|
||||
run_control:
|
||||
iteration: "one scheduler pass over every slot at the 600 s cadence — dispatch free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> requote; resting slots untouched (slot_state_machine.scheduler_iteration)"
|
||||
max_iterations: null
|
||||
break:
|
||||
every: [6, 10] # passes (resting offers persist; state = GE offers + per-slot record)
|
||||
for: [60, 240] # seconds of idle AFK
|
||||
stop_conditions:
|
||||
- "session GP target reached"
|
||||
- "capital under floor"
|
||||
- "no net-positive candidates on 3 consecutive scans"
|
||||
- "3 consecutive failed/aborted passes (client issue — do not loop)"
|
||||
|
||||
# ================= WORKED EXAMPLE (pinned, deterministic — owner addendum 2026-08-28; slot-machine walk per governor 10323, corrected per 10390) =================
|
||||
# Members account, 8 GE slots, capital 22,500,000 gp. Snapshot prices 2026-08-27
|
||||
# (buy/sell = low/high edge; tax = min(floor(0.02 x sell), 5,000,000)).
|
||||
# The run below walks THREE slots in detail (arrowtips / iron / cosmic) exactly as
|
||||
# the committed scheduler-dispatch + conservation fixtures; the other five slots
|
||||
# behave identically to the iron slot and are omitted for brevity.
|
||||
# Ledgers are SIDE-AWARE throughout: a BUY's acquired items are `filled` ONLY;
|
||||
# unfilled/cancelled quantity is CASH (spent / still_committed / refunded).
|
||||
#
|
||||
# t0 idle -> scan -> schedule (pass 1): all slots free. Scheduler dispatch:
|
||||
# free -> quote. Slot ledger after the pass (conservation holds):
|
||||
# arrowtips: buy-open, quoted 10,000 @ 223; cash_quoted 2,230,000
|
||||
# = spent 0 + still_committed 2,230,000 + refunded 0
|
||||
# iron: buy-open, quoted 10,000 @ 145; cash_quoted 1,450,000
|
||||
# cosmic: buy-open, quoted 18,000 @ 122; cash_quoted 2,196,000
|
||||
# Unit buckets: quoted == still_open for every slot; filled/cancelled = 0.
|
||||
#
|
||||
# t1 wait, ~18 min: arrowtips PARTIAL fill 4,000/10,000 (a6 verdict PARTIAL).
|
||||
# BUY ledger: quoted 10,000 == filled 4,000 + still_open 6,000 + cancelled 0.
|
||||
# Acquired items = 4,000 ONLY; they sit in the GE collection box (collectible
|
||||
# at any bank), while the offer keeps resting with 6,000 still open. Cash:
|
||||
# cash_quoted 2,230,000 == spent 892,000 + still_committed 1,338,000.
|
||||
# limit_ledger: first_fill_ts now, limit_used 4,000, limit_resets_at = +4h,
|
||||
# remaining 6,000 (window CAPACITY — not an offer deadline).
|
||||
# Iron: FULL fill 10,000/10,000 -> buy-filled-awaiting-collect (units sit in
|
||||
# the box until ge_collect; the slot cannot sell them directly — finding 2).
|
||||
# Iron cash: spent 1,450,000. Cosmic: no fill; offer keeps resting (edge
|
||||
# positive, quote age within policy — finding 1: no-fill alone never aborts).
|
||||
#
|
||||
# t2 cadence -> schedule (pass 2): dispatch ONLY the dispatchable slot:
|
||||
# iron buy-filled-awaiting-collect -> ge_collect (a9): withdraw 10,000 units;
|
||||
# ledger reconciled (quoted 10,000 == filled 10,000; cash spent 1,450,000,
|
||||
# no refund); sell edge positive -> ready-to-sell.
|
||||
# arrowtips buy-open -> NO ACTION (resting, partial fill still open).
|
||||
# cosmic buy-open -> NO ACTION (resting, no fill, edge positive).
|
||||
# => the pass never closes or advances unrelated slots (regression fixture).
|
||||
#
|
||||
# t3 pass 3: iron ready-to-sell -> sell-open (a7): 10,000 @ 157 (high edge).
|
||||
#
|
||||
# t4 iron sell-open FULL fill -> sell-filled-awaiting-collect: proceeds
|
||||
# 1,570,000 sit in the GE collection box until ge_collect. SELL ledger:
|
||||
# quoted 10,000 == sold 10,000 + still_open 0 + returned_unsold 0.
|
||||
#
|
||||
# t5 pass 4: iron sell-filled-awaiting-collect -> ge_collect (a9): withdraw
|
||||
# 1,570,000; tax floor(157 x 0.02) = 3/item -> net (157 - 3 - 145) x 10,000
|
||||
# = 90,000. proceeds_in_box -> cash_collected; ledger reconciled and
|
||||
# ARCHIVED (the immutable cycle record: quoted 10,000 == sold 10,000);
|
||||
# sub-state -> closed, and the SAME pass's archival dispatch releases the
|
||||
# physical slot: closed -> free (governor 10471). The slot can place the
|
||||
# next flip's buy immediately — it is never stranded after one flip.
|
||||
# Conservation: quoted 10,000 == sold 10,000, zero proceeds left uncollected.
|
||||
#
|
||||
# t6 LIMIT RESET, NOT AN OFFER EVENT (finding 1): arrowtips reaches
|
||||
# limit_resets_at with 6,000 still open on the resting offer. The window
|
||||
# reset restores buying capacity ONLY — the offer KEEPS RESTING at its edge;
|
||||
# nothing is cancelled and the 4,000 filled units stay collectible in the
|
||||
# box. Cosmic likewise unaffected.
|
||||
#
|
||||
# t7 arrowtips offer FULLY fills the remaining 6,000 -> buy-filled-awaiting-
|
||||
# collect -> pass 5 ge_collect: withdraw 10,000 units (4,000 from the t1
|
||||
# partial + 6,000 from the t7 fills — the units are what the offer actually
|
||||
# bought; NO phantom "refunded units"). BUY ledger: quoted 10,000 == filled
|
||||
# 10,000; cash 2,230,000 == spent 2,230,000 (still_committed 0 — a full fill
|
||||
# spends its whole quote). Sell edge positive -> ready-to-sell.
|
||||
# limit_ledger: window 1 used 4,000 (t1); window 2 (post-reset) used 6,000.
|
||||
#
|
||||
# t8 pass 6: arrowtips ready-to-sell -> sell-open 10,000 @ 244 -> full fill ->
|
||||
# sell-filled-awaiting-collect -> pass 7 ge_collect: proceeds 2,440,000;
|
||||
# tax floor(244 x 0.02) = 4/item -> net (244 - 4 - 223) x 10,000 = 170,000
|
||||
# (matches the 170,000 4h/slot table row) -> closed (ledger archived) ->
|
||||
# closed -> free: the arrowtips physical slot is released for the next flip.
|
||||
#
|
||||
# t8b SECOND ROTATION in the SAME physical slot (governor 10471 regression):
|
||||
# the iron slot released at t5 and the arrowtips slot released at t8 are
|
||||
# free and dispatchable again — pass 8 quotes a NEW arrowtips buy in the
|
||||
# iron slot (free -> buy-open, a4; buy_capacity_ok re-checked against the
|
||||
# CURRENT window's limit_ledger). Rotation 1's archived ledgers (iron
|
||||
# closed record, arrowtips closed record) remain archived and bit-for-bit
|
||||
# untouched — no loss, duplication, or mutation — while the live ledger is
|
||||
# a fresh rotation-2 record (quoted 10,000, cash_quoted 2,230,000,
|
||||
# still_open 10,000). This is the committed slot_reuse regression: a slot
|
||||
# completes one flip, is released, and runs the next one.
|
||||
#
|
||||
# t9 STALE-EDGE recovery (cosmic, finding 1 cancel trigger): at a cadence check
|
||||
# the cosmic LOW edge has risen 122 -> 126: net 128 - floor(128 x 0.02)=2 -
|
||||
# 126 = 0 -> margin no longer positive -> quote_age_policy aborts the offer
|
||||
# (ge_cancel, a8): ZERO units were filled, so the collection box receives
|
||||
# refunded CASH 2,196,000 and NO items. BUY ledger: quoted 18,000 == filled 0
|
||||
# + still_open 0 + cancelled_unfilled 18,000; cash 2,196,000 == spent 0 +
|
||||
# still_committed 0 + refunded 2,196,000. Sub-state -> buy-cancelled-
|
||||
# awaiting-collect. (The 18,000 never-purchased units are cash, never
|
||||
# "refunded units" — the phantom-items fixture.)
|
||||
#
|
||||
# t10 pass 8: cosmic buy-cancelled-awaiting-collect -> ge_collect: withdraw
|
||||
# refunded CASH 2,196,000; ledger reconciled (refunded -> collected). The
|
||||
# fresh-edge margin is still 0, so the slot is NOT re-quoted: ready-to-
|
||||
# requote requires a positive margin — decide free, capital reallocated to a
|
||||
# candidate that still clears (e.g. magic logs); a requote in a later window
|
||||
# is governed by buy_capacity_ok as usual (the cancel did NOT renew the
|
||||
# window, and the next reset does not cancel anything).
|
||||
#
|
||||
# t11 FIRST SCHEDULED BREAK after 6-10 passes (midpoint 8): every slot is
|
||||
# resting (buy-open / sell-open), free, or collected (closed) — nothing
|
||||
# dispatchable; per-slot records persisted (slot_state_machine.break_resume);
|
||||
# take-break 60-240 s (a10); RESUME at a2 (re-open GE) -> a6 (check-fills)
|
||||
# -> schedule (next pass) -> continue per-slot from persisted sub-state.
|
||||
# Breaks never strand an offer and never interrupt a collect/place.
|
||||
#
|
||||
# t12 STOP: session target (e.g. 900,000 net) -> collect -> complete; capital
|
||||
# fell below floor -> idle -> complete; 3 consecutive failed passes -> failed.
|
||||
#
|
||||
# Rate qualification (typed ceiling vs realized scenarios — finding 5):
|
||||
# ceiling 149,500 = 598,000 per 4h / 4h — ideal full fills of all 8 slots,
|
||||
# zero overhead. Realized HIGH 138,000 = 149,500 x 37/40 (37 min resting waits
|
||||
# + 3 min scan/place/collect per 40-min rotation at the 600 s cadence).
|
||||
# Realized LOW 74,750 = 149,500 x 0.50 (requote-heavy: every 4h window's
|
||||
# volume completes only after one ge_cancel + requote cycle — the no-fill
|
||||
# remainder is refunded and re-quoted on the next window, halving volume).
|
||||
# The ceiling is NEVER presented as an achieved runtime rate.
|
||||
# =====================================================================================================
|
||||
|
||||
risk:
|
||||
ban_level: low
|
||||
notes:
|
||||
- "Flipping is standard gameplay; the detectable signal is quote churn — keep per-offer pacing humanised, never instant-buy/sell loops."
|
||||
- "The 2% fee (since 2025-05-29) and 5M/item cap make sub-2%-spread staples unprofitable at band edges; re-scan before every rotation."
|
||||
- "Thin/volatile items: a resting offer can sit for hours — offer lifetime is governed by quote_age_policy (abort only on a stale scanned edge or strategy reallocation), NEVER by the 4h buy-limit window. A limit reset merely restores buy capacity; it cancels nothing."
|
||||
- "Partial fills are normal: filled units of a partial buy sit in the GE collection box (collectible at any bank) while the offer keeps resting; never cancel-and-replace a partially-filled offer mid-window just to churn — the side-aware ledger keeps the filled qty attached until the offer fully fills or is aborted per quote_age_policy."
|
||||
- "ge_cancel NEVER returns items/coins directly to inventory: everything lands in the GE collection box. BUY cancels return filled items + refunded CASH (the unfilled quantity is cash, never 'refunded units'); SELL cancels return unsold units + proceeds received. Reuse a slot only after ge_collect + side-aware ledger reconciliation (conservation invariants)."
|
||||
- "The 4h buy limit is independent of offer life: cancelling does not renew it and the reset does not cancel offers. A slot at limit_used == limit cannot place a new/requoted BUY until limit_resets_at (first_fill_ts + 4h); its resting offer is unaffected."
|
||||
- "Breaks are safe only when no slot is dispatchable (resting offers or post-collect points, see a10 and break_resume); state = GE offers + the persisted per-slot record, which survive the pause."
|
||||
- "A completed cycle NEVER strands a slot: after ge_collect the ledger is reconciled + archived at 'closed' and the scheduler pass releases the physical slot to free (closed -> free archival release), so every slot keeps running flip after flip. Never model a slot as one-flip-then-done (governor 10471)."
|
||||
|
||||
sources:
|
||||
- "OSRS wiki — Grand Exchange (2% convenience fee since 2025-05-29, capped 5M/item, sub-50gp effectively untaxed, tax-exempt low-value items; buying rate restricted per 4h window starting on the FIRST purchase of the window; connected limits; verified 2026-08-27)"
|
||||
- "OSRS wiki — Grand Exchange/Buying limits (4h window mechanics; the limit controls how many units may be bought and resets 4h after the first unit is bought; outstanding offers remain active until filled or explicitly aborted — a limit reset is NOT offer expiry)"
|
||||
- "OSRS wiki — Grand Exchange + Bank (collection interface): items/coins from full or partial trades — including aborted offers — remain in the collection box until collected; partially completed trades can be collected at any bank"
|
||||
- "OSRS wiki — Merchanting (flipping = spread exploitation; high trade volume = safety; bulk flipping needs ~20M+ cash; avoid static-price items; offer matching at or better than your price)"
|
||||
- "prices.runescape.wiki/api/v1/osrs/mapping (item ids, members flags, limits — limits cross-checked against Module:Exchange/<Item>, all agree)"
|
||||
- "prices.runescape.wiki/api/v1/osrs/latest (high/low price band snapshot 2026-08-27)"
|
||||
- "governor review comment 10323 on PR #7 (2026-08-28): per-slot asynchronous sub-state machine + scheduler pass (finding 3); ge_cancel -> collection box + ge_collect + reconciliation (finding 2); buy-limit ledger distinct from offer expiry, cancellation never renews (finding 1); conservation invariants (finding 4); typed ceiling vs documented realized scenarios (finding 5); explicit scheduler iteration + persisted break/resume, no slot downgrade (finding 6)"
|
||||
- "governor review comment 10390 on PR #7 (2026-08-28): the 4h limit reset never cancels offers — resting offers remain open while the margin is positive (finding 1); filled offers must pass through explicit *-filled-awaiting-collect states and ge_collect before reuse/close (finding 2); side-aware ledgers kill the phantom-units error (finding 3); side-aware conservation with negative fixtures, including sale-before-collection and close-before-proceeds-collection (finding 4)"
|
||||
- "governor review comment 10471 on PR #7 (2026-08-29): a completed flip must NOT permanently strand the GE slot — after proceeds are collected and the completed ledger is archived/reconciled the physical slot returns to free via the archival transition closed -> free (also for a deliberately closed cancelled sell); committed two-rotation regression (buy/collect/sell/collect -> archive -> second buy in the same slot) with the archived ledger preserved"
|
||||
- "owner authorisations 2026-08-28/29 (guidance comments 10264, 10271, 10334, 10527/10528): capped/floored global tax formula; complete lifecycle contract; continued full-lifecycle repair on the same branch/PR (retry-budget superseded)"
|
||||
- "docs/action-grammar.md (ge_cancel/ge_collect rows, 2026-08-28) + schemas/entry.schema.json (slot_state_machine, ceiling_gp_per_hour)"
|
||||
verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27
|
||||
|
||||
related:
|
||||
- low-alch-f2p
|
||||
- f2p-alch-catalog
|
||||
- mule-topup
|
||||
@ -68,8 +68,9 @@ Legend for the mapping table:
|
||||
| `ge_open` | open GE at a bank | — | — | — |
|
||||
| `ge_buy` | buy `qty` of `item` at offer price, paced | — | — | — |
|
||||
| `ge_sell` | sell `qty` of `item`, confirm | — | — | — |
|
||||
| `ge_cancel` | abort an offer (quote_age_policy only — stale edge or strategy reallocation, never a 4h limit-window reset): BUY — filled items + refunded CASH (unspent remainder) enter the GE COLLECTION BOX; SELL — unsold units + proceeds received so far enter the box; NEVER directly to inventory; the 4h buy limit is NOT renewed | — | — | — |
|
||||
| `ge_offers_done` | predicate: no pending buy/sell offers | — | — | — |
|
||||
| `ge_collect` | collect finished GE transactions | — | — | — |
|
||||
| `ge_collect` | withdraw from the GE collection box: bought (filled) items, refunded CASH, unsold units, sale proceeds; reconcile the SIDE-AWARE per-slot ledger (buy: quoted = filled + still_open + cancelled_unfilled; sell: quoted = sold + still_open + returned_unsold) before any slot reuse; a completed cycle closes with the ledger reconciled + ARCHIVED and the same pass releases the physical slot to free (`closed -> free` archival transition), so the slot immediately runs the next flip (governor 10471) | — | — | — |
|
||||
| `ge_check_price` | read current buy/sell price of an item | — | — | — |
|
||||
|
||||
## F. Combat
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
"net_per_cycle": { "type": "number" },
|
||||
"gp_per_hour_low": { "type": "number" },
|
||||
"gp_per_hour_high": { "type": "number" },
|
||||
"ceiling_gp_per_hour": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"xp_per_hour": { "type": "number" },
|
||||
"supplies": {
|
||||
"type": "array",
|
||||
@ -175,6 +176,39 @@
|
||||
}
|
||||
},
|
||||
|
||||
"slot_state_machine": {
|
||||
"type": "object",
|
||||
"required": ["sub_states", "slot_transitions", "slot_transition_whens",
|
||||
"scheduler_iteration", "quote_age_policy", "persisted_state",
|
||||
"limit_ledger", "conservation", "break_resume"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"sub_states": {
|
||||
"type": "array",
|
||||
"minItems": 10,
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"slot_transitions": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"slot_transition_whens": {
|
||||
"type": "array",
|
||||
"minItems": 8,
|
||||
"items": { "type": "string", "minLength": 8 }
|
||||
},
|
||||
"scheduler_iteration": { "type": "string", "minLength": 10 },
|
||||
"quote_age_policy": { "type": "string", "minLength": 10 },
|
||||
"persisted_state": { "type": "string", "minLength": 10 },
|
||||
"limit_ledger": { "type": "string", "minLength": 10 },
|
||||
"conservation": { "type": "string", "minLength": 10 },
|
||||
"break_resume": { "type": "string", "minLength": 10 }
|
||||
}
|
||||
},
|
||||
|
||||
"best_practice": {
|
||||
"type": "object",
|
||||
"required": ["equipment"],
|
||||
@ -268,7 +302,7 @@
|
||||
"click_button", "menu_option",
|
||||
"bank_inventory", "deposit_all", "withdraw", "has_item",
|
||||
"count_item", "move_to_inv", "sort_inventory",
|
||||
"ge_open", "ge_buy", "ge_sell", "ge_offers_done", "ge_collect",
|
||||
"ge_open", "ge_buy", "ge_sell", "ge_cancel", "ge_offers_done", "ge_collect",
|
||||
"ge_check_price",
|
||||
"attack", "prayer", "buff", "eat", "retreat", "is_dead", "respawn",
|
||||
"is_at", "item_count", "level_is", "xp_is", "hp_is",
|
||||
|
||||
@ -37,16 +37,59 @@ Runs:
|
||||
* the entry's economy.xp_per_hour (fast-xp headline) must sit
|
||||
within 2% of the fastest declared fast-xp site's modelled
|
||||
ores/hr x its ore xp.
|
||||
- GE-margin entries (tags include flipping and/or merchanting —
|
||||
the unambiguous margin signals): the 2% GE convenience fee is
|
||||
rounded DOWN to whole coins per item and capped at 5,000,000 GP
|
||||
per item, so net_per_cycle MUST equal
|
||||
income - min(floor(0.02*income), 5_000_000) - cost.
|
||||
Fractional-tax arithmetic (0.98*sell - buy) and uncapped tax
|
||||
(6M on a 300M sale) are errors.
|
||||
NOTE: the generic `ge` tag alone does NOT gate this rule — a
|
||||
training/processing/procurement entry may legitimately carry
|
||||
`ge` while its income/cost model has nothing to do with flip tax.
|
||||
- GE-flip lifecycle (flipping/merchanting entries): the 4h buy-limit
|
||||
ledger (limit_used / limit_resets_at) is DISTINCT from offer
|
||||
expiry — a limit-window reset never cancels an offer and cancelling
|
||||
never renews the limit; every slot is an independent asynchronous
|
||||
sub-state machine (canonical set: free / buy-open /
|
||||
buy-filled-awaiting-collect / buy-cancelled-awaiting-collect /
|
||||
ready-to-sell / sell-open / sell-filled-awaiting-collect /
|
||||
sell-cancelled-awaiting-collect / ready-to-requote / closed) driven
|
||||
by a global scheduler pass that never touches unrelated slots;
|
||||
ge_cancel moves filled items, refunded CASH, unsold units and
|
||||
proceeds to the GE collection box for a separate ge_collect;
|
||||
per-slot conservation is SIDE-AWARE (buy: quoted units =
|
||||
filled + still_open + cancelled_unfilled and acquired items =
|
||||
filled ONLY — the unfilled/cancelled quantity is cash, never
|
||||
refunded units; sell: quoted = sold + still_open + returned_unsold)
|
||||
with phase gates (a full buy cannot sell before collection, a full
|
||||
sell cannot close before proceeds collection);
|
||||
closed has ONE outgoing transition — the archival release
|
||||
``closed -> free`` — so a completed cycle's physical GE slot
|
||||
returns to free for the next flip instead of stranding forever
|
||||
(governor 10471); the two-rotation reuse regression proves a slot
|
||||
completes buy/collect/sell/collect, archives the first ledger, and
|
||||
places a second buy in the same slot without loss, duplication, or
|
||||
mutation of the archived record;
|
||||
economy.ceiling_gp_per_hour is the TYPED economic ceiling and
|
||||
0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling.
|
||||
- Regression fixtures (--selftest): a fractional-tax flipping
|
||||
entry must FAIL; an above-cap high-value flip with the
|
||||
correctly capped-tax net must PASS; a non-margin entry carrying
|
||||
only `ge` plus a non-flip tag must PASS; plus per-slot scheduler
|
||||
dispatch, conservation, buy-limit and nesting fixtures.
|
||||
3. Global id uniqueness across the whole codex tree.
|
||||
|
||||
Exit codes: 0 = clean (warnings ok), 1 = validation errors.
|
||||
|
||||
Usage: python3 tools/validate.py [--stale-days 30] [--selftest]
|
||||
--selftest: run the committed regression fixtures only.
|
||||
--selftest: run the committed regression fixtures only (no tree scan)
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@ -621,12 +664,50 @@ def run_selftest(verbose=False):
|
||||
any("ge_open" in e for e in errs))
|
||||
|
||||
return errors
|
||||
MARGIN_TAGS = {"flipping", "merchanting"}
|
||||
|
||||
|
||||
def ge_floor_tax_error(rel, data):
|
||||
"""GE floor-tax gate for a single entry.
|
||||
|
||||
Applies ONLY to the unambiguous margin tags (flipping/merchanting).
|
||||
The generic `ge` tag is not proof that income_per_cycle is a taxed
|
||||
GE resale: a training/processing/procurement entry may carry `ge`
|
||||
while modeling unrelated income/cost, so it must not be gated by
|
||||
this rule. Returns an error message, or None when the entry is not
|
||||
gated or its floored-tax arithmetic is correct.
|
||||
|
||||
Wiki: "The 2% tax rounds down to the nearest whole number, so items
|
||||
sold for below 50 coins have a tax of less than one coin" — per-item
|
||||
net MUST use the floored tax, never 0.98*sell - buy. The fee is also
|
||||
capped at 5,000,000 GP per item (per-item cap, documented in the
|
||||
flip-candidates best_practice/risk), so tax = min(floor(0.02*sell),
|
||||
5_000_000). Owner decision 2026-08-28: one global formula for all
|
||||
entries (option A), no per-entry tax-model discriminator.
|
||||
"""
|
||||
tags = {str(t).lower() for t in data.get("tags", [])}
|
||||
if not tags & MARGIN_TAGS:
|
||||
return None
|
||||
eco = data.get("economy") or {}
|
||||
if not all(k in eco for k in ("income_per_cycle", "cost_per_cycle", "net_per_cycle")):
|
||||
return None
|
||||
sell, buy = eco["income_per_cycle"], eco["cost_per_cycle"]
|
||||
tax = min(math.floor(sell * 0.02), 5_000_000)
|
||||
expected = sell - tax - buy
|
||||
if eco["net_per_cycle"] != expected:
|
||||
return (
|
||||
f"{rel}: net_per_cycle {eco['net_per_cycle']} != capped floored-tax "
|
||||
f"model {expected} (sell {sell} - min(floor({sell}*0.02), 5_000_000)"
|
||||
f"={tax} - buy {buy}); "
|
||||
f"GE tax rounds down per item and is capped at 5,000,000 GP per item "
|
||||
f"— fractional 0.98*sell - buy and uncapped tax are invalid")
|
||||
return None
|
||||
|
||||
|
||||
def alch_worked_example_regression(rel, data, raw_text):
|
||||
"""One-cast model worked-example regression (owner guidance 2026-08-28,
|
||||
PR #8 repair, merged via origin/main into issue-4): entries with the
|
||||
owner-selected one-cast iteration model must pin a worked example with
|
||||
PR #8 repair, merged via origin/main during branch reconciliation):
|
||||
entries with the
|
||||
MONOTONIC, INDEPENDENT counters — breaks every 550 casts (1,100, 1,650,
|
||||
...) regardless of restocks, and restocks every 901 casts (1,802, ...),
|
||||
so restocking never resets or shifts the break counter. Scoped to
|
||||
@ -727,6 +808,675 @@ def alch_accounting_regression(rel, data, raw_text):
|
||||
return errs
|
||||
|
||||
|
||||
# --- GE-flip lifecycle gates (owner guidance 2026-08-28, governor 10323 + 10390) ---
|
||||
# Canonical per-slot sub-states: each GE slot is an INDEPENDENT asynchronous
|
||||
# machine driven by a global scheduler pass; a pass never closes or advances
|
||||
# a slot it did not touch. A COMPLETED offer leaves its assets in the GE
|
||||
# collection box until ge_collect: fully filled buys and sells each get an
|
||||
# explicit *-filled-awaiting-collect state (governor 10390 finding 2).
|
||||
SLOT_SUB_STATES = [
|
||||
"free", # no offer, nothing held
|
||||
"buy-open", # resting buy offer (partial fills keep resting)
|
||||
"buy-filled-awaiting-collect", # buy FULLY filled — units sit in the GE collection box until ge_collect
|
||||
"buy-cancelled-awaiting-collect", # aborted buy: bought units + refunded CASH in the box
|
||||
"ready-to-sell", # units in hand (ge_collect'ed), sell edge positive
|
||||
"sell-open", # resting sell offer (partial sells tracked)
|
||||
"sell-filled-awaiting-collect", # sell FULLY filled — proceeds sit in the box until ge_collect
|
||||
"sell-cancelled-awaiting-collect", # aborted sell: proceeds + unsold units in the box
|
||||
"ready-to-requote", # collected units/refunds; requote or abandon
|
||||
"closed", # completed cycle: ledger reconciled + ARCHIVED; the physical slot is released to free via the closed -> free archival transition (governor 10471)
|
||||
]
|
||||
# Dispatch table: the pure action a slot in a given sub-state needs this pass.
|
||||
SLOT_DISPATCH = {
|
||||
"free": "quote",
|
||||
"buy-open": None, # resting; fill/abort decided by policies
|
||||
"buy-filled-awaiting-collect": "collect",
|
||||
"buy-cancelled-awaiting-collect": "collect",
|
||||
"ready-to-sell": "sell",
|
||||
"sell-open": None, # resting; fill/abort decided by policies
|
||||
"sell-filled-awaiting-collect": "collect",
|
||||
"sell-cancelled-awaiting-collect": "collect",
|
||||
"ready-to-requote": "requote",
|
||||
"closed": "release", # archival release: closed -> free (governor 10471)
|
||||
}
|
||||
|
||||
|
||||
def scheduler_dispatch(sub_state):
|
||||
"""The pending action for one slot in one scheduler pass, or None.
|
||||
|
||||
A None (resting) slot is left completely untouched — the pass must not
|
||||
close, advance, or mutate it. This is the property the worked-example
|
||||
regression proves. A 'closed' slot is NOT resting: its completed
|
||||
cycle's ledger was archived at ge_collect time and the pass releases
|
||||
the physical slot to free ('release' = the closed -> free archival
|
||||
transition), so the slot can run another flip instead of stranding
|
||||
forever (governor 10471). Resting states are buy-open / sell-open
|
||||
only.
|
||||
"""
|
||||
return SLOT_DISPATCH.get(sub_state)
|
||||
|
||||
|
||||
# Side-aware GE bucket names (governor 10390 finding 4).
|
||||
# BUY: the player commits CASH; acquired items are `filled` ONLY — the
|
||||
# unfilled/cancelled quantity is an unspent-CASH refund, never "refunded
|
||||
# units". SELL: the player holds units; sold units yield proceeds.
|
||||
BUY_UNIT_BUCKETS = ["filled", "still_open", "cancelled_unfilled"]
|
||||
SELL_UNIT_BUCKETS = ["sold", "still_open", "returned_unsold"]
|
||||
# Physically impossible co-occurrences per side (cancel is atomic: a live
|
||||
# offer has no remainder once cancelled).
|
||||
IMPOSSIBLE_UNIT_PAIRS = {
|
||||
"buy": [("still_open", "cancelled_unfilled")],
|
||||
"sell": [("still_open", "returned_unsold")],
|
||||
}
|
||||
|
||||
|
||||
def conservation_violations(record):
|
||||
"""Side-aware per-slot GE conservation (governor 10390 finding 4).
|
||||
|
||||
record: {
|
||||
'side': 'buy'|'sell', # REQUIRED — the ledger is
|
||||
'quoted': {item: qty}, # side-aware
|
||||
**unit buckets (side-specific): # {item: qty}
|
||||
'cash_quoted': int, # BUY: cash committed
|
||||
'spent'/'still_committed'/'refunded': int, # BUY cash partition
|
||||
'proceeds_in_box'/'cash_collected': int, # SELL proceeds
|
||||
'sub_state': str, # phase gate input
|
||||
}
|
||||
|
||||
BUY invariants:
|
||||
- unit partition: quoted == filled + still_open + cancelled_unfilled
|
||||
per item;
|
||||
- ACQUIRED ITEMS == filled ONLY: any sell-side unit bucket (sold /
|
||||
returned_unsold) or a 'refunded' unit bucket on a buy record is a
|
||||
violation — the unfilled/cancelled quantity is CASH (phantom
|
||||
"refunded units" guard);
|
||||
- cash partition: cash_quoted == spent + still_committed + refunded
|
||||
(the unspent remainder of a buy is the cash refund);
|
||||
- no item may be both still_open and cancelled_unfilled (cancel is
|
||||
atomic).
|
||||
SELL invariants:
|
||||
- unit partition: quoted == sold + still_open + returned_unsold per
|
||||
item;
|
||||
- no item may be both still_open and returned_unsold (cancel is
|
||||
atomic); sold + still_open IS legal (partial sell rest).
|
||||
Shared: no negative quantities.
|
||||
"""
|
||||
out = []
|
||||
side = record.get("side", "buy")
|
||||
if side not in ("buy", "sell"):
|
||||
out.append(f"record side {side!r} must be 'buy' or 'sell'")
|
||||
return out
|
||||
buckets = BUY_UNIT_BUCKETS if side == "buy" else SELL_UNIT_BUCKETS
|
||||
foreign = {"filled", "still_open", "cancelled_unfilled", "sold",
|
||||
"returned_unsold"} - set(buckets)
|
||||
for b in sorted(foreign):
|
||||
if record.get(b):
|
||||
out.append(
|
||||
f"{side} ledger uses {b} units — invalid for side {side}: "
|
||||
f"buy-side unfilled/cancelled quantity is CASH "
|
||||
f"(refunded), never {b} units; sell-side units partition "
|
||||
f"into sold/still_open/returned_unsold")
|
||||
if side == "buy":
|
||||
# A unit map under a CASH field is the phantom-items guard: a buy's
|
||||
# unfilled/cancelled quantity is an unspent-CASH refund, never
|
||||
# "refunded units" (governor 10390 finding 4).
|
||||
for c in ("cash_quoted", "spent", "still_committed", "refunded"):
|
||||
if isinstance(record.get(c), dict):
|
||||
out.append(
|
||||
f"buy ledger carries {c} as units — the unfilled/"
|
||||
f"cancelled quantity of a buy is CASH (refunded), "
|
||||
f"never {c} units (phantom items)")
|
||||
for b in buckets:
|
||||
for item in record.get(b, {}):
|
||||
q = record[b][item]
|
||||
if q < 0:
|
||||
out.append(f"item '{item}' bucket {b} is negative ({q})")
|
||||
quoted = record.get("quoted", {})
|
||||
for item, qq in quoted.items():
|
||||
if qq < 0:
|
||||
out.append(f"item '{item}' quoted is negative ({qq})")
|
||||
total = sum(record.get(b, {}).get(item, 0) for b in buckets)
|
||||
if total != qq:
|
||||
out.append(
|
||||
f"item '{item}' quoted {qq} != "
|
||||
f"{' + '.join(buckets)} ({total})")
|
||||
in_b = {b for b in buckets if item in record.get(b, {})}
|
||||
for a, b in IMPOSSIBLE_UNIT_PAIRS[side]:
|
||||
if (a in in_b and b in in_b
|
||||
and record.get(a, {}).get(item)
|
||||
and record.get(b, {}).get(item)):
|
||||
out.append(
|
||||
f"item '{item}' appears in both {a} and {b} — "
|
||||
f"physically impossible (a cancelled offer has no "
|
||||
f"live remainder)")
|
||||
if side == "buy":
|
||||
cash_fields = ["cash_quoted", "spent", "still_committed", "refunded"]
|
||||
for c in cash_fields:
|
||||
if isinstance(record.get(c), (int, float)) and record[c] < 0:
|
||||
out.append(f"{c} is negative ({record[c]})")
|
||||
cash_vals = {c: record.get(c) for c in cash_fields}
|
||||
if all(isinstance(v, (int, float)) for v in cash_vals.values()):
|
||||
if cash_vals["cash_quoted"] != (
|
||||
cash_vals["spent"] + cash_vals["still_committed"]
|
||||
+ cash_vals["refunded"]):
|
||||
out.append(
|
||||
f"buy cash quoted {cash_vals['cash_quoted']} != spent "
|
||||
f"{cash_vals['spent']} + still_committed "
|
||||
f"{cash_vals['still_committed']} + refunded "
|
||||
f"{cash_vals['refunded']} — the unspent remainder of a "
|
||||
f"buy is the CASH refund")
|
||||
else:
|
||||
for c in ("proceeds_in_box", "cash_collected"):
|
||||
if record.get(c, 0) < 0:
|
||||
out.append(f"{c} is negative ({record[c]})")
|
||||
return out
|
||||
|
||||
|
||||
def slot_phase_violations(record):
|
||||
"""Phase gates (governor 10390 finding 4): assets may not be reused or
|
||||
a slot closed before the GE collection box is withdrawn.
|
||||
|
||||
- 'buy-filled-awaiting-collect': filled must equal quoted (fully
|
||||
filled) and NOTHING may have been sold yet — a full buy that sells
|
||||
before ge_collect is a violation (owned units cannot leave the
|
||||
collection box early).
|
||||
- 'sell-filled-awaiting-collect': sold units require proceeds_in_box
|
||||
> 0 — proceeds arrive in the box as units fill and only leave via
|
||||
ge_collect.
|
||||
- 'closed': nothing may remain uncollected — proceeds_in_box must be
|
||||
0 and no cancelled/returned units may still be in the box (a full
|
||||
sell may not close before proceeds collection).
|
||||
"""
|
||||
out = []
|
||||
sub = record.get("sub_state")
|
||||
side = record.get("side", "buy")
|
||||
sold = sum((record.get("sold") or {}).values())
|
||||
filled = sum((record.get("filled") or {}).values())
|
||||
quoted = sum((record.get("quoted") or {}).values())
|
||||
if side == "buy" and sub == "buy-filled-awaiting-collect":
|
||||
if sold:
|
||||
out.append(
|
||||
"buy-filled-awaiting-collect slot has sold units — a full "
|
||||
"buy cannot sell before ge_collect")
|
||||
if filled != quoted:
|
||||
out.append(
|
||||
f"buy-filled-awaiting-collect slot is not fully filled "
|
||||
f"(filled {filled} != quoted {quoted})")
|
||||
if sub == "sell-filled-awaiting-collect" and sold and not record.get("proceeds_in_box"):
|
||||
out.append(
|
||||
"sell-filled-awaiting-collect slot has sold units but no "
|
||||
"proceeds_in_box — proceeds arrive in the collection box on "
|
||||
"fill and leave only via ge_collect")
|
||||
if sub == "closed":
|
||||
if record.get("proceeds_in_box"):
|
||||
out.append(
|
||||
"closed slot still has proceeds_in_box — a full sell may "
|
||||
"not close before proceeds collection (ge_collect first)")
|
||||
cancelled = sum((record.get("cancelled_unfilled") or {}).values())
|
||||
returned = sum((record.get("returned_unsold") or {}).values())
|
||||
if cancelled or returned:
|
||||
out.append(
|
||||
"closed slot still holds cancelled/returned units — "
|
||||
"collect them before closing the slot")
|
||||
return out
|
||||
|
||||
|
||||
def buy_capacity_ok(limit_used, limit, qty_requested):
|
||||
"""The 4h buy-limit ledger (governor 10323 finding 1): the limit resets
|
||||
4h after the FIRST unit was bought; cancelling an offer does NOT renew
|
||||
it, so a requote before the reset cannot buy beyond the remaining
|
||||
capacity. Returns True when limit_used + qty_requested <= limit.
|
||||
"""
|
||||
return limit_used + qty_requested <= limit
|
||||
|
||||
|
||||
# Deterministic ledger frames for ONE physical GE slot across TWO full
|
||||
# flips (governor 10471): the fixture walks a complete buy/collect/
|
||||
# sell/collect cycle, archives the first ledger at `closed`, releases the
|
||||
# physical slot to `free`, and opens a SECOND buy in the SAME slot. Every
|
||||
# frame must satisfy both conservation checks; the archived first-cycle
|
||||
# record must survive rotation 2 untouched (no loss, duplication, or
|
||||
# mutation). Prices: Rune arrowtips buy 223 / sell 244 / tax
|
||||
# min(floor(244*0.02), 5M) = 4 / proceeds 2,440,000 (snapshot 2026-08-27,
|
||||
# matches the worked example).
|
||||
_CYCLE_ITEM = "Rune arrowtips"
|
||||
|
||||
_CYCLE_FRAMES = [
|
||||
# (sub_state, ledger) — ledger is side-aware and conservation-clean.
|
||||
("free", {"side": "buy", "quoted": {}, "filled": {}, "still_open": {},
|
||||
"cancelled_unfilled": {}, "cash_quoted": 0, "spent": 0,
|
||||
"still_committed": 0, "refunded": 0}),
|
||||
("buy-open", {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000},
|
||||
"filled": {}, "still_open": {_CYCLE_ITEM: 10_000},
|
||||
"cancelled_unfilled": {}, "cash_quoted": 2_230_000,
|
||||
"spent": 0, "still_committed": 2_230_000, "refunded": 0}),
|
||||
("buy-filled-awaiting-collect",
|
||||
{"side": "buy", "quoted": {_CYCLE_ITEM: 10_000},
|
||||
"filled": {_CYCLE_ITEM: 10_000}, "still_open": {},
|
||||
"cancelled_unfilled": {}, "cash_quoted": 2_230_000,
|
||||
"spent": 2_230_000, "still_committed": 0, "refunded": 0}),
|
||||
("ready-to-sell",
|
||||
{"side": "buy", "quoted": {_CYCLE_ITEM: 10_000},
|
||||
"filled": {_CYCLE_ITEM: 10_000}, "still_open": {},
|
||||
"cancelled_unfilled": {}, "cash_quoted": 2_230_000,
|
||||
"spent": 2_230_000, "still_committed": 0, "refunded": 0}),
|
||||
("sell-open", {"side": "sell", "quoted": {_CYCLE_ITEM: 10_000},
|
||||
"sold": {}, "still_open": {_CYCLE_ITEM: 10_000},
|
||||
"returned_unsold": {}, "proceeds_in_box": 0,
|
||||
"cash_collected": 0}),
|
||||
("sell-filled-awaiting-collect",
|
||||
{"side": "sell", "quoted": {_CYCLE_ITEM: 10_000},
|
||||
"sold": {_CYCLE_ITEM: 10_000}, "still_open": {},
|
||||
"returned_unsold": {}, "proceeds_in_box": 2_440_000,
|
||||
"cash_collected": 0}),
|
||||
("closed", {"side": "sell", "quoted": {_CYCLE_ITEM: 10_000},
|
||||
"sold": {_CYCLE_ITEM: 10_000}, "still_open": {},
|
||||
"returned_unsold": {}, "proceeds_in_box": 0,
|
||||
"cash_collected": 2_440_000}), # ARCHIVED here
|
||||
("free", {"side": "buy", "quoted": {}, "filled": {}, "still_open": {},
|
||||
"cancelled_unfilled": {}, "cash_quoted": 0, "spent": 0,
|
||||
"still_committed": 0, "refunded": 0}), # released: physical slot reusable
|
||||
# rotation 2: the SAME physical slot opens a second buy (ledger reset;
|
||||
# the archived rotation-1 record above must remain untouched).
|
||||
("buy-open", {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000},
|
||||
"filled": {}, "still_open": {_CYCLE_ITEM: 10_000},
|
||||
"cancelled_unfilled": {}, "cash_quoted": 2_230_000,
|
||||
"spent": 0, "still_committed": 2_230_000, "refunded": 0}),
|
||||
]
|
||||
|
||||
# The canonical complete flip cycle: rotation 1 (buy/collect/sell/collect)
|
||||
# plus the archival release and the start of rotation 2.
|
||||
_CYCLE_PATH = [f[0] for f in _CYCLE_FRAMES]
|
||||
|
||||
# Index of the `closed` frame (archived ledger) and the frames around it.
|
||||
_CLOSED_IDX = _CYCLE_PATH.index("closed")
|
||||
|
||||
|
||||
def slot_reuse_violations(transitions):
|
||||
"""Two-rotation slot-reuse regression (governor 10471).
|
||||
|
||||
Walks ``_CYCLE_PATH`` through the entry's ``slot_transitions`` and
|
||||
validates every ledger frame against both conservation gates. A
|
||||
``closed`` state with no outgoing transition (or one that does not
|
||||
release to ``free``) strands the physical GE slot after one flip —
|
||||
the exact defect this regression exists to forbid. Errors are
|
||||
returned as a list of strings.
|
||||
"""
|
||||
out = []
|
||||
for a, b in zip(_CYCLE_PATH, _CYCLE_PATH[1:]):
|
||||
if b not in (transitions.get(a) or []):
|
||||
out.append(
|
||||
f"slot cycle: transition {a} -> {b} is missing — a completed "
|
||||
f"flip must end by releasing the physical slot to free "
|
||||
f"(archival release closed -> free), otherwise the slot "
|
||||
f"performs one flip and then becomes permanently unusable")
|
||||
for state, record in _CYCLE_FRAMES:
|
||||
rec = dict(record, sub_state=state)
|
||||
con = conservation_violations(rec)
|
||||
if con:
|
||||
out.append(f"slot cycle: conservation fails at '{state}': {con}")
|
||||
ph = slot_phase_violations(rec)
|
||||
if ph:
|
||||
out.append(f"slot cycle: phase gate fails at '{state}': {ph}")
|
||||
return out
|
||||
|
||||
|
||||
def archived_ledger_preserved_after_second_buy():
|
||||
"""The rotation-1 archived ledger survives rotation 2 intact.
|
||||
|
||||
``closed`` archives a deep copy of the completed cycle's ledger; when
|
||||
the same physical slot is released to free and opens a second buy, the
|
||||
archived record must be bit-for-bit unchanged (no loss, duplication,
|
||||
or mutation) while the live ledger is a fresh empty record.
|
||||
"""
|
||||
archived = json.loads(json.dumps(_CYCLE_FRAMES[_CLOSED_IDX][1]))
|
||||
archived_state = _CYCLE_FRAMES[_CLOSED_IDX][0]
|
||||
live = {k: (dict(v) if isinstance(v, dict) else v)
|
||||
for k, v in _CYCLE_FRAMES[-1][1].items()} # rotation-2 buy-open ledger
|
||||
if archived != _CYCLE_FRAMES[_CLOSED_IDX][1]:
|
||||
return ["slot cycle: archived rotation-1 ledger was MUTATED by rotation 2"]
|
||||
if archived_state != "closed":
|
||||
return [f"slot cycle: unexpected archived state {archived_state!r}"]
|
||||
# The live ledger at rotation-2 buy-open must be a fresh record, not a
|
||||
# copy of the archived one: quoted cash/units belong to rotation 2.
|
||||
if live.get("cash_quoted") != 2_230_000 or live.get("proceeds_in_box"):
|
||||
return ["slot cycle: rotation-2 live ledger is not a fresh record"]
|
||||
return []
|
||||
|
||||
|
||||
def ge_lifecycle_errors(rel, data):
|
||||
"""GE-flip lifecycle gates (flipping/merchanting entries only):
|
||||
|
||||
- the 4h buy-limit ledger is distinct from offer expiry: the entry must
|
||||
declare a limit_ledger + an independent quote_age_policy;
|
||||
- the slot model is per-slot asynchronous: slot_state_machine must
|
||||
declare exactly the canonical sub-states, valid transitions, a
|
||||
scheduler_iteration, persisted_state, conservation and break_resume;
|
||||
'closed' is NOT a sink: it must release the physical slot via the
|
||||
archival transition closed -> free, and the committed two-rotation
|
||||
reuse regression (slot_reuse_violations) must pass;
|
||||
- rates: ceiling_gp_per_hour is the typed economic ceiling and
|
||||
0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling.
|
||||
"""
|
||||
errors = []
|
||||
tags = {str(t).lower() for t in data.get("tags", [])}
|
||||
if not tags & MARGIN_TAGS:
|
||||
return errors
|
||||
eco = data.get("economy") or {}
|
||||
# --- typed ceiling vs realized bounds ---
|
||||
ceil = eco.get("ceiling_gp_per_hour")
|
||||
if not isinstance(ceil, (int, float)) or ceil <= 0:
|
||||
errors.append(f"{rel}: flipping entries must declare "
|
||||
f"economy.ceiling_gp_per_hour (the typed 4h buy-limit "
|
||||
f"economic ceiling, distinct from realized rates)")
|
||||
return errors
|
||||
low, high = eco.get("gp_per_hour_low"), eco.get("gp_per_hour_high")
|
||||
for name, val in (("gp_per_hour_low", low), ("gp_per_hour_high", high)):
|
||||
if not isinstance(val, (int, float)) or val < 0:
|
||||
errors.append(f"{rel}: {name} must be a non-negative realized "
|
||||
f"scenario bound")
|
||||
if isinstance(low, (int, float)) and isinstance(high, (int, float)) \
|
||||
and isinstance(ceil, (int, float)):
|
||||
if not (low <= high <= ceil):
|
||||
errors.append(
|
||||
f"{rel}: realized bounds must nest under the ceiling: "
|
||||
f"0 <= gp_per_hour_low {low} <= gp_per_hour_high {high} "
|
||||
f"<= ceiling_gp_per_hour {ceil}")
|
||||
# --- per-slot asynchronous model ---
|
||||
ssm = data.get("slot_state_machine") or {}
|
||||
if not isinstance(ssm, dict):
|
||||
errors.append(f"{rel}: flipping entries must declare a "
|
||||
f"slot_state_machine block (per-slot asynchronous sub-states)")
|
||||
return errors
|
||||
declared = [str(s) for s in ssm.get("sub_states", [])]
|
||||
if sorted(declared) != sorted(SLOT_SUB_STATES):
|
||||
errors.append(
|
||||
f"{rel}: slot_state_machine.sub_states must be exactly the canonical "
|
||||
f"set: {', '.join(SLOT_SUB_STATES)} (got: {', '.join(declared) or 'none'})")
|
||||
trans = ssm.get("slot_transitions") or {}
|
||||
if not isinstance(trans, dict):
|
||||
errors.append(f"{rel}: slot_state_machine.slot_transitions must be a mapping")
|
||||
trans = {}
|
||||
for from_s, tos in trans.items():
|
||||
if from_s not in SLOT_SUB_STATES:
|
||||
errors.append(f"{rel}: slot transition from unknown sub-state '{from_s}'")
|
||||
for to_s in tos if isinstance(tos, list) else []:
|
||||
if to_s not in SLOT_SUB_STATES:
|
||||
errors.append(f"{rel}: slot transition '{from_s}' -> unknown "
|
||||
f"sub-state '{to_s}'")
|
||||
for state in SLOT_SUB_STATES:
|
||||
if not trans.get(state):
|
||||
errors.append(f"{rel}: slot sub-state '{state}' has no transitions "
|
||||
f"— every state must be able to advance; a closed "
|
||||
f"slot must release the physical slot via the "
|
||||
f"archival closed -> free transition (governor 10471)")
|
||||
if list(trans.get("closed") or []) != ["free"]:
|
||||
errors.append(f"{rel}: 'closed' must have exactly the archival release "
|
||||
f"['free'] — a closed slot with no outgoing transition "
|
||||
f"performs one flip and then becomes permanently "
|
||||
f"unusable (governor 10471)")
|
||||
whens = [str(w) for w in ssm.get("slot_transition_whens", []) or []]
|
||||
for w in whens:
|
||||
arrow = w.split("->")
|
||||
if len(arrow) != 2 or arrow[0].strip() not in SLOT_SUB_STATES \
|
||||
or arrow[1].strip().split(":")[0].strip() not in SLOT_SUB_STATES:
|
||||
errors.append(f"{rel}: slot_transition_whens entry '{w}' must be "
|
||||
f"'from -> to: condition' with canonical sub-states")
|
||||
for field in ("scheduler_iteration", "quote_age_policy", "persisted_state",
|
||||
"limit_ledger", "conservation", "break_resume"):
|
||||
if not str(ssm.get(field, "")).strip():
|
||||
errors.append(f"{rel}: slot_state_machine.{field} must be declared")
|
||||
# Two-rotation reuse regression (governor 10471): every flipping entry's
|
||||
# encoded machine must complete buy/collect/sell/collect, release the
|
||||
# physical slot to free, and support a second buy in the same slot.
|
||||
errors += [f"{rel}: {e}" for e in slot_reuse_violations(trans)]
|
||||
return errors
|
||||
|
||||
|
||||
def run_flip_selftest(verbose=False):
|
||||
"""Regression fixtures for the GE floor-tax + GE-flip lifecycle gates
|
||||
(merged from PR #7 / main reconciliation); returns error strings."""
|
||||
errors = []
|
||||
|
||||
def check(name, passed):
|
||||
if verbose:
|
||||
print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}")
|
||||
if not passed:
|
||||
errors.append(f"selftest: {name}")
|
||||
|
||||
fixtures = [
|
||||
# name, tags, economy, expect_error
|
||||
("fractional-tax flipping entry", ["flipping"],
|
||||
{"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 16.12}, True),
|
||||
# 5000 - floor(5000*0.02) - 3000 = 1900 != 2000: this fixture's
|
||||
# economics deliberately do NOT satisfy the flip equation, so the
|
||||
# old `ge`-tag scoping would false-positive on it (2000 != 1900).
|
||||
("non-margin entry with only 'ge' + 'processing'", ["ge", "processing"],
|
||||
{"income_per_cycle": 5000, "cost_per_cycle": 3000, "net_per_cycle": 2000}, False),
|
||||
("correctly-floored flipping entry", ["flipping", "merchanting"],
|
||||
{"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17}, False),
|
||||
# sell 300,000,000 -> uncapped floor tax is 6,000,000; the per-item
|
||||
# cap makes it 5,000,000, so the correct net is 1,000,000. The old
|
||||
# uncapped rule demanded net=0 and rejected this valid entry.
|
||||
("high-value flip above the 5,000,000 GP per-item tax cap",
|
||||
["flipping"],
|
||||
{"income_per_cycle": 300_000_000, "cost_per_cycle": 294_000_000,
|
||||
"net_per_cycle": 1_000_000}, False),
|
||||
]
|
||||
for name, tags, eco, expect_error in fixtures:
|
||||
rel = f"selftest/{name}"
|
||||
got_error = ge_floor_tax_error(rel, {"tags": tags, "economy": eco}) is not None
|
||||
passed = got_error == expect_error
|
||||
if verbose:
|
||||
print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}")
|
||||
if not passed:
|
||||
errors.append(
|
||||
f"selftest: '{name}' expected "
|
||||
f"{'an error' if expect_error else 'no error'} but got "
|
||||
f"{'an error' if got_error else 'no error'}")
|
||||
|
||||
# --- per-slot scheduler dispatch: the worked example as a regression ---
|
||||
# Governor 10323 finding 3 + owner: reproduce the simultaneous
|
||||
# partial-buy / full-buy-fill / no-fill-buy case and prove no
|
||||
# unrelated slot is closed or advanced. Under governor 10390 finding 2
|
||||
# a FULL buy fill goes to buy-filled-awaiting-collect (assets sit in
|
||||
# the GE collection box) — the pass dispatches ge_collect, it never
|
||||
# lets the slot sell directly.
|
||||
slot_states = {"arrowtips": "buy-open", # partial fill 4,000/10,000 — resting
|
||||
"iron": "buy-filled-awaiting-collect", # full fill — ge_collect this pass
|
||||
"cosmic": "buy-open"} # no fill, edge positive — resting
|
||||
dispatched = {name: scheduler_dispatch(s) for name, s in slot_states.items()}
|
||||
ok = dispatched == {"arrowtips": None, "iron": "collect", "cosmic": None}
|
||||
check("worked example: partial-buy + full-buy-fill + no-fill slots dispatch only the iron slot to ge_collect; unrelated slots stay untouched",
|
||||
ok)
|
||||
check("resting slots (buy-open) are never closed or advanced by a pass",
|
||||
dispatched.get("arrowtips") is None and dispatched.get("cosmic") is None)
|
||||
check("a fully filled buy must pass through buy-filled-awaiting-collect (ge_collect) before any sell",
|
||||
scheduler_dispatch("buy-filled-awaiting-collect") == "collect"
|
||||
and scheduler_dispatch("sell-filled-awaiting-collect") == "collect"
|
||||
and scheduler_dispatch("ready-to-sell") == "sell")
|
||||
|
||||
# --- side-aware conservation invariants (finding 4) ---
|
||||
# BUY balanced snapshot mirroring the worked example t1: 10,000 Rune
|
||||
# arrowtips quoted @ 223; 4,000 filled (units in the GE collection
|
||||
# box), 6,000 still open on the resting offer; cash 2,230,000 =
|
||||
# spent 892,000 + still_committed 1,338,000 + refunded 0.
|
||||
buy_partial = {
|
||||
"side": "buy",
|
||||
"quoted": {"Rune arrowtips": 10_000},
|
||||
"filled": {"Rune arrowtips": 4_000},
|
||||
"still_open": {"Rune arrowtips": 6_000},
|
||||
"cancelled_unfilled": {},
|
||||
"cash_quoted": 2_230_000, "spent": 892_000,
|
||||
"still_committed": 1_338_000, "refunded": 0,
|
||||
}
|
||||
check("conservation: BUY side — partial fill splits units into filled + still_open and cash into spent + still_committed (partition sums hold, zero violations)",
|
||||
conservation_violations(buy_partial) == [])
|
||||
buy_cancelled = dict(buy_partial, still_open={},
|
||||
cancelled_unfilled={"Rune arrowtips": 6_000},
|
||||
still_committed=0, refunded=1_338_000)
|
||||
check("conservation: BUY side — a cancelled partial buy carries filled units + refunded CASH (still_open -> cancelled_unfilled + refunded), zero violations",
|
||||
conservation_violations(buy_cancelled) == [])
|
||||
phantom_rec = dict(buy_cancelled, refunded={"Rune arrowtips": 6_000})
|
||||
check("conservation: BUY side — 'refunded UNITS' on a buy is a phantom (unfilled/cancelled quantity is CASH): violation",
|
||||
any("CASH" in v or "invalid for side" in v
|
||||
for v in conservation_violations(phantom_rec)))
|
||||
sold_on_buy = dict(buy_partial, sold={"Rune arrowtips": 4_000})
|
||||
check("conservation: BUY side — a full buy that sells before ge_collect (sold units on a buy ledger) is a violation",
|
||||
any("invalid for side" in v for v in conservation_violations(sold_on_buy)))
|
||||
buy_cash_bad = dict(buy_partial, still_committed=1_380_000)
|
||||
check("conservation: BUY side — cash partition mismatch (spent + still_committed + refunded != cash_quoted) is a violation",
|
||||
any("cash quoted" in v for v in conservation_violations(buy_cash_bad)))
|
||||
# SELL balanced: 10,000 sold @ 157, proceeds 1,570,000 in the box.
|
||||
sell_full = {
|
||||
"side": "sell",
|
||||
"quoted": {"Iron bar": 10_000},
|
||||
"sold": {"Iron bar": 10_000},
|
||||
"still_open": {}, "returned_unsold": {},
|
||||
"proceeds_in_box": 1_570_000, "cash_collected": 0,
|
||||
}
|
||||
check("conservation: SELL side — full sell partitions sold + still_open + returned_unsold, proceeds in the box (zero violations)",
|
||||
conservation_violations(sell_full) == [])
|
||||
sell_partial = dict(sell_full, sold={"Iron bar": 4_000},
|
||||
still_open={"Iron bar": 6_000},
|
||||
proceeds_in_box=628_000)
|
||||
check("conservation: SELL side — a partial sell legitimately spans sold + still_open (zero violations)",
|
||||
conservation_violations(sell_partial) == [])
|
||||
sell_dup = dict(sell_full, sold={"Iron bar": 6_000},
|
||||
still_open={"Iron bar": 3_000},
|
||||
returned_unsold={"Iron bar": 1_000})
|
||||
check("conservation: SELL side — units both in a live offer AND returned (impossible pair) is a violation",
|
||||
any("both still_open and returned_unsold" in v
|
||||
for v in conservation_violations(sell_dup)))
|
||||
over_rec = dict(buy_partial, filled={"Rune arrowtips": 7_000})
|
||||
check("conservation: over-reported buckets break the quoted partition (sum 13,000 != quoted 10,000)",
|
||||
any("quoted 10000 !=" in v for v in conservation_violations(over_rec)))
|
||||
neg_rec = dict(sell_full, sold={"Iron bar": -1})
|
||||
check("conservation: a negative bucket is a violation",
|
||||
any("negative" in v for v in conservation_violations(neg_rec)))
|
||||
|
||||
# --- phase gates (finding 4: assets cannot be reused/closed pre-collect) ---
|
||||
check("phase gate: a full buy that sells before ge_collect (buy-filled-awaiting-collect with sold units) is rejected",
|
||||
any("cannot sell before ge_collect" in v
|
||||
for v in slot_phase_violations(dict(buy_partial, sub_state="buy-filled-awaiting-collect",
|
||||
filled={"Rune arrowtips": 10_000}, still_open={},
|
||||
sold={"Rune arrowtips": 10_000}))))
|
||||
check("phase gate: a full sell that closes before proceeds collection (closed with proceeds_in_box > 0) is rejected",
|
||||
any("before proceeds collection" in v
|
||||
for v in slot_phase_violations(dict(sell_full, sub_state="closed"))))
|
||||
check("phase gate: a sell-filled-awaiting-collect slot with sold units but no proceeds_in_box is rejected",
|
||||
any("no proceeds_in_box" in v
|
||||
for v in slot_phase_violations(dict(sell_full, sub_state="sell-filled-awaiting-collect",
|
||||
proceeds_in_box=0))))
|
||||
check("phase gate: a closed slot that has been fully collected (no proceeds_in_box, no cancelled/returned units) passes",
|
||||
slot_phase_violations(dict(sell_full, sub_state="closed",
|
||||
proceeds_in_box=0, cash_collected=1_570_000)) == [])
|
||||
|
||||
# --- 4h buy-limit ledger vs offer expiry (finding 1) ---
|
||||
check("buy limit: a requote within the remaining capacity is allowed (4,000 used of 10,000 -> 6,000)",
|
||||
buy_capacity_ok(limit_used=4000, limit=10000, qty_requested=6000))
|
||||
check("buy limit: a requote beyond the remaining capacity is blocked (6,001 > 6,000 remaining)",
|
||||
not buy_capacity_ok(limit_used=4000, limit=10000, qty_requested=6001))
|
||||
check("buy limit: a cancelled offer does NOT renew the limit (10,000 used of 10,000 -> any requote blocked before reset)",
|
||||
not buy_capacity_ok(limit_used=10000, limit=10000, qty_requested=1))
|
||||
|
||||
# --- structured lifecycle gates (findings 1-5 + governor 10390) ---
|
||||
margin_tags = ["flipping"]
|
||||
ssm_ok = {
|
||||
"sub_states": SLOT_SUB_STATES,
|
||||
"slot_transitions": {
|
||||
"free": ["buy-open"],
|
||||
"buy-open": ["buy-filled-awaiting-collect",
|
||||
"buy-cancelled-awaiting-collect"],
|
||||
"buy-filled-awaiting-collect": ["ready-to-sell", "free"],
|
||||
"buy-cancelled-awaiting-collect": ["ready-to-sell", "ready-to-requote", "free"],
|
||||
"ready-to-sell": ["sell-open"],
|
||||
"sell-open": ["sell-filled-awaiting-collect",
|
||||
"sell-cancelled-awaiting-collect"],
|
||||
"sell-filled-awaiting-collect": ["closed"],
|
||||
"sell-cancelled-awaiting-collect": ["ready-to-requote", "closed"],
|
||||
"ready-to-requote": ["buy-open", "sell-open", "free"],
|
||||
"closed": ["free"], # archival release: physical slot reusable (governor 10471)
|
||||
},
|
||||
"slot_transition_whens": [
|
||||
"free -> buy-open: quote placed",
|
||||
"buy-open -> buy-filled-awaiting-collect: offer FULLY filled — units sit in the GE collection box until ge_collect",
|
||||
"buy-open -> buy-cancelled-awaiting-collect: abort on stale edge (margin no longer positive) or strategy reallocation — never on the 4h limit reset",
|
||||
"buy-filled-awaiting-collect -> ready-to-sell: ge_collect withdrawn units; sell edge positive",
|
||||
"buy-filled-awaiting-collect -> free: ge_collect withdrawn; margin gone, slot released",
|
||||
"buy-cancelled-awaiting-collect -> ready-to-sell: collected, sell edge positive",
|
||||
"buy-cancelled-awaiting-collect -> ready-to-requote: collected; margin positive and capacity remains",
|
||||
"buy-cancelled-awaiting-collect -> free: collected, full cash refund, slot freed",
|
||||
"ready-to-sell -> sell-open: sell offer placed",
|
||||
"sell-open -> sell-filled-awaiting-collect: sell FULLY filled — proceeds sit in the box until ge_collect",
|
||||
"sell-open -> sell-cancelled-awaiting-collect: abort on stale edge or strategy reallocation",
|
||||
"sell-filled-awaiting-collect -> closed: proceeds collected and ledger reconciled + ARCHIVED",
|
||||
"sell-cancelled-awaiting-collect -> ready-to-requote: collected, requote",
|
||||
"sell-cancelled-awaiting-collect -> closed: collected, ledger archived, slot closed (exit taken)",
|
||||
"closed -> free: archival release — completed cycle's ledger stays archived; the physical GE slot is reusable for the next flip",
|
||||
"ready-to-requote -> buy-open: requoted buy",
|
||||
"ready-to-requote -> sell-open: requoted sell",
|
||||
"ready-to-requote -> free: abandoned",
|
||||
],
|
||||
"scheduler_iteration": "one pass over all slots; dispatch pending actions only",
|
||||
"quote_age_policy": "offers rest while the scanned margin stays positive; aborted only on a stale edge or strategy reallocation — the 4h limit reset never cancels an offer",
|
||||
"persisted_state": "per-slot record: sub-state + side-aware quoted/filled/sold/collected buckets",
|
||||
"limit_ledger": "limit_used + first_fill_ts + limit_resets_at, independent of offer expiry; cancellation never renews it",
|
||||
"conservation": "side-aware: buy quoted units = filled + still_open + cancelled_unfilled and acquired items = filled only; sell quoted = sold + still_open + returned_unsold",
|
||||
"break_resume": "breaks between passes; state persists in the per-slot record",
|
||||
}
|
||||
flip_ok = {"tags": margin_tags, "economy": {
|
||||
"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17,
|
||||
"ceiling_gp_per_hour": 149500, "gp_per_hour_low": 102600,
|
||||
"gp_per_hour_high": 138800}, "slot_state_machine": ssm_ok}
|
||||
check("flip lifecycle: ceiling + nested realized bounds + canonical 10-state slot machine passes",
|
||||
ge_lifecycle_errors("ok.yaml", flip_ok) == [])
|
||||
errs = ge_lifecycle_errors("bad.yaml", {"tags": margin_tags, "economy": {
|
||||
"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17,
|
||||
"ceiling_gp_per_hour": 149500, "gp_per_hour_low": 160000,
|
||||
"gp_per_hour_high": 138800}})
|
||||
check("flip lifecycle: realized low above the ceiling is rejected (bounds must nest)",
|
||||
any("nest" in e for e in errs))
|
||||
errs = ge_lifecycle_errors("bad2.yaml", {"tags": margin_tags, "economy": {
|
||||
"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17,
|
||||
"gp_per_hour_low": 0, "gp_per_hour_high": 138800}})
|
||||
check("flip lifecycle: missing typed ceiling field is rejected",
|
||||
any("ceiling_gp_per_hour" in e for e in errs))
|
||||
errs = ge_lifecycle_errors("bad3.yaml", dict(flip_ok, slot_state_machine=dict(
|
||||
ssm_ok, sub_states=["free", "buy-open"])))
|
||||
check("flip lifecycle: non-canonical sub-state set is rejected",
|
||||
any("canonical" in e for e in errs))
|
||||
errs = ge_lifecycle_errors("bad4.yaml", dict(flip_ok, slot_state_machine=dict(
|
||||
ssm_ok, slot_transitions={"free": ["buy-open"],
|
||||
"buy-open": ["ready-to-sell",
|
||||
"buy-cancelled-awaiting-collect"]},
|
||||
slot_transition_whens=ssm_ok["slot_transition_whens"][:4])))
|
||||
check("flip lifecycle: a sub-state without transitions (ready-to-sell missing) is rejected",
|
||||
any("no transitions" in e for e in errs))
|
||||
errs = ge_lifecycle_errors("bad5.yaml", dict(flip_ok, slot_state_machine=dict(
|
||||
ssm_ok, slot_transitions=dict(ssm_ok["slot_transitions"], closed=[]))))
|
||||
check("flip lifecycle: a 'closed' sink (no archival release) is rejected — the physical slot would perform one flip and strand",
|
||||
any("archival release" in e for e in errs))
|
||||
|
||||
# --- slot reuse through rotations (governor 10471) ---
|
||||
check("slot cycle: the archival release closed -> free is a dispatched scheduler action",
|
||||
scheduler_dispatch("closed") == "release")
|
||||
check("slot reuse: one full buy/collect/sell/collect lifecycle completes and the SAME physical slot opens a second buy (two-rotation walk passes)",
|
||||
slot_reuse_violations(ssm_ok["slot_transitions"]) == [])
|
||||
check("slot reuse: the rotation-1 archived ledger survives rotation 2 intact (no loss, duplication, or mutation)",
|
||||
archived_ledger_preserved_after_second_buy() == [])
|
||||
sink_trans = dict(ssm_ok["slot_transitions"], closed=[])
|
||||
check("slot reuse: a machine whose closed has no outgoing transition FAILS the two-rotation regression (one flip, then permanently unusable)",
|
||||
any("permanently unusable" in v
|
||||
for v in slot_reuse_violations(sink_trans)))
|
||||
closed_no_free = dict(ssm_ok["slot_transitions"], closed=["ready-to-sell"])
|
||||
check("slot reuse: a closed that does not release to free FAILS the two-rotation regression",
|
||||
any("closed -> free" in v
|
||||
for v in slot_reuse_violations(closed_no_free)))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main():
|
||||
global STALE_DAYS
|
||||
if "--stale-days" in sys.argv:
|
||||
@ -735,8 +1485,14 @@ def main():
|
||||
errors, warnings = [], []
|
||||
if "--selftest" in sys.argv:
|
||||
errors += run_selftest(verbose=True)
|
||||
errors += run_flip_selftest(verbose=True)
|
||||
print(f"RESULT: selftest, {len(errors)} errors, 0 warnings")
|
||||
return 1 if errors else 0
|
||||
|
||||
# The fixes themselves are gated: both regression suites run on every
|
||||
# invocation (mining model + GE flip lifecycle), not just --selftest.
|
||||
errors += run_selftest(verbose=False)
|
||||
errors += run_flip_selftest(verbose=False)
|
||||
entries = load_entries()
|
||||
if not entries:
|
||||
print("ERROR: no codex entries found under codex/")
|
||||
@ -809,6 +1565,17 @@ def main():
|
||||
# 2b. structured mining-model gates (pickaxe gates + run-mode contract)
|
||||
errors += mining_model_errors(rel, data)
|
||||
|
||||
# GE-margin floor-tax arithmetic (see ge_floor_tax_error): scoped to
|
||||
# the unambiguous margin tags flipping/merchanting. The generic `ge`
|
||||
# tag alone must not gate this rule — alchemy/processing/procurement
|
||||
# entries can carry `ge` with income unrelated to a taxed GE resale.
|
||||
err = ge_floor_tax_error(rel, data)
|
||||
if err:
|
||||
errors.append(err)
|
||||
# GE-flip lifecycle gates (per-slot async model, 4h limit ledger vs
|
||||
# offer expiry, typed ceiling vs realized scenario bounds).
|
||||
errors += ge_lifecycle_errors(rel, data)
|
||||
|
||||
# 3. staleness / research TODOs
|
||||
if data.get("status") == "verified":
|
||||
try:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user