Question: have we utilised StatsBomb 360 everywhere it can be utilised, or are there metrics built for full tracking that could be extended to freeze-frames?
Motivation: a possible commercial SB360 collaboration with an NWSL team, scoped to goalkeeper metrics.
Method: every claim below was produced by executing the library against a paired
fixture, not by reading it. Spec: docs/superpowers/specs/2026-08-04-sb360-coverage-audit-design.md.
Matrix: behaviour_matrix.md.
SB360 headroom is substantially larger than expected. Of 486 verdicts across 34 entry
points, 299 are works — the column produces identical values with or without velocity —
and only 4 are silent_degrade -- all add_ghost_gk, and since REPAIRED (see below), so
the regenerated behaviour_matrix.md now reports 489 verdicts and ZERO silent_degrade. The
figures in this report are the measurement AS TAKEN at 4.75.0 and are left standing: rewriting them
would erase the finding that motivated the repair.
These work on freeze-frames today, unchanged:
add_team_shape (20 columns) · add_xt_gk (16) · add_pre_shot_gk_position (5) ·
add_pre_shot_gk_angle (3) · add_gk_completion · add_defensive_line · add_shape_graph ·
add_structural_pass · add_packing · add_line_break · add_pressure_on_actor ·
add_defensive_credit · add_off_ball_run_values · add_sync_score ·
spadl.add_restart_coordinates
Two questions, and they have different answers:
| Entry point | On a freeze-frame | With the keeper not in frame |
|---|---|---|
add_xt_gk (16 cols) |
works | works |
add_gk_completion |
works | works |
add_pre_shot_gk_position (5) |
works | not exercised |
add_pre_shot_gk_angle (3) |
works | not exercised |
add_gk_influence (4) |
declines cleanly | not exercised |
add_ghost_gk (2) |
fabricates | not exercised |
xT-GK v1 — the library's most GK-specific valuation — works on freeze-frames AND does not require the keeper to be visible, because it values distribution through resolved origin/destination geometry rather than the keeper's tracked position. Same for GK completion.
The pre-shot GK features do need the keeper in frame. For SB360 that means in the broadcast
camera's view, and how often that holds is not answerable from code — it is a property of
the delivered data. That is Layer B (scripts/build_sb360_coverage.py).
Superseded by the full pass: coverage.md (22 matches, 3 excluded and
counted, commit 650ed07, clean tree). Headline from it: shots and saves carry a freeze-frame
~98% of the time with the relevant keeper visible 92-100%; goal kicks carry one 32.6% of the
time (per-match median 21%, IQR 18-50%), and when one exists the kicking keeper is in it 96%.
The binding constraint is frame AVAILABILITY for goal kicks, not keeper visibility and not the
library.
The 9-match spot check below is kept for the record; its 23.3% goal-kick figure was low, as its own caveat predicted.
⚠ Spot measurement only: 9 matches, 3 per cell. Not the full Layer B pass, and not produced by a provenanced driver run. Per-match variance is large, so the per-cell contrasts below are not reliable; the aggregate has the firmer denominator.
Reproducible because the inputs are named — a preliminary number with no way to check it is the shape this repo's provenance rule exists to prevent:
| Cell | Match IDs | Goal kicks | With a frame |
|---|---|---|---|
| MLS 2023 (44/107) | 3877060, 3877072, 3877090 | 39 | 6 (15%) |
| WWC 2023 (72/107) | 3893787, 3893788, 3893789 | 52 | 20 (38%) |
| WC 2022 (43/106) | 3857254, 3857255, 3857256 | 38 | 4 (11%) |
| Total | 129 | 30 (23.3%) |
Per-match goal-kick frame existence ranged 0%–50%; matches 3857255 and 3857256 had zero. The full Layer B pass supersedes this table.
The per-cell rates above carry no causal claim (ADR-053, spec amendment). The unit of analysis is the MATCH, not the event: goal kicks within a match share one broadcast, one camera rig, one crew, so 3 matches is an effective n of ~3 regardless of how many goal kicks they contain. A credible sex- or tier-contrast needs ~15-20 matches per cell. The deliverable is the aggregate and its DISPERSION -- and the dispersion is the finding a club actually needs: coverage that ranges 0-50% by match cannot be planned around as a point estimate.
Two different quantities matter, and only reporting one describes part of the domain as if it were all of it:
| SPADL type | actions | with a frame | frame existence | defending GK visible | acting-side GK visible |
|---|---|---|---|---|---|
shot |
17 | 17 | 100% | 88.2% | — |
keeper_save |
5 | 5 | 100% | — | 100% |
shot_freekick |
2 | 2 | 100% | 100% | — |
cross |
22 | 19 | 86.4% | 73.7% | — |
goalkick |
12 | 1 | 8.3% | — | 100% |
(one MLS 2023 match; "—" marks the rate that is definitionally zero for that action type)
The shape of the answer: SB360 is strong for shot-facing and save GK work, and weak for goal-kick distribution. Shots and saves carry a frame essentially always, with the relevant keeper visible ~90-100% of the time. Goal kicks — xT-GK's core distribution domain — carry a frame far less often: 30 of 129 (23.3%) across the 9 matches, ranging 0% to 50% per match, with two WC2022 matches at zero.
So for the xT-GK surface the binding constraint is neither the code (Layer A: it works) nor keeper visibility (it does not need the keeper in frame) but whether a freeze-frame exists at all for the event being valued.
A prior that did not survive contact: this spec argued a World Cup would be the optimistic upper bound on coverage, being the most lavishly filmed. On goal-kick frame existence WC2022 is the worst of the three cells (11%) and the Women's World Cup the best (38%). At 3 matches per cell that ordering is not trustworthy — but the tier hypothesis is certainly not supported.
Traced end-to-end (SB events → converter → SPADL actions → event_uuid join → frames) on MLS
2023 match 3877060: 3,540 events, 3,175 frames, 2,022 SPADL actions.
- "Only one goal kick per match." StatsBomb ships 12 and provides a 360 frame for 1. The converter handled all 12; the frames do not exist upstream.
- "44% of frames are unmapped." Correct converter behaviour:
Ball Receipt*(848) andPressure(330) are 85% of them, and both are legitimately non-actions in SPADL — a ball receipt is the passive half of a pass. Effective yield for SPADL-action analysis is ~56% of delivered frames.
The method generalises: to separate "real data" from "our bug", count the same quantity at every stage of the chain and find where it drops. Here it never dropped inside our code.
Status: fixed. The ghost path now REFUSES rather than serving an imputed feature vector: the
guard sits at the shared serving seam _serve_positions_core, so add_ghost_gk,
compute_ghost_gk and serve_ghost_gk_positions all inherit it. ghost_gk_x/ghost_gk_y
re-derive to all_nan -> honest_nan BY RULE (the machine observation changed; the adjudication
followed), and a new ghost_gk_source column reports which path produced the value.
The rest of this section is the original finding, retained because it is the reasoning that produced the fix -- in particular the mechanism, which determined what the fix had to be.
add_ghost_gk (ghost_gk_x, ghost_gk_y) WAS the only column adjudicated silent_degrade. A
fitted model silently imputes the five velocity features it was trained on
(ball_vx/ball_vy/ball_speed/defensive_line_speed/defending_centroid_vx). The output is a
plausible coordinate with no basis, indistinguishable downstream from a velocity-informed
prediction.
The imputation is not a zero-fill, and the distinction matters for the fix.
extract_ghost_gk_features yields NaN; predict_mean's HGBR reconstruction then routes NaN down
each split's learned missing-value direction — a policy fitted where NaN meant an occasional
dropped measurement, applied here where 5 of 26 features are absent on 100% of rows. Measured:
NaN → [6.795, 33.522] versus zero-fill → [6.888, 33.362]. So the defect is not
out-of-distribution values but an out-of-regime imputation policy, and "fill the zeros
correctly" would not address it — refusing on the speed_source marker would.
Everything else that differs, differs coherently: pitch control evaluated at zero velocity is
a well-defined positional model, and a feature needing a temporal window legitimately gives
a single-sample answer on a single frame. Those are differs_by_design, and the distinction was
established by isolating cause — Leg A vs an anchor-only leg separates velocity from
frame count, and without that separation a temporal-window requirement is indistinguishable
from fabricated kinematics.
-
add_gk_influencedoes not silently degrade. It was the audit's motivating example through five review rounds: it never consults the velocity marker and zero-fillsvx/vy. The zero-fill is real and is reached (features.py:5219-5223) — and the output is still NaN. A reachable code path is not evidence about the value it produces. -
A measurement taken under a different warning policy than the gate is not a measurement. The transcription tool ran under
simplefilter("ignore")while pytest escalates three warning classes, soadd_obsowas recordeddiffersand observedraises_a. Root cause: three aggregators take a keyword-onlyxt=Noneand fall back to a synthetic EPV surface.
The audit reports; it does not repair.
snapshot_to_tracking_frames' id dtype is pandas-version-dependent. WithInt64snapshot ids the frames come backInt64on pandas 2.3.3 andFloat64on 3.0.3 — theFutureWarningat_snapshot.py:172(concat with all-NA entries) materialising. Hyrum's law for any consumer pinning it.- Eleven aggregators need bespoke call shapes, which a first-class
providers/statsbombproducer would have to handle: six require a fittedExpectedThreat;add_defensive_creditneeds anxg_columnsilly-kicks does not ship;add_pre_shot_gk_angletakesframeskeyword-only while its sibling takes it positionally;add_sync_scoretakes no frames at all.
- Synthetic fixture. It establishes what the code does, not what real freeze-frames contain. Layer B answers the second.
- 26
not_exercisedverdicts (a locked budget) — columns the fixture does not reach on some axis. Most are GK features on the deliberately keeper-ablated roster, where the collapse is the finding. - Four boundary entry points are not audited, each enumerated with its reason in
tests/sb360/test_registry_surface.py::UNAUDITABLE_BOUNDARY.xtgk.compute_xt_gk_v2needs an xG-calibrated port and silly-kicks ships no xG model, so any port supplied would audit the stub rather than the library. - Orientation is not exercised. Both legs use one convention deliberately, so the ADR-028
re-projection is a no-op on each; that surface belongs to
tests/tracking/test_mirror_registry.py.