# silly-kicks

Maintained fork of socceraction — SPADL event conversion + VAEP action valuation for soccer analytics.

## Architecture

- **Hexagonal**: All core functions are pure (pandas in, pandas out). Zero I/O, zero global state mutation.
- **Converters** (`spadl/`): Each provider (StatsBomb, Opta, Wyscout, Sportec, Metrica, Gradient Sports, SkillCorner, plus the Kloppy gateway) has its own module. All return `tuple[pd.DataFrame, ConversionReport]` with guaranteed columns/dtypes via `_finalize_output()`. **Gradient Sports goal-capture (4.13.0, ADR-018):** own goals = `RE`+`shotOutcomeType=="G"` → `bad_touch`+`owngoal` (conceding team/rebounder kept; post-LTR own-half geometry tripwire warns+reverts a mis-located one); cross-goals = `CR`+`G` → keep the cross + synthesize a `shot`/`shot_freekick`+`success` by the crosser (foul-synth 0.5-offset pattern); synthesized rows (cross-goal shot + foul) carry `is_synthetic=True` (new GS schema column) since they share the parent's `original_event_id`; voided `possessionEvents.nonEvent==True` events are excluded (optional column — absent → observable no-op warn, tallied in `excluded_counts`). `"O"` shot-outcome is off-target → `fail` (4.12.2, not own-goal). Empirically grounded in the full WC2022 catalog; owner-gated e2e in `tests/spadl/test_gradientsports_scoreline_e2e.py`. **GS null-actor team attribution (4.25.0, ADR-027):** the null-actor duel/foul events (594 `OTB`+`CH` + 28 `FOUL`+`FO` on the canonical WC2022 feed — both `gameEvents.teamId` AND `playerId` NULL; a CH is a two-sided 50/50 duel) carry NaN `team_id`/`player_id` (GS schema ids are now nullable `Int64`, NOT the `SPADL_COLUMNS` `int64`), NEVER a sentinel `0` (non-NaN → bypassed `pd.isna` routing → crashed the lakehouse opponent guard `_resolve_opponent_team_id`). ADR-001 forbids back-filling from duel/foul qualifiers; an ADR-001-legal canonical-player self-heal (`_resolve_team_ids`) fills team ONLY from a real same-match `player_id` (ambiguity raises). `convert_to_atomic` now preserves source id dtype (was force-casting to atomic `int64` — latent crash for GS NaN AND kloppy-family object strings). VAEP/tracking-retrain trigger. **Provider data-quality fixes (4.20.1):** (1) **SkillCorner `time_seconds` is period-relative** — `_parse_time_start` parsed the continuous broadcast clock; `_to_period_relative` now subtracts period-start offsets `{1:0,2:2700,3:5400,4:6300,5:7200}` (2nd-half events were ~2700–5800s vs period-relative frames → linkage collapse; ADR-017). (2) **SkillCorner goalkick result** no longer hard-wired `success` → routed through `same_team_next`. (3) **sportec pass/set-piece result from native DFL `play_evaluation`** — it previously marked ALL passes/set-pieces/goalkicks success, ignoring the parsed `play_evaluation`; the punt-synth goalkick inherits its parent Play's eval. **4.21.3 generalized this to a kloppy-aligned success-ALLOWLIST** (`silly_kicks/spadl/sportec.py` `_extract_play_eval`/`_play_evaluation_is_fail`/`_warn_unexpected_play_eval`, single-sourced across the main + synth sites): `fail` iff the Evaluation is non-empty AND ∉ `{successfullyCompleted, successful}`, so unseen reason-coded failures (`unsuccessfulBecauseOfFoul`) fail by construction; empty/absent/NULL → success (no mass-fail on a missing column); unexpected tokens warn. Byte-identical on the 7 IDSSE matches (only non-success token is `unsuccessful`; owner-gated bronze e2e `tests/spadl/test_sportec_playeval_e2e.py`). Hyrum: a DFL stream with failure tokens beyond `unsuccessful` shifts its fail distribution. (4) **metrica left unchanged** (measured-correct: PASS = completed pass, loss = separate `BALL LOST` event). (5) **Frame-orientation `home_team_id` dtype bug (ADR-019)** — `gradientsports.py`/`sportec.py` tracking adapters set `team_attacking_direction` via a raw `team_id == home_team_id` that silently matched ZERO players for an int `home_team_id` vs object-string `team_id` → mislabeled → `play_left_to_right` double-flip → mis-oriented frames (the `structural_sgm` away-blow-up ROOT CAUSE + latent corruption of every frame-linked tracking feature on dtype-mismatch); both now use `ids_match` + **fail loud** on zero-match (kloppy gateway unaffected — derives home_team_id internally as string). SkillCorner + sportec + orientation changes are **VAEP/tracking-retrain triggers**. DFL goalkicks ~71% complete (not 100%). **Restart-coordinate enrichment (4.22.0, ADR-025):** post-conversion `spadl.add_restart_coordinates(actions, *, frames=None, links=None)` imputes NaN restart coords for Law-fixed-spot types (goalkick/penalty/corner/throw-in) as NEW provenance-tagged columns (`enriched_start_x/_y`/`enriched_end_x/_y` + `start_coord_source`/`end_coord_source` ∈ `{native, tracking_ball, tracking_gk, restart_prior, next_event, unresolved, tripwire_reverted}` + `start_coord_confidence`/`end_coord_confidence`), NEVER mutating canonical `start_x/end_x` (Phase 1, additive — **no retrain**; canonical promotion is deferred Phase 2). Single engine `tracking.resolve_restart_geometry` (`impute_types` gate); `tracking.resolve_gk_geometry` is now a byte-identical shim over it (`impute_types=(goalkick,)`, golden-snapshot-gated → xT-GK/completion unchanged). Geometry tripwire (ADR-018-style) lives at the `add_restart_coordinates` EDGE (engine is pure); reverts an imputed-out-of-region origin → `tripwire_reverted` (warns), native out-of-region warns only. `tracking.RestartCoordinateReport` aggregate QA. NaN coords are a GS set-piece phenomenon (StatsBomb/Wyscout/SkillCorner 0%; live-probe-grounded). C4-free (spadl helper, not a tracking aggregator — count stays 27).
- **Tracking** (`tracking/`): Per-frame namespace parallel to `spadl/`. 20-column long-form schema (`TRACKING_FRAMES_COLUMNS`); native Sportec + Gradient Sports adapters; kloppy gateway for Metrica + SkillCorner. Linkage primitive (`link_actions_to_frames` + `slice_around_event`) joins SPADL actions to frames; pointer-DataFrame return + `LinkReport` audit (ADR-004). **`time_seconds` is period-relative (resets to 0 each period — NOT absolute match-clock); ADR-017 documents + enforces this contract:** `link_actions_to_frames` gains a per-period `min_link_rate`/`on_low_coverage` (warn-default) coverage guard + `LinkReport.per_period_link_rate` (computed from the internal per-period merge, never laundered behind a healthy period); public `validate_time_base` + `TimeBaseDiagnosis` are the pre-link guard for time-filtering consumers (the linker can't see pre-filtered-out actions); `MISMATCH_OVERLAP_FLOOR=0.2` time-base-mismatch diagnostic decoupled from the link-rate symptom; convention pinned by Opta/StatsBomb lock tests (GS time-base guarded lakehouse-side). PR-S26 adds `is_goalkeeper_source` column (values `"native"` / `"derived"`) + `_gk_identification.derive_goalkeepers` B+ filtered algorithm (always-run with agreement-based source resolution) fixing Metrica/SkillCorner 21–50%→100% GK detection; `TrackingConversionReport` extended with `n_teams_gk_derived` + `derived_gk_picks` audit fields. **SkillCorner derived-GK is Tier-1 external-roster validated (PR-S86, 4.19.1: 20/20 team-GKs vs the pining `match.json` roster across 10 matches, `tests/tracking/test_gk_skillcorner_roster_e2e.py`); Metrica stays Tier-2 (anonymized public data, no roster).** Decision: ADR-007. Tracking-aware features ship in `tracking.features`: PR-S20's 4 `action_context` features + `add_action_context` aggregator + `tracking_default_xfns`; PR-S21's 4 `pre_shot_gk_*` features + `add_pre_shot_gk_position` aggregator + `pre_shot_gk_default_xfns`; PR-S24's 2 `pre_shot_gk_angle_*` features (`to_shot_trajectory` + `off_goal_line`, both signed) + `add_pre_shot_gk_angle` aggregator + `pre_shot_gk_angle_default_xfns` + `pre_shot_gk_full_default_xfns` union list. PR-S24 also ships `silly_kicks.tracking.preprocess` (Savitzky-Golay / EMA `smooth_frames` + linear `interpolate_frames` + SG-derivative `derive_velocities`; shared `PreprocessConfig` dataclass with flag-based `is_default()` + per-provider defaults from a codegen-from-JSON pipeline; tracking converters take optional `preprocess: PreprocessConfig | None = None` kwarg with auto-promotion to `for_provider(self.provider_name)`) and `silly_kicks.tracking.utils.sync_score` / `add_sync_score` / `LinkReport.sync_scores()` per-action sync-quality (TF-6). Events-side `silly_kicks.spadl.utils.add_pre_shot_gk_context(*, frames=None)` lazy-imports the GK position + angle aggregators when frames are supplied (per ADR-005 § 5; emits 6 cols when `frames=...`, 4 cols when `frames=None` — backcompat). Mirror atomic-SPADL surface in `atomic.tracking.features`; VAEP integration via composition (`compute_features(*, frames=None)` + `_frame_aware` xfn marker dispatch). Decision: ADR-005. PR-S25 adds 2 `actor_*_pre_window` features (`arc_length` + `displacement`, TF-3) + `add_actor_pre_window` + multi-flavor `pressure_on_actor` (TF-2; methods `andrienko_oval` (default) / `link_zones` / `bekkers_pi` from `silly_kicks.tracking.pressure`'s frozen-dataclass param surface) + `add_pressure_on_actor` umbrella + ADR-005 §8 amendment codifying `<feature>__<method>` column-naming convention. PR-S31 ships TF-7 pitch control subpackage (`tracking/pitch_control/`: Spearman/Fernandez-Bornn/Voronoi three-flavor dispatch, `PitchControlSurface` frozen return type, optional numba acceleration, action-coupled `pitch_control_at_target` (renamed from `pitch_control_at_action` in 4.32.0/ADR-033) + `add_pitch_control` + `pitch_control_xfns` factory). Decision: ADR-008. GKDV research program TF-15..TF-19 now unblocked. PR-S27 ships TF-13 `defending_gk_from_frames` (frame-based GK resolution fallback, ~120 LOC in `_gk_resolve.py`; **4.39.0/PR-S106 adds the mirror `acting_gk_from_frames`** — acting-team GK per action via a shared predicate-parameterized `_gk_from_frames_linked` body (defending byte-identical) + an **identity fallback** resolving the roster-stable `is_goalkeeper` identity when the keeper is undetected at the linked goal-kick frame + GK-sub nearest-in-time; pure `player_id` resolver, no action mutation, enables the lakehouse goal-kick actor-override) + TF-14 `compute_defensive_line` (per-frame 6-column back-line geometry in `_defensive_line.py`) + action-coupled 6 per-Series features + `add_defensive_line` aggregator + `defensive_line_xfns` factory (batch kernel via `_row_idx` positional pattern for gamestates compatibility). PR-S28 ships TF-5 `infer_ball_carrier` (per-frame ball-carrier inference with composite distance + velocity scoring + hysteresis in `_ball_carrier.py`) + `ball_carrier_at_action` action-coupled wrapper; also adds `game_id` to `compute_defensive_line` groupby/return schema for multi-game batch consistency. PR-S30 ships TF-4 off-ball runs + line-break detection (4 off-ball-run + 2 line-break action-coupled columns in `_off_ball_runs.py`) + `add_off_ball_runs` / `add_line_break` / `add_off_ball_context` aggregators + `off_ball_context_xfns` VAEP factory. PR-S32 ships TF-28 DAS adapter (`_das.py`: `get_das` / `get_individual_das` / `get_xc` wrappers over `accessible-space` PyPI package; `das_at_action` + `add_das` + `das_xfns` single-pass VAEP integration; `derive_team_in_possession` general helper in `_ball_carrier.py`). PR-S33 ships TF-31 team shape envelope (`_team_shape.py`: `compute_team_shape` per-frame 7-metric primitive; `add_team_shape` 14-column aggregator; `team_shape_xfns` 36-column VAEP factory) + TF-32 Ward-clustering line-breaking (`_line_breaking.py`: `detect_line_breaking` 3-column Ward clustering + straddle test; `add_line_break` extended with `method="ward"` dispatch; `line_breaking_ward_xfns` 9-column VAEP factory).
PR-S34 ships TF-15 GK influence primitives (GKDV Layer 1, `_gk_influence.py`: `compute_gk_influence` per-frame entry point with 3 primitives — threat-weighted pitch control share, uniquely reachable area, zone closing time; `Zone`/`GkInfluence`/`ZoneClosingTime` frozen dataclasses; `add_gk_influence` aggregator; `gk_influence_xfns` VAEP factory with frame-precomputation cache; 4 per-Series helpers; atomic mirror). Also ships prerequisite extractions (`compute_tti` public export, `select_back_line_players` extraction) and bundled TF-32 fixes (H1 joint dropna, H2 extension-poisoning, M4 non-pass filtering).
PR-S36 ships TF-30 cover shadow features (`_cover_shadows.py`: `CoverShadowParams`/`LaneControlResult`/`BlockingScoreResult` frozen dataclasses; `ball_drag_time` Spearman 2017 drag model; `player_tti` 3-phase TTI; `lane_control` corridor-discretized blocking; `compute_blocking_score` Voronoi counterfactual; `add_cover_shadows` 5-column aggregator; `cover_shadow_xfns` 15-column VAEP factory; atomic mirror).
PR-S51 ships TF-36 + TF-33 per-player influence primitives (`_player_influence.py`: `PlayerInfluence` frozen dataclass; `compute_player_influence` per-frame off-ball xT via PC share × xT + uniquely reachable area for all outfield; `add_player_influence` 7-column aggregator with `_team`/`_opponent`/`_diff` convention; `player_influence_xfns` 21-column VAEP factory; 5 per-Series helpers; atomic mirror).
PR-S52 ships TF-18 ghost-GK positioning model (GKDV Layer 2, `_ghost_gk.py`: `GhostGkModel` RFCDE density estimation via HistGradientBoostingRegressor leaf co-occurrence + weighted 2D KDE; `GhostGkDensity` frozen dataclass 60×64 grid; `extract_ghost_gk_features` 26-feature goal-relative extractor; `compute_ghost_gk` batched per-frame primitive; `add_ghost_gk` action-coupled aggregator (no provenance leak); `ghost_gk_xfns` 9-column VAEP factory; vectorized numpy tree traversal (no sklearn at inference); npz+JSON+SHA256 serialization (no pickle); two bundled model variants `"default"` (9 MB, 36k samples) / `"full"` (91 MB, 537k samples) via `GhostGkVariant` Literal type; `model="default"|"full"|GhostGkModel|None` on `compute_ghost_gk`/`add_ghost_gk`; atomic mirror).
PR-S55 ships TF-18 training pipeline (`prepare_ghost_gk_training_data` public API with match context resolution via `_build_score_lookup`/`_build_phase_lookup`, shared `_extract_all_ghost_gk_features` batch helper, label domain filtering; `compute_ghost_gk` + `add_ghost_gk` now accept optional `actions`/`actions_for_context` for score+phase enrichment; `scripts/train_ghost_gk.py` StratifiedGroupKFold CV + permutation importance + metrics.json + feature extraction disk cache + `predict_mean()` fast path).
PR-S81 (4.10.0) ships the Ghost-GK serve-carrier fix + R3 + 4.7.0 re-fit: `compute_ghost_gk` now computes the ball-carrier on the FULL frames (fixing `team_in_possession` always-0-at-serve train/serve skew) with the model's recorded `carrier_params`; `GhostGkModel` records+consumes `carrier_params` in `metadata.json` (model version 1.1.0, R3 — mirrors xS) + `sklearn_version`/`training_commit`/`training_platform`; `compute_ghost_gk`/`add_ghost_gk`/`ghost_gk_xfns` gain an optional `carrier=` passthrough; `prepare_ghost_gk_training_data` gains additive `carrier_params=`. Bundled `default` + Hub `full` re-fit on 81 pining matches (887k samples) vs the 4.7.0 carrier defaults; serve-output change measured at 0.4% of frames / max ~4 m (long-tail, Hyrum-flagged for lakehouse).
PR-S57 ships library extraction TF-39..TF-44: TF-39 Shape Graph (`_shape_graph.py`: Sotudeh 2026 iterative Delaunay edge-removal + 5×5 face-center position decomposition; `compute_shape_graph`/`infer_positions`/`ShapeGraph`/`PositionLabel`; `add_shape_graph` aggregator; `shape_graph_xfns` 36-column VAEP factory); TF-40 OBSO (`_obso.py`: `compute_obso_surface`/`compute_pass_obso`/`ObsoSurface`/`ObsoParams`; `add_obso` aggregator; `obso_xfns` 9-column VAEP factory); TF-41 Space Creation (`_space_creation.py`: `compute_space_created`/`SpaceCreationParams` + `include_opponent_perspective` defender-side LOO on the opponent-attacking OBSO surface (4.23.0 lakehouse-mandated, ADR-026; **4.24.0 round-2 fix: the opponent surface is weighed by the x-MIRRORED transition/EPV artifacts** — the 4.23.0 shared-unmirrored multiplier made the complement LOO the exact pointwise negation of the team LOO (`opp_destroyed ≡ team_created`, informationally empty); complement decomposition = zero extra PC cost analytical / explicit opponent PC naive, BOTH on the mirrored multiplier; two-team loud guard, NaN-mask parity. **4.24.0 LEAN 2-COLUMN CONTRACT (owner-approved breaking): `space_created_m2` (attack) + `space_denied_m2_opponent` (rest-defense) ONLY — the monotone-LOO structural zeros (team destroyed, opponent created) and the redundant nets were RETIRED; guard test blocks resurrection**); `add_space_creation` 2-column aggregator; `space_creation_xfns` 6-column VAEP factory); TF-42 PAUSA (`_pausa.py`: `compute_pausa`/`compute_pausa_batch`; `add_pausa` aggregator; `pausa_xfns` 9-column VAEP factory); TF-43 ELASTIC (`_elastic_sync.py`: `align_events_to_frames`/`extract_ball_features`/`ElasticSyncParams`; `add_elastic_sync` aggregator; `elastic_sync_xfns` 6-column VAEP factory); TF-44 Ward inter-line gaps (`_team_shape.py` enhancement: `defensive_line_height`/`inter_line_gap_1`/`inter_line_gap_2` via Ward clustering; `team_shape_xfns` 36→54 columns).
PR-S75 ships TF-16 xShotOccurrence (xS, GKDV Layer 2, `_xshot_occurrence.py`: `XShotOccurrenceModel` deterministic-XGBoost shot-occurrence classifier — P(shot attempted by the in-possession team within ~1 s of a frame); paper-faithful 27-feature `extract_xshot_features` (ball r/θ/z/speed, `openGoal` goal-mouth obstruction, GK dist/bearing, 5 nearest defenders + 5 nearest attackers) in goal-relative coords via the shared `_geometry` helper; `build_xshot_labels` time-windowed labels; `prepare_xshot_training_data` shared train/serve entry point with alive-ball + attacking-third domain filter (always returns the faithful class distribution; negative subsampling lives in the standalone train-only `subsample_negatives` helper); `compute_xshot_occurrence` / `add_xshot_occurrence` / `xshot_occurrence_xfns` ADR-005 surfaces; pickle-free booster-JSON + SHA256 serialization; `XShotFeatureSet` Literal (`"extended"` reserved); ruthless HPO objective (`_xshot_occurrence_objective.py`) + `scripts/train_xshot_occurrence.py`; atomic mirror. **Trained weights bundled 4.9.0 (PR-S80):** a single `public` variant (skillcorner+idsse) trained on 81 matches against the 4.7.0 carrier defaults — the pre-registered `public`-vs-`full` two-candidate test found owner-tier Gradient Sports data degraded public-held-out PR-AUC in all 5 folds, so the reproducible public-only model shipped (`from_variant("default")`/`from_hub`; wired into `pre_shot_gk_full_default_xfns` only). Decision: ADR-011 (trained-model lifecycle); attribution arXiv:2512.00203.
PR-S82 ships TF-17 xCrossAttempt (xCross, GKDV Layer 2, `_xcross_attempt.py`: `XCrossAttemptModel` deterministic-XGBoost cross-attempt-propensity classifier — **STATE-anchored** P(the in-possession team attempts a cross within ~1 s of a frame), reframed from the Cao et al. (arXiv:2505.11841) sender-level event treatment; **7 of the paper's 8 confounders** (crosser-position #7 omitted — no faithful tracking-only proxy) **+ a novel, isolatable GK-position confounder block** (the paper's headline gap) in goal-relative coords via the shared `_geometry` helper; shared `_build_occurrence_labels` (`_occurrence_labels.py`; `build_xshot_labels` refactored onto it, bit-identical); `prepare_xcross_training_data` shared train/serve entry point with alive-ball + **wide-area** domain filter + optional `actions=`-supplied `score_differential` (reuses ghost-GK's `_build_score_lookup`, NaN-tolerant); `compute_xcross_attempt` / `add_xcross_attempt` / `xcross_attempt_xfns` ADR-005 surfaces; pickle-free booster-JSON + SHA256 serialization; `XCrossFeatureSet` Literal (`"extended"` reserved); ruthless HPO objective (`_xcross_attempt_objective.py`) + `scripts/train_xcross_attempt.py`; atomic mirror. **WEIGHTS shipped 4.18.0 (PR-B, PR-S85):** bundled `public` model (skillcorner+idsse) trained on the clean-4.13.0-GS 81-match pining corpus vs the 4.7.0 carrier defaults; pre-registered public-vs-full paired test → public-only (`full` degraded public held-out in all 5 folds, mirrors xS); `from_variant("default")`/`from_hub` live; `xcross_attempt_xfns` wired into `pre_shot_gk_full_default_xfns` (+atomic) only. Shipped-surface GK validation lives in the private `_xcross_eval.py` (`gk_block_ablation` + `gk_substitution_probe` + CV-held-out `permutation_importance_report` + shared `_cv_score` the trainer's `_cv_metrics` delegates to); the TF-19-viability gate is the substitution probe alone against a pre-registered `ratio>=2.0 AND abs-floor>=0.01` rule — result **`tf19_ready=False`** (GK carries relative signal, 2.6× the nearest-defender control, but below the absolute floor → TF-19 consumption gated on GK feature-engineering, never shipped silently as novelty); `score_differential` measured as the #2 feature (0.0216) on the clean GS stream. **PR-C (causal harness, ADR-015, 4.18.0) closes TF-17:** the private `silly_kicks/_causal/` port (pure numpy/sklearn, no R, no new dep) — `matching.py` (propensity-score matching: ATT/ATNT, 1:1 NN **with replacement**, no caliper, logistic PS on standardized covariates, **Abadie–Imbens 2006 matching SEs**) + `opportunities.py` (spell state-machine crosser-anchored rows; treatment `(entry, min(entry+T, spell_end)]` — fixed-`T` cap + possession-clamp; outcome strictly post-cross; 7 paper confounders, GK missing-indicator) + `scripts/validate_xcross_causal.py` (GK-vs-row-permuted-GK-placebo ablation, positivity guard, PS-overlap+SMD claim gate). REPORTED-not-gated (`docs/research/xcross_causal/`); only the known-truth `tests/causal/` method tests gate CI. **Clean all-provider result (`docs/research/xcross_causal/`): ATT_with_GK +0.0927 (SE 0.0156), 23966 opp / 669 treated, PS overlap 1.0, SMD 0.51→0.078; GK ablation shift 0.0179 < placebo p95 0.0239 → `gk_clears_placebo_band=False` — independently corroborates `tf19_ready=False`.** A latent GS bug fixed en route: `extract_xcross_features` stringified nullable-`Int64` ids via `.to_numpy().astype(str)` → `"366.0"≠"366"` → all-NaN GK/carrier features for ~83% of the corpus (all GS) + the shipped GS-inference path; fixed through the ADR-019 `canonical_id` contract (public weights train on string providers → no retrain; repairs GS inference). Decision: ADR-011 (trained-model lifecycle) + ADR-015 (causal-validation port); attribution arXiv:2505.11841. (PR-S82 = PR-A code; PR-B weights + PR-C causal harness ship together in 4.18.0 as PR-S85.)
PR-S83 (4.14.0) ships the TF-18 Ghost-GK served-estimator fix (ADR-016): `compute_ghost_gk`/`predict()`/`predict_mean` now serve the **exact sklearn HGBR boosted prediction**, reconstructed **pickle-free** as `baseline + Σ_trees leaf_value` via a new `_vectorized_leaf_values` (sibling of `_vectorized_leaf_indices`) — closing a pre-existing integrity gap (production served the KDE **mode** ~4.65 m while the card reported ~1.1 m for the `predict_mean` that `save()`/`load()` discarded → never served); clean re-fit serves **1.07 m** held-out MAE. `fit()` trains `phase` **numerically** (`categorical_features=None` — makes the numeric reconstruction match sklearn exactly AND closes a latent KDE categorical-routing capability gap) + re-adds the **gk_y** HGBR + stores both ensembles' tree nodes + `_baseline_x/y`; `predict_density` retained for `ghost_gk_density_spread` + the mode; `GhostGkDensity.central_*` + `_central_estimate` removed (the rejected Option-B leaf-weighted conditional mean — DGX-measured 7.0 m, *worse* than the mode, because the conditional density is broad/multimodal). **Breaking column rename `ghost_gk_spread` → `ghost_gk_density_spread`** (it is the conditional-**density** dispersion, NOT the served point's SE; lakehouse re-materializes). `save()/load()` serialize the gk_y trees + baselines (artifact version 1.2.0, `serve_estimator="boosted_mean"`, `load()` fail-closed on a conflicting tag AND on pre-Option-A artifacts → "re-fit required"); inference stays sklearn-free + **sklearn-version-independent** (sklearn couples only at fit/extract; a blocking parity-on-fresh-fit gate guards it). Both bundled `default` + Hub `full` **re-fit on the clean 4.13.0 GS events** (887k samples); atomic mirror. Decision: ADR-016; numpy-reconstruction chosen over skops/treelite/ONNX (no sklearn-at-inference, no new dep). **4.22.1 (ADR-016 amendment):** `compute_ghost_gk` clamps the served `ghost_gk_x/y` (goal-relative) to the physical pitch (x∈[0,105], y∈[0,68]; warns) — fires only on garbage input (e.g. upstream mis-flagged `is_goalkeeper` wrong-footing the goal-side flip → OOD extrapolation below the trained x∈[0,30] label hull); clamp lives at the serving seam, NOT in `predict_mean` (exact-boosted parity contract untouched); healthy sweeper-rush extrapolation past 30 m byte-unchanged.
PR-S84 (4.16.0) ships **TF-45 structural-pass primitives** (`_structural_pass.py`: pure `_structural_pass_core` + `compute_structural_pass_metrics` per-frame + `StructuralPassParams`[`sigma=15.0` empirically tuned on 2,466 real WC2022 passes; the original "smallest σ where the inverse-density SGM is intrinsically pitch-bounded" claim was FALSIFIED on real byline-cross/fast-break frames (rho→underflow, 1/rho→~1e8) and fixed in 4.20.1 by an explicit eps-floor on rho at `exp(-4.5)≈0.0111` (3σ contribution), capping `1/rho`≈90] + `@nan_safe_enrichment add_structural_pass` emitting raw `structural_lbs`/`structural_sgm`/`structural_sdi` via the shared `_kernels._structural_pass_at_actions` + `structural_pass_xfns` VAEP factory [3×-not-9× call-count budget]; atomic mirror with `end=x+dx` synthesis; arXiv:2603.28916, ADR-005). **Library = RAW primitives only** — TIV z-norm composite + K-means(K=4) archetypes + ΔTIV/cumulative rankings stay consumer-side (frozen-exogenous, ADR-009). LBS = Line Bypass Score (defenders with `start_x < d_x ≤ end_x` in attack-positive coords; 1-D caveat), SGM = Space Gain Metric (Δ inverse-Gaussian defender density receiver−passer), SDI = Structural Disruption Index (Δ dist-from-full-team-2D-centroid); SDI uses a *fresh* centroid (NOT `compute_defensive_line`'s back-line mean), SGM a *direct* KDE (NOT `_cover_shadows`'). Receiver = pass destination `end_x/end_y` (no SPADL `receiver_player_id`). Owner-gated e2e validates real WC2022 GS. **Also fixes a systemic dup-`action_id` crash in 8 frame-aware xfns** (`pitch_control`/`obso`/`pausa`/`space_creation`/`pressure`/`cover_shadow`/`gk_influence`/`player_influence`): VAEP shifted gamestate slots repeat the boundary action → non-unique `action_id` → `set_index("action_id").at[aid]` returns a Series (raises) / provenance merges fan out. Fixed via shared `_kernels.resolve_frame_ids_by_position` (positional, byte-equivalent to the old `.at` on unique ids) + a red-first behavioral gate auto-enumerating every `*_xfns` (`tests/tracking/test_frame_aware_xfns_dup_action_id.py`, meta-asserts coverage, discriminates the bug from a fixture gap). Production `add_*` aggregator path (unique ids) unaffected; the xfns path now produces values vs crashing → **VAEP-xfns-path retrain trigger**. Decision: ADR-020 (driven by a behavioral gate, not a `.at` grep — which missed `pausa`/`pressure`).
PR-S88 (4.21.0) ships **xT-GK** (Eyestone, GKDV-distinct GK-distribution value, ADR-024): `_xt_gk.py` pure parametric compute feature (NOT trained — no ADR-011) — `compute_xt_gk`/`@nan_safe_enrichment add_xt_gk`/`xt_gk_xfns` + `XtGkParams.for_philosophy` (5 presets) + atomic mirror (`end=x+dx` synthesis). Emits raw `xt_gk_base`/`pev`/`rav`/`dzv`/`pressure` + composite `xt_gk` per GK-distribution action (goalkick / GK-actor pass / throw_in). **Tracking-required** (PEV needs `pressure_on_actor`; no pressure survives SPADL); **RAV P(success) = fitted `GkCompletionModel`** (bundled GS `default`; replaced the OOD-on-goalkicks open-play `get_xc`, ~31%→~100% coverage; `[das]` now OPTIONAL); **required injected pre-fitted `ExpectedThreat`** (no self-fit/leakage; unfitted-grid guard). **Option B (Jeffrey 2026-06-08): destination counted ONCE** — `base = −xT★(z)` origin-only, RAV owns `xT★(z')`; separate `progress` feeds PEV (else origin-only base zeroes it); composite `T·(−xT★(z) + γ·PEV + RAV) + φ·DZV`. Normative params (γ/δ/φ/η/`v_def`/`defensive_third_boundary`/`pressure_scale`) intent-set NEVER calibrated; `convolution_sigma` hand-set (no calibration phase). `_grid_value` reuses `xthreat._grid._get_cell_indexes` (pinned to `ExpectedThreat.rate`). `home_team_id` parity-only (LTR coords). In **no** default xfn list (additive — opt-in = self-triggered VAEP retrain). Construct-invariant gates (back-pass↑, def-third DZV>0, pressure→PEV). **Phase 2 (team γ/δ/φ/η estimation) deferred** (Jeffrey Q7). Presets provisional (exact values pending). Attribution: Eyestone (PtP-1); public-with-attribution + formula consent in NOTICE/ADR-024. **Goal-kick coverage (folded into 4.21.0, ADR-024 amendment):** the OOD e2e escalated (real goal-kick get_xc ~31%), so `resolve_gk_geometry` (`_gk_geometry.py`) derives the missing origin (conditional: native → in-area tracking-GK clamped `x≤16.5m` → rule-point `(5.5,34)`) + destination (native → in-period next-event, `(game_id,period_id)` boundary-guarded) WITHOUT mutating shared `actions`, emitting per-row `xt_gk_origin_source`/`xt_gk_dest_source`/`xt_gk_origin_confidence` provenance (+ aggregate `XtGkReport`); `GkCompletionModel` (`_gk_completion.py`; logistic, pure-numpy `sigmoid(Xβ)` serve, pickle-free JSON+SHA256, `from_variant("default")`/`completion=` override) bundled GS `default` trained on 30 WC2022 matches via `scripts/train_gk_completion.py` (native-origin POOLED out-of-fold green gate: AUC 0.838 CI95 [0.81,0.86], n_native 1395, Brier 0.122<0.171; density 96% finite). Density NaN→training-mean impute (neutral post-standardization); whole-row geometry-unscoreable→per-type base rate (standalone `compute_gk_completion` only — RAV NaNs unresolvable-dest rows honestly). **Train==serve parity** enforced at every producer (shared domain predicate `_gk_distribution_mask`, `resolve_gk_geometry` on FULL-then-mask, shared `_gk_completion_density`, shared `extract_gk_completion_features`). `compute_gk_completion` + `add_gk_completion` exported (`add_gk_completion` = the lakehouse wide-table aggregator: emits `gk_completion` per in-scope GK distribution, NaN out-of-scope, geom-on-full-then-mask reusing RAV's `_completion_p`; C4 action-coupled-aggregator count 26→27). **SkillCorner completion + provider-aware variants (folded into 4.21.0, ADR-024 amendment):** `spadl/skillcorner.py` `result_id` now routes through the **native** outcome — `pass_outcome`→`received==True`(success-only)→residual `same_team_next` — via `_native_completion_result`, emitting a dedicated **`result_source`** column (`native`/`inferred`/`stopgap`) in `SKILLCORNER_SPADL_COLUMNS` (the old bare `same_team_next` proxy agreed only ~0.72–0.79 with the native outcome + overstated goal-kick success ~16pp; `received==False` is NOT a fail — could be completion to a non-targeted teammate). **VAEP-retrain trigger** (SkillCorner scores/concedes shift; lakehouse re-materializes). `GkCompletionModel` gains a **provider-keyed variant registry**: pure `variant_key_for_provider` (`skillcorner`→own weights, else→`gs`) + auto-selection in `compute_xt_gk` from `frames["source_provider"]` (caller `completion=` wins; >1 real provider raises; `snapshot` excluded; missing-variant→`gs` fallback + warn; 4.22.1: `from_variant` aliases the `gs` KEY→bundled `default` DIR so `from_variant(variant_key_for_provider(p))` composes without `FileNotFoundError`). **Training is `result_source=="native"`-ONLY** (G1: `inferred` is positive-only → would bias the multiplicatively-consumed calibration; only `pass_outcome` gives both classes). Bundled **`skillcorner`** variant (10 matches): GS does NOT transfer on the corrected native label (GK-pass AUC 0.412 < chance → distinct weights), SkillCorner GK-pass **AUC 0.739 / ECE 0.036**; **goal-kicks chance (0.433) — model-scored in 4.21.0; base-rate-served as of 4.21.4** (per-type serve gate, SK-91 — see PR-S91 below). Provenance: `xt_gk_completion_variant`/`xt_gk_completion_source` + `XtGkReport.spans_multiple_variants`; cross-provider comparability gate (`scripts/_xtgk_comparability.py`, owner-run) → SC-vs-GS `xt_gk` **within tolerance** (pool directly, no re-scale; G2). No-pool-across-variants-without-comparability contract documented (ADR-024). C4: no enumeration change (a completion *variant* is not a new aggregator; count stays 27). **SkillCorner keeper-origin resolution (4.37.0, ADR-024 amendment, PR-S104):** SkillCorner broadcast tracking records a GK-distribution's native origin as the **ball-detection event location, not the keeper** (goal-kick `start_x` SD 23.2; own-box 51% vs ~100% full-tracking) — `resolve_gk_geometry` trusted it, corrupting `xt_gk` base/DZV + keeper pressure/PEV. Native-origin trust is now **provider-aware** via a FAIL-SAFE allowlist (`native_origin_is_trusted`: unknown/None/future → distrust; only `gradientsports`/`idsse`/`metrica`/`sportec`/`statsbomb`/`wyscout` trusted) decided at the `compute_xt_gk` edge and passed as the opt-in `distrust_native_origin` into `resolve_restart_geometry`/`resolve_gk_geometry` (default-off → full-tracking byte-identical). **Distrust scope = GOAL-KICKS ONLY** (narrowed after real-data validation: open-play GK passes' native origin IS the keeper — ball-at-release — measured 0.4 m vs the detected keeper, so they keep native; goal-kick native is the broadcast ball ~14–20 m downfield, so it's distrusted). A goal-kick resolves by: keeper **detected within ±1 s** (`visibility`-gated, nearest-in-time ties→at-or-before; `_tracking_gk_xy_detected`, window centred on the action clock per ADR-017, **re-projected to action-LTR via ADR-028 `acting_team_attacks_rtl`** — away-team detections else land at the wrong end of the pitch) + in-box clamp → `tracking_gk`; else rule-point `(5.5,34)` → `goalkick_prior`. `unresolved` is now a rare residual (NaN-native open-play pass + no detection). **Origin-only** (a dedicated `origin_eligible` mask leaves the destination cascade untouched — the broadcast ball *endpoint* is correct). S4 `flag_native_goalkick_out_of_region` warns + emits the per-row `xt_gk_native_goalkick_out_of_region` provenance flag (NOT in `_OUTPUT_COLS`) + `XtGkReport.n_native_goalkick_out_of_region`. S1 within-pitch invariant in `skillcorner.convert_to_frames` warns + counts `TrackingConversionReport.n_gross_off_pitch` (LAYERED: `derive_goalkeepers`'s `[-15,120]×[-15,83]` raise stays the catastrophic player backstop; S1 = a thin player band a margin INSIDE that shared bound + the SOLE off-pitch signal for the ball). C1: removed the mixed-provider `completion=` escape hatch (`_resolve_single_provider` enforces one-call-one-match uniformly across completion AND geometry). **`xt_gk` opt-in → not a forced VAEP retrain, but SkillCorner `xt_gk` serve output changes → lakehouse re-materializes.** Deferred (tracked): S1/S4 CI rate-gates + TOL calibration from the pining corpus; open-play own-half bound (validate-then-maybe); detection-window perf (measure-before-optimize). C4 count unchanged (28).
PR-S93 (4.28.0) ships **TF-48 post-shot goalmouth crossing geometry** (ADR-030, lakehouse-PSxG feed): `_shot_goalmouth.py` pure engine `compute_shot_goalmouth` + `@nan_safe_enrichment add_shot_goalmouth` + per-Series wrappers + atomic mirror ({shot, shot_penalty} — `shot_freekick` is a `freekick` atom). Per shot action, fits the post-contact ball trajectory (x/y constant-velocity LS; z by a `rolling`/`airborne`/`bounced`-with-hysteresis taxonomy, fixed-g ballistic; bounce supersedes to the LATEST sub-segment — what the GK faces) and intersects the attacked goal plane → `shot_crossing_y/z` (canonical attacked-goal-at-x=105, point reflection x→105−x+y→68−y), `shot_speed` (ALWAYS the contact sub-segment), `shot_time_to_goal_line`, `shot_on_target_derived` (posts/bar + ball-radius tolerance, width folded in BY DECISION) + provenance (`shot_crossing_source`/`confidence`/`fit_n_frames`/`fit_rmse`/`fit_end_reason`/`z_profile`) + `ShotGoalmouthReport`. Engine is orientation-AGNOSTIC (goal ends from the GK map — `defended_goal_x` extracted byte-identically from xS into `_gk_resolve.py`; never reads `team_attacking_direction`; both-conventions byte-identity gate); fit window ends at FIRST of observed plane straddle / trajectory break (LOCAL residual + ground-gated speed-drop + reversal-vs-own-initial-direction — own goals fall to `no_crossing` intentionally) / window cap. Kernel hardened over 7 real-WC2022 pilot cycles: sample-and-hold collapse (GS raw `balls` = ~15 Hz duplicated at 29.97 Hz stamps), flight-run + CONTACT anchoring (the GS event x/y is an exact ball-track point; contact-EXISTENCE: a window whose ball never comes contactably near the stamp at z≤2.6 m provably lacks the shot → `insufficient_frames`), z-aware flight (chips), 0.1 s-baseline velocities, extrapolation-leverage cap (t* ≤ 3× evidence span). **NO VAEP xfns** (post-contact outcome = HybridVAEP-class leakage; auto-discovering guard over every default xfn list) → no retrain trigger. **HOLDOUT-VALIDATED (one-shot protocol, ADR-030 floors ALL PASS: 48 held-out WC2022 matches / 999 matched shots — goals dy median 2.17 m ≤ 2.5, dz 0.48 ≤ 1.25, coverage 0.620 ≥ 0.60, on-target agreement 0.60 ≥ 0.45; y-handedness settled on SB-freeze-frame-GK-vs-tracked-GK GEOMETRY, 0.882 on 646 voters).** Owner-gated GS↔StatsBomb WC2022 acceptance harness (`scripts/validate_shot_goalmouth_sb.py`; SB y is inverted vs SPADL; GOALS-vs-SAVES stratified — SB save end_locations are save-points; SB↔GS clocks are BOTH cumulative — round-1 aborted on a period-relative conversion that had excluded ALL P2 shots, full failure record in ADR-030). Also fixed: the pining GS loader hardcoded `z=0.0` and dropped real ball z (present on 100% of raw GS ball records). C4 count 27→28. **Completion cycle (4.28.0):** two kernel refinements re-validated on the FULL 64-match GS corpus (post-holdout protocol) — (1) span-gated curve-aware y extrapolation (`_extrapolate_crossing_y`: quadratic-y when the producing segment supports curvature AND it markedly out-fits the line, capped tighter than the linear leverage; fixes a curling-chip's linear-extrapolation miss) + (2) earliest-reaching flight-run tie-break (`_find_flight_run`: among runs that REACH the plane prefer the EARLIEST — the bare nearest-plane rule anchored t0 PAST a real in-mouth crossing). Full-corpus floors all pass, goals dy 2.17→2.08 (no regression); `--sweep` extended to the contact/flight module constants confirms robustness. **Provider coverage:** GS validated; SkillCorner/IDSSE returned `insufficient_frames` at TF-48's time (4.28.0) due to a then-open upstream y-inversion (kloppy tracking frames inverted vs SPADL actions — the tracking gateway omitted the `to_coordinate_system` pin the event gateway used; `docs/research/bug_kloppy_tracking_y_inverted.md`). **FIXED in 4.29.0/4.30.0 (ADR-031):** the kloppy tracking gateway now pins the canonical SPADL coordinate system (CS-pin, T1/PR-S94) and the DFL parse-port retired the y-inverting dev loader (T3/PR-S95), so SC/IDSSE resolve on the corrected path — the coordinate-fix smoke-test had predicted SkillCorner 0.12→0.60 (GS baseline), proving the gap was upstream, not in TF-48.
PR-S98 (4.33.0) ships **TF-23 SkillCorner + Metrica bronze→frame builders** (ADR-034): pure bronze-consuming converters `tracking.skillcorner.convert_to_frames` / `tracking.metrica.convert_to_frames` (parallel to the native sportec/GS adapters) that single-source the SkillCorner/Metrica coordinate rescale + period-relative clock + id-namespacing + GK derivation + speed + LTR orientation the luxury-lakehouse previously duplicated **three** ways (the kloppy gateway oracle + two lakehouse builders `analytics/action_context/convert.py` + `ingestion/tracking_context.py`). Promotes the lakehouse ADR-053 flag-free **geometric frame-LTR net** to the library as `tracking.orient_frames_to_ltr_by_geometry` (per-period home-GK-median-x anchor → point-reflect mis-oriented periods; idempotent; silent on normal flips; reused by both builders; retained ALONGSIDE the flag-based `orient_frames_to_ltr`). Recovers SkillCorner `ball_z` (previously discarded → SkillCorner TF-48 PSxG features silently null). **Clock:** SkillCorner reuses the events converter's nominal `_PERIOD_START_SECONDS` (same-provider single-source); Metrica rebases per-`(period)`-min (its 3 public sample games have MIXED continuous/period-relative raw clocks — verified). **GK:** SkillCorner **trusts** its authoritative roster `is_goalkeeper` (4.38.0/PR-S105: use it as-is, `is_goalkeeper_source="native"`, SKIP `derive_goalkeepers`; derive only as fallback for a `(game,team)` with no native GK) — the prior unconditional re-derivation over-flagged ~15 "keepers"/team across the lakehouse's 250-frame per-batch builds since positional derivation is unstable on a 25 s window (real-bronze union 15/13→1/1); + S2 guard `warnings.warn`+`TrackingConversionReport.n_implausible_gk_teams` on an implausible per-team GK count (>2 or 0). **Metrica (no roster) is still contaminated the same way — derive-once-per-match is a tracked follow-up.** Metrica seeds NONE (`gk_jersey_numbers` is a flat, team-agnostic list; `derive_goalkeepers` ORs-never-clears → a mis-flag would reach the orientation anchor) and lets the validated positional algorithm derive it (Tier-2, ADR-007). **Metrica input CONTRACT:** bronze `y` must be SPADL bottom-to-top (kloppy's metrica NATIVE CS is top-to-bottom — a kloppy-`TrackingDataset` consumer MUST flip `1−y`; the lakehouse bronze already does). Gate C closes on the shipping path via a kloppy-independent **event-anchored ball-position y-identity gate** (SkillCorner, real Databricks `skillcorner_tracking ⋈ skillcorner_matches` + `spadl_actions` slice, both teams/periods); the Metrica builder is validated canonical-correct vs the kloppy gateway (dx=dy=0 on open-data game 1). Lakehouse delete-and-depends both builder copies + retires its orientation net (its re-materialize trigger, not silly-kicks'); GS-native-adapter ET orientation = TF-23b follow-on; a relayed lakehouse find: metrica `spadl_actions` event-y didn't co-locate with the canonical tracking ball (a lakehouse events issue, NOT the builder). C4-free (new converters + a geometric orienter, not action-coupled aggregators; count stays 28).
PR-S91 (4.21.4) ships the **xT-GK per-type base-rate serve switch** (ADR-024 amendment, SK-91): `compute_xt_gk` serves the per-type calibrated base rate (tagged `xt_gk_completion_source="base_rate"`) instead of the geometric `p` for any completion sub-domain whose held-out AUC LCB ≤ 0.5 (or degenerate / `n<_GATE_N_MIN`=50). One pure `serve_mode_from_lcb(lcb,n)` (`_gk_completion.py`) baked into `GkCompletionModel` (`_type_serve_mode`+`_type_gate_metrics`, artifact **VERSION 1.1.0**; `load()` **fail-opens** → a pre-gate 4.21.0 artifact serves all types `model` = prior behavior). `predict_proba` stays a pure scorer; the switch+tag live in `compute_xt_gk` (geometry-resolved RAV path only); atomic mirror inherits; `XtGkReport.completion_source_counts`. **Data-driven per variant, not a blanket goal-kick rule:** bundled **SkillCorner** goalkick→`base_rate` (AUC 0.433), GK-pass→`model` (0.737); bundled **GS `default`** goalkick→`model` (AUC **0.836**, LCB 0.798 — GS goal-kick completion IS geometry-predictable), degenerate throw-ins→`base_rate` both. **Coef byte-unchanged** — the re-bundle attaches the gate onto the *loaded committed* model (the fresh fit is only a corpus-identity probe); weight diff additive-only (`model.json` version+gate + `SHA256SUMS`). The corpus-identity guard relaxed `atol=1e-9`→`_CORPUS_IDENTITY_ATOL=0.05` in `train_gk_completion.py` (still aborts a real retrain ~0.47, ~9× the floor; tolerates unrecorded-`tracking_limit` density float noise ≤0.0056). **Training defaults to FULL-match frames** (`train_gk_completion.py` `--tracking-limit` default `200`→`None`, SK-91): a small cap starves the SkillCorner derived-GK → over-flags GKs in 2/10 matches, inflating the frame-derived GK-pass domain 461→538 (full frames reproduce 542 exactly; event-pinned goalkicks stay 81 — the tell; a small cap is dev-smoke-only, never to bundle). Not a VAEP retrain (xt_gk opt-in, in no default xfn list) but an `xt_gk` serve-output change → lakehouse re-materializes SkillCorner goalkicks (~15% of its GK-distribution rows) + degenerate throw-ins; GS goalkicks unchanged. C4-free (serve gate on an existing model — no new aggregator/model/backend; count stays 27).
PR-S100 (4.35.0) ships the **xT-GK PEV/DZV fidelity fix** (ADR-024 amendment, Eyestone Q1–Q3 answered 2026-06-27). **CHANGE 1 — PEV reads the GK-revalued surface:** `progress = V_GK*(z') − V_GK*(z)` where `V_GK = xT ⊙ φ(z,d)` (convolved like `xT*`), NOT raw `xT*` — raw xT flatlines in keeper zones (the measured PEV inertia is the surface, not the tournament); `PEV = ρ·max(0, progress)` kept exactly. **CHANGE 2 — DZV is the published revaluation multiplier** `M(z) = φ(z,d)·[1 − V_GK(z)/max V_GK]` applied as the **increment** `(M−1)·V_GK(z)` (Option A), def-third-gated — replaces the old additive `v_def − xT_raw(z)` floor (literal M≈2.5 would swamp the other terms → the increment revalues the small origin possession). **Magnitude (corrected PR-S103):** on the production grid (deep raw xT≈0.0085 → deep V_GK≈0.02) DZV lands **≈+0.02/action (live WC2022 +0.021)**; the earlier "O(0.01)" described the *understating* unit fixtures, and the 2× vs Jeff's ~0.009 La Liga anchor is grid amplitude (within his sanity band), form faithful — see `docs/research/xtgk_test_production_parity_audit.md`. **φ(z,d)** `= α·(1 − d/D_max)^(−β)` for `d < D_threshold` else 1; `α=2.1`/`β=0.8` **CANONICAL**, `D_max=105`/`D_threshold=35` (= `defensive_third_boundary`) provisional. `XtGkParams` gains `dzv_alpha`/`dzv_beta`/`dzv_d_max`, **retires `v_def`**; scalar `phi` stays the preset-modulated overall DZV weight (canonical shape in the φ grid). **Invariant (Eyestone constraint): φ enters value via PEV + DZV ONLY** — `base = −xT*(z)` and RAV (`xT*(z')`/`xT*_counter`) stay on raw `xT*`, so the destination is never revalued twice and **Option B is unchanged**; behaviorally guarded (`test_phi_shape_changes_only_pev_and_dzv_not_base_or_rav` + `test_pev_reads_revalued_surface_not_raw`). Not a forced VAEP retrain (xt_gk opt-in) but an `xt_gk` serve-output change → lakehouse re-materializes `fct_action_context` + re-runs the WC2022 cohort/report. C4-free (count stays 28). Atomic mirror inherits.
PR-S101 (4.36.0) ships the **xT-GK resolved-coordinate audit columns** (ADR-024 amendment, pre-Jeff-verification handoff): `compute_xt_gk` emits `xt_gk_origin_x`/`xt_gk_origin_y`/`xt_gk_dest_x`/`xt_gk_dest_y` — the EXACT coords the grid lookups used per in-scope GK distribution (NaN off-scope), emitted BEFORE the not-scoreable early return (an unresolvable-dest goalkick still shows its resolved origin + NaN dest). For goal-kicks ~67% are the imputed `resolve_gk_geometry` origin (rule-point `(5.5,34)` / in-area tracking-GK), NOT native `start_x`/`end_x` → every `xt_gk` row externally auditable. Parallel **`_COORD_COLS`** set, deliberately **NOT in `_OUTPUT_COLS`** (so `xt_gk_xfns` doesn't surface them as per-slot VAEP features — they're provenance, not a metric); ride through `add_xt_gk` + atomic mirror automatically. Tie-to-value gate: `base == −xT*(origin_x,origin_y)` on the convolved grid. Liveness gate: the 4 audit coords join the non-constant `provenance` exemption (legitimately constant across goalkicks sharing the rule-point origin; non-null check still applies). `XtGkReport` unchanged (counts report, not per-row coords). **Additive — no `xt_gk_*` value change, no retrain;** unblocks the held lakehouse persist-coords schema migration + external orientation verification. C4-free (count stays 28).
- **VAEP** (`vaep/`): Feature engineering + gradient boosting binary classifiers. `HybridVAEP` removes result leakage from a0 features. xG-targeted labels available via `xg_column` parameter. PR-S32 ships TF-29 design-space variants: `window` parameter on `scores()`/`concedes()` (`"action"` / `"possession"` / `"time"`); `xfns_default_no_goalscore` / `hybrid_xfns_default_no_goalscore` goalscore-bias-control lists. **4.13.0 (ADR-018): own goals are counted by RESULT (`result_id==owngoal`), not via a `type_name` shot-gate** — single-source `_is_goal`/`_is_owngoal` helpers in `vaep/labels.py` (all sites route through them; a guard test forbids the old shot-gated owngoal predicate). Fixes a codebase-wide undercount (every converter emits own goals as `bad_touch`, which the old `str.contains("shot")` gate zeroed out); the atomic label path already counted them. Hyrum: shifts scores/concedes/xG label distributions for all providers (VAEP-model retrain trigger).
- **xT** (`xthreat/`): Expected Threat. SK-xT-1 (4.17.0, ADR-021) refactored the `xthreat.py` module into a package with a house-style **pluggable transition family** (string-dispatch + frozen-dataclass params, NOT ABCs): `ExpectedThreat(method="singh_counts"|"kde_smoothed", params=, l=, w=)`. The `singh_counts` default is **byte-identical** to the classic socceraction grid (frozen-oracle parity gate at `tests/xthreat_legacy_reference.py`); `singh_transition_matrix` is vectorized (`np.add.at`, byte-identical). KDE-smoothed transition (`kde_smoothed_transition_matrix`, Silverman-1986 bandwidth, adaptive per-source-zone); `KDEParams.bandwidth=1.0` (pure Silverman) is a conservative default — the held-out-NLL optimum is **corpus-size-dependent** (~1 on a 64-match sample, ≥4 on an 8.9M-action mart), tune via the evaluator. `GridSpec` first-class variable resolution (pitch dims stay in `spadlconfig`). Standalone `value_iteration` (raw-diff convergence — monotone-from-below, do NOT "fix" to abs; optional `max_iter`). Held-out **transition-model** NLL evaluator (`holdout_split` `game_id`-keyed, `compute_holdout_nll` pure, `compute_holdout_nll_per_group`) — NOT an xT-quality metric. KNN/conditional deferred (pre-publication); lakehouse `XTGrid` declined (keep raw `.xT`). Additive — no Singh-path change, no retrain trigger (TF-24 calibration `FrozenXt` untouched). Lakehouse-proposed; attribution Singh 2018 / Silverman 1986 / Salimi 2026 (pre-pub). Decision: ADR-021. **SK-xT-2 (4.19.0, ADR-022)** wires xT into VAEP as an opt-in, frame-free feature: `vaep.features.xt_xfns(*, model)` + atomic mirror `atomic.vaep.features.xt_xfns` emit `xt__<model.method>` per gamestate slot (ADR-005 §8 naming), caller-supplies-the-fitted-model (fail-closed; `str`/`None`/unfitted raise; `ExpectedThreat` imported `TYPE_CHECKING`-only — duck-typed at runtime, **no new runtime edge**). In **no** default xfn list (additive — opting it in is a self-triggered VAEP retrain; guard test enforces absence). Atomic reuses `model.rate()` **unchanged** (no `_rate_cells` extraction — SK-xT-1 parity gate untouched) via a synthesized standard-SPADL frame + **type-aware** `result_id` (dribble intrinsic since dribbles are never followed by `receival`; pass/cross success iff next atom is `receival`); maps slots by composite `(game_id, period_id, action_id)`; period-last pass/cross → NaN (inherent atomic edge). Cross-representation symmetry verified by a geometry-keyed oracle (NOT `action_id` — `convert_to_atomic` renumbers it) + a dribble keystone gate on the committed WC2018 fixture. Bundled-grid variant reserved via a typed `str` door (ADR-011). C4-free.
- **xT-GK v2 possession value** (`xtgk/`): PR-S107 (4.40.0, ADR-036) — the honest value surface `V(z,p)` that replaces xT-GK v1's flat destination-only raw-xT inside the v2 metric `ρ·[V(s′)−V(s)] − (1−ρ)·[V(s)+κ·V_opp]` (v1 `tracking/_xt_gk.py` is **frozen**, coexists until the lakehouse migrates its `xt_gk*` columns; two "xt-gk" homes documented here deliberately). Hexagonal: a `PossessionValue` Protocol + two adapters. **`MarkovPossessionValue`** (production) = pressure-stratified value iteration reusing `xthreat.value_iteration` **verbatim**, with (i) an **xG-calibrated first-shot immediate reward** `E[xG\|shot]·P(shot)` (`_xg_reward.xg_scoring_prob` — the xG analogue of `_grid._scoring_prob`; NOT the goal-gated `vaep.labels` possession surface, which reproduces the v1 flat deep zone), (ii) a **goal-kick-inclusive move-set** (`_moves.MOVE_TYPE_IDS` = pass∪dribble∪cross∪goalkick∪throw_in — the metric scores keeper distributions, which classic xT's move-set excludes), (iii) pressure terciles (`PressureLevels`). `V(z,p) = E[xG of the possession's FIRST shot | ball at z under pressure p]` (the shoot branch is terminal; deep-zone value is pure forward propagation). **xtgk-local builders** (`_moves.xtgk_transition_matrix`/`xtgk_action_prob`) reuse `xthreat`'s low-level seams (`_kde_transition_from_grouped`, `_zone_centres`, `_get_flat_indexes`, `_count`) and modify **NO** `xthreat` source — Singh consumes all-results moves, KDE the success-filtered set (the two need different populations, so a single injected `move_actions=` on the public builders would be wrong); classic xT stays **byte-identical** (parity-gated over random cohorts in `tests/test_xtgk_builder_parity.py` + the frozen oracle). **`EmpiricalPossessionValue`** (model-free cross-check, NOT shipped) = per-action first-shot empirical surface (O(n) reverse scan; independent of the Markov estimator so §8.5 disagreement is diagnostic — graded on BUILD-UP cells, never the ≈0 deep cells). **Pre-registered occupied-cell deep-zone gate** (`run_deep_zone_gate`/`GateConfig`/`DeepZoneGateReport`, `_diagnostics.py`) — the make-or-break go/no-go; operates on deep cells with ≥`n_min` support in ALL THREE terciles, effect floor + `expected_direction` + cross-check tolerance owner/Eyestone-locked before fitting (selection≠certification). **`delta_v`** = two-factor Shapley split (`ΔV_pressure + ΔV_position = ΔV`). **Injected `xg_column`** (silly-kicks ships NO xG model; sourced from the lakehouse `fct_shot_xg` mart on `(match_key, action_id)`); `V` fittable only where a calibrated per-shot xG exists. Pickle-free `save`/`load` (npz+JSON+SHA256, `_serialize.py`). Attack-orientation + column guard via `validate_possession_value_input`. In **NO** default xfn list (opt-in). Owner-run real-data gate `scripts/validate_xtgk_possession_value.py` (blocked on the locked gate numbers; WC2022/gradientsports cohort — SkillCorner is xG-`ood_flag`-uncertified in `fct_shot_xg`). C4-free (no action-coupled aggregator; count stays 28). Attribution: Singh 2018 (xT lineage), Eyestone (xT-GK v2).
- **Atomic-SPADL** (`atomic/`): Variant where actions are decomposed into atomic sub-actions (receival, interception, out, etc.).
- **Providers** (`providers/`): Per-provider raw-data **parse ports** (bytes → provider-canonical bronze → converter input). **SK-S95 (4.30.0, ADR-031 T3):** `providers/sportec/parse.py` single-sources the IDSSE/Sportec **DFL parser** as a **verbatim lift** of the luxury-lakehouse parser (`ingestion/idsse.py` + `finalize_bronze_df` + `idsse_native_match_id` + the `convert.py`/`spadl_adapter.py` shapers, pinned @ `0efac60`): `parse_dfl_match_info`/`parse_dfl_tracking`/`parse_dfl_events` (DFL XML → RAW bronze) + `shape_tracking_to_native`/`shape_events_to_native` (bronze → `tracking.sportec`/`spadl.sportec` `EXPECTED_INPUT_COLUMNS`) + `derive_idsse_home_team_start_left{,_extratime}`; typed returns `MatchInfo`/`SportecTrackingBronze`/`SportecEventBronze` (silly-kicks' own domain names — a versioned cross-repo bronze contract, ADR-031 N1). Behind the `[parse-dfl]` extra (pure stdlib `xml.etree` + pandas/numpy — no new runtime dep). **Emits RAW bronze — data-quality (Savitzky-Golay smoothing, velocities) stays consumer-side** (the harness applies `_preprocess` after shaping; a delete-and-depend lakehouse keeps its own `_smooth_tracking` after the parse). The dev harness `scripts/_loader_pining.py::_build_idsse` is re-routed onto the port → native converters, retiring the y-inverting loader-local kloppy `_kloppy_tracking_to_frames`; the action `team_id` is remapped `"home"/"away"`→DFL-CLU so the ADR-028 action↔frame join aligns with the CLU-keyed frames. Golden parity (`tests/providers/sportec/test_parse_port_parity.py`) pins the lift to real lakehouse output on a reduced WC2022 IDSSE slice (`tests/datasets/sportec/idsse_slice/`, `SOURCE_SHA`). **VAEP/tracking-retrain trigger** for IDSSE calibration/pining consumers (N6 — action-anchored feature values change; the new path aligns action↔frame y to ~0.2 m vs ~11.8 m on the old kloppy path). C4-free (no new action-coupled aggregator; count stays 28).
- **Calibration** (`calibration/`): TF-24 Optuna calibration harness (optional `[calibration]` extra; lazy-import-guarded, NOT imported by `silly_kicks/__init__`). Pure provider-agnostic objectives/CV/gates: `CarrierAccuracyObjective` (Stage 1 carrier accuracy, maximize) + `AugmentedVaepBrierObjective` (Stage 2 augmented-VAEP held-out Brier, minimize) — a `ruthless` `CachedObjective` with invariant-prepare + per-trial-patch and a deterministic-XGBoost cache-equivalence guarantee (`assert_cache_equivalence`, 1e-9); match-stratified CV (`GroupKFold(5)` for >7 matches / leave-one-match-out ≤7); `FrozenXt` frozen **exogenous** xT artifact (fit once on a corpus disjoint from the calibration matches, sha256 + fail-closed exclusion, train–serve-consistent); H1 stateless default-Brier-anchored penalty + per-provider signal-sanity + DAS-degradation surfacing + TF-25 provider-specific-defaults gate. I/O lives in `scripts/calibrate_tracking_defaults.py` (CLI) + `scripts/_loader_pining.py` (SkillCorner/IDSSE public, Gradient Sports owner-tier) + `scripts/_loader_databricks.py` (bronze). The harness **recommends** values + an auditable manifest; it does NOT change the library default constants (separate "apply" PR). Decision: ADR-009. **SK-xT-3 (4.20.0)** adds `XtBandwidthObjective` + `xt_bandwidth_config` + `scripts/calibrate_xt_bandwidth.py` — a per-corpus `bandwidth × GridSpec-resolution × adaptive` sweep minimizing K-fold held-out xT **transition-NLL** (Singh baseline reported alongside; recommendation scoped to destination-likelihood with a reported Spearman-vs-goals cross-check, ADR-009 amendment + ADR-021). Plain duck-typed objective (resolution-keyed lazy cache, NOT a `CachedObjective`); caches the small per-`(grid,fold)` grouped destinations and shares the vectorized gaussian `_gaussian_transition_from_grouped` seam with `xthreat` (gaussian KDE re-pinned to the vectorized core — Chesterton-verified single caller; sklearn retained for non-gaussian kernels). Does not change library defaults.

## Key conventions

- No pandera — schemas are plain Python dicts (`SPADL_COLUMNS`, `ATOMIC_SPADL_COLUMNS`).
- Config DataFrames (`actiontypes_df()`, etc.) are cached with `@functools.cache`.
- Vectorized dispatch: converters use `np.select` over pre-flattened columns, not `apply(axis=1)`.
- All `warnings.warn()` calls include `stacklevel=2`.
- ML naming conventions (uppercase `X`, `Y`, `Pscores`) are allowed in `vaep/` and `xthreat/*.py` per ruff per-file-ignores.
- **Converter identifier conventions are sacred.** SPADL DataFrame converters never override the caller's `team_id` / `player_id` columns from provider-specific qualifiers. Qualifier-derived facts surface as dedicated output columns (see `tackle_winner_*` / `tackle_loser_*` on sportec). Decision: ADR-001.
- **Public enrichment helpers (post-conversion `add_*` family) tolerate NaN in caller-supplied identifier columns.** NaN identifiers route to the documented per-row default (typically NaN-output / False / 0); helpers never crash on NaN input. Decoration with `@nan_safe_enrichment` from `silly_kicks._nan_safety` is the formal opt-in; the CI gate at `tests/test_enrichment_nan_safety.py` auto-discovers decorated helpers. Decision: ADR-003.
- **Public `add_*` enrichers are PURE — input-mutation is CI-gated (ADR-033, 4.32.0).** Every registered `add_*` (the full public surface across `spadl`/`atomic.spadl`/`tracking`/`atomic.tracking` — `__all__` UNION `.features.__all__`, since the 15 `atomic.tracking.features` mirrors export via the submodule, not the package) must NOT mutate any caller-supplied DataFrame/Series/ndarray and MUST return a NEW object. Gate: `tests/test_add_star_purity.py` — one canonical `PURITY_ENTRIES` registry (key `"<package>:<add_name>"`), build-fresh-owned-inputs-once + snapshot-every-array-arg + value-equality + `out is not input`; two meta-assertions pin the surface to the public export (a new `add_*` must register or CI fails). A best-effort AST heuristic NUDGES toward per-branch coverage. **Contributor contract:** any `add_*` that *conditionally* adds columns (a present/absent branch) MUST register ≥2 purity variants (both branches) — the heuristic is a nudge for the one known shape, NOT a guarantee, so the contract is the real backstop. Born from the `add_gk_distribution_metrics` gk_role-present in-place-mutation defect (the gk_role-absent path always copied, so the old column-list guard never caught it). Same PR renamed `pitch_control_at_action`→`pitch_control_at_target` (function aligns with its emitted `pitch_control_at_target__<method>` column base, unchanged since 4.31.0) — note the lakehouse keeps its own DEFCON `pitch_control_at_action` mart column (different semantics; not silly-kicks').
- **Academic attribution discipline.** Every new feature implementing a published methodology gets an entry in the `NOTICE` file's "Mathematical / Methodological References" section. Cross-link from per-feature docstrings via `See NOTICE for full bibliographic citations.` Mirrors the lakehouse pattern. Decision: ADR-005.
- **Aggregator column liveness is CI-gated (non-NaN AND non-constant).** Every registered `tracking` `add_*` (the full `__all__` surface, jersey helper included) must produce non-null values for every column it ADDS, and every float METRIC column with ≥2 observed values must carry >1 distinct value, on the multi-domain fixture in `tests/tracking/test_aggregator_column_liveness.py` (5 action windows: pass / shot / GK goalkick / attacking-third / wide-area; actor carries the ball through a final-step kick; per-window velocity/layout/GK/kick variation; event clocks jittered off the frame grid). A meta-assertion pins the gate surface to `tracking.__all__`, so a NEW `add_*` must wire a liveness entry or CI fails. Constants BY DESIGN go in the `STRUCTURAL_CONSTANTS` registry with a justification + dedicated invariant test — never silent exclusions. The registry is currently EMPTY: the space-creation structural zeros were RETIRED from the contract (4.24.0), and the dead `pitch_control_at_ball__spearman` near-ball-PPCF degeneracy was RETIRED + replaced by the LIVE `pitch_control_at_target__spearman` (sampled at the action destination + ADR-028-reprojected; 4.31.0, ADR-032). Born from the TF-41 hard-coded-NaN `_opponent` defect (ADR-026; non-constant added 4.24.0 after `space_destroyed_m2 ≡ 0` showed non-NaN alone is insufficient).
- **Linkage-provenance columns are idempotent.** All tracking `add_*` aggregators that merge provenance columns (`frame_id`, `time_offset_seconds`, `link_quality_score`, `n_candidate_frames`) must skip the merge when those columns already exist on the input DataFrame. This allows callers to chain multiple `add_*` calls without producing `_x`/`_y` suffixed duplicates. Pattern: `if not any(c in out.columns for c in provenance_cols):`. CI gate: `tests/tracking/test_provenance_skip_guard.py`.
- **Pre-linking optimization via `links` kwarg.** All tracking `add_*` aggregators accept an optional `links: pd.DataFrame | None = None` kwarg. When provided, the function skips its internal `link_actions_to_frames` call and uses the caller-supplied pointers. Pipeline callers (e.g. lakehouse) pre-link once and pass `links` to all enrichment steps, reducing N × 2-5s to 1 × 2-5s per match. The `links` DataFrame must be the pointers output of `link_actions_to_frames` (columns: `action_id`, `frame_id`, `time_offset_seconds`, `n_candidate_frames`, `link_quality_score`). Internal helpers (`_resolve_action_frame_context`, kernel functions) also accept `links` for full thread-through. `infer_ball_carrier` additionally accepts `pre` (a precomputed `_pre_index_frames(frames)`) and `ball_carrier_at_action` accepts both `pre` and `links`; the pre-index dominates carrier cost (~99%) yet is param-invariant, so the TF-24 Stage-1 calibration objective caches `pre`+`links` per match (invariant-prepare) and re-runs only the carrier kernel per Optuna trial (~100× faster sweep; bit-identical).
- **Shared pitch-control surface via `pitch_control_cache` kwarg.** All pitch-control-consuming aggregators (`add_obso`, `add_cover_shadows`, `add_gk_influence`, `add_player_influence`, `add_space_creation`, `add_pitch_control`) accept an optional `pitch_control_cache: PitchControlCache | None = None` kwarg (mirrors `links`). `PitchControlCache` (in `tracking/pitch_control/`) memoizes **canonical** per-frame surfaces keyed on `(game_id, period_id, frame_id, team, method, params, ball_position, decompose)`, so families that need pitch control on overlapping frames compute each surface once instead of once per family. Each aggregator uses a fresh local cache by default (within-pass reuse); pipeline callers pre-build one cache and pass it to all steps for cross-family reuse (like `links`). Only canonical-frame surfaces are cached — **counterfactual** (player-removed) surfaces in `cover_shadows`/`space_creation` are never routed through the cache. Zero global state. Decision: ADR-008 (TF-7).
- **Dtype-safe id comparisons at tracking-feature seams (ADR-019, 4.15.0).** Tracking consumers NEVER compare SPADL-action ids vs frame ids (or `home_team_id`) with raw `==`/`!=`, and NEVER merge action↔frame on id keys without alignment — those silently mis-resolve across dtypes (`Int64(366) == "366"` → False) or raise on a mixed merge key. Route through `silly_kicks.tracking._id_compat`: `ids_equal`/`ids_differ` (column-vs-column, NA-safe both-present), `ids_match` (Series/array vs scalar), `same_id` (scalar↔scalar groupby loops), `align_join_keys` (pre-merge, numeric-vs-object only). One `_canonical` truth (integral-float collapse; genuine strings pass through), same-kind/both-object fast path (zero overhead matched pipelines). Opt-in loud guard: public `validate_id_dtypes` + `IdDtypeDiagnosis` (mirrors ADR-017's `validate_time_base`). CI gates: red-first **asymmetric** dtype-invariance gate over every registered `add_*` (numeric actions × string frames + reverse, `home_team_id` independent axis) + meta-assertion (gate surface == registered surface) + boundary-focused AST lint (`tests/tracking/test_id_compat_lint.py`; 4.21.1 amendment extends it to the converter-adapter `convert_to_frames` orientation seam — `gradientsports`/`sportec` use `ids_match`, `kloppy` routed through `same_id`, so **only `_id_compat.py` is exempt**; `ALLOW_MODULES == {_id_compat.py}` pinned) + structural de-dup perf guard. Fixed three latent bugs (object-`is_ball` `~`-no-op ball-as-opponent; `str(5.0)` iterrows-upcast player-influence/cover-shadow mislabel; object-path opponent join-miss) → **VAEP retrain trigger**. ADR-001 preserved (fix lives at consumer seams, not converters). **4.25.0 (ADR-027): the AST lint is a NAME-heuristic** — it missed a raw `t != action_team` in `_line_breaking.py` ward (a NaN-team crash + an Int64-vs-string silent opponent miscompute on GS) because the operands are not named `*_id`; the behavioral NaN-safety gate (`tests/test_enrichment_nan_safety.py`, NaN-team fixture) caught it. Fixed via `same_id` + a `pd.isna` NaN-route. The behavioral gate, not the lint, is the backstop.
- **Per-action geometry is emitted in the SPADL action-LTR frame (ADR-028, 4.26.0).** Actions are per-acting-team LTR (acting team attacks x=105); `convert_to_frames` is home-attacks-right (home attacks x=105 every period). They are a 180° point reflection apart for **away-team actions**, and the geometry layer historically sampled frame positions WITHOUT re-projecting → ~50% of tracking action rows wrong: visibly-bimodal absolute positions (`pre_shot_gk_x`, `defensive_line_x`, `team_shape_centroid_*`) AND silently-wrong mixed-frame scalars (`nearest_defender_distance`, `receiver_zone_density`, `defenders_in_triangle_to_goal`, all `pressure_on_actor__*`, `pre_shot_gk_distance_to_shot`/`_angle_*` — action anchor combined with frame positions). One frame serves BOTH teams' actions, so the caller cannot pre-orient; re-projection is per-action + library-owned. Fixed by ONE helper `tracking/_action_orientation.py` (`acting_team_attacks_rtl` keyed on the frame's `team_attacking_direction`, `align_join_keys` for the id join; `reproject_to_action_ltr`) at THREE seams: the shared `_resolve_action_frame_context` (fixes all 8 context kernels at once + makes their hardcoded goal-at-(105,34) correct), `_kernels._defensive_line_at_actions`, and `add_team_shape`/`_team_shape_at_actions`. `add_ghost_gk` emits action-LTR (`x→105−gr_x` uniform; `y→68−gr_y` away only) — model stays goal-relative; `compute_team_shape` made **orientation-aware** (deepest line nearest the defended goal; ltr/home byte-identical, only rtl/away changes). Self-reconciling features (`structural_pass`/`gk_influence`/`player_influence`/`cover_shadows`/`shape_graph`/`obso`/`space_creation`/`das`/`pitch_control`/`pausa`/`xt_gk`) DON'T use the context and are untouched (no double-flip). Durable guard: `tests/tracking/test_action_ltr_mirror_invariance.py` (same physical situation → identical action-LTR output under a frame mirror). **VAEP/tracking-retrain trigger** (away-team values change; home-team byte-identical). C4-free (no new aggregator/backend/model). Atomic mirror inherits.
- **Frame-LTR orientation is single-sourced for consumer-built frames (ADR-029, 4.27.0).** ADR-028's per-action re-projection only works on frames already in the canonical **home-attacks-right** convention; it no-ops on absolute frames (`team_attacking_direction` all-null) — filtering to `.notna()` rows yields an empty direction lookup → no flip → ~50% mirror-wrong geometry. Providers converted via `convert_to_frames(output_convention="ltr")` (sportec/GS) or the kloppy gateway (metrica/skillcorner) are already oriented, but a consumer holding **bronze DataFrames** (not a kloppy `TrackingDataset`) builds frames itself and must orient them. Public `tracking.orient_frames_to_ltr(frames, *, home_team_id, home_team_start_left, home_team_start_left_extratime=None)` is the **unlabeled-input** sibling of `play_left_to_right` (which serves already-labeled frames): pure composition of `compute_attacking_direction` (private) + `play_left_to_right` with fail-loud guards (missing-schema; already-labeled → raise + route to `play_left_to_right`; zero home-match (ADR-019); ET-without-flag). Two entry points by input state — labeled → `play_left_to_right`, unlabeled → `orient_frames_to_ltr`. **Consumer contract:** any consumer building frames from a non-kloppy source MUST orient before the geometry layer; the helper is only as correct as the caller-derived `home_team_start_left` (validate per game — e.g. defending GK lands near the attacked goal post-orient). Additive — no provider behaviour change, no silly-kicks retrain; the lakehouse re-materializes metrica/skillcorner after adopting it. The sportec/GS adapters are NOT refactored through the helper (primitives already shared; Chesterton's Fence). Positive ET orientation is regression-guarded on both the helper and the native adapters (`tests/tracking/test_adapter_extra_time_orientation.py`), prompted by a live GS-ET flip that was a consumer-side `home_team_start_left_extratime` placeholder bug, not a silly-kicks bug.
- **The kloppy TRACKING gateway pins the canonical SPADL coordinate system (ADR-031, 4.29.0).** `tracking/kloppy.py` previously emitted frames **y-mirrored** vs SPADL actions (`action_y == 68 − frame_y`) for SkillCorner / Metrica / IDSSE-dev: the EVENT gateway (`spadl/kloppy.py`) pinned `_SoccerActionCoordinateSystem` (now extracted to the shared `spadl/_kloppy_coordinates.py` + `socceraction_coordinate_system` helper, imported by both gateways), the tracking gateway never did, so frames kept each provider's kloppy-native vertical. Single-axis y mirror (NOT orientation — orthogonal to ADR-028/029; error `|68−2y|` → 0 at centre, ~full width at touchlines, which hid it). Fix: `convert_to_frames` pins the CS via a **CS-only `transform()` signature** — dropping `to_pitch_dimensions`, which otherwise silently OVERRIDES the CS vertical and leaves y inverted (Gate 0); the CS carries its own standardized 0–105/0–68 dims, so x is byte-identical. **NOT** a blanket `y=68−y` flip (which would double-invert an already-canonical provider; no-op-on-canonical guarded). DGX-verified: SkillCorner + Metrica both inverted → fixed (**calibration retrain trigger** — but the lakehouse builds SC/Metrica via its OWN bronze builders, so PR-S94 fixes the calibration/pining path + external gateway consumers, NOT lakehouse prod); the **native** sportec/IDSSE path is y-CORRECT (Gate D — no fix, no retrain). First of a sequence (PR-S94 / T1); the IDSSE/Sportec DFL **parse-port** single-sourcing (T3, PR-S95) + the lakehouse's own SC/Metrica builder y-check follow. C4-free (no new aggregator; count stays 28).
- **TF-23b geometric frame-LTR backstop on the native tracking adapters (ADR-035, 4.34.0).** `tracking.{gradientsports,sportec}.convert_to_frames` now self-correct a wrong/absent `home_team_start_left_extratime` from GK geometry: a shared `direction.finalize_orientation` tail (ET guard → per-period flag flip → period-gated label → the idempotent `orient_frames_to_ltr_by_geometry` backstop) replaces the duplicated inline orientation block in both adapters (**supersedes the ADR-029 no-refactor fence for the native adapters ONLY** — kloppy gateway stays intentionally un-routed). The net gains `on_missing_home` (default `"raise"`; adapters pass `"warn"` to keep their warn-don't-raise contract) + `copy` (default `True`; `finalize` passes `False` to drop the redundant 3rd full-frame copy on tracking-scale data) and **no longer orients period-5/PSO frames for ANY caller** (incl. the TF-23 SC/Metrica builders; PSO excluded from geometric analysis). **Byte-identical no-op on the correct-flag path** (DGX G1, strict-dtype: 69/71 corpus matches byte-identical; the only 2 changed are GS WC2022 ET-tracking matches **10506 + 10517** whose constant placeholder flag was geometrically wrong → **VAEP/tracking-retrain trigger for those 2 ET-frame sets only**). Closes ADR-031 **Gate D** (sportec/IDSSE-ET handedness; gated on synthetic sportec ET + the provider-agnostic mechanism — 0 real IDSSE-ET in the pining corpus, G3 re-scoped). C4-free (count stays 28).

## Testing

```bash
python -m pytest tests/ -m "not e2e" -v --tb=short
```

e2e tests require dataset fixtures not committed to the repo. Tests with
fixtures committed to the repo should not be marked e2e — they run in
the regular suite.

CI runs a bulk suite step serial (`--benchmark-skip`) on every matrix leg and a benchmark
*measurements* step single-threaded (`--benchmark-only`) on the primary leg only (see the
slow-test-gating note below for the per-leg split). There are no wall-clock
`assert ms < budget` perf tests — performance regressions are guarded by
**deterministic structural guards** (call-count spies on each function's dominant
primitive; see `tests/_perf_structural.py` + the `*_perf_budget.py` files), which
never flake on shared runners. (An xdist parallelization was tried and reverted: it
regressed py3.12 on the 4-core/7GB CI runners — memory/JIT pressure.)

**`@pytest.mark.slow` = expensive AND platform/interpreter-INVARIANT** (does-it-run train-script
smokes, same-run internal-consistency/parity, calibration cache-equivalence). These run **once on the
CI primary leg (`ubuntu-3.12`, matrix `primary: true`)**; every other leg runs `-m "not e2e and not
slow"`. Do **NOT** mark version-sensitive tests `slow` (golden-hash / snapshot / absolute-numeric — e.g.
`test_golden_*`) — they must stay on all legs (OS + interpreter axes), as must cheap behavioral-contract
guards (dup-`action_id`, id-dtype-invariance, orientation/roster). The matrix partition is guarded by
`tests/test_ci_slow_gating_wired.py`. Decision: ADR-023.

## Open Items

See [TODO.md](TODO.md) for tracked work.

## Dependencies

- Runtime: pandas, numpy, scikit-learn (no pandera, no multimethod)
- Optional: kloppy (>= 3.18 for tracking parsers), xgboost, catboost, lightgbm, accessible-space (for DAS features), ruthless-efficiency[optuna]>=0.2.1 + xgboost>=2.0,<3.0 (for the `[calibration]` harness AND the `[train]` extra — shared trained-model HPO substrate; the ruthless floor fixes a `warm_start` n_trials off-by-one, the xgboost bound protects the 1e-9 cache-equivalence gate's `tree_method="hist"` determinism). xS inference gates on the `[xgboost]` extra. numba (>=0.59) accelerates the pitch-control `@njit` kernels (ADR-008) and the ghost-GK `cpu-numba` KDE backend (ADR-013) via the `[numba]` extra (also bundled in `[test]` so CI exercises the `@njit` parity). `statsbombpy` is an OPTIONAL, `importorskip`-guarded dep used only by the network-gated xT open-data e2e (`tests/test_xthreat_statsbomb_e2e.py`, `@pytest.mark.e2e`); not in any committed extra — the test self-skips without it.
- numpy>=2.0 compatible
