osrs-codex/tools/validate.py
Claude a0b03b87c6 codex: mining-f2p — level-1 pickaxe tie-break: Mining 1 selects+equips iron (bounce-fix attempt 6)
Owner guidance 2026-08-29 for governor review 10476 (PR #11), finding by finding:

1. Explicit tier rank/tie-break for equal Mining gates. pickaxe_tiers
   declared bronze and iron both at Mining 1 / Attack 1 while the
   level-1 worked example called bronze canonical and carried it (27
   slots) — internally contradictory under the entry's own
   highest-unlocked acquisition policy. pickaxe_tiers now carries an
   explicit rank (bronze 1 .. rune 6) and the selection rule resolves
   equal Mining gates by rank: Mining 1 deterministically selects IRON
   (7-tick roll, rank 2) over bronze (rank 1), and Attack 1 EQUIPS it
   (iron attack gate = 1) -> 28 ore slots. Stated at every policy
   layer (summary, accounts.requires_other, best_practice.equipment,
   prepare state, economy.supplies, a14 equip-tool gate, pickaxe_tiers
   head comment). An owned-only bronze run is documented as an operator
   choice, never the entry's selection rule.

2. Level-1 lifecycle/rate inputs recomputed. The level-1 worked
   example now uses the equipped iron pickaxe: 28 ore slots per drop
   load, iron cadence [4200, 6000] ms (midpoint 5.1 s), and the
   copper/tin rate re-derived from that cadence: 1.6 rolls x 5.1 + 0.5
   drop + 0.39 break = 9.05 s/ore -> 398 ores/hr -> ~6,960 XP/hr (pin
   6,900), replacing the bronze-midpoint 360 ores/hr / 6,300 XP/hr.
   Updated in mine_sites (se-varrock-copper / se-varrock-tin),
   economy comment block, profit_drivers, worktree example, and risk
   notes; no other rate changes (fast-xp iron 28,000 and banked rates
   untouched).

3. Recomputed 1-Attack/1-Mining fixture + capacity proof. Schema:
   pickaxe_tiers gains required rank (1..6); pickaxe_examples gain
   required expected_slots (28 equipped / 27 carried). Validator:
   select_pickaxe() ties by rank; every pickaxe_examples row is
   recomputed including expected_slots, the 1-Attack/41-Mining
   rune-carry fixture is preserved, and the 1-Attack/1-Mining row is
   now mandatory (selects iron, equipped, 28). Committed negative
   fixtures f18-f22: bronze-at-Mining-1, carried-at-Attack-1, wrong
   slots, missing 1/1 fixture, and a tier missing rank are all
   rejected (22 selftests pass).

Preserved: complete GE tool acquisition loop, structured copper/tin
below-15 fallback, fast-xp/banked-income modes, dynamic 27/28
inventory handling, declared banked targets, respawn/world-hop model,
and all prior rate gates. version 6 -> 7, updated 2026-08-29.

Gate: python3 tools/validate.py -> 2 entries, 0 errors, 1 pre-existing
F2P WARN (low-alch-f2p researched, untouched); --selftest -> 22 PASS;
git diff --check clean.
2026-08-29 03:12:40 -05:00

728 lines
37 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 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")
# 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())