- Move `import os` from inside evaluate_game() to module top-level imports
(lazy imports are only for circular dependency avoidance)
- Add get_or_none idempotency guard before RefractorBoostAudit.create()
inside db.atomic() to prevent IntegrityError on UNIQUE(card_state, tier)
constraint in PostgreSQL when apply_tier_boost is called twice for the
same tier
- Update atomicity test stub to provide card_state/tier attributes for
the new Peewee expression in the idempotency guard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a card reaches a new Refractor tier during game evaluation, the
system now creates a boosted variant card with modified ratings. This
connects the Phase 2 Foundation pure functions (PR #176) to the live
evaluate-game endpoint.
Key changes:
- evaluate_card() gains dry_run parameter so apply_tier_boost() is the
sole writer of current_tier, ensuring atomicity with variant creation
- apply_tier_boost() orchestrates the full boost flow: source card
lookup, boost application, variant card + ratings creation, audit
record, and atomic state mutations inside db.atomic()
- evaluate_game() calls evaluate_card(dry_run=True) then loops through
intermediate tiers on tier-up, with error isolation per player
- Display stat helpers compute fresh avg/obp/slg for variant cards
- REFRACTOR_BOOST_ENABLED env var provides a kill switch
- 51 new tests: unit tests for display stats, integration tests for
orchestration, HTTP endpoint tests for multi-tier jumps, pitcher
path, kill switch, atomicity, idempotency, and cross-player isolation
- Clarified all "79-sum" references to note the 108-total card invariant
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename `evolution_tier` parameter to `refractor_tier` in compute_variant_hash()
to match the refractor naming convention established in PR #131
- Update hash input dict key accordingly (safe: function is new, no stored hashes)
- Update test docstrings referencing the old parameter name
- Remove redundant parentheses on boost_delta_json TextField declaration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pure functions for computing boosted card ratings when a player
reaches a new Refractor tier. Batter boost applies fixed +0.5 to
four offensive columns per tier; pitcher boost uses a 1.5 TB-budget
priority algorithm. Both preserve the 108-sum invariant.
- Create refractor_boost.py with apply_batter_boost, apply_pitcher_boost,
and compute_variant_hash (Decimal arithmetic, zero-floor truncation)
- Add RefractorBoostAudit model, Card.variant, BattingCard/PitchingCard
image_url, RefractorCardState.variant fields to db_engine.py
- Add migration SQL for refractor_card_state.variant column and
refractor_boost_audit table (JSONB, UNIQUE constraint, transactional)
- 26 unit tests covering 108-sum invariant, deltas, truncation, TB
accounting, determinism, x-check protection, and variant hash behavior
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename route /{team_id}/evolutions → /{team_id}/refractors
- Rename function initialize_card_evolution → initialize_card_refractor
- Rename index names in migration SQL
- Update all test references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete rename of the card progression system from "Evolution" to
"Refractor" across all code, routes, models, services, seeds, and tests.
- Route prefix: /api/v2/evolution → /api/v2/refractor
- Model classes: EvolutionTrack → RefractorTrack, etc.
- 12 files renamed, 8 files content-edited
- New migration to rename DB tables
- 117 tests pass, no logic changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes regression from PR #118 — utcnow() was reintroduced in
evolution_evaluator.py.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents (None, team_id) tuples from being added to pitching_pairs
when a StratPlay row has no pitcher (edge case matching the existing
batter_id guard).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- evaluate_card() docstring: "Override for PlayerSeasonStats" →
"Override for BattingSeasonStats/PitchingSeasonStats"
- New test_decision_only_pitcher: exercises the edge case where a pitcher
has a Decision row but no StratPlay rows, verifying _get_player_pairs()
correctly includes them via the Decision table scan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- fn.COUNT(fn.DISTINCT(expr)) → fn.COUNT(expr.distinct()) for correct
COUNT(DISTINCT ...) SQL on PostgreSQL
- _get_player_pairs() now also scans Decision table to include pitchers
who have a Decision row but no StratPlay rows (rare edge case)
- Updated stale docstring references to PlayerSeasonStats and r.k
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous approach accumulated per-game deltas into season stats rows,
which was fragile — partial processing corrupted stats, upsert bugs
compounded, and there was no self-healing mechanism.
Now update_season_stats() recomputes full season totals from all StratPlay
rows for each affected player whenever a game is processed. The result
replaces whatever was stored, eliminating double-counting and enabling
self-healing via force=True.
Also fixes:
- evolution_evaluator.py: broken PlayerSeasonStats import → queries
BattingSeasonStats or PitchingSeasonStats based on card_type
- evolution_evaluator.py: r.k → r.strikeouts
- test_evolution_models.py, test_postgame_evolution.py: PlayerSeasonStats
→ BattingSeasonStats (model never existed)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename _CareerTotals.k → .strikeouts to match formula engine's
stats.strikeouts Protocol
- Update test stubs: TrackStub fields t1→t1_threshold etc. to match
EvolutionTrack model
- Fix fully_evolved logic: derive from post-max current_tier, not
new_tier (prevents contradictory state on tier regression)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes#73
Adds POST /api/v2/evolution/cards/{card_id}/evaluate — force-recalculates
a card's evolution state from career totals (SUM across all
player_season_stats rows for the player-team pair).
Changes:
- app/services/evolution_evaluator.py: evaluate_card() function that
aggregates career stats, delegates to formula engine for value/tier
computation, updates evolution_card_state with no-regression guarantee
- app/routers_v2/evolution.py: POST /cards/{card_id}/evaluate endpoint
plus existing GET /tracks and GET /tracks/{id} endpoints (WP-06)
- tests/test_evolution_evaluator.py: 15 unit tests covering tier
assignment, advancement, partial progress, idempotency, fully evolved,
no regression, multi-season aggregation, missing state error, and
return shape
- tests/__init__.py, tests/conftest.py: shared test infrastructure
All 15 tests pass. Models and formula engine are lazily imported so
this module is safely importable before WP-01/WP-05/WP-07/WP-09 merge.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
formula_engine.tier_from_value read track.t1/t2/t3/t4 but the
EvolutionTrack model defines t1_threshold/t2_threshold/etc. Updated
both the function and test fixtures to use the _threshold suffix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mirror the batter_id is None guard in _build_pitching_groups() so that
a StratPlay row with a null pitcher_id is skipped rather than creating
a None key in the groups dict (which would fail on the NOT NULL FK
constraint during upsert).
- Revert Dockerfile to the next-release base: drop the COPY path change
and CMD addition that were already merged in PR #101 and are unrelated
to the ProcessedGame ledger feature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#75.
New file app/services/evolution_init.py:
- _determine_card_type(player): pure fn mapping pos_1 to 'batter'/'sp'/'rp'
- initialize_card_evolution(player_id, team_id, card_type): get_or_create
EvolutionCardState with current_tier=0, current_value=0.0, fully_evolved=False
- Safe failure: all exceptions caught and logged, never raises
- Idempotent: duplicate calls for same (player_id, team_id) are no-ops
and do NOT reset existing evolution progress
Modified app/routers_v2/cards.py:
- Add WP-10 hook after Card.bulk_create in the POST endpoint
- For each card posted, call _determine_card_type + initialize_card_evolution
- Wrapped in try/except so evolution failures cannot block pack opening
- Fix pre-existing lint violations (unused lc_id, bare f-string, unused e)
New file tests/test_evolution_init.py (16 tests, all passing):
- Unit: track assignment for batter / SP / RP / CP positions
- Integration: first card creates state with zeroed fields
- Integration: duplicate card is a no-op (progress not reset)
- Integration: different players on same team get separate states
- Integration: card_type routes to correct EvolutionTrack
- Integration: missing track returns None gracefully
Fix tests/test_evolution_models.py: correct PlayerSeasonStats import/usage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#105
Replace the last_game FK guard in update_season_stats() with an atomic
INSERT into a new processed_game ledger table. The old guard only blocked
same-game immediate replay; it was silently bypassed if game G+1 was
processed first (last_game already overwritten). The ledger is keyed on
game_id so any re-delivery — including out-of-order — is caught reliably.
Changes:
- app/db_engine.py: add ProcessedGame model (game FK PK + processed_at)
- app/services/season_stats.py: replace last_game check with
ProcessedGame.get_or_create(); import ProcessedGame; update docstrings
- migrations/2026-03-18_add_processed_game.sql: CREATE TABLE IF NOT EXISTS
processed_game with FK to stratgame ON DELETE CASCADE
- tests/conftest.py: add ProcessedGame to imports and _TEST_MODELS list
- tests/test_season_stats_update.py: add test_out_of_order_replay_prevented;
update test_double_count_prevention docstring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Correct idempotency guard docstring in update_season_stats() to
accurately describe the last_game FK check limitation: only detects
replay of the most-recently-processed game; out-of-order re-delivery
(game G after G+1) bypasses the guard. References issue #105 for the
planned ProcessedGame ledger fix.
- Fix migration card_type comment: 'batting' or 'pitching' → 'batter',
'sp', or 'rp' to match actual seeded values.
- Remove local rarity fixture in test_season_stats_update.py that
shadowed the conftest.py fixture; remove unused rarity parameter from
player_batter and player_pitcher fixtures.
- Update test_double_count_prevention docstring to note the known
out-of-order re-delivery limitation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CRITICAL: Fix migration FK refs player(id) → player(player_id)
- Remove dead is_start flag from pitching groups (no starts column)
- Fix hr → homerun in test make_play helper
- Add explanatory comment to ruff.toml
- Replace print() with logging in seed script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implement update_season_stats(game_id) in app/services/season_stats.py.
Aggregates StratPlay batting/pitching stats and Decision win/loss/save
data into PlayerSeasonStats with idempotency guard and dual-backend
upsert (PostgreSQL EXCLUDED increments, SQLite read-modify-write).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract shared pitcher value computation into _pitcher_value() helper.
Consolidate duplicated column lists and index helper in season stats tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Separate batting and pitching into distinct tables with descriptive column
names. Eliminates naming collisions (so/k ambiguity) and column mismatches
between the ORM model and raw SQL. Each table now covers all aggregatable
fields from its source (BattingStat/PitchingStat) including sac, ibb, gidp,
earned_runs, runs_allowed, wild_pitches, balks, and games_started.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The single-letter `k` field was ambiguous and too short for comfortable use.
Rename to `so_pitcher` for clarity, and `so` to `so_batter` to distinguish
batting strikeouts from pitching strikeouts in the same model.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes#74
Adds app/services/formula_engine.py with three pure formula functions
(compute_batter_value, compute_sp_value, compute_rp_value), a dispatch
helper (compute_value_for_track), and a tier classifier (tier_from_value).
Tier boundaries and thresholds match the locked seed data from WP-03.
Note: pitcher formulas use stats.k (not stats.so) to match the
PlayerSeasonStats model field name introduced in WP-02.
19 unit tests in tests/test_formula_engine.py — all pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>