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>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
"""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],
|
||||
}
|
||||
Reference in New Issue
Block a user