#!/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) - Cross-entry supply-fact consistency: the same (supply item, source) across entries must carry the same ge_limit. A diverging limit (e.g. nature rune "12500" in one entry vs "18000" in another) is a silent fact error, not a per-entry choice — GE buy limits are item-global. - SII translatability: an action that casts Superheat Item must declare a `selection:` block (mode highest-unlocked, eligible rule, fallback) and the state machine must cover both the SII window and the stay-on-LA fallback. Every declared recipe must pin a `target_input` that is one of its declared inputs and never coal (the spell targets the primary ore), and smithing_level gates must be unique so "highest unlocked" selection is deterministic. - Realized-rate consistency (owner guidance 2026-08-28, bounce 4): entries that DECLARE a `rate_model` must have xp_per_hour derived from their OWN declared lifecycle — cast ticks + the casting action's mandatory anti_ban.delay (additive), run_control breaks, per-recipe inventory capacity (floor(slots / total input qty)), restock trips, and startup amortised over the session horizon. The gate is behind an explicit discriminator: entries without `rate_model` (e.g. mining) are never treated as alchemy. Every declared SII recipe must pin `rates` (capacity + min/mid/max casts/hr + Magic xp/hr) matching the derived model, and capacity must fit a 28-slot inventory (1 nature stack -> 27 input slots). 3. Global id uniqueness across the whole codex tree. Malformed/scalar YAML documents (an empty doc, a bare string, a list) are rejected with a normal validator ERROR — they never reach the cross-entry gates and never crash the tool with a traceback. The same holds for schema-INVALID MAPPINGS (e.g. `economy: not-a-mapping`): shared-gate rows are only collected after schema validation succeeds (regression: a malformed nested field used to crash `supply_limit_conflicts` with an AttributeError). Exit codes: 0 = clean (warnings ok), 1 = validation errors. Usage: python3 tools/validate.py [--stale-days 30] [--selftest] --selftest: run the regression fixtures only """ import copy import datetime import json 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) def document_error(rel, data): """Shape check: an entry must parse to a YAML mapping. Returns an error string, or None for a well-formed mapping. The caller keeps malformed/scalar documents out of the shared row set so downstream gates never see None/scalars (regression: a scalar document used to crash `supply_limit_conflicts` with an AttributeError instead of reporting a validation error). """ if isinstance(data, dict): return None if data is None: got = "an empty document" elif isinstance(data, (list, tuple)): got = f"a {type(data).__name__} document" else: got = f"{type(data).__name__} ({data!r})" return f"{rel}: document is not a YAML mapping (parsed as {got})" def supply_limit_conflicts(rows): """Cross-entry consistency for duplicate supply facts. rows: iterable of (rel, data). The same (item, source) appearing in more than one entry must agree on ge_limit — GE buy limits are a property of the item, so a divergence is a silent fact error. Returns a list of error strings. """ facts = {} for rel, data in rows: if not isinstance(data, dict): # never crash on malformed rows continue eco = data.get("economy") if not isinstance(eco, dict): # schema-invalid nested mapping (e.g. economy: "x") continue for sup in eco.get("supplies", []): if not isinstance(sup, dict): continue lim = sup.get("ge_limit") if lim is None: continue key = (sup.get("item"), sup.get("source")) facts.setdefault(key, {}).setdefault(lim, set()).add(rel) errors = [] for (item, source), by_lim in sorted(facts.items()): if len(by_lim) > 1: detail = "; ".join( f"limit {lim} in {', '.join(sorted(rs))}" for lim, rs in sorted(by_lim.items())) errors.append( f"conflicting ge_limit for supply '{item}' (source {source}): {detail}") return errors def recipe_total_input_qty(recipe): """Total input count of a recipe — every input and quantity counts. A multi-input recipe (e.g. steel-bar = 1 iron ore + 2 coal) must account for all inputs, not just the headline ore. """ return sum(int(inp.get("qty", 0)) for inp in recipe.get("inputs", [])) def recipe_margin(recipe, bar_price, input_prices, nature_price): """Complete-recipe margin for Superheat Item. bar GE price minus (ALL inputs at live GE buy prices + one nature rune). Negative/zero => the bar is not worth producing with SII. This is the exact formula the entries pin; tests use it with fixed prices so the fixture is deterministic. """ cost = sum( int(inp.get("qty", 0)) * int(input_prices.get(inp.get("item"), 0)) for inp in recipe.get("inputs", [])) return bar_price - (cost + nature_price) # --- rate-model constants (owner guidance 2026-08-28, bounce 4) --- # The cadence is DERIVED from each entry's declared rate_model + actions + # run_control + recipes — nothing is hard-coded per activity class. These # are the only universal facts: # * one game tick = 0.6 s (docs/action-grammar.md); # * anti_ban.delay is an ADDITIVE post-action sleep # (docs/action-grammar.md:135), so cycle = cast_ticks*0.6 + delay; # * LA/SII/HLA Magic XP per cast (wiki infoboxes): 31 / 53 / 65. XP_LA, XP_SII, XP_HLA = 31, 53, 65 RATE_TOLERANCE = 0.02 # pinned values may round to 3 significant figures def _mean(pair): return (pair[0] + pair[1]) / 2.0 if pair else 0.0 def _casting_delay(data): """The mandatory post-action delay of the entry's casting action(s), converted from ms (anti_ban.delay units) to seconds. Derived from the entry's OWN actions: the first action whose step starts with 'cast' and carries an anti_ban.delay pair. Returns (delay_min_s, delay_max_s) or None. """ for a in data.get("actions", []): if not isinstance(a, dict): continue if not str(a.get("step", "")).startswith("cast"): continue delay = (a.get("anti_ban") or {}).get("delay") if isinstance(delay, list) and len(delay) == 2: return float(delay[0]) / 1000.0, float(delay[1]) / 1000.0 return None def _break_amort(data): """Break time per cast from the entry's OWN run_control.break. break.every counts completed casts (one-cast iteration model), so the amortised break share = mean(break.for) / mean(break.every). Fast/slow ends use for_min/every_max and for_max/every_min respectively. """ brk = (data.get("run_control") or {}).get("break") or {} every, for_ = brk.get("every"), brk.get("for") if not (isinstance(every, list) and len(every) == 2 and isinstance(for_, list) and len(for_) == 2): return None e_min, e_max = float(every[0]), float(every[1]) f_min, f_max = float(for_[0]), float(for_[1]) return { "fast": f_min / e_max, "mid": _mean(for_) / _mean(every), "slow": f_max / e_min} def _session_factor(rm, pace): """Startup amortisation factor = 1 - startup_s / session_horizon_s. pace selects the matching end of the startup range: fast cast pace pairs with the fast (short) startup, slow with the slow one, mid with the midpoint. Returns None when the horizon is missing or startup exceeds the horizon (impossible session).""" startup = rm.get("startup_s") or [300, 600] horizon = rm.get("session_horizon_s") if not isinstance(horizon, (int, float)) or horizon <= 0: return None if not (isinstance(startup, list) and len(startup) == 2): return None s_min, s_max = float(startup[0]), float(startup[1]) pick = {"fast": s_min, "mid": _mean(startup), "slow": s_max}[pace] if pick >= horizon: return None return 1.0 - pick / horizon def recipe_capacity(recipe, slots): """Inventory capacity of a recipe: floor(slots / total input qty). SII bars are non-stackable and one slot holds the nature rune stack, so a 28-slot inventory leaves `slots` (=27) for recipe inputs. A recipe whose inputs exceed the slots cannot exist (capacity 0).""" qty = recipe_total_input_qty(recipe) if qty <= 0: return 0 return int(slots // qty) def _alch_pace_casts_hr(cycle, trip, break_share, batch_casts): """Steady-state realized casts/hr for an alch tier (LA/HLA), derived from the batch model (carried_batch - restock_threshold casts per restock trip) plus breaks.""" batch_s = batch_casts * cycle + trip + break_share * batch_casts if batch_s <= 0: return 0.0 return batch_casts * 3600.0 / batch_s def rate_model_errors(rel, data): """xp_per_hour and per-recipe rates must derive from the entry's OWN declared lifecycle (owner guidance 2026-08-28, bounce 4). The gate is OPT-IN behind an explicit `rate_model` discriminator: entries without one (e.g. a mining entry) are never treated as alchemy. For entries that declare it, the model is derived from the entry's own fields — cast_ticks + the casting action's mandatory anti_ban.delay, run_control.break, restock trips, the recipes table (capacity = floor(slots / total input qty)), and startup amortised over session_horizon_s. Regression coverage (--selftest): non-alchemy pass, loop-only/old-regime failures, cadence-mutation failure (one-hour breaks), per-recipe capacity and rate pins.""" def err(msg): return f"{rel}: {msg}" rm = data.get("rate_model") if not isinstance(rm, dict): return [] # explicit discriminator — unrelated entries pass errors = [] tick_s = 0.6 cast_ticks = rm.get("cast_ticks") if not isinstance(cast_ticks, (int, float)) or cast_ticks < 1: errors.append(err("rate_model.cast_ticks must be >= 1")) return errors cast_s = cast_ticks * tick_s delay = _casting_delay(data) if delay is None: errors.append(err("rate_model requires a casting action (step 'cast-*') " "with anti_ban.delay [min, max] — the rate derives from " "the entry's own mandatory pacing")) return errors cycle = {"fast": cast_s + delay[0], "mid": cast_s + _mean(delay), "slow": cast_s + delay[1]} break_amort = _break_amort(data) if break_amort is None: errors.append(err("rate_model requires run_control.break with every + for " "ranges (cast-count iteration model)")) return errors trip = rm.get("restock_trip_s") if not (isinstance(trip, list) and len(trip) == 2): errors.append(err("rate_model.restock_trip_s must be [min, max] seconds")) return errors trip = {"fast": float(trip[0]), "mid": _mean(trip), "slow": float(trip[1])} slots = rm.get("sii_capacity_slots", 27) # --- per-recipe SII rates (capacity + min/mid/max), fully derived --- recipes = data.get("recipes") if isinstance(recipes, dict): for rid, recipe in recipes.items(): if not isinstance(recipe, dict): continue cap = recipe_capacity(recipe, slots) rates = recipe.get("rates") if not isinstance(rates, dict): errors.append(err(f"recipe '{rid}' must pin a rates block " f"(capacity, casts_per_hour, magic_xp_per_hour)")) continue if cap < 1: errors.append(err(f"recipe '{rid}' cannot fit the inventory: " f"total input qty {recipe_total_input_qty(recipe)} " f"> {slots} input slots (capacity 0)")) continue pinned_cap = rates.get("capacity") if pinned_cap != cap: errors.append(err(f"recipe '{rid}' rates.capacity {pinned_cap} != " f"derived floor({slots} / {recipe_total_input_qty(recipe)}) = {cap}")) casts = {} for pace in ("min", "mid", "max"): p = {"min": "fast", "mid": "mid", "max": "slow"}[pace] per_cast = cycle[p] + trip[p] / cap + break_amort[p] casts[pace] = 3600.0 / per_cast if per_cast > 0 else 0.0 pin_cph = (rates.get("casts_per_hour") or {}) pin_xp = (rates.get("magic_xp_per_hour") or {}) for pace, computed in casts.items(): expect_xp = computed * XP_SII pc, px = pin_cph.get(pace), pin_xp.get(pace) for label, pinned, expected in (("casts_per_hour", pc, computed), ("magic_xp_per_hour", px, expect_xp)): if not isinstance(pinned, (int, float)): errors.append(err(f"recipe '{rid}' rates.{label}.{pace} missing")) elif abs(pinned - expected) / expected > RATE_TOLERANCE: errors.append(err( f"recipe '{rid}' rates.{label}.{pace} {pinned} is not the " f"derived rate ({expected:.1f} from cycle {cycle[p]:.2f}s + " f"trip {trip[p]:.1f}s/{cap} capacity + break {break_amort[p]:.3f}s; " f"tolerance {RATE_TOLERANCE:.0%})")) # --- xp_per_hour anchor: the declared anchor tier, same lifecycle --- eco = data.get("economy") xph = eco.get("xp_per_hour") if isinstance(eco, dict) else None if not isinstance(xph, (int, float)): errors.append(err("rate_model requires economy.xp_per_hour to pin the " "anchor tier's realized rate")) return errors anchor = rm.get("anchor_tier") per_cast = {"la": XP_LA, "sii": XP_SII, "hla": XP_HLA}.get(anchor) if per_cast is None: errors.append(err("rate_model.anchor_tier must be la, sii or hla")) return errors if anchor == "sii": rid = rm.get("anchor_recipe") recipe = (recipes or {}).get(rid) if isinstance(recipes, dict) else None if not isinstance(recipe, dict): errors.append(err(f"rate_model.anchor_recipe '{rid}' is not a declared " f"recipe — the anchor must be one of the recipes")) return errors cap = recipe_capacity(recipe, slots) if cap < 1: return errors # already reported as capacity 0 casts_mid = 3600.0 / (cycle["mid"] + trip["mid"] / cap + break_amort["mid"]) else: batch = rm.get("alch_carry_batch") threshold = rm.get("alch_restock_threshold") if not (isinstance(batch, (int, float)) and batch > 1 and isinstance(threshold, (int, float)) and 0 <= threshold < batch): errors.append(err("rate_model.alch_carry_batch and alch_restock_threshold " "are required for la/hla anchors (threshold < batch)")) return errors batch_casts = batch - threshold casts_mid = _alch_pace_casts_hr(cycle["mid"], trip["mid"], break_amort["mid"], batch_casts) factor = _session_factor(rm, "mid") if factor is None: errors.append(err("rate_model startup_s must be < session_horizon_s " "(startup cannot exceed the session)")) return errors expected = per_cast * casts_mid * factor if abs(xph - expected) / expected > RATE_TOLERANCE: errors.append(err( f"xp_per_hour {xph} is not the anchor tier's derived realized rate " f"(expected ~{expected:.0f} = {per_cast} xp/cast x {casts_mid:.1f} " f"steady-state casts/hr x startup factor {factor:.3f} over " f"{rm.get('session_horizon_s')}s; tolerance {RATE_TOLERANCE:.0%})")) return errors def highest_unlocked_recipe(recipes, smithing_level): """Deterministic Superheat selection: the declared recipe with the highest smithing_level <= the account's Smithing level, else None. Uniqueness of the gates is enforced by recipe_model_errors, so this helper's ordering cannot be ambiguous. An account can NEVER be offered a recipe above its Smithing level. """ best, best_lvl = None, -1 for rid, recipe in (recipes or {}).items(): lvl = recipe.get("smithing_level") if isinstance(recipe, dict) else None if isinstance(lvl, int) and lvl <= smithing_level and lvl > best_lvl: best, best_lvl = rid, lvl return best def select_recipe(recipes, smithing_level, bar_prices, input_prices, nature_price, fill_factor=1.0): """Deterministic full selection (owner guidance 2026-08-28, bounce 4): among the recipes whose Smithing gate is unlocked, pick the HIGHEST-gate one whose complete-recipe margin is positive at the given prices. fill_factor scales every input and the nature rune price to the ALLOWED fill cost (live low + 5% -> 1.05), so eligibility uses the FULL actual/allowed recipe cost. Returns None when no unlocked recipe has a positive margin — the caller then skips SII and stays on LA (the fallback chain: this helper already drops to the next lower unlocked recipe whenever the highest one is margin-negative).""" unlocked = [] for rid, recipe in (recipes or {}).items(): if not isinstance(recipe, dict): continue lvl = recipe.get("smithing_level") if isinstance(lvl, int) and lvl <= smithing_level: cost = sum( int(inp.get("qty", 0)) * int(input_prices.get(inp.get("item"), 0)) for inp in recipe.get("inputs", [])) margin = (bar_prices.get(recipe.get("bar"), 0) - (cost * fill_factor + nature_price * fill_factor)) unlocked.append((lvl, rid, margin)) unlocked.sort(key=lambda t: t[0], reverse=True) for _lvl, rid, margin in unlocked: if margin > 0: return rid return None def recipe_model_errors(rel, data): """SII recipe-model + selection gates (owner guidance 2026-08-28). - every declared recipe pins a `target_input` that is one of its declared inputs and is never coal (the spell targets the primary ore — Superheat cannot be cast on coal); - smithing_level gates are unique, so "highest unlocked" selection is deterministic; - an entry casting Superheat Item must declare a `selection:` block (mode highest-unlocked + eligible_rule + fallback) and its state machine must cover both the SII window (43-55) and the fallback (skip SII / stay on LA / next unlocked recipe). """ errors = [] recipes = data.get("recipes") if recipes is not None and not isinstance(recipes, dict): return [ f"{rel}: 'recipes' must be a mapping of recipe id -> recipe definition"] if isinstance(recipes, dict): seen_levels = {} for rid, recipe in recipes.items(): if not isinstance(recipe, dict): continue # schema reports the shape inputs = recipe.get("inputs") or [] in_names = [str(i.get("item", "")).lower() for i in inputs if isinstance(i, dict)] ti = recipe.get("target_input") if not isinstance(ti, str) or not ti: errors.append( f"{rel}: recipe '{rid}' has no target_input — the spell " f"targets the primary ore and it must be pinned per recipe") elif ti.lower() not in in_names: errors.append( f"{rel}: recipe '{rid}' target_input '{ti}' is not one of " f"its declared inputs") if ti and (ti.lower() == "coal" or ti.strip().isdigit() and int(ti) in {453}): errors.append( f"{rel}: recipe '{rid}' target_input '{ti}' is coal — coal " f"can never be the Superheat spell target") lvl = recipe.get("smithing_level") if isinstance(lvl, int): if lvl in seen_levels: errors.append( f"{rel}: recipes '{seen_levels[lvl]}' and '{rid}' share " f"smithing_level {lvl} — highest-unlocked selection " f"would be ambiguous") seen_levels[lvl] = rid has_sii = any( "superheat" in (str(a.get("params") or {}).lower() + str(a.get("target") or "").lower() + str(a.get("step") or "").lower()) for a in data.get("actions", [])) if has_sii: sel = data.get("selection") if not isinstance(sel, dict): errors.append( f"{rel}: a Superheat Item action requires a 'selection' block " f"(mode, eligible_rule, fallback)") else: if sel.get("mode") != "highest-unlocked": errors.append( f"{rel}: selection.mode must be 'highest-unlocked'") if not (isinstance(sel.get("eligible_rule"), str) and len(sel["eligible_rule"]) >= 10): errors.append( f"{rel}: selection.eligible_rule must state the eligibility " f"rule (smithing gate + complete-recipe margin)") if not (isinstance(sel.get("fallback"), str) and len(sel["fallback"]) >= 10): errors.append( f"{rel}: selection.fallback must state the fallback path " f"(next unlocked recipe; else skip SII / stay on LA)") whens = [str(t.get("when", "")) for t in data.get("state_machine", {}).get("transitions", [])] if not any("43-55" in w for w in whens): errors.append( f"{rel}: no transition covers the SII window (43-55) — the " f"selected recipe tier is unreachable") if not any(("stay on la" in w or "skip" in w or "fallback" in w) for w in whens): errors.append( f"{rel}: no transition covers the SII fallback (skip SII / " f"stay on LA) — selection is not deterministic") return errors def run_selftest(verbose=False): """Regression fixtures for the cross-field gates; error strings.""" errors = [] def check(name, passed): if verbose: print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") if not passed: errors.append(f"selftest: {name}") # --- supply-consistency fixtures --- fixtures = [ ("identical (item, source) with diverging ge_limit (18000 vs 12500)", [("e1.yaml", {"economy": {"supplies": [ {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}}), ("e2.yaml", {"economy": {"supplies": [ {"item": "nature rune", "source": "ge", "ge_limit": 12500}]}})], True), ("identical (item, source) with agreeing ge_limit (18000 both)", [("e1.yaml", {"economy": {"supplies": [ {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}}), ("e2.yaml", {"economy": {"supplies": [ {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}})], False), ("no supplies / missing ge_limit are not gated", [("e1.yaml", {"economy": {}}), ("e2.yaml", {"economy": {"supplies": [ {"item": "gold necklace", "source": "ge"}]}})], False), ] for name, rows, expect_error in fixtures: got = bool(supply_limit_conflicts(rows)) check(name, got == expect_error) # --- malformed-document regression: no traceback, normal errors --- check("supply_limit_conflicts handles (bad.yaml, None) rows without crashing", supply_limit_conflicts([("bad.yaml", None)]) == []) check("supply_limit_conflicts ignores schema-invalid nested mappings (economy: not-a-mapping) without raising", supply_limit_conflicts([("bad.yaml", {"economy": "not-a-mapping"})]) == []) for val in (None, "just a scalar", [1, 2, 3]): msg = document_error("bad.yaml", val) check( f"document_error rejects {type(val).__name__} docs with an ERROR, no raise", isinstance(msg, str) and "not a YAML mapping" in msg) check("document_error accepts mapping documents", document_error("ok.yaml", {"id": "x"}) is None) check("end-to-end: schema-invalid MAPPING -> exit 1, clean ERROR, no Traceback", _end_to_end_bad_mapping()) # --- deterministic multi-input recipe fixture: steel-bar (1 iron + 2 coal) --- steel = {"bar": "Steel bar", "bar_item_id": 2353, "smithing_level": 30, "target_input": "Iron ore", "inputs": [ {"item": "Iron ore", "item_id": 440, "qty": 1, "source": "ge"}, {"item": "Coal", "item_id": 453, "qty": 2, "source": "ge"}]} check("steel-bar recipe totals 3 inputs (1 iron ore + 2 coal)", recipe_total_input_qty(steel) == 3) # cost = 100*1 + 150*2 = 400; bar 600 - (400 + nature 145) = +55 check("steel-bar complete-recipe margin = 55 (bar 600 - (iron 100 + 2*coal 150 + nature 145))", recipe_margin(steel, bar_price=600, input_prices={"Iron ore": 100, "Coal": 150}, nature_price=145) == 55) check("steel-bar margin goes negative when the bar price no longer covers the full recipe", recipe_margin(steel, bar_price=500, input_prices={"Iron ore": 100, "Coal": 150}, nature_price=145) < 0) # --- recipe-model fixtures: target_input, unique gates, selection block --- f2p_bars = { "bronze-bar": {"bar": "Bronze bar", "bar_item_id": 2349, "smithing_level": 1, "smithing_xp": 6.2, "target_input": "Copper ore", "inputs": [{"item": "Copper ore", "item_id": 436, "qty": 1, "source": "ge"}, {"item": "Tin ore", "item_id": 438, "qty": 1, "source": "ge"}]}, "iron-bar": {"bar": "Iron bar", "bar_item_id": 2351, "smithing_level": 15, "smithing_xp": 12.5, "target_input": "Iron ore", "inputs": [{"item": "Iron ore", "item_id": 440, "qty": 1, "source": "ge"}]}, "silver-bar": {"bar": "Silver bar", "bar_item_id": 2355, "smithing_level": 20, "smithing_xp": 13.7, "target_input": "Silver ore", "inputs": [{"item": "Silver ore", "item_id": 442, "qty": 1, "source": "ge"}]}, "steel-bar": steel, "runite-bar": {"bar": "Runite bar", "bar_item_id": 2363, "smithing_level": 85, "smithing_xp": 50, "target_input": "Runite ore", "inputs": [{"item": "Runite ore", "item_id": 451, "qty": 1, "source": "ge"}, {"item": "Coal", "item_id": 453, "qty": 8, "source": "ge"}]}, } # Smithing-level eligibility: highest unlocked, never an unmet gate check("smithing 1 -> bronze-bar (highest unlocked)", highest_unlocked_recipe(f2p_bars, 1) == "bronze-bar") check("smithing 15 -> iron-bar", highest_unlocked_recipe(f2p_bars, 15) == "iron-bar") check("smithing 20 -> silver-bar (steel at 30 stays locked)", highest_unlocked_recipe(f2p_bars, 20) == "silver-bar") check("smithing 30 -> steel-bar", highest_unlocked_recipe(f2p_bars, 30) == "steel-bar") check("smithing 85 -> runite-bar", highest_unlocked_recipe(f2p_bars, 85) == "runite-bar") check("smithing 0 -> None (no recipe selected below the lowest gate)", highest_unlocked_recipe(f2p_bars, 0) is None) # Full selection: Smithing gate + complete-recipe margin at actual and # ALLOWED fill prices, falling back to the next lower unlocked recipe bar_prices = {"Bronze bar": 200, "Iron bar": 380, "Silver bar": 450, "Steel bar": 600, "Runite bar": 12500} input_prices = {"Copper ore": 50, "Tin ore": 30, "Iron ore": 100, "Silver ore": 150, "Coal": 150, "Runite ore": 11000} nature = 145 check("full selection at live prices: smithing 30 + steel margin +55 -> steel-bar", select_recipe(f2p_bars, 30, bar_prices, input_prices, nature) == "steel-bar") check("allowed-fill eligibility (x1.05): steel margin +27.75 -> steel-bar still selected", select_recipe(f2p_bars, 30, bar_prices, input_prices, nature, fill_factor=1.05) == "steel-bar") check("material availability: steel margin-negative at allowed fills -> next lower unlocked (silver-bar)", select_recipe(f2p_bars, 30, {**bar_prices, "Steel bar": 570}, input_prices, nature, fill_factor=1.05) == "silver-bar") check("no unlocked recipe with a positive margin -> None (skip SII, stay on LA)", select_recipe(f2p_bars, 30, {"Bronze bar": 10, "Iron bar": 10, "Silver bar": 10, "Steel bar": 10}, input_prices, nature) is None) check("an unmet Smithing gate is never selected (smithing 20 -> silver-bar, steel stays locked)", select_recipe(f2p_bars, 20, bar_prices, input_prices, nature) == "silver-bar") superheat_action = { "id": "a6", "step": "cast-superheat", "verb": "use_item_on", "target": "the selected recipe's declared target_input in inventory (primary ore — never coal)", "params": {"spell": "Superheat Item", "selected_recipe": "runtime selection per selection.mode: highest-unlocked recipe with a positive complete-cost margin", "target_input": "the selected recipe's declared target_input field"}, "on_fail": "skip"} selection_ok = {"mode": "highest-unlocked", "eligible_rule": "recipes entries with smithing_level <= current Smithing and a positive complete-cost margin", "fallback": "next lower unlocked recipe when materials cannot be obtained; else skip SII and stay on LA"} transitions_ok = [ {"from": "cast", "when": "level 43-55 and the SII window is chosen (selection.mode picks the highest unlocked recipe with positive margin)", "to": "cast"}, {"from": "cast", "when": "no unlocked recipe has a positive margin this window — skip SII and stay on LA", "to": "cast"}] entry_model_ok = {"actions": [superheat_action], "recipes": f2p_bars, "selection": selection_ok, "state_machine": {"transitions": transitions_ok}} check("recipe model with declared target_inputs + selection block passes", recipe_model_errors("ok.yaml", entry_model_ok) == []) # coal can never be the spell target entry_coal = {"actions": [superheat_action], "recipes": {"steel-bar": dict(steel, target_input="Coal")}, "selection": selection_ok, "state_machine": {"transitions": transitions_ok}} coal_errs = recipe_model_errors("bad.yaml", entry_coal) check("steel-bar target_input 'Coal' is rejected (coal is never the spell target)", any("is coal" in e for e in coal_errs)) # target_input must be a declared input entry_bad_ti = {"actions": [superheat_action], "recipes": {"steel-bar": dict(steel, target_input="Runite ore")}, "selection": selection_ok, "state_machine": {"transitions": transitions_ok}} bad_ti = recipe_model_errors("bad.yaml", entry_bad_ti) check("target_input not among the recipe's declared inputs is rejected", any("not one of" in e for e in bad_ti)) # duplicate smithing gates would make highest-unlocked ambiguous entry_dup = {"actions": [superheat_action], "recipes": {"steel-bar": steel, "steel-bar-2": dict(steel, bar="Steel bar 2", bar_item_id=2353)}, "selection": selection_ok, "state_machine": {"transitions": transitions_ok}} dup_errs = recipe_model_errors("bad.yaml", entry_dup) check("duplicate smithing_level across recipes is rejected (ambiguous highest-unlocked)", any("share smithing_level" in e for e in dup_errs)) # Superheat action requires the selection block + fallback transitions entry_no_sel = {"actions": [superheat_action], "recipes": f2p_bars, "state_machine": {"transitions": transitions_ok}} no_sel = recipe_model_errors("bad.yaml", entry_no_sel) check("Superheat action without a selection block is rejected", any("'selection' block" in e for e in no_sel)) entry_no_fb = {"actions": [superheat_action], "recipes": f2p_bars, "selection": selection_ok, "state_machine": {"transitions": [ {"from": "cast", "when": "level 43-55 and the SII window is chosen", "to": "cast"}]}} no_fb = recipe_model_errors("bad.yaml", entry_no_fb) check("Superheat entry without an SII fallback transition is rejected", any("SII fallback" in e for e in no_fb)) # --- rate_model fixtures (owner guidance 2026-08-28, bounce 4) --- # The gate is OPT-IN: entries without rate_model are never treated as # alchemy, and the rate is DERIVED from the entry's own declared # lifecycle (cast action delay, run_control.break, recipes, startup). cast_action = {"id": "a3", "step": "cast-alch", "verb": "use_item_on", "params": {"spell": "Low Level Alchemy"}, "on_fail": "retry", "anti_ban": {"delay": [1600, 3400]}} base = { "rate_model": {"anchor_tier": "la", "cast_ticks": 3, "restock_trip_s": [60, 120], "session_horizon_s": 7200, "startup_s": [300, 600], "alch_carry_batch": 1000, "alch_restock_threshold": 100}, "actions": [cast_action], "run_control": {"break": {"every": [400, 700], "for": [45, 120]}}, "economy": {"xp_per_hour": 23000}} check("non-alchemy entry without rate_model passes (mining xp 52,500 untouched)", rate_model_errors("mining.yaml", {"economy": {"xp_per_hour": 52500}}) == []) check("LA entry declaring rate_model with the derived 23,000 xp/hr passes", rate_model_errors("ok.yaml", dict(base)) == []) check("loop-only LA 25,953 fails the derived realized-rate gate", any("derived realized rate" in e for e in rate_model_errors("bad.yaml", dict(base, **{"economy": {"xp_per_hour": 25953}})))) check("pre-startup realized 24,500 fails too (startup amortisation is part of the model)", any("derived realized rate" in e for e in rate_model_errors("bad.yaml", dict(base, **{"economy": {"xp_per_hour": 24500}})))) check("8-14-cast-break regime 9,455 fails the derived rate gate", any("derived realized rate" in e for e in rate_model_errors("bad.yaml", dict(base, **{"economy": {"xp_per_hour": 9455}})))) mutated = copy.deepcopy(base) mutated["run_control"]["break"]["for"] = [3600, 3600] # one-hour break per cast check("cadence mutation caught: one-hour break per cast fails the derived rate gate", any("derived realized rate" in e for e in rate_model_errors("bad.yaml", mutated))) # SII: per-recipe capacity (floor(27 / total input qty)) + derived rates def recipe(rid, bar, bar_id, lvl, ti, inputs, cap, cph, xp): return {"bar": bar, "bar_item_id": bar_id, "smithing_level": lvl, "target_input": ti, "inputs": [{"item": it, "item_id": iid, "qty": q, "source": "ge"} for it, iid, q in inputs], "rates": {"capacity": cap, "casts_per_hour": {"min": cph[0], "mid": cph[1], "max": cph[2]}, "magic_xp_per_hour": {"min": xp[0], "mid": xp[1], "max": xp[2]}}} steel_recipe = recipe("steel-bar", "Steel bar", 2353, 30, "Iron ore", [("Iron ore", 440, 1), ("Coal", 453, 2)], 9, (355.3, 249.1, 191.2), (18833, 13204, 10131)) bronze_recipe = recipe("bronze-bar", "Bronze bar", 2349, 1, "Copper ore", [("Copper ore", 436, 1), ("Tin ore", 438, 1)], 13, (445.6, 316.5, 244.4), (23617, 16776, 12952)) runite_recipe = recipe("runite-bar", "Runite bar", 2363, 85, "Runite ore", [("Runite ore", 451, 1), ("Coal", 453, 8)], 3, (153.4, 104.5, 79.1), (8131, 5538, 4194)) sii_base = dict(base) sii_base["rate_model"] = {"anchor_tier": "sii", "anchor_recipe": "steel-bar", "cast_ticks": 3, "restock_trip_s": [60, 120], "session_horizon_s": 7200, "startup_s": [300, 600]} sii_base["economy"] = {"xp_per_hour": 12400} sii_base["recipes"] = {"steel-bar": steel_recipe, "bronze-bar": bronze_recipe, "runite-bar": runite_recipe} check("SII entry passes: capacities floor(27/qty) = steel 9, bronze 13, runite 3, all rate pins derived", rate_model_errors("ok.yaml", dict(sii_base)) == []) check("steel anchor xp 12,400 (249.1 x 53 x startup 0.9375) passes the anchor gate", rate_model_errors("ok2.yaml", dict(sii_base)) == []) check("old 24-pack SII rate 27,500 fails the derived per-recipe rate", any("derived realized rate" in e for e in rate_model_errors("bad.yaml", dict(sii_base, **{"economy": {"xp_per_hour": 27500}})))) bad_cap = copy.deepcopy(sii_base) bad_cap["recipes"]["steel-bar"]["rates"]["capacity"] = 8 check("capacity pin mismatch (8 vs floor(27/3)=9) is rejected", any("rates.capacity 8" in e for e in rate_model_errors("bad.yaml", bad_cap))) bad_xp = copy.deepcopy(sii_base) bad_xp["recipes"]["steel-bar"]["rates"]["magic_xp_per_hour"]["mid"] = 20000 check("recipe magic_xp pin far from the derived 13,204 is rejected", any("magic_xp_per_hour.mid" in e for e in rate_model_errors("bad.yaml", bad_xp))) oversized = copy.deepcopy(sii_base) oversized["recipes"]["oversized-bar"] = recipe("oversized-bar", "Oversized bar", 9999, 90, "Iron ore", [("Iron ore", 440, 28)], 1, (1, 1, 1), (53, 53, 53)) check("recipe needing more input slots than the inventory (qty 28 > 27) is rejected", any("cannot fit the inventory" in e for e in rate_model_errors("bad.yaml", oversized))) return errors def _end_to_end_bad_mapping(): """Regression (governor bounce 3, finding 1): a schema-invalid MAPPING with a malformed nested field — `economy: not-a-mapping` — must yield exit 1 with a normal ERROR line and NO traceback. Writes the fixture into the real codex tree and shells out to the real validator so the full main() path is exercised, then removes the file. Returns True when the gate behaves. """ import subprocess bad = ROOT / "codex" / "selftest-bad-mapping.yaml" try: bad.write_text("id: selftest-bad-mapping\neconomy: not-a-mapping\n") proc = subprocess.run( [sys.executable, str(Path(__file__).resolve()), "--stale-days", "9999"], capture_output=True, text=True, cwd=str(ROOT), timeout=120) out = (proc.stdout or "") + (proc.stderr or "") return (proc.returncode == 1 and "selftest-bad-mapping" in out and "ERROR" in out and "Traceback" not in out) finally: bad.unlink(missing_ok=True) 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() rows = [] 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 shape_err = document_error(rel, data) if shape_err: errors.append(shape_err) continue # 1. schema — BEFORE the shared row set. Schema-invalid documents # (including mappings with malformed nested fields like # `economy: not-a-mapping`) must never reach the cross-entry gates: # regression — rows.append() used to run first, and # supply_limit_conflicts() then crashed with an AttributeError. v = jsonschema.Draft7Validator(SCHEMA) schema_errors = sorted(v.iter_errors(data), key=lambda e: list(e.path)) for err in schema_errors: loc = "/".join(str(p) for p in err.path) or "(root)" errors.append(f"{rel} [{loc}]: {err.message}") if schema_errors: # cross-checks on a schema-invalid doc are noise continue rows.append((rel, data)) 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") # SII recipe-model + selection gates + realized-rate consistency errors += recipe_model_errors(rel, data) errors += rate_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") # 2b. cross-entry supply-fact consistency (GE buy limits are item-global) errors += supply_limit_conflicts(rows) # 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())