Repository navigation
feat(stats): promote defensive metrics from raw_stats into the typed model - #29
Merged
Merged
Conversation
Carried from dev/chatbot-agent because it is tooling, not that feature. The PreToolUse gate and the PostToolUse doc hook both had an `if` of Bash(gh pr create*), which does not parse as a permission rule and so matched every Bash call. Uses the documented Bash(gh pr create *) prefix form now, and the doc prompt re-checks tool_input.command itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKAXmoCoJ3gAu4yyD3zkCL
…model Sofascore's defensive columns were always scraped and stored in each competition entry's raw_stats, but never promoted into Stats. Because METRIC_FIELDS is derived from Stats, they could not be sorted, filtered or queried. This is a representation gap, not a data gap: no re-fetch. Adds 11 counts (tackles, tackles_won, interceptions, clearances, blocks, aerial_duels_won, aerial_lost, ball_recoveries, dribbled_past, errors_lead_to_goal, errors_lead_to_shot) and 2 rates. METRIC_FIELDS goes from 39 to 52. - Rates are derived from counts, never read from Sofascore's own percentage columns, and aggregate_stats recomputes them from the summed counts. Averaging them would be wrong: 90% over 10 tackles and 50% over 90 average to 70%, but the true combined rate is 54%. A test pins this. - One mapping in domain/defensive_stats.py serves both the live fetch and the backfill, so the two paths cannot drift. - Backfill applied: 1235 of 1256 player_stats docs, 1394 entries. The 21 untouched docs have all-zero defensive columns. - Scores are untouched. ScoringEngine does not read these fields, so s_final is unchanged and the math spec needs no update. Folding defence into s_final is Layer 3 of the design doc and still needs sign-off. - Replaces the hand-listed field maps in _stats_to_dict/_stats_from_dict with asdict/fields. Stats fields were duplicated in five places; the serialization pair silently dropped new fields and StatsOut silently ignored them. Tests now guard both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKAXmoCoJ3gAu4yyD3zkCL
…rmula The Defensive Metrics bullet is the user-visible half of the previous commit: 13 metrics reachable through METRIC_FIELDS and selectable in the Rankings metric picker. It says plainly that scoring is untouched, so it cannot be misread as a scoring change. The S_final correction is unrelated to this feature and pre-existing: the README still described the pre-overhaul formula, in both the Core Features bullet and the architecture diagram, after PR #28 replaced it. Fixed here because the new bullet refers to S_final and would otherwise sit under a wrong definition of it. Mathematical_Specification.md deliberately unchanged: ScoringEngine reads none of the new fields, Defensive_DF is still CS x 4, and nothing in that document became inaccurate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKAXmoCoJ3gAu4yyD3zkCL
…end mirror Restores the PreToolUse /code-review gate. Commit 43f4f90 carried the settings file over from dev/chatbot-agent and deleted that whole block while its message claimed only to rescope the doc hook. Merging it would have removed the code-review gate from master as a side effect of a metrics PR. Both hooks now use the Bash(gh pr create:*) permission-prefix form, matching the allow rules in the same file; Bash(gh pr create *) would not have matched a bare `gh pr create` with no flags. Backfill now writes every scanned doc, not only changed ones. 21 docs had all-zero defensive columns, so they were skipped and kept no defensive keys at all. Mongo's $gte does not match a missing field, so those players disappeared from any defensive filter through get_players, while the stats_view path returned them because _stats_from_dict defaults them to 0 in Python. One query, two answers. Re-run: all 1256 docs now match a tackles >= 0 filter, previously 1235. aerial_lost was typed and allowlisted but had no METRIC_OPTIONS entry, so it could not be filtered from the UI, and the spec wrongly claimed the fields were mirrored. Added, and the defensive block moved out of the middle of the goalkeeping group. A new test compares METRIC_OPTIONS against METRIC_FIELDS so this drift fails the suite next time. Also documents that fetches must be stopped before running the backfill, which rewrites each doc's whole competitions array. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKAXmoCoJ3gAu4yyD3zkCL
- The frontend mirror test read frontend/src/api/players.ts by relative path. docker-compose mounts only ./backend at /app, so in the container that path resolved outside the mount and the test raised FileNotFoundError. It now skips when the file is absent, so a backend-only checkout or container run is unaffected. - The backend CI job is gated on a backend/** paths filter, so a PR touching only players.ts skipped it and the mirror test never ran — exactly the drift it exists to catch. players.ts is now in that filter. - The backfill printed "every scanned doc was written" unconditionally, including under --dry-run when nothing was written. That is the same kind of false reassurance the change was meant to remove. Dry-run and real runs now report separately, with their own written counter. - The METRIC_OPTIONS extractor now asserts it parsed something, so a formatting change fails as "could not parse" instead of a misleading field mismatch, and it accepts digits in metric names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKAXmoCoJ3gAu4yyD3zkCL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Sofascore's defensive columns were always scraped and stored in every competition entry's
raw_stats— they were never promoted into the typedStatsmodel. BecauseMETRIC_FIELDSis derived fromStats, they could not be sorted, filtered, or queried. This was a representation gap, not a data gap, so no re-fetch was required.Promotes 11 counts and 2 derived rates.
METRIC_FIELDSgoes 39 → 52.tackles,tackles_won,interceptions,clearances,blocks,aerial_duels_won,aerial_lost,ball_recoveries,dribbled_past,errors_lead_to_goal,errors_lead_to_shottackles_won_pct,aerial_duels_won_pctImplements Layer 1 of
docs/superpowers/specs/2026-07-11-defender-representation-design.md, which is included here (it was previously untracked).Rates are recomputed, never averaged
aggregate_statsre-derives both rates from the summed counts. Averaging per-competition percentages is wrong, and this is pinned by a test: 90% over 10 tackles and 50% over 90 tackles average to 70%, but the true combined rate is 54%.One mapping in
domain/defensive_stats.pyserves both the live fetch and the backfill, so the two paths cannot drift.Scoring is untouched
ScoringEnginereadsStatsfields explicitly and references none of the new ones, sos_finalis unchanged andMathematical_Specification.mdneeded no edit. Folding defence into scoring is Layer 3 of the design doc and still needs sign-off.Migration — action required after merge
backend/scripts/DB/backfill_defensive_stats.pyre-reads existingraw_statsinto the new typed fields. It has been run against the local dev DB (all 1256player_statsdocs). Any other environment needs it run once, or defensive filters return empty/partial results with no error:Stop any running fetch job first — the script rewrites each doc's whole
competitionsarray.Review fixes included
Two
/code-reviewpasses ran; every finding was addressed.$gtedoes not match a missing field, so those players vanished from defensive filters viaget_players, while thestats_viewpath returned them (Python defaults them to 0). One query, two answers. Now every scanned doc is written: all 1256 match atackles >= 0filter, previously 1235.aerial_losthad noMETRIC_OPTIONSentry, so it could not be filtered from the UI. Added, plus a test comparingMETRIC_OPTIONSagainstMETRIC_FIELDSso this drift fails the suite.docker-composemounts only./backend, so the relative path toplayers.tsresolved outside the mount. It now skips when the file is absent.backend/**paths filter, so a PR touching onlyplayers.tsskipped it entirely.frontend/src/api/players.tsis now in that filter.--dry-runclaimed writes had happened. It printed "every scanned doc was written" unconditionally. Dry-run and real runs now report separately.Also fixed
The README documented the pre-overhaul
S_finalin both Core Features and the architecture diagram — stale since PR #28. Unrelated to this feature, corrected here because the new bullet referencesS_final.Known limitations, deliberately not addressed
tackles_won_pctandaerial_duels_won_pctare the first ratio metrics in the allowlist and have no minimum-volume guard. Sorting by them puts a player with 1 tackle won from 1 (100%) above a defender at 200/300 (66.7%). The counts are unaffected. Worth deciding separately whether to add a volume threshold.CLAUDE.mdhas drifted: itsdomain/tree omits the newdefensive_stats.py. Left for/init.Verification
ruff checkandruff format --checkcleantsc --noEmitcleanbackend/scripts/snapshots/pre-defender-metrics.json🤖 Generated with Claude Code
https://claude.ai/code/session_01SKAXmoCoJ3gAu4yyD3zkCL