"""Audit qualitative relations.json against STRATZ matchup / synergy caches. Sources: - shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups) - shared/data/synergies.json STRATZ teammate synergies Hero matchup numbers for the Web site come only from web/data/stratz_matchup_tops.json (fetch_stratz_meta.py); this audit does not cross-check against OpenDota. Usage: python shared/audit_relations.py python shared/audit_relations.py --min-games 80 --out shared/data/relations_audit.json """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import argparse import json from datetime import datetime, timezone from shared.grid import hero_table from shared.paths import ROOT, SHARED_DATA from shared.relations import DEFAULT_RELATIONS, load_relations STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json" SYNERGIES = SHARED_DATA / "synergies.json" # Legacy monorepo-move leftovers (repo-root data/). _LEGACY_DATA = ROOT / "data" def _resolve_cache(*candidates: Path) -> Path: for p in candidates: if p.is_file(): return p return candidates[0] def _load_json_safe(path: Path) -> dict: """Read a JSON file, returning {} on missing/corrupt data.""" if not path.is_file(): return {} try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]: table = hero_table() id_to_key = {int(h["id"]): h["key"] for h in table} key_to_id = {h["key"]: int(h["id"]) for h in table} names = {h["key"]: h.get("name_loc") or h["key"] for h in table} return id_to_key, key_to_id, names def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]: cell = (by_hero.get(str(aid)) or {}).get(str(bid)) if not cell: return None, 0 games = int(cell.get("games") or 0) if games <= 0: return None, 0 return float(cell.get("advantage") or 0.0), games def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--min-games", type=int, default=80) ap.add_argument("--stratz-agree", type=float, default=1.0) ap.add_argument("--stratz-disagree", type=float, default=-1.0) ap.add_argument("--syn-agree", type=float, default=1.0) ap.add_argument("--suggest-min-adv", type=float, default=2.0) ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json") args = ap.parse_args() stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json") syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json") _id_to_key, key_to_id, names = load_id_maps() all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k]) rel = load_relations() stratz = _load_json_safe(stratz_path) syn_file = _load_json_safe(syn_path) by_s = stratz.get("by_hero") or {} by_syn = syn_file.get("by_hero") or {} counters = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("counters") or []] syns = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("synergies") or []] known_counters = {(a, b) for a, b, _ in counters} counter_ok, counter_conflict, counter_weak, counter_no_data = [], [], [], [] for a, b, reason in counters: aid, bid = key_to_id.get(a), key_to_id.get(b) if aid is None or bid is None: counter_no_data.append({"a": a, "b": b, "why": "unknown_hero"}) continue s_adv, s_games = stratz_adv(by_s, aid, bid) r_s, r_sg = stratz_adv(by_s, bid, aid) row = { "a": a, "a_loc": names.get(a, a), "b": b, "b_loc": names.get(b, b), "reason": reason, "stratz_adv": None if s_adv is None else round(s_adv, 3), "stratz_games": s_games, "reverse_stratz_adv": None if r_s is None else round(r_s, 3), } s_ok = s_adv is not None and s_games >= args.min_games and s_adv >= args.stratz_agree s_bad = ( s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree ) if s_adv is None: counter_no_data.append({**row, "why": "missing_stratz"}) elif s_games < args.min_games: counter_no_data.append({**row, "why": "low_games"}) elif s_ok: counter_ok.append({**row, "agree_sources": ["stratz"]}) elif s_bad: tip = "conflict" if r_s is not None and r_sg >= args.min_games and r_s >= args.stratz_agree: tip = "maybe_reverse" counter_conflict.append({**row, "tip": tip}) else: counter_weak.append(row) syn_ok, syn_conflict, syn_missing = [], [], [] for a, b, reason in syns: aid, bid = key_to_id.get(a), key_to_id.get(b) if aid is None or bid is None: syn_missing.append({"a": a, "b": b, "why": "unknown_hero"}) continue cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or ( by_syn.get(str(bid)) or {} ).get(str(aid)) if not cell: syn_missing.append( { "a": a, "b": b, "a_loc": names.get(a), "b_loc": names.get(b), "why": "no_stratz", } ) continue score = float(cell.get("synergy") or 0) games = int(cell.get("games") or 0) row = { "a": a, "a_loc": names.get(a, a), "b": b, "b_loc": names.get(b, b), "reason": reason, "stratz_synergy": round(score, 3), "stratz_games": games, } if games < args.min_games: syn_missing.append({**row, "why": "low_games"}) elif score >= args.syn_agree: syn_ok.append(row) elif score <= -args.syn_agree: syn_conflict.append(row) else: syn_missing.append({**row, "why": "weak_synergy"}) suggestions: list[dict] = [] for akey in all_keys: aid = key_to_id[akey] for bkey in all_keys: if akey == bkey or (akey, bkey) in known_counters: continue bid = key_to_id[bkey] s_adv, s_games = stratz_adv(by_s, aid, bid) if ( s_adv is not None and s_games >= args.min_games and s_adv >= args.suggest_min_adv ): suggestions.append( { "a": akey, "a_loc": names[akey], "b": bkey, "b_loc": names[bkey], "stratz_adv": round(s_adv, 3), "stratz_games": s_games, } ) suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["stratz_games"])) suggestions = suggestions[:100] counters_from: dict[str, int] = {k: 0 for k in all_keys} counters_to: dict[str, int] = {k: 0 for k in all_keys} syn_count: dict[str, int] = {k: 0 for k in all_keys} for a, b, _ in counters: if a in counters_from: counters_from[a] += 1 if b in counters_to: counters_to[b] += 1 for a, b, _ in syns: if a in syn_count: syn_count[a] += 1 if b in syn_count: syn_count[b] += 1 missing_cache = { "stratz_matchups": [ f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_s ], "stratz_synergies": [ f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_syn ], } no_counter_out = [k for k in all_keys if counters_from[k] == 0] no_counter_in = [k for k in all_keys if counters_to[k] == 0] no_synergy = [k for k in all_keys if syn_count[k] == 0] thin = [ k for k in all_keys if counters_from[k] + counters_to[k] + syn_count[k] <= 1 ] empty = [ k for k in all_keys if counters_from[k] == 0 and counters_to[k] == 0 and syn_count[k] == 0 ] suggest_by_hero: dict[str, int] = {} for row in suggestions: suggest_by_hero[row["a"]] = suggest_by_hero.get(row["a"], 0) + 1 needs_fill = sorted( [ { "key": k, "name_loc": names[k], "counters_out": counters_from[k], "counters_in": counters_to[k], "synergies": syn_count[k], "stratz_suggestions": suggest_by_hero.get(k, 0), } for k in empty + [x for x in thin if x not in empty] ], key=lambda r: ( r["counters_out"] + r["counters_in"] + r["synergies"], -r["stratz_suggestions"], r["key"], ), ) def _rel(path: Path) -> str | None: if not path.is_file(): return None try: return str(path.relative_to(ROOT)).replace("\\", "/") except ValueError: return str(path).replace("\\", "/") report = { "generated_at": datetime.now(timezone.utc).isoformat(), "sources": { "relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"), "stratz_matchups": { "path": _rel(stratz_path), "source": stratz.get("source"), "fetched_at": stratz.get("fetched_at"), "heroes": len(by_s), }, "stratz_synergies": { "path": _rel(syn_path), "source": syn_file.get("source"), "fetched_at": syn_file.get("fetched_at"), "heroes": len(by_syn), }, }, "thresholds": { "min_games": args.min_games, "stratz_agree": args.stratz_agree, "stratz_disagree": args.stratz_disagree, "syn_agree": args.syn_agree, "suggest_min_adv": args.suggest_min_adv, }, "summary": { "relations_counters": len(counters), "relations_synergies": len(syns), "counter_agree": len(counter_ok), "counter_conflict": len(counter_conflict), "counter_weak": len(counter_weak), "counter_no_data": len(counter_no_data), "synergy_agree": len(syn_ok), "synergy_conflict": len(syn_conflict), "synergy_weak_or_missing": len(syn_missing), "heroes_total": len(all_keys), "heroes_empty_relations": len(empty), "heroes_thin_relations": len(thin), }, "missing_cache": missing_cache, "missing_qualitative": { "empty": [{"key": k, "name_loc": names[k]} for k in empty], "no_counters_out": [{"key": k, "name_loc": names[k]} for k in no_counter_out], "no_counters_in": [{"key": k, "name_loc": names[k]} for k in no_counter_in], "no_synergy": [{"key": k, "name_loc": names[k]} for k in no_synergy], "priority_fill": needs_fill, }, "counter_conflicts": sorted( counter_conflict, key=lambda r: (r.get("stratz_adv") or 0), ), "counter_agree": counter_ok, "suggest_add_counters": suggestions, "synergy_conflicts": syn_conflict, } args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text( json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) s = report["summary"] print("=== relations audit ===") print( f"counters: agree={s['counter_agree']} " f"conflict={s['counter_conflict']} weak={s['counter_weak']} " f"no_data={s['counter_no_data']} / total={s['relations_counters']}" ) print( f"synergies: agree={s['synergy_agree']} conflict={s['synergy_conflict']} " f"weak/missing={s['synergy_weak_or_missing']} / total={s['relations_synergies']}" ) print( f"qualitative gaps: empty={s['heroes_empty_relations']} " f"thin(<={1} edge)={s['heroes_thin_relations']} / heroes={s['heroes_total']}" ) print("cache gaps:") for k, v in missing_cache.items(): print(f" {k}: {len(v)}") print("\nPriority heroes with no qualitative edges:") for row in needs_fill[:40]: if row["counters_out"] + row["counters_in"] + row["synergies"] == 0: print( f" {row['name_loc']}({row['key']}) " f"suggest={row['stratz_suggestions']}" ) print(f"\nwrote {args.out}") if __name__ == "__main__": main()