#!/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) - GE-margin entries (tags include flipping and/or merchanting — the unambiguous margin signals): the 2% GE convenience fee is rounded DOWN to whole coins per item and capped at 5,000,000 GP per item, so net_per_cycle MUST equal income - min(floor(0.02*income), 5_000_000) - cost. Fractional-tax arithmetic (0.98*sell - buy) and uncapped tax (6M on a 300M sale) are errors. NOTE: the generic `ge` tag alone does NOT gate this rule — a training/processing/procurement entry may legitimately carry `ge` while its income/cost model has nothing to do with flip tax. - Regression fixtures (--selftest): a fractional-tax flipping entry must FAIL; an above-cap high-value flip with the correctly capped-tax net must PASS; a non-margin entry carrying only `ge` plus a non-flip tag must PASS. 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 GE floor-tax regression fixtures only (no tree scan) """ import datetime import json import math import re 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 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) MARGIN_TAGS = {"flipping", "merchanting"} def ge_floor_tax_error(rel, data): """GE floor-tax gate for a single entry. Applies ONLY to the unambiguous margin tags (flipping/merchanting). The generic `ge` tag is not proof that income_per_cycle is a taxed GE resale: a training/processing/procurement entry may carry `ge` while modeling unrelated income/cost, so it must not be gated by this rule. Returns an error message, or None when the entry is not gated or its floored-tax arithmetic is correct. Wiki: "The 2% tax rounds down to the nearest whole number, so items sold for below 50 coins have a tax of less than one coin" — per-item net MUST use the floored tax, never 0.98*sell - buy. The fee is also capped at 5,000,000 GP per item (per-item cap, documented in the flip-candidates best_practice/risk), so tax = min(floor(0.02*sell), 5_000_000). Owner decision 2026-08-28: one global formula for all entries (option A), no per-entry tax-model discriminator. """ tags = {str(t).lower() for t in data.get("tags", [])} if not tags & MARGIN_TAGS: return None eco = data.get("economy") or {} if not all(k in eco for k in ("income_per_cycle", "cost_per_cycle", "net_per_cycle")): return None sell, buy = eco["income_per_cycle"], eco["cost_per_cycle"] tax = min(math.floor(sell * 0.02), 5_000_000) expected = sell - tax - buy if eco["net_per_cycle"] != expected: return ( f"{rel}: net_per_cycle {eco['net_per_cycle']} != capped floored-tax " f"model {expected} (sell {sell} - min(floor({sell}*0.02), 5_000_000)" f"={tax} - buy {buy}); " f"GE tax rounds down per item and is capped at 5,000,000 GP per item " f"— fractional 0.98*sell - buy and uncapped tax are invalid") return None def run_selftest(verbose=False): """Regression fixtures for the GE floor-tax gate; returns error strings.""" fixtures = [ # name, tags, economy, expect_error ("fractional-tax flipping entry", ["flipping"], {"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 16.12}, True), # 5000 - floor(5000*0.02) - 3000 = 1900 != 2000: this fixture's # economics deliberately do NOT satisfy the flip equation, so the # old `ge`-tag scoping would false-positive on it (2000 != 1900). ("non-margin entry with only 'ge' + 'processing'", ["ge", "processing"], {"income_per_cycle": 5000, "cost_per_cycle": 3000, "net_per_cycle": 2000}, False), ("correctly-floored flipping entry", ["flipping", "merchanting"], {"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17}, False), # sell 300,000,000 -> uncapped floor tax is 6,000,000; the per-item # cap makes it 5,000,000, so the correct net is 1,000,000. The old # uncapped rule demanded net=0 and rejected this valid entry. ("high-value flip above the 5,000,000 GP per-item tax cap", ["flipping"], {"income_per_cycle": 300_000_000, "cost_per_cycle": 294_000_000, "net_per_cycle": 1_000_000}, False), ] errors = [] for name, tags, eco, expect_error in fixtures: rel = f"selftest/{name}" got_error = ge_floor_tax_error(rel, {"tags": tags, "economy": eco}) is not None passed = got_error == expect_error if verbose: print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") if not passed: errors.append( f"selftest: '{name}' expected " f"{'an error' if expect_error else 'no error'} but got " f"{'an error' if got_error else 'no error'}") 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 # The fix itself is gated: regression fixtures run on every invocation. errors += run_selftest(verbose=False) 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") # GE-margin floor-tax arithmetic (see ge_floor_tax_error): scoped to # the unambiguous margin tags flipping/merchanting. The generic `ge` # tag alone must not gate this rule — alchemy/processing/procurement # entries can carry `ge` with income unrelated to a taxed GE resale. err = ge_floor_tax_error(rel, data) if err: errors.append(err) # 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())