Merge pull request 'feat: Refractor Phase 2 foundation — boost functions, schema, tests' (#176) from feature/refractor-phase2-foundation into main
This commit is contained in:
commit
70f984392d
@ -474,6 +474,7 @@ class Card(BaseModel):
|
||||
team = ForeignKeyField(Team, null=True)
|
||||
pack = ForeignKeyField(Pack, null=True)
|
||||
value = IntegerField(default=0)
|
||||
variant = IntegerField(null=True, default=None)
|
||||
|
||||
def __str__(self):
|
||||
if self.player:
|
||||
@ -755,6 +756,7 @@ class BattingCard(BaseModel):
|
||||
running = IntegerField()
|
||||
offense_col = IntegerField()
|
||||
hand = CharField(default="R")
|
||||
image_url = CharField(null=True, max_length=500)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
@ -824,6 +826,7 @@ class PitchingCard(BaseModel):
|
||||
batting = CharField(null=True)
|
||||
offense_col = IntegerField()
|
||||
hand = CharField(default="R")
|
||||
image_url = CharField(null=True, max_length=500)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
@ -1232,6 +1235,7 @@ class RefractorCardState(BaseModel):
|
||||
current_value = FloatField(default=0.0)
|
||||
fully_evolved = BooleanField(default=False)
|
||||
last_evaluated_at = DateTimeField(null=True)
|
||||
variant = IntegerField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
@ -1290,9 +1294,29 @@ class RefractorCosmetic(BaseModel):
|
||||
table_name = "refractor_cosmetic"
|
||||
|
||||
|
||||
class RefractorBoostAudit(BaseModel):
|
||||
card_state = ForeignKeyField(RefractorCardState, on_delete="CASCADE")
|
||||
tier = IntegerField() # 1-4
|
||||
battingcard = ForeignKeyField(BattingCard, null=True)
|
||||
pitchingcard = ForeignKeyField(PitchingCard, null=True)
|
||||
variant_created = IntegerField()
|
||||
boost_delta_json = TextField() # JSONB in PostgreSQL; TextField for SQLite test compat
|
||||
applied_at = DateTimeField(default=datetime.now)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "refractor_boost_audit"
|
||||
|
||||
|
||||
if not SKIP_TABLE_CREATION:
|
||||
db.create_tables(
|
||||
[RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic],
|
||||
[
|
||||
RefractorTrack,
|
||||
RefractorCardState,
|
||||
RefractorTierBoost,
|
||||
RefractorCosmetic,
|
||||
RefractorBoostAudit,
|
||||
],
|
||||
safe=True,
|
||||
)
|
||||
|
||||
|
||||
315
app/services/refractor_boost.py
Normal file
315
app/services/refractor_boost.py
Normal file
@ -0,0 +1,315 @@
|
||||
"""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,
|
||||
refractor_tier: int,
|
||||
cosmetics: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Compute a stable, deterministic variant identifier for a boosted card.
|
||||
|
||||
Hashes the combination of player_id, refractor_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.
|
||||
refractor_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,
|
||||
"refractor_tier": refractor_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
|
||||
47
migrations/2026-03-28_refractor_phase2_boost.sql
Normal file
47
migrations/2026-03-28_refractor_phase2_boost.sql
Normal file
@ -0,0 +1,47 @@
|
||||
-- Migration: Refractor Phase 2 — rating boost support
|
||||
-- Date: 2026-03-28
|
||||
-- Purpose: Extends the Refractor system to track and audit rating boosts
|
||||
-- applied at each tier-up. Adds a variant column to
|
||||
-- refractor_card_state (mirrors card.variant for promoted copies)
|
||||
-- and creates the refractor_boost_audit table to record the
|
||||
-- boost delta, source card, and variant assigned at each tier.
|
||||
--
|
||||
-- Tables affected:
|
||||
-- refractor_card_state — new column: variant INTEGER
|
||||
-- refractor_boost_audit — new table
|
||||
--
|
||||
-- Run on dev first, verify with:
|
||||
-- SELECT column_name FROM information_schema.columns
|
||||
-- WHERE table_name = 'refractor_card_state'
|
||||
-- AND column_name = 'variant';
|
||||
-- SELECT count(*) FROM refractor_boost_audit;
|
||||
--
|
||||
-- Rollback: See DROP/ALTER statements at bottom of file
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Verify card.variant column exists (should be from Phase 1 migration).
|
||||
-- If not present, uncomment:
|
||||
-- ALTER TABLE card ADD COLUMN IF NOT EXISTS variant INTEGER DEFAULT NULL;
|
||||
|
||||
-- New columns on refractor_card_state (additive, no data migration needed)
|
||||
ALTER TABLE refractor_card_state ADD COLUMN IF NOT EXISTS variant INTEGER;
|
||||
|
||||
-- Boost audit table: records what was applied at each tier-up
|
||||
CREATE TABLE IF NOT EXISTS refractor_boost_audit (
|
||||
id SERIAL PRIMARY KEY,
|
||||
card_state_id INTEGER NOT NULL REFERENCES refractor_card_state(id) ON DELETE CASCADE,
|
||||
tier SMALLINT NOT NULL,
|
||||
battingcard_id INTEGER REFERENCES battingcard(id),
|
||||
pitchingcard_id INTEGER REFERENCES pitchingcard(id),
|
||||
variant_created INTEGER NOT NULL,
|
||||
boost_delta_json JSONB NOT NULL,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(card_state_id, tier) -- Prevent duplicate audit records on retry
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Rollback:
|
||||
-- DROP TABLE IF EXISTS refractor_boost_audit;
|
||||
-- ALTER TABLE refractor_card_state DROP COLUMN IF EXISTS variant;
|
||||
@ -44,10 +44,13 @@ from app.db_engine import (
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
BattingCard,
|
||||
PitchingCard,
|
||||
RefractorTrack,
|
||||
RefractorCardState,
|
||||
RefractorTierBoost,
|
||||
RefractorCosmetic,
|
||||
RefractorBoostAudit,
|
||||
ScoutOpportunity,
|
||||
ScoutClaim,
|
||||
)
|
||||
@ -80,6 +83,9 @@ _TEST_MODELS = [
|
||||
RefractorCardState,
|
||||
RefractorTierBoost,
|
||||
RefractorCosmetic,
|
||||
BattingCard,
|
||||
PitchingCard,
|
||||
RefractorBoostAudit,
|
||||
]
|
||||
|
||||
|
||||
|
||||
906
tests/test_refractor_boost.py
Normal file
906
tests/test_refractor_boost.py
Normal file
@ -0,0 +1,906 @@
|
||||
"""Unit tests for refractor_boost.py — Phase 2 boost functions.
|
||||
|
||||
Tests are grouped by function: batter boost, pitcher boost, and variant hash.
|
||||
Each class covers one behavioral concern.
|
||||
|
||||
The functions under test are pure (no DB, no I/O), so every test calls the
|
||||
function directly with a plain dict and asserts on the returned dict.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from app.services.refractor_boost import (
|
||||
apply_batter_boost,
|
||||
apply_pitcher_boost,
|
||||
compute_variant_hash,
|
||||
BATTER_OUTCOME_COLUMNS,
|
||||
PITCHER_OUTCOME_COLUMNS,
|
||||
PITCHER_PRIORITY,
|
||||
PITCHER_XCHECK_COLUMNS,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures: representative card ratings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _silver_batter_vr():
|
||||
"""Robinson Cano 2020 vR split — Silver batter with typical distribution.
|
||||
|
||||
Sum of the 22 outcome columns is exactly 108.0.
|
||||
Has comfortable strikeout (14.7) and groundout_a (3.85) buffers so a
|
||||
normal boost can be applied without triggering truncation.
|
||||
flyout_rf_b adjusted to 21.2 so the 22-column total is exactly 108.0.
|
||||
"""
|
||||
return {
|
||||
"homerun": 3.05,
|
||||
"bp_homerun": 0.0,
|
||||
"triple": 0.0,
|
||||
"double_three": 2.25,
|
||||
"double_two": 2.25,
|
||||
"double_pull": 8.95,
|
||||
"single_two": 3.35,
|
||||
"single_one": 14.55,
|
||||
"single_center": 5.0,
|
||||
"bp_single": 0.0,
|
||||
"hbp": 6.2,
|
||||
"walk": 7.4,
|
||||
"strikeout": 14.7,
|
||||
"lineout": 0.0,
|
||||
"popout": 0.0,
|
||||
"flyout_a": 5.3,
|
||||
"flyout_bq": 1.45,
|
||||
"flyout_lf_b": 1.95,
|
||||
"flyout_rf_b": 21.2,
|
||||
"groundout_a": 3.85,
|
||||
"groundout_b": 3.0,
|
||||
"groundout_c": 3.55,
|
||||
}
|
||||
|
||||
|
||||
def _contact_batter_vl():
|
||||
"""Low-strikeout contact hitter — different archetype for variety testing.
|
||||
|
||||
Strikeout (8.0) and groundout_a (5.0) are both non-zero, so the normal
|
||||
boost applies without truncation. Sum of 22 columns is exactly 108.0.
|
||||
flyout_rf_b raised to 16.5 to bring sum from 107.5 to 108.0.
|
||||
"""
|
||||
return {
|
||||
"homerun": 1.5,
|
||||
"bp_homerun": 0.0,
|
||||
"triple": 0.5,
|
||||
"double_three": 3.0,
|
||||
"double_two": 3.0,
|
||||
"double_pull": 7.0,
|
||||
"single_two": 5.0,
|
||||
"single_one": 18.0,
|
||||
"single_center": 6.0,
|
||||
"bp_single": 0.0,
|
||||
"hbp": 4.0,
|
||||
"walk": 8.0,
|
||||
"strikeout": 8.0,
|
||||
"lineout": 0.0,
|
||||
"popout": 0.0,
|
||||
"flyout_a": 6.0,
|
||||
"flyout_bq": 2.0,
|
||||
"flyout_lf_b": 3.0,
|
||||
"flyout_rf_b": 16.5,
|
||||
"groundout_a": 5.0,
|
||||
"groundout_b": 6.0,
|
||||
"groundout_c": 5.5,
|
||||
}
|
||||
|
||||
|
||||
def _power_batter_vr():
|
||||
"""High-HR power hitter — third archetype for variety testing.
|
||||
|
||||
Large strikeout (22.0) and groundout_a (2.0). Sum of 22 columns is 108.0.
|
||||
"""
|
||||
return {
|
||||
"homerun": 8.0,
|
||||
"bp_homerun": 0.0,
|
||||
"triple": 0.0,
|
||||
"double_three": 2.0,
|
||||
"double_two": 2.0,
|
||||
"double_pull": 6.0,
|
||||
"single_two": 3.0,
|
||||
"single_one": 10.0,
|
||||
"single_center": 4.0,
|
||||
"bp_single": 0.0,
|
||||
"hbp": 5.0,
|
||||
"walk": 9.0,
|
||||
"strikeout": 22.0,
|
||||
"lineout": 0.0,
|
||||
"popout": 0.0,
|
||||
"flyout_a": 7.0,
|
||||
"flyout_bq": 2.0,
|
||||
"flyout_lf_b": 3.0,
|
||||
"flyout_rf_b": 15.0,
|
||||
"groundout_a": 2.0,
|
||||
"groundout_b": 5.0,
|
||||
"groundout_c": 3.0,
|
||||
}
|
||||
|
||||
|
||||
def _singles_pitcher_vl():
|
||||
"""Gibson 2020 vL — contact/groundball SP with typical distribution.
|
||||
|
||||
Variable outcome columns (18) sum to 79. X-check columns sum to 29.
|
||||
Full card sums to 108. double_cf=2.95 so the priority algorithm will
|
||||
start there before moving to singles.
|
||||
"""
|
||||
return {
|
||||
# Variable columns (sum=79)
|
||||
"homerun": 3.3,
|
||||
"bp_homerun": 2.0,
|
||||
"triple": 0.75,
|
||||
"double_three": 0.0,
|
||||
"double_two": 0.0,
|
||||
"double_cf": 2.95,
|
||||
"single_two": 5.7,
|
||||
"single_one": 0.0,
|
||||
"single_center": 5.0,
|
||||
"bp_single": 5.0,
|
||||
"hbp": 3.0,
|
||||
"walk": 5.0,
|
||||
"strikeout": 10.0,
|
||||
"flyout_lf_b": 15.1,
|
||||
"flyout_cf_b": 0.9,
|
||||
"flyout_rf_b": 0.0,
|
||||
"groundout_a": 15.1,
|
||||
"groundout_b": 5.2,
|
||||
# X-check columns (sum=29)
|
||||
"xcheck_p": 1.0,
|
||||
"xcheck_c": 3.0,
|
||||
"xcheck_1b": 2.0,
|
||||
"xcheck_2b": 6.0,
|
||||
"xcheck_3b": 3.0,
|
||||
"xcheck_ss": 7.0,
|
||||
"xcheck_lf": 2.0,
|
||||
"xcheck_cf": 3.0,
|
||||
"xcheck_rf": 2.0,
|
||||
}
|
||||
|
||||
|
||||
def _no_doubles_pitcher():
|
||||
"""Pitcher with 0 in all double columns — tests priority skip behavior.
|
||||
|
||||
All three double columns (double_cf, double_three, double_two) are 0.0,
|
||||
so the algorithm must skip past them and start reducing singles.
|
||||
Variable columns sum to 79; x-checks sum to 29; full card sums to 108.
|
||||
groundout_b raised to 10.5 to bring variable sum from 77.0 to 79.0.
|
||||
"""
|
||||
return {
|
||||
# Variable columns (sum=79)
|
||||
"homerun": 2.0,
|
||||
"bp_homerun": 1.0,
|
||||
"triple": 0.5,
|
||||
"double_three": 0.0,
|
||||
"double_two": 0.0,
|
||||
"double_cf": 0.0,
|
||||
"single_two": 6.0,
|
||||
"single_one": 3.0,
|
||||
"single_center": 7.0,
|
||||
"bp_single": 5.0,
|
||||
"hbp": 2.0,
|
||||
"walk": 6.0,
|
||||
"strikeout": 12.0,
|
||||
"flyout_lf_b": 8.0,
|
||||
"flyout_cf_b": 4.0,
|
||||
"flyout_rf_b": 2.0,
|
||||
"groundout_a": 10.0,
|
||||
"groundout_b": 10.5,
|
||||
# X-check columns (sum=29)
|
||||
"xcheck_p": 1.0,
|
||||
"xcheck_c": 3.0,
|
||||
"xcheck_1b": 2.0,
|
||||
"xcheck_2b": 6.0,
|
||||
"xcheck_3b": 3.0,
|
||||
"xcheck_ss": 7.0,
|
||||
"xcheck_lf": 2.0,
|
||||
"xcheck_cf": 3.0,
|
||||
"xcheck_rf": 2.0,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _batter_sum(d: dict) -> float:
|
||||
"""Return sum of the 22 batter outcome columns."""
|
||||
return sum(d[col] for col in BATTER_OUTCOME_COLUMNS)
|
||||
|
||||
|
||||
def _pitcher_var_sum(d: dict) -> float:
|
||||
"""Return sum of the 18 pitcher variable outcome columns."""
|
||||
return sum(d[col] for col in PITCHER_OUTCOME_COLUMNS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batter boost: 108-sum invariant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatterBoost108Sum:
|
||||
"""Verify the 22-column 108-sum invariant is maintained after batter boost."""
|
||||
|
||||
def test_single_tier(self):
|
||||
"""After one boost, all 22 batter outcome columns still sum to exactly 108.
|
||||
|
||||
What: Apply a single boost to the Cano vR card and assert that the sum
|
||||
of all 22 outcome columns is 108.0 within float tolerance (1e-6).
|
||||
|
||||
Why: The fundamental invariant of every batter card is that outcomes
|
||||
add to 108 (the number of results in a standard at-bat die table).
|
||||
A boost that violates this would corrupt every game simulation that
|
||||
uses the card.
|
||||
"""
|
||||
result = apply_batter_boost(_silver_batter_vr())
|
||||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||||
|
||||
def test_cumulative_four_tiers(self):
|
||||
"""Apply boost 4 times sequentially; sum must be 108.0 after each tier.
|
||||
|
||||
What: Start from the Cano vR card and apply four successive tier
|
||||
boosts. Assert the 108-sum after each application — not just the
|
||||
last one.
|
||||
|
||||
Why: Floating-point drift can accumulate over multiple applications.
|
||||
By checking after every tier we catch any incremental drift before it
|
||||
compounds to a visible error in gameplay.
|
||||
"""
|
||||
card = _silver_batter_vr()
|
||||
for tier in range(1, 5):
|
||||
card = apply_batter_boost(card)
|
||||
total = _batter_sum(card)
|
||||
assert total == pytest.approx(108.0, abs=1e-6), (
|
||||
f"Sum drifted to {total} after tier {tier}"
|
||||
)
|
||||
|
||||
def test_different_starting_ratings(self):
|
||||
"""Apply boost to 3 different batter archetypes; all maintain 108.
|
||||
|
||||
What: Boost the Silver (Cano), Contact, and Power batter cards once
|
||||
each and verify the 108-sum for every one.
|
||||
|
||||
Why: The 108-sum must hold regardless of the starting distribution.
|
||||
A card with unusual column proportions (e.g. very high or low
|
||||
strikeout) might expose an edge case in the scaling math.
|
||||
"""
|
||||
for card_fn in (_silver_batter_vr, _contact_batter_vl, _power_batter_vr):
|
||||
result = apply_batter_boost(card_fn())
|
||||
total = _batter_sum(result)
|
||||
assert total == pytest.approx(108.0, abs=1e-6), (
|
||||
f"108-sum violated for {card_fn.__name__}: got {total}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batter boost: correct deltas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatterBoostDeltas:
|
||||
"""Verify that exactly the right columns change by exactly the right amounts."""
|
||||
|
||||
def test_positive_columns_increase(self):
|
||||
"""homerun, double_pull, single_one, and walk each increase by exactly 0.5.
|
||||
|
||||
What: Compare the four positive-delta columns before and after a boost
|
||||
on a card where no truncation occurs (Cano vR — strikeout=14.7 and
|
||||
groundout_a=3.85 are well above the required reductions).
|
||||
|
||||
Why: The boost's offensive intent is encoded in these four columns.
|
||||
Any deviation (wrong column, wrong amount) would silently change card
|
||||
power without the intended effect being applied.
|
||||
"""
|
||||
before = _silver_batter_vr()
|
||||
after = apply_batter_boost(before.copy())
|
||||
|
||||
assert after["homerun"] == pytest.approx(before["homerun"] + 0.5, abs=1e-9)
|
||||
assert after["double_pull"] == pytest.approx(
|
||||
before["double_pull"] + 0.5, abs=1e-9
|
||||
)
|
||||
assert after["single_one"] == pytest.approx(
|
||||
before["single_one"] + 0.5, abs=1e-9
|
||||
)
|
||||
assert after["walk"] == pytest.approx(before["walk"] + 0.5, abs=1e-9)
|
||||
|
||||
def test_negative_columns_decrease(self):
|
||||
"""strikeout decreases by 1.5 and groundout_a decreases by 0.5.
|
||||
|
||||
What: Compare the two negative-delta columns before and after a boost
|
||||
where no truncation occurs (Cano vR card).
|
||||
|
||||
Why: These reductions are the cost of the offensive improvement.
|
||||
If either column is not reduced by the correct amount the 108-sum
|
||||
would remain balanced only by accident.
|
||||
"""
|
||||
before = _silver_batter_vr()
|
||||
after = apply_batter_boost(before.copy())
|
||||
|
||||
assert after["strikeout"] == pytest.approx(before["strikeout"] - 1.5, abs=1e-9)
|
||||
assert after["groundout_a"] == pytest.approx(
|
||||
before["groundout_a"] - 0.5, abs=1e-9
|
||||
)
|
||||
|
||||
def test_extra_keys_passed_through(self):
|
||||
"""Extra keys in the input dict (like x-check columns) survive the boost unchanged.
|
||||
|
||||
What: Add a full set of x-check keys (xcheck_p, xcheck_c, xcheck_1b,
|
||||
xcheck_2b, xcheck_3b, xcheck_ss, xcheck_lf, xcheck_cf, xcheck_rf) to
|
||||
the standard Cano vR batter dict before boosting. Assert that every
|
||||
x-check key is present in the output and that its value is identical to
|
||||
what was passed in. Also assert the 22 outcome columns still sum to 108.
|
||||
|
||||
Why: The batter boost function only reads and writes BATTER_OUTCOME_COLUMNS.
|
||||
Any additional keys in the input dict should be forwarded to the output
|
||||
via the `result = dict(ratings_dict)` copy. This guards against a
|
||||
regression where the function returns a freshly-constructed dict that
|
||||
drops non-outcome keys, which would strip x-check data when the caller
|
||||
chains batter and pitcher operations on a shared dict.
|
||||
"""
|
||||
xcheck_values = {
|
||||
"xcheck_p": 1.0,
|
||||
"xcheck_c": 3.0,
|
||||
"xcheck_1b": 2.0,
|
||||
"xcheck_2b": 6.0,
|
||||
"xcheck_3b": 3.0,
|
||||
"xcheck_ss": 7.0,
|
||||
"xcheck_lf": 2.0,
|
||||
"xcheck_cf": 3.0,
|
||||
"xcheck_rf": 2.0,
|
||||
}
|
||||
card = _silver_batter_vr()
|
||||
card.update(xcheck_values)
|
||||
|
||||
result = apply_batter_boost(card)
|
||||
|
||||
# All x-check keys survive with unchanged values
|
||||
for col, expected in xcheck_values.items():
|
||||
assert col in result, f"X-check key '{col}' missing from output"
|
||||
assert result[col] == pytest.approx(expected, abs=1e-9), (
|
||||
f"X-check column '{col}' value changed: {expected} → {result[col]}"
|
||||
)
|
||||
# The 22 outcome columns still satisfy the 108-sum invariant
|
||||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||||
|
||||
def test_unmodified_columns_unchanged(self):
|
||||
"""All 16 columns not in BATTER_POSITIVE_DELTAS or BATTER_NEGATIVE_DELTAS
|
||||
are identical before and after a boost where no truncation occurs.
|
||||
|
||||
What: After boosting the Cano vR card, compare every column that is
|
||||
NOT one of the six modified columns and assert it is unchanged.
|
||||
|
||||
Why: A bug that accidentally modifies an unrelated column (e.g. a
|
||||
copy-paste error or an off-by-one in a column list) would corrupt
|
||||
game balance silently. This acts as a "column integrity" guard.
|
||||
"""
|
||||
modified_cols = {
|
||||
"homerun",
|
||||
"double_pull",
|
||||
"single_one",
|
||||
"walk", # positive
|
||||
"strikeout",
|
||||
"groundout_a", # negative
|
||||
}
|
||||
unmodified_cols = [c for c in BATTER_OUTCOME_COLUMNS if c not in modified_cols]
|
||||
|
||||
before = _silver_batter_vr()
|
||||
after = apply_batter_boost(before.copy())
|
||||
|
||||
for col in unmodified_cols:
|
||||
assert after[col] == pytest.approx(before[col], abs=1e-9), (
|
||||
f"Column '{col}' changed unexpectedly: {before[col]} → {after[col]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batter boost: 0-floor truncation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatterBoostTruncation:
|
||||
"""Verify that the 0-floor is enforced and positive deltas are scaled
|
||||
proportionally so the 108-sum is preserved even when negative columns
|
||||
cannot be fully reduced.
|
||||
"""
|
||||
|
||||
def test_groundout_a_near_zero(self):
|
||||
"""Card with groundout_a=0.3: only 0.3 is taken, positive deltas
|
||||
scale down proportionally, and the sum still equals 108.
|
||||
|
||||
What: Construct a card where groundout_a=0.3 (less than the requested
|
||||
0.5 reduction). The algorithm must floor groundout_a at 0 (taking only
|
||||
0.3), and scale the positive deltas so that total_added == total_reduced
|
||||
(1.5 + 0.3 = 1.8 from strikeout + groundout_a; positive budget is
|
||||
also reduced accordingly).
|
||||
|
||||
Why: Without the 0-floor, groundout_a would go negative — an invalid
|
||||
card state. Without proportional scaling the 108-sum would break.
|
||||
This test validates both protections together.
|
||||
"""
|
||||
card = _silver_batter_vr()
|
||||
card["groundout_a"] = 0.3
|
||||
# Re-balance: move the excess 3.55 to groundout_b to keep sum==108
|
||||
diff = 3.85 - 0.3 # original groundout_a was 3.85
|
||||
card["groundout_b"] = card["groundout_b"] + diff
|
||||
|
||||
result = apply_batter_boost(card)
|
||||
|
||||
# groundout_a must be exactly 0 (floored)
|
||||
assert result["groundout_a"] == pytest.approx(0.0, abs=1e-9)
|
||||
# 108-sum preserved
|
||||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||||
|
||||
def test_strikeout_near_zero(self):
|
||||
"""Card with strikeout=1.0: only 1.0 is taken instead of the requested
|
||||
1.5, and positive deltas are scaled down to compensate.
|
||||
|
||||
What: Build a card with strikeout=1.0 (less than the requested 1.5
|
||||
reduction). The algorithm can only take 1.0 from strikeout (floored
|
||||
at 0), and the full 0.5 from groundout_a — so total_actually_reduced
|
||||
is 1.5 against a total_requested_reduction of 2.0. Truncation DOES
|
||||
occur: positive deltas are scaled to 0.75x (1.5 / 2.0) so the
|
||||
108-sum is preserved, and strikeout lands exactly at 0.0.
|
||||
|
||||
Why: Strikeout is the most commonly near-zero column on elite contact
|
||||
hitters. Verifying the floor specifically on strikeout ensures that
|
||||
the tier-1 boost on a card already boosted multiple times won't
|
||||
produce an invalid negative strikeout value.
|
||||
"""
|
||||
card = _silver_batter_vr()
|
||||
# Replace strikeout=14.7 with strikeout=1.0 and re-balance via flyout_rf_b
|
||||
diff = 14.7 - 1.0
|
||||
card["strikeout"] = 1.0
|
||||
card["flyout_rf_b"] = card["flyout_rf_b"] + diff
|
||||
|
||||
result = apply_batter_boost(card)
|
||||
|
||||
# strikeout must be exactly 0 (fully consumed by the floor)
|
||||
assert result["strikeout"] == pytest.approx(0.0, abs=1e-9)
|
||||
# 108-sum preserved regardless of truncation
|
||||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||||
|
||||
def test_both_out_columns_zero(self):
|
||||
"""When both strikeout=0 and groundout_a=0, the boost becomes a no-op.
|
||||
|
||||
What: Build a card where strikeout=0.0 and groundout_a=0.0 (the 18.55
|
||||
chances freed by zeroing both are redistributed to flyout_rf_b so the
|
||||
22-column sum remains exactly 108.0). When the boost runs,
|
||||
total_actually_reduced is 0, so the scale factor is 0 and every
|
||||
positive delta becomes 0 as well.
|
||||
|
||||
Why: This validates the edge of the truncation path where no budget
|
||||
whatsoever is available. Without the `if total_requested_addition > 0`
|
||||
guard, the algorithm would divide by zero; without correct scaling it
|
||||
would silently apply non-zero positive deltas and break the 108-sum.
|
||||
"""
|
||||
card = _silver_batter_vr()
|
||||
# Zero out both negative-delta source columns and compensate via flyout_rf_b
|
||||
freed = card["strikeout"] + card["groundout_a"] # 14.7 + 3.85 = 18.55
|
||||
card["strikeout"] = 0.0
|
||||
card["groundout_a"] = 0.0
|
||||
card["flyout_rf_b"] = card["flyout_rf_b"] + freed
|
||||
|
||||
before_homerun = card["homerun"]
|
||||
before_double_pull = card["double_pull"]
|
||||
before_single_one = card["single_one"]
|
||||
before_walk = card["walk"]
|
||||
|
||||
result = apply_batter_boost(card)
|
||||
|
||||
# 108-sum is preserved (nothing moved)
|
||||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||||
# Source columns remain at zero
|
||||
assert result["strikeout"] == pytest.approx(0.0, abs=1e-9)
|
||||
assert result["groundout_a"] == pytest.approx(0.0, abs=1e-9)
|
||||
# Positive-delta columns are unchanged (scale == 0 means no additions)
|
||||
assert result["homerun"] == pytest.approx(before_homerun, abs=1e-9)
|
||||
assert result["double_pull"] == pytest.approx(before_double_pull, abs=1e-9)
|
||||
assert result["single_one"] == pytest.approx(before_single_one, abs=1e-9)
|
||||
assert result["walk"] == pytest.approx(before_walk, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pitcher boost: 108-sum invariant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPitcherBoost108Sum:
|
||||
"""Verify the pitcher card invariant: 18 variable columns sum to 79,
|
||||
x-checks sum to 29, total is 108 — all after every boost.
|
||||
"""
|
||||
|
||||
def test_singles_heavy_pitcher(self):
|
||||
"""Gibson-like pitcher (double_cf=2.95, no other doubles) maintains
|
||||
79-sum for the 18 variable columns after one boost.
|
||||
|
||||
What: Boost the Gibson vL card once. Assert that the 18 variable
|
||||
columns (PITCHER_OUTCOME_COLUMNS) still sum to 79.
|
||||
|
||||
Why: The pitcher boost algorithm converts hit/walk chances to
|
||||
strikeouts without changing the total number of outcomes. If any
|
||||
chance is created or destroyed, the 79-sum breaks and game simulation
|
||||
results become unreliable.
|
||||
"""
|
||||
result = apply_pitcher_boost(_singles_pitcher_vl())
|
||||
assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6)
|
||||
|
||||
def test_no_doubles_pitcher(self):
|
||||
"""Pitcher with all three double columns at 0 skips them, reduces
|
||||
singles instead, and the 18-column variable sum stays at 79.
|
||||
|
||||
What: Boost the no-doubles pitcher fixture once. The priority list
|
||||
starts with double_cf, double_three, double_two — all zero — so the
|
||||
algorithm must skip them and begin consuming single_center. The
|
||||
79-sum must be preserved.
|
||||
|
||||
Why: Validates the zero-skip logic in the priority loop. Without it,
|
||||
the algorithm would incorrectly deduct from a 0-value column, producing
|
||||
a negative entry and an invalid 79-sum.
|
||||
"""
|
||||
result = apply_pitcher_boost(_no_doubles_pitcher())
|
||||
assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6)
|
||||
|
||||
def test_cumulative_four_tiers(self):
|
||||
"""Four successive boosts: sum==79 after each tier; no column is negative.
|
||||
|
||||
What: Apply four boosts in sequence to the Gibson vL card (highest
|
||||
number of reducible doubles/singles available). After each boost,
|
||||
assert that the 18-column sum is 79.0 and that no individual column
|
||||
went negative.
|
||||
|
||||
Why: Cumulative boost scenarios are the real production use case.
|
||||
Float drift and edge cases in the priority budget loop could silently
|
||||
corrupt the card over multiple tier-ups. Checking after every
|
||||
iteration catches both issues early.
|
||||
"""
|
||||
card = _singles_pitcher_vl()
|
||||
for tier in range(1, 5):
|
||||
card = apply_pitcher_boost(card)
|
||||
total = _pitcher_var_sum(card)
|
||||
assert total == pytest.approx(79.0, abs=1e-6), (
|
||||
f"79-sum drifted to {total} after tier {tier}"
|
||||
)
|
||||
for col in PITCHER_OUTCOME_COLUMNS:
|
||||
assert card[col] >= -1e-9, (
|
||||
f"Column '{col}' went negative ({card[col]}) after tier {tier}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pitcher boost: determinism
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPitcherBoostDeterminism:
|
||||
"""Same input always produces identical output across multiple calls."""
|
||||
|
||||
def test_repeated_calls_identical(self):
|
||||
"""Call apply_pitcher_boost 10 times with the same input; all outputs match.
|
||||
|
||||
What: Pass the same Gibson vL dict to apply_pitcher_boost ten times
|
||||
and assert that every result is byte-for-byte equal to the first.
|
||||
|
||||
Why: Determinism is a hard requirement for reproducible card states.
|
||||
Any non-determinism (e.g. from random number usage, hash iteration
|
||||
order, or floating-point compiler variance) would make two identically-
|
||||
seeded boosts produce different card variants — a correctness bug.
|
||||
"""
|
||||
base = _singles_pitcher_vl()
|
||||
first = apply_pitcher_boost(base.copy())
|
||||
for i in range(1, 10):
|
||||
result = apply_pitcher_boost(base.copy())
|
||||
for col in PITCHER_OUTCOME_COLUMNS:
|
||||
assert result[col] == first[col], (
|
||||
f"Call {i} differed on column '{col}': "
|
||||
f"first={first[col]}, got={result[col]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pitcher boost: TB budget accounting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPitcherBoostTBAccounting:
|
||||
"""Verify the TB budget is debited at the correct rate per outcome type."""
|
||||
|
||||
def test_doubles_cost_2tb(self):
|
||||
"""Reducing a double column by 0.75 chances spends exactly 1.5 TB.
|
||||
|
||||
What: Build a minimal pitcher card where only double_cf is non-zero
|
||||
(2.0 chances) and all singles/walks/HR are 0. With the default
|
||||
budget of 1.5 TB and a cost of 2 TB per chance, the algorithm can
|
||||
take at most 0.75 chances. Assert that double_cf decreases by exactly
|
||||
0.75 and strikeout increases by exactly 0.75.
|
||||
|
||||
Why: If the TB cost factor for doubles were applied incorrectly (e.g.
|
||||
as 1 instead of 2), the algorithm would over-consume chances and
|
||||
produce a card that has been boosted more than a single tier allows.
|
||||
"""
|
||||
# Construct a minimal card: only double_cf has value
|
||||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||||
card["double_cf"] = 2.0
|
||||
card["strikeout"] = 77.0 # 2.0 + 77.0 = 79.0 to satisfy invariant
|
||||
for col in PITCHER_XCHECK_COLUMNS:
|
||||
card[col] = 0.0
|
||||
card["xcheck_2b"] = 29.0 # put all xcheck budget in one column
|
||||
|
||||
result = apply_pitcher_boost(card)
|
||||
|
||||
# Budget = 1.5 TB, cost = 2 TB/chance → max chances = 0.75
|
||||
assert result["double_cf"] == pytest.approx(2.0 - 0.75, abs=1e-9)
|
||||
assert result["strikeout"] == pytest.approx(77.0 + 0.75, abs=1e-9)
|
||||
|
||||
def test_singles_cost_1tb(self):
|
||||
"""Reducing a singles column spends 1 TB per chance.
|
||||
|
||||
What: Build a minimal card where only single_center is non-zero
|
||||
(5.0 chances) and all higher-priority columns are 0. With the
|
||||
default budget of 1.5 TB and a cost of 1 TB per chance, the algorithm
|
||||
takes exactly 1.5 chances from single_center.
|
||||
|
||||
Why: Singles are the most common target in the priority list. Getting
|
||||
the cost factor wrong (e.g. 2 instead of 1) would halve the effective
|
||||
boost impact on contact-heavy pitchers.
|
||||
"""
|
||||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||||
card["single_center"] = 5.0
|
||||
card["strikeout"] = 74.0 # 5.0 + 74.0 = 79.0
|
||||
for col in PITCHER_XCHECK_COLUMNS:
|
||||
card[col] = 0.0
|
||||
card["xcheck_2b"] = 29.0
|
||||
|
||||
result = apply_pitcher_boost(card)
|
||||
|
||||
# Budget = 1.5 TB, cost = 1 TB/chance → takes exactly 1.5 chances
|
||||
assert result["single_center"] == pytest.approx(5.0 - 1.5, abs=1e-9)
|
||||
assert result["strikeout"] == pytest.approx(74.0 + 1.5, abs=1e-9)
|
||||
|
||||
def test_budget_exhaustion(self):
|
||||
"""Algorithm stops exactly when the TB budget reaches 0, leaving the
|
||||
next priority column untouched.
|
||||
|
||||
What: Build a card where double_cf=1.0 (costs 2 TB/chance). With
|
||||
budget=1.5 the algorithm takes 0.75 chances from double_cf (spending
|
||||
exactly 1.5 TB) and then stops. single_center, which comes later in
|
||||
the priority list, must be completely untouched.
|
||||
|
||||
Why: Overspending the budget would boost the pitcher by more than one
|
||||
tier allows. This test directly verifies the `if remaining <= 0:
|
||||
break` guard in the loop.
|
||||
"""
|
||||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||||
card["double_cf"] = 1.0
|
||||
card["single_center"] = 5.0
|
||||
card["strikeout"] = 73.0 # 1.0 + 5.0 + 73.0 = 79.0
|
||||
for col in PITCHER_XCHECK_COLUMNS:
|
||||
card[col] = 0.0
|
||||
card["xcheck_2b"] = 29.0
|
||||
|
||||
result = apply_pitcher_boost(card)
|
||||
|
||||
# double_cf consumed 0.75 chances (1.5 TB at 2 TB/chance) — budget gone
|
||||
assert result["double_cf"] == pytest.approx(1.0 - 0.75, abs=1e-9)
|
||||
# single_center must be completely unchanged (budget was exhausted)
|
||||
assert result["single_center"] == pytest.approx(5.0, abs=1e-9)
|
||||
# strikeout gained exactly 0.75 (from double_cf only)
|
||||
assert result["strikeout"] == pytest.approx(73.0 + 0.75, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pitcher boost: zero-skip and x-check protection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPitcherBoostZeroSkip:
|
||||
"""Verify that zero-valued priority columns are skipped and that x-check
|
||||
columns are never modified under any circumstances.
|
||||
"""
|
||||
|
||||
def test_skip_zero_doubles(self):
|
||||
"""Pitcher with all three double columns at 0: first reduction comes
|
||||
from single_center (the first non-zero column in priority order).
|
||||
|
||||
What: Boost the no-doubles pitcher fixture once. Assert that
|
||||
double_cf, double_three, and double_two are still 0.0 after the boost,
|
||||
and that single_center has decreased (the first non-zero column in
|
||||
the priority list after the three doubles).
|
||||
|
||||
Why: The priority loop must not subtract from columns that are already
|
||||
at 0 — doing so would create negative values and an invalid card.
|
||||
This test confirms the `if ratings[col] <= 0: continue` guard works.
|
||||
"""
|
||||
before = _no_doubles_pitcher()
|
||||
after = apply_pitcher_boost(before.copy())
|
||||
|
||||
# All double columns were 0 and must remain 0
|
||||
assert after["double_cf"] == pytest.approx(0.0, abs=1e-9)
|
||||
assert after["double_three"] == pytest.approx(0.0, abs=1e-9)
|
||||
assert after["double_two"] == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
# single_center (4th in priority) must have decreased
|
||||
assert after["single_center"] < before["single_center"], (
|
||||
"single_center should have been reduced when doubles were all 0"
|
||||
)
|
||||
|
||||
def test_all_priority_columns_zero(self):
|
||||
"""When every priority column is 0, the TB budget cannot be spent.
|
||||
|
||||
What: Build a pitcher card where all 12 PITCHER_PRIORITY columns
|
||||
(double_cf, double_three, double_two, single_center, single_two,
|
||||
single_one, bp_single, walk, homerun, bp_homerun, triple, hbp) are
|
||||
0.0. The remaining 79 variable-column chances are distributed across
|
||||
the non-priority variable columns (strikeout, flyout_lf_b, flyout_cf_b,
|
||||
flyout_rf_b, groundout_a, groundout_b). X-check columns sum to 29 as
|
||||
usual. The algorithm iterates all 12 priority entries, finds nothing
|
||||
to take, logs a warning, and returns.
|
||||
|
||||
Why: This is the absolute edge of the zero-skip path. Without the
|
||||
`if remaining > 0: logger.warning(...)` guard the unspent budget would
|
||||
be silently discarded. More importantly, no column should be modified:
|
||||
the 79-sum must be preserved, strikeout must not change, and every
|
||||
priority column must still be exactly 0.0.
|
||||
"""
|
||||
priority_cols = {col for col, _ in PITCHER_PRIORITY}
|
||||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||||
# Distribute all 79 variable chances across non-priority outcome columns
|
||||
card["strikeout"] = 20.0
|
||||
card["flyout_lf_b"] = 15.0
|
||||
card["flyout_cf_b"] = 15.0
|
||||
card["flyout_rf_b"] = 15.0
|
||||
card["groundout_a"] = 7.0
|
||||
card["groundout_b"] = 7.0
|
||||
# Add x-check columns (sum=29), required by the card structure
|
||||
for col in PITCHER_XCHECK_COLUMNS:
|
||||
card[col] = 0.0
|
||||
card["xcheck_2b"] = 29.0
|
||||
|
||||
before_strikeout = card["strikeout"]
|
||||
|
||||
result = apply_pitcher_boost(card)
|
||||
|
||||
# Variable columns still sum to 79
|
||||
assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6)
|
||||
# Strikeout is unchanged — nothing was moved into it
|
||||
assert result["strikeout"] == pytest.approx(before_strikeout, abs=1e-9)
|
||||
# All priority columns remain at 0
|
||||
for col in priority_cols:
|
||||
assert result[col] == pytest.approx(0.0, abs=1e-9), (
|
||||
f"Priority column '{col}' changed unexpectedly: {result[col]}"
|
||||
)
|
||||
# X-check columns are untouched
|
||||
for col in PITCHER_XCHECK_COLUMNS:
|
||||
assert result[col] == pytest.approx(card[col], abs=1e-9)
|
||||
|
||||
def test_xcheck_columns_never_modified(self):
|
||||
"""All 9 x-check columns are identical before and after a boost.
|
||||
|
||||
What: Boost both the Gibson vL card and the no-doubles pitcher card
|
||||
once each and verify that every xcheck_* column is unchanged.
|
||||
|
||||
Why: X-check columns encode defensive routing weights that are
|
||||
completely separate from the offensive outcome probabilities. The
|
||||
boost algorithm must never touch them; modifying them would break
|
||||
game simulation logic that relies on their fixed sum of 29.
|
||||
"""
|
||||
for card_fn in (_singles_pitcher_vl, _no_doubles_pitcher):
|
||||
before = card_fn()
|
||||
after = apply_pitcher_boost(before.copy())
|
||||
for col in PITCHER_XCHECK_COLUMNS:
|
||||
assert after[col] == pytest.approx(before[col], abs=1e-9), (
|
||||
f"X-check column '{col}' was unexpectedly modified in "
|
||||
f"{card_fn.__name__}: {before[col]} → {after[col]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variant hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVariantHash:
|
||||
"""Verify the behavior of compute_variant_hash: determinism, uniqueness,
|
||||
never-zero guarantee, and cosmetics influence.
|
||||
"""
|
||||
|
||||
def test_deterministic(self):
|
||||
"""Same (player_id, refractor_tier, cosmetics) inputs produce the
|
||||
same hash on every call.
|
||||
|
||||
What: Call compute_variant_hash 20 times with the same arguments and
|
||||
assert every result equals the first.
|
||||
|
||||
Why: The variant hash is stored in the database as a stable card
|
||||
identifier. Any non-determinism would cause the same card state to
|
||||
generate a different variant key on different calls, creating phantom
|
||||
duplicate variants in the DB.
|
||||
"""
|
||||
first = compute_variant_hash(42, 2, ["foil"])
|
||||
for _ in range(19):
|
||||
assert compute_variant_hash(42, 2, ["foil"]) == first
|
||||
|
||||
def test_different_tiers_different_hash(self):
|
||||
"""Tier 1 and tier 2 for the same player produce different hashes.
|
||||
|
||||
What: Compare compute_variant_hash(player_id=1, refractor_tier=1)
|
||||
vs compute_variant_hash(player_id=1, refractor_tier=2).
|
||||
|
||||
Why: Each Refractor tier represents a distinct card version. If two
|
||||
tiers produced the same hash, the DB unique-key constraint on variant
|
||||
would incorrectly merge them into a single entry.
|
||||
"""
|
||||
h1 = compute_variant_hash(1, 1)
|
||||
h2 = compute_variant_hash(1, 2)
|
||||
assert h1 != h2, f"Tier 1 and tier 2 unexpectedly produced the same hash: {h1}"
|
||||
|
||||
def test_different_players_different_hash(self):
|
||||
"""Same tier for different players produces different hashes.
|
||||
|
||||
What: Compare compute_variant_hash(player_id=10, refractor_tier=1)
|
||||
vs compute_variant_hash(player_id=11, refractor_tier=1).
|
||||
|
||||
Why: Each player's Refractor card is a distinct asset. If two players
|
||||
at the same tier shared a hash, their boosted variants could not be
|
||||
distinguished in the database.
|
||||
"""
|
||||
ha = compute_variant_hash(10, 1)
|
||||
hb = compute_variant_hash(11, 1)
|
||||
assert ha != hb, (
|
||||
f"Player 10 and player 11 at tier 1 unexpectedly share hash: {ha}"
|
||||
)
|
||||
|
||||
def test_never_zero(self):
|
||||
"""Hash is never 0 across a broad set of (player_id, tier) combinations.
|
||||
|
||||
What: Generate hashes for player_ids 0–999 at tiers 0–4 (5000 total)
|
||||
and assert every result is >= 1.
|
||||
|
||||
Why: Variant 0 is the reserved sentinel for base (un-boosted) cards.
|
||||
The function must remap any SHA-256-derived value that happens to be 0
|
||||
to 1. Testing with a large batch guards against the extremely unlikely
|
||||
collision while confirming the guard is active.
|
||||
"""
|
||||
for player_id in range(1000):
|
||||
for tier in range(5):
|
||||
result = compute_variant_hash(player_id, tier)
|
||||
assert result >= 1, (
|
||||
f"compute_variant_hash({player_id}, {tier}) returned 0"
|
||||
)
|
||||
|
||||
def test_cosmetics_affect_hash(self):
|
||||
"""Adding cosmetics to the same player/tier produces a different hash.
|
||||
|
||||
What: Compare compute_variant_hash(1, 1, cosmetics=None) vs
|
||||
compute_variant_hash(1, 1, cosmetics=["chrome"]).
|
||||
|
||||
Why: Cosmetic variants (special art, foil treatment, etc.) must be
|
||||
distinguishable from the base-tier variant. If cosmetics were ignored
|
||||
by the hash, two cards that look different would share the same variant
|
||||
key and collide in the database.
|
||||
"""
|
||||
base = compute_variant_hash(1, 1)
|
||||
with_cosmetic = compute_variant_hash(1, 1, ["chrome"])
|
||||
assert base != with_cosmetic, "Adding cosmetics did not change the variant hash"
|
||||
|
||||
def test_cosmetics_order_independent(self):
|
||||
"""Cosmetics list is sorted before hashing; order does not matter.
|
||||
|
||||
What: Compare compute_variant_hash(1, 1, ["foil", "chrome"]) vs
|
||||
compute_variant_hash(1, 1, ["chrome", "foil"]). They must be equal.
|
||||
|
||||
Why: Callers should not need to sort cosmetics before passing them in.
|
||||
If the hash were order-dependent, the same logical card state could
|
||||
produce two different variant keys depending on how the caller
|
||||
constructed the list.
|
||||
"""
|
||||
h1 = compute_variant_hash(1, 1, ["foil", "chrome"])
|
||||
h2 = compute_variant_hash(1, 1, ["chrome", "foil"])
|
||||
assert h1 == h2, (
|
||||
f"Cosmetics order affected hash: ['foil','chrome']={h1}, "
|
||||
f"['chrome','foil']={h2}"
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user