megadicing / reporting /stats.py
gloygum
Initial megadicing space
35465a0
Raw
History Blame Contribute Delete
67.7 kB
import math
import numpy as np
import matplotlib.pyplot as plt
from collections import defaultdict
from matplotlib.transforms import blended_transform_factory as _blended_xform
from reporting.team_colours import TEAM0_MPL, TEAM1_MPL
import modelling.tails as tails
from modelling.math_utils import natural_log
from modelling.modeled_rows import compute_v1_team_surprise
from reporting.report_model import collect_modeled_report_rows
from modelling.row_schema import CumulativeSeriesPayload, UsableSurpriseRow
from modelling.roll_events import collect_roll_events_compat
from core.turn_allocation_adjustments import build_game_turn_map
def extract_gamer_names(json_data):
replay = json_data.get("Replay", {}) if isinstance(json_data, dict) else {}
# Primary source in many BB3 replays.
game_infos = replay.get("NotificationGameJoined", {}).get("GameInfos", {}) if isinstance(replay, dict) else {}
gamers_infos = game_infos.get("GamersInfos", {}) if isinstance(game_infos, dict) else {}
gamer_infos = gamers_infos.get("GamerInfos", []) if isinstance(gamers_infos, dict) else []
if isinstance(gamer_infos, dict):
gamer_infos = [gamer_infos]
names_by_slot = {}
for gamer in gamer_infos:
if not isinstance(gamer, dict):
continue
slot = gamer.get("Slot")
name = gamer.get("Name")
if slot is None or name in (None, ""):
continue
try:
slot_int = int(str(slot))
except (TypeError, ValueError):
continue
names_by_slot[slot_int] = str(name)
if 0 in names_by_slot and 1 in names_by_slot:
return names_by_slot[0], names_by_slot[1]
# Fallback source.
rosters = replay.get("Rosters", {}) if isinstance(replay, dict) else {}
team_rosters = rosters.get("TeamRoster", []) if isinstance(rosters, dict) else []
if isinstance(team_rosters, dict):
team_rosters = [team_rosters]
names = []
for idx, team_roster in enumerate(team_rosters):
default_name = f"Team{idx}"
if not isinstance(team_roster, dict):
names.append(default_name)
continue
team = team_roster.get("Team", {})
if not isinstance(team, dict):
names.append(default_name)
continue
gamer_name = team.get("GamerName") or team.get("CoachName") or team.get("Name")
names.append(str(gamer_name) if gamer_name else default_name)
while len(names) < 2:
names.append(f"Team{len(names)}")
return names[0], names[1]
def format_one_in_games(p_tail):
if not isinstance(p_tail, (int, float)):
return "N/A"
if p_tail <= 0:
return "infinite"
value = 1.0 / p_tail
if value >= 1000:
return f"{value:,.0f}"
if value >= 100:
return f"{value:.1f}"
return f"{value:.2f}"
def _display_team_name(name, team_index, team_meta=None):
base = str(name).strip() if name is not None else ""
if team_meta is not None:
try:
team_label = team_meta.team_label(str(team_index))
if team_label not in (None, ""):
base = str(team_label)
except Exception:
pass
if base == "":
base = f"Team{team_index}"
suffix = f"(team {team_index})"
if suffix.lower() in base.lower():
return base
return f"{base} {suffix}"
def _extend_prefix_vector(prefix: np.ndarray, chunk: np.ndarray) -> np.ndarray:
if chunk.size == 0:
return prefix
if prefix.size == 0:
return chunk
return np.concatenate((prefix, chunk))
def _to_turn_int(value):
try:
return int(str(value))
except (TypeError, ValueError):
return None
def _collect_touchdown_markers(game_state):
"""Return touchdown markers with turn and scoring team attribution.
Turn numbers are derived from the step-turn counter (via the step_number
lookup) so they stay consistent with the dice-roll rows used by the
cumulative surprise series.
"""
# Build step-turn map once for the whole game.
tt_map = build_game_turn_map(getattr(game_state, "raw_replay_steps", []))
def _step_turn(step_number, fallback_game_turn):
"""Return step-counter game-turn int for a step, falling back to board-state turn."""
if step_number is not None and tt_map:
tt_str = tt_map.get(step_number)
if tt_str is not None:
turn = _to_turn_int(tt_str)
if turn is not None:
return turn
return _to_turn_int(fallback_game_turn)
markers = []
touchdown_events = getattr(game_state, "touchdown_events", None)
if isinstance(touchdown_events, list) and touchdown_events:
for touchdown in touchdown_events:
if not isinstance(touchdown, dict):
continue
step_number = touchdown.get("step_number")
turn_int = _step_turn(step_number, touchdown.get("game_turn"))
if turn_int is None:
continue
team_value = touchdown.get("team_id")
try:
team_id = int(str(team_value)) if team_value is not None else None
except (TypeError, ValueError):
team_id = None
if team_id not in (0, 1):
team_id = None
markers.append(
{
"turn": turn_int,
"team_id": team_id,
"step_number": step_number,
"player_name": touchdown.get("player_name"),
}
)
else:
for step in getattr(game_state, "steps", []) or []:
touchdown = getattr(step, "touchdown", None)
if not isinstance(touchdown, dict):
continue
step_number = getattr(step, "step_number", None)
turn_int = _step_turn(step_number, getattr(step, "game_turn", None))
if turn_int is None:
continue
team_value = touchdown.get("team_id")
try:
team_id = int(str(team_value)) if team_value is not None else None
except (TypeError, ValueError):
team_id = None
if team_id not in (0, 1):
team_id = None
markers.append(
{
"turn": turn_int,
"team_id": team_id,
"step_number": step_number,
"player_name": touchdown.get("player_name"),
}
)
markers.sort(
key=lambda item: (
int(item.get("turn", 0)),
int(item.get("step_number", 0)) if str(item.get("step_number", "")).isdigit() else 0,
)
)
return markers
def _collect_usable_surprise_rows(game_state, team_meta=None, calculator=None) -> tuple[list[UsableSurpriseRow], list[dict]]:
"""Collect normalized surprise rows used by megadicing and plotting."""
modeled_payload = collect_modeled_report_rows(
game_state,
team_meta=team_meta,
calculator=calculator,
target_turn=None,
)
events = list(modeled_payload.get("rows") or [])
kickoff_events = list(modeled_payload.get("kickoff_rows") or [])
usable_rows = []
for event in events:
if not isinstance(event, dict):
continue
team0_surprise, team1_surprise = compute_v1_team_surprise(event)
if team0_surprise is None and team1_surprise is None:
continue
turn_int = _to_turn_int(event.get("game_turn"))
if turn_int is None:
continue
team_id_raw = event.get("dice_roller")
if team_id_raw is None:
team_id_raw = event.get("team_id")
try:
team_id = int(str(team_id_raw))
except (TypeError, ValueError):
continue
if team_id not in (0, 1):
continue
p_success = event.get("probability_success")
p_fail = event.get("probability_fail")
if not isinstance(p_success, (int, float)) or not isinstance(p_fail, (int, float)):
continue
surprise = float(team0_surprise if team_id == 0 else team1_surprise)
usable_rows.append(
{
"turn": turn_int,
"team_id": team_id,
"surprise": surprise,
"p_success": float(p_success),
"p_fail": float(p_fail),
}
)
return usable_rows, kickoff_events
def _infer_cumulative_series_max_turn(usable_rows, touchdown_markers, fallback=16):
"""Infer the last turn worth rendering for cumulative surprise outputs."""
candidate_turns = []
for row in usable_rows:
if not isinstance(row, dict):
continue
turn_int = _to_turn_int(row.get("turn"))
if turn_int is not None and turn_int > 0:
candidate_turns.append(turn_int)
for marker in touchdown_markers:
if not isinstance(marker, dict):
continue
turn_int = _to_turn_int(marker.get("turn"))
if turn_int is not None and turn_int > 0:
candidate_turns.append(turn_int)
if candidate_turns:
return max(candidate_turns)
return int(fallback)
def _lr_quantile_centred(p_target, pvec, qvec, right_hint=None, left_hint=None):
"""Return (right_centred, left_centred) LR quantile thresholds in centred space.
right_centred > 0: P(centred_score >= right_centred) ≈ p_target
left_centred < 0: P(centred_score <= left_centred) ≈ p_target
Returns (0.0, 0.0) on any failure or empty input.
"""
if len(pvec) == 0:
return 0.0, 0.0
try:
k1 = float(tails.K1(0, pvec, qvec))
right_raw_hint = None if right_hint is None else float(k1 + right_hint)
left_raw_hint = None if left_hint is None else float(k1 + left_hint)
right, left = tails.LR_quantile_pair(
p_target,
pvec,
qvec,
_mu=k1,
right_hint=right_raw_hint,
left_hint=left_raw_hint,
)
return float(right - k1), float(left - k1)
except Exception:
return 0.0, 0.0
def _compute_team_prefix_stats(rows):
"""Compute score, p/q vectors, K1 mean, and centred score for one team's rows."""
score = float(sum(row["surprise"] for row in rows))
pvec = np.array([row["p_success"] for row in rows], dtype=float)
qvec = np.array([row["p_fail"] for row in rows], dtype=float)
k1 = float(tails.K1(0, pvec, qvec)) if len(rows) > 0 else 0.0
std = float(np.sqrt(tails.K2(0, pvec, qvec))) if len(rows) > 0 else 0.0
centred = float(score - k1)
return {
"score": score,
"pvec": pvec,
"qvec": qvec,
"k1": k1,
"std": std,
"centred": centred,
"count": len(rows),
}
def build_cumulative_surprise_series_v1(
game_state,
team_meta=None,
calculator=None,
max_turn=None,
include_extra_bands: bool = True,
) -> CumulativeSeriesPayload:
"""Build cumulative V1 surprise series through each turn prefix.
Definitions per team i and prefix turn n:
- score_i_n: sum(log(1/p_success)) on success and log(p_fail) on fail
- pvec_i_n: all p_success for team i up to turn n
- qvec_i_n: all p_fail for team i up to turn n
- centred_i_n: score_i_n - K1(0, pvec_i_n, qvec_i_n)
Delta series:
- delta_centred_n = centred_0_n - centred_1_n
"""
usable_rows, kickoff_events = _collect_usable_surprise_rows(
game_state,
team_meta=team_meta,
calculator=calculator,
)
touchdown_markers = _collect_touchdown_markers(game_state)
touchdown_turns = sorted(set(int(marker["turn"]) for marker in touchdown_markers))
if max_turn is None:
max_turn = _infer_cumulative_series_max_turn(usable_rows, touchdown_markers)
else:
max_turn = int(max_turn)
# Pre-compute per-event K1 and K2 contributions at t=0 for all events at once
# using vectorized numpy. K1 and K2 at t=0 are additive (separable per event),
# so prefix cumulants can be accumulated in O(1) per turn instead of O(N).
if usable_rows:
_all_p = np.array([r["p_success"] for r in usable_rows], dtype=float)
_all_q = np.array([r["p_fail"] for r in usable_rows], dtype=float)
_lp = np.zeros_like(_all_p)
_lq = np.zeros_like(_all_q)
_pmask = _all_p > 0
_qmask = _all_q > 0
_lp[_pmask] = np.log(_all_p[_pmask])
_lq[_qmask] = np.log(_all_q[_qmask])
_k1c = -_all_p * _lp + _all_q * _lq
_k2c = (_all_p * _lp**2 + _all_q * _lq**2) - _k1c**2
for _i, _row in enumerate(usable_rows):
_row["_k1c"] = float(_k1c[_i])
_row["_k2c"] = float(_k2c[_i])
# Pre-group rows by turn so each turn's iteration is O(1) lookup rather
# than an O(N) scan over all usable_rows.
_rows_grouped = defaultdict(list)
for row in usable_rows:
_rows_grouped[row["turn"]].append(row)
# Running accumulators for prefix K1/K2 (avoids O(N) recompute each turn).
# k1_delta = k1_0_acc - k1_1_acc (K1 under p↔q swap flips sign)
# k2_delta = k2_0_acc + k2_1_acc (K2 is symmetric under p↔q swap)
k1_0_acc = 0.0
k2_0_acc = 0.0
k1_1_acc = 0.0
k2_1_acc = 0.0
score0_acc = 0.0
score1_acc = 0.0
team0_total = sum(1 for row in usable_rows if row.get("team_id") == 0)
team1_total = sum(1 for row in usable_rows if row.get("team_id") == 1)
pvec0_buf = np.empty(team0_total, dtype=float)
qvec0_buf = np.empty(team0_total, dtype=float)
pvec1_buf = np.empty(team1_total, dtype=float)
qvec1_buf = np.empty(team1_total, dtype=float)
p_delta_buf = np.empty(team0_total + team1_total, dtype=float)
q_delta_buf = np.empty(team0_total + team1_total, dtype=float)
idx0 = 0
idx1 = 0
prev_diff_q10_r = prev_diff_q10_l = None
prev_diff_q01_r = prev_diff_q01_l = None
prev_diff_q001_r = prev_diff_q001_l = None
prev_t0_q10_r_n = prev_t0_q10_l_n = None
prev_t1_q10_r_n = prev_t1_q10_l_n = None
rows_by_turn = {}
for n in range(1, max_turn + 1):
current_rows = _rows_grouped.get(n, [])
current_score0 = 0.0
current_score1 = 0.0
current_k1_0 = 0.0
current_k1_1 = 0.0
current_k2_0 = 0.0
current_k2_1 = 0.0
pvec_c0_list = []
qvec_c0_list = []
pvec_c1_list = []
qvec_c1_list = []
# Extend prefix accumulators with this turn's events.
for row in current_rows:
if row["team_id"] == 0:
current_score0 += row["surprise"]
current_k1_0 += row["_k1c"]
current_k2_0 += row["_k2c"]
p = row["p_success"]
q = row["p_fail"]
pvec_c0_list.append(p)
qvec_c0_list.append(q)
else:
current_score1 += row["surprise"]
current_k1_1 += row["_k1c"]
current_k2_1 += row["_k2c"]
p = row["p_success"]
q = row["p_fail"]
pvec_c1_list.append(p)
qvec_c1_list.append(q)
score0_acc += current_score0
score1_acc += current_score1
k1_0_acc += current_k1_0
k1_1_acc += current_k1_1
k2_0_acc += current_k2_0
k2_1_acc += current_k2_1
score0 = score0_acc
score1 = score1_acc
centred0 = score0_acc - k1_0_acc
centred1 = score1_acc - k1_1_acc
std0 = math.sqrt(max(k2_0_acc, 0.0))
std1 = math.sqrt(max(k2_1_acc, 0.0))
current_std0 = math.sqrt(max(current_k2_0, 0.0))
current_std1 = math.sqrt(max(current_k2_1, 0.0))
current_centred0 = float(current_score0 - current_k1_0)
current_centred1 = float(current_score1 - current_k1_1)
pvec_c0 = np.array(pvec_c0_list, dtype=float) if pvec_c0_list else np.array([], dtype=float)
qvec_c0 = np.array(qvec_c0_list, dtype=float) if qvec_c0_list else np.array([], dtype=float)
pvec_c1 = np.array(pvec_c1_list, dtype=float) if pvec_c1_list else np.array([], dtype=float)
qvec_c1 = np.array(qvec_c1_list, dtype=float) if qvec_c1_list else np.array([], dtype=float)
c0_len = int(pvec_c0.size)
c1_len = int(pvec_c1.size)
if c0_len:
pvec0_buf[idx0: idx0 + c0_len] = pvec_c0
qvec0_buf[idx0: idx0 + c0_len] = qvec_c0
idx0 += c0_len
if c1_len:
pvec1_buf[idx1: idx1 + c1_len] = pvec_c1
qvec1_buf[idx1: idx1 + c1_len] = qvec_c1
idx1 += c1_len
pvec0_acc = pvec0_buf[:idx0]
qvec0_acc = qvec0_buf[:idx0]
pvec1_acc = pvec1_buf[:idx1]
qvec1_acc = qvec1_buf[:idx1]
diff_centred = float(centred0 - centred1)
# Delta p/q vectors still needed for LR_two_sided and quantile band calls.
# k1/k2 for delta follow from linearity (K1 negates under p↔q; K2 is unchanged).
delta_len = idx0 + idx1
if delta_len > 0:
p_delta_buf[:idx0] = pvec0_acc
p_delta_buf[idx0:delta_len] = qvec1_acc
q_delta_buf[:idx0] = qvec0_acc
q_delta_buf[idx0:delta_len] = pvec1_acc
p_delta = p_delta_buf[:delta_len]
q_delta = q_delta_buf[:delta_len]
else:
p_delta = np.array([], dtype=float)
q_delta = np.array([], dtype=float)
k1_delta = k1_0_acc - k1_1_acc
k2_delta = k2_0_acc + k2_1_acc
std_delta = math.sqrt(max(k2_delta, 0.0))
diff_raw = float(score0 - score1)
# Per-turn prefix p-tail.
# Sign: + (right tail) = team0 luckier relative to mean; - (left tail) = team1 luckier.
if p_delta.size > 0 and q_delta.size > 0:
try:
p_tail_n, which_tail_n, abs_err_n = tails.LR_two_sided(diff_raw, p_delta, q_delta)
if which_tail_n == "right":
signed_p_n = float(p_tail_n)
elif which_tail_n == "left":
signed_p_n = -float(p_tail_n)
else:
signed_p_n = None
except Exception:
p_tail_n, which_tail_n, abs_err_n, signed_p_n = None, None, None, None
else:
p_tail_n, which_tail_n, abs_err_n, signed_p_n = None, None, None, None
# LR quantile bands for diff panel (centred space, asymmetric right/left).
# The light path keeps only the 1-in-10 band, which is all the app needs by default.
if p_delta.size > 0:
diff_q10_r, diff_q10_l = _lr_quantile_centred(
0.10,
p_delta,
q_delta,
right_hint=prev_diff_q10_r,
left_hint=prev_diff_q10_l,
)
if include_extra_bands:
diff_q01_r, diff_q01_l = _lr_quantile_centred(
0.01,
p_delta,
q_delta,
right_hint=prev_diff_q01_r,
left_hint=prev_diff_q01_l,
)
diff_q001_r, diff_q001_l = _lr_quantile_centred(
0.001,
p_delta,
q_delta,
right_hint=prev_diff_q001_r,
left_hint=prev_diff_q001_l,
)
else:
diff_q01_r = diff_q01_l = diff_q001_r = diff_q001_l = None
else:
diff_q10_r = diff_q10_l = 0.0
diff_q01_r = diff_q01_l = diff_q001_r = diff_q001_l = None
prev_diff_q10_r, prev_diff_q10_l = diff_q10_r, diff_q10_l
if include_extra_bands:
prev_diff_q01_r, prev_diff_q01_l = diff_q01_r, diff_q01_l
prev_diff_q001_r, prev_diff_q001_l = diff_q001_r, diff_q001_l
# LR quantile bands for per-turn per-team worm plot.
t0_q10_r_turn, t0_q10_l_turn = _lr_quantile_centred(0.10, pvec_c0, qvec_c0)
t1_q10_r_turn, t1_q10_l_turn = _lr_quantile_centred(0.10, pvec_c1, qvec_c1)
# LR quantile bands for cumulative per-team worm plot.
t0_q10_r_n, t0_q10_l_n = _lr_quantile_centred(
0.10,
pvec0_acc,
qvec0_acc,
right_hint=prev_t0_q10_r_n,
left_hint=prev_t0_q10_l_n,
)
t1_q10_r_n, t1_q10_l_n = _lr_quantile_centred(
0.10,
pvec1_acc,
qvec1_acc,
right_hint=prev_t1_q10_r_n,
left_hint=prev_t1_q10_l_n,
)
prev_t0_q10_r_n, prev_t0_q10_l_n = t0_q10_r_n, t0_q10_l_n
prev_t1_q10_r_n, prev_t1_q10_l_n = t1_q10_r_n, t1_q10_l_n
rows_by_turn[n] = {
"turn": n,
"usable_event_count": int(idx0 + idx1),
"usable_event_count_turn": int(pvec_c0.size + pvec_c1.size),
"team0_event_count": int(idx0),
"team1_event_count": int(idx1),
"team0_event_count_turn": int(pvec_c0.size),
"team1_event_count_turn": int(pvec_c1.size),
"score_0_turn": float(current_score0),
"score_1_turn": float(current_score1),
"k1_0_turn": float(current_k1_0),
"k1_1_turn": float(current_k1_1),
"std_0_turn": float(current_std0),
"std_1_turn": float(current_std1),
"team0_centred_turn": float(current_centred0),
"team1_centred_turn": float(current_centred1),
"score_0_n": score0,
"score_1_n": score1,
"k1_0_n": k1_0_acc,
"k1_1_n": k1_1_acc,
"std_0_n": std0,
"std_1_n": std1,
"team0_centred": centred0,
"team1_centred": centred1,
"diff_raw": diff_raw,
"k1_delta_n": k1_delta,
"std_delta_n": std_delta,
"diff_centred": diff_centred,
"p_tail_n": p_tail_n,
"which_tail_n": which_tail_n,
"abs_err_n": abs_err_n,
"signed_p_n": signed_p_n,
"diff_q10_right_n": diff_q10_r,
"diff_q10_left_n": diff_q10_l,
"t0_q10_right_turn": t0_q10_r_turn,
"t0_q10_left_turn": t0_q10_l_turn,
"t1_q10_right_turn": t1_q10_r_turn,
"t1_q10_left_turn": t1_q10_l_turn,
"t0_q10_right_n": t0_q10_r_n,
"t0_q10_left_n": t0_q10_l_n,
"t1_q10_right_n": t1_q10_r_n,
"t1_q10_left_n": t1_q10_l_n,
}
if include_extra_bands:
rows_by_turn[n]["diff_q01_right_n"] = diff_q01_r
rows_by_turn[n]["diff_q01_left_n"] = diff_q01_l
rows_by_turn[n]["diff_q001_right_n"] = diff_q001_r
rows_by_turn[n]["diff_q001_left_n"] = diff_q001_l
final_turn = int(max(rows_by_turn.keys())) if rows_by_turn else 0
final_centred_score = rows_by_turn.get(final_turn, {}).get("diff_centred", 0.0)
return {
"kickoff_events_included": len(kickoff_events),
"touchdown_markers": touchdown_markers,
"touchdown_turns": touchdown_turns,
"usable_rows_total": len(usable_rows),
"final_turn": final_turn,
"final_centred_score": final_centred_score,
"by_turn": rows_by_turn,
}
def build_signed_p_series_v1(series):
"""Extract signed p-value at each turn prefix from a cumulative series result.
Sign convention (matches diff_centred / "Delta centred by turn prefix" plot):
+ (right tail): team0 had more surprising rolls relative to mean
- (left tail): team1 had more surprising rolls relative to mean
Returns a list of dicts ordered by turn, each with:
turn, signed_p, p_tail, abs_err, which_tail, diff_centred, usable_event_count
"""
by_turn = series.get("by_turn", {})
result = []
for n in sorted(by_turn.keys()):
row = by_turn[n]
result.append({
"turn": n,
"signed_p": row.get("signed_p_n"),
"p_tail": row.get("p_tail_n"),
"abs_err": row.get("abs_err_n"),
"which_tail": row.get("which_tail_n"),
"diff_centred": row.get("diff_centred"),
"usable_event_count": row.get("usable_event_count"),
})
return result
def build_cumulative_surprise_series_meter_only(
game_state,
team_meta=None,
calculator=None,
max_turn=None,
) -> dict:
"""Fast meter-only path: cumulative series without quantile bands or figures.
Returns a minimal series dict with only:
- by_turn[n] with: turn, usable_event_count, team0_centred, team1_centred,
diff_centred, std_delta_n, p_tail_n, which_tail_n, abs_err_n, signed_p_n
Skips all _lr_quantile_centred calls (the expensive part) and all per-team quantile bands.
Estimated speedup: ~90% faster than full builder (1-2s vs 9-10s).
"""
usable_rows, _ = _collect_usable_surprise_rows(
game_state,
team_meta=team_meta,
calculator=calculator,
)
touchdown_markers = _collect_touchdown_markers(game_state)
if max_turn is None:
max_turn = _infer_cumulative_series_max_turn(usable_rows, touchdown_markers)
else:
max_turn = int(max_turn)
# Pre-compute per-event K1 and K2 contributions at t=0.
if usable_rows:
_all_p = np.array([r["p_success"] for r in usable_rows], dtype=float)
_all_q = np.array([r["p_fail"] for r in usable_rows], dtype=float)
_lp = np.zeros_like(_all_p)
_lq = np.zeros_like(_all_q)
_pmask = _all_p > 0
_qmask = _all_q > 0
_lp[_pmask] = np.log(_all_p[_pmask])
_lq[_qmask] = np.log(_all_q[_qmask])
_k1c = -_all_p * _lp + _all_q * _lq
_k2c = (_all_p * _lp**2 + _all_q * _lq**2) - _k1c**2
for _i, _row in enumerate(usable_rows):
_row["_k1c"] = float(_k1c[_i])
_row["_k2c"] = float(_k2c[_i])
# Pre-group rows by turn.
_rows_grouped = defaultdict(list)
for row in usable_rows:
_rows_grouped[row["turn"]].append(row)
# Running accumulators for prefix K1/K2.
k1_0_acc = 0.0
k2_0_acc = 0.0
k1_1_acc = 0.0
k2_1_acc = 0.0
score0_acc = 0.0
score1_acc = 0.0
pvec0_acc = np.array([], dtype=float)
qvec0_acc = np.array([], dtype=float)
pvec1_acc = np.array([], dtype=float)
qvec1_acc = np.array([], dtype=float)
rows_by_turn = {}
for n in range(1, max_turn + 1):
current_rows = _rows_grouped.get(n, [])
current0 = [row for row in current_rows if row["team_id"] == 0]
current1 = [row for row in current_rows if row["team_id"] == 1]
pvec_c0_list = []
qvec_c0_list = []
pvec_c1_list = []
qvec_c1_list = []
# Extend prefix accumulators with this turn's events.
for row in current0:
score0_acc += row["surprise"]
k1_0_acc += row["_k1c"]
k2_0_acc += row["_k2c"]
pvec_c0_list.append(row["p_success"])
qvec_c0_list.append(row["p_fail"])
for row in current1:
score1_acc += row["surprise"]
k1_1_acc += row["_k1c"]
k2_1_acc += row["_k2c"]
pvec_c1_list.append(row["p_success"])
qvec_c1_list.append(row["p_fail"])
pvec_c0 = np.array(pvec_c0_list, dtype=float) if pvec_c0_list else np.array([], dtype=float)
qvec_c0 = np.array(qvec_c0_list, dtype=float) if qvec_c0_list else np.array([], dtype=float)
pvec_c1 = np.array(pvec_c1_list, dtype=float) if pvec_c1_list else np.array([], dtype=float)
qvec_c1 = np.array(qvec_c1_list, dtype=float) if qvec_c1_list else np.array([], dtype=float)
pvec0_acc = _extend_prefix_vector(pvec0_acc, pvec_c0)
qvec0_acc = _extend_prefix_vector(qvec0_acc, qvec_c0)
pvec1_acc = _extend_prefix_vector(pvec1_acc, pvec_c1)
qvec1_acc = _extend_prefix_vector(qvec1_acc, qvec_c1)
centred0 = score0_acc - k1_0_acc
centred1 = score1_acc - k1_1_acc
k2_delta = k2_0_acc + k2_1_acc
std_delta = math.sqrt(max(k2_delta, 0.0))
diff_centred = float(centred0 - centred1)
diff_raw = float(score0_acc - score1_acc)
# Per-turn signed p-value (LR_two_sided is still called, but only once per turn).
p_delta = np.concatenate((pvec0_acc, qvec1_acc)) if (pvec0_acc.size + qvec1_acc.size) > 0 else np.array([], dtype=float)
q_delta = np.concatenate((qvec0_acc, pvec1_acc)) if (qvec0_acc.size + pvec1_acc.size) > 0 else np.array([], dtype=float)
if p_delta.size > 0 and q_delta.size > 0:
try:
p_tail_n, which_tail_n, abs_err_n = tails.LR_two_sided(diff_raw, p_delta, q_delta)
if which_tail_n == "right":
signed_p_n = float(p_tail_n)
elif which_tail_n == "left":
signed_p_n = -float(p_tail_n)
else:
signed_p_n = None
except Exception:
p_tail_n, which_tail_n, abs_err_n, signed_p_n = None, None, None, None
else:
p_tail_n, which_tail_n, abs_err_n, signed_p_n = None, None, None, None
# Minimal row for meter: only what's needed for gauge rendering and metadata.
rows_by_turn[n] = {
"turn": n,
"usable_event_count": int(pvec0_acc.size + pvec1_acc.size),
"team0_centred": float(centred0),
"team1_centred": float(centred1),
"diff_centred": diff_centred,
"std_delta_n": std_delta,
"p_tail_n": p_tail_n,
"which_tail_n": which_tail_n,
"abs_err_n": abs_err_n,
"signed_p_n": signed_p_n,
}
final_turn = int(max(rows_by_turn.keys())) if rows_by_turn else 0
final_centred_score = rows_by_turn.get(final_turn, {}).get("diff_centred", 0.0)
return {
"by_turn": rows_by_turn,
"final_turn": final_turn,
"final_centred_score": float(final_centred_score),
}
def _add_td_markers(ax, touchdown_markers, team0_color, team1_color, team0_name, team1_name,
x_min=None, x_max=None):
"""Place 'TD: team_name' annotations above the top frame, stacking multiple TDs per turn.
Returns the maximum number of TDs in any single turn (0 if no markers).
"""
if not touchdown_markers:
return 0
trans = _blended_xform(ax.transData, ax.transAxes)
by_turn: dict = {}
for marker in touchdown_markers:
t = marker.get("turn")
by_turn.setdefault(t, []).append(marker)
max_per_turn = max(len(v) for v in by_turn.values())
_circle_y_base = 1.0
_text_y_base = 1.06
_step_y = 0.10
for td_turn, markers in sorted(by_turn.items()):
# Clamp text alignment so it stays within the left/right frame lines.
if x_min is not None and x_max is not None and x_min < x_max:
_span = x_max - x_min
_rel = (float(td_turn) - x_min) / _span # 0.0 = left edge, 1.0 = right edge
if _rel <= 0.15:
_ha = "left"
elif _rel >= 0.85:
_ha = "right"
else:
_ha = "center"
else:
_ha = "center"
for i, marker in enumerate(markers):
td_team_id = marker.get("team_id")
if td_team_id == 0:
color = team0_color
name = team0_name
elif td_team_id == 1:
color = team1_color
name = team1_name
else:
color = "crimson"
name = "?"
circle_y = _circle_y_base + i * _step_y
text_y = _text_y_base + i * _step_y
ax.plot(
[float(td_turn)], [circle_y],
marker="s", markersize=9, color=color,
transform=trans, clip_on=False,
linestyle="none", zorder=5,
)
ax.text(
float(td_turn), text_y,
f"TD: {name}",
transform=trans,
ha=_ha, va="bottom",
fontsize=10, color=color,
clip_on=False,
)
return max_per_turn
def plot_cumulative_surprise_series_v1(
game_state,
team_meta=None,
calculator=None,
gamer0="Team0",
gamer1="Team1",
max_turn=None,
include_extra_bands: bool = False,
include_diff_figure: bool = True,
):
"""Plot cumulative centred surprise curves and centred team-difference curve."""
team0_label = _display_team_name(gamer0, 0, team_meta=team_meta)
team1_label = _display_team_name(gamer1, 1, team_meta=team_meta)
team0_bare = team0_label.replace(" (team 0)", "").strip() or team0_label
team1_bare = team1_label.replace(" (team 1)", "").strip() or team1_label
series = build_cumulative_surprise_series_v1(
game_state,
team_meta=team_meta,
calculator=calculator,
max_turn=max_turn,
include_extra_bands=include_extra_bands,
)
by_turn = series.get("by_turn", {})
touchdown_turns = series.get("touchdown_turns", [])
touchdown_markers = series.get("touchdown_markers", [])
turns = sorted(by_turn.keys())
team0_centred_turn = [by_turn[t]["team0_centred_turn"] for t in turns]
team1_centred_turn = [by_turn[t]["team1_centred_turn"] for t in turns]
team0_std_turn = [by_turn[t]["std_0_turn"] for t in turns]
team1_std_turn = [by_turn[t]["std_1_turn"] for t in turns]
team0_centred = [by_turn[t]["team0_centred"] for t in turns]
team1_centred = [by_turn[t]["team1_centred"] for t in turns]
team0_std = [by_turn[t]["std_0_n"] for t in turns]
team1_std = [by_turn[t]["std_1_n"] for t in turns]
diff_centred = [by_turn[t]["diff_centred"] for t in turns]
diff_std = [by_turn[t]["std_delta_n"] for t in turns]
if include_diff_figure:
fig_diff, ax_diff = plt.subplots(1, 1, figsize=(11, 4.5))
else:
fig_diff, ax_diff = None, None
fig_worm, (ax_turn, ax_cum) = plt.subplots(2, 1, figsize=(11, 8), sharex=True)
halftime_turn_boundary = 8.5
show_halftime = bool(turns) and min(turns) <= 8 and max(turns) >= 9
team0_color = TEAM0_MPL
team1_color = TEAM1_MPL
if show_halftime:
_axes = [ax_turn, ax_cum]
if ax_diff is not None:
_axes.insert(0, ax_diff)
for ax in _axes:
ax.axvline(
halftime_turn_boundary,
color="0.35",
linewidth=1.2,
linestyle="--",
alpha=0.45,
zorder=0,
)
_label_axes = [ax_turn]
if ax_diff is not None:
_label_axes.insert(0, ax_diff)
for _ht_ax in _label_axes:
_ht_ax.text(
halftime_turn_boundary + 0.08,
0.96,
"Half time",
transform=_ht_ax.get_xaxis_transform(),
color="0.35",
fontsize=9,
alpha=0.8,
ha="left",
va="top",
)
_td_x_min = min(turns) if turns else None
_td_x_max = max(turns) if turns else None
if ax_diff is not None:
_max_td_diff = _add_td_markers(
ax_diff, touchdown_markers, team0_color, team1_color, team0_bare, team1_bare,
x_min=_td_x_min, x_max=_td_x_max,
)
else:
_max_td_diff = 0
_max_td_worm = _add_td_markers(
ax_turn, touchdown_markers, team0_color, team1_color, team0_bare, team1_bare,
x_min=_td_x_min, x_max=_td_x_max,
)
# LR quantile bands — pre-computed in build_cumulative_surprise_series_v1.
# These are asymmetric: right (positive) and left (negative) thresholds differ
# because the delta distribution is generally skewed by the teams' roll profiles.
t0_q10_turn_r = [by_turn[t].get("t0_q10_right_turn", 0.0) for t in turns]
t0_q10_turn_l = [by_turn[t].get("t0_q10_left_turn", 0.0) for t in turns]
t1_q10_turn_r = [by_turn[t].get("t1_q10_right_turn", 0.0) for t in turns]
t1_q10_turn_l = [by_turn[t].get("t1_q10_left_turn", 0.0) for t in turns]
t0_q10_r = [by_turn[t].get("t0_q10_right_n", 0.0) for t in turns]
t0_q10_l = [by_turn[t].get("t0_q10_left_n", 0.0) for t in turns]
t1_q10_r = [by_turn[t].get("t1_q10_right_n", 0.0) for t in turns]
t1_q10_l = [by_turn[t].get("t1_q10_left_n", 0.0) for t in turns]
if ax_diff is not None:
d_q10_r = [by_turn[t].get("diff_q10_right_n", 0.0) for t in turns]
d_q10_l = [by_turn[t].get("diff_q10_left_n", 0.0) for t in turns]
if include_extra_bands:
d_q01_r = [by_turn[t].get("diff_q01_right_n", 0.0) for t in turns]
d_q01_l = [by_turn[t].get("diff_q01_left_n", 0.0) for t in turns]
d_q001_r = [by_turn[t].get("diff_q001_right_n", 0.0) for t in turns]
d_q001_l = [by_turn[t].get("diff_q001_left_n", 0.0) for t in turns]
# Per-turn bands.
ax_turn.fill_between(turns, t0_q10_turn_l, t0_q10_turn_r, color=team0_color, alpha=0.12, label="_nolegend_")
ax_turn.fill_between(turns, t1_q10_turn_l, t1_q10_turn_r, color=team1_color, alpha=0.12, label="_nolegend_")
# Cumulative per-team bands.
ax_cum.fill_between(turns, t0_q10_l, t0_q10_r, color=team0_color, alpha=0.12, label="_nolegend_")
ax_cum.fill_between(turns, t1_q10_l, t1_q10_r, color=team1_color, alpha=0.12, label="_nolegend_")
if ax_diff is not None:
# Diff panel: gauge-style shading — grey centre with the 1-in-10 band by default.
# The tighter bands are optional because they are the expensive part.
_y_outer = max(max((abs(v) for v in diff_centred), default=1.0), max((abs(v) for v in d_q10_r + d_q10_l), default=1.0)) * 1.5
_large = [_y_outer] * len(turns)
_neg_large = [-_y_outer] * len(turns)
# Grey centre (between left-1/10 and right-1/10)
ax_diff.fill_between(turns, d_q10_l, d_q10_r, color="0.75", alpha=0.35, zorder=0, label="_nolegend_")
if include_extra_bands:
# Light colour: 1/10 → 1/100
ax_diff.fill_between(turns, d_q10_r, d_q01_r, color=team0_color, alpha=0.18, zorder=0, label="_nolegend_")
ax_diff.fill_between(turns, d_q01_l, d_q10_l, color=team1_color, alpha=0.18, zorder=0, label="_nolegend_")
# Medium colour: 1/100 → 1/1000
ax_diff.fill_between(turns, d_q01_r, d_q001_r, color=team0_color, alpha=0.28, zorder=0, label="_nolegend_")
ax_diff.fill_between(turns, d_q001_l, d_q01_l, color=team1_color, alpha=0.28, zorder=0, label="_nolegend_")
# Dark colour: beyond 1/1000
ax_diff.fill_between(turns, d_q001_r, _large, color=team0_color, alpha=0.45, zorder=0, label="_nolegend_")
ax_diff.fill_between(turns, _neg_large, d_q001_l, color=team1_color, alpha=0.45, zorder=0, label="_nolegend_")
# Threshold lines
ax_diff.plot(turns, d_q10_r, color="0.5", linewidth=0.8, linestyle="-", alpha=0.6, zorder=1, label="_nolegend_")
ax_diff.plot(turns, d_q10_l, color="0.5", linewidth=0.8, linestyle="-", alpha=0.6, zorder=1, label="_nolegend_")
if include_extra_bands:
ax_diff.plot(turns, d_q01_r, color=team0_color, linewidth=0.8, linestyle="-", alpha=0.6, zorder=1, label="_nolegend_")
ax_diff.plot(turns, d_q01_l, color=team1_color, linewidth=0.8, linestyle="-", alpha=0.6, zorder=1, label="_nolegend_")
ax_diff.plot(turns, d_q001_r, color=team0_color, linewidth=0.8, linestyle="--", alpha=0.7, zorder=1, label="_nolegend_")
ax_diff.plot(turns, d_q001_l, color=team1_color, linewidth=0.8, linestyle="--", alpha=0.7, zorder=1, label="_nolegend_")
# Diff panel line — black so it reads clearly over coloured background.
ax_diff.plot(
turns,
diff_centred,
marker="o",
color="black",
zorder=3,
)
ax_diff.axhline(0.0, color="black", linewidth=1, alpha=0.6)
_diff_abs_max = max((abs(v) for v in diff_centred), default=1.0)
# Show at least the full region boundary that the final point sits in.
_final_diff = diff_centred[-1] if diff_centred else 0.0
_final_q10_r = d_q10_r[-1] if d_q10_r else 0.0
_final_q10_l = d_q10_l[-1] if d_q10_l else 0.0
if include_extra_bands:
_final_q01_r = d_q01_r[-1] if d_q01_r else 0.0
_final_q01_l = d_q01_l[-1] if d_q01_l else 0.0
_final_q001_r = d_q001_r[-1] if d_q001_r else 0.0
_final_q001_l = d_q001_l[-1] if d_q001_l else 0.0
if _final_diff >= 0:
_bounds = [_final_q10_r]
if include_extra_bands:
_bounds.extend([_final_q01_r, _final_q001_r])
else:
_bounds = [abs(_final_q10_l)]
if include_extra_bands:
_bounds.extend([abs(_final_q01_l), abs(_final_q001_l)])
_region_bound = next((b for b in _bounds if abs(_final_diff) <= b), abs(_final_diff))
_diff_ylim = max(_diff_abs_max, _region_bound)
_ylim_top = 1.2 * _diff_ylim
ax_diff.set_ylim(-_ylim_top, _ylim_top)
# Boundary labels — label each threshold line at the right frame edge (or where it exits).
if turns:
_label_pairs = [(d_q10_r, "1/10"), (d_q10_l, "1/10")]
if include_extra_bands:
_label_pairs.extend(
[(d_q01_r, "1/100"), (d_q001_r, "1/1000"), (d_q01_l, "1/100"), (d_q001_l, "1/1000")]
)
for _bvals, _lbl in _label_pairs:
_bvals_arr = np.array(_bvals, dtype=float)
_y_last = float(_bvals_arr[-1])
_at_top = _y_last >= 0
_lim = _ylim_top if _at_top else -_ylim_top
if abs(_y_last) <= _ylim_top:
ax_diff.text(turns[-1], _y_last, f" {_lbl}", color="black", fontsize=9,
va=("bottom" if _at_top else "top"), ha="right",
zorder=4, clip_on=False)
else:
_in_mask = np.abs(_bvals_arr) <= _ylim_top
if _in_mask.any():
_li = int(np.where(_in_mask)[0][-1])
if _li + 1 < len(turns):
_t0v, _t1v = float(turns[_li]), float(turns[_li + 1])
_y0v, _y1v = float(_bvals_arr[_li]), float(_bvals_arr[_li + 1])
_xc = _t0v + (_t1v - _t0v) * (_lim - _y0v) / (_y1v - _y0v)
else:
_xc = float(turns[-1])
ax_diff.text(_xc, _lim, f" {_lbl}", color="black", fontsize=9,
va=("top" if _at_top else "bottom"), ha="left",
zorder=4, clip_on=False)
ax_diff.set_ylabel("Luck Difference")
ax_diff.set_title("") # title set below after tight_layout
ax_diff.grid(True, alpha=0.3)
ax_turn.plot(turns, team0_centred_turn, marker="o", color=team0_color, label=team0_bare)
ax_turn.plot(turns, team1_centred_turn, marker="o", color=team1_color, label=team1_bare)
ax_turn.axhline(0.0, color="black", linewidth=1, alpha=0.6)
_turn_abs_max = max((abs(v) for v in team0_centred_turn + team1_centred_turn), default=1.0)
ax_turn.set_ylim(-1.2 * _turn_abs_max, 1.2 * _turn_abs_max)
ax_turn.set_ylabel("Team Luck")
ax_turn.set_title("")
ax_turn.grid(True, alpha=0.3)
ax_turn.legend(loc="upper left")
ax_cum.plot(turns, team0_centred, marker="o", color=team0_color, label=team0_bare)
ax_cum.plot(turns, team1_centred, marker="o", color=team1_color, label=team1_bare)
ax_cum.axhline(0.0, color="black", linewidth=1, alpha=0.6)
_cum_abs_max = max((abs(v) for v in team0_centred + team1_centred), default=1.0)
ax_cum.set_ylim(-1.2 * _cum_abs_max, 1.2 * _cum_abs_max)
ax_cum.set_ylabel("Cumulative Team Luck")
ax_cum.set_title("")
ax_cum.grid(True, alpha=0.3)
ax_cum.legend(loc="upper left")
# x-axis: integer tick marks for every turn, starting at 1; labels on all panels.
if turns:
x_ticks = list(range(max(1, min(turns)), max(turns) + 1))
_axes = [ax_turn, ax_cum]
if ax_diff is not None:
_axes.insert(0, ax_diff)
for ax in _axes:
ax.set_xlim(min(x_ticks), max(x_ticks))
ax.set_xticks(x_ticks)
ax.tick_params(labelbottom=True)
if ax_diff is not None:
ax_diff.set_xlabel("Turn")
ax_turn.set_xlabel("Turn")
ax_cum.set_xlabel("Turn")
_btm_diff = (0.10 * _max_td_diff + 0.05) if _max_td_diff > 0 else 0.0
_top_worm = 1.0 - (0.06 * _max_td_worm + 0.04) if _max_td_worm > 0 else 1.0
if fig_diff is not None:
fig_diff.tight_layout(rect=[0, _btm_diff, 1, 1])
fig_worm.tight_layout(rect=[0, 0, 1, _top_worm])
fig_worm.subplots_adjust(hspace=0.35)
if fig_diff is not None:
# Multi-colour title on diff figure, placed after tight_layout so axes size is stable.
_t0_bare = team0_bare
_t1_bare = team1_bare
_title_segs = [
("(", "black"),
(_t0_bare, team0_color),
(" luck) \u2212 (", "black"),
(_t1_bare, team1_color),
(" luck)", "black"),
]
_title_fs = 11
fig_diff.canvas.draw()
_rdr = fig_diff.canvas.get_renderer()
_ax_bbox = ax_diff.get_window_extent(renderer=_rdr)
# Measure each segment width using a temporary invisible text object.
_seg_widths = []
for _seg, _col in _title_segs:
_tmp = ax_diff.text(0, 0, _seg, color=_col, fontsize=_title_fs,
transform=ax_diff.transAxes, va="bottom", ha="left",
clip_on=False)
_seg_widths.append(_tmp.get_window_extent(renderer=_rdr).width)
_tmp.remove()
_total_title_px = sum(_seg_widths)
_start_px = _ax_bbox.x0 + (_ax_bbox.width - _total_title_px) / 2.0
_cur_px = _start_px
for (_seg, _col), _sw in zip(_title_segs, _seg_widths):
_x_axes = (_cur_px - _ax_bbox.x0) / _ax_bbox.width
ax_diff.text(_x_axes, 1.02, _seg, color=_col, fontsize=_title_fs,
transform=ax_diff.transAxes, va="bottom", ha="left",
clip_on=False)
_cur_px += _sw
return fig_diff, fig_worm, series
def build_mega_dicing_v1_data(game_state, team_meta=None, calculator=None):
"""Pure data-layer mega dicing V1 computation (no printing)."""
usable_rows, kickoff_events = _collect_usable_surprise_rows(
game_state,
team_meta=team_meta,
calculator=calculator,
)
team0surprise = []
team1surprise = []
pvec0_list = []
qvec0_list = []
pvec1_list = []
qvec1_list = []
for row in usable_rows:
if not isinstance(row, dict):
continue
team_id = row.get("team_id")
if team_id not in (0, 1):
continue
surprise = float(row.get("surprise", 0.0))
p_success = float(row.get("p_success", 0.0))
p_fail = float(row.get("p_fail", 0.0))
if team_id == 0:
team0surprise.append(surprise)
pvec0_list.append(p_success)
qvec0_list.append(p_fail)
else:
team1surprise.append(surprise)
pvec1_list.append(p_success)
qvec1_list.append(p_fail)
usable_events = len(usable_rows)
if usable_events == 0:
return None
team0surprise = np.array(team0surprise, dtype=float)
team1surprise = np.array(team1surprise, dtype=float)
pvec0 = np.array(pvec0_list, dtype=float)
qvec0 = np.array(qvec0_list, dtype=float)
pvec1 = np.array(pvec1_list, dtype=float)
qvec1 = np.array(qvec1_list, dtype=float)
score0 = float(np.sum(team0surprise))
score1 = float(np.sum(team1surprise))
k1_0 = float(tails.K1(0, pvec0, qvec0)) if len(pvec0) > 0 else 0.0
k1_1 = float(tails.K1(0, pvec1, qvec1)) if len(pvec1) > 0 else 0.0
# User-defined final centred score:
# score_0_N - K1(pvec_0_N,qvec_0_N) - score_1_N + K1(pvec_1_N,qvec_1_N)
raw_delta = float(score0 - score1)
centred_delta = float((score0 - k1_0) - (score1 - k1_1))
# User-defined p/q for tails analysis:
# p = concat(pvec_0_N, qvec_1_N), q = concat(qvec_0_N, pvec_1_N)
p_vec = np.concatenate((pvec0, qvec1)) if (len(pvec0) + len(qvec1)) > 0 else np.array([], dtype=float)
q_vec = np.concatenate((qvec0, pvec1)) if (len(qvec0) + len(pvec1)) > 0 else np.array([], dtype=float)
p_tail, which_tail, abs_err = tails.LR_two_sided(raw_delta, p_vec, q_vec)
dist_mean = float(tails.K1(0, p_vec, q_vec))
dist_std = float(np.sqrt(tails.K2(0, p_vec, q_vec)))
return {
"usable_events": usable_events,
"kickoff_events_included": len(kickoff_events),
"score_0_n": score0,
"score_1_n": score1,
"raw_delta": raw_delta,
"k1_0_n": k1_0,
"k1_1_n": k1_1,
"delta_surprise": centred_delta,
"dist_mean": dist_mean,
"dist_std": dist_std,
"p_tail": p_tail,
"which_tail": which_tail,
"abs_err": abs_err,
"one_in_games": (1.0 / p_tail) if isinstance(p_tail, (int, float)) and p_tail > 0 else None,
}
def build_details_summary(game_state, series, team_meta, gamer0, gamer1):
"""Assemble all data needed for the Details tab summary table.
Returns a dict with keys:
coach0, coach1 : gamer display names
team0, team1 : team (roster) names
race0, race1 : race names
td0, td1 : touchdown counts (int)
luck0, luck1 : centred luck score at final turn (float)
diff_centred : luck0 - luck1 (float)
luckier : 0 or 1 (index of team with higher centred luck), or None if equal
p_tail : two-sided tail p-value (float or None)
abs_err : p-value error estimate (float or None)
which_tail : "left" | "right" | None
one_in_games_str : "1 in X" formatted string (str)
diced_label : short severity label (str), e.g. "regular", "mild", "mega"
diced_team : 0 or 1 (which team was diced), or None if no significant dicing
"""
# ── Per-team luck from final turn of series ────────────────────────────────
by_turn = series.get("by_turn", {}) if series else {}
final_turn = int(max(by_turn.keys())) if by_turn else None
final_row = by_turn.get(final_turn, {}) if final_turn is not None else {}
luck0 = final_row.get("team0_centred")
luck1 = final_row.get("team1_centred")
diff_centred = final_row.get("diff_centred")
p_tail = final_row.get("p_tail_n")
abs_err = final_row.get("abs_err_n")
which_tail = final_row.get("which_tail_n")
# Meter-only series can omit explicit tail metadata; reconstruct from signed p.
if p_tail is None:
signed_p = final_row.get("signed_p_n")
if isinstance(signed_p, (int, float)):
p_tail = abs(float(signed_p))
if which_tail is None:
which_tail = "right" if signed_p >= 0 else "left"
# If team-centred values are unavailable, derive a consistent split from diff.
if luck0 is None and luck1 is None and isinstance(diff_centred, (int, float)):
if diff_centred >= 0:
luck0 = float(diff_centred)
luck1 = 0.0
else:
luck0 = 0.0
luck1 = float(-diff_centred)
# ── Luckier team ───────────────────────────────────────────────────────────
if luck0 is not None and luck1 is not None:
if luck0 > luck1:
luckier = 0
elif luck1 > luck0:
luckier = 1
else:
luckier = None
else:
luckier = None
# ── p-value → "1 in X" string ─────────────────────────────────────────────
one_in_games_str = f"1 in {format_one_in_games(p_tail)}" if isinstance(p_tail, (int, float)) and p_tail > 0 else "N/A"
# ── Severity / diced label (mirrors gauge.py tier logic) ──────────────────
# which_tail "right" → team0 luckier → team1 was diced
# which_tail "left" → team1 luckier → team0 was diced
diced_team = None
diced_label = "no significant dicing"
if isinstance(p_tail, (int, float)) and p_tail > 0:
abs_p = p_tail # p_tail is already two-sided
if abs_p > 1.0 / 10:
diced_label = "no significant dicing"
diced_team = None
elif abs_p > 1.0 / 32:
diced_label = "mildly diced"
elif abs_p > 1.0 / 100:
diced_label = "diced"
elif abs_p > 1.0 / 320:
diced_label = "mega diced"
elif abs_p > 1.0 / 1000:
diced_label = "comedy mega diced"
else:
diced_label = "extra comedy mega diced"
if diced_label != "no significant dicing":
diced_team = 1 if which_tail == "right" else (0 if which_tail == "left" else None)
# ── Touchdowns ────────────────────────────────────────────────────────────
td_events = getattr(game_state, "touchdown_events", None) or []
td0 = sum(1 for e in td_events if isinstance(e, dict) and str(e.get("team_id", "")) == "0")
td1 = sum(1 for e in td_events if isinstance(e, dict) and str(e.get("team_id", "")) == "1")
# ── Team / race names ─────────────────────────────────────────────────────
team0 = team_meta.team_label("0") if team_meta else "Team 0"
team1 = team_meta.team_label("1") if team_meta else "Team 1"
race0 = team_meta.team_race("0") if team_meta else ""
race1 = team_meta.team_race("1") if team_meta else ""
return {
"coach0": gamer0,
"coach1": gamer1,
"team0": team0,
"team1": team1,
"race0": race0,
"race1": race1,
"td0": td0,
"td1": td1,
"luck0": luck0,
"luck1": luck1,
"diff_centred": diff_centred,
"luckier": luckier,
"p_tail": p_tail,
"abs_err": abs_err,
"which_tail": which_tail,
"one_in_games_str": one_in_games_str,
"diced_label": diced_label,
"diced_team": diced_team,
}
def _determine_diced_team(result, team0_label, team1_label):
"""Pure helper: determine which team was diced based on result thresholds."""
if not isinstance(result, dict):
return None, None
raw_delta = result.get("raw_delta")
dist_mean = result.get("dist_mean")
which_tail = result.get("which_tail")
if not isinstance(raw_delta, (int, float)) or not isinstance(dist_mean, (int, float)):
return None, None
if which_tail not in ("left", "right"):
return None, None
if raw_delta > dist_mean and which_tail == "right":
return (team0_label, team1_label)
elif raw_delta < dist_mean and which_tail == "left":
return (team1_label, team0_label)
else:
return (None, None)
def print_mega_dicing_v1_results(result, dicer, dicee, team0_label, team1_label):
"""Print formatted mega dicing V1 result report."""
if result is None:
return
print("MEGA DICING V1")
print("=" * 72)
print(f"Usable roll events: {result['usable_events']}")
print(f"Kickoff events included: {result['kickoff_events_included']}")
print(f"{team0_label} score_N: {result['score_0_n']:.6f}")
print(f"{team1_label} score_N: {result['score_1_n']:.6f}")
print(f"{team0_label} K1_N: {result['k1_0_n']:.6f}")
print(f"{team1_label} K1_N: {result['k1_1_n']:.6f}")
print(f"Raw score difference (T0 - T1): {result['raw_delta']:.6f}")
print(f"Combined K1 mean: {result['dist_mean']:.6f}")
print(f"Final centred score: {result['delta_surprise']:.6f}")
print(f"Distribution mean: {result['dist_mean']:.6f}")
print(f"Distribution std : {result['dist_std']:.6f}")
print(f"Tail p-value : {result['p_tail']:.6g} +/- {result['abs_err']:.2g}")
print(f"Tail side : {result['which_tail']}")
print("-" * 72)
if dicer is not None and dicee is not None:
print(f"{dicer} had more surprising dice relative to mean {result['dist_mean']:.2f}.")
print(f"{dicee} was diced with p-value {result['p_tail']:.3g} +/- {result['abs_err']:.1g}.")
print(f"A result at least as bad as this is expected roughly one in {format_one_in_games(result['p_tail'])} games.")
else:
print("Both gamers had same level of surprising dices")
def mega_dicing_v1_from_game_state(game_state, gamer0="Team0", gamer1="Team1", team_meta=None, calculator=None):
"""Presentation wrapper for mega dicing V1 over pure data builder."""
result = build_mega_dicing_v1_data(game_state, team_meta=team_meta, calculator=calculator)
if result is None:
print("No usable roll events for Mega dicing V1 calculation.")
return None
team0_label = _display_team_name(gamer0, 0, team_meta=team_meta)
team1_label = _display_team_name(gamer1, 1, team_meta=team_meta)
dicer, dicee = _determine_diced_team(result, team0_label, team1_label)
print_mega_dicing_v1_results(result, dicer, dicee, team0_label, team1_label)
return result
# ── Roll summary (Success-Fail distribution + dice distribution per player) ──
def build_roll_summary(game_state, team_meta=None):
"""Build a summary of dice roll outcomes grouped by roll category and coach.
Returns a dict:
{
"team_names": {"0": str, "1": str},
"by_category": {
team_id: {
category: [
{
"prob_label": str, # e.g. "67%"
"p_expected": float,
"success": int,
"neutral": int,
"fail": int,
"total": int,
},
... # sorted by p_expected descending
]
}
},
"by_coach": {
team_id: {
category: {die_face (int): count (int)}
}
},
}
Included roll categories: 'action' (includes KO recov), 'armour', 'block',
'injury', 'casualty'. Ball, touchdown, and referee-event categories are
excluded.
"""
import collections
# Categories merged into 'action'
_MERGE_TO_ACTION = frozenset({"KO recov"})
# Categories excluded from all output
_EXCLUDE = frozenset({
"ball", "touchdown",
"Officious Ref sent off", "OfficiousRefSendOff",
"Argue Call",
})
events = [
ev for ev in collect_roll_events_compat(game_state)
if not (isinstance(ev, dict) and ev.get("exclude_from_detailed_report"))
]
team_names = {"0": "Team 0", "1": "Team 1"}
if team_meta is not None:
try:
team_names["0"] = team_meta.team_label("0")
team_names["1"] = team_meta.team_label("1")
except Exception:
pass
# Accumulate by category+probability bucket → success/neutral/fail counts
_cat_acc = {
"0": collections.defaultdict(lambda: collections.defaultdict(lambda: {"success": 0, "neutral": 0, "fail": 0, "p_expected": 0.0, "difficulty": None})),
"1": collections.defaultdict(lambda: collections.defaultdict(lambda: {"success": 0, "neutral": 0, "fail": 0, "p_expected": 0.0, "difficulty": None})),
}
# Accumulate per coach (team) → category → die face → count
_coach_acc = {
"0": collections.defaultdict(collections.Counter),
"1": collections.defaultdict(collections.Counter),
}
for ev in events:
if ev.get("is_marker_only_action"):
continue
# Use dice_roller (who physically rolled) for attribution.
# For action/block rolls dice_roller == team_id; they differ for armour
# and injury rolls, where the attacker rolls against the defender.
# Fall back to team_id only when dice_roller is not a known team.
dr = str(ev.get("dice_roller") or "")
team_id = dr if dr in ("0", "1") else str(ev.get("team_id") or "")
if team_id not in ("0", "1"):
continue
raw_cat = str(ev.get("roll_category") or "other")
if raw_cat in _EXCLUDE:
continue
cat = "action" if raw_cat in _MERGE_TO_ACTION else raw_cat
rc = str(ev.get("result_classification") or "neutral")
p_suc = ev.get("probability_success")
dice_values = ev.get("dice_values") or []
# Round probability to nearest integer percent for bucketing
p_pct = int(round((p_suc or 0.0) * 100))
bucket = _cat_acc[team_id][cat][p_pct]
if bucket["p_expected"] == 0.0 and p_suc:
bucket["p_expected"] = float(p_suc)
if bucket["difficulty"] is None:
bucket["difficulty"] = ev.get("difficulty")
if rc == "success":
bucket["success"] += 1
elif rc == "fail":
bucket["fail"] += 1
else:
bucket["neutral"] += 1
# Accumulate die face counts per coach and category.
# Block dice use replay encoding 0-4; all other categories use D6 faces 1-6.
for face in dice_values:
if not isinstance(face, int):
continue
if cat == "block":
if 0 <= face <= 4:
_coach_acc[team_id][cat][face] += 1
else:
if 1 <= face <= 6:
_coach_acc[team_id][cat][face] += 1
def _action_prob_label(difficulty, p_pct):
"""Format 'N+ (X%)' or 'N++ (X%)' for a single-die action roll bucket."""
try:
n = int(difficulty)
except (TypeError, ValueError):
return f"{p_pct}%"
if not (2 <= n <= 6):
return f"{p_pct}%"
single_pct = round((7 - n) / 6 * 100)
suffix = "+" if p_pct == single_pct else "++"
return f"{n}{suffix} ({p_pct}%)"
def _armour_prob_label(difficulty, p_pct):
"""Format 'N+ (X%)' for a 2D6 armour roll bucket."""
try:
n = int(difficulty)
return f"{n}+ ({p_pct}%)"
except (TypeError, ValueError):
return f"{p_pct}%"
# Reference probabilities for block dice pools (MrMesmer labels)
_BLOCK_LABEL_REF = [
(5/6, "1d [5 success] (83%)"),
(4/6, "1d [4 success] (67%)"),
(3/6, "1d [3 success] (50%)"),
(2/6, "1d [2 success] (33%)"),
(1/6, "1d [1 success] (16%)"),
(35/36, "2d [5 success] (97%)"),
(32/36, "2d [4 success] (89%)"),
(27/36, "2d [3 success] (75%)"),
(20/36, "2d [2 success] (55%)"),
(11/36, "2d [1 success] (31%)"),
(215/216, "3d [5 success] (100%)"),
(208/216, "3d [4 success] (96%)"),
(189/216, "3d [3 success] (87%)"),
(152/216, "3d [2 success] (70%)"),
(91/216, "3d [1 success] (42%)"),
(9/36, ":red[red2d] [3 success] (25%)"),
(4/36, ":red[red2d] [2 success] (11%)"),
(1/36, ":red[red2d] [1 success] (3%)"),
]
def _block_prob_label(p_expected):
"""Match a block roll's probability to the closest MrMesmer label."""
for ref_p, label in _BLOCK_LABEL_REF:
if abs(p_expected - ref_p) < 0.015:
return label
return f"{int(p_expected * 100)}%"
# 2D6 cumulative probabilities for computing injury thresholds
_2D6_CUMUL = [
(2, 36/36), (3, 35/36), (4, 33/36), (5, 30/36),
(6, 26/36), (7, 21/36), (8, 15/36), (9, 10/36),
(10, 6/36), (11, 3/36), (12, 1/36),
]
def _injury_prob_label(p_expected, p_pct):
"""Compute 2D6 target from probability and format 'N+ (X%)'."""
for n, ref_p in _2D6_CUMUL:
if abs(p_expected - ref_p) < 0.015:
return f"{n}+ ({p_pct}%)"
return f"{p_pct}%"
# Categories excluded from by_category (no meaningful pass/fail probability)
_EXCLUDE_FROM_BY_CATEGORY = frozenset({"casualty"})
# Preferred display order for categories
_CAT_ORDER = ["action", "block", "armour", "injury", "casualty"]
# Build final by_category structure sorted by p_expected descending
by_category = {}
for tid in ("0", "1"):
raw_cats = {}
for cat, prob_dict in _cat_acc[tid].items():
if cat in _EXCLUDE_FROM_BY_CATEGORY:
continue
rows = []
for p_pct, counts in sorted(prob_dict.items(), reverse=True):
# Action rows: drop 100% bucket; format remaining as N+/N++
if cat == "action":
if p_pct == 100:
continue
prob_label = _action_prob_label(counts["difficulty"], p_pct)
elif cat == "armour":
prob_label = _armour_prob_label(counts["difficulty"], p_pct)
elif cat == "block":
prob_label = _block_prob_label(counts["p_expected"])
elif cat == "injury":
prob_label = _injury_prob_label(counts["p_expected"], p_pct)
else:
prob_label = f"{p_pct}%"
total = counts["success"] + counts["neutral"] + counts["fail"]
rows.append({
"prob_label": prob_label,
"p_expected": counts["p_expected"],
"success": counts["success"],
"neutral": counts["neutral"],
"fail": counts["fail"],
"total": total,
})
raw_cats[cat] = rows
# Apply preferred category ordering
ordered = {}
for cat in _CAT_ORDER:
if cat in raw_cats:
ordered[cat] = raw_cats[cat]
for cat in raw_cats:
if cat not in ordered:
ordered[cat] = raw_cats[cat]
by_category[tid] = ordered
# Build final by_coach structure
by_coach = {
tid: {cat: dict(counter) for cat, counter in cat_dict.items()}
for tid, cat_dict in _coach_acc.items()
}
return {
"team_names": team_names,
"by_category": by_category,
"by_coach": by_coach,
}