Merge remote-tracking branch 'origin/main' into issue-4

# Conflicts:
#	schemas/entry.schema.json
#	tools/validate.py
This commit is contained in:
Claude 2026-08-29 03:30:20 -05:00
commit 7ee36da445
4 changed files with 1433 additions and 5 deletions

View 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

View File

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

View File

@ -43,6 +43,7 @@
"net_per_cycle": { "type": "number" }, "net_per_cycle": { "type": "number" },
"gp_per_hour_low": { "type": "number" }, "gp_per_hour_low": { "type": "number" },
"gp_per_hour_high": { "type": "number" }, "gp_per_hour_high": { "type": "number" },
"ceiling_gp_per_hour": { "type": "number", "exclusiveMinimum": 0 },
"xp_per_hour": { "type": "number" }, "xp_per_hour": { "type": "number" },
"supplies": { "supplies": {
"type": "array", "type": "array",
@ -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": { "best_practice": {
"type": "object", "type": "object",
"required": ["equipment"], "required": ["equipment"],
@ -268,7 +302,7 @@
"click_button", "menu_option", "click_button", "menu_option",
"bank_inventory", "deposit_all", "withdraw", "has_item", "bank_inventory", "deposit_all", "withdraw", "has_item",
"count_item", "move_to_inv", "sort_inventory", "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", "ge_check_price",
"attack", "prayer", "buff", "eat", "retreat", "is_dead", "respawn", "attack", "prayer", "buff", "eat", "retreat", "is_dead", "respawn",
"is_at", "item_count", "level_is", "xp_is", "hp_is", "is_at", "item_count", "level_is", "xp_is", "hp_is",

View File

@ -37,16 +37,59 @@ Runs:
* the entry's economy.xp_per_hour (fast-xp headline) must sit * the entry's economy.xp_per_hour (fast-xp headline) must sit
within 2% of the fastest declared fast-xp site's modelled within 2% of the fastest declared fast-xp site's modelled
ores/hr x its ore xp. 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. 3. Global id uniqueness across the whole codex tree.
Exit codes: 0 = clean (warnings ok), 1 = validation errors. Exit codes: 0 = clean (warnings ok), 1 = validation errors.
Usage: python3 tools/validate.py [--stale-days 30] [--selftest] 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 datetime
import json import json
import math
import re
import sys import sys
from pathlib import Path from pathlib import Path
@ -621,12 +664,50 @@ def run_selftest(verbose=False):
any("ge_open" in e for e in errs)) any("ge_open" in e for e in errs))
return errors 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): def alch_worked_example_regression(rel, data, raw_text):
"""One-cast model worked-example regression (owner guidance 2026-08-28, """One-cast model worked-example regression (owner guidance 2026-08-28,
PR #8 repair, merged via origin/main into issue-4): entries with the PR #8 repair, merged via origin/main during branch reconciliation):
owner-selected one-cast iteration model must pin a worked example with entries with the
MONOTONIC, INDEPENDENT counters — breaks every 550 casts (1,100, 1,650, MONOTONIC, INDEPENDENT counters — breaks every 550 casts (1,100, 1,650,
...) regardless of restocks, and restocks every 901 casts (1,802, ...), ...) regardless of restocks, and restocks every 901 casts (1,802, ...),
so restocking never resets or shifts the break counter. Scoped to 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 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(): def main():
global STALE_DAYS global STALE_DAYS
if "--stale-days" in sys.argv: if "--stale-days" in sys.argv:
@ -735,8 +1485,14 @@ def main():
errors, warnings = [], [] errors, warnings = [], []
if "--selftest" in sys.argv: if "--selftest" in sys.argv:
errors += run_selftest(verbose=True) errors += run_selftest(verbose=True)
errors += run_flip_selftest(verbose=True)
print(f"RESULT: selftest, {len(errors)} errors, 0 warnings") print(f"RESULT: selftest, {len(errors)} errors, 0 warnings")
return 1 if errors else 0 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() entries = load_entries()
if not entries: if not entries:
print("ERROR: no codex entries found under codex/") print("ERROR: no codex entries found under codex/")
@ -809,6 +1565,17 @@ def main():
# 2b. structured mining-model gates (pickaxe gates + run-mode contract) # 2b. structured mining-model gates (pickaxe gates + run-mode contract)
errors += mining_model_errors(rel, data) 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 # 3. staleness / research TODOs
if data.get("status") == "verified": if data.get("status") == "verified":
try: try: