osrs-codex/tools/validate.py
Claude 2a5cfb30af Merge remote-tracking branch 'origin/main' into issue-4
# Conflicts:
#	tools/validate.py
2026-08-29 03:18:04 -05:00

841 lines
43 KiB
Python

#!/usr/bin/env python3
"""OSRS Codex validator — the gate for every codex/**.yaml entry.
Runs:
1. JSON-Schema validation (schemas/entry.schema.json)
2. Cross-field consistency checks the schema can't express:
- state_machine `next`/transition targets must be declared states
- `initial` must be a declared state
- duplicate action ids / step names
- status:verified entries with `verified:` older than --stale-days
=> WARNING (entry should be re-verified, not dropped)
- `best_practice` lines whose source starts with "researched"
=> WARNING (citation TODO — file a follow-up issue)
- Mining model (pickaxe gates + run-mode contract) — STRUCTURED
checks on the declared `pickaxe_tiers` / `pickaxe_examples` /
`mining_modes` / `mine_sites` blocks (owner guidance 2026-08-28):
* pickaxe tier values must match the canonical F2P table exactly
(Mining = usability gate, Attack = equip gate only, roll ticks,
and the explicit rank tie-break for equal Mining gates — Mining 1
selects iron over bronze, equipped at Attack 1);
* every pickaxe_examples row is recomputed (select highest tier
whose mining_level <= mining, tie-break by rank; equipped = attack
>= that tier's attack_level; expected_slots = 28 equipped / 27
carried) and must match the declared expectation — the pinned
1-Attack/41-Mining account must select and CARRY rune and the
1-Attack/1-Mining account must select and EQUIP iron;
* both run modes must be declared with the correct inventory
policy (fast-xp = drop, banked-income = bank) and only
reference declared, selectable sites whose policy matches;
* a selectable banked site with respawn >= 120 s MUST model
world hopping (world_hop: true AND a hop_world action in the
entry) — long-respawn sites are not executable otherwise;
* a selectable site whose access is Wilderness is rejected
(PK hazard cannot be hand-waved);
* modeled_ores_per_hour must not exceed the cadence ceiling
3600 / (rolls_per_ore x 2.7s rune delay midpoint);
* the entry's economy.xp_per_hour (fast-xp headline) must sit
within 2% of the fastest declared fast-xp site's modelled
ores/hr x its ore xp.
3. Global id uniqueness across the whole codex tree.
Exit codes: 0 = clean (warnings ok), 1 = validation errors.
Usage: python3 tools/validate.py [--stale-days 30] [--selftest]
--selftest: run the committed regression fixtures only.
"""
import datetime
import json
import sys
from pathlib import Path
import jsonschema
import yaml
ROOT = Path(__file__).resolve().parent.parent
SCHEMA = json.loads((ROOT / "schemas" / "entry.schema.json").read_text())
CODEX = ROOT / "codex"
STALE_DAYS = 30
# Canonical F2P pickaxe table (OSRS wiki pickaxe pages, verified 2026-08-28):
# mining_level = which pickaxe can be USED; attack_level = only whether it can
# be EQUIPPED; roll_ticks = ore roll interval (ticks x 0.6 s);
# rank = explicit tier order used as the SELECTION tie-break for equal
# Mining gates (bronze and iron both open at Mining 1 -> Mining 1 = iron,
# per the owner decision on governor review 10476).
CANONICAL_PICKAXES = {
"bronze-pickaxe": {"mining_level": 1, "attack_level": 1, "roll_ticks": 8, "rank": 1},
"iron-pickaxe": {"mining_level": 1, "attack_level": 1, "roll_ticks": 7, "rank": 2},
"steel-pickaxe": {"mining_level": 6, "attack_level": 5, "roll_ticks": 6, "rank": 3},
"mithril-pickaxe": {"mining_level": 21, "attack_level": 20, "roll_ticks": 5, "rank": 4},
"adamant-pickaxe": {"mining_level": 31, "attack_level": 30, "roll_ticks": 4, "rank": 5},
"rune-pickaxe": {"mining_level": 41, "attack_level": 40, "roll_ticks": 3, "rank": 6},
}
# a2 anti_ban delay midpoint [1800, 3600] ms — the paced roll-attempt cadence
# the rate model derives from (rune anchor; see mining-f2p.yaml economy).
RUNE_DELAY_MID_S = 2.7
XP_TOLERANCE = 0.02
def load_entries():
out = []
for p in CODEX.rglob("*.yaml"):
if p.name.startswith("_"): # dataset files (e.g. _f2p-alch-catalog.yaml)
continue
out.append(p)
return sorted(out)
def select_pickaxe(mining_level, tiers):
"""Highest eligible tier by the two-gate rule: among tiers whose
mining_level gate the account meets, the highest RANK wins (rank is
the explicit tie-break for equal Mining gates — bronze and iron both
open at Mining 1, so Mining 1 deterministically selects iron)."""
best, best_key = None, None
for tid, tdef in (tiers or {}).items():
if not isinstance(tdef, dict):
continue
gate = tdef.get("mining_level")
if not isinstance(gate, int) or gate > mining_level:
continue
rank = tdef.get("rank")
key = (rank if isinstance(rank, int) else gate, gate)
if best_key is None or key > best_key:
best, best_key = tid, key
return best
def select_fast_site(mining_level, modes, sites):
"""Deterministic fast-xp site selection (mining_selections recompute):
highest ore XP among eligible selectable fast-xp sites whose
mining_level <= level; tie-break lexicographic site id (copper/tin share
an XP+level tie). Returns the site id or None."""
best_xp, cands = -1, []
for sid in (modes.get("fast-xp", {}).get("sites", []) or []):
sdef = sites.get(sid)
if not isinstance(sdef, dict) or sdef.get("selectable") is not True:
continue
ml, xp = sdef.get("mining_level"), sdef.get("xp")
if isinstance(ml, int) and isinstance(xp, (int, float)) and ml <= mining_level:
if xp > best_xp:
best_xp, cands = xp, [sid]
elif xp == best_xp:
cands.append(sid)
return sorted(cands)[0] if cands else None
def mining_model_errors(rel, data):
"""STRUCTURED mining-model gates (owner guidance 2026-08-28; replaces the
earlier phrase-search checks, which could pass on comments alone)."""
errors = []
tiers = data.get("pickaxe_tiers")
if tiers is None:
return errors # non-mining entry — not gated
if not isinstance(tiers, dict):
return [f"{rel}: 'pickaxe_tiers' must be a mapping of tier id -> gates"]
# --- 1. canonical tier values (exact) ---
for tid, canon in CANONICAL_PICKAXES.items():
got = tiers.get(tid)
if not isinstance(got, dict):
errors.append(
f"{rel}: pickaxe tier '{tid}' missing from pickaxe_tiers "
f"(canonical F2P table: mining {canon['mining_level']} use / "
f"attack {canon['attack_level']} equip / {canon['roll_ticks']} ticks)")
continue
for key, want in canon.items():
if got.get(key) != want:
errors.append(
f"{rel}: pickaxe tier '{tid}' {key}={got.get(key)}; canonical "
f"value is {want} (Mining = usability gate, Attack = equip "
f"gate ONLY, roll_ticks = ore roll interval)")
for tid in tiers:
if tid not in CANONICAL_PICKAXES:
errors.append(f"{rel}: pickaxe_tiers declares unknown tier '{tid}'")
# --- 2. examples recomputed from the DECLARED tiers (two-gate proof) ---
examples = data.get("pickaxe_examples")
if not isinstance(examples, list) or not examples:
errors.append(f"{rel}: pickaxe_examples must declare at least one account "
f"example for the two-gate selection proof")
else:
has_141 = has_11 = False
for ex in examples:
if not isinstance(ex, dict):
continue
atk, mng = ex.get("attack"), ex.get("mining")
if atk == 1 and mng == 41:
has_141 = True
if atk == 1 and mng == 1:
has_11 = True
chosen = select_pickaxe(mng, tiers)
if chosen is None:
errors.append(f"{rel}: example '{ex.get('account')}' (Mining {mng}) "
f"has no eligible pickaxe tier")
continue
tdef = tiers.get(chosen) or {}
should_equip = atk >= tdef.get("attack_level", 10**9)
if ex.get("expected_tier") != chosen:
errors.append(
f"{rel}: example '{ex.get('account')}' (Attack {atk}/Mining "
f"{mng}) expects tier '{ex.get('expected_tier')}' but the "
f"Mining-use gate selects '{chosen}'")
if ex.get("expected_equipped") != should_equip:
errors.append(
f"{rel}: example '{ex.get('account')}' (Attack {atk}/Mining "
f"{mng}) expects equipped={ex.get('expected_equipped')} but "
f"the Attack-equip gate on '{chosen}' says equipped={should_equip}")
# capacity proof: equipped pickaxe -> 28 ore slots, carried -> 27
want_slots = 28 if should_equip else 27
if ex.get("expected_slots") is None:
errors.append(
f"{rel}: example '{ex.get('account')}' must declare "
f"expected_slots ({want_slots} for tier '{chosen}' "
f"equipped={should_equip})")
elif ex.get("expected_slots") != want_slots:
errors.append(
f"{rel}: example '{ex.get('account')}' expects "
f"expected_slots={ex.get('expected_slots')} but the "
f"declared pickaxe '{chosen}' equipped={should_equip} "
f"gives {want_slots} ore slots (28 equipped / 27 carried)")
if not has_141:
errors.append(
f"{rel}: pickaxe_examples must include the owner-mandated "
f"1-Attack/41-Mining account (selects, carries, and uses a rune "
f"pickaxe — equipped=false)")
if not has_11:
errors.append(
f"{rel}: pickaxe_examples must include the 1-Attack/1-Mining "
f"account (owner decision on governor review 10476: bronze and "
f"iron share the Mining-1 gate, the rank tie-break selects "
f"IRON, and Attack 1 equips it — equipped=true, 28 ore slots)")
# --- 3. run-mode contract ---
modes = data.get("mining_modes")
sites = data.get("mine_sites")
if not isinstance(modes, dict) or not isinstance(sites, dict):
errors.append(f"{rel}: a mining entry must declare both 'mining_modes' "
f"and 'mine_sites' blocks")
return errors
if modes.get("fast-xp", {}).get("inventory_policy") != "drop":
errors.append(f"{rel}: mining_modes.fast-xp.inventory_policy must be "
f"'drop' (powermine drops every load — never banks)")
if modes.get("banked-income", {}).get("inventory_policy") != "bank":
errors.append(f"{rel}: mining_modes.banked-income.inventory_policy must "
f"be 'bank'")
def site_errors(site_id, where, want_policy):
out = []
sdef = sites.get(site_id)
if not isinstance(sdef, dict):
out.append(f"{rel}: {where} references '{site_id}' which is not "
f"declared in mine_sites")
return out
if sdef.get("selectable") is not True:
out.append(f"{rel}: {where} references '{site_id}' which is marked "
f"selectable: false (not an eligible target)")
if sdef.get("inventory_policy") != want_policy:
out.append(f"{rel}: {where} site '{site_id}' has inventory_policy "
f"'{sdef.get('inventory_policy')}' but the mode requires "
f"'{want_policy}'")
if sdef.get("selectable") is True and any(
not str(sdef.get(k, "")).strip() for k in ("access", "safety", "route")):
out.append(f"{rel}: selectable site '{site_id}' must carry access, "
f"safety and route descriptions")
return out
for sid in modes.get("fast-xp", {}).get("sites", []) or []:
errors += site_errors(sid, "mining_modes.fast-xp.sites", "drop")
for sid in modes.get("banked-income", {}).get("targets", []) or []:
errors += site_errors(sid, "mining_modes.banked-income.targets", "bank")
# --- 4. per-site execitability: long respawn, wilderness, rate ceiling ---
verbs = [a.get("verb") for a in data.get("actions", [])]
for sid, sdef in sites.items():
if not isinstance(sdef, dict):
continue
if sdef.get("selectable") is True:
access = str(sdef.get("access", "")).lower()
if "wilderness" in access:
errors.append(f"{rel}: selectable site '{sid}' is in the "
f"Wilderness (PK hazard) — it must be selectable: "
f"false, not silently exposed")
respawn = sdef.get("respawn_seconds")
if isinstance(respawn, (int, float)) and respawn >= 120:
if sdef.get("world_hop") is not True:
errors.append(f"{rel}: selectable site '{sid}' has a "
f"{respawn}s respawn — long-respawn sites need "
f"world_hop: true (the wiki's 120+ ores/hr "
f"assumes hopping worlds); without hopping the "
f"ceiling is ~30 ores/hr")
elif "hop_world" not in verbs:
errors.append(f"{rel}: site '{sid}' declares world_hop: true "
f"but the entry has no hop_world action — "
f"world hopping must be executable")
rolls = sdef.get("rolls_per_ore")
modeled = sdef.get("modeled_ores_per_hour")
if isinstance(rolls, (int, float)) and isinstance(modeled, (int, float)):
ceiling = 3600.0 / (rolls * RUNE_DELAY_MID_S)
if modeled > ceiling * (1 + 1e-9):
errors.append(
f"{rel}: site '{sid}' models {modeled} ores/hr but the "
f"cadence ceiling at rolls_per_ore {rolls} is {ceiling:.0f} "
f"(3600 / (rolls_per_ore x 2.7 s)) — the rate exceeds what "
f"the declared click policy can produce")
# --- 4b. GE tool purchase must be executable end to end ---
gev = set(v for v in verbs if v in {
"ge_open", "ge_buy", "ge_offers_done", "ge_collect", "ge_sell"})
if "ge_buy" in gev:
for must, why in (("ge_open", "open the exchange before placing an offer"),
("ge_offers_done", "wait one BOUNDED fill window"),
("ge_collect", "collect the item / refund the gp on timeout")):
if must not in gev:
errors.append(
f"{rel}: ge_buy must be part of an executable GE loop — "
f"missing '{must}' ({why}); a resting offer must never be "
f"retried indefinitely")
elif gev:
if "ge_open" not in gev:
errors.append(
f"{rel}: GE verbs {sorted(gev)} require a ge_open action — "
f"the GE flow must start by opening the exchange")
# --- 4c. below-15 copper/tin path must be structurally arming-able ---
min_level = (data.get("accounts") or {}).get("min_level")
fx_levels = []
for sid in (modes.get("fast-xp", {}).get("sites", []) or []):
sdef = sites.get(sid)
if isinstance(sdef, dict) and sdef.get("selectable") is True:
ml = sdef.get("mining_level")
if isinstance(ml, int):
fx_levels.append(ml)
if isinstance(min_level, int) and min_level < 15:
if not fx_levels:
errors.append(
f"{rel}: min_level {min_level} (<15) but fast-xp has no "
f"structured selectable site")
else:
fmin = min(fx_levels)
if fmin > 14:
errors.append(
f"{rel}: fast-xp sites all require Mining >= {fmin} but "
f"the entry claims a copper/tin below-15 path (min_level "
f"{min_level}) — add a structured low-level (<=14) "
f"fallback site or raise min_level")
if fmin > min_level:
errors.append(
f"{rel}: the lowest fast-xp site requires Mining {fmin} "
f"but min_level is {min_level} — a level-{min_level} "
f"account cannot arm a fast-xp run")
# --- 4d. mining_selections pins (deterministic, recomputed) ---
sel = data.get("mining_selections")
if not isinstance(sel, dict) or not isinstance(sel.get("fast_xp"), list) \
or not sel["fast_xp"]:
errors.append(
f"{rel}: mining entries must declare mining_selections.fast_xp "
f"(level -> expected_site pins, recomputed by the validator)")
else:
rows = {}
for r in sel["fast_xp"]:
lvl = r.get("level") if isinstance(r, dict) else None
if not isinstance(lvl, int) or lvl < 1:
errors.append(
f"{rel}: mining_selections.fast_xp rows need a positive "
f"integer 'level'")
continue
rows[lvl] = r
if isinstance(min_level, int) and min_level <= 14:
for lvl in (1, 14):
if lvl not in rows:
errors.append(
f"{rel}: mining_selections.fast_xp must pin level "
f"{lvl} (a below-15 account must have a structured, "
f"selectable fast-xp site)")
for lvl, r in rows.items():
got = select_fast_site(lvl, modes, sites)
if got is None:
errors.append(
f"{rel}: no eligible selectable fast-xp site at level "
f"{lvl} — the low-level (copper/tin) fallback is missing")
elif r.get("expected_site") != got:
errors.append(
f"{rel}: mining_selections level {lvl} expects site "
f"'{r.get('expected_site')}' but the selection rule "
f"picks '{got}'")
# --- 5. fast-xp headline xp rate consistent with the modelled sites ---
eco = data.get("economy")
xph = eco.get("xp_per_hour") if isinstance(eco, dict) else None
if isinstance(xph, (int, float)):
best = None
for sid in modes.get("fast-xp", {}).get("sites", []) or []:
sdef = sites.get(sid)
if not isinstance(sdef, dict):
continue
rate = sdef.get("modeled_ores_per_hour") or 0
xpv = (sdef.get("xp") or 0) * rate
if best is None or xpv > best:
best = xpv
if best:
if abs(xph - best) / best > XP_TOLERANCE:
errors.append(
f"{rel}: economy.xp_per_hour {xph} is not the fast-xp "
f"modelled policy rate (expected ~{best:.0f} = fastest "
f"fast-xp site's modelled ores/hr x ore xp; tolerance "
f"{XP_TOLERANCE:.0%})")
return errors
def _with_runite(sites):
"""Fixture helper: the `good` site set plus a non-selectable Wilderness
runite row (hyphenated id so the declared reference resolves)."""
out = dict(sites)
out["runite-wilderness"] = {
"ore": "Runite ore", "ore_id": 451, "mining_level": 85, "xp": 125,
"rock_count": 2, "respawn_seconds": 720, "rolls_per_ore": 3.0,
"modeled_ores_per_hour": 0, "world_hop": False, "selectable": False,
"inventory_policy": "bank", "access": "Wilderness (PK risk)",
"safety": "out of scope", "route": "not selectable"}
return out
def run_selftest(verbose=False):
"""Committed regression fixtures for the structured mining gates."""
errors = []
def check(name, passed):
if verbose:
print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}")
if not passed:
errors.append(f"selftest: {name}")
canon = {k: dict(v) for k, v in CANONICAL_PICKAXES.items()} # deep copy — fixtures below must never mutate the module table
good_examples = [
{"account": "1-Attack/1-Mining", "attack": 1, "mining": 1,
"expected_tier": "iron-pickaxe", "expected_equipped": True,
"expected_slots": 28},
{"account": "1-Attack/41-Mining", "attack": 1, "mining": 41,
"expected_tier": "rune-pickaxe", "expected_equipped": False,
"expected_slots": 27},
{"account": "40-Attack/70-Mining", "attack": 40, "mining": 70,
"expected_tier": "rune-pickaxe", "expected_equipped": True,
"expected_slots": 28},
{"account": "5-Attack/30-Mining", "attack": 5, "mining": 30,
"expected_tier": "mithril-pickaxe", "expected_equipped": False,
"expected_slots": 27},
]
sites = {
"al-kharid-powermine": {"ore": "Iron ore", "ore_id": 440, "mining_level": 15,
"xp": 35, "rock_count": 3, "respawn_seconds": 5.4, "rolls_per_ore": 1.33,
"modeled_ores_per_hour": 803, "world_hop": False, "selectable": True,
"inventory_policy": "drop", "access": "combat 29+", "safety": "gate",
"route": "drop in place"},
"se-varrock-copper": {"ore": "Copper ore", "ore_id": 436, "mining_level": 1,
"xp": 17.5, "rock_count": 2, "respawn_seconds": 5.4, "rolls_per_ore": 1.6,
"modeled_ores_per_hour": 360, "world_hop": False, "selectable": True,
"inventory_policy": "drop", "access": "safe", "safety": "none",
"route": "drop in place"},
"se-varrock-tin": {"ore": "Tin ore", "ore_id": 438, "mining_level": 1,
"xp": 17.5, "rock_count": 2, "respawn_seconds": 5.4, "rolls_per_ore": 1.6,
"modeled_ores_per_hour": 360, "world_hop": False, "selectable": True,
"inventory_policy": "drop", "access": "safe", "safety": "none",
"route": "drop in place"},
"se-varrock-bank": {"ore": "Iron ore", "ore_id": 440, "mining_level": 15,
"xp": 35, "rock_count": 2, "respawn_seconds": 5.4, "rolls_per_ore": 1.33,
"modeled_ores_per_hour": 713, "world_hop": False, "selectable": True,
"inventory_policy": "bank", "access": "safe", "safety": "none",
"route": "east Varrock bank 30s"},
"al-kharid-south-adamantite": {"ore": "Adamantite ore", "ore_id": 449,
"mining_level": 70, "xp": 95, "rock_count": 2, "respawn_seconds": 240,
"rolls_per_ore": 2.0, "modeled_ores_per_hour": 120, "world_hop": True,
"selectable": True, "inventory_policy": "bank", "access": "south rocks",
"safety": "banked locally", "route": "bank 25s + hop_world"},
}
good = {"accounts": {"min_level": 1},
"pickaxe_tiers": canon, "pickaxe_examples": good_examples,
"mining_modes": {"fast-xp": {"inventory_policy": "drop",
"ore_policy": "iron 15+ / copper/tin under 15",
"sites": ["al-kharid-powermine",
"se-varrock-copper",
"se-varrock-tin"]},
"banked-income": {"inventory_policy": "bank",
"targets": ["se-varrock-bank",
"al-kharid-south-adamantite"],
"stop": "gp target"}},
"mining_selections": {"fast_xp": [
{"level": 1, "expected_site": "se-varrock-copper"},
{"level": 14, "expected_site": "se-varrock-copper"}]},
"mine_sites": sites,
"actions": [{"id": "a1", "step": "hop-world", "verb": "hop_world",
"on_fail": "retry"}],
"economy": {"xp_per_hour": 28100}}
check("canonical pickaxe tiers + recomputed examples pass",
mining_model_errors("ok.yaml", good) == [])
# pickaxe tier value mismatch
bad_tiers = dict(canon)
bad_tiers["rune-pickaxe"] = {"mining_level": 41, "attack_level": 45, "roll_ticks": 3}
errs = mining_model_errors("bad.yaml", {**good, "pickaxe_tiers": bad_tiers})
check("rune equip gate value 45 != canonical 40 is rejected",
any("rune-pickaxe" in e and "attack_level" in e for e in errs))
# 1-Attack/41-Mining must carry rune — declared expectation is enforced
bad_141 = {"account": "1-Attack/41-Mining", "attack": 1, "mining": 41,
"expected_tier": "rune-pickaxe", "expected_equipped": True,
"expected_slots": 27}
errs = mining_model_errors("bad2.yaml", {**good, "pickaxe_examples": [bad_141]})
check("1-Attack/41-Mining example expecting equipped=True is rejected (equip gate: Attack 1 < 40)",
any("equipped=True" in e and "equipped=False" in e for e in errs))
errs = mining_model_errors("bad3.yaml", {**good, "pickaxe_examples": [
{"account": "40-Attack/70-Mining", "attack": 40, "mining": 70,
"expected_tier": "rune-pickaxe", "expected_equipped": True,
"expected_slots": 28}]})
check("missing the 1-Attack/41-Mining example is rejected (owner-mandated)",
any("1-Attack/41-Mining" in e for e in errs))
# level-1 tie-break (governor review 10476, owner decision 2026-08-29):
# Mining 1 must select IRON (rank tie-break over bronze) and EQUIP it
# (attack gate 1) with 28 ore slots — the old bronze-canonical model is
# rejected on three independent axes
tie_bronze = {"account": "1-Attack/1-Mining", "attack": 1, "mining": 1,
"expected_tier": "bronze-pickaxe", "expected_equipped": True,
"expected_slots": 28}
errs = mining_model_errors("f18.yaml", {**good, "pickaxe_examples":
[x for x in good_examples if x["mining"] != 1] + [tie_bronze]})
check("Mining 1 expecting the bronze pickaxe is rejected (rank tie-break selects iron)",
any("'bronze-pickaxe'" in e and "selects 'iron-pickaxe'" in e for e in errs))
tie_carry = {"account": "1-Attack/1-Mining", "attack": 1, "mining": 1,
"expected_tier": "iron-pickaxe", "expected_equipped": False,
"expected_slots": 27}
errs = mining_model_errors("f19.yaml", {**good, "pickaxe_examples":
[x for x in good_examples if x["mining"] != 1] + [tie_carry]})
check("1-Attack/1-Mining expecting a CARRIED iron pickaxe is rejected (Attack 1 >= iron's attack gate 1 -> equipped)",
any("equipped=True" in e and "equipped=False" in e for e in errs) or
any("gives 28 ore slots" in e for e in errs))
tie_slots = {"account": "1-Attack/1-Mining", "attack": 1, "mining": 1,
"expected_tier": "iron-pickaxe", "expected_equipped": True,
"expected_slots": 27}
errs = mining_model_errors("f20.yaml", {**good, "pickaxe_examples":
[x for x in good_examples if x["mining"] != 1] + [tie_slots]})
check("1-Attack/1-Mining with 27 slots while equipped is rejected (equipped iron = 28 ore slots)",
any("28 equipped / 27 carried" in e for e in errs))
no_11 = {**good, "pickaxe_examples":
[x for x in good_examples if not (x["attack"] == 1 and x["mining"] == 1)]}
errs = mining_model_errors("f21.yaml", no_11)
check("missing the 1-Attack/1-Mining fixture is rejected (owner-mandated tie-break proof)",
any("1-Attack/1-Mining" in e for e in errs))
bad_tiers2 = {k: dict(v) for k, v in canon.items()}
del bad_tiers2["iron-pickaxe"]["rank"]
errs = mining_model_errors("f22.yaml", {**good, "pickaxe_tiers": bad_tiers2})
check("a pickaxe tier missing the rank field is rejected (tie-break must be explicit)",
any("iron-pickaxe" in e and "rank" in e for e in errs))
# mode contract
errs = mining_model_errors("bad4.yaml", {**good, "mining_modes": {
"fast-xp": {"inventory_policy": "bank",
"ore_policy": "iron", "sites": ["al-kharid-powermine"]},
"banked-income": {"inventory_policy": "bank", "targets": ["se-varrock-bank"],
"stop": "gp target"}}})
check("fast-xp mode with inventory_policy bank is rejected",
any("fast-xp.inventory_policy" in e for e in errs))
errs = mining_model_errors("bad5.yaml", {**good, "mining_modes": {
"fast-xp": {"inventory_policy": "drop", "ore_policy": "iron",
"sites": ["al-kharid-powermine"]},
"banked-income": {"inventory_policy": "bank",
"targets": ["runite-wilderness"], "stop": "gp target"}},
"mine_sites": _with_runite(sites)})
check("banked-income targeting a selectable:false site is rejected",
any("selectable: false" in e for e in errs))
errs = mining_model_errors("bad6.yaml", {**good, "mining_modes": {
"fast-xp": {"inventory_policy": "drop", "ore_policy": "iron",
"sites": ["nope-mine"]},
"banked-income": {"inventory_policy": "bank", "targets": ["se-varrock-bank"],
"stop": "gp target"}}})
check("mode referencing an undeclared site is rejected",
any("not declared in mine_sites" in e for e in errs))
# long-respawn / wilderness / hop executability
no_hop = {**good, "mine_sites": dict(sites, al_kharid_south_adamantite={
**sites["al-kharid-south-adamantite"], "world_hop": False})}
errs = mining_model_errors("bad7.yaml", no_hop)
check("selectable 240s-respawn site without world_hop is rejected",
any("world_hop: true" in e for e in errs))
hop_no_action = {**good, "actions": [{"id": "a1", "step": "mine-stuff",
"verb": "click_object", "on_fail": "retry"}]}
errs = mining_model_errors("bad8.yaml", hop_no_action)
check("world_hop site without a hop_world action is rejected",
any("hop_world action" in e for e in errs))
wild = {**good, "mine_sites": dict(sites, wildy={"ore": "Runite ore",
"ore_id": 451, "mining_level": 85, "xp": 125, "rock_count": 2,
"respawn_seconds": 720, "rolls_per_ore": 3.0, "modeled_ores_per_hour": 0,
"world_hop": False, "selectable": True, "inventory_policy": "bank",
"access": "Wilderness level 46 (PK risk)", "safety": "none",
"route": "wilderness"})}
errs = mining_model_errors("bad9.yaml", wild)
check("selectable Wilderness site is rejected",
any("Wilderness" in e and "selectable" in e for e in errs))
# rate ceiling
over = {**good, "mine_sites": dict(sites, al_kharid_south_adamantite={
**sites["al-kharid-south-adamantite"], "modeled_ores_per_hour": 839})}
errs = mining_model_errors("bad10.yaml", over)
check("adamantite 839 ores/hr exceeds the 2.0-roll cadence ceiling (~666) and is rejected",
any("cadence ceiling" in e for e in errs))
# headline xp consistency
errs = mining_model_errors("bad11.yaml", {**good, "economy": {"xp_per_hour": 32400}})
check("xp_per_hour 32,400 != modelled fast-xp rate (~28,105) is rejected",
any("xp_per_hour" in e for e in errs))
errs = mining_model_errors("ok2.yaml", {**good, "economy": {"xp_per_hour": 28000}})
check("xp_per_hour 28,000 within 2% of the modelled fast-xp rate 28,105 passes",
errs == [])
# below-15 fallback + mining_selections pins (governor bounce 5)
no_fallback = {**good, "mining_modes": {
"fast-xp": {"inventory_policy": "drop", "ore_policy": "iron 15+ only",
"sites": ["al-kharid-powermine"]},
"banked-income": {"inventory_policy": "bank", "targets": ["se-varrock-bank",
"al-kharid-south-adamantite"], "stop": "gp target"}}}
errs = mining_model_errors("f14.yaml", no_fallback)
check("min_level 1 with iron-only fast-xp sites (no copper/tin fallback) is rejected",
any("below-15" in e for e in errs))
wrongpin = {**good, "mining_selections": {"fast_xp": [
{"level": 1, "expected_site": "se-varrock-tin"},
{"level": 14, "expected_site": "se-varrock-copper"}]}}
errs = mining_model_errors("f15.yaml", wrongpin)
check("mining_selections level-1 expected site mismatching the recomputed selection is rejected",
any("selection rule picks" in e for e in errs))
no_pin14 = {**good, "mining_selections": {"fast_xp": [
{"level": 1, "expected_site": "se-varrock-copper"}]}}
errs = mining_model_errors("f16.yaml", no_pin14)
check("mining_selections missing the level-14 pin is rejected when min_level < 15",
any("pin level 14" in e for e in errs))
ge_no_open = {**good, "actions": [
{"id": "a1", "step": "buy-pick", "verb": "ge_buy", "on_fail": "retry"},
{"id": "a2", "step": "hop-world", "verb": "hop_world", "on_fail": "retry"}]}
errs = mining_model_errors("f17.yaml", ge_no_open)
check("ge_buy without ge_open / ge_offers_done / ge_collect is rejected",
any("ge_open" in e for e in errs))
return errors
def alch_worked_example_regression(rel, data, raw_text):
"""One-cast model worked-example regression (owner guidance 2026-08-28,
PR #8 repair, merged via origin/main into issue-4): entries with the
owner-selected one-cast iteration model must pin a worked example with
MONOTONIC, INDEPENDENT counters — breaks every 550 casts (1,100, 1,650,
...) regardless of restocks, and restocks every 901 casts (1,802, ...),
so restocking never resets or shifts the break counter. Scoped to
entries that declare the one-cast model AND carry a worked example, so
unrelated entries are untouched.
"""
ruco = data.get("run_control") or {}
if "one cast" not in str(ruco.get("iteration", "")):
return []
if "worked example" not in raw_text.lower():
return []
errs = []
if "1,452" in raw_text:
errs.append(f"{rel}: worked example resets the break interval at a restock "
f"(cast 1,452 = 901 + restarted interval) — breaks must stay "
f"monotonic at 550/1,100/1,650 independently of restocks")
for marker, label in (("550", "break #1 (cast 550)"),
("901", "restock #1 (cast 901)"),
("1,100", "break #2 (cast 1,100)"),
("1,650", "break #3 (cast 1,650)"),
("1,802", "restock #2 (cast 1,802)")):
if marker not in raw_text:
errs.append(f"{rel}: worked example missing monotonic counter pin "
f"{label} — breaks/restocks must be pinned literally")
return errs
def _loss_reserve_stop(initial_capital, total_assets, reserve):
"""Loss-reserve stop comparator in the pinned direction (owner guidance
2026-08-29, governor review 10361, PR #8): stop fires only when the
loss EXCEEDS the reserve, i.e. when (initial capital - total assets)
> reserve. A loss makes the LHS positive; a profit (total assets >
initial capital) makes it negative and can never fire this stop. The
reversed 'total assets - initial capital' form is invalid because it
is negative exactly when a loss exists.
"""
return (initial_capital - total_assets) > reserve
def alch_accounting_regression(rel, data, raw_text):
"""Loss-reserve + fill-price accounting regression (owner guidance
2026-08-29 for governor review 10361, PR #8): the low-alch-members
entry must express the loss-reserve stop in the correct direction
EVERYWHERE ('initial capital - total assets > 162,990'), the reversed
sign form must never reappear, and the realized GP/hour bounds must be
derived from the declared fill-price scenarios (A live-low -3.00/cast
vs B max allowed +5% fill -30.15/cast), exposing the +5% fill loss
instead of recycling the live-low margin. Includes below/at/above
boundary fixtures and a profit-side negative regression for the
comparator itself. Scoped to the members low-alchemy entry only.
"""
if data.get("id") != "low-alch-members":
return []
errs = []
# 1. comparator boundary fixtures (pure function, entry's own constants)
INIT, RESERVE = 1_308_290, 162_990 # entry capital / 6-batch reserve
fixtures = [
("below (loss 162,989)", INIT - 162_989, False),
("at boundary (loss 162,990, strict >)", INIT - 162_990, False),
("above (loss 162,991)", INIT - 162_991, True),
("profit side (assets 50,000 above initial)", INIT + 50_000, False),
("breakeven (assets == initial)", INIT, False),
]
for label, total_assets, expect in fixtures:
got = _loss_reserve_stop(INIT, total_assets, RESERVE)
if got != expect:
errs.append(f"{rel}: loss-reserve comparator fixture '{label}' "
f"returned {got}, expected {expect}")
# 2. entry direction: correct form present, reversed form absent
if "initial capital - total assets > 162,990" not in raw_text:
errs.append(f"{rel}: loss-reserve stop must be expressed as "
f"'initial capital - total assets > 162,990' everywhere")
if "total assets - initial capital" in raw_text:
errs.append(f"{rel}: loss-reserve stop uses the reversed sign form — "
f"on a loss it is negative and can never exceed the "
f"reserve; use 'initial capital - total assets > 162,990'")
# 3. realized GP bounds must come from the fill-price scenarios
eco = data.get("economy", {})
if eco.get("gp_per_hour_low") != -29463:
errs.append(f"{rel}: gp_per_hour_low must be -29,463 (max-fill "
f"scenario B -30.15/cast x session-fast 977.2 casts/hr "
f"= -29,462.6), got {eco.get('gp_per_hour_low')}")
if eco.get("gp_per_hour_high") != -1758:
errs.append(f"{rel}: gp_per_hour_high must be -1,758 (live-low "
f"scenario A -3.00/cast x session-slow 585.9 casts/hr "
f"= -1,757.7), got {eco.get('gp_per_hour_high')}")
for pin, label in (("-30.15", "max-fill per-cast loss -30.15"),
("570.15", "max-fill per-cast cost 570.15"),
("423.15", "max-fill candidate price 423.15")):
if pin not in raw_text:
errs.append(f"{rel}: economy FILL-PRICE SCENARIOS missing {label} "
f"({pin}) — the +5% accepted-fill scenario must be "
f"declared so the realized GP bounds cannot silently "
f"revert to the live-low-only margin")
return errs
def main():
global STALE_DAYS
if "--stale-days" in sys.argv:
STALE_DAYS = int(sys.argv[sys.argv.index("--stale-days") + 1])
errors, warnings = [], []
if "--selftest" in sys.argv:
errors += run_selftest(verbose=True)
print(f"RESULT: selftest, {len(errors)} errors, 0 warnings")
return 1 if errors else 0
entries = load_entries()
if not entries:
print("ERROR: no codex entries found under codex/")
return 1
ids = {}
today = datetime.date.today()
for path in entries:
rel = str(path.relative_to(ROOT))
try:
data = yaml.safe_load(path.read_text())
except yaml.YAMLError as e:
errors.append(f"{rel}: YAML parse error: {e}")
continue
# 1. schema
v = jsonschema.Draft7Validator(SCHEMA)
for err in sorted(v.iter_errors(data), key=lambda e: list(e.path)):
loc = "/".join(str(p) for p in err.path) or "(root)"
errors.append(f"{rel} [{loc}]: {err.message}")
if errors and errors[-1].startswith(rel):
# schema already broke; cross-checks on a broken doc are noise
continue
eid = data.get("id", rel)
if eid in ids:
errors.append(f"{rel}: duplicate entry id '{eid}' (also in {ids[eid]})")
ids[eid] = rel
# 2. state machine consistency
sm = data.get("state_machine", {})
states = sm.get("states", {})
declared = set(states)
terminals = set(sm.get("terminal", []))
for sname, sdef in states.items():
if sname not in terminals and not sdef.get("next"):
errors.append(f"{rel}: non-terminal state '{sname}' has no 'next'")
for nxt in sdef.get("next", []):
if nxt not in declared:
errors.append(f"{rel}: state '{sname}' next->'{nxt}' not declared")
if sm.get("initial") not in declared:
errors.append(f"{rel}: initial state '{sm.get('initial')}' not declared")
for t in sm.get("transitions", []):
if t["from"] not in declared:
errors.append(f"{rel}: transition from '{t['from']}' not declared")
if t["to"] not in declared:
errors.append(f"{rel}: transition to '{t['to']}' not declared")
for term in sm.get("terminal", []):
if term not in declared:
errors.append(f"{rel}: terminal '{term}' not declared")
# one-cast worked-example regression (owner guidance 2026-08-28,
# PR #8): monotonic break/restock counters, scoped to the one-cast model
errors += alch_worked_example_regression(rel, data, path.read_text())
# loss-reserve direction + fill-price GP bound regression (owner
# guidance 2026-08-29 for governor 10361, PR #8), scoped to
# low-alch-members
errors += alch_accounting_regression(rel, data, path.read_text())
# action id/step uniqueness
a_ids = [a["id"] for a in data["actions"]]
a_steps = [a["step"] for a in data["actions"]]
if len(a_ids) != len(set(a_ids)):
errors.append(f"{rel}: duplicate action ids")
if len(a_steps) != len(set(a_steps)):
errors.append(f"{rel}: duplicate action step names")
# 2b. structured mining-model gates (pickaxe gates + run-mode contract)
errors += mining_model_errors(rel, data)
# 3. staleness / research TODOs
if data.get("status") == "verified":
try:
vdate = datetime.date.fromisoformat(data["verified"])
age = (today - vdate).days
if age > STALE_DAYS:
warnings.append(
f"{rel}: status=verified but facts are {age}d old "
f"(> {STALE_DAYS}d) — re-verify or drop to status:stale")
except ValueError:
pass
for line in data.get("best_practice", {}).get("equipment", []) + \
data.get("best_practice", {}).get("techniques", []):
if str(line.get("source", "")).startswith("researched"):
warnings.append(
f"{rel}: best_practice '{line.get('item') or line.get('name')}' "
f"carries a 'researched' source — citation TODO, file follow-up")
# report
for w in warnings:
print(f"WARN {w}")
for e in errors:
print(f"ERROR {e}")
print(f"RESULT: {len(entries)} entries, {len(errors)} errors, {len(warnings)} warnings")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())