Files
climperor/shared/audit_relations.py
T
vosonandCursor d2cfcd7461 v0.5.71: matches tab, streamer viewport video load, matchup cross-check.
Add pro watchlist matches page; load streamer clips by viewport tier with posters; harden STRATZ matchup refresh and OpenDota cross hints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:03:49 +08:00

459 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Audit qualitative relations.json against OpenDota + STRATZ.
Sources:
- shared/data/matchups.json OpenDota vs winrates
- shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
- shared/data/synergies.json STRATZ teammate synergies
- web/data/stratz_matchup_tops.json Web 对位 Top (optional cross-check section)
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.matchup_cross import audit_matchup_tops
from shared.paths import DATA, ROOT, SHARED_DATA
from shared.relations import DEFAULT_RELATIONS, load_relations
MATCHUPS = SHARED_DATA / "matchups.json"
STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json"
SYNERGIES = SHARED_DATA / "synergies.json"
STRATZ_MATCHUP_TOPS = DATA / "stratz_matchup_tops.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 _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=SHARED_DATA / "relations_audit.json")
args = ap.parse_args()
matchups_path = _resolve_cache(MATCHUPS, _LEGACY_DATA / "matchups.json")
stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json")
syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json")
tops_path = _resolve_cache(STRATZ_MATCHUP_TOPS, _LEGACY_DATA / "stratz_matchup_tops.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()
odota = _load_json_safe(matchups_path)
stratz = _load_json_safe(stratz_path)
syn_file = _load_json_safe(syn_path)
tops = _load_json_safe(tops_path)
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}
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"})
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]
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
],
}
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],
"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"],
),
)
web_tops_audit = None
if tops.get("by_hero"):
web_tops_audit = audit_matchup_tops(
tops,
odota,
id_to_key=id_to_key,
key_to_id=key_to_id,
names=names,
min_games=args.min_games,
)
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("\\", "/"),
"opendota_matchups": {
"path": _rel(matchups_path),
"source": odota.get("source"),
"fetched_at": odota.get("fetched_at"),
"heroes": len(by_o),
},
"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),
},
"stratz_matchup_tops": {
"path": _rel(tops_path),
"source": tops.get("source"),
"fetched_at": tops.get("fetched_at"),
"heroes": len(tops.get("by_hero") or {}),
},
},
"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,
"web_matchup_tops_cross": web_tops_audit,
}
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']}"
)
if web_tops_audit:
ws = web_tops_audit["summary"]
print(
"web matchup tops × OpenDota: "
f"agree={ws['agree']} conflict={ws['conflict']} weak={ws['weak']} "
f"/ rows={ws['counter_rows']} heroes={ws['heroes']}"
)
print(f" note: {web_tops_audit['manual_review_note']}")
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()