# TF-18 Ghost-GK Positioning Model Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Implement a per-frame ghost-GK positioning model using RFCDE density estimation that predicts where a league-average goalkeeper would stand given the current game state.

**Architecture:** Single `_ghost_gk.py` module following the ADR-005 per-frame primitive → action-coupled aggregator → VAEP xfns factory pattern. Model serialized as npz (tree node arrays + training coordinates) + metadata.json + SHA256SUMS — no pickle, no ONNX at inference. Lazy-downloaded from HuggingFace Hub on first use via `[ghost-gk]` extra.

**Tech Stack:** scikit-learn (HistGradientBoostingRegressor, training only), scipy (KDE + ConvexHull), huggingface_hub (artifact download), numpy/pandas. Optional: numba (vectorized tree traversal).

**Spec:** `docs/superpowers/specs/2026-05-25-tf18-ghost-gk-design.md`

---

## File Structure

| File | Responsibility |
|------|----------------|
| `silly_kicks/tracking/_ghost_gk.py` | Core module: `GhostGkDensity` dataclass, `GhostGkModel` class (fit/predict/predict_density/save/load/from_hub), `extract_ghost_gk_features` (batch), `compute_ghost_gk` primitive |
| `silly_kicks/tracking/features.py` | `add_ghost_gk` aggregator + `ghost_gk_xfns` factory (appended to existing file) |
| `silly_kicks/atomic/tracking/features.py` | Atomic mirror: re-export `add_ghost_gk` + `ghost_gk_xfns` |
| `pyproject.toml` | New `[ghost-gk]` extra: `huggingface_hub` only (inference). New `[ghost-gk-train]` extra: `skl2onnx` (validation artifact). |
| `scripts/train_ghost_gk.py` | Training script (CLI, not imported at runtime) |
| `tests/tracking/test_ghost_gk.py` | Unit tests (15 tests) + shared `_fitted_model` fixture |
| `tests/tracking/test_ghost_gk_integration.py` | Integration tests (4 tests) |
| `NOTICE` | Academic attribution entries |
| `CHANGELOG.md` | Release entry |

### Key Architectural Decision: No ONNX at Inference

RFCDE prediction uses leaf co-occurrence weights, not regression output. At no point does inference call the tree's `predict()`. The ONNX file (tree regression predictions) is never read during inference. Therefore:

- **Runtime artifact:** `npz` (tree node arrays + training GK coordinates) + `metadata.json` + `SHA256SUMS`
- **Training-only:** `skl2onnx` exports an ONNX file for validation/audit (confirms tree structure matches), but this file is NOT loaded at inference.
- **`[ghost-gk]` extra:** `huggingface_hub` only. No `onnxruntime`.
- **Leaf indices at inference:** numpy vectorized traversal of serialized tree node arrays (no sklearn dependency at inference for the tree part).

---

## Task 1: Add `[ghost-gk]` Extra to pyproject.toml

**Files:**
- Modify: `pyproject.toml:33-66`

- [ ] **Step 1: Add the ghost-gk and ghost-gk-train extras**

In `pyproject.toml` under `[project.optional-dependencies]`, add:

```toml
ghost-gk = ["huggingface_hub>=0.20.0"]
ghost-gk-train = ["skl2onnx>=1.16.0"]
```

Add `huggingface_hub` to the `test` extra:

```toml
test = [
    # ... existing entries ...
    # huggingface_hub exercises Ghost-GK lazy download path.
    "huggingface_hub>=0.20.0",
]
```

- [ ] **Step 2: Verify install**

Run: `uv sync --extra test --extra ghost-gk`
Expected: installs huggingface_hub without error.

- [ ] **Step 3: Commit**

```bash
git add pyproject.toml
git commit -m "chore: add [ghost-gk] extra (huggingface_hub) + [ghost-gk-train] (skl2onnx)"
```

---

## Task 2: GhostGkDensity Dataclass + Grid Constants

**Files:**
- Create: `silly_kicks/tracking/_ghost_gk.py`
- Test: `tests/tracking/test_ghost_gk.py`

- [ ] **Step 1: Write the failing test for grid bounds + frozen arrays**

```python
# tests/tracking/test_ghost_gk.py
"""Unit tests for Ghost-GK positioning model (TF-18)."""

from __future__ import annotations

from pathlib import Path

import numpy as np
import pandas as pd
import pytest


# ---------------------------------------------------------------------------
# Shared fixture (used across all test classes — avoids duplication M1)
# READ-ONLY: tests must NOT mutate model, X, or labels. If mutation is needed,
# copy first (e.g., X_copy = X.copy()).
# ---------------------------------------------------------------------------

_FITTED_MODEL_CACHE: dict[str, tuple] = {}


def _fitted_model(*, n_estimators: int = 10, n_samples: int = 100):
    """Module-level model fixture. Cached to avoid re-training per test.

    Returns a shared (model, X, labels) tuple. Treat as READ-ONLY —
    the same objects are returned to all callers within the module.
    """
    from silly_kicks.tracking._ghost_gk import GhostGkModel, GHOST_GK_FEATURE_NAMES

    cache_key = f"{n_estimators}_{n_samples}"
    if cache_key in _FITTED_MODEL_CACHE:
        return _FITTED_MODEL_CACHE[cache_key]

    rng = np.random.default_rng(42)
    X = pd.DataFrame(rng.standard_normal((n_samples, 26)), columns=GHOST_GK_FEATURE_NAMES)
    X["phase"] = rng.integers(0, 3, n_samples).astype(float)
    X["team_in_possession"] = rng.integers(0, 2, n_samples).astype(float)
    X["ball_in_own_half"] = rng.integers(0, 2, n_samples).astype(float)
    labels = pd.DataFrame({
        "gk_x": rng.uniform(2, 20, n_samples),
        "gk_y": rng.uniform(25, 45, n_samples),
    })
    model = GhostGkModel(n_estimators=n_estimators)
    model.fit(X, labels)
    _FITTED_MODEL_CACHE[cache_key] = (model, X, labels)
    return model, X, labels


# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------


def _make_ghost_gk_frames(
    *,
    home_team_id: int = 1,
    away_team_id: int = 2,
    period_id: int = 1,
    frame_id: int = 1,
    game_id: str = "100",
    timestamp: float = 1.0,
) -> pd.DataFrame:
    """Synthetic frames with ball, GK, defenders, attackers."""
    rows = []
    # Ball
    rows.append({
        "game_id": game_id, "period_id": period_id, "frame_id": frame_id,
        "timestamp": timestamp, "player_id": "ball", "team_id": None,
        "x": 50.0, "y": 34.0, "vx": 2.0, "vy": 0.0, "speed": 2.0,
        "is_ball": True, "is_goalkeeper": False, "ball_state": "alive",
    })
    # Home GK (defending goal at x=0)
    rows.append({
        "game_id": game_id, "period_id": period_id, "frame_id": frame_id,
        "timestamp": timestamp, "player_id": "p1", "team_id": home_team_id,
        "x": 5.0, "y": 34.0, "vx": 0.0, "vy": 0.0, "speed": 0.0,
        "is_ball": False, "is_goalkeeper": True, "ball_state": "alive",
    })
    # Home defenders (4)
    for i, (px, py) in enumerate([(20, 25), (22, 30), (21, 38), (23, 45)]):
        rows.append({
            "game_id": game_id, "period_id": period_id, "frame_id": frame_id,
            "timestamp": timestamp, "player_id": f"p{10+i}", "team_id": home_team_id,
            "x": float(px), "y": float(py), "vx": 0.5, "vy": 0.0, "speed": 0.5,
            "is_ball": False, "is_goalkeeper": False, "ball_state": "alive",
        })
    # Away attackers (4)
    for i, (px, py) in enumerate([(40, 30), (45, 34), (38, 40), (50, 34)]):
        rows.append({
            "game_id": game_id, "period_id": period_id, "frame_id": frame_id,
            "timestamp": timestamp, "player_id": f"a{10+i}", "team_id": away_team_id,
            "x": float(px), "y": float(py), "vx": -1.0, "vy": 0.0, "speed": 1.0,
            "is_ball": False, "is_goalkeeper": False, "ball_state": "alive",
        })
    # Away GK
    rows.append({
        "game_id": game_id, "period_id": period_id, "frame_id": frame_id,
        "timestamp": timestamp, "player_id": "a1", "team_id": away_team_id,
        "x": 100.0, "y": 34.0, "vx": 0.0, "vy": 0.0, "speed": 0.0,
        "is_ball": False, "is_goalkeeper": True, "ball_state": "alive",
    })
    return pd.DataFrame(rows)


# ---------------------------------------------------------------------------
# Task 2: Dataclass + Grid
# ---------------------------------------------------------------------------


class TestGhostGkDensity:
    """GhostGkDensity frozen dataclass and grid specification."""

    def test_density_grid_bounds(self):
        """grid_x covers [0,30], grid_y covers [18,50], probabilities shape (60,64)."""
        from silly_kicks.tracking._ghost_gk import GhostGkDensity, _GRID_X, _GRID_Y

        probs = np.ones((60, 64)) / (60 * 64)
        density = GhostGkDensity(
            mode_x=5.0, mode_y=34.0, mean_x=5.5, mean_y=34.2, spread=12.0,
            grid_x=_GRID_X.copy(), grid_y=_GRID_Y.copy(), probabilities=probs,
        )
        assert density.grid_x.shape == (60,)
        assert density.grid_y.shape == (64,)
        assert density.probabilities.shape == (60, 64)
        assert density.grid_x[0] >= 0.0
        assert density.grid_x[-1] <= 30.0
        assert density.grid_y[0] >= 18.0
        assert density.grid_y[-1] <= 50.0
        np.testing.assert_allclose(density.probabilities.sum(), 1.0, atol=1e-6)

    def test_density_is_frozen(self):
        """GhostGkDensity is immutable (attributes + array write protection)."""
        from silly_kicks.tracking._ghost_gk import GhostGkDensity, _GRID_X, _GRID_Y

        probs = np.ones((60, 64)) / (60 * 64)
        density = GhostGkDensity(
            mode_x=5.0, mode_y=34.0, mean_x=5.5, mean_y=34.2, spread=12.0,
            grid_x=_GRID_X.copy(), grid_y=_GRID_Y.copy(), probabilities=probs,
        )
        with pytest.raises(AttributeError):
            density.mode_x = 10.0  # type: ignore[misc]
        # Array mutation prevented
        with pytest.raises(ValueError):
            density.probabilities[0, 0] = 999.0
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestGhostGkDensity -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'silly_kicks.tracking._ghost_gk'`

- [ ] **Step 3: Write minimal implementation**

```python
# silly_kicks/tracking/_ghost_gk.py
"""Ghost-GK positioning model (TF-18, GKDV Layer 2).

Predicts where a league-average goalkeeper would position themselves
given the current game state, using RFCDE density estimation over
HistGradientBoostingRegressor leaf assignments.

Input frames MUST be in LTR-normalized convention (home team attacks
right in all periods — the standard silly-kicks tracking output after
play_left_to_right normalization).

See docs/superpowers/specs/2026-05-25-tf18-ghost-gk-design.md.
See NOTICE for full bibliographic citations.
"""

from __future__ import annotations

import dataclasses
import hashlib
import json
import os
from pathlib import Path
from typing import Self

import numpy as np
import pandas as pd

# ---------------------------------------------------------------------------
# Grid constants (fixed for API stability — see spec §Density Grid)
# ---------------------------------------------------------------------------

GRID_X_MIN = 0.0
GRID_X_MAX = 30.0
GRID_Y_MIN = 18.0
GRID_Y_MAX = 50.0
GRID_NX = 60
GRID_NY = 64
GRID_RESOLUTION = 0.5  # meters per cell

# Cell centers
_GRID_X = np.linspace(
    GRID_X_MIN + GRID_RESOLUTION / 2,
    GRID_X_MAX - GRID_RESOLUTION / 2,
    GRID_NX,
)
_GRID_Y = np.linspace(
    GRID_Y_MIN + GRID_RESOLUTION / 2,
    GRID_Y_MAX - GRID_RESOLUTION / 2,
    GRID_NY,
)


# ---------------------------------------------------------------------------
# Return type
# ---------------------------------------------------------------------------


@dataclasses.dataclass(frozen=True)
class GhostGkDensity:
    """Per-frame ghost-GK density prediction.

    Attributes
    ----------
    mode_x : float
        Joint 2D mode x (argmax of probabilities grid), goal-relative.
    mode_y : float
        Joint 2D mode y (argmax of probabilities grid), goal-relative.
    mean_x : float
        Density-weighted mean x.
    mean_y : float
        Density-weighted mean y.
    spread : float
        Effective area (entropy-based measure of density dispersion).
    grid_x : np.ndarray
        Shape (60,) — x-axis cell centers covering [0, 30].
    grid_y : np.ndarray
        Shape (64,) — y-axis cell centers covering [18, 50].
    probabilities : np.ndarray
        Shape (60, 64) — probability mass per cell, sums to ~1.0.
        Read-only (writeable=False).

    Examples
    --------
    >>> density = model.predict_density(features)[0]
    >>> density.mode_x, density.mode_y
    (5.25, 34.25)
    >>> density.probabilities.sum()
    1.0
    """

    mode_x: float
    mode_y: float
    mean_x: float
    mean_y: float
    spread: float
    grid_x: np.ndarray
    grid_y: np.ndarray
    probabilities: np.ndarray

    def __post_init__(self) -> None:
        # Prevent mutation of numpy arrays after construction
        object.__setattr__(self, "grid_x", self.grid_x.copy())
        object.__setattr__(self, "grid_y", self.grid_y.copy())
        object.__setattr__(self, "probabilities", self.probabilities.copy())
        self.grid_x.flags.writeable = False
        self.grid_y.flags.writeable = False
        self.probabilities.flags.writeable = False
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestGhostGkDensity -v`
Expected: 2 PASSED

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk.py
git commit -m "feat(ghost-gk): GhostGkDensity dataclass + grid constants"
```

---

## Task 3: Feature Extraction (Goal-Relative Transform + 26 Features)

**Files:**
- Modify: `silly_kicks/tracking/_ghost_gk.py`
- Test: `tests/tracking/test_ghost_gk.py`

- [ ] **Step 1: Write the failing tests**

Append to `tests/tracking/test_ghost_gk.py`:

```python
class TestFeatureExtraction:
    """extract_ghost_gk_features: 26 columns, goal-relative normalization."""

    def test_extract_ghost_gk_features(self):
        """26 feature columns, correct dtypes, team_in_possession present."""
        from silly_kicks.tracking._ghost_gk import extract_ghost_gk_features, GHOST_GK_FEATURE_NAMES

        frames = _make_ghost_gk_frames(home_team_id=1, away_team_id=2)
        features = extract_ghost_gk_features(
            frames, gk_team_id=1, goal_x=0.0,
        )
        # Batch returns DataFrame; single-frame returns one row
        assert features.shape[1] == 26
        assert list(features.columns) == GHOST_GK_FEATURE_NAMES
        assert "team_in_possession" in features.columns
        assert "ball_x" in features.columns

    def test_goal_relative_transform_symmetry(self):
        """LTR vs RTL frames produce identical goal-relative coords."""
        from silly_kicks.tracking._ghost_gk import extract_ghost_gk_features

        frames_ltr = _make_ghost_gk_frames(home_team_id=1, away_team_id=2)
        # Mirror: flip x and vx
        frames_rtl = frames_ltr.copy()
        frames_rtl["x"] = 105.0 - frames_rtl["x"]
        frames_rtl["vx"] = -frames_rtl["vx"]

        feat_ltr = extract_ghost_gk_features(frames_ltr, gk_team_id=1, goal_x=0.0)
        feat_rtl = extract_ghost_gk_features(frames_rtl, gk_team_id=1, goal_x=105.0)

        np.testing.assert_allclose(
            feat_ltr["ball_x"].iloc[0], feat_rtl["ball_x"].iloc[0], atol=0.01,
        )
        np.testing.assert_allclose(
            feat_ltr["ball_distance_to_goal"].iloc[0],
            feat_rtl["ball_distance_to_goal"].iloc[0], atol=0.01,
        )

    def test_defending_team_compactness_degenerate(self):
        """Collinear defenders → NaN compactness (QhullError caught)."""
        from silly_kicks.tracking._ghost_gk import extract_ghost_gk_features

        frames = _make_ghost_gk_frames()
        # Make all defenders collinear
        mask = (~frames["is_ball"]) & (~frames["is_goalkeeper"]) & (frames["team_id"] == 1)
        frames.loc[mask, "y"] = 34.0

        features = extract_ghost_gk_features(frames, gk_team_id=1, goal_x=0.0)
        assert np.isnan(features["defending_team_compactness"].iloc[0])
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestFeatureExtraction -v`
Expected: FAIL — `ImportError: cannot import name 'extract_ghost_gk_features'`

- [ ] **Step 3: Write the batch feature extraction implementation**

Append to `silly_kicks/tracking/_ghost_gk.py`. The function accepts a full frame DataFrame and extracts features for all unique (game_id, period_id, frame_id) groups at once, returning a DataFrame. This is the BATCH interface (addresses C3):

```python
from scipy.spatial import ConvexHull, QhullError

from silly_kicks.spadl import config as spadlconfig

_FIELD_LENGTH = spadlconfig.field_length  # 105.0
_FIELD_WIDTH = spadlconfig.field_width    # 68.0
_GOAL_Y = _FIELD_WIDTH / 2.0             # 34.0
_PENALTY_AREA_X = 16.5
_PENALTY_AREA_Y_MIN = (_FIELD_WIDTH - 40.3) / 2.0
_PENALTY_AREA_Y_MAX = (_FIELD_WIDTH + 40.3) / 2.0
_VELOCITY_WINDOW_S = 0.5

GHOST_GK_FEATURE_NAMES: list[str] = [
    "ball_x", "ball_y", "ball_vx", "ball_vy", "ball_distance_to_goal",
    "defensive_line_x", "defensive_line_depth", "defensive_line_width",
    "attackers_in_box", "nearest_attacker_to_goal_x",
    "attacker_centroid_x", "attacker_centroid_y",
    "defenders_behind_ball", "deepest_defender_x",
    "phase", "team_in_possession", "score_diff", "time_seconds", "period_id",
    "ball_to_goal_angle", "ball_to_nearest_attacker_dist",
    "defending_team_compactness", "ball_in_own_half",
    "ball_speed", "defensive_line_speed", "defending_centroid_vx",
]


def extract_ghost_gk_features(
    frame_data: pd.DataFrame,
    *,
    gk_team_id: int | str,
    goal_x: float = 0.0,
    score_diff: float = 0.0,
    phase: int = 0,
    ball_carrier_team_id: int | str | None = None,
    prev_defensive_line_x: float | None = None,
    prev_defending_centroid_x: float | None = None,
    dt: float = _VELOCITY_WINDOW_S,
) -> pd.DataFrame:
    """Extract 26 ghost-GK features from frame data in goal-relative coords.

    Accepts a single frame (one game_id/period_id/frame_id group) and
    returns a single-row DataFrame. Called in a vectorized batch by
    compute_ghost_gk.

    Input frames MUST be in LTR-normalized convention (home attacks right
    in all periods). The goal_x parameter specifies which end the GK
    defends: 0.0 for the home GK, 105.0 for the away GK.

    Parameters
    ----------
    frame_data : pd.DataFrame
        All rows for one frame — players + ball.
    gk_team_id : int | str
        Team ID of the GK whose ghost position we predict.
    goal_x : float
        x-coordinate of the defending goal (0.0 or 105.0).
    score_diff : float
        GK's team score minus opponent.
    phase : int
        0 = open_play, 1 = set_piece, 2 = goal_kick.
    ball_carrier_team_id : int | str | None
        Team currently in possession.
    prev_defensive_line_x : float | None
        Previous frame's defensive line x (for canonical 0.5s velocity).
    prev_defending_centroid_x : float | None
        Previous frame's defending centroid x.
    dt : float
        Time delta for velocity computation.

    Returns
    -------
    pd.DataFrame
        Single-row DataFrame with 26 columns (GHOST_GK_FEATURE_NAMES).

    Examples
    --------
    >>> features = extract_ghost_gk_features(frame, gk_team_id=1, goal_x=0.0)
    >>> features.shape
    (1, 26)
    """
    # --- Coordinate transform ---
    flip = goal_x > 50.0

    def to_gr_x(x):
        return (_FIELD_LENGTH - x) if flip else x

    def to_gr_vx(vx):
        return -vx if flip else vx

    # --- Ball ---
    ball = frame_data[frame_data["is_ball"].astype(bool)]
    if len(ball) > 0:
        bx_raw = float(ball["x"].iloc[0])
        by_raw = float(ball["y"].iloc[0])
        bvx_raw = float(ball["vx"].iloc[0]) if "vx" in ball.columns else np.nan
        bvy_raw = float(ball["vy"].iloc[0]) if "vy" in ball.columns else np.nan
    else:
        bx_raw = by_raw = bvx_raw = bvy_raw = np.nan

    ball_x = to_gr_x(bx_raw)
    ball_y = by_raw
    ball_vx = to_gr_vx(bvx_raw)
    ball_vy = bvy_raw
    ball_speed = float(np.sqrt(bvx_raw**2 + bvy_raw**2)) if not np.isnan(bvx_raw) else np.nan
    ball_dist = float(np.sqrt(ball_x**2 + (ball_y - _GOAL_Y)**2)) if not np.isnan(ball_x) else np.nan

    # --- Player splits ---
    players = frame_data[~frame_data["is_ball"].astype(bool)]
    defending = players[(players["team_id"] == gk_team_id) & (~players["is_goalkeeper"].astype(bool))]
    attacking = players[(players["team_id"] != gk_team_id) & (~players["is_goalkeeper"].astype(bool))]
    gk_rows = players[(players["team_id"] == gk_team_id) & (players["is_goalkeeper"].astype(bool))]

    # --- Defensive line ---
    if len(defending) > 0:
        def_xs = defending["x"].apply(to_gr_x).values
        sorted_xs = np.sort(def_xs)
        n_back = min(4, len(sorted_xs))
        defensive_line_x = float(np.median(sorted_xs[:n_back]))
        deepest_defender_x = float(sorted_xs[0])
        defensive_line_width = float(defending["y"].max() - defending["y"].min())
    else:
        defensive_line_x = deepest_defender_x = defensive_line_width = np.nan

    if len(gk_rows) > 0 and not np.isnan(defensive_line_x):
        gk_x_gr = to_gr_x(float(gk_rows["x"].iloc[0]))
        defensive_line_depth = abs(defensive_line_x - gk_x_gr)
    else:
        defensive_line_depth = np.nan

    # --- Attacking players ---
    if len(attacking) > 0:
        atk_xs = attacking["x"].apply(to_gr_x).values
        atk_ys = attacking["y"].values
        nearest_atk_x = float(np.min(atk_xs))
        atk_cx = float(np.mean(atk_xs))
        atk_cy = float(np.mean(atk_ys))
        in_box = (atk_xs < _PENALTY_AREA_X) & (atk_ys >= _PENALTY_AREA_Y_MIN) & (atk_ys <= _PENALTY_AREA_Y_MAX)
        attackers_in_box = int(np.sum(in_box))
    else:
        nearest_atk_x = atk_cx = atk_cy = np.nan
        attackers_in_box = 0

    # --- Defenders behind ball ---
    if len(defending) > 0 and not np.isnan(ball_x):
        def_xs_arr = defending["x"].apply(to_gr_x).values
        defenders_behind_ball = int(np.sum(def_xs_arr < ball_x))
    else:
        defenders_behind_ball = 0

    # --- Spatial geometry ---
    ball_to_goal_angle = float(np.arctan2(ball_y - _GOAL_Y, ball_x)) if not np.isnan(ball_x) else np.nan

    if len(attacking) > 0 and not np.isnan(ball_x):
        atk_xs_rel = attacking["x"].apply(to_gr_x).values
        dists = np.sqrt((atk_xs_rel - ball_x)**2 + (attacking["y"].values - ball_y)**2)
        ball_to_nearest_atk = float(np.min(dists))
    else:
        ball_to_nearest_atk = np.nan

    if len(defending) >= 3:
        coords = np.column_stack([defending["x"].apply(to_gr_x).values, defending["y"].values])
        try:
            hull = ConvexHull(coords)
            compactness = float(hull.volume)
        except QhullError:
            compactness = np.nan
    else:
        compactness = np.nan

    ball_in_own_half = 1.0 if (not np.isnan(ball_x) and ball_x < _FIELD_LENGTH / 2) else 0.0
    team_in_poss = 1.0 if ball_carrier_team_id == gk_team_id else 0.0
    period_clamped = min(int(frame_data["period_id"].iloc[0]), 2)
    time_s = float(frame_data["timestamp"].iloc[0]) if "timestamp" in frame_data.columns else 0.0

    # --- Velocity (canonical 0.5s window) ---
    if prev_defensive_line_x is not None and not np.isnan(defensive_line_x):
        def_line_speed = (defensive_line_x - prev_defensive_line_x) / dt
    else:
        def_line_speed = np.nan

    if prev_defending_centroid_x is not None and len(defending) > 0:
        def_cx = float(np.mean(defending["x"].apply(to_gr_x).values))
        def_centroid_vx = (def_cx - prev_defending_centroid_x) / dt
    else:
        def_centroid_vx = np.nan

    row = [
        ball_x, ball_y, ball_vx, ball_vy, ball_dist,
        defensive_line_x, defensive_line_depth, defensive_line_width,
        attackers_in_box, nearest_atk_x, atk_cx, atk_cy,
        defenders_behind_ball, deepest_defender_x,
        phase, team_in_poss, score_diff, time_s, period_clamped,
        ball_to_goal_angle, ball_to_nearest_atk, compactness, ball_in_own_half,
        ball_speed, def_line_speed, def_centroid_vx,
    ]
    return pd.DataFrame([row], columns=GHOST_GK_FEATURE_NAMES)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestFeatureExtraction -v`
Expected: 3 PASSED

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk.py
git commit -m "feat(ghost-gk): extract_ghost_gk_features — 26-feature goal-relative extractor"
```

---

## Task 4: GhostGkModel — fit() + Vectorized Leaf Traversal + predict_density()

This task resolves C1, C2, and H1: single code path for leaf indices using vectorized numpy tree traversal from the start.

**Files:**
- Modify: `silly_kicks/tracking/_ghost_gk.py`
- Test: `tests/tracking/test_ghost_gk.py`

- [ ] **Step 1: Write the failing tests**

Append to `tests/tracking/test_ghost_gk.py`:

```python
from silly_kicks.tracking._ghost_gk import GHOST_GK_FEATURE_NAMES, GRID_X_MIN, GRID_X_MAX, GRID_Y_MIN, GRID_Y_MAX


class TestGhostGkModel:
    """GhostGkModel fit/predict/predict_density."""

    def test_ghost_gk_model_fit_predict(self):
        """fit() + predict() returns (n, 2) joint mode."""
        model, X, _ = _fitted_model()
        predictions = model.predict(X[:10])
        assert predictions.shape == (10, 2)
        assert np.all(predictions[:, 0] >= GRID_X_MIN)
        assert np.all(predictions[:, 0] <= GRID_X_MAX)
        assert np.all(predictions[:, 1] >= GRID_Y_MIN)
        assert np.all(predictions[:, 1] <= GRID_Y_MAX)

    def test_ghost_gk_density(self):
        """predict_density() grid sums to ~1.0, mode in bounds."""
        from silly_kicks.tracking._ghost_gk import GhostGkDensity
        model, X, _ = _fitted_model()
        densities = model.predict_density(X[:5])
        assert len(densities) == 5
        for d in densities:
            assert isinstance(d, GhostGkDensity)
            np.testing.assert_allclose(d.probabilities.sum(), 1.0, atol=0.01)
            assert GRID_X_MIN <= d.mode_x <= GRID_X_MAX
            assert GRID_Y_MIN <= d.mode_y <= GRID_Y_MAX

    def test_ghost_gk_density_joint_mode(self):
        """Mode is argmax of 2D grid (not two independent 1D argmaxes)."""
        model, X, _ = _fitted_model()
        densities = model.predict_density(X[:3])
        for d in densities:
            flat_idx = np.argmax(d.probabilities)
            ix, iy = np.unravel_index(flat_idx, d.probabilities.shape)
            np.testing.assert_allclose(d.mode_x, d.grid_x[ix])
            np.testing.assert_allclose(d.mode_y, d.grid_y[iy])

    def test_ghost_gk_predict_with_nan_features(self):
        """NaN velocity features → finite predictions in bounds."""
        model, X, _ = _fitted_model()
        X_nan = X[:5].copy()
        for col in ["ball_vx", "ball_vy", "ball_speed", "defensive_line_speed", "defending_centroid_vx"]:
            X_nan[col] = np.nan
        predictions = model.predict(X_nan)
        assert predictions.shape == (5, 2)
        assert np.all(np.isfinite(predictions))
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestGhostGkModel -v`
Expected: FAIL — `ImportError: cannot import name 'GhostGkModel'`

- [ ] **Step 3: Implement GhostGkModel with vectorized leaf traversal**

Append to `silly_kicks/tracking/_ghost_gk.py`:

```python
from scipy.stats import gaussian_kde
from sklearn.ensemble import HistGradientBoostingRegressor


def _vectorized_leaf_indices(nodes_list: list[np.ndarray], X: np.ndarray) -> np.ndarray:
    """Vectorized tree traversal for all trees at once.

    Parameters
    ----------
    nodes_list : list[np.ndarray]
        Each element is a structured array of tree nodes with fields:
        left, right, feature_idx, num_threshold, missing_go_to_left.
    X : np.ndarray
        Feature matrix, shape (n_samples, n_features).

    Returns
    -------
    np.ndarray
        Shape (n_samples, n_trees) — leaf node index per sample per tree.
    """
    n_samples = X.shape[0]
    n_trees = len(nodes_list)
    leaves = np.zeros((n_samples, n_trees), dtype=np.intp)

    for t_idx, nodes in enumerate(nodes_list):
        # Start all samples at root (node 0)
        current = np.zeros(n_samples, dtype=np.intp)

        # Traverse until all samples reach leaves
        # Max depth is bounded; iterate until no sample moves
        for _ in range(100):  # depth bound
            node_data = nodes[current]
            is_leaf = node_data["left"] == 0
            if np.all(is_leaf):
                break

            feature_idx = node_data["feature_idx"]
            thresholds = node_data["num_threshold"]
            go_left_missing = node_data["missing_go_to_left"]

            # Gather feature values for each sample's current split feature
            feat_vals = X[np.arange(n_samples), feature_idx]

            # Decision: NaN handling + threshold comparison
            is_nan = np.isnan(feat_vals)
            go_left = np.where(
                is_nan,
                go_left_missing.astype(bool),
                feat_vals <= thresholds,
            )

            # Advance non-leaf samples
            next_node = np.where(go_left, node_data["left"], node_data["right"])
            current = np.where(is_leaf, current, next_node)

        leaves[:, t_idx] = current

    return leaves


class GhostGkModel:
    """League-average GK positioning model using RFCDE density estimation.

    Uses HistGradientBoostingRegressor leaf-assignment (NaN-tolerant)
    + weighted 2D KDE for joint density estimation. The regressor is
    trained on gk_x as a proxy target — its role is purely to partition
    feature space into regions of similar game state (NFL Ghosts §3.1,
    Pospisil & Lee 2018). All spatial predictions come from the joint
    2D density layer.

    Thread-safe for concurrent predict/predict_density calls after fit/load.

    Examples
    --------
    >>> model = GhostGkModel()
    >>> model.fit(X_train, labels_train)
    >>> positions = model.predict(X_test)  # shape (n, 2)
    >>> densities = model.predict_density(X_test)  # list[GhostGkDensity]

    See NOTICE for full bibliographic citations.
    """

    def __init__(self, *, n_estimators: int = 500, max_depth: int = 8):
        self._n_estimators = n_estimators
        self._max_depth = max_depth
        # Populated by fit() or load():
        self._tree_nodes: list[np.ndarray] | None = None  # serializable node arrays
        self._training_gk_x: np.ndarray | None = None
        self._training_gk_y: np.ndarray | None = None
        self._training_leaves: np.ndarray | None = None

    def fit(self, features: pd.DataFrame, labels: pd.DataFrame) -> Self:
        """Train on feature matrix + (gk_x, gk_y) labels.

        Parameters
        ----------
        features : pd.DataFrame
            Shape (n_samples, 26).
        labels : pd.DataFrame
            Columns "gk_x", "gk_y" — actual GK positions (goal-relative).

        Examples
        --------
        >>> model = GhostGkModel()
        >>> model.fit(X_train, labels_train)
        """
        X = features.values.astype(np.float64)
        y_x = labels["gk_x"].values.astype(np.float64)

        # Determine categorical feature index (phase)
        phase_idx = list(features.columns).index("phase") if "phase" in features.columns else None
        cat_features = [phase_idx] if phase_idx is not None else []

        regressor = HistGradientBoostingRegressor(
            max_iter=self._n_estimators,
            max_depth=self._max_depth,
            categorical_features=cat_features if cat_features else "from_dtype",
            random_state=42,
        )
        regressor.fit(X, y_x)

        # Extract tree node arrays for serialization + inference
        self._tree_nodes = []
        for tree_list in regressor._predictors:
            tree = tree_list[0]
            self._tree_nodes.append(tree.nodes.copy())

        # Compute training leaves
        self._training_leaves = _vectorized_leaf_indices(self._tree_nodes, X)
        self._training_gk_x = y_x.copy()
        self._training_gk_y = labels["gk_y"].values.astype(np.float64).copy()

        return self

    def predict(self, features: pd.DataFrame) -> np.ndarray:
        """Predict joint (x, y) mode for each sample.

        Returns shape (n_samples, 2) — argmax of joint 2D density grid.

        Examples
        --------
        >>> model.predict(X_test).shape
        (100, 2)
        """
        densities = self.predict_density(features)
        return np.array([[d.mode_x, d.mode_y] for d in densities])

    def predict_density(self, features: pd.DataFrame) -> list[GhostGkDensity]:
        """Full density prediction per sample.

        Computes leaf co-occurrence weights, weighted 2D KDE, grid evaluation.

        Examples
        --------
        >>> densities = model.predict_density(X_test)
        >>> densities[0].probabilities.sum()
        1.0
        """
        if self._tree_nodes is None or self._training_leaves is None:
            msg = "Model not fitted. Call .fit() or .load() first."
            raise RuntimeError(msg)

        X = features.values.astype(np.float64)
        query_leaves = _vectorized_leaf_indices(self._tree_nodes, X)
        n_trees = query_leaves.shape[1]

        # Precompute grid mesh
        grid_xx, grid_yy = np.meshgrid(_GRID_X, _GRID_Y, indexing="ij")
        grid_points = np.vstack([grid_xx.ravel(), grid_yy.ravel()])

        results: list[GhostGkDensity] = []
        for i in range(len(X)):
            # Weight = fraction of trees with matching leaf
            matches = (self._training_leaves == query_leaves[i])
            weights = matches.sum(axis=1).astype(np.float64) / n_trees

            nonzero = weights > 0
            w = weights[nonzero]
            gk_x_w = self._training_gk_x[nonzero]
            gk_y_w = self._training_gk_y[nonzero]

            if len(w) < 2:
                probs = np.ones((GRID_NX, GRID_NY)) / (GRID_NX * GRID_NY)
            else:
                try:
                    kde = gaussian_kde(
                        np.vstack([gk_x_w, gk_y_w]),
                        weights=w,
                        bw_method="scott",
                    )
                    density_vals = kde(grid_points)
                    probs = density_vals.reshape(GRID_NX, GRID_NY)
                    total = probs.sum()
                    probs = probs / total if total > 0 else np.ones((GRID_NX, GRID_NY)) / (GRID_NX * GRID_NY)
                except np.linalg.LinAlgError:
                    probs = np.ones((GRID_NX, GRID_NY)) / (GRID_NX * GRID_NY)

            # Joint 2D mode
            flat_idx = int(np.argmax(probs))
            ix, iy = np.unravel_index(flat_idx, probs.shape)
            mode_x = float(_GRID_X[ix])
            mode_y = float(_GRID_Y[iy])

            # Mean
            mean_x = float(np.sum(probs * grid_xx))
            mean_y = float(np.sum(probs * grid_yy))

            # Spread (entropy)
            nz = probs[probs > 0]
            entropy = float(-np.sum(nz * np.log(nz)))
            spread = float(np.exp(entropy) * GRID_RESOLUTION**2)

            results.append(GhostGkDensity(
                mode_x=mode_x, mode_y=mode_y, mean_x=mean_x, mean_y=mean_y,
                spread=spread, grid_x=_GRID_X, grid_y=_GRID_Y, probabilities=probs,
            ))

        return results
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestGhostGkModel -v`
Expected: 4 PASSED

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk.py
git commit -m "feat(ghost-gk): GhostGkModel with vectorized numpy leaf traversal + RFCDE"
```

---

## Task 5: Serialization (npz + SHA-256 — No ONNX at Inference)

Resolves C1 and H4: tree node arrays stored directly in npz. No onnxruntime needed at inference.

**Files:**
- Modify: `silly_kicks/tracking/_ghost_gk.py`
- Test: `tests/tracking/test_ghost_gk.py`

- [ ] **Step 1: Write the failing tests**

Append to `tests/tracking/test_ghost_gk.py`:

```python
import tempfile


class TestSerialization:
    """GhostGkModel save/load — npz + SHA-256, no pickle."""

    def test_ghost_gk_model_save_load(self):
        """Round-trip: save → load → predict matches."""
        model, X, _ = _fitted_model()
        preds_before = model.predict(X[:5])

        with tempfile.TemporaryDirectory() as tmpdir:
            save_path = Path(tmpdir) / "ghost_gk_test"
            model.save(save_path)

            # Verify artifact structure
            assert (save_path / "rfcde_weights.npz").exists()
            assert (save_path / "metadata.json").exists()
            assert (save_path / "SHA256SUMS").exists()

            # No pickle/joblib files
            import glob as glob_mod
            bad = glob_mod.glob(str(save_path / "*.pkl")) + glob_mod.glob(str(save_path / "*.joblib"))
            assert len(bad) == 0

            loaded = model.__class__.load(save_path)
            preds_after = loaded.predict(X[:5])

        np.testing.assert_allclose(preds_before, preds_after, atol=0.01)

    def test_ghost_gk_model_sha256_verification(self):
        """Tampered artifact raises IntegrityError on load."""
        from silly_kicks.tracking._ghost_gk import IntegrityError
        model, _, _ = _fitted_model()

        with tempfile.TemporaryDirectory() as tmpdir:
            save_path = Path(tmpdir) / "ghost_gk_tampered"
            model.save(save_path)

            # Tamper with npz
            npz_path = save_path / "rfcde_weights.npz"
            with open(npz_path, "ab") as f:
                f.write(b"TAMPERED")

            with pytest.raises(IntegrityError):
                model.__class__.load(save_path)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestSerialization -v`
Expected: FAIL — `ImportError: cannot import name 'IntegrityError'`

- [ ] **Step 3: Implement save/load**

Add to `silly_kicks/tracking/_ghost_gk.py`:

```python
class IntegrityError(Exception):
    """Raised when artifact SHA-256 verification fails."""


_ENV_VAR = "SILLY_KICKS_GHOST_GK_PATH"


# Add these methods to GhostGkModel:

    def save(self, path: Path) -> None:
        """Serialize to npz + metadata.json + SHA256SUMS (no pickle).

        Artifact structure:
        - rfcde_weights.npz: tree_nodes_* arrays + training_gk_x/y + training_leaves
        - metadata.json: feature_names, grid_spec, hyperparams, version
        - SHA256SUMS: per-file integrity hashes

        Examples
        --------
        >>> model.save(Path("models/ghost_gk_v1"))
        """
        if self._tree_nodes is None:
            msg = "Model not fitted. Call .fit() first."
            raise RuntimeError(msg)

        path = Path(path)
        path.mkdir(parents=True, exist_ok=True)

        # 1. Save all data to npz
        save_dict = {
            "training_gk_x": self._training_gk_x,
            "training_gk_y": self._training_gk_y,
            "training_leaves": self._training_leaves,
            "n_trees": np.array([len(self._tree_nodes)]),
        }
        # Serialize each tree's node array
        for i, nodes in enumerate(self._tree_nodes):
            # Convert structured array to a flat representation
            # Store the raw bytes + dtype description
            save_dict[f"tree_nodes_{i}"] = nodes.view(np.uint8)
            save_dict[f"tree_dtype_{i}"] = np.array(
                [str(nodes.dtype)], dtype="U200"
            )

        npz_path = path / "rfcde_weights.npz"
        np.savez_compressed(npz_path, **save_dict)

        # 2. Metadata
        metadata = {
            "feature_names": GHOST_GK_FEATURE_NAMES,
            "grid_spec": {
                "x_min": GRID_X_MIN, "x_max": GRID_X_MAX,
                "y_min": GRID_Y_MIN, "y_max": GRID_Y_MAX,
                "nx": GRID_NX, "ny": GRID_NY, "resolution": GRID_RESOLUTION,
            },
            "n_estimators": self._n_estimators,
            "max_depth": self._max_depth,
            "version": "1.0.0",
        }
        meta_path = path / "metadata.json"
        with open(meta_path, "w") as f:
            json.dump(metadata, f, indent=2)

        # 3. SHA-256
        sums_path = path / "SHA256SUMS"
        with open(sums_path, "w") as f:
            for fname in ["rfcde_weights.npz", "metadata.json"]:
                h = hashlib.sha256((path / fname).read_bytes()).hexdigest()
                f.write(f"{h}  {fname}\n")

    @classmethod
    def load(cls, path: Path) -> GhostGkModel:
        """Load from local directory with SHA-256 verification.

        No sklearn or onnxruntime needed — tree traversal uses stored
        node arrays with numpy.

        Examples
        --------
        >>> model = GhostGkModel.load(Path("models/ghost_gk_v1"))
        """
        path = Path(path)

        # Verify integrity
        sums_path = path / "SHA256SUMS"
        if not sums_path.exists():
            raise IntegrityError(f"SHA256SUMS not found in {path}")

        with open(sums_path) as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                expected_hash, fname = line.split("  ", 1)
                actual_hash = hashlib.sha256((path / fname).read_bytes()).hexdigest()
                if actual_hash != expected_hash:
                    raise IntegrityError(
                        f"Integrity check failed for {fname}: "
                        f"expected {expected_hash}, got {actual_hash}"
                    )

        # Load metadata
        with open(path / "metadata.json") as f:
            metadata = json.load(f)

        # Load npz
        data = np.load(path / "rfcde_weights.npz", allow_pickle=False)

        n_trees = int(data["n_trees"][0])
        tree_nodes = []
        import ast

        for i in range(n_trees):
            raw_bytes = data[f"tree_nodes_{i}"]
            dtype_str = str(data[f"tree_dtype_{i}"][0])
            dtype = np.dtype(ast.literal_eval(dtype_str))
            nodes = raw_bytes.view(dtype)
            tree_nodes.append(nodes)

        model = cls(
            n_estimators=metadata.get("n_estimators", 500),
            max_depth=metadata.get("max_depth", 8),
        )
        model._tree_nodes = tree_nodes
        model._training_gk_x = data["training_gk_x"]
        model._training_gk_y = data["training_gk_y"]
        model._training_leaves = data["training_leaves"]

        return model

    @classmethod
    def from_hub(cls, repo_id: str = "karsten-s-nielsen/ghost-gk-v1") -> GhostGkModel:
        """Download from HuggingFace Hub and load.

        Examples
        --------
        >>> model = GhostGkModel.from_hub()
        """
        try:
            from huggingface_hub import snapshot_download
        except ImportError:
            msg = "Ghost GK requires: pip install silly-kicks[ghost-gk]"
            raise ImportError(msg) from None

        local_dir = snapshot_download(repo_id=repo_id)
        return cls.load(Path(local_dir))
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestSerialization -v`
Expected: 2 PASSED

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk.py
git commit -m "feat(ghost-gk): save/load via npz + SHA-256 (no pickle, no ONNX at inference)"
```

---

## Task 6: Fail-Fast Check + _resolve_model

**Files:**
- Modify: `silly_kicks/tracking/_ghost_gk.py`
- Test: `tests/tracking/test_ghost_gk.py`

- [ ] **Step 1: Write the failing tests**

Append to `tests/tracking/test_ghost_gk.py`:

```python
from unittest.mock import patch


class TestFailFast:
    """Fail-fast when [ghost-gk] extra not installed or model unavailable."""

    def test_ghost_gk_missing_huggingface_hub(self):
        """Mock huggingface_hub unavailable → ImportError with install instructions."""
        from silly_kicks.tracking._ghost_gk import _resolve_model

        with patch.dict("sys.modules", {"huggingface_hub": None}):
            with pytest.raises(ImportError, match="pip install silly-kicks\\[ghost-gk\\]"):
                _resolve_model(None)

    def test_resolve_model_passthrough(self):
        """Pre-loaded model passed through without download."""
        from silly_kicks.tracking._ghost_gk import _resolve_model
        model, _, _ = _fitted_model()
        assert _resolve_model(model) is model
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestFailFast -v`
Expected: FAIL — `ImportError: cannot import name '_resolve_model'`

- [ ] **Step 3: Implement _resolve_model**

Add to `silly_kicks/tracking/_ghost_gk.py`:

```python
def _resolve_model(model: GhostGkModel | None) -> GhostGkModel:
    """Resolve model parameter: load default from Hub if None.

    Checks huggingface_hub availability BEFORE attempting download.
    Respects SILLY_KICKS_GHOST_GK_PATH env var for offline use.

    Examples
    --------
    >>> resolved = _resolve_model(None)  # downloads from Hub
    >>> resolved = _resolve_model(my_model)  # pass-through
    """
    if model is not None:
        return model

    # Check huggingface_hub availability before download attempt
    try:
        import huggingface_hub  # noqa: F401
    except ImportError:
        msg = "Ghost GK requires: pip install silly-kicks[ghost-gk]"
        raise ImportError(msg) from None

    # Check env var override
    env_path = os.environ.get(_ENV_VAR)
    if env_path is not None:
        return GhostGkModel.load(Path(env_path))

    return GhostGkModel.from_hub()
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestFailFast -v`
Expected: 2 PASSED

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk.py
git commit -m "feat(ghost-gk): _resolve_model with fail-fast huggingface_hub check"
```

---

## Task 7: compute_ghost_gk — Batched Per-Frame Primitive

Resolves C3 (performance), H5 (direction), M3 (velocity tracking), R2-M1 (merge write-back), R2-M2 (dead code), R2-M3 (actual dt).

**Files:**
- Modify: `silly_kicks/tracking/_ghost_gk.py`
- Test: `tests/tracking/test_ghost_gk.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/tracking/test_ghost_gk.py`:

```python
class TestComputeGhostGk:
    """compute_ghost_gk batched per-frame primitive."""

    def test_compute_ghost_gk_adds_columns(self):
        """Adds ghost_gk_x, ghost_gk_y, ghost_gk_spread per frame."""
        from silly_kicks.tracking._ghost_gk import compute_ghost_gk
        model, _, _ = _fitted_model()

        frames = pd.concat([
            _make_ghost_gk_frames(frame_id=1, timestamp=1.0),
            _make_ghost_gk_frames(frame_id=2, timestamp=1.5),
        ], ignore_index=True)

        result = compute_ghost_gk(frames, model=model, home_team_id=1)
        assert "ghost_gk_x" in result.columns
        assert "ghost_gk_y" in result.columns
        assert "ghost_gk_spread" in result.columns
        # Results on GK rows
        gk_mask = result["is_goalkeeper"].astype(bool) & ~result["is_ball"].astype(bool)
        assert result.loc[gk_mask, "ghost_gk_x"].notna().any()

    def test_compute_ghost_gk_requires_ltr_normalized(self):
        """Documents: input must be LTR-normalized."""
        from silly_kicks.tracking._ghost_gk import compute_ghost_gk
        model, _, _ = _fitted_model()
        frames = _make_ghost_gk_frames()
        # Should work without error (frames are already LTR-like)
        result = compute_ghost_gk(frames, model=model, home_team_id=1)
        assert len(result) == len(frames)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestComputeGhostGk -v`
Expected: FAIL — `ImportError: cannot import name 'compute_ghost_gk'`

- [ ] **Step 3: Implement batched compute_ghost_gk**

The key optimization: extract features for ALL frame/GK combinations first, then call `predict_density` once on the full batch.

```python
def compute_ghost_gk(
    frames: pd.DataFrame,
    *,
    model: GhostGkModel | None = None,
    home_team_id: int | str,
) -> pd.DataFrame:
    """Per-frame ghost-GK primitive (batched).

    Adds ghost_gk_x, ghost_gk_y, ghost_gk_spread columns.
    One prediction per (frame, GK team). Results written to GK rows.

    Input frames MUST be in LTR-normalized convention (home team attacks
    right in all periods — standard silly-kicks tracking output).

    Parameters
    ----------
    frames : pd.DataFrame
        Tracking frames (TRACKING_FRAMES_COLUMNS schema, LTR-normalized).
    model : GhostGkModel | None
        Pre-loaded model. None = lazy download from Hub.
    home_team_id : int | str
        Home team ID (attacks right → defends at x=0).

    Returns
    -------
    pd.DataFrame
        Copy of frames with ghost_gk_x, ghost_gk_y, ghost_gk_spread added.

    Examples
    --------
    >>> from silly_kicks.tracking._ghost_gk import compute_ghost_gk
    >>> result = compute_ghost_gk(frames, home_team_id=1)
    """
    resolved = _resolve_model(model)
    out = frames.copy()
    out["ghost_gk_x"] = np.nan
    out["ghost_gk_y"] = np.nan
    out["ghost_gk_spread"] = np.nan

    group_keys = ["game_id", "period_id", "frame_id"]
    grouped = list(frames.groupby(group_keys, sort=True))

    # Collect all (frame_group, gk_team, goal_x) combinations + track velocity state
    feature_rows: list[pd.DataFrame] = []
    index_map: list[tuple] = []  # (game_id, period_id, frame_id, gk_team_id)

    # Per-team velocity state tracking
    prev_state: dict[tuple, tuple[float, float]] = {}  # (game_id, gk_team) -> (def_line_x, def_centroid_x)
    prev_timestamps: dict[tuple, float] = {}  # (game_id, gk_team) -> last timestamp

    for (gid, pid, fid), frame_data in grouped:
        gk_rows = frame_data[
            frame_data["is_goalkeeper"].astype(bool) & ~frame_data["is_ball"].astype(bool)
        ]
        for _, gk_row in gk_rows.iterrows():
            gk_team = gk_row["team_id"]
            # Direction: home team defends at x=0, away at x=105 (LTR convention, H5 fix)
            goal_x = 0.0 if str(gk_team) == str(home_team_id) else _FIELD_LENGTH

            # Velocity from previous frame with actual dt (M3 fix)
            state_key = (gid, gk_team)
            prev_dl_x, prev_dc_x = prev_state.get(state_key, (None, None))
            current_ts = float(frame_data["timestamp"].iloc[0]) if "timestamp" in frame_data.columns else 0.0
            prev_ts = prev_timestamps.get(state_key)
            actual_dt = (current_ts - prev_ts) if prev_ts is not None and current_ts > prev_ts else _VELOCITY_WINDOW_S

            feat = extract_ghost_gk_features(
                frame_data,
                gk_team_id=gk_team,
                goal_x=goal_x,
                prev_defensive_line_x=prev_dl_x,
                prev_defending_centroid_x=prev_dc_x,
                dt=actual_dt,
            )
            feature_rows.append(feat)
            index_map.append((gid, pid, fid, gk_team))

            # Update velocity state for next frame
            defending = frame_data[
                (frame_data["team_id"] == gk_team)
                & ~frame_data["is_goalkeeper"].astype(bool)
                & ~frame_data["is_ball"].astype(bool)
            ]
            if len(defending) > 0:
                flip = goal_x > 50.0
                def_cx = float(np.mean(
                    (_FIELD_LENGTH - defending["x"]) if flip else defending["x"]
                ))
            else:
                def_cx = np.nan
            prev_state[state_key] = (float(feat["defensive_line_x"].iloc[0]), def_cx)
            prev_timestamps[state_key] = current_ts

    if not feature_rows:
        return out

    # Batch predict (amortized — addresses C3)
    batch_features = pd.concat(feature_rows, ignore_index=True)
    densities = resolved.predict_density(batch_features)

    # Build result DataFrame from predictions (M1 fix: O(n+m) merge, not O(n×m) loop)
    result_df = pd.DataFrame({
        "game_id": [t[0] for t in index_map],
        "period_id": [t[1] for t in index_map],
        "frame_id": [t[2] for t in index_map],
        "team_id": [t[3] for t in index_map],
        "ghost_gk_x": [d.mode_x for d in densities],
        "ghost_gk_y": [d.mode_y for d in densities],
        "ghost_gk_spread": [d.spread for d in densities],
    })

    # Merge into GK rows via single join
    gk_mask = out["is_goalkeeper"].astype(bool) & ~out["is_ball"].astype(bool)
    gk_rows = out.loc[gk_mask, ["game_id", "period_id", "frame_id", "team_id"]].copy()
    gk_rows = gk_rows.merge(result_df, on=["game_id", "period_id", "frame_id", "team_id"], how="left")
    out.loc[gk_mask, "ghost_gk_x"] = gk_rows["ghost_gk_x"].values
    out.loc[gk_mask, "ghost_gk_y"] = gk_rows["ghost_gk_y"].values
    out.loc[gk_mask, "ghost_gk_spread"] = gk_rows["ghost_gk_spread"].values

    return out
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestComputeGhostGk -v`
Expected: 2 PASSED

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk.py
git commit -m "feat(ghost-gk): compute_ghost_gk batched primitive (LTR-required, velocity-tracked)"
```

---

## Task 8: add_ghost_gk Aggregator + ghost_gk_xfns Factory

Resolves H2 (compute once), H3 (no provenance leak), R2-H1 (short-circuit pre-computed frames).

**Files:**
- Modify: `silly_kicks/tracking/features.py`
- Test: `tests/tracking/test_ghost_gk.py`

- [ ] **Step 1: Write the failing tests**

Append to `tests/tracking/test_ghost_gk.py`:

```python
class TestAggregatorAndXfns:
    """add_ghost_gk aggregator + ghost_gk_xfns VAEP factory."""

    def _make_actions(self):
        return pd.DataFrame({
            "game_id": ["100", "100"],
            "action_id": [1, 2],
            "period_id": [1, 1],
            "time_seconds": [1.0, 2.0],
            "team_id": [2, 2],
            "player_id": ["a10", "a11"],
            "start_x": [50.0, 55.0],
            "start_y": [34.0, 34.0],
            "end_x": [55.0, 60.0],
            "end_y": [34.0, 34.0],
            "type_id": [0, 0],
            "result_id": [1, 1],
            "bodypart_id": [0, 0],
        })

    def test_add_ghost_gk_aggregator(self):
        """Expected columns, no provenance leak."""
        from silly_kicks.tracking.features import add_ghost_gk

        model, _, _ = _fitted_model()
        actions = self._make_actions()
        frames = pd.concat([
            _make_ghost_gk_frames(frame_id=1, timestamp=1.0),
            _make_ghost_gk_frames(frame_id=2, timestamp=2.0),
        ], ignore_index=True)

        result = add_ghost_gk(actions, frames, model=model, home_team_id=1)
        assert "ghost_gk_x" in result.columns
        assert "ghost_gk_y" in result.columns
        assert "ghost_gk_spread" in result.columns
        assert len(result) == len(actions)
        # H3: No provenance leak — ghost-GK only adds ghost-GK columns
        assert "time_offset_seconds" not in result.columns
        assert "link_quality_score" not in result.columns

    def test_ghost_gk_xfns_factory(self):
        """Correct column names, silent NaN on dummy gamestate."""
        from silly_kicks.tracking.features import ghost_gk_xfns

        model, _, _ = _fitted_model()
        xfns = ghost_gk_xfns(model=model, home_team_id=1)
        assert len(xfns) == 1

        dummy = pd.DataFrame({
            "game_id": ["100"] * 10, "action_id": range(10),
            "period_id": [1] * 10, "time_seconds": [1.0] * 10,
            "team_id": [1] * 10, "player_id": [1] * 10,
            "start_x": [50.0] * 10, "start_y": [34.0] * 10,
            "end_x": [55.0] * 10, "end_y": [34.0] * 10,
            "type_id": [0] * 10, "result_id": [1] * 10, "bodypart_id": [0] * 10,
        })
        states = [dummy, dummy, dummy]

        # With frames=None → silent NaN (VAEP introspection path)
        result = xfns[0](states, None)
        assert all("ghost_gk" in col for col in result.columns)
        assert result.isna().all().all()
        # 3 columns × 3 states = 9
        assert result.shape[1] == 9
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestAggregatorAndXfns -v`
Expected: FAIL — `ImportError: cannot import name 'add_ghost_gk'`

- [ ] **Step 3: Implement in features.py**

Add to `silly_kicks/tracking/features.py`:

```python
@nan_safe_enrichment
def add_ghost_gk(
    actions: pd.DataFrame,
    frames: pd.DataFrame,
    *,
    model=None,
    links: pd.DataFrame | None = None,
    home_team_id: int | str,
) -> pd.DataFrame:
    """Enrich actions with ghost-GK positioning columns.

    Adds ghost_gk_x, ghost_gk_y, ghost_gk_spread per action (defending
    GK's ghost position at the linked frame).

    Only adds ghost-GK columns — does NOT add link provenance columns.
    Callers wanting provenance should call link_actions_to_frames directly.

    Parameters
    ----------
    actions : pd.DataFrame
        SPADL actions.
    frames : pd.DataFrame
        Tracking frames (LTR-normalized).
    model : GhostGkModel | None
        Pre-loaded model. None = lazy download.
    links : pd.DataFrame | None
        Pre-computed link pointers.
    home_team_id : int | str
        Home team ID.

    Examples
    --------
    >>> from silly_kicks.tracking.features import add_ghost_gk
    >>> enriched = add_ghost_gk(actions, frames, home_team_id=1)

    See NOTICE for full bibliographic citations.
    """
    from ._ghost_gk import _resolve_model, compute_ghost_gk

    resolved_model = _resolve_model(model)
    out = actions.copy()

    # Link actions to frames
    if links is not None:
        pointers = links
    else:
        pointers, _ = link_actions_to_frames(actions, frames)

    # Short-circuit: skip compute if frames already have ghost columns (H1 fix —
    # allows xfns factory to pre-compute once and pass through)
    if "ghost_gk_x" in frames.columns and frames["ghost_gk_x"].notna().any():
        ghost_frames = frames
    else:
        ghost_frames = compute_ghost_gk(frames, model=resolved_model, home_team_id=home_team_id)

    # Extract ghost predictions from GK rows
    gk_ghost = ghost_frames[
        ghost_frames["is_goalkeeper"].astype(bool)
        & ~ghost_frames["is_ball"].astype(bool)
        & ghost_frames["ghost_gk_x"].notna()
    ][["game_id", "period_id", "frame_id", "team_id", "ghost_gk_x", "ghost_gk_y", "ghost_gk_spread"]].copy()

    # Build linked lookup
    linked = pointers.merge(
        actions[["action_id", "game_id", "period_id", "team_id"]],
        on="action_id",
    )

    # Align game_id dtype (PR-S53 pattern)
    if len(linked) > 0 and len(gk_ghost) > 0:
        if linked["game_id"].dtype != gk_ghost["game_id"].dtype:
            linked["game_id"] = linked["game_id"].astype(str)
            gk_ghost["game_id"] = gk_ghost["game_id"].astype(str)

    # Merge: find defending GK (opposite team from action's team)
    merged = linked.merge(
        gk_ghost,
        on=["game_id", "period_id", "frame_id"],
        how="left",
        suffixes=("_action", "_gk"),
    )
    # Defending GK = opposite team
    defending = merged[merged["team_id_action"] != merged["team_id_gk"]]
    deduped = defending.drop_duplicates(subset=["action_id"], keep="first")

    # Join back to actions
    ghost_cols = deduped.set_index("action_id")[["ghost_gk_x", "ghost_gk_y", "ghost_gk_spread"]]
    out = out.merge(ghost_cols, left_on="action_id", right_index=True, how="left")

    return out


def ghost_gk_xfns(*, model=None, home_team_id: int | str) -> list:
    """Factory returning a FrameAwareTransformer for ghost-GK features.

    3 columns × 3 game states = 9 VAEP columns.

    Examples
    --------
    >>> from silly_kicks.tracking.features import ghost_gk_xfns
    >>> xfns = ghost_gk_xfns(home_team_id=1)

    See NOTICE for full bibliographic citations.
    """
    col_names = ["ghost_gk_x", "ghost_gk_y", "ghost_gk_spread"]

    def _ghost_gk_transformer(states, frames):
        out = pd.DataFrame(index=states[0].index)

        if frames is None:
            for i in range(3):
                for col in col_names:
                    out[f"{col}_a{i}"] = np.nan
            return out

        from ._ghost_gk import _resolve_model, compute_ghost_gk
        resolved = _resolve_model(model)

        # Compute once (H2 fix — single compute_ghost_gk call)
        ghost_frames = compute_ghost_gk(frames, model=resolved, home_team_id=home_team_id)

        for i, slot in enumerate(states[:3]):
            enriched = add_ghost_gk(
                slot, ghost_frames, model=resolved, home_team_id=home_team_id,
            )
            for col in col_names:
                out[f"{col}_a{i}"] = enriched[col].values if col in enriched.columns else np.nan

        return out

    _ghost_gk_transformer._frame_aware = True  # type: ignore[attr-defined]
    _ghost_gk_transformer.__name__ = "ghost_gk_xfn"
    return [_ghost_gk_transformer]
```

Update `__all__` in `features.py` to add `"add_ghost_gk"` and `"ghost_gk_xfns"`.

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/tracking/test_ghost_gk.py::TestAggregatorAndXfns -v`
Expected: 2 PASSED

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/features.py tests/tracking/test_ghost_gk.py
git commit -m "feat(ghost-gk): add_ghost_gk + ghost_gk_xfns (no provenance leak, single compute)"
```

---

## Task 9: Atomic Mirror + Integration Tests

**Files:**
- Modify: `silly_kicks/atomic/tracking/features.py`
- Create: `tests/tracking/test_ghost_gk_integration.py`

- [ ] **Step 1: Write integration tests**

```python
# tests/tracking/test_ghost_gk_integration.py
"""Integration tests for Ghost-GK positioning model (TF-18)."""

from __future__ import annotations

import numpy as np
import pandas as pd
import pytest

from tests.tracking.test_ghost_gk import _fitted_model, _make_ghost_gk_frames
from silly_kicks.tracking._ghost_gk import (
    GhostGkModel, GhostGkDensity, GHOST_GK_FEATURE_NAMES, GRID_NX, GRID_NY,
)


class TestAtomicMirror:
    def test_atomic_mirror(self):
        from silly_kicks.atomic.tracking.features import add_ghost_gk as atomic_add
        from silly_kicks.tracking.features import add_ghost_gk

        model, _, _ = _fitted_model()
        actions = pd.DataFrame({
            "game_id": ["100", "100"], "action_id": [1, 2],
            "period_id": [1, 1], "time_seconds": [1.0, 2.0],
            "team_id": [2, 2], "player_id": ["a10", "a11"],
            "start_x": [50.0, 55.0], "start_y": [34.0, 34.0],
            "end_x": [55.0, 60.0], "end_y": [34.0, 34.0],
            "type_id": [0, 0], "result_id": [1, 1], "bodypart_id": [0, 0],
        })
        frames = _make_ghost_gk_frames(frame_id=1)

        std = add_ghost_gk(actions, frames, model=model, home_team_id=1)
        atomic = atomic_add(actions, frames, model=model, home_team_id=1)

        for col in ["ghost_gk_x", "ghost_gk_y", "ghost_gk_spread"]:
            assert col in atomic.columns


class TestDtypeMismatch:
    def test_add_ghost_gk_dtype_mismatch(self):
        """int64 actions + str frames → no crash."""
        from silly_kicks.tracking.features import add_ghost_gk
        model, _, _ = _fitted_model()

        actions = pd.DataFrame({
            "game_id": [100, 100], "action_id": [1, 2],
            "period_id": [1, 1], "time_seconds": [1.0, 2.0],
            "team_id": [2, 2], "player_id": ["a10", "a11"],
            "start_x": [50.0, 55.0], "start_y": [34.0, 34.0],
            "end_x": [55.0, 60.0], "end_y": [34.0, 34.0],
            "type_id": [0, 0], "result_id": [1, 1], "bodypart_id": [0, 0],
        })
        frames = _make_ghost_gk_frames(game_id="100")
        result = add_ghost_gk(actions, frames, model=model, home_team_id=1)
        assert len(result) == 2


class TestTF19Interface:
    def test_ghost_gk_with_gk_deterrent_interface(self):
        """Density grid compatible with TF-19 consumption."""
        model, X, _ = _fitted_model()
        densities = model.predict_density(X[:3])
        for d in densities:
            assert d.grid_x.shape == (GRID_NX,)
            assert d.grid_y.shape == (GRID_NY,)
            assert d.probabilities.shape == (GRID_NX, GRID_NY)
            # Element-wise multiplication with shot region
            shot_region = np.random.default_rng(42).random((GRID_NX, GRID_NY))
            threat = float((d.probabilities * shot_region).sum())
            assert 0.0 <= threat <= 1.0
```

- [ ] **Step 2: Add atomic mirror imports**

In `silly_kicks/atomic/tracking/features.py`, add:

```python
from silly_kicks.tracking.features import (
    # ... existing ...
    add_ghost_gk,
    ghost_gk_xfns,
)
```

And update `__all__`.

- [ ] **Step 3: Run tests**

Run: `python -m pytest tests/tracking/test_ghost_gk_integration.py -v`
Expected: 4 PASSED

- [ ] **Step 4: Commit**

```bash
git add silly_kicks/atomic/tracking/features.py tests/tracking/test_ghost_gk_integration.py
git commit -m "feat(ghost-gk): atomic mirror + integration tests (dtype, TF-19 interface)"
```

---

## Task 10: Benchmark Test

**Files:**
- Create: `tests/tracking/test_ghost_gk_benchmark.py`

- [ ] **Step 1: Write benchmark**

```python
# tests/tracking/test_ghost_gk_benchmark.py
"""Performance benchmark for Ghost-GK (TF-18)."""

import numpy as np
import pandas as pd
import pytest

from silly_kicks.tracking._ghost_gk import (
    GhostGkModel, GHOST_GK_FEATURE_NAMES, _vectorized_leaf_indices,
)


@pytest.fixture(scope="module")
def trained_model():
    rng = np.random.default_rng(42)
    X = pd.DataFrame(rng.standard_normal((500, 26)), columns=GHOST_GK_FEATURE_NAMES)
    X["phase"] = rng.integers(0, 3, 500).astype(float)
    labels = pd.DataFrame({"gk_x": rng.uniform(2, 20, 500), "gk_y": rng.uniform(25, 45, 500)})
    model = GhostGkModel(n_estimators=50)
    model.fit(X, labels)
    return model, X


def test_leaf_traversal_performance(trained_model, benchmark):
    """Vectorized leaf traversal should handle 1000 samples in < 500ms."""
    model, X = trained_model
    batch = X.iloc[:1000] if len(X) >= 1000 else pd.concat([X] * 2, ignore_index=True).iloc[:1000]
    X_arr = batch.values.astype(np.float64)

    result = benchmark(lambda: _vectorized_leaf_indices(model._tree_nodes, X_arr))
    assert result.shape == (1000, 50)


def test_predict_density_performance(trained_model, benchmark):
    """predict_density for 10 samples should complete in < 2s."""
    model, X = trained_model
    batch = X.iloc[:10]

    result = benchmark(lambda: model.predict_density(batch))
    assert len(result) == 10
```

- [ ] **Step 2: Run benchmark**

Run: `python -m pytest tests/tracking/test_ghost_gk_benchmark.py -v --benchmark-disable`
Expected: 2 PASSED (benchmarks run but no failure on timing in CI — timing is informational).

- [ ] **Step 3: Commit**

```bash
git add tests/tracking/test_ghost_gk_benchmark.py
git commit -m "test(ghost-gk): performance benchmark for leaf traversal + predict_density"
```

---

## Task 11: Training Script

**Files:**
- Create: `scripts/train_ghost_gk.py`

- [ ] **Step 1: Create training script (no sys.path hack)**

```python
#!/usr/bin/env python
"""Train Ghost-GK positioning model (TF-18).

Usage:
    uv run python scripts/train_ghost_gk.py \
        --data-dir /path/to/tracking/parquet/ \
        --output-dir models/ \
        --subsample-fps 1.0 \
        --n-estimators 500 \
        --max-depth 8 \
        --cv-folds 5

Requires: silly-kicks installed (uv run handles this).
Training-only dep: skl2onnx (for validation ONNX export).

See docs/superpowers/specs/2026-05-25-tf18-ghost-gk-design.md.
"""

from __future__ import annotations

import argparse
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.model_selection import GroupKFold

from silly_kicks.tracking._ghost_gk import (
    GHOST_GK_FEATURE_NAMES,
    GhostGkModel,
    extract_ghost_gk_features,
)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Train Ghost-GK model")
    parser.add_argument("--data-dir", type=Path, required=True)
    parser.add_argument("--output-dir", type=Path, default=Path("models"))
    parser.add_argument("--subsample-fps", type=float, default=1.0)
    parser.add_argument("--n-estimators", type=int, default=500)
    parser.add_argument("--max-depth", type=int, default=8)
    parser.add_argument("--cv-folds", type=int, default=5)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    args.output_dir.mkdir(parents=True, exist_ok=True)

    print(f"Config: n_estimators={args.n_estimators}, max_depth={args.max_depth}")
    print(f"Data: {args.data_dir}, subsample_fps={args.subsample_fps}")
    print(f"CV: {args.cv_folds}-fold GroupKFold (match-level)")
    print(f"Output: {args.output_dir}")

    # Training data loading + feature extraction + CV evaluation
    # implemented when real multi-provider tracking data is available.
    # The model class + feature extraction are fully implemented in _ghost_gk.py.
    print("\nTraining script ready for data integration.")


if __name__ == "__main__":
    main()
```

- [ ] **Step 2: Verify**

Run: `uv run python scripts/train_ghost_gk.py --data-dir . --output-dir /tmp/ghost_gk_test`
Expected: Prints config, exits cleanly.

- [ ] **Step 3: Commit**

```bash
git add scripts/train_ghost_gk.py
git commit -m "feat(ghost-gk): training script skeleton (uv run, no sys.path hack)"
```

---

## Task 12: NOTICE + CHANGELOG + Version Bump

**Files:**
- Modify: `NOTICE`, `CHANGELOG.md`, `pyproject.toml`, `silly_kicks/__init__.py`

- [ ] **Step 1: Add academic attribution to NOTICE**

Append to "Mathematical / Methodological References":

```
Ghost-GK Positioning Model (TF-18)
-----------------------------------
Le, H. M., Yue, Y., Carr, P. & Lucey, P. (2017). "Data-Driven Ghosting Using
Deep Imitation Learning." MIT Sloan Sports Analytics Conference.

Dutta, R., Yurko, R. & Ventura, S. (2024). "NFL Ghosts: A framework for
evaluating defender positioning with conditional density estimation."
arXiv:2406.17220.

Pospisil, T. & Lee, A. B. (2018). "RFCDE: Random Forests for Conditional
Density Estimation." arXiv:1804.05753.
```

- [ ] **Step 2: Update CHANGELOG.md + bump version**

Version in `pyproject.toml` and `silly_kicks/__init__.py` — use next minor after current (determine at execution time from `silly_kicks.__version__`).

- [ ] **Step 3: Run full test suite + lint**

Run: `python -m pytest tests/ -m "not e2e" --tb=short -q && ruff check silly_kicks/ tests/ && ruff format --check silly_kicks/ tests/ && pyright silly_kicks/`
Expected: All clean.

- [ ] **Step 4: Commit**

```bash
git add NOTICE CHANGELOG.md pyproject.toml silly_kicks/__init__.py
git commit -m "chore: bump version — Ghost-GK positioning model (TF-18)"
```

---

## Self-Review

**Spec coverage:** All items from v3 spec covered. Deferred items (e2e tests, from_hub network test, ADR-005 amendment) noted at bottom.

**Critical fixes from lakehouse review:**
- C1 ✓ — No ONNX at inference; tree nodes in npz; load() works immediately
- C2 ✓ — Single `_vectorized_leaf_indices` from the start (no dead-code version)
- C3 ✓ — Batched: extract all features → single `predict_density(batch)`
- H1 ✓ — Numpy vectorized traversal (all samples advance in parallel per tree)
- H2 ✓ — `ghost_gk_xfns` computes once, passes `ghost_frames` to aggregator
- H3 ✓ — `add_ghost_gk` only adds ghost-GK columns, no provenance leak
- H4 ✓ — `[ghost-gk]` is `huggingface_hub` only; no `onnxruntime`
- H5 ✓ — Documents LTR-normalized input requirement; direction from home_team_id
- M1 ✓ — Shared `_fitted_model()` fixture (module-level, cached)
- M2 ✓ — Documented in GhostGkModel docstring (cites NFL Ghosts §3.1)
- M3 ✓ — `compute_ghost_gk` tracks prev_defensive_line_x/prev_defending_centroid_x
- M4 ✓ — Benchmark test added (Task 10)
- M5 ✓ — `uv run python scripts/...` instead of sys.path manipulation
- L1 ✓ — `uv sync` in Task 1
- L2 ✓ — "determine at execution time" in Task 12
- L3 ✓ — `writeable=False` in `__post_init__`

**Not covered (deferred per spec):**
- E2e tests (`@pytest.mark.e2e`) — require trained model artifact on real data
- `test_ghost_gk_from_hub_download` (`@pytest.mark.network`) — requires published Hub artifact
- `test_velocity_canonical_window` — requires multi-fps fixtures; added during training integration
- ADR-005 amendment — written after PR ships
