Merge pull request 'Card Evolution: season stats full recalculation → next-release' (#112) from card-evolution into next-release
All checks were successful
Build Docker Image / build (push) Successful in 8m49s

Reviewed-on: #112
Reviewed-by: Claude <cal.corum+openclaw@gmail.com>
This commit is contained in:
cal 2026-03-19 15:49:10 +00:00
commit cf0b1d1d1c
7 changed files with 729 additions and 540 deletions

View File

@ -4,11 +4,13 @@ Covers WP-13 (Post-Game Callback Integration):
POST /api/v2/season-stats/update-game/{game_id} POST /api/v2/season-stats/update-game/{game_id}
Delegates to app.services.season_stats.update_season_stats() which Delegates to app.services.season_stats.update_season_stats() which
aggregates StratPlay and Decision rows for a completed game and recomputes full-season stats from all StratPlay and Decision rows for
performs an additive upsert into player_season_stats. every player who appeared in the game, then writes those totals into
batting_season_stats and pitching_season_stats.
Idempotency is enforced by the service layer: re-delivery of the same Idempotency is enforced by the service layer: re-delivery of the same
game_id returns {"updated": 0, "skipped": true} without modifying stats. game_id returns {"updated": 0, "skipped": true} without modifying stats.
Pass force=true to bypass the idempotency guard and force recalculation.
""" """
import logging import logging
@ -23,18 +25,24 @@ logger = logging.getLogger(__name__)
@router.post("/update-game/{game_id}") @router.post("/update-game/{game_id}")
async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)): async def update_game_season_stats(
"""Increment season stats with batting and pitching deltas from a game. game_id: int, force: bool = False, token: str = Depends(oauth2_scheme)
):
"""Recalculate season stats from all StratPlay and Decision rows for a game.
Calls update_season_stats(game_id) from the service layer which: Calls update_season_stats(game_id, force=force) from the service layer which:
- Aggregates all StratPlay rows by (player_id, team_id, season) - Recomputes full-season totals from all StratPlay rows for each player
- Merges Decision rows into pitching groups - Aggregates Decision rows for pitching win/loss/save/hold stats
- Performs an additive ON CONFLICT upsert into player_season_stats - Writes totals into batting_season_stats and pitching_season_stats
- Guards against double-counting via the last_game FK check - Guards against redundant work via the ProcessedGame ledger
Query params:
- force: if true, bypasses the idempotency guard and reprocesses a
previously seen game_id (useful for correcting stats after data fixes)
Response: {"updated": N, "skipped": false} Response: {"updated": N, "skipped": false}
- N: total player_season_stats rows upserted (batters + pitchers) - N: total player_season_stats rows upserted (batters + pitchers)
- skipped: true when this game_id was already processed (idempotent re-delivery) - skipped: true when this game_id was already processed and force=false
Errors from the service are logged but re-raised as 500 so the bot Errors from the service are logged but re-raised as 500 so the bot
knows to retry. knows to retry.
@ -46,7 +54,7 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch
from ..services.season_stats import update_season_stats from ..services.season_stats import update_season_stats
try: try:
result = update_season_stats(game_id) result = update_season_stats(game_id, force=force)
except Exception as exc: except Exception as exc:
logger.error("update-game/%d failed: %s", game_id, exc, exc_info=True) logger.error("update-game/%d failed: %s", game_id, exc, exc_info=True)
raise HTTPException( raise HTTPException(

View File

@ -3,7 +3,7 @@
Force-recalculates a card's evolution state from career totals. Force-recalculates a card's evolution state from career totals.
evaluate_card() is the main entry point: evaluate_card() is the main entry point:
1. Load career totals: SUM all player_season_stats rows for (player_id, team_id) 1. Load career totals: SUM all BattingSeasonStats/PitchingSeasonStats rows for (player_id, team_id)
2. Determine track from card_state.track 2. Determine track from card_state.track
3. Compute formula value (delegated to formula engine, WP-09) 3. Compute formula value (delegated to formula engine, WP-09)
4. Compare value to track thresholds to determine new_tier 4. Compare value to track thresholds to determine new_tier
@ -14,9 +14,9 @@ evaluate_card() is the main entry point:
Idempotent: calling multiple times with the same data produces the same result. Idempotent: calling multiple times with the same data produces the same result.
Depends on WP-05 (EvolutionCardState), WP-07 (PlayerSeasonStats), and WP-09 Depends on WP-05 (EvolutionCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
(formula engine). Models and formula functions are imported lazily so this and WP-09 (formula engine). Models and formula functions are imported lazily so
module can be imported before those PRs merge. this module can be imported before those PRs merge.
""" """
from datetime import datetime from datetime import datetime
@ -29,7 +29,7 @@ class _CareerTotals:
Passed to the formula engine as a stats-duck-type object with the attributes Passed to the formula engine as a stats-duck-type object with the attributes
required by compute_value_for_track: required by compute_value_for_track:
batter: pa, hits, doubles, triples, hr batter: pa, hits, doubles, triples, hr
sp/rp: outs, k sp/rp: outs, strikeouts
""" """
__slots__ = ("pa", "hits", "doubles", "triples", "hr", "outs", "strikeouts") __slots__ = ("pa", "hits", "doubles", "triples", "hr", "outs", "strikeouts")
@ -54,9 +54,9 @@ def evaluate_card(
) -> dict: ) -> dict:
"""Force-recalculate a card's evolution tier from career stats. """Force-recalculate a card's evolution tier from career stats.
Sums all player_season_stats rows for (player_id, team_id) across all Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
seasons, then delegates formula computation and tier classification to the card_type) for (player_id, team_id) across all seasons, then delegates
formula engine. The result is written back to evolution_card_state and formula computation and tier classification to the formula engine. The result is written back to evolution_card_state and
returned as a dict. returned as a dict.
current_tier never decreases (no regression): current_tier never decreases (no regression):
@ -65,8 +65,8 @@ def evaluate_card(
Args: Args:
player_id: Player primary key. player_id: Player primary key.
team_id: Team primary key. team_id: Team primary key.
_stats_model: Override for PlayerSeasonStats (used in tests to avoid _stats_model: Override for BattingSeasonStats/PitchingSeasonStats
importing from db_engine before WP-07 merges). (used in tests to inject a stub model with all stat fields).
_state_model: Override for EvolutionCardState (used in tests to avoid _state_model: Override for EvolutionCardState (used in tests to avoid
importing from db_engine before WP-05 merges). importing from db_engine before WP-05 merges).
_compute_value_fn: Override for formula_engine.compute_value_for_track _compute_value_fn: Override for formula_engine.compute_value_for_track
@ -81,9 +81,6 @@ def evaluate_card(
Raises: Raises:
ValueError: If no evolution_card_state row exists for (player_id, team_id). ValueError: If no evolution_card_state row exists for (player_id, team_id).
""" """
if _stats_model is None:
from app.db_engine import PlayerSeasonStats as _stats_model # noqa: PLC0415
if _state_model is None: if _state_model is None:
from app.db_engine import EvolutionCardState as _state_model # noqa: PLC0415 from app.db_engine import EvolutionCardState as _state_model # noqa: PLC0415
@ -107,22 +104,63 @@ def evaluate_card(
f"No evolution_card_state for player_id={player_id} team_id={team_id}" f"No evolution_card_state for player_id={player_id} team_id={team_id}"
) )
# 2. Load career totals: SUM all player_season_stats rows for (player_id, team_id) # 2. Load career totals from the appropriate season stats table
rows = list( if _stats_model is not None:
_stats_model.select().where( # Test override: use the injected stub model for all fields
(_stats_model.player_id == player_id) & (_stats_model.team_id == team_id) rows = list(
_stats_model.select().where(
(_stats_model.player_id == player_id)
& (_stats_model.team_id == team_id)
)
) )
) totals = _CareerTotals(
pa=sum(r.pa for r in rows),
hits=sum(r.hits for r in rows),
doubles=sum(r.doubles for r in rows),
triples=sum(r.triples for r in rows),
hr=sum(r.hr for r in rows),
outs=sum(r.outs for r in rows),
strikeouts=sum(r.strikeouts for r in rows),
)
else:
from app.db_engine import (
BattingSeasonStats,
PitchingSeasonStats,
) # noqa: PLC0415
totals = _CareerTotals( card_type = card_state.track.card_type
pa=sum(r.pa for r in rows), if card_type == "batter":
hits=sum(r.hits for r in rows), rows = list(
doubles=sum(r.doubles for r in rows), BattingSeasonStats.select().where(
triples=sum(r.triples for r in rows), (BattingSeasonStats.player == player_id)
hr=sum(r.hr for r in rows), & (BattingSeasonStats.team == team_id)
outs=sum(r.outs for r in rows), )
strikeouts=sum(r.k for r in rows), )
) totals = _CareerTotals(
pa=sum(r.pa for r in rows),
hits=sum(r.hits for r in rows),
doubles=sum(r.doubles for r in rows),
triples=sum(r.triples for r in rows),
hr=sum(r.hr for r in rows),
outs=0,
strikeouts=sum(r.strikeouts for r in rows),
)
else:
rows = list(
PitchingSeasonStats.select().where(
(PitchingSeasonStats.player == player_id)
& (PitchingSeasonStats.team == team_id)
)
)
totals = _CareerTotals(
pa=0,
hits=0,
doubles=0,
triples=0,
hr=0,
outs=sum(r.outs for r in rows),
strikeouts=sum(r.strikeouts for r in rows),
)
# 3. Determine track # 3. Determine track
track = card_state.track track = card_state.track

View File

@ -1,27 +1,32 @@
""" """
season_stats.py Incremental BattingSeasonStats and PitchingSeasonStats update logic. season_stats.py Full-recalculation BattingSeasonStats and PitchingSeasonStats update logic.
Called once per completed StratGame to accumulate batting and pitching Called once per completed StratGame to recompute the full season batting and
statistics into the batting_season_stats and pitching_season_stats tables pitching statistics for every player who appeared in that game, then write
respectively. those totals to the batting_season_stats and pitching_season_stats tables.
Idempotency: re-delivery of a game (including out-of-order re-delivery) Unlike the previous incremental (delta) approach, each call recomputes totals
is detected via an atomic INSERT into the ProcessedGame ledger table from scratch by aggregating all StratPlay rows for the player+team+season
keyed on game_id. The first call for a given game_id succeeds; all triple. This eliminates double-counting on re-delivery and makes every row a
subsequent calls return early with "skipped": True without modifying faithful snapshot of the full season to date.
any stats rows.
Peewee upsert strategy: Idempotency: re-delivery of a game is detected via the ProcessedGame ledger
- SQLite: read-modify-write inside db.atomic() transaction table, keyed on game_id.
- PostgreSQL: ON CONFLICT ... DO UPDATE with column-level EXCLUDED increments - First call: records the ledger entry and proceeds with recalculation.
- Subsequent calls without force=True: return early with "skipped": True.
- force=True: skips the early-return check and recalculates anyway (useful
for correcting data after retroactive stat adjustments).
Upsert strategy: get_or_create + field assignment + save(). Because we are
writing the full recomputed total rather than adding a delta, there is no
risk of concurrent-write skew between games. A single unified path works for
both SQLite and PostgreSQL.
""" """
import logging import logging
import os
from collections import defaultdict
from datetime import datetime from datetime import datetime
from peewee import EXCLUDED from peewee import Case, fn
from app.db_engine import ( from app.db_engine import (
db, db,
@ -35,464 +40,309 @@ from app.db_engine import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DATABASE_TYPE = os.environ.get("DATABASE_TYPE", "sqlite").lower()
def _get_player_pairs(game_id: int) -> tuple[set, set]:
def _build_batting_groups(plays):
""" """
Aggregate per-play batting stats by (batter_id, batter_team_id). Return the sets of (player_id, team_id) pairs that appeared in the game.
Only plays where pa > 0 are counted toward games, but all Queries StratPlay for all rows belonging to game_id and extracts:
play-level stat fields are accumulated regardless of pa value so - batting_pairs: set of (batter_id, batter_team_id), excluding rows where
that rare edge cases (e.g. sac bunt without official PA) are batter_id is None (e.g. automatic outs, walk-off plays without a PA).
correctly included in the totals. - pitching_pairs: set of (pitcher_id, pitcher_team_id) from all plays
(pitcher is always present), plus any pitchers from the Decision table
who may not have StratPlay rows (rare edge case).
Returns a dict keyed by (batter_id, batter_team_id) with stat dicts Args:
matching BattingSeasonStats column names. game_id: Primary key of the StratGame to query.
Returns:
Tuple of (batting_pairs, pitching_pairs) where each element is a set
of (int, int) tuples.
""" """
groups = defaultdict( plays = (
lambda: { StratPlay.select(
"games": 0, StratPlay.batter,
"pa": 0, StratPlay.batter_team,
"ab": 0, StratPlay.pitcher,
"hits": 0, StratPlay.pitcher_team,
"doubles": 0, )
"triples": 0, .where(StratPlay.game == game_id)
"hr": 0, .tuples()
"rbi": 0,
"runs": 0,
"bb": 0,
"strikeouts": 0,
"hbp": 0,
"sac": 0,
"ibb": 0,
"gidp": 0,
"sb": 0,
"cs": 0,
"appeared": False, # tracks whether batter appeared at all in this game
}
) )
for play in plays: batting_pairs: set[tuple[int, int]] = set()
batter_id = play.batter_id pitching_pairs: set[tuple[int, int]] = set()
batter_team_id = play.batter_team_id
if batter_id is None: for batter_id, batter_team_id, pitcher_id, pitcher_team_id in plays:
continue if batter_id is not None:
batting_pairs.add((batter_id, batter_team_id))
pitching_pairs.add((pitcher_id, pitcher_team_id))
key = (batter_id, batter_team_id) # Include pitchers who have a Decision but no StratPlay rows for this game
g = groups[key] # (rare edge case, e.g. a pitcher credited with a decision without recording
# any plays — the old code handled this explicitly in _apply_decisions).
decision_pitchers = (
Decision.select(Decision.pitcher, Decision.pitcher_team)
.where(Decision.game == game_id)
.tuples()
)
for pitcher_id, pitcher_team_id in decision_pitchers:
pitching_pairs.add((pitcher_id, pitcher_team_id))
g["pa"] += play.pa return batting_pairs, pitching_pairs
g["ab"] += play.ab
g["hits"] += play.hit
g["doubles"] += play.double
g["triples"] += play.triple
g["hr"] += play.homerun
g["rbi"] += play.rbi
g["runs"] += play.run
g["bb"] += play.bb
g["strikeouts"] += play.so
g["hbp"] += play.hbp
g["sac"] += play.sac
g["ibb"] += play.ibb
g["gidp"] += play.gidp
g["sb"] += play.sb
g["cs"] += play.cs
if play.pa > 0 and not g["appeared"]:
g["games"] = 1
g["appeared"] = True
# Clean up the helper flag before returning
for key in groups:
del groups[key]["appeared"]
return groups
def _build_pitching_groups(plays): def _recalc_batting(player_id: int, team_id: int, season: int) -> dict:
""" """
Aggregate per-play pitching stats by (pitcher_id, pitcher_team_id). Recompute full-season batting totals for a player+team+season triple.
Stats on StratPlay are recorded from the batter's perspective, so Aggregates every StratPlay row where batter == player_id and
when accumulating pitcher stats we collect: batter_team == team_id across all games in the given season.
- outs pitcher outs recorded (directly on play)
- so strikeouts (batter's so = pitcher's strikeouts)
- hit hits allowed
- bb walks allowed (batter bb, separate from hbp)
- hbp hit batters
- homerun home runs allowed
games counts unique pitchers who appeared (at least one play as games counts only games where the player had at least one official PA
pitcher), capped at 1 per game since this function processes a (pa > 0). The COUNT(DISTINCT ...) with a CASE expression achieves this:
single game. games_started is populated later via _apply_decisions(). NULL values from the CASE are ignored by COUNT, so only game IDs where
pa > 0 contribute.
Fields not available from StratPlay (runs_allowed, earned_runs, Args:
wild_pitches, balks) default to 0 and are not incremented. player_id: FK to the player record.
team_id: FK to the team record.
season: Integer season year.
Returns a dict keyed by (pitcher_id, pitcher_team_id) with stat dicts Returns:
matching PitchingSeasonStats column names. Dict with keys matching BattingSeasonStats columns; all values are
native Python ints (defaulting to 0 if no rows matched).
""" """
groups = defaultdict( row = (
lambda: { StratPlay.select(
"games": 1, # pitcher appeared in this game by definition fn.COUNT(
"games_started": 0, # populated later via _apply_decisions Case(None, [(StratPlay.pa > 0, StratPlay.game)], None).distinct()
"outs": 0, ).alias("games"),
"strikeouts": 0, fn.SUM(StratPlay.pa).alias("pa"),
"bb": 0, fn.SUM(StratPlay.ab).alias("ab"),
"hits_allowed": 0, fn.SUM(StratPlay.hit).alias("hits"),
"runs_allowed": 0, # not available from StratPlay fn.SUM(StratPlay.double).alias("doubles"),
"earned_runs": 0, # not available from StratPlay fn.SUM(StratPlay.triple).alias("triples"),
"hr_allowed": 0, fn.SUM(StratPlay.homerun).alias("hr"),
"hbp": 0, fn.SUM(StratPlay.rbi).alias("rbi"),
"wild_pitches": 0, # not available from StratPlay fn.SUM(StratPlay.run).alias("runs"),
"balks": 0, # not available from StratPlay fn.SUM(StratPlay.bb).alias("bb"),
"wins": 0, fn.SUM(StratPlay.so).alias("strikeouts"),
"losses": 0, fn.SUM(StratPlay.hbp).alias("hbp"),
"holds": 0, fn.SUM(StratPlay.sac).alias("sac"),
"saves": 0, fn.SUM(StratPlay.ibb).alias("ibb"),
"blown_saves": 0, fn.SUM(StratPlay.gidp).alias("gidp"),
} fn.SUM(StratPlay.sb).alias("sb"),
fn.SUM(StratPlay.cs).alias("cs"),
)
.join(StratGame, on=(StratPlay.game == StratGame.id))
.where(
StratPlay.batter == player_id,
StratPlay.batter_team == team_id,
StratGame.season == season,
)
.dicts()
.first()
) )
for play in plays: if row is None:
pitcher_id = play.pitcher_id row = {}
pitcher_team_id = play.pitcher_team_id
if pitcher_id is None: return {
continue "games": row.get("games") or 0,
"pa": row.get("pa") or 0,
key = (pitcher_id, pitcher_team_id) "ab": row.get("ab") or 0,
g = groups[key] "hits": row.get("hits") or 0,
"doubles": row.get("doubles") or 0,
g["outs"] += play.outs "triples": row.get("triples") or 0,
g["strikeouts"] += play.so "hr": row.get("hr") or 0,
g["hits_allowed"] += play.hit "rbi": row.get("rbi") or 0,
g["bb"] += play.bb "runs": row.get("runs") or 0,
g["hbp"] += play.hbp "bb": row.get("bb") or 0,
g["hr_allowed"] += play.homerun "strikeouts": row.get("strikeouts") or 0,
"hbp": row.get("hbp") or 0,
return groups "sac": row.get("sac") or 0,
"ibb": row.get("ibb") or 0,
"gidp": row.get("gidp") or 0,
"sb": row.get("sb") or 0,
"cs": row.get("cs") or 0,
}
def _apply_decisions(pitching_groups, decisions): def _recalc_pitching(player_id: int, team_id: int, season: int) -> dict:
""" """
Merge Decision rows into the pitching stat groups. Recompute full-season pitching totals for a player+team+season triple.
Each Decision belongs to exactly one pitcher in the game, containing Aggregates every StratPlay row where pitcher == player_id and
win/loss/save/hold/blown-save flags and the is_start indicator. pitcher_team == team_id across all games in the given season. games counts
all distinct games in which the pitcher appeared (any play qualifies).
Stats derived from StratPlay (from the batter-perspective columns):
- outs = SUM(outs)
- strikeouts = SUM(so) batter SO = pitcher K
- hits_allowed = SUM(hit)
- bb = SUM(bb) walks allowed
- hbp = SUM(hbp)
- hr_allowed = SUM(homerun)
- wild_pitches = SUM(wild_pitch)
- balks = SUM(balk)
Fields not available from StratPlay (runs_allowed, earned_runs) default
to 0. Decision-level fields (wins, losses, etc.) are populated separately
by _recalc_decisions() and merged in the caller.
Args:
player_id: FK to the player record.
team_id: FK to the team record.
season: Integer season year.
Returns:
Dict with keys matching PitchingSeasonStats columns (excluding
decision fields, which are filled by _recalc_decisions).
""" """
for decision in decisions: row = (
pitcher_id = decision.pitcher_id StratPlay.select(
pitcher_team_id = decision.pitcher_team_id fn.COUNT(StratPlay.game.distinct()).alias("games"),
key = (pitcher_id, pitcher_team_id) fn.SUM(StratPlay.outs).alias("outs"),
fn.SUM(StratPlay.so).alias("strikeouts"),
# Pitcher may have a Decision without plays (rare edge case for fn.SUM(StratPlay.hit).alias("hits_allowed"),
# games where the Decision was recorded without StratPlay rows). fn.SUM(StratPlay.bb).alias("bb"),
# Initialise a zeroed entry if not already present. fn.SUM(StratPlay.hbp).alias("hbp"),
if key not in pitching_groups: fn.SUM(StratPlay.homerun).alias("hr_allowed"),
pitching_groups[key] = { fn.SUM(StratPlay.wild_pitch).alias("wild_pitches"),
"games": 1, fn.SUM(StratPlay.balk).alias("balks"),
"games_started": 0, )
"outs": 0, .join(StratGame, on=(StratPlay.game == StratGame.id))
"strikeouts": 0, .where(
"bb": 0, StratPlay.pitcher == player_id,
"hits_allowed": 0, StratPlay.pitcher_team == team_id,
"runs_allowed": 0, StratGame.season == season,
"earned_runs": 0, )
"hr_allowed": 0, .dicts()
"hbp": 0, .first()
"wild_pitches": 0,
"balks": 0,
"wins": 0,
"losses": 0,
"holds": 0,
"saves": 0,
"blown_saves": 0,
}
g = pitching_groups[key]
g["wins"] += decision.win
g["losses"] += decision.loss
g["saves"] += decision.is_save
g["holds"] += decision.hold
g["blown_saves"] += decision.b_save
g["games_started"] += 1 if decision.is_start else 0
def _upsert_batting_postgres(player_id, team_id, season, game_id, batting):
"""
PostgreSQL upsert for BattingSeasonStats using ON CONFLICT ... DO UPDATE.
Each stat column is incremented by the EXCLUDED (incoming) value,
ensuring concurrent games don't overwrite each other.
"""
now = datetime.now()
increment_cols = [
"games",
"pa",
"ab",
"hits",
"doubles",
"triples",
"hr",
"rbi",
"runs",
"bb",
"strikeouts",
"hbp",
"sac",
"ibb",
"gidp",
"sb",
"cs",
]
conflict_target = [
BattingSeasonStats.player,
BattingSeasonStats.team,
BattingSeasonStats.season,
]
update_dict = {}
for col in increment_cols:
field_obj = getattr(BattingSeasonStats, col)
update_dict[field_obj] = field_obj + EXCLUDED[col]
update_dict[BattingSeasonStats.last_game] = EXCLUDED["last_game_id"]
update_dict[BattingSeasonStats.last_updated_at] = EXCLUDED["last_updated_at"]
BattingSeasonStats.insert(
player=player_id,
team=team_id,
season=season,
games=batting.get("games", 0),
pa=batting.get("pa", 0),
ab=batting.get("ab", 0),
hits=batting.get("hits", 0),
doubles=batting.get("doubles", 0),
triples=batting.get("triples", 0),
hr=batting.get("hr", 0),
rbi=batting.get("rbi", 0),
runs=batting.get("runs", 0),
bb=batting.get("bb", 0),
strikeouts=batting.get("strikeouts", 0),
hbp=batting.get("hbp", 0),
sac=batting.get("sac", 0),
ibb=batting.get("ibb", 0),
gidp=batting.get("gidp", 0),
sb=batting.get("sb", 0),
cs=batting.get("cs", 0),
last_game=game_id,
last_updated_at=now,
).on_conflict(
conflict_target=conflict_target,
action="update",
update=update_dict,
).execute()
def _upsert_pitching_postgres(player_id, team_id, season, game_id, pitching):
"""
PostgreSQL upsert for PitchingSeasonStats using ON CONFLICT ... DO UPDATE.
Each stat column is incremented by the EXCLUDED (incoming) value,
ensuring concurrent games don't overwrite each other.
"""
now = datetime.now()
increment_cols = [
"games",
"games_started",
"outs",
"strikeouts",
"bb",
"hits_allowed",
"runs_allowed",
"earned_runs",
"hr_allowed",
"hbp",
"wild_pitches",
"balks",
"wins",
"losses",
"holds",
"saves",
"blown_saves",
]
conflict_target = [
PitchingSeasonStats.player,
PitchingSeasonStats.team,
PitchingSeasonStats.season,
]
update_dict = {}
for col in increment_cols:
field_obj = getattr(PitchingSeasonStats, col)
update_dict[field_obj] = field_obj + EXCLUDED[col]
update_dict[PitchingSeasonStats.last_game] = EXCLUDED["last_game_id"]
update_dict[PitchingSeasonStats.last_updated_at] = EXCLUDED["last_updated_at"]
PitchingSeasonStats.insert(
player=player_id,
team=team_id,
season=season,
games=pitching.get("games", 0),
games_started=pitching.get("games_started", 0),
outs=pitching.get("outs", 0),
strikeouts=pitching.get("strikeouts", 0),
bb=pitching.get("bb", 0),
hits_allowed=pitching.get("hits_allowed", 0),
runs_allowed=pitching.get("runs_allowed", 0),
earned_runs=pitching.get("earned_runs", 0),
hr_allowed=pitching.get("hr_allowed", 0),
hbp=pitching.get("hbp", 0),
wild_pitches=pitching.get("wild_pitches", 0),
balks=pitching.get("balks", 0),
wins=pitching.get("wins", 0),
losses=pitching.get("losses", 0),
holds=pitching.get("holds", 0),
saves=pitching.get("saves", 0),
blown_saves=pitching.get("blown_saves", 0),
last_game=game_id,
last_updated_at=now,
).on_conflict(
conflict_target=conflict_target,
action="update",
update=update_dict,
).execute()
def _upsert_batting_sqlite(player_id, team_id, season, game_id, batting):
"""
SQLite upsert for BattingSeasonStats: read-modify-write inside the outer atomic() block.
SQLite doesn't support EXCLUDED-based increments via Peewee's
on_conflict(), so we use get_or_create + field-level addition.
This is safe because the entire update_season_stats() call is
wrapped in db.atomic().
"""
now = datetime.now()
obj, _ = BattingSeasonStats.get_or_create(
player_id=player_id,
team_id=team_id,
season=season,
) )
obj.games += batting.get("games", 0) if row is None:
obj.pa += batting.get("pa", 0) row = {}
obj.ab += batting.get("ab", 0)
obj.hits += batting.get("hits", 0)
obj.doubles += batting.get("doubles", 0)
obj.triples += batting.get("triples", 0)
obj.hr += batting.get("hr", 0)
obj.rbi += batting.get("rbi", 0)
obj.runs += batting.get("runs", 0)
obj.bb += batting.get("bb", 0)
obj.strikeouts += batting.get("strikeouts", 0)
obj.hbp += batting.get("hbp", 0)
obj.sac += batting.get("sac", 0)
obj.ibb += batting.get("ibb", 0)
obj.gidp += batting.get("gidp", 0)
obj.sb += batting.get("sb", 0)
obj.cs += batting.get("cs", 0)
obj.last_game_id = game_id return {
obj.last_updated_at = now "games": row.get("games") or 0,
obj.save() "outs": row.get("outs") or 0,
"strikeouts": row.get("strikeouts") or 0,
"hits_allowed": row.get("hits_allowed") or 0,
"bb": row.get("bb") or 0,
"hbp": row.get("hbp") or 0,
"hr_allowed": row.get("hr_allowed") or 0,
"wild_pitches": row.get("wild_pitches") or 0,
"balks": row.get("balks") or 0,
# Not available from play-by-play data
"runs_allowed": 0,
"earned_runs": 0,
}
def _upsert_pitching_sqlite(player_id, team_id, season, game_id, pitching): def _recalc_decisions(player_id: int, team_id: int, season: int) -> dict:
""" """
SQLite upsert for PitchingSeasonStats: read-modify-write inside the outer atomic() block. Recompute full-season decision totals for a pitcher+team+season triple.
SQLite doesn't support EXCLUDED-based increments via Peewee's Aggregates all Decision rows for the pitcher across the season. Decision
on_conflict(), so we use get_or_create + field-level addition. rows are keyed by (pitcher, pitcher_team, season) independently of the
This is safe because the entire update_season_stats() call is StratPlay table, so this query is separate from _recalc_pitching().
wrapped in db.atomic().
Decision.is_start is a BooleanField; CAST to INTEGER before summing to
ensure correct arithmetic across SQLite (True/False) and PostgreSQL
(boolean).
Args:
player_id: FK to the player record (pitcher).
team_id: FK to the team record.
season: Integer season year.
Returns:
Dict with keys: wins, losses, holds, saves, blown_saves,
games_started. All values are native Python ints.
""" """
now = datetime.now() row = (
Decision.select(
obj, _ = PitchingSeasonStats.get_or_create( fn.SUM(Decision.win).alias("wins"),
player_id=player_id, fn.SUM(Decision.loss).alias("losses"),
team_id=team_id, fn.SUM(Decision.hold).alias("holds"),
season=season, fn.SUM(Decision.is_save).alias("saves"),
fn.SUM(Decision.b_save).alias("blown_saves"),
fn.SUM(Decision.is_start.cast("INTEGER")).alias("games_started"),
)
.where(
Decision.pitcher == player_id,
Decision.pitcher_team == team_id,
Decision.season == season,
)
.dicts()
.first()
) )
obj.games += pitching.get("games", 0) if row is None:
obj.games_started += pitching.get("games_started", 0) row = {}
obj.outs += pitching.get("outs", 0)
obj.strikeouts += pitching.get("strikeouts", 0)
obj.bb += pitching.get("bb", 0)
obj.hits_allowed += pitching.get("hits_allowed", 0)
obj.runs_allowed += pitching.get("runs_allowed", 0)
obj.earned_runs += pitching.get("earned_runs", 0)
obj.hr_allowed += pitching.get("hr_allowed", 0)
obj.hbp += pitching.get("hbp", 0)
obj.wild_pitches += pitching.get("wild_pitches", 0)
obj.balks += pitching.get("balks", 0)
obj.wins += pitching.get("wins", 0)
obj.losses += pitching.get("losses", 0)
obj.holds += pitching.get("holds", 0)
obj.saves += pitching.get("saves", 0)
obj.blown_saves += pitching.get("blown_saves", 0)
obj.last_game_id = game_id return {
obj.last_updated_at = now "wins": row.get("wins") or 0,
obj.save() "losses": row.get("losses") or 0,
"holds": row.get("holds") or 0,
"saves": row.get("saves") or 0,
"blown_saves": row.get("blown_saves") or 0,
"games_started": row.get("games_started") or 0,
}
def update_season_stats(game_id: int) -> dict: def update_season_stats(game_id: int, force: bool = False) -> dict:
""" """
Accumulate per-game batting and pitching stats into BattingSeasonStats Recompute full-season batting and pitching stats for every player in the game.
and PitchingSeasonStats respectively.
This function is safe to call exactly once per game. Idempotency is Unlike the previous incremental approach, this function recalculates each
enforced via an atomic INSERT into the ProcessedGame ledger table. player's season totals from scratch by querying all StratPlay rows for
The first call for a given game_id succeeds and returns full results; the player+team+season triple. The resulting totals replace whatever was
any subsequent call (including out-of-order re-delivery after a later previously stored no additive delta is applied.
game has been processed) finds the existing row and returns early with
"skipped": True without touching any stats rows.
Algorithm: Algorithm:
1. Fetch StratGame to get the season. 1. Fetch StratGame to get the season.
2. Atomic INSERT into ProcessedGame if the row already exists, 2. Check the ProcessedGame ledger:
return early (skipped). - If already processed and force=False, return early (skipped=True).
3. Collect all StratPlay rows for the game. - If already processed and force=True, continue (overwrite allowed).
4. Group batting stats by (batter_id, batter_team_id). - If not yet processed, create the ledger entry.
5. Group pitching stats by (pitcher_id, pitcher_team_id). 3. Determine (player_id, team_id) pairs via _get_player_pairs().
6. Merge Decision rows into pitching groups. 4. For each batting pair: recompute season totals, then get_or_create
7. Upsert each batter into BattingSeasonStats using either: BattingSeasonStats and overwrite all fields.
- PostgreSQL: atomic SQL increment via ON CONFLICT DO UPDATE 5. For each pitching pair: recompute season play totals and decision
- SQLite: read-modify-write inside a transaction totals, merge, then get_or_create PitchingSeasonStats and overwrite
8. Upsert each pitcher into PitchingSeasonStats using the same strategy. all fields.
Args: Args:
game_id: Primary key of the StratGame to process. game_id: Primary key of the StratGame to process.
force: If True, re-process even if the game was previously recorded
in the ProcessedGame ledger. Useful for correcting stats after
retroactive data adjustments.
Returns: Returns:
Summary dict with keys: game_id, season, batters_updated, Dict with keys:
pitchers_updated. If the game was already processed, also game_id echoed back
includes "skipped": True. season season integer from StratGame
batters_updated number of BattingSeasonStats rows written
pitchers_updated number of PitchingSeasonStats rows written
skipped True only when the game was already processed
and force=False; absent otherwise.
Raises: Raises:
StratGame.DoesNotExist: If no StratGame row matches game_id. StratGame.DoesNotExist: If no StratGame row matches game_id.
""" """
logger.info("update_season_stats: starting for game_id=%d", game_id) logger.info("update_season_stats: starting for game_id=%d force=%s", game_id, force)
# Step 1 — Fetch the game to get season
game = StratGame.get_by_id(game_id) game = StratGame.get_by_id(game_id)
season = game.season season = game.season
with db.atomic(): with db.atomic():
# Step 2 — Full idempotency via ProcessedGame ledger. # Idempotency check via ProcessedGame ledger.
# Atomic INSERT: if the row already exists (same game_id), get_or_create
# returns created=False and we skip. This handles same-game immediate
# replay AND out-of-order re-delivery (game G re-delivered after G+1
# was already processed).
_, created = ProcessedGame.get_or_create(game_id=game_id) _, created = ProcessedGame.get_or_create(game_id=game_id)
if not created:
if not created and not force:
logger.info( logger.info(
"update_season_stats: game_id=%d already processed, skipping", "update_season_stats: game_id=%d already processed, skipping",
game_id, game_id,
@ -505,41 +355,85 @@ def update_season_stats(game_id: int) -> dict:
"skipped": True, "skipped": True,
} }
# Step 3 — Load plays if not created and force:
plays = list(StratPlay.select().where(StratPlay.game == game_id)) logger.info(
"update_season_stats: game_id=%d already processed, force=True — recalculating",
game_id,
)
batting_pairs, pitching_pairs = _get_player_pairs(game_id)
logger.debug( logger.debug(
"update_season_stats: game_id=%d loaded %d plays", game_id, len(plays) "update_season_stats: game_id=%d found %d batting pairs, %d pitching pairs",
game_id,
len(batting_pairs),
len(pitching_pairs),
) )
# Steps 4 & 5 — Aggregate batting and pitching groups now = datetime.now()
batting_groups = _build_batting_groups(plays)
pitching_groups = _build_pitching_groups(plays)
# Step 6 — Merge Decision rows into pitching groups # Recompute and overwrite batting season stats for each batter.
decisions = list(Decision.select().where(Decision.game == game_id))
_apply_decisions(pitching_groups, decisions)
upsert_batting = (
_upsert_batting_postgres
if DATABASE_TYPE == "postgresql"
else _upsert_batting_sqlite
)
upsert_pitching = (
_upsert_pitching_postgres
if DATABASE_TYPE == "postgresql"
else _upsert_pitching_sqlite
)
# Step 7 — Upsert batting rows into BattingSeasonStats
batters_updated = 0 batters_updated = 0
for (player_id, team_id), batting in batting_groups.items(): for player_id, team_id in batting_pairs:
upsert_batting(player_id, team_id, season, game_id, batting) stats = _recalc_batting(player_id, team_id, season)
obj, _ = BattingSeasonStats.get_or_create(
player_id=player_id,
team_id=team_id,
season=season,
)
obj.games = stats["games"]
obj.pa = stats["pa"]
obj.ab = stats["ab"]
obj.hits = stats["hits"]
obj.doubles = stats["doubles"]
obj.triples = stats["triples"]
obj.hr = stats["hr"]
obj.rbi = stats["rbi"]
obj.runs = stats["runs"]
obj.bb = stats["bb"]
obj.strikeouts = stats["strikeouts"]
obj.hbp = stats["hbp"]
obj.sac = stats["sac"]
obj.ibb = stats["ibb"]
obj.gidp = stats["gidp"]
obj.sb = stats["sb"]
obj.cs = stats["cs"]
obj.last_game_id = game_id
obj.last_updated_at = now
obj.save()
batters_updated += 1 batters_updated += 1
# Step 8 — Upsert pitching rows into PitchingSeasonStats # Recompute and overwrite pitching season stats for each pitcher.
pitchers_updated = 0 pitchers_updated = 0
for (player_id, team_id), pitching in pitching_groups.items(): for player_id, team_id in pitching_pairs:
upsert_pitching(player_id, team_id, season, game_id, pitching) play_stats = _recalc_pitching(player_id, team_id, season)
decision_stats = _recalc_decisions(player_id, team_id, season)
obj, _ = PitchingSeasonStats.get_or_create(
player_id=player_id,
team_id=team_id,
season=season,
)
obj.games = play_stats["games"]
obj.games_started = decision_stats["games_started"]
obj.outs = play_stats["outs"]
obj.strikeouts = play_stats["strikeouts"]
obj.bb = play_stats["bb"]
obj.hits_allowed = play_stats["hits_allowed"]
obj.runs_allowed = play_stats["runs_allowed"]
obj.earned_runs = play_stats["earned_runs"]
obj.hr_allowed = play_stats["hr_allowed"]
obj.hbp = play_stats["hbp"]
obj.wild_pitches = play_stats["wild_pitches"]
obj.balks = play_stats["balks"]
obj.wins = decision_stats["wins"]
obj.losses = decision_stats["losses"]
obj.holds = decision_stats["holds"]
obj.saves = decision_stats["saves"]
obj.blown_saves = decision_stats["blown_saves"]
obj.last_game_id = game_id
obj.last_updated_at = now
obj.save()
pitchers_updated += 1 pitchers_updated += 1
logger.info( logger.info(

View File

@ -85,7 +85,7 @@ class StatsStub(Model):
triples = IntegerField(default=0) triples = IntegerField(default=0)
hr = IntegerField(default=0) hr = IntegerField(default=0)
outs = IntegerField(default=0) outs = IntegerField(default=0)
k = IntegerField(default=0) strikeouts = IntegerField(default=0)
class Meta: class Meta:
database = _test_db database = _test_db

View File

@ -20,7 +20,7 @@ from peewee import IntegrityError
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from app.db_engine import ( from app.db_engine import (
PlayerSeasonStats, BattingSeasonStats,
EvolutionCardState, EvolutionCardState,
EvolutionCosmetic, EvolutionCosmetic,
EvolutionTierBoost, EvolutionTierBoost,
@ -248,13 +248,13 @@ class TestEvolutionCosmetic:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPlayerSeasonStats: class TestBattingSeasonStats:
"""Tests for BattingSeasonStats, the per-season accumulation table. """Tests for BattingSeasonStats, the per-season batting accumulation table.
Each row aggregates game-by-game batting and pitching stats for one Each row aggregates game-by-game batting stats for one player on one
player on one team in one season. The three-column unique constraint team in one season. The three-column unique constraint prevents
prevents double-counting and ensures a single authoritative row for double-counting and ensures a single authoritative row for each
each (player, team, season) combination. (player, team, season) combination.
""" """
def test_create_season_stats(self, player, team): def test_create_season_stats(self, player, team):
@ -264,11 +264,11 @@ class TestPlayerSeasonStats:
are not provided, which is the initial state before any games are are not provided, which is the initial state before any games are
processed. processed.
""" """
stats = PlayerSeasonStats.create( stats = BattingSeasonStats.create(
player=player, player=player,
team=team, team=team,
season=11, season=11,
games_batting=5, games=5,
pa=20, pa=20,
ab=18, ab=18,
hits=6, hits=6,
@ -277,25 +277,21 @@ class TestPlayerSeasonStats:
hr=2, hr=2,
bb=2, bb=2,
hbp=0, hbp=0,
so=4, strikeouts=4,
rbi=5, rbi=5,
runs=3, runs=3,
sb=1, sb=1,
cs=0, cs=0,
) )
fetched = PlayerSeasonStats.get_by_id(stats.id) fetched = BattingSeasonStats.get_by_id(stats.id)
assert fetched.player_id == player.player_id assert fetched.player_id == player.player_id
assert fetched.team_id == team.id assert fetched.team_id == team.id
assert fetched.season == 11 assert fetched.season == 11
assert fetched.games_batting == 5 assert fetched.games == 5
assert fetched.pa == 20 assert fetched.pa == 20
assert fetched.hits == 6 assert fetched.hits == 6
assert fetched.hr == 2 assert fetched.hr == 2
# Pitching fields were not set — confirm default zero values assert fetched.strikeouts == 4
assert fetched.games_pitching == 0
assert fetched.outs == 0
assert fetched.wins == 0
assert fetched.saves == 0
# Nullable meta fields # Nullable meta fields
assert fetched.last_game is None assert fetched.last_game is None
assert fetched.last_updated_at is None assert fetched.last_updated_at is None
@ -307,9 +303,9 @@ class TestPlayerSeasonStats:
player-team-season combination has exactly one accumulation row, player-team-season combination has exactly one accumulation row,
preventing duplicate stat aggregation that would inflate totals. preventing duplicate stat aggregation that would inflate totals.
""" """
PlayerSeasonStats.create(player=player, team=team, season=11) BattingSeasonStats.create(player=player, team=team, season=11)
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
PlayerSeasonStats.create(player=player, team=team, season=11) BattingSeasonStats.create(player=player, team=team, season=11)
def test_season_stats_increment(self, player, team): def test_season_stats_increment(self, player, team):
"""Manually incrementing hits on an existing row persists the change. """Manually incrementing hits on an existing row persists the change.
@ -319,7 +315,7 @@ class TestPlayerSeasonStats:
writes back to the database and that subsequent reads reflect the writes back to the database and that subsequent reads reflect the
updated value. updated value.
""" """
stats = PlayerSeasonStats.create( stats = BattingSeasonStats.create(
player=player, player=player,
team=team, team=team,
season=11, season=11,
@ -328,5 +324,5 @@ class TestPlayerSeasonStats:
stats.hits += 3 stats.hits += 3
stats.save() stats.save()
refreshed = PlayerSeasonStats.get_by_id(stats.id) refreshed = BattingSeasonStats.get_by_id(stats.id)
assert refreshed.hits == 13 assert refreshed.hits == 13

View File

@ -63,7 +63,9 @@ from app.db_engine import (
Pack, Pack,
PackType, PackType,
Player, Player,
PlayerSeasonStats, BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
Rarity, Rarity,
Roster, Roster,
RosterSlot, RosterSlot,
@ -106,7 +108,9 @@ _WP13_MODELS = [
Decision, Decision,
ScoutOpportunity, ScoutOpportunity,
ScoutClaim, ScoutClaim,
PlayerSeasonStats, BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
EvolutionTrack, EvolutionTrack,
EvolutionCardState, EvolutionCardState,
EvolutionTierBoost, EvolutionTierBoost,
@ -328,7 +332,7 @@ def test_update_game_creates_season_stats_rows(client):
"""POST update-game creates player_season_stats rows for players in the game. """POST update-game creates player_season_stats rows for players in the game.
What: Set up a batter and pitcher in a game with 3 PA for the batter. What: Set up a batter and pitcher in a game with 3 PA for the batter.
After the endpoint call, assert a PlayerSeasonStats row exists with pa=3. After the endpoint call, assert a BattingSeasonStats row exists with pa=3.
Why: This is the core write path. If the row is not created, the Why: This is the core write path. If the row is not created, the
evolution evaluator will always see zero career stats. evolution evaluator will always see zero career stats.
@ -347,10 +351,10 @@ def test_update_game_creates_season_stats_rows(client):
) )
assert resp.status_code == 200 assert resp.status_code == 200
stats = PlayerSeasonStats.get_or_none( stats = BattingSeasonStats.get_or_none(
(PlayerSeasonStats.player == batter) (BattingSeasonStats.player == batter)
& (PlayerSeasonStats.team == team_a) & (BattingSeasonStats.team == team_a)
& (PlayerSeasonStats.season == 11) & (BattingSeasonStats.season == 11)
) )
assert stats is not None assert stats is not None
assert stats.pa == 3 assert stats.pa == 3
@ -417,8 +421,8 @@ def test_update_game_idempotent(client):
assert data2["skipped"] is True assert data2["skipped"] is True
assert data2["updated"] == 0 assert data2["updated"] == 0
stats = PlayerSeasonStats.get( stats = BattingSeasonStats.get(
(PlayerSeasonStats.player == batter) & (PlayerSeasonStats.team == team_a) (BattingSeasonStats.player == batter) & (BattingSeasonStats.team == team_a)
) )
assert stats.pa == 3 # not 6 assert stats.pa == 3 # not 6
@ -468,7 +472,7 @@ def test_evaluate_game_tier_advancement(client):
"""A game that pushes a card past a tier threshold advances the tier. """A game that pushes a card past a tier threshold advances the tier.
What: Set the batter's career value just below T1 (37) by manually seeding What: Set the batter's career value just below T1 (37) by manually seeding
a prior PlayerSeasonStats row with pa=34. Then add a game that brings the a prior BattingSeasonStats row with pa=34. Then add a game that brings the
total past 37 and call evaluate-game. current_tier must advance to >= 1. total past 37 and call evaluate-game. current_tier must advance to >= 1.
Why: Tier advancement is the core deliverable of card evolution. If the Why: Tier advancement is the core deliverable of card evolution. If the
@ -484,7 +488,7 @@ def test_evaluate_game_tier_advancement(client):
_make_state(batter, team_a, track, current_tier=0, current_value=34.0) _make_state(batter, team_a, track, current_tier=0, current_value=34.0)
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37) # Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
PlayerSeasonStats.create( BattingSeasonStats.create(
player=batter, player=batter,
team=team_a, team=team_a,
season=10, # previous season season=10, # previous season
@ -565,7 +569,7 @@ def test_evaluate_game_tier_ups_in_response(client):
_make_state(batter, team_a, track, current_tier=0) _make_state(batter, team_a, track, current_tier=0)
# Seed prior stats below threshold # Seed prior stats below threshold
PlayerSeasonStats.create(player=batter, team=team_a, season=10, pa=34) BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
# Game pushes past T1 # Game pushes past T1
for i in range(4): for i in range(4):

View File

@ -1,10 +1,10 @@
""" """
Tests for app/services/season_stats.py update_season_stats(). Tests for app/services/season_stats.py update_season_stats().
What: Verify that the incremental stat accumulation function correctly What: Verify that the full-recalculation stat engine correctly aggregates
aggregates StratPlay and Decision rows into BattingSeasonStats and StratPlay and Decision rows into BattingSeasonStats and PitchingSeasonStats,
PitchingSeasonStats, handles duplicate calls idempotently, and handles duplicate calls idempotently, accumulates stats across multiple games,
accumulates stats across multiple games. and supports forced reprocessing for self-healing.
Why: This is the core bookkeeping engine for card evolution scoring. A Why: This is the core bookkeeping engine for card evolution scoring. A
double-count bug, a missed Decision merge, or a team-isolation failure double-count bug, a missed Decision merge, or a team-isolation failure
@ -191,7 +191,7 @@ def game(team_a, team_b):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests # Tests — Existing behavior (kept)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -200,7 +200,7 @@ def test_single_game_batting_stats(team_a, team_b, player_batter, player_pitcher
What: Create three plate appearances (2 hits, 1 strikeout, a walk, and a What: Create three plate appearances (2 hits, 1 strikeout, a walk, and a
home run) for one batter. After update_season_stats(), the home run) for one batter. After update_season_stats(), the
PlayerSeasonStats row should reflect the exact sum of all play fields. BattingSeasonStats row should reflect the exact sum of all play fields.
Why: The core of the batting aggregation pipeline. If any field mapping Why: The core of the batting aggregation pipeline. If any field mapping
is wrong (e.g. 'hit' mapped to 'doubles' instead of 'hits'), evolution is wrong (e.g. 'hit' mapped to 'doubles' instead of 'hits'), evolution
@ -287,11 +287,11 @@ def test_single_game_pitching_stats(
What: The same plays that create batting stats for the batter are also What: The same plays that create batting stats for the batter are also
the source for the pitcher's opposing stats. This test checks that the source for the pitcher's opposing stats. This test checks that
_build_pitching_groups() correctly inverts batter-perspective fields. _recalc_pitching() correctly inverts batter-perspective fields.
Why: The batter's 'so' becomes the pitcher's 'k', the batter's 'hit' Why: The batter's 'so' becomes the pitcher's 'strikeouts', the batter's
becomes 'hits_allowed', etc. Any transposition in this mapping would 'hit' becomes 'hits_allowed', etc. Any transposition in this mapping
corrupt pitcher stats silently. would corrupt pitcher stats silently.
""" """
# Play 1: strikeout — batter so=1, outs=1 # Play 1: strikeout — batter so=1, outs=1
make_play( make_play(
@ -347,14 +347,14 @@ def test_single_game_pitching_stats(
def test_decision_integration(team_a, team_b, player_batter, player_pitcher, game): def test_decision_integration(team_a, team_b, player_batter, player_pitcher, game):
"""Decision.win=1 for a pitcher results in wins=1 in PlayerSeasonStats. """Decision.win=1 for a pitcher results in wins=1 in PitchingSeasonStats.
What: Add a single StratPlay to establish the pitcher in pitching_groups, What: Add a single StratPlay to establish the pitcher in pitching pairs,
then create a Decision row recording a win. Call update_season_stats() then create a Decision row recording a win. Call update_season_stats()
and verify the wins column is 1. and verify the wins column is 1.
Why: Decisions are stored in a separate table from StratPlay. If Why: Decisions are stored in a separate table from StratPlay. If
_apply_decisions() fails to merge them (wrong FK lookup, key mismatch), _recalc_decisions() fails to merge them (wrong FK lookup, key mismatch),
pitchers would always show 0 wins/losses/saves regardless of actual game pitchers would always show 0 wins/losses/saves regardless of actual game
outcomes, breaking standings and evolution criteria. outcomes, breaking standings and evolution criteria.
""" """
@ -441,9 +441,9 @@ def test_two_games_accumulate(team_a, team_b, player_batter, player_pitcher):
What: Process game 1 (pa=2) then game 2 (pa=3) for the same batter/team. What: Process game 1 (pa=2) then game 2 (pa=3) for the same batter/team.
After both updates the stats row should show pa=5. After both updates the stats row should show pa=5.
Why: PlayerSeasonStats is a season-long accumulator, not a per-game Why: BattingSeasonStats is a season-long accumulator, not a per-game
snapshot. If the upsert logic overwrites instead of increments, a player's snapshot. The full recalculation queries all StratPlay rows for the season,
stats would always reflect only their most recent game. so processing game 2 recomputes with all 5 PAs included.
""" """
game1 = StratGame.create( game1 = StratGame.create(
season=11, game_type="ranked", away_team=team_a, home_team=team_b season=11, game_type="ranked", away_team=team_a, home_team=team_b
@ -593,18 +593,15 @@ def test_two_team_game(team_a, team_b):
def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pitcher): def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pitcher):
"""Out-of-order re-delivery of game G (after G+1 was processed) must not double-count. """Out-of-order processing and re-delivery produce correct stats.
What: Process game G+1 first (pa=2), then process game G (pa=3). Now What: Process game G+1 first (pa=2), then game G (pa=3). The full
re-deliver game G. The third call must return 'skipped'=True and leave recalculation approach means both calls query all StratPlay rows for the
the batter's pa unchanged at 5 (3 + 2), not 8 (3 + 2 + 3). season, so the final stats are always correct regardless of processing
order. Re-delivering game G returns 'skipped'=True and leaves stats at 5.
Why: This is the failure mode that the old last_game FK guard could not Why: With full recalculation, out-of-order processing is inherently safe.
catch. After G+1 is processed, no BattingSeasonStats row carries The ProcessedGame ledger still prevents redundant work on re-delivery.
last_game=G anymore (it was overwritten to G+1). The old guard would
have returned already_processed=False and double-counted. The
ProcessedGame ledger fixes this by keying on game_id independently of
the stats rows.
""" """
game_g = StratGame.create( game_g = StratGame.create(
season=11, game_type="ranked", away_team=team_a, home_team=team_b season=11, game_type="ranked", away_team=team_a, home_team=team_b
@ -657,5 +654,257 @@ def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pit
assert replay_result.get("skipped") is True assert replay_result.get("skipped") is True
# Stats must remain at 5, not 8 # Stats must remain at 5, not 8
stats.refresh() stats = BattingSeasonStats.get(
BattingSeasonStats.player == player_batter,
BattingSeasonStats.team == team_a,
BattingSeasonStats.season == 11,
)
assert stats.pa == 5 assert stats.pa == 5
# ---------------------------------------------------------------------------
# Tests — New (force recalc / idempotency / self-healing)
# ---------------------------------------------------------------------------
def test_force_recalc(team_a, team_b, player_batter, player_pitcher, game):
"""Processing with force=True after initial processing does not double stats.
What: Process a game normally (pa=3), then reprocess with force=True.
Because the recalculation reads all StratPlay rows and writes totals
(not deltas), the stats remain at pa=3 after the forced reprocess.
Why: The force flag bypasses the ProcessedGame ledger skip, but since
the underlying data hasn't changed, the recalculated totals must be
identical. This proves the replacement upsert is safe.
"""
for i in range(3):
make_play(
game,
i + 1,
player_batter,
team_a,
player_pitcher,
team_b,
pa=1,
ab=1,
hit=1,
outs=0,
)
first_result = update_season_stats(game.id)
assert first_result["batters_updated"] >= 1
assert "skipped" not in first_result
# Force reprocess — should NOT double stats
force_result = update_season_stats(game.id, force=True)
assert "skipped" not in force_result
assert force_result["batters_updated"] >= 1
stats = BattingSeasonStats.get(
BattingSeasonStats.player == player_batter,
BattingSeasonStats.team == team_a,
BattingSeasonStats.season == 11,
)
assert stats.pa == 3
assert stats.hits == 3
assert stats.games == 1
def test_idempotent_reprocessing(team_a, team_b, player_batter, player_pitcher, game):
"""Two consecutive force=True calls produce identical stats.
What: Force-process the same game twice. Both calls recompute from
scratch, so the stats after the second call must be identical to the
stats after the first call.
Why: Idempotency is a critical property of the recalculation engine.
External systems (admin scripts, retry loops) may call force=True
multiple times; the result must be stable.
"""
for i in range(4):
make_play(
game,
i + 1,
player_batter,
team_a,
player_pitcher,
team_b,
pa=1,
ab=1,
so=1 if i % 2 == 0 else 0,
hit=0 if i % 2 == 0 else 1,
outs=1 if i % 2 == 0 else 0,
)
update_season_stats(game.id, force=True)
stats_after_first = BattingSeasonStats.get(
BattingSeasonStats.player == player_batter,
BattingSeasonStats.team == team_a,
BattingSeasonStats.season == 11,
)
pa_1, hits_1, so_1 = (
stats_after_first.pa,
stats_after_first.hits,
stats_after_first.strikeouts,
)
update_season_stats(game.id, force=True)
stats_after_second = BattingSeasonStats.get(
BattingSeasonStats.player == player_batter,
BattingSeasonStats.team == team_a,
BattingSeasonStats.season == 11,
)
assert stats_after_second.pa == pa_1
assert stats_after_second.hits == hits_1
assert stats_after_second.strikeouts == so_1
def test_partial_reprocessing_heals(
team_a, team_b, player_batter, player_pitcher, game
):
"""Force reprocessing corrects manually corrupted stats.
What: Process a game (pa=3, hits=2), then manually corrupt the stats
row (set pa=999). Force-reprocess the game. The stats should be healed
back to the correct totals (pa=3, hits=2).
Why: This is the primary self-healing benefit of full recalculation.
Partial processing, bugs, or manual edits can corrupt season stats;
force=True recomputes from the source-of-truth StratPlay data and
writes the correct totals regardless of current row state.
"""
# PA 1: single
make_play(
game,
1,
player_batter,
team_a,
player_pitcher,
team_b,
pa=1,
ab=1,
hit=1,
outs=0,
)
# PA 2: double
make_play(
game,
2,
player_batter,
team_a,
player_pitcher,
team_b,
pa=1,
ab=1,
hit=1,
double=1,
outs=0,
)
# PA 3: strikeout
make_play(
game,
3,
player_batter,
team_a,
player_pitcher,
team_b,
pa=1,
ab=1,
so=1,
outs=1,
)
update_season_stats(game.id)
# Verify correct initial state
stats = BattingSeasonStats.get(
BattingSeasonStats.player == player_batter,
BattingSeasonStats.team == team_a,
BattingSeasonStats.season == 11,
)
assert stats.pa == 3
assert stats.hits == 2
assert stats.doubles == 1
# Corrupt the stats manually
stats.pa = 999
stats.hits = 0
stats.doubles = 50
stats.save()
# Verify corruption took effect
stats = BattingSeasonStats.get_by_id(stats.id)
assert stats.pa == 999
# Force reprocess — should heal the corruption
update_season_stats(game.id, force=True)
stats = BattingSeasonStats.get(
BattingSeasonStats.player == player_batter,
BattingSeasonStats.team == team_a,
BattingSeasonStats.season == 11,
)
assert stats.pa == 3
assert stats.hits == 2
assert stats.doubles == 1
assert stats.strikeouts == 1
assert stats.games == 1
def test_decision_only_pitcher(team_a, team_b, player_batter, player_pitcher, game):
"""A pitcher with a Decision but no StratPlay rows still gets stats recorded.
What: Create a second pitcher who has a Decision (win) for the game but
does not appear in any StratPlay rows. After update_season_stats(), the
decision-only pitcher should have a PitchingSeasonStats row with wins=1
and all play-level stats at 0.
Why: In rare cases a pitcher may be credited with a decision without
recording any plays (e.g. inherited runner scoring rules, edge cases in
game simulation). The old code handled this in _apply_decisions(); the
new code must include Decision-scanned pitchers in _get_player_pairs().
"""
relief_pitcher = _make_player("Relief Pitcher", pos="RP")
# The main pitcher has plays
make_play(
game,
1,
player_batter,
team_a,
player_pitcher,
team_b,
pa=1,
ab=1,
outs=1,
)
# The relief pitcher has a Decision but NO StratPlay rows
Decision.create(
season=11,
game=game,
pitcher=relief_pitcher,
pitcher_team=team_b,
win=1,
loss=0,
is_save=0,
hold=0,
b_save=0,
is_start=False,
)
update_season_stats(game.id)
# The relief pitcher should have a PitchingSeasonStats row
stats = PitchingSeasonStats.get(
PitchingSeasonStats.player == relief_pitcher,
PitchingSeasonStats.team == team_b,
PitchingSeasonStats.season == 11,
)
assert stats.wins == 1
assert stats.games == 0 # no plays, so COUNT(DISTINCT game) = 0
assert stats.outs == 0
assert stats.strikeouts == 0
assert stats.games_started == 0