diff --git a/tools/validate.py b/tools/validate.py index 5dde203..5947f6a 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -11,15 +11,23 @@ Runs: => 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/merchanting): the 2% GE - convenience fee is rounded DOWN to whole coins per item, so - net_per_cycle MUST equal income - floor(0.02*income) - cost. - Fractional-tax arithmetic (0.98*sell - buy) is an error. + - 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, so net_per_cycle MUST + equal income - floor(0.02*income) - cost. Fractional-tax + arithmetic (0.98*sell - buy) is an error. + 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; 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] +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 @@ -46,12 +54,82 @@ def load_entries(): 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. + """ + 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 = math.floor(sell * 0.02) + expected = sell - tax - buy + if eco["net_per_cycle"] != expected: + return ( + f"{rel}: net_per_cycle {eco['net_per_cycle']} != floored-tax model " + f"{expected} (sell {sell} - floor({sell}*0.02)={tax} - buy {buy}); " + f"GE tax rounds down per item — fractional 0.98*sell - buy is 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), + ] + 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/") @@ -112,24 +190,13 @@ def main(): if len(a_steps) != len(set(a_steps)): errors.append(f"{rel}: duplicate action step names") - # GE-margin floor-tax arithmetic. The 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. Scoped to GE-margin tags so alchemy - # (income is alch value, not a GE sale) is unaffected. - tags = {str(t).lower() for t in data.get("tags", [])} - eco = data.get("economy") or {} - if tags & {"flipping", "merchanting", "ge"} and all( - k in eco for k in ("income_per_cycle", "cost_per_cycle", "net_per_cycle") - ): - sell, buy = eco["income_per_cycle"], eco["cost_per_cycle"] - tax = math.floor(sell * 0.02) - expected = sell - tax - buy - if eco["net_per_cycle"] != expected: - errors.append( - f"{rel}: net_per_cycle {eco['net_per_cycle']} != floored-tax model " - f"{expected} (sell {sell} - floor({sell}*0.02)={tax} - buy {buy}); " - f"GE tax rounds down per item — fractional 0.98*sell - buy is invalid") + # 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":