fix: guard against T4 rarity upgrade collision in live-series pipeline (#57)

Closes #57

- Add RARITY_LADDER and rarity_is_downgrade() to rarity_thresholds.py
- Add get_fully_evolved_players() to db_calls.py — queries a to-be-created
  database endpoint; returns empty set safely if endpoint is unavailable
- In batters/creation.py post_player_updates(): pre-flight check identifies
  players where OPS rarity would downgrade, then guards the rarity write to
  skip any downgrade for fully-evolved (T4) cards
- Same guard added to pitchers/creation.py post_player_updates()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-04-08 06:08:59 -05:00
parent ae73ce6755
commit aaa2eaa252
4 changed files with 125 additions and 18 deletions

View File

@ -18,11 +18,11 @@ from creation_helpers import (
calculate_rarity_cost_adjustment,
DEFAULT_BATTER_OPS,
)
from db_calls import db_post, db_get, db_put, db_patch
from db_calls import db_post, db_get, db_put, db_patch, get_fully_evolved_players
from . import calcs_batter as cba
from defenders import calcs_defense as cde
from exceptions import logger
from rarity_thresholds import get_batter_thresholds
from rarity_thresholds import get_batter_thresholds, rarity_is_downgrade
async def pd_battingcards_df(cardset_id: int):
@ -384,6 +384,24 @@ async def post_player_updates(
)
player_updates = {} # { <player_id> : [ (param pairs) ] }
# T4 rarity guard: identify players where OPS-derived rarity would be a
# downgrade and check whether any of them have a fully-evolved refractor
# state. If so, their current (T4-earned) rarity is the floor and must
# not be overwritten. Falls back to empty set if the API endpoint is
# unavailable, so the guard degrades safely without blocking the pipeline.
downgrade_candidates = player_data[
player_data.apply(
lambda r: rarity_is_downgrade(r["rarity"], r["new_rarity_id"]), axis=1
)
]["player_id"].tolist()
t4_protected_ids = await get_fully_evolved_players(downgrade_candidates)
if t4_protected_ids:
logger.info(
f"batters.creation.post_player_updates - {len(t4_protected_ids)} player(s) "
f"protected from rarity downgrade by T4 refractor floor: {t4_protected_ids}"
)
rarity_group = player_data.query("rarity == new_rarity_id").groupby("rarity")
average_ops = rarity_group["total_OPS"].mean().to_dict()
@ -454,13 +472,27 @@ async def post_player_updates(
)
elif df_data["rarity"] != df_data["new_rarity_id"]:
# Calculate adjusted cost for rarity change using lookup table
new_cost = calculate_rarity_cost_adjustment(
old_rarity=df_data["rarity"],
new_rarity=df_data["new_rarity_id"],
old_cost=df_data["cost"],
)
params.extend([("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])])
# T4 guard: skip rarity downgrades for fully-evolved cards so that
# a T4-earned rarity bump is not silently reverted by the pipeline.
if (
rarity_is_downgrade(df_data["rarity"], df_data["new_rarity_id"])
and df_data.player_id in t4_protected_ids
):
logger.info(
f"batters.creation.post_player_updates - Skipping rarity downgrade "
f"for player_id={df_data.player_id}: T4 floor rarity={df_data['rarity']}, "
f"OPS rarity={df_data['new_rarity_id']}"
)
else:
# Calculate adjusted cost for rarity change using lookup table
new_cost = calculate_rarity_cost_adjustment(
old_rarity=df_data["rarity"],
new_rarity=df_data["new_rarity_id"],
old_cost=df_data["cost"],
)
params.extend(
[("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])]
)
if len(params) > 0:
if df_data.player_id not in player_updates.keys():

View File

@ -186,6 +186,31 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3)
raise ValueError(f"DB: {e}")
async def get_fully_evolved_players(player_ids: list) -> set:
"""Return the subset of player_ids that have any fully-evolved (T4) refractor card state.
Calls GET /api/v2/refractor/fully-evolved with a comma-separated player_ids
query parameter. Returns an empty set if the endpoint is unavailable (404,
error, or missing field) so the caller degrades safely no T4 protection is
applied rather than blocking the pipeline.
NOTE: This endpoint does not yet exist in the database API. It must return
{"player_ids": [<int>, ...]} listing player IDs with fully_evolved=True.
Until it is added, this function always returns an empty set.
"""
if not player_ids:
return set()
ids_param = ",".join(str(pid) for pid in player_ids)
result = await db_get(
"refractor/fully-evolved",
params=[("player_ids", ids_param)],
none_okay=True,
)
if result is None or "player_ids" not in result:
return set()
return set(result["player_ids"])
def get_player_data(
player_id: str,
id_type: Literal["bbref", "fangraphs"],

View File

@ -17,11 +17,11 @@ from creation_helpers import (
DEFAULT_STARTER_OPS,
DEFAULT_RELIEVER_OPS,
)
from db_calls import db_post, db_get, db_put, db_patch
from db_calls import db_post, db_get, db_put, db_patch, get_fully_evolved_players
from defenders import calcs_defense as cde
from . import calcs_pitcher as cpi
from exceptions import logger
from rarity_thresholds import get_pitcher_thresholds
from rarity_thresholds import get_pitcher_thresholds, rarity_is_downgrade
def get_pitching_stats(
@ -463,6 +463,20 @@ async def post_player_updates(
)
player_updates = {} # { <player_id> : [ (param pairs) ] }
# T4 rarity guard: same protection as batters — see batters/creation.py.
downgrade_candidates = player_data[
player_data.apply(
lambda r: rarity_is_downgrade(r["rarity"], r["new_rarity_id"]), axis=1
)
]["player_id"].tolist()
t4_protected_ids = await get_fully_evolved_players(downgrade_candidates)
if t4_protected_ids:
logger.info(
f"pitchers.creation.post_player_updates - {len(t4_protected_ids)} player(s) "
f"protected from rarity downgrade by T4 refractor floor: {t4_protected_ids}"
)
sp_rarity_group = player_data.query(
"rarity == new_rarity_id and starter_rating >= 4"
).groupby("rarity")
@ -551,13 +565,27 @@ async def post_player_updates(
)
elif df_data["rarity"] != df_data["new_rarity_id"]:
# Calculate adjusted cost for rarity change using lookup table
new_cost = calculate_rarity_cost_adjustment(
old_rarity=df_data["rarity"],
new_rarity=df_data["new_rarity_id"],
old_cost=df_data["cost"],
)
params.extend([("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])])
# T4 guard: skip rarity downgrades for fully-evolved cards so that
# a T4-earned rarity bump is not silently reverted by the pipeline.
if (
rarity_is_downgrade(df_data["rarity"], df_data["new_rarity_id"])
and df_data.player_id in t4_protected_ids
):
logger.info(
f"pitchers.creation.post_player_updates - Skipping rarity downgrade "
f"for player_id={df_data.player_id}: T4 floor rarity={df_data['rarity']}, "
f"OPS rarity={df_data['new_rarity_id']}"
)
else:
# Calculate adjusted cost for rarity change using lookup table
new_cost = calculate_rarity_cost_adjustment(
old_rarity=df_data["rarity"],
new_rarity=df_data["new_rarity_id"],
old_cost=df_data["cost"],
)
params.extend(
[("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])]
)
if len(params) > 0:
if df_data.player_id not in player_updates.keys():

View File

@ -144,3 +144,25 @@ def get_batter_thresholds(season: int) -> BatterRarityThresholds:
return BATTER_THRESHOLDS_2025
else:
return BATTER_THRESHOLDS_2024
# Ordered from worst to best rarity. Used for ladder comparisons such as the
# T4 refractor guard — do not change the order.
RARITY_LADDER = [5, 4, 3, 2, 1, 99] # Common → Bronze → Silver → Gold → Diamond → HoF
def rarity_is_downgrade(current_rarity_id: int, new_rarity_id: int) -> bool:
"""Return True if new_rarity_id is a less prestigious tier than current_rarity_id.
Uses the RARITY_LADDER ordering. Unknown IDs are treated as position 0
(worst), so an unknown current rarity will never trigger a downgrade guard.
"""
try:
current_pos = RARITY_LADDER.index(current_rarity_id)
except ValueError:
return False
try:
new_pos = RARITY_LADDER.index(new_rarity_id)
except ValueError:
return False
return current_pos > new_pos