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:
cal 2026-03-30 16:11:07 +00:00
commit 70f984392d
5 changed files with 1299 additions and 1 deletions

View File

@ -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,
)

View 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 (04) 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

View 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;

View File

@ -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,
]

View 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 0999 at tiers 04 (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}"
)