#!/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())