refactor: recalibrate hype scoring to deflate gauge and add momentum signals
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 15s

Unify overlapping late-P3 bonuses into a single score-state lookup, add
high-scoring and goal-spike signals, and tighten every component ceiling so
filling the hype bar is reserved for genuinely rare moments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 11:26:07 -04:00
parent 108b77ed39
commit e0db8f0859
2 changed files with 220 additions and 179 deletions
+172 -138
View File
@@ -33,9 +33,11 @@ def parse_games(scoreboard_data):
for game in scoreboard_data.get("games", []):
game_state = convert_game_state(game["gameState"])
priority_comps = _priority_components(game)
comeback = get_comeback_bonus(game)
momentum = _momentum_components(game)
importance_comps = _importance_components(game)
total_priority = priority_comps["total"] + comeback + importance_comps["total"]
total_priority = (
priority_comps["total"] + momentum["total"] + importance_comps["total"]
)
extracted_info.append(
{
"Home Team": game["homeTeam"]["name"]["default"],
@@ -63,10 +65,12 @@ def parse_games(scoreboard_data):
"base": priority_comps["base"],
"time": priority_comps["time"],
"matchup_bonus": priority_comps["matchup_bonus"],
"closeness": priority_comps["closeness"],
"score_state": priority_comps["score_state"],
"high_scoring": priority_comps["high_scoring"],
"power_play": priority_comps["power_play"],
"empty_net": priority_comps["empty_net"],
"comeback": comeback,
"comeback": momentum["comeback"],
"goal_spike": momentum["goal_spike"],
"importance": importance_comps["total"],
"importance_season_weight": importance_comps["season_weight"],
"importance_playoff_relevance": importance_comps[
@@ -109,17 +113,18 @@ def parse_games(scoreboard_data):
return sorted(extracted_info, key=_sort_key)
def get_comeback_bonus(game):
"""Persistent comeback bonus that scales with deficit recovered.
def _momentum_components(game):
"""Detects comeback recovery and fresh-goal spikes in a single pass.
Tracks the maximum score differential seen in the game. A recovery of 2+
goals earns a sustained bonus that persists as long as the game remains
close. One-goal swings are normal hockey and earn no bonus.
Updates both caches exactly once per call. Returns:
- comeback: persistent bonus while a 2+ goal deficit is being recovered
- goal_spike: one-tick bonus on the refresh where a goal just landed
"""
zero = {"comeback": 0, "goal_spike": 0, "total": 0}
if game["gameState"] not in ("LIVE", "CRIT"):
return 0
return zero
if game["clock"]["inIntermission"]:
return 0
return zero
home_name = game["homeTeam"]["name"]["default"]
away_name = game["awayTeam"]["name"]["default"]
@@ -127,25 +132,54 @@ def get_comeback_bonus(game):
home_score = game["homeTeam"]["score"]
away_score = game["awayTeam"]["score"]
current = (home_score, away_score)
current_diff = abs(home_score - away_score)
period = game.get("periodDescriptor", {}).get("number", 0)
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
# ── Goal spike (fires the single tick after a score changes) ─────────
previous = _score_cache.get(key)
goal_spike = 0
if previous is not None and previous != current:
if period >= 4:
goal_spike = 100
elif period == 3 and time_remaining <= 300:
goal_spike = 80
elif period == 3 and time_remaining <= 720:
goal_spike = 60
elif period == 3:
goal_spike = 40
else:
goal_spike = 25
# ── Comeback tracking ────────────────────────────────────────────────
tracker_max = _comeback_tracker.get(key, 0)
if key in _score_cache:
prev_diff = abs(_score_cache[key][0] - _score_cache[key][1])
tracker_max = max(tracker_max, prev_diff)
if previous is not None:
tracker_max = max(tracker_max, abs(previous[0] - previous[1]))
_comeback_tracker[key] = tracker_max
_score_cache[key] = (home_score, away_score)
_score_cache[key] = current
recovery = tracker_max - current_diff
if recovery < 2 or tracker_max < 2:
return 0
comeback = 0
if recovery >= 2 and tracker_max >= 2:
base = {2: 50, 3: 90}.get(recovery, 120)
period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2)
tie_bonus = 20 if current_diff == 0 else 0
comeback = int(base * period_mult + tie_bonus)
base = {2: 60, 3: 120}.get(recovery, 160)
period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2)
tie_bonus = 30 if current_diff == 0 else 0
return {
"comeback": comeback,
"goal_spike": goal_spike,
"total": comeback + goal_spike,
}
return int(base * period_mult + tie_bonus)
def get_comeback_bonus(game):
return _momentum_components(game)["comeback"]
def get_goal_spike(game):
return _momentum_components(game)["goal_spike"]
def convert_game_state(game_state):
@@ -210,13 +244,42 @@ def _get_man_advantage(situation):
return abs(home_total - away_total)
def _score_state_bonus(diff, period, time_remaining):
"""Unified score-state contribution: closeness reward AND blowout penalty,
coherent across period and time remaining. Replaces closeness + diff
penalty + late-3rd urgency as a single signal."""
if period >= 4:
# OT is always tied — flat tension bonus
return 60
if period <= 2:
return {0: 50, 1: 30, 2: 10, 3: -30, 4: -80}.get(diff, -120)
# Period 3 — depends on time remaining
mins_left = time_remaining / 60
if mins_left > 12:
return {0: 70, 1: 40, 2: 0, 3: -50, 4: -120}.get(diff, -160)
if mins_left > 6:
return {0: 120, 1: 80, 2: 20, 3: -80, 4: -180}.get(diff, -240)
if mins_left > 2:
# Goalie-pull zone — 2-goal deficit becomes interesting again
return {0: 180, 1: 140, 2: 50, 3: -80, 4: -200}.get(diff, -260)
# Final 2 min — peak tension
return {0: 220, 1: 180, 2: 80, 3: -50, 4: -220}.get(diff, -280)
def _priority_components(game):
"""Return a dict of all priority components plus the final total."""
"""Return a dict of all priority components plus the final total.
Calibrated so typical late-P3 tied games land around 550-650 and only
genuinely rare moments (playoff OT + comeback + PP) exceed 900.
"""
_zero = {
"base": 0,
"time": 0,
"matchup_bonus": 0,
"closeness": 0,
"score_state": 0,
"high_scoring": 0,
"power_play": 0,
"empty_net": 0,
"total": 0,
@@ -225,25 +288,32 @@ def _priority_components(game):
if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]:
return _zero
period = game.get("periodDescriptor", {}).get("number", 0)
# Pushes intermission games to the bottom, retains relative sort order
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
if game["clock"]["inIntermission"]:
return {**_zero, "total": -2000 - time_remaining}
period = game.get("periodDescriptor", {}).get("number", 0)
home_score = game["homeTeam"]["score"]
away_score = game["awayTeam"]["score"]
score_difference = abs(home_score - away_score)
total_goals = home_score + away_score
is_playoff = game.get("gameType", 2) == 3
# ── 1. Base priority by period ────────────────────────────────────────
# ── 1. Base priority by period (tightened) ───────────────────────────
if is_playoff:
base_priority = {1: 150, 2: 200, 3: 350}.get(period, 600 + (period - 4) * 150)
# Multi-OT escalates aggressively (P4=500, P5=620, P6=760, P7=920…)
base_priority = {1: 120, 2: 180, 3: 280}.get(
period, 500 + (period - 4) * 120 + max(0, period - 5) * 20
)
else:
base_priority = {1: 150, 2: 200, 3: 350, 4: 600, 5: 700}.get(period, 150)
# Regular season: P4=5-min OT, P5=shootout
base_priority = {1: 80, 2: 120, 3: 200, 4: 400, 5: 380}.get(period, 80)
# ── 2. Period length for time calculations ───────────────────────────
# ── 2. Period length for time calculations ───────────────────────────
period_length = (1200 if is_playoff else 300) if period >= 4 else 1200
# ── 3. Standings-quality matchup bonus ───────────────────────────────
# Invert rank so that #1 (best) contributes the most quality points.
# league_sequence 1=best, 32=worst → inverted: 32 quality pts for #1, 1 for #32.
# ── 3. Matchup bonus (minor contributor now) ─────────────────────────
home_standings = get_team_standings(game["homeTeam"]["name"]["default"])
away_standings = get_team_standings(game["awayTeam"]["name"]["default"])
home_quality = (33 - home_standings["league_sequence"]) + (
@@ -252,115 +322,84 @@ def _priority_components(game):
away_quality = (33 - away_standings["league_sequence"]) + (
33 - away_standings["league_l10_sequence"]
)
# Higher period = matchup matters less (any OT is exciting regardless of teams)
matchup_multiplier = {1: 1.5, 2: 1.5, 3: 1.25, 4: 1.0}.get(period, 1.0)
matchup_bonus = (home_quality + away_quality) * matchup_multiplier
# Max combined quality = 128. Divided by 5 → 0-25.6. Multiplier tightens further.
matchup_raw = (home_quality + away_quality) / 5
matchup_multiplier = {1: 1.5, 2: 1.25, 3: 1.0, 4: 0.5}.get(period, 0.5)
matchup_bonus = int(matchup_raw * matchup_multiplier)
# ── Shootout: flat priority, no time component (rounds, not clock) ───
# ── Shootout: flat skills-competition score, no time component ──────
if period == 5 and not is_playoff:
so_base = 550
so_closeness = 80
so_matchup = (home_quality + away_quality) * 1.0
so_total = int(so_base + so_closeness + so_matchup)
so_total = int(380 + 60 + matchup_bonus)
return {
"base": so_base,
"base": 380,
"time": 0,
"matchup_bonus": int(so_matchup),
"closeness": so_closeness,
"matchup_bonus": matchup_bonus,
"score_state": 60,
"high_scoring": 0,
"power_play": 0,
"empty_net": 0,
"total": so_total,
}
# ── 4. Score-differential penalty (period-aware) ───────────────────────
score_differential_adjustment = 0
if period <= 2:
adj = {0: 0, 1: 0, 2: 60, 3: 200, 4: 350}
score_differential_adjustment = adj.get(
score_difference, 350 + (score_difference - 4) * 100
)
elif period == 3:
mins_left = time_remaining / 60
if mins_left > 10:
adj = {0: 0, 1: 0, 2: 80, 3: 250, 4: 400}
elif mins_left > 5:
adj = {0: 0, 1: 0, 2: 120, 3: 350, 4: 500}
elif mins_left > 2:
# Goalie-pull zone: 2-goal penalty DECREASES
adj = {0: 0, 1: 0, 2: 80, 3: 450, 4: 600}
else:
# Final 2 min: 2-goal deficit with active goalie pull is exciting
adj = {0: 0, 1: 0, 2: 60, 3: 550, 4: 700}
score_differential_adjustment = adj.get(
score_difference, adj[4] + (score_difference - 4) * 100
)
# OT: always tied, no penalty needed
# ── 4. Unified score-state bonus ─────────────────────────────────────
score_state = _score_state_bonus(score_difference, period, time_remaining)
base_priority -= score_differential_adjustment
# ── 5. Late-3rd urgency bonus ─────────────────────────────────────────
if period == 3 and time_remaining <= 720:
if score_difference == 0:
base_priority += 100
elif score_difference == 1:
base_priority += 60
if period == 3 and time_remaining <= 360:
if score_difference == 0:
base_priority += 50
elif score_difference == 1:
base_priority += 30
# ── 6. Closeness bonus ───────────────────────────────────────────────
closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0)
# ── 7. Time priority (non-linear — final minutes weighted more) ─────
time_multiplier = {4: 5, 3: 5, 2: 3}.get(period, 2.0 if period >= 5 else 1.5)
# ── 5. Time priority (cap per period, non-linear toward end) ─────────
time_priority_max = {1: 30, 2: 60, 3: 120}.get(period, 200 if is_playoff else 60)
elapsed_fraction = (
max(0.0, (period_length - time_remaining) / period_length)
if period_length
else 0
)
time_priority = (elapsed_fraction**1.5) * (period_length / 20) * time_multiplier
time_priority = (elapsed_fraction**1.6) * time_priority_max
# ── 8. Power play bonus ───────────────────────────────────────────────
# ── 6. High-scoring bonus for close games with 6+ combined goals ─────
# Rewards the "shootout-y" vibe where next goal carries more weight.
high_scoring_bonus = 0
if total_goals >= 6 and score_difference <= 2:
high_scoring_bonus = min((total_goals - 5) * 12, 60)
if period < 3:
high_scoring_bonus = int(high_scoring_bonus * 0.5)
# ── 7. Power play bonus (tightened) ──────────────────────────────────
pp_bonus = 0
situation = game.get("situation", {})
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
if "PP" in home_descs or "PP" in away_descs:
man_advantage = _get_man_advantage(situation)
advantage_mult = 1.0 if man_advantage <= 1 else 1.6
advantage_mult = 1.0 if man_advantage <= 1 else 1.5
if period >= 4:
pp_bonus = int(200 * advantage_mult)
pp_bonus = int(120 * advantage_mult)
elif period == 3 and time_remaining <= 300:
pp_bonus = int(150 * advantage_mult)
pp_bonus = int(90 * advantage_mult)
elif period == 3 and time_remaining <= 720:
pp_bonus = int(100 * advantage_mult)
pp_bonus = int(60 * advantage_mult)
elif period == 3:
pp_bonus = int(50 * advantage_mult)
pp_bonus = int(35 * advantage_mult)
else:
pp_bonus = int(30 * advantage_mult)
pp_bonus = int(20 * advantage_mult)
# ── 9. Empty net bonus ───────────────────────────────────────────────
# ── 8. Empty net bonus (tightened) ───────────────────────────────────
en_bonus = 0
if "EN" in home_descs or "EN" in away_descs:
if period >= 4:
en_bonus = 250
en_bonus = 180
elif period == 3 and time_remaining <= 180:
en_bonus = 200
en_bonus = 140
elif period == 3 and time_remaining <= 360:
en_bonus = 150
en_bonus = 100
else:
en_bonus = 75
en_bonus = 50
logger.debug(
"priority components — base: %s, time: %.0f, matchup_bonus: %.0f, "
"closeness: %s, pp: %s, en: %s",
"priority components — base: %s, time: %.0f, matchup: %s, "
"score_state: %s, high_scoring: %s, pp: %s, en: %s",
base_priority,
time_priority,
matchup_bonus,
closeness_bonus,
score_state,
high_scoring_bonus,
pp_bonus,
en_bonus,
)
@@ -369,20 +408,18 @@ def _priority_components(game):
base_priority
+ time_priority
+ matchup_bonus
+ closeness_bonus
+ score_state
+ high_scoring_bonus
+ pp_bonus
+ en_bonus
)
# Pushes intermission games to the bottom, retains relative sort order
if game["clock"]["inIntermission"]:
return {**_zero, "total": -2000 - time_remaining}
return {
"base": base_priority,
"time": int(time_priority),
"matchup_bonus": int(matchup_bonus),
"closeness": closeness_bonus,
"matchup_bonus": matchup_bonus,
"score_state": score_state,
"high_scoring": high_scoring_bonus,
"power_play": pp_bonus,
"empty_net": en_bonus,
"total": final_priority,
@@ -428,15 +465,14 @@ def get_team_standings(team_name):
def _playoff_importance(game):
"""Importance for playoff games based on series context and round."""
"""Importance for playoff games — round + series context. Max 150."""
series = game.get("seriesStatus", {})
if not series:
# No series data available — flat playoff bonus
return {
"season_weight": 1.0,
"playoff_relevance": 0.50,
"rivalry": 1.0,
"total": 100,
"total": 60,
}
round_num = series.get("round", 1)
@@ -448,17 +484,17 @@ def _playoff_importance(game):
round_mult = {1: 1.0, 2: 1.15, 3: 1.30, 4: 1.50}.get(round_num, 1.0)
if max_wins == 3 and min_wins == 3:
series_factor = 1.0
series_factor = 1.0 # Game 7
elif max_wins == 3:
series_factor = 0.85
series_factor = 0.90 # Elimination game
elif max_wins == 2 and min_wins == 2:
series_factor = 0.70
series_factor = 0.75 # Pivotal tied series
elif max_wins == 2:
series_factor = 0.55
series_factor = 0.60
else:
series_factor = 0.40
series_factor = 0.45
importance = min(int(series_factor * round_mult * 200), 200)
importance = min(int(series_factor * round_mult * 100), 150)
return {
"season_weight": round_mult,
@@ -469,7 +505,7 @@ def _playoff_importance(game):
def _importance_components(game):
"""Return a dict of all importance components plus the final total."""
"""Regular-season importance — season_weight × stakes × rivalry. Max 100."""
_zero = {"season_weight": 0.0, "playoff_relevance": 0.0, "rivalry": 1.0, "total": 0}
if game["gameState"] in ("FINAL", "OFF"):
@@ -482,30 +518,29 @@ def _importance_components(game):
home_st = get_team_standings(game["homeTeam"]["name"]["default"])
away_st = get_team_standings(game["awayTeam"]["name"]["default"])
# Season weight — near-zero before game 30, sharp ramp 55-70, max at 82
# Season weight — zero before game 30, linear ramp to 1.0 at game 70+
avg_gp = (home_st["games_played"] + away_st["games_played"]) / 2
if avg_gp <= 30:
season_weight = 0.05
season_weight = 0.0
else:
t = (avg_gp - 30) / (82 - 30)
season_weight = min(t**1.8, 1.0)
season_weight = min((avg_gp - 30) / 40, 1.0)
# Playoff relevance — peaks for bubble teams (wildcard rank ~17-19)
# Playoff stakes — peaks on the bubble, drops for locked-in or out
best_wc = min(
home_st["wildcard_sequence"] or 32, away_st["wildcard_sequence"] or 32
)
if best_wc <= 12:
playoff_relevance = 0.60
if best_wc <= 8:
stakes = 0.5 # locked in, pressure off
elif best_wc <= 16:
playoff_relevance = 0.85
elif best_wc <= 19:
playoff_relevance = 1.00
elif best_wc <= 23:
playoff_relevance = 0.65
stakes = 0.8 # comfortable, meaningful
elif best_wc <= 20:
stakes = 1.0 # bubble, every point critical
elif best_wc <= 24:
stakes = 0.5 # slipping
else:
playoff_relevance = 0.15
stakes = 0.2 # effectively out
# Division/conference rivalry multiplier
# Rivalry — division > conference > other
home_div = home_st["division_abbrev"]
away_div = away_st["division_abbrev"]
home_conf = home_st["conference_abbrev"]
@@ -517,21 +552,20 @@ def _importance_components(game):
else:
rivalry_multiplier = 1.0
raw = season_weight * playoff_relevance * rivalry_multiplier
importance = max(0, min(int((raw / 1.4) * 150), 150))
importance = int(season_weight * stakes * rivalry_multiplier * 70)
importance = max(0, min(importance, 100))
logger.debug(
"importance components — season_weight: %.3f, playoff_relevance: %.2f, "
"rivalry: %.1f, importance: %s",
"importance — season_weight: %.2f, stakes: %.2f, rivalry: %.1f, total: %s",
season_weight,
playoff_relevance,
stakes,
rivalry_multiplier,
importance,
)
return {
"season_weight": round(season_weight, 3),
"playoff_relevance": playoff_relevance,
"playoff_relevance": stakes,
"rivalry": rivalry_multiplier,
"total": importance,
}