"""Cross-check STRATZ web matchup tops against OpenDota hero matchups. Web-only observation evidence — never merge into relations.json or recommend. Statuses: agree — OpenDota baseline-adjusted advantage agrees with STRATZ direction conflict — OpenDota disagrees with STRATZ direction (enough games) weak — OpenDota sample too small or advantage near zero """ from __future__ import annotations from typing import Any # Defaults aligned with shared/audit_relations.py thresholds. DEFAULT_MIN_GAMES = 80 DEFAULT_ODOTA_AGREE = 0.015 DEFAULT_ODOTA_DISAGREE = -0.015 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 classify_cross_source( *, stratz_signed: float, odota_adv_val: float | None, odota_games: int, min_games: int = DEFAULT_MIN_GAMES, odota_agree: float = DEFAULT_ODOTA_AGREE, odota_disagree: float = DEFAULT_ODOTA_DISAGREE, ) -> str: """Classify whether OpenDota agrees with a STRATZ signed advantage. ``stratz_signed`` > 0 means STRATZ says A is favored vs B (counters). For countered rows the caller should pass the original vs advantage (positive = A still favored), not the negated display value. """ if odota_adv_val is None or odota_games < min_games: return "weak" if stratz_signed >= 0: if odota_adv_val >= odota_agree: return "agree" if odota_adv_val <= odota_disagree: return "conflict" return "weak" if odota_adv_val <= -odota_agree: return "agree" if odota_adv_val >= -odota_disagree: return "conflict" return "weak" def enrich_entry_cross( entry: dict, *, hero_id: int, kind: str, by_odota: dict, baseline: dict[int, float], min_games: int = DEFAULT_MIN_GAMES, ) -> dict: """Attach ``cross`` quality blob to one counters/countered/synergies row. For ``countered`` rows, STRATZ stores negated advantage for display; we restore the original vs-sign for classification (``-advantage``). Synergies skip OpenDota (no teammate WR in matchups.json). """ out = dict(entry) peer = int(entry.get("hero_id") or 0) if kind == "synergies" or peer <= 0 or hero_id <= 0: out["cross"] = {"status": "weak", "reason": "no_odota_synergy"} return out o_adv, o_games = odota_adv(by_odota, baseline, hero_id, peer) raw_adv = float(entry.get("advantage") or 0.0) # countered display advantage is already negated; restore original vs sign. stratz_signed = -raw_adv if kind == "countered" else raw_adv status = classify_cross_source( stratz_signed=stratz_signed, odota_adv_val=o_adv, odota_games=o_games, min_games=min_games, ) out["cross"] = { "status": status, "opendota_adv": None if o_adv is None else round(o_adv, 4), "opendota_games": o_games, "min_games": min_games, } return out def enrich_hero_matchups_cross( cell: dict, *, hero_id: int, by_odota: dict, baseline: dict[int, float], min_games: int = DEFAULT_MIN_GAMES, ) -> dict: """Return a shallow-copied hero matchup cell with per-row ``cross`` fields.""" out = { "counters": [ enrich_entry_cross( e, hero_id=hero_id, kind="counters", by_odota=by_odota, baseline=baseline, min_games=min_games, ) for e in (cell.get("counters") or []) if isinstance(e, dict) ], "countered": [ enrich_entry_cross( e, hero_id=hero_id, kind="countered", by_odota=by_odota, baseline=baseline, min_games=min_games, ) for e in (cell.get("countered") or []) if isinstance(e, dict) ], "synergies": [ enrich_entry_cross( e, hero_id=hero_id, kind="synergies", by_odota=by_odota, baseline=baseline, min_games=min_games, ) for e in (cell.get("synergies") or []) if isinstance(e, dict) ], } for k in ("fetched_at", "stale"): if k in cell: out[k] = cell[k] return out def summarize_cross_rows(rows: list[dict]) -> dict[str, int]: counts = {"agree": 0, "conflict": 0, "weak": 0} for e in rows: status = ((e.get("cross") or {}).get("status")) or "weak" if status not in counts: status = "weak" counts[status] += 1 return counts def audit_matchup_tops( tops: dict, odota: dict, *, id_to_key: dict[int, str], key_to_id: dict[str, int], names: dict[str, str], min_games: int = DEFAULT_MIN_GAMES, ) -> dict[str, Any]: """Audit STRATZ web tops vs OpenDota; return report section (no file IO).""" by_odota = odota.get("by_hero") or {} baseline = build_baseline(by_odota) by_hero = tops.get("by_hero") or {} pairs: list[dict] = [] summary = { "heroes": 0, "counter_rows": 0, "agree": 0, "conflict": 0, "weak": 0, } for key, cell in sorted(by_hero.items()): if not isinstance(cell, dict): continue hid = key_to_id.get(key) if hid is None: # Prefer embedded id when key map lags new heroes. hid = int(cell.get("id") or 0) or None if hid is None: continue summary["heroes"] += 1 enriched = enrich_hero_matchups_cross( cell, hero_id=hid, by_odota=by_odota, baseline=baseline, min_games=min_games, ) for kind in ("counters", "countered"): for e in enriched.get(kind) or []: summary["counter_rows"] += 1 cross = e.get("cross") or {} status = cross.get("status") or "weak" summary[status] = summary.get(status, 0) + 1 peer_id = int(e.get("hero_id") or 0) peer_key = id_to_key.get(peer_id, str(peer_id)) pairs.append( { "hero": key, "hero_loc": names.get(key, key), "peer": peer_key, "peer_loc": names.get(peer_key, peer_key), "kind": kind, "stratz_advantage": e.get("advantage"), "stratz_wr": e.get("wr"), "stratz_games": e.get("games"), "status": status, "opendota_adv": cross.get("opendota_adv"), "opendota_games": cross.get("opendota_games"), } ) conflicts = [p for p in pairs if p["status"] == "conflict"] conflicts.sort( key=lambda r: ( abs(float(r.get("stratz_advantage") or 0)), -(int(r.get("opendota_games") or 0)), ), reverse=True, ) agrees = [p for p in pairs if p["status"] == "agree"] agrees.sort( key=lambda r: ( abs(float(r.get("stratz_advantage") or 0)), -(int(r.get("opendota_games") or 0)), ), reverse=True, ) return { "summary": summary, "thresholds": {"min_games": min_games}, "manual_review_note": ( "Dota2ProTracker (7k+ MMR / pro) is a manual high-MMR reference for " "conflict rows; not automated (login-gated)." ), "conflicts": conflicts[:100], "agrees_sample": agrees[:40], }