mtg_python_deckbuilder/code/web/services/sampling_config.py

123 lines
4.3 KiB
Python

"""Scoring & sampling configuration constants (Phase 2 extraction).
Centralizes knobs used by the sampling pipeline so future tuning (or
experimentation via environment variables) can occur without editing the
core algorithm code.
Public constants (import into sampling.py and tests):
COMMANDER_COLOR_FILTER_STRICT
COMMANDER_OVERLAP_BONUS
COMMANDER_THEME_MATCH_BONUS
SPLASH_OFF_COLOR_PENALTY
ROLE_BASE_WEIGHTS
ROLE_SATURATION_PENALTY
Helper functions:
rarity_weight_base() -> dict[str, float]
Returns per-rarity base weights (reads env each call to preserve
existing test expectations that patch env before invoking sampling).
"""
from __future__ import annotations
import os
from typing import Dict, Tuple, Optional
# Commander related bonuses (identical defaults to previous inline values)
COMMANDER_COLOR_FILTER_STRICT = True
COMMANDER_OVERLAP_BONUS = 1.8
COMMANDER_THEME_MATCH_BONUS = 0.9
# Penalties / bonuses
SPLASH_OFF_COLOR_PENALTY = -0.3
# Adaptive splash penalty feature flag & scaling factors.
# When SPLASH_ADAPTIVE=1 the effective penalty becomes:
# base_penalty * splash_adaptive_scale(color_count)
# Where color_count is the number of distinct commander colors (1-5).
# Default scale keeps existing behavior at 1-3 colors, softens at 4, much lighter at 5.
SPLASH_ADAPTIVE_ENABLED = os.getenv("SPLASH_ADAPTIVE", "0") == "1"
_DEFAULT_SPLASH_SCALE = "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35"
def parse_splash_adaptive_scale() -> Dict[int, float]: # dynamic to allow test env changes
spec = os.getenv("SPLASH_ADAPTIVE_SCALE", _DEFAULT_SPLASH_SCALE)
mapping: Dict[int, float] = {}
for part in spec.split(','):
part = part.strip()
if not part or ':' not in part:
continue
k_s, v_s = part.split(':', 1)
try:
k = int(k_s)
v = float(v_s)
if 1 <= k <= 5 and v > 0:
mapping[k] = v
except Exception:
continue
# Ensure all 1-5 present; fallback to 1.0 if unspecified
for i in range(1, 6):
mapping.setdefault(i, 1.0)
return mapping
ROLE_SATURATION_PENALTY = -0.4
# Base role weights applied inside score calculation
ROLE_BASE_WEIGHTS: Dict[str, float] = {
"payoff": 2.5,
"enabler": 2.0,
"support": 1.5,
"wildcard": 0.9,
}
# Rarity base weights (diminishing duplicate influence applied in sampling pipeline)
# Read from env at call time to allow tests to modify.
def rarity_weight_base() -> Dict[str, float]: # dynamic to allow env override per test
return {
"mythic": float(os.getenv("RARITY_W_MYTHIC", "1.2")),
"rare": float(os.getenv("RARITY_W_RARE", "0.9")),
"uncommon": float(os.getenv("RARITY_W_UNCOMMON", "0.65")),
"common": float(os.getenv("RARITY_W_COMMON", "0.4")),
}
__all__ = [
"COMMANDER_COLOR_FILTER_STRICT",
"COMMANDER_OVERLAP_BONUS",
"COMMANDER_THEME_MATCH_BONUS",
"SPLASH_OFF_COLOR_PENALTY",
"SPLASH_ADAPTIVE_ENABLED",
"parse_splash_adaptive_scale",
"ROLE_BASE_WEIGHTS",
"ROLE_SATURATION_PENALTY",
"rarity_weight_base",
"parse_rarity_diversity_targets",
"RARITY_DIVERSITY_OVER_PENALTY",
]
# Extended rarity diversity (optional) ---------------------------------------
# Env var RARITY_DIVERSITY_TARGETS pattern e.g. "mythic:0-1,rare:0-2,uncommon:0-4,common:0-6"
# Parsed into mapping rarity -> (min,max). Only max is enforced currently (penalty applied
# when overflow occurs); min reserved for potential future boosting logic.
RARITY_DIVERSITY_OVER_PENALTY = float(os.getenv("RARITY_DIVERSITY_OVER_PENALTY", "-0.5"))
def parse_rarity_diversity_targets() -> Optional[Dict[str, Tuple[int, int]]]:
spec = os.getenv("RARITY_DIVERSITY_TARGETS")
if not spec:
return None
targets: Dict[str, Tuple[int, int]] = {}
for part in spec.split(','):
part = part.strip()
if not part or ':' not in part:
continue
name, rng = part.split(':', 1)
name = name.strip().lower()
if '-' not in rng:
continue
lo_s, hi_s = rng.split('-', 1)
try:
lo = int(lo_s)
hi = int(hi_s)
if lo < 0 or hi < lo:
continue
targets[name] = (lo, hi)
except Exception:
continue
return targets or None