Files
climperor/shared/audit_relations.py
vosonandCursor d6212169af v0.5.109: hero role toolbar, skill side-by-side layout, taller detail panel.
Move role filters between grid and detail, fix skill video/text layout and detail height jump, unify item dividers, drop OpenDota matchup cross, sync docs and assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 00:48:07 +08:00

354 lines
13 KiB
Python

"""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()