feat: Refractor Phase 2 integration — wire boost into evaluate-game #177
No reviewers
Labels
No Label
ai-changes-requested
ai-failed
ai-merged
ai-pr-opened
ai-reviewed
ai-reviewing
ai-reviewing
ai-working
bug
enhancement
evolution
performance
phase-0
phase-1a
phase-1b
phase-1c
phase-1d
security
tech-debt
todo
No Milestone
No project
No Assignees
2 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/paper-dynasty-database#177
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/refractor-phase2-integration"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
app/services/refractor_boost.py— Addedapply_tier_boost()orchestration function (creates boosted variant card + ratings on tier-up, writes audit record, updates tier+variant atomically viadb.atomic()),compute_batter_display_stats()andcompute_pitcher_display_stats()helpers,card_typevalidation guardapp/services/refractor_evaluator.py— Addeddry_runparameter toevaluate_card()so tier computation is separated from tier write; addedcomputed_tierandcomputed_fully_evolvedto return dictapp/routers_v2/refractor.py— Wired boost intoevaluate_game(): callsevaluate_card(dry_run=True), loops through intermediate tiers on tier-up, handles partial multi-tier failures,REFRACTOR_BOOST_ENABLEDenv var kill switch, fixed pre-existingisoformat()crashtests/test_refractor_boost_integration.py(new, 27 tests) — Full integration coverage: batter/pitcher tier-up flows, cumulative T1-T4, idempotency, Card propagation, atomicity rollback, dry_run, cross-player isolation, card_type validationtests/test_refractor_boost.py(12 new tests) — Display stat helper unit tests, clarified 79-sum referencestests/test_postgame_refractor.py(7 new tests) — HTTP endpoint tests: kill switch, multi-tier jump, pitcher path, variant_created field, empty card_typetests/test_refractor_evaluator.py(6 new tests) — dry_run unit testsTest plan
python -m pytest tests/test_refractor_boost.py -v— 38/38 passedpython -m pytest tests/test_refractor_boost_integration.py -v— 27/27 passedpython -m pytest tests/test_postgame_refractor.py -v— 18/18 passedpython -m pytest tests/test_refractor_evaluator.py -v— 27/27 passed🤖 Generated with Claude Code
Review — feat: Refractor Phase 2 integration
Verdict: COMMENT (self-review restriction; no blocking defects but two issues below warrant a fix before merge)
Overall assessment
The architecture is well-conceived. Using
dry_run=Truein the evaluator to separate tier-detection from tier-write, then delegating the write toapply_tier_boost()which commits tier + variant + audit atomically in a singledb.atomic()block, is the correct design. The kill switch, partial multi-tier failure handling (report last-successful-tier, retry remainder next game), and card propagation all follow from the design cleanly. Test coverage is exemplary — 52 new tests across four files with detailed docstrings and verified math. Theisoformat()crash fix is also correct:hasattr(..., "isoformat")safely handles the case where SQLite returns a string instead of a datetime object.Two issues need attention before this ships.
Issue 1 —
import osinside function body (violates CLAUDE.md)app/routers_v2/refractor.py, insideevaluate_game():osis a stdlib module with zero circular-import risk. The lazy-import pattern used here (and throughout the router) exists specifically to avoid circular dependencies withdb_engine. Placingimport osinside a function is inconsistent with that rationale and violates the CLAUDE.md rule: "Never add lazy imports to middle of file."Fix: move
import osto the top-level imports inrefractor.py.Issue 2 — Audit idempotency gap: tests pass under SQLite but second call raises IntegrityError in PostgreSQL
apply_tier_boost()guards card creation (step 6) and ratings creation (step 7) withget_or_nonechecks — both are idempotent. The audit write (step 8a, insidedb.atomic()) is not guarded:The PostgreSQL
RefractorBoostAudittable hasUNIQUE(card_state_id, tier)(established in the Phase 2 foundation migration). Callingapply_tier_boosta second time for the same(player_id, team_id, tier)will:card_statefrom DB (already atnew_tier) ✅db.atomic(), attemptcreate(**audit_data)— IntegrityError 💥IntegrityErrorpropagates to callerThe
TestIdempotencytests callapply_tier_boosttwice without expecting an exception, and pass — because the SQLite in-memory database does not enforce the UNIQUE constraint (it isn't in the Peewee model'sMeta.indexes, only in the SQL migration). In PostgreSQL production, those same tests would fail.Practical impact: The
evaluate_gameendpoint is safe in the normal flow — the first successful call toapply_tier_boostwritescurrent_tier, so the nextevaluate_gameinvocation reads the updatedold_tierand never detects a tier-up for the same tier again. The failure scenario is limited to direct/retry calls and to multi-tier loops where the same tier is re-entered.Fix: Add an idempotency guard before the audit
createin step 8a:This makes the entire function idempotent end-to-end, consistent with the existing card/ratings guards.
Minor observations (non-blocking)
REFRACTOR_BOOST_ENABLEDdefault — defaults to enabled (!= "false"). Correct for production; the kill switch requires an explicit opt-out.computed_tier = last_successful_tierreassignment beforetier_ups.appendcorrectly reports only the successfully committed tiers. On next game evaluationold_tierwill equal the last successful tier, so the remaining tiers are retried automatically. Clean.compute_batter_display_stats/compute_pitcher_display_stats— formulas match the Pydantic validators (denominator 108, double_pull for batters, double_cf for pitchers, bp_homerun/bp_single at 0.5 weight). Unit tests verify each coefficient.source_variantfor T2+ — correctly chains fromcompute_variant_hash(player_id, new_tier - 1)so each tier's boost builds on the previous tier's card, producing accurate cumulative deltas. Verified byTestCumulativeT1ToT4.test_t4_has_cumulative_deltas.@ -305,7 +307,10 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):logging.warning("Bad Token: [REDACTED]")raise HTTPException(status_code=401, detail="Unauthorized")import osimport oshere is unnecessary inside the function body —osis a stdlib module with no circular-import risk. Move to top-level imports. CLAUDE.md: "Never add lazy imports to middle of file."@ -316,0 +563,4 @@boosted_splits[row.vs_hand] = boosted# 5. Compute new variant hash.No idempotency guard on the audit write. If
apply_tier_boostis called twice for the same(card_state_id, tier),_audit_model.create()will raise anIntegrityErrorin PostgreSQL (UNIQUE constraint from migration). Cards and ratings have guards above but audit does not. Addexisting_audit = _audit_model.get_or_none(...)check beforecreate. Tests pass today only because SQLite in-memory doesn't enforce the migration-only UNIQUE constraint.Approved via pd-pr
Approved via pd-pr
AI Code Review
Files Reviewed
app/routers_v2/refractor.py(modified)app/services/refractor_boost.py(modified)app/services/refractor_evaluator.py(modified)tests/test_refractor_boost_integration.py(added, 1317 lines)tests/test_refractor_evaluator.py(modified, 804 lines)tests/test_refractor_boost.py(modified)tests/test_postgame_refractor.py(modified)Prior Review Issues — Status
Both issues flagged in the previous review are now resolved:
import osplacement — moved to top-level imports (line 1 ofrefractor.py), no longer insideevaluate_game()body. Complies with CLAUDE.md convention.apply_tier_boost()now guards the audit write withget_or_none()before_audit_model.create()(lines 670–674), consistent with the card/ratings idempotency guards. A second call for the same (card_state_id, tier) will not raise IntegrityError on PostgreSQL.Findings
Correctness
evaluate_card(dry_run=True)correctly skips writingcurrent_tier/fully_evolved, leaving those writes toapply_tier_boost()which commits them atomically with the variant and audit record.for tier in range(old_tier + 1, computed_tier + 1)) correctly handles skipped tiers. Partial failure reports the last successfully committed tier, preventing false-positive tier-up notifications.REFRACTOR_BOOST_ENABLED=false) suppresses both the boost and the tier-up notification, ensuring no false positive is emitted whencurrent_tierwas never written.isoformat()crash fix (hasattr(..., "isoformat")) correctly handles both SQLite string and datetime objects.ValueErrorwithout writing anything to the DB.Edge Cases
state.trackFK access inevaluate_game()(line 367) will raiseDoesNotExistif the track is missing, not returnNone. This is caught by the outerexcept Exceptionloop handler, logged, and skipped — functionally safe but worth noting.CardModel.get_or_none()in step 2 ofapply_tier_boost()filters byplayer_idandvariantonly, notteam_id. For base cards (variant=0) and computed variants (hash is player+tier), a player may have the same variant across teams; the ratings are identical so getting any matching row is equivalent. Non-blocking._card_model.update(variant=new_variant).where(player == player_id, team == team_id)) propagates variant to all Card rows for the player+team, not just the card type being boosted. This appears intentional (the docstring describes it as "all Card rows") but means both batting and pitching Card rows receive the same variant key on a batter tier-up. If team+player can have both card types, confirm this is the intended propagation scope.Security
Style & Conventions
refractor_evaluator.py. Module-leveldb = None+_get_db()lazy-load pattern is documented and consistent with test patching strategy.Suggestions
compute_batter_display_stats()andcompute_pitcher_display_stats()could note that the denominator is 108 even though PITCHER_OUTCOME_COLUMNS sums to 79 (the x-check columns bring it to 108). The existing docstring says "All denominators are 108" which is correct but a cross-reference to the column structure would help future readers. Very minor.Verdict: COMMENT (self-review restriction — no blocking issues)
Both prior blocking issues are resolved. Logic is correct, atomicity guarantees hold, idempotency is enforced at every write point, and test coverage is thorough (52 new tests across 4 files). This is ready to merge.
Automated review by Claude PR Reviewer
Re-review — commit
7f17c9b— APPROVEDBoth issues flagged in the previous review are resolved.
Issue 1 —
import osat top of file ✅import osnow appears at line 1 ofapp/routers_v2/refractor.py, before all other imports. The lazy import insideevaluate_game()has been removed. Correct placement.Issue 2 — Audit idempotency guard ✅
refractor_boost.pylines 670–674 now guard the audit write inside_db.atomic():The
get_or_nonecheck is correctly placed inside thewith _db.atomic():block (step 8a), before_audit_model.create(). A second call for the same(card_state_id, tier)pair will find the existing row and skip creation — preventing theIntegrityErrorthat would have been raised by the UNIQUE constraint on PostgreSQL.No other concerns. APPROVED — Gitea blocks self-review so posting as COMMENT.