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_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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user