|
| 1 | +"""Build the readable report for the cross-wiki move mapping, and check it against the product. |
| 2 | +
|
| 3 | + python research/ground_truth/scripts/wiki_mapping_report.py # rewrite the report |
| 4 | + python research/ground_truth/scripts/wiki_mapping_report.py --check # only verify, exit 1 on problems |
| 5 | +
|
| 6 | +The mapping files (semantic/wiki_mapping/<boss>.json) are the source of truth. The check |
| 7 | +fails when a product move in backend/seed/bosses.json is missing from the mapping, mapped |
| 8 | +twice, or no longer exists, so the mapping cannot silently drift from the product. |
| 9 | +""" |
| 10 | + |
| 11 | +import json |
| 12 | +import sys |
| 13 | +from collections import Counter |
| 14 | +from pathlib import Path |
| 15 | + |
| 16 | +ROOT = Path(__file__).resolve().parents[3] |
| 17 | +MAPPING_DIR = ROOT / "research" / "ground_truth" / "semantic" / "wiki_mapping" |
| 18 | +REPORT = MAPPING_DIR / "README.md" |
| 19 | +SEED = ROOT / "backend" / "seed" / "bosses.json" |
| 20 | + |
| 21 | +RELATIONS = { |
| 22 | + "same": "Both wikis describe the same action with the same boundaries.", |
| 23 | + "partial": "Overlapping, but details differ (hit count, follow-ups, conditions).", |
| 24 | + "split": "Divided differently: one entry on one side covers several on the other.", |
| 25 | + "conflict": "The wikis contradict each other.", |
| 26 | + "unclear": "No confident correspondence.", |
| 27 | + "only_fextralife": "Only Fextralife describes it.", |
| 28 | + "only_fandom": "Only Fandom describes it.", |
| 29 | +} |
| 30 | +V4_STATUSES = {"unverified"} |
| 31 | + |
| 32 | + |
| 33 | +def load_mappings() -> list[dict]: |
| 34 | + return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(MAPPING_DIR.glob("*.json"))] |
| 35 | + |
| 36 | + |
| 37 | +def check(mappings: list[dict]) -> list[str]: |
| 38 | + seed = json.loads(SEED.read_text(encoding="utf-8")) |
| 39 | + product = {b["id"]: {m["id"] for p in b["phases"] for m in p["moves"]} for b in seed} |
| 40 | + problems = [] |
| 41 | + for boss_id in product.keys() - {m["boss"] for m in mappings}: |
| 42 | + problems.append(f"{boss_id}: no mapping file") |
| 43 | + for mapping in mappings: |
| 44 | + boss_id = mapping["boss"] |
| 45 | + mapped = Counter(move for entry in mapping["entries"] for move in entry["product_moves"]) |
| 46 | + for move, count in mapped.items(): |
| 47 | + if count > 1: |
| 48 | + problems.append(f"{boss_id}/{move}: mapped {count} times") |
| 49 | + for move in product.get(boss_id, set()) - mapped.keys(): |
| 50 | + problems.append(f"{boss_id}/{move}: product move missing from the mapping") |
| 51 | + for move in mapped.keys() - product.get(boss_id, set()): |
| 52 | + problems.append(f"{boss_id}/{move}: mapped but not in the product") |
| 53 | + for entry in mapping["entries"]: |
| 54 | + if entry["relation"] not in RELATIONS: |
| 55 | + problems.append(f"{boss_id}: unknown relation {entry['relation']!r}") |
| 56 | + if entry["v4_status"] not in V4_STATUSES: |
| 57 | + # Anything beyond "unverified" must be decided by the project owner, not by tooling. |
| 58 | + problems.append(f"{boss_id}: unexpected v4_status {entry['v4_status']!r}") |
| 59 | + return problems |
| 60 | + |
| 61 | + |
| 62 | +def cell(items: list[str]) -> str: |
| 63 | + return "<br>".join(f"`{i}`" if " " not in i and i.islower() else i for i in items) or "—" |
| 64 | + |
| 65 | + |
| 66 | +def report(mappings: list[dict]) -> str: |
| 67 | + lines = [ |
| 68 | + "# Cross-wiki move mapping", |
| 69 | + "", |
| 70 | + "Generated by `research/ground_truth/scripts/wiki_mapping_report.py` from the JSON files in this folder. Do not edit by hand.", |
| 71 | + "", |
| 72 | + "For every boss in the product, each row lines up a product move with the Fextralife and Fandom entries that describe it, " |
| 73 | + "and records how they relate. It measures how much two independent human sources disagree, which is a **lower bound** on the " |
| 74 | + "gap between the product's movesets and engine-level ground truth.", |
| 75 | + "", |
| 76 | + "**Nothing here is ground truth.** Every row is `unverified`. V4 adds runtime and engine evidence to `engine_evidence`; " |
| 77 | + "which semantic moves and mappings become ground truth is decided by the project owner, never by tooling or by Claude.", |
| 78 | + "", |
| 79 | + "## Relations", |
| 80 | + "", |
| 81 | + "| Relation | Meaning |", |
| 82 | + "|---|---|", |
| 83 | + *[f"| `{name}` | {meaning} |" for name, meaning in RELATIONS.items()], |
| 84 | + "", |
| 85 | + "## Summary", |
| 86 | + "", |
| 87 | + "| Boss | Product moves | Rows | same | partial | split | conflict | unclear | only Fextralife | only Fandom | Product moves matched 1:1 |", |
| 88 | + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|", |
| 89 | + ] |
| 90 | + totals = Counter() |
| 91 | + for m in mappings: |
| 92 | + rel = Counter(e["relation"] for e in m["entries"]) |
| 93 | + product_moves = sum(len(e["product_moves"]) for e in m["entries"]) |
| 94 | + matched = sum(len(e["product_moves"]) for e in m["entries"] if e["relation"] == "same") |
| 95 | + totals.update(rel) |
| 96 | + totals["product"] += product_moves |
| 97 | + totals["rows"] += len(m["entries"]) |
| 98 | + totals["matched"] += matched |
| 99 | + lines.append( |
| 100 | + f"| {m['boss']} | {product_moves} | {len(m['entries'])} | " |
| 101 | + + " | ".join(str(rel[r]) for r in RELATIONS) |
| 102 | + + f" | {matched}/{product_moves} |" |
| 103 | + ) |
| 104 | + lines.append( |
| 105 | + f"| **Total** | **{totals['product']}** | **{totals['rows']}** | " |
| 106 | + + " | ".join(f"**{totals[r]}**" for r in RELATIONS) |
| 107 | + + f" | **{totals['matched']}/{totals['product']}** |" |
| 108 | + ) |
| 109 | + |
| 110 | + for m in mappings: |
| 111 | + fx, fd = m["sources"]["fextralife"], m["sources"]["fandom"] |
| 112 | + lines += [ |
| 113 | + "", |
| 114 | + f"## {m['boss']}", |
| 115 | + "", |
| 116 | + f"- Ruleset: {m['ruleset']}. Product data originally from: {m['product_source']}.", |
| 117 | + f"- Fextralife: <{fx['url']}> ({fx['version']}, fetched {fx['fetched']})", |
| 118 | + f"- Fandom: <{fd['url']}> ({fd['version']}, fetched {fd['fetched']})", |
| 119 | + *[f"- {note}" for note in m["notes"]], |
| 120 | + "", |
| 121 | + "| Product moves | Fextralife | Fandom | Relation | Notes |", |
| 122 | + "|---|---|---|---|---|", |
| 123 | + ] |
| 124 | + for e in m["entries"]: |
| 125 | + notes = e["notes"].replace("|", "/") or "" |
| 126 | + lines.append( |
| 127 | + f"| {cell(e['product_moves'])} | {cell(e['fextralife'])} | {cell(e['fandom'])} | `{e['relation']}` | {notes} |" |
| 128 | + ) |
| 129 | + return "\n".join(lines) + "\n" |
| 130 | + |
| 131 | + |
| 132 | +def main(argv: list[str]) -> int: |
| 133 | + mappings = load_mappings() |
| 134 | + problems = check(mappings) |
| 135 | + for problem in problems: |
| 136 | + print(problem, file=sys.stderr) |
| 137 | + if problems: |
| 138 | + return 1 |
| 139 | + if "--check" not in argv: |
| 140 | + REPORT.write_text(report(mappings), encoding="utf-8", newline="\n") |
| 141 | + print(f"wrote {REPORT.relative_to(ROOT)}") |
| 142 | + return 0 |
| 143 | + |
| 144 | + |
| 145 | +if __name__ == "__main__": |
| 146 | + sys.exit(main(sys.argv[1:])) |
0 commit comments