Pure functions for computing boosted card ratings when a player reaches a new Refractor tier. Batter boost applies fixed +0.5 to four offensive columns per tier; pitcher boost uses a 1.5 TB-budget priority algorithm. Both preserve the 108-sum invariant. - Create refractor_boost.py with apply_batter_boost, apply_pitcher_boost, and compute_variant_hash (Decimal arithmetic, zero-floor truncation) - Add RefractorBoostAudit model, Card.variant, BattingCard/PitchingCard image_url, RefractorCardState.variant fields to db_engine.py - Add migration SQL for refractor_card_state.variant column and refractor_boost_audit table (JSONB, UNIQUE constraint, transactional) - 26 unit tests covering 108-sum invariant, deltas, truncation, TB accounting, determinism, x-check protection, and variant hash behavior Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
316 lines
10 KiB
Python
316 lines
10 KiB
Python
"""Refractor rating boost service (Phase 2).
|
||
|
||
Pure functions for computing boosted card ratings when a player
|
||
reaches a new Refractor tier. Called by the orchestration layer
|
||
in apply_tier_boost().
|
||
|
||
Batter boost: fixed +0.5 to four offensive columns per tier.
|
||
Pitcher boost: 1.5 TB-budget priority algorithm per tier.
|
||
"""
|
||
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batter constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
BATTER_POSITIVE_DELTAS: dict[str, Decimal] = {
|
||
"homerun": Decimal("0.50"),
|
||
"double_pull": Decimal("0.50"),
|
||
"single_one": Decimal("0.50"),
|
||
"walk": Decimal("0.50"),
|
||
}
|
||
|
||
BATTER_NEGATIVE_DELTAS: dict[str, Decimal] = {
|
||
"strikeout": Decimal("-1.50"),
|
||
"groundout_a": Decimal("-0.50"),
|
||
}
|
||
|
||
# All 22 outcome columns that must sum to 108.
|
||
BATTER_OUTCOME_COLUMNS: list[str] = [
|
||
"homerun",
|
||
"bp_homerun",
|
||
"triple",
|
||
"double_three",
|
||
"double_two",
|
||
"double_pull",
|
||
"single_two",
|
||
"single_one",
|
||
"single_center",
|
||
"bp_single",
|
||
"hbp",
|
||
"walk",
|
||
"strikeout",
|
||
"lineout",
|
||
"popout",
|
||
"flyout_a",
|
||
"flyout_bq",
|
||
"flyout_lf_b",
|
||
"flyout_rf_b",
|
||
"groundout_a",
|
||
"groundout_b",
|
||
"groundout_c",
|
||
]
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pitcher constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# (column, tb_cost) pairs in priority order.
|
||
PITCHER_PRIORITY: list[tuple[str, int]] = [
|
||
("double_cf", 2),
|
||
("double_three", 2),
|
||
("double_two", 2),
|
||
("single_center", 1),
|
||
("single_two", 1),
|
||
("single_one", 1),
|
||
("bp_single", 1),
|
||
("walk", 1),
|
||
("homerun", 4),
|
||
("bp_homerun", 4),
|
||
("triple", 3),
|
||
("hbp", 1),
|
||
]
|
||
|
||
# All 18 variable outcome columns that must sum to 79.
|
||
PITCHER_OUTCOME_COLUMNS: list[str] = [
|
||
"homerun",
|
||
"bp_homerun",
|
||
"triple",
|
||
"double_three",
|
||
"double_two",
|
||
"double_cf",
|
||
"single_two",
|
||
"single_one",
|
||
"single_center",
|
||
"bp_single",
|
||
"hbp",
|
||
"walk",
|
||
"strikeout",
|
||
"flyout_lf_b",
|
||
"flyout_cf_b",
|
||
"flyout_rf_b",
|
||
"groundout_a",
|
||
"groundout_b",
|
||
]
|
||
|
||
# Cross-check columns that are NEVER modified by the boost algorithm.
|
||
PITCHER_XCHECK_COLUMNS: list[str] = [
|
||
"xcheck_p",
|
||
"xcheck_c",
|
||
"xcheck_1b",
|
||
"xcheck_2b",
|
||
"xcheck_3b",
|
||
"xcheck_ss",
|
||
"xcheck_lf",
|
||
"xcheck_cf",
|
||
"xcheck_rf",
|
||
]
|
||
|
||
PITCHER_TB_BUDGET = Decimal("1.5")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batter boost
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def apply_batter_boost(ratings_dict: dict) -> dict:
|
||
"""Apply one Refractor tier boost to a batter's outcome ratings.
|
||
|
||
Adds fixed positive deltas to four offensive columns (homerun, double_pull,
|
||
single_one, walk) while funding that increase by reducing strikeout and
|
||
groundout_a. A 0-floor is enforced on negative columns: if the full
|
||
reduction cannot be taken, positive deltas are scaled proportionally so that
|
||
the invariant (22 columns sum to 108.0) is always preserved.
|
||
|
||
Args:
|
||
ratings_dict: Dict containing at minimum all 22 BATTER_OUTCOME_COLUMNS
|
||
as numeric (int or float) values.
|
||
|
||
Returns:
|
||
New dict with the same keys as ratings_dict, with boosted outcome column
|
||
values as floats. All other keys are passed through unchanged.
|
||
|
||
Raises:
|
||
KeyError: If any BATTER_OUTCOME_COLUMNS key is missing from ratings_dict.
|
||
"""
|
||
result = dict(ratings_dict)
|
||
|
||
# Step 1 — convert the 22 outcome columns to Decimal for precise arithmetic.
|
||
ratings: dict[str, Decimal] = {
|
||
col: Decimal(str(result[col])) for col in BATTER_OUTCOME_COLUMNS
|
||
}
|
||
|
||
# Step 2 — apply negative deltas with 0-floor, tracking how much was
|
||
# actually removed versus how much was requested.
|
||
total_requested_reduction = Decimal("0")
|
||
total_actually_reduced = Decimal("0")
|
||
|
||
for col, delta in BATTER_NEGATIVE_DELTAS.items():
|
||
requested = abs(delta)
|
||
total_requested_reduction += requested
|
||
actual = min(requested, ratings[col])
|
||
ratings[col] -= actual
|
||
total_actually_reduced += actual
|
||
|
||
# Step 3 — check whether any truncation occurred.
|
||
total_truncated = total_requested_reduction - total_actually_reduced
|
||
|
||
# Step 4 — scale positive deltas if we couldn't take the full reduction.
|
||
if total_truncated > Decimal("0"):
|
||
# Positive additions must equal what was actually reduced so the
|
||
# 108-sum is preserved.
|
||
total_requested_addition = sum(BATTER_POSITIVE_DELTAS.values())
|
||
if total_requested_addition > Decimal("0"):
|
||
scale = total_actually_reduced / total_requested_addition
|
||
else:
|
||
scale = Decimal("0")
|
||
logger.warning(
|
||
"refractor_boost: batter truncation occurred — "
|
||
"requested_reduction=%.4f actually_reduced=%.4f scale=%.6f",
|
||
float(total_requested_reduction),
|
||
float(total_actually_reduced),
|
||
float(scale),
|
||
)
|
||
# Quantize the first N-1 deltas independently, then assign the last
|
||
# delta as the remainder so the total addition equals
|
||
# total_actually_reduced exactly (no quantize drift across 4 ops).
|
||
pos_cols = list(BATTER_POSITIVE_DELTAS.keys())
|
||
positive_deltas = {}
|
||
running_sum = Decimal("0")
|
||
for col in pos_cols[:-1]:
|
||
scaled = (BATTER_POSITIVE_DELTAS[col] * scale).quantize(
|
||
Decimal("0.000001"), rounding=ROUND_HALF_UP
|
||
)
|
||
positive_deltas[col] = scaled
|
||
running_sum += scaled
|
||
last_delta = total_actually_reduced - running_sum
|
||
positive_deltas[pos_cols[-1]] = max(last_delta, Decimal("0"))
|
||
else:
|
||
positive_deltas = BATTER_POSITIVE_DELTAS
|
||
|
||
# Step 5 — apply (possibly scaled) positive deltas.
|
||
for col, delta in positive_deltas.items():
|
||
ratings[col] += delta
|
||
|
||
# Write boosted values back as floats.
|
||
for col in BATTER_OUTCOME_COLUMNS:
|
||
result[col] = float(ratings[col])
|
||
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pitcher boost
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def apply_pitcher_boost(ratings_dict: dict, tb_budget: float = 1.5) -> dict:
|
||
"""Apply one Refractor tier boost to a pitcher's outcome ratings.
|
||
|
||
Iterates through PITCHER_PRIORITY in order, converting as many outcome
|
||
chances as the TB budget allows into strikeouts. The TB cost per chance
|
||
varies by outcome type (e.g. a double costs 2 TB budget units, a single
|
||
costs 1). The strikeout column absorbs all converted chances.
|
||
|
||
X-check columns (xcheck_p through xcheck_rf) are never touched.
|
||
|
||
Args:
|
||
ratings_dict: Dict containing at minimum all 18 PITCHER_OUTCOME_COLUMNS
|
||
as numeric (int or float) values.
|
||
tb_budget: Total base budget available for this boost tier. Defaults
|
||
to 1.5 (PITCHER_TB_BUDGET).
|
||
|
||
Returns:
|
||
New dict with the same keys as ratings_dict, with boosted outcome column
|
||
values as floats. All other keys are passed through unchanged.
|
||
|
||
Raises:
|
||
KeyError: If any PITCHER_OUTCOME_COLUMNS key is missing from ratings_dict.
|
||
"""
|
||
result = dict(ratings_dict)
|
||
|
||
# Step 1 — convert outcome columns to Decimal, set remaining budget.
|
||
ratings: dict[str, Decimal] = {
|
||
col: Decimal(str(result[col])) for col in PITCHER_OUTCOME_COLUMNS
|
||
}
|
||
remaining = Decimal(str(tb_budget))
|
||
|
||
# Step 2 — iterate priority list, draining budget.
|
||
for col, tb_cost in PITCHER_PRIORITY:
|
||
if ratings[col] <= Decimal("0"):
|
||
continue
|
||
|
||
tb_cost_d = Decimal(str(tb_cost))
|
||
max_chances = remaining / tb_cost_d
|
||
chances_to_take = min(ratings[col], max_chances)
|
||
|
||
ratings[col] -= chances_to_take
|
||
ratings["strikeout"] += chances_to_take
|
||
remaining -= chances_to_take * tb_cost_d
|
||
|
||
if remaining <= Decimal("0"):
|
||
break
|
||
|
||
# Step 3 — warn if budget was not fully spent (rare, indicates all priority
|
||
# columns were already at zero).
|
||
if remaining > Decimal("0"):
|
||
logger.warning(
|
||
"refractor_boost: pitcher TB budget not fully spent — "
|
||
"remaining=%.4f of tb_budget=%.4f",
|
||
float(remaining),
|
||
tb_budget,
|
||
)
|
||
|
||
# Write boosted values back as floats.
|
||
for col in PITCHER_OUTCOME_COLUMNS:
|
||
result[col] = float(ratings[col])
|
||
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Variant hash
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def compute_variant_hash(
|
||
player_id: int,
|
||
evolution_tier: int,
|
||
cosmetics: list[str] | None = None,
|
||
) -> int:
|
||
"""Compute a stable, deterministic variant identifier for a boosted card.
|
||
|
||
Hashes the combination of player_id, evolution_tier, and an optional sorted
|
||
list of cosmetic identifiers to produce a compact integer suitable for use
|
||
as a database variant key. The result is derived from the first 8 hex
|
||
characters of a SHA-256 digest, so collisions are extremely unlikely in
|
||
practice.
|
||
|
||
variant=0 is reserved and will never be returned; any hash that resolves to
|
||
0 is remapped to 1.
|
||
|
||
Args:
|
||
player_id: Player primary key.
|
||
evolution_tier: Refractor tier (0–4) the card has reached.
|
||
cosmetics: Optional list of cosmetic tag strings (e.g. special art
|
||
identifiers). Order is normalised — callers need not sort.
|
||
|
||
Returns:
|
||
A positive integer in the range [1, 2^32 - 1].
|
||
"""
|
||
inputs = {
|
||
"player_id": player_id,
|
||
"evolution_tier": evolution_tier,
|
||
"cosmetics": sorted(cosmetics or []),
|
||
}
|
||
raw = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
|
||
result = int(raw[:8], 16)
|
||
return result if result != 0 else 1 # variant=0 is reserved
|