"""Audit qualitative relations.json against OpenDota + STRATZ. Sources: - data/matchups.json OpenDota vs winrates - data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups) - data/synergies.json STRATZ teammate synergies Usage: python audit_relations.py python audit_relations.py --min-games 80 --out data/relations_audit.json """ from __future__ import annotations import argparse import json from datetime import datetime, timezone from pathlib import Path from common import ROOT from grid import hero_table from relations import DEFAULT_RELATIONS, load_relations MATCHUPS = ROOT / "data" / "matchups.json" STRATZ_MATCHUPS = ROOT / "data" / "stratz_matchups.json" SYNERGIES = ROOT / "data" / "synergies.json" 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 _pair_key(a: str, b: str) -> tuple[str, str]: return (a, b) if a <= b else (b, a) 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 matchup_wr(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) wins = int(cell.get("wins") or 0) if games <= 0: return None, 0 return wins / games, games def build_baseline(by_hero: dict) -> dict[int, float]: out: dict[int, float] = {} for hid_s, opps in by_hero.items(): tw = tg = 0 for cell in opps.values(): g = int(cell.get("games") or 0) w = int(cell.get("wins") or 0) tw += w tg += g if tg > 0: out[int(hid_s)] = tw / tg return out def odota_adv(by_hero: dict, baseline: dict[int, float], aid: int, bid: int) -> tuple[float | None, int]: wr, games = matchup_wr(by_hero, aid, bid) if wr is None: return None, 0 base = baseline.get(aid) if base is None: return None, games return wr - base, games 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("--odota-agree", type=float, default=0.015) ap.add_argument("--odota-disagree", type=float, default=-0.015) 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("--top", type=int, default=10) ap.add_argument("--out", type=Path, default=ROOT / "data" / "relations_audit.json") args = ap.parse_args() 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() odota = _load_json_safe(MATCHUPS) stratz = _load_json_safe(STRATZ_MATCHUPS) syn_file = _load_json_safe(SYNERGIES) by_o = odota.get("by_hero") or {} by_s = stratz.get("by_hero") or {} by_syn = syn_file.get("by_hero") or {} baseline = build_baseline(by_o) 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} known_syn = {_pair_key(a, b) for a, b, _ in syns} 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 o_adv, o_games = odota_adv(by_o, baseline, aid, bid) s_adv, s_games = stratz_adv(by_s, aid, bid) r_o, r_og = odota_adv(by_o, baseline, bid, aid) 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, "opendota_adv": None if o_adv is None else round(o_adv, 4), "opendota_games": o_games, "stratz_adv": None if s_adv is None else round(s_adv, 3), "stratz_games": s_games, "reverse_opendota_adv": None if r_o is None else round(r_o, 4), "reverse_stratz_adv": None if r_s is None else round(r_s, 3), } o_ok = o_adv is not None and o_games >= args.min_games and o_adv >= args.odota_agree o_bad = o_adv is not None and o_games >= args.min_games and o_adv <= args.odota_disagree 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 o_adv is None and s_adv is None: counter_no_data.append({**row, "why": "missing_both"}) elif (o_games < args.min_games and (s_games < args.min_games or s_adv is None)): counter_no_data.append({**row, "why": "low_games"}) elif o_ok or s_ok: sources = [] if o_ok: sources.append("opendota") if s_ok: sources.append("stratz") counter_ok.append({**row, "agree_sources": sources}) elif o_bad or s_bad: tip = "conflict" if ( (r_o is not None and r_og >= args.min_games and r_o >= args.odota_agree) or (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"}) # Cross-source suggestions: both OpenDota and STRATZ agree A counters B, missing from relations 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] o_adv, o_games = odota_adv(by_o, baseline, aid, bid) s_adv, s_games = stratz_adv(by_s, aid, bid) if ( o_adv is not None and o_games >= args.min_games and o_adv >= 0.03 and s_adv is not None and s_games >= args.min_games and s_adv >= 2.0 ): suggestions.append({ "a": akey, "a_loc": names[akey], "b": bkey, "b_loc": names[bkey], "opendota_adv": round(o_adv, 4), "opendota_games": o_games, "stratz_adv": round(s_adv, 3), "stratz_games": s_games, }) suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["opendota_adv"])) suggestions = suggestions[:100] # Per-hero coverage in qualitative relations 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 = { "opendota_matchups": [ f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_o ], "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 ], } # Qualitative gaps: no counter-out, no counter-in, no synergy 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 ] # Heroes with strong cross-source suggestions but empty/thin qualitative coverage 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], "cross_source_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["cross_source_suggestions"], r["key"]), ) report = { "generated_at": datetime.now(timezone.utc).isoformat(), "sources": { "relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"), "opendota_matchups": { "path": str(MATCHUPS.relative_to(ROOT)).replace("\\", "/") if MATCHUPS.is_file() else None, "source": odota.get("source"), "fetched_at": odota.get("fetched_at"), "heroes": len(by_o), }, "stratz_matchups": { "path": str(STRATZ_MATCHUPS.relative_to(ROOT)).replace("\\", "/") if STRATZ_MATCHUPS.is_file() else None, "source": stratz.get("source"), "fetched_at": stratz.get("fetched_at"), "heroes": len(by_s), }, "stratz_synergies": { "path": str(SYNERGIES.relative_to(ROOT)).replace("\\", "/") if SYNERGIES.is_file() else None, "source": syn_file.get("source"), "fetched_at": syn_file.get("fetched_at"), "heroes": len(by_syn), }, }, "thresholds": { "min_games": args.min_games, "odota_agree": args.odota_agree, "odota_disagree": args.odota_disagree, "stratz_agree": args.stratz_agree, "stratz_disagree": args.stratz_disagree, "syn_agree": args.syn_agree, }, "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), (r.get("opendota_adv") or 0)), ), "counter_agree_both": [r for r in counter_ok if set(r.get("agree_sources") or []) == {"opendota", "stratz"}], "suggest_add_counters_cross_source": 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']} (both={len(report['counter_agree_both'])}) " f"conflict={s['counter_conflict']} weak={s['counter_weak']} no_data={s['counter_no_data']} " f"/ 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']} thin(<={1} edge)={s['heroes_thin_relations']} " f"/ 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['cross_source_suggestions']}" ) print(f"\nwrote {args.out}") if __name__ == "__main__": main()