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