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