# Action-Context Hot-Path Acceleration — Phase 0 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:** Replace ghost-GK's per-sample `scipy.stats.gaussian_kde` (74% of the AC-1 chain) with a vectorized, scipy-faithful KDE kernel, and remove the per-row `.iloc` dict-build hot spots — both behaviour-preserving and golden-mastered — then re-measure to gate any future GPU work.

**Architecture:** Keep the existing `scipy.stats.gaussian_kde` path as a selectable `_reference` oracle. Add a `kde_backend` parameter to `GhostGkModel.predict_density`; implement a `"vectorized"` kernel that reuses scipy's exact Cholesky linear algebra (`cho_factor`/`cho_solve`, `log_det = 2·Σ log diag`) over each sample's ragged leaf-matched training subset, with train-set chunking for the 537k "full" variant. A model-traveling parity test keeps the vectorized path validated against scipy on whatever model is loaded. Separately, de-`iloc` profile-attributed per-row dict loops (`_elastic_sync` first). No new dependencies, no public API change, no value-scale shift.

**Tech Stack:** Python 3.10, numpy 2.x, scipy 1.15 (`scipy.linalg.cho_factor`/`cho_solve` — already a dependency), pandas 2.x, pytest, pytest-benchmark 5.2 (informational only).

**Spec:** `docs/superpowers/specs/2026-06-01-action-context-hotpath-acceleration-design.md` (§3 Phase 0).

---

## File structure

**Modified:**
- `silly_kicks/tracking/_ghost_gk.py` — factor the scipy KDE out of `predict_density` into `_kde_density_scipy`; add `_kde_density_vectorized`; add `kde_backend` param to `predict_density`; vectorize the per-sample leaf-match into `_leaf_match_weights`.
- `silly_kicks/tracking/_elastic_sync.py` — vectorize `_build_player_ball_distance_lookup`'s dict build; (conditionally) other profile-attributed sites.
- `silly_kicks/tracking/_ball_carrier.py` (Task 0c.1) — `derive_team_in_possession` preserves `ball_carrier_player_id`.
- `silly_kicks/tracking/_das.py` (Tasks 0c.2-0c.3) — `player_in_possession_col` param + resolver + forward; own the offside-warning UX (one-time).
- `silly_kicks/tracking/features.py` (Task 0c.4) — dead-ball guard on the link-restricted subset in `_precompute_das_lookup`.

**Created:**
- `tests/tracking/test_ghost_gk_kde_vectorized.py` — kernel parity (vs scipy), leaf-match isolation, golden master (continuous + discrete split), structural perf guard.
- `tests/tracking/fixtures/ghost_gk_kde_golden.npz` — frozen continuous grids + discrete modes + spreads for the bundled `"default"` model on a seeded feature batch (generated by a committed script, compressed).
- `scripts/profile_ac1_hotpaths.py` — local benchmark + `.pstats` capture harness (no-contention, persist-before-cleanup); produces the Phase-0 measurement report.

(Train-set chunking is exercised in CI via seeded **inline** large-`k` arrays at the kernel level — Task 5 — so no large model fixture is committed.)
- `scripts/gen_ghost_gk_kde_golden.py` — regenerates the golden fixture (documented generation env).
- `tests/tracking/test_elastic_sync_lookup_golden.py` — behaviour-preserving golden check for the de-`iloc` lookup.
- `tests/tracking/test_das_offside.py` (Tasks 0c.2-0c.4) — carrier forwarding value-neutrality + warning UX + dead-ball guard.
- `tests/tracking/test_ball_carrier.py` (Task 0c.1) — extend (create if absent) for the preserved carrier column.

**Conventions to follow (verified in-repo):**
- Grid constants live in `_ghost_gk.py`: `GRID_NX=60`, `GRID_NY=64`, `GRID_RESOLUTION=0.5`, `_GRID_X` (60,), `_GRID_Y` (64,).
- `GhostGkModel` attrs: `_tree_nodes`, `_training_gk_x`, `_training_gk_y`, `_training_leaves` (shape `(n_train, n_trees)`), transient `_regressor_x/_y`.
- `_vectorized_leaf_indices(nodes_list, X) -> (n_samples, n_trees)`.
- Bundled `"default"` model resolved via `_resolve_model("default")` / `GhostGkModel.from_variant("default")`.
- Benchmark tests use the `benchmark` fixture (pytest-benchmark) — see `tests/tracking/test_ghost_gk_benchmark.py`.

---

## Task 1: Extract the scipy KDE into a selectable `_reference` (pure refactor)

Behaviour-preserving extraction so the scipy math becomes a named, reusable oracle and `predict_density` gains a `kde_backend` switch (default `"scipy"` for now — flipped in Task 7).

**Files:**
- Modify: `silly_kicks/tracking/_ghost_gk.py` (the `predict_density` body, ~1114-1203)
- Test: `tests/tracking/test_ghost_gk_kde_vectorized.py`

- [ ] **Step 1: Write the failing test (refactor identity)**

```python
# tests/tracking/test_ghost_gk_kde_vectorized.py
"""Phase 0a — vectorized ghost-GK KDE: kernel parity, leaf-match, golden master."""
from __future__ import annotations

import numpy as np
import pandas as pd
import pytest

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


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


def test_kde_density_scipy_matches_inline_predict(small_model):
    """The extracted _kde_density_scipy reproduces predict_density's grid."""
    model, X = small_model
    densities = model.predict_density(X.iloc[:5], kde_backend="scipy")
    assert len(densities) == 5
    assert densities[0].probabilities.shape == (60, 64)
    assert densities[0].probabilities.sum() == pytest.approx(1.0, abs=1e-9)
```

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py -v`
Expected: FAIL — `ImportError: cannot import name '_kde_density_scipy'` and `predict_density() got an unexpected keyword argument 'kde_backend'`.

- [ ] **Step 3: Extract the scipy KDE + add the `kde_backend` switch**

In `silly_kicks/tracking/_ghost_gk.py`, add a module-level helper (near the other private helpers, e.g. just above `class GhostGkModel`):

```python
def _kde_density_scipy(
    gk_x_w: np.ndarray,
    gk_y_w: np.ndarray,
    w: np.ndarray,
    grid_points: np.ndarray,
) -> np.ndarray:
    """Reference per-sample weighted KDE on the fixed grid (scipy oracle).

    gk_x_w, gk_y_w, w : shape (k,) — the sample's nonzero-weight leaf subset.
    grid_points : shape (2, GRID_NX*GRID_NY).
    Returns the UNnormalized density grid (GRID_NX, GRID_NY); callers normalize.
    """
    from scipy.stats import gaussian_kde

    kde = gaussian_kde(np.vstack([gk_x_w, gk_y_w]), weights=w, bw_method="scott")
    density_vals = kde(grid_points)
    return density_vals.reshape(GRID_NX, GRID_NY)
```

Then change `predict_density`'s signature and inner loop to dispatch. Replace the
signature line:

```python
    def predict_density(self, features: pd.DataFrame) -> list[GhostGkDensity]:
```

with:

```python
    def predict_density(
        self, features: pd.DataFrame, *, kde_backend: str = "scipy"
    ) -> list[GhostGkDensity]:
```

and replace the inner `if len(w) < 2: ... else: try: kde = gaussian_kde(...) ... except`
block (current lines ~1156-1173) with:

```python
            if len(w) < 2:
                probs = np.ones((GRID_NX, GRID_NY)) / (GRID_NX * GRID_NY)
            else:
                try:
                    if kde_backend == "scipy":
                        probs = _kde_density_scipy(gk_x_w, gk_y_w, w, grid_points)
                    elif kde_backend == "vectorized":
                        probs = _kde_density_vectorized(gk_x_w, gk_y_w, w, grid_points)
                    else:
                        msg = f"Unknown kde_backend: {kde_backend!r}"
                        raise ValueError(msg)
                    total = probs.sum()
                    if total > 0:
                        probs = probs / total
                    else:
                        probs = 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)
```

(Remove the now-duplicated `gaussian_kde(...)` import line at module top only if it is
unused elsewhere — keep it; `_kde_density_scipy` imports locally.) Do **not** yet define
`_kde_density_vectorized` (Task 3 adds it); the `"scipy"` default path must work now.

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py -v`
Expected: PASS.

- [ ] **Step 5: Run the existing ghost-GK suite (no regression)**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk.py tests/tracking/test_ghost_gk_integration.py -q`
Expected: PASS (the refactor is identity for the default `"scipy"` path).

- [ ] **Step 6: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk_kde_vectorized.py
git commit -m "refactor(ghost-gk): extract scipy KDE as selectable _reference (kde_backend)"
```

---

## Task 2: Freeze the golden master (continuous grid + discrete mode split)

Lock the bundled `"default"` model's current outputs as fixtures so the vectorized kernel is proven faithful. Continuous (grid/mean/spread) → rtol+atol+NaN-mask; discrete (mode_x/y) → exact argmax index.

**Files:**
- Create: `scripts/gen_ghost_gk_kde_golden.py`
- Create: `tests/tracking/fixtures/ghost_gk_kde_golden.npz`
- Test: `tests/tracking/test_ghost_gk_kde_vectorized.py`

- [ ] **Step 1: Write the golden-generation script**

```python
# scripts/gen_ghost_gk_kde_golden.py
"""Regenerate the ghost-GK KDE golden fixture (Phase 0a).

Generation env: record `python --version`, numpy/scipy versions in the printout.
Run: .venv/Scripts/python.exe scripts/gen_ghost_gk_kde_golden.py
"""
from __future__ import annotations

import sys
from pathlib import Path

import numpy as np
import pandas as pd

from silly_kicks.tracking._ghost_gk import GHOST_GK_FEATURE_NAMES, GhostGkModel

OUT = Path(__file__).parent.parent / "tests/tracking/fixtures/ghost_gk_kde_golden.npz"


def _seeded_features(n: int = 24) -> pd.DataFrame:
    rng = np.random.default_rng(2026)
    X = pd.DataFrame(rng.standard_normal((n, 26)), columns=GHOST_GK_FEATURE_NAMES)
    X["phase"] = rng.integers(0, 3, n).astype(float)
    return X


def main() -> None:
    import scipy

    model = GhostGkModel.from_variant("default")
    X = _seeded_features()
    densities = model.predict_density(X, kde_backend="scipy")
    probs = np.stack([d.probabilities for d in densities])  # (n, 60, 64)
    mode_x = np.array([d.mode_x for d in densities])
    mode_y = np.array([d.mode_y for d in densities])
    mean_x = np.array([d.mean_x for d in densities])
    mean_y = np.array([d.mean_y for d in densities])
    spread = np.array([d.spread for d in densities])
    OUT.parent.mkdir(parents=True, exist_ok=True)
    np.savez_compressed(
        OUT, features=X.values, feature_cols=np.array(X.columns, dtype=object),
        probs=probs, mode_x=mode_x, mode_y=mode_y, mean_x=mean_x, mean_y=mean_y,
        spread=spread,
    )
    print(f"wrote {OUT} ({OUT.stat().st_size/1e6:.2f} MB)")
    print(f"gen-env: py={sys.version.split()[0]} numpy={np.__version__} scipy={scipy.__version__}")


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

- [ ] **Step 2: Generate the fixture**

Run: `.venv/Scripts/python.exe scripts/gen_ghost_gk_kde_golden.py`
Expected: prints `wrote .../ghost_gk_kde_golden.npz (... MB)` and the gen-env line. The
24-sample × 60×64 float64 grid compresses to well under ~1 MB.

- [ ] **Step 3: Write the golden test (split gate) — will pass for scipy now**

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

```python
from pathlib import Path

_GOLDEN = Path(__file__).parent / "fixtures" / "ghost_gk_kde_golden.npz"


@pytest.fixture(scope="module")
def golden():
    d = np.load(_GOLDEN, allow_pickle=True)
    return d


@pytest.fixture(scope="module")
def default_model_features(golden):
    from silly_kicks.tracking._ghost_gk import GhostGkModel

    model = GhostGkModel.from_variant("default")
    cols = [str(c) for c in golden["feature_cols"]]
    X = pd.DataFrame(golden["features"], columns=cols)
    return model, X


@pytest.mark.parametrize("kde_backend", ["scipy", "vectorized"])
def test_golden_continuous(golden, default_model_features, kde_backend):
    """Density grid + mean + spread: rtol/atol + explicit NaN-mask equality.

    NB: this full-path golden gates at rtol=1e-7, looser than the raw-kernel parity
    (1e-9, Task 3). That is intentional, not slack: predict_density adds the grid-sum
    renormalization division + the mode/mean/spread derivations on top of the kernel,
    which amplify floating error by ~2 orders. Do NOT tighten this to 1e-9 — it will flake.
    """
    model, X = default_model_features
    densities = model.predict_density(X, kde_backend=kde_backend)
    probs = np.stack([d.probabilities for d in densities])
    spread = np.array([d.spread for d in densities])
    mean_x = np.array([d.mean_x for d in densities])
    mean_y = np.array([d.mean_y for d in densities])
    # NaN-mask equality FIRST (a silent NaN->0 fill must fail here, not pass equal_nan)
    assert np.array_equal(np.isnan(probs), np.isnan(golden["probs"]))
    # atol floor: per-cell prob scale ~ 1/3840 ~ 2.6e-4; floor well below it
    np.testing.assert_allclose(probs, golden["probs"], rtol=1e-7, atol=1e-12, equal_nan=True)
    np.testing.assert_allclose(spread, golden["spread"], rtol=1e-7, atol=1e-9)
    np.testing.assert_allclose(mean_x, golden["mean_x"], rtol=1e-7, atol=1e-9)
    np.testing.assert_allclose(mean_y, golden["mean_y"], rtol=1e-7, atol=1e-9)


@pytest.mark.parametrize("kde_backend", ["scipy", "vectorized"])
def test_golden_discrete_mode(golden, default_model_features, kde_backend):
    """mode_x/y (argmax): exact grid-cell match (NOT rtol)."""
    model, X = default_model_features
    densities = model.predict_density(X, kde_backend=kde_backend)
    mode_x = np.array([d.mode_x for d in densities])
    mode_y = np.array([d.mode_y for d in densities])
    np.testing.assert_array_equal(mode_x, golden["mode_x"])
    np.testing.assert_array_equal(mode_y, golden["mode_y"])
```

- [ ] **Step 4: Run the scipy-parametrized golden tests (vectorized xfails until Task 4)**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py -v -k "golden and scipy"`
Expected: the `scipy` params PASS. (The `vectorized` params ERROR/FAIL until Task 3-4 — that is expected; do not fix them here.)

- [ ] **Step 5: Commit**

```bash
git add scripts/gen_ghost_gk_kde_golden.py tests/tracking/fixtures/ghost_gk_kde_golden.npz tests/tracking/test_ghost_gk_kde_vectorized.py
git commit -m "test(ghost-gk): freeze KDE golden master (continuous rtol/atol + discrete argmax)"
```

> **Maintenance note (record in the gen script's module docstring):** the golden is frozen
> against scipy 1.15 / numpy 2.x (the pinned versions). A CI bump past those can shift scipy's
> Cholesky/`gaussian_kde` reduction order enough to fail the 1e-7 gate cross-environment —
> regenerate via `scripts/gen_ghost_gk_kde_golden.py` and review the diff when bumping either.

---

## Task 3: Implement the vectorized Cholesky KDE kernel + kernel-level parity

The kernel reuses scipy's exact algebra so it matches `gaussian_kde` within rtol/atol; the parity test is the gate that forces exactness.

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

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

Append:

```python
def _scipy_kde_grid(gk_x_w, gk_y_w, w, grid_points):
    from scipy.stats import gaussian_kde

    kde = gaussian_kde(np.vstack([gk_x_w, gk_y_w]), weights=w, bw_method="scott")
    return kde(grid_points).reshape(60, 64)


@pytest.mark.parametrize("k", [3, 10, 200])
def test_vectorized_kernel_matches_scipy(k):
    """_kde_density_vectorized == scipy gaussian_kde on random weighted points."""
    from silly_kicks.tracking._ghost_gk import _GRID_X, _GRID_Y, _kde_density_vectorized

    rng = np.random.default_rng(k)
    gk_x_w = rng.uniform(0, 30, k)
    gk_y_w = rng.uniform(18, 50, k)
    w = rng.uniform(0.1, 1.0, k)
    w = w / w.sum()
    gxx, gyy = np.meshgrid(_GRID_X, _GRID_Y, indexing="ij")
    grid_points = np.vstack([gxx.ravel(), gyy.ravel()])

    got = _kde_density_vectorized(gk_x_w, gk_y_w, w, grid_points)
    ref = _scipy_kde_grid(gk_x_w, gk_y_w, w, grid_points)
    np.testing.assert_allclose(got, ref, rtol=1e-9, atol=1e-12)
```

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py::test_vectorized_kernel_matches_scipy -v`
Expected: FAIL — `cannot import name '_kde_density_vectorized'`.

- [ ] **Step 3: Implement the vectorized kernel**

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

```python
def _kde_density_vectorized(
    gk_x_w: np.ndarray,
    gk_y_w: np.ndarray,
    w: np.ndarray,
    grid_points: np.ndarray,
    *,
    train_block: int = 1024,
) -> np.ndarray:
    """Vectorized weighted Gaussian KDE on the fixed grid (scipy-faithful).

    Reuses scipy's Scott bandwidth + weighted covariance + Cholesky whitening
    (NOT a matrix inverse) so it matches scipy.stats.gaussian_kde within ~1e-9.
    Streams the training subset in blocks of ``train_block`` to bound memory for
    the 537k "full" variant. Returns the UNnormalized density grid (GRID_NX, GRID_NY).

    Default ``train_block=1024`` is the conservative serverless choice: with
    grid m=3840, the largest transient (2, kb, m) + tdiff + energy is ~150 MB/block,
    safe under the Databricks 1 GB ``applyInPandas`` cap. The serverless venue runs the
    9 MB "default" model whose per-sample nonzero leaf subsets are small (k often <
    1024 → chunking rarely binds), so 1024 costs ~nothing there; the local Task 8
    benchmark can raise it for the in-memory "full" model. See spec §3.1 / §3.5 handoff.
    """
    from scipy.linalg import cho_factor, cho_solve

    w = np.asarray(w, dtype=np.float64)
    w = w / w.sum()
    data = np.vstack([np.asarray(gk_x_w, np.float64), np.asarray(gk_y_w, np.float64)])  # (2, k)
    d = 2
    neff = 1.0 / np.sum(w**2)
    factor = neff ** (-1.0 / (d + 4))
    data_cov = np.atleast_2d(np.cov(data, rowvar=1, bias=False, aweights=w))
    covariance = data_cov * factor**2
    # Cholesky path (matches scipy _kde.py): whitening via cho_solve; lower=True so the
    # factor's diagonal is in np.diag(chol[0]). cho_factor raises np.linalg.LinAlgError on
    # a singular covariance (collinear/identical points) — same as scipy's cholesky — and
    # predict_density's `except np.linalg.LinAlgError` degrades both to the uniform grid.
    chol = cho_factor(covariance, lower=True)
    log_det = 2.0 * np.sum(np.log(np.diag(chol[0])))
    # norm is DERIVED from scipy's normalization 1/((2*pi)^(d/2) * sqrt(det(H))), not tuned:
    #   sqrt(det(H)) = exp(0.5*log_det)  =>  norm = exp(-0.5*(log_det + d*log(2*pi))).
    norm = np.exp(-0.5 * (log_det + d * np.log(2.0 * np.pi)))

    m = grid_points.shape[1]
    out = np.zeros(m, dtype=np.float64)
    k = data.shape[1]
    for start in range(0, k, train_block):
        sl = slice(start, min(start + train_block, k))
        diff = grid_points[:, None, :] - data[:, sl, None]  # (2, kb, m)
        kb = diff.shape[1]
        flat = diff.reshape(d, kb * m)
        tdiff = cho_solve(chol, flat)  # whiten (2, kb*m)
        energy = 0.5 * np.sum(flat * tdiff, axis=0).reshape(kb, m)  # (kb, m)
        out += np.einsum("k,km->m", w[sl], np.exp(-energy))
    out *= norm
    return out.reshape(GRID_NX, GRID_NY)
```

- [ ] **Step 4: Run the parity test (drives exactness)**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py::test_vectorized_kernel_matches_scipy -v`
Expected: PASS for `k=3,10,200`. The `norm` constant is **derived** from scipy's normalization (see the kernel comment), not tuned — the parity test is a backstop against a transcription error, not a license to twiddle the constant until green. (In ghost-GK the grid is renormalized to sum 1, so the constant cancels downstream anyway; match it here for a strict kernel-level oracle.)

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk_kde_vectorized.py
git commit -m "feat(ghost-gk): vectorized scipy-faithful KDE kernel (Cholesky, train-block streamed)"
```

---

## Task 4: Validate the vectorized backend end-to-end (golden + model-traveling parity)

**Files:**
- Test: `tests/tracking/test_ghost_gk_kde_vectorized.py`

- [ ] **Step 1: Run the vectorized golden params (now that the kernel exists)**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py -v -k golden`
Expected: BOTH `scipy` and `vectorized` params PASS (continuous rtol/atol + NaN-mask; discrete exact argmax). If `test_golden_discrete_mode[vectorized]` flakes on a near-tie sample, see Step 2.

- [ ] **Step 2: Write the model-traveling parity test + near-tie quantification**

Append:

```python
def test_model_traveling_parity(default_model_features):
    """vectorized ≈ scipy on whatever model is loaded (auto-revalidates on retrain)."""
    model, X = default_model_features
    sci = model.predict_density(X, kde_backend="scipy")
    vec = model.predict_density(X, kde_backend="vectorized")
    p_sci = np.stack([d.probabilities for d in sci])
    p_vec = np.stack([d.probabilities for d in vec])
    np.testing.assert_allclose(p_vec, p_sci, rtol=1e-7, atol=1e-12)
    # Discrete mode must agree exactly OR be a quantified near-tie (<= 1 cell apart)
    for a, b in zip(sci, vec):
        dx = abs(a.mode_x - b.mode_x)
        dy = abs(a.mode_y - b.mode_y)
        assert dx <= 0.5 + 1e-9 and dy <= 0.5 + 1e-9, (a.mode_x, a.mode_y, b.mode_x, b.mode_y)


# --- Degenerate fallback paths (the inputs most likely to diverge: real tracking hits these) ---

def test_kernel_singular_covariance_raises_linalgerror():
    """Collinear/identical points -> singular cov -> LinAlgError (BOTH scipy & vectorized).

    This is the path predict_density catches and degrades to the uniform grid; if scipy
    raised but the vectorized kernel did not (or vice-versa) they would diverge silently.
    """
    from silly_kicks.tracking._ghost_gk import _GRID_X, _GRID_Y, _kde_density_vectorized

    gxx, gyy = np.meshgrid(_GRID_X, _GRID_Y, indexing="ij")
    grid_points = np.vstack([gxx.ravel(), gyy.ravel()])
    # Identical points -> zero covariance -> singular.
    gk_x_w = np.array([5.0, 5.0, 5.0]); gk_y_w = np.array([34.0, 34.0, 34.0])
    w = np.array([1 / 3, 1 / 3, 1 / 3])
    with pytest.raises(np.linalg.LinAlgError):
        _scipy_kde_grid(gk_x_w, gk_y_w, w, grid_points)
    with pytest.raises(np.linalg.LinAlgError):
        _kde_density_vectorized(gk_x_w, gk_y_w, w, grid_points)


def _small_dispatch_model():
    """Small model for end-to-end backend-parity (NOT a <2-weight trigger).

    Shallow/large-leaf trees mean every query leaf-matches many training points, so this
    does NOT exercise the len(w)<2 branch — that branch is tested directly in Task 6
    (it is backend-agnostic: it returns the uniform grid before the kde_backend dispatch).
    The singular-covariance fallback is covered by test_kernel_singular_covariance_raises_linalgerror.
    """
    from silly_kicks.tracking._ghost_gk import GHOST_GK_FEATURE_NAMES, GhostGkModel

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


@pytest.mark.parametrize("backend", ["scipy", "vectorized"])
def test_predict_density_backend_parity_small_model(backend):
    """predict_density end-to-end: vectorized == scipy (NaN-mask + values) on a small model."""
    model, X = _small_dispatch_model()
    sci = model.predict_density(X, kde_backend="scipy")
    other = model.predict_density(X, kde_backend=backend)
    p_sci = np.stack([d.probabilities for d in sci])
    p_other = np.stack([d.probabilities for d in other])
    assert np.array_equal(np.isnan(p_sci), np.isnan(p_other))
    np.testing.assert_allclose(p_other, p_sci, rtol=1e-7, atol=1e-12)
```

(If Step 1's exact-argmax assertion fails only on a genuine near-tie, relax
`test_golden_discrete_mode` to a `±1-cell` tolerance — `abs(mode - golden) <= GRID_RESOLUTION + 1e-9`
— and add a comment documenting the tied-cell sample index. Prefer exact match; only
relax with the documented reason.)

- [ ] **Step 3: Run**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py -v`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add tests/tracking/test_ghost_gk_kde_vectorized.py
git commit -m "test(ghost-gk): model-traveling scipy parity + near-tie mode quantification"
```

---

## Task 5: Exercise train-set chunking in CI (synthetic large-train fixture)

The 537k "full" variant is the one that most stresses memory; the in-repo `"default"` is small. A synthetic large-train model forces the `train_block` streaming path under CI.

**Files:**
- Create: `tests/tracking/fixtures/ghost_gk_large_train.npz` (generated by the script below, committed; keep < ~2 MB)
- Modify: `scripts/gen_ghost_gk_kde_golden.py` (add a `--large` mode) OR inline in the test via a seeded synthetic model
- Test: `tests/tracking/test_ghost_gk_kde_vectorized.py`

- [ ] **Step 1: Write the failing chunking test (synthetic large subset)**

Append:

```python
def test_vectorized_chunking_invariant():
    """Result is independent of train_block (streaming correctness)."""
    from silly_kicks.tracking._ghost_gk import _GRID_X, _GRID_Y, _kde_density_vectorized

    rng = np.random.default_rng(99)
    k = 12000  # the test below hard-codes train_block=4096 -> 3 blocks (default is 1024)
    gk_x_w = rng.uniform(0, 30, k)
    gk_y_w = rng.uniform(18, 50, k)
    w = rng.uniform(0.1, 1.0, k)
    w = w / w.sum()
    gxx, gyy = np.meshgrid(_GRID_X, _GRID_Y, indexing="ij")
    grid_points = np.vstack([gxx.ravel(), gyy.ravel()])

    full = _kde_density_vectorized(gk_x_w, gk_y_w, w, grid_points, train_block=k)
    chunked = _kde_density_vectorized(gk_x_w, gk_y_w, w, grid_points, train_block=4096)
    np.testing.assert_allclose(chunked, full, rtol=1e-12, atol=1e-15)
```

- [ ] **Step 2: Run to verify pass (chunking already implemented in Task 3)**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py::test_vectorized_chunking_invariant -v`
Expected: PASS (the loop accumulates identically regardless of block size). If it FAILS, the block accumulation has a bug — fix the `out +=` accumulation in `_kde_density_vectorized`.

- [ ] **Step 3: Add a STRUCTURAL bounded-memory guard (cho_solve-call spy)**

A `tracemalloc`/RSS assertion is unreliable here — numpy's raw buffers under-report in
tracemalloc, and shared-runner RSS is flaky (this repo's learned lesson). Instead spy on
`scipy.linalg.cho_solve` (called once per train-block, on a `(d, kb*m)` array): an unchunked
implementation calls it **once** with `kb=k`; a correctly chunked one calls it `ceil(k/block)`
times, each with `shape[1] <= block*m`. This fails loudly on a regression to unchunked.

```python
def test_vectorized_is_chunked_structural(monkeypatch):
    """Structural bound: cho_solve runs per-block, never on the full k*m at once."""
    import scipy.linalg as sla
    from silly_kicks.tracking import _ghost_gk as g
    from silly_kicks.tracking._ghost_gk import _GRID_X, _GRID_Y, _kde_density_vectorized

    m = 60 * 64
    block = 1024
    k = 5000  # -> ceil(5000/1024) = 5 blocks
    calls = []
    real = sla.cho_solve

    def _spy(c_and_lower, b, **kw):
        calls.append(b.shape)
        return real(c_and_lower, b, **kw)

    # Patch the name the kernel imports (it does `from scipy.linalg import cho_solve`).
    monkeypatch.setattr(g, "cho_solve", _spy, raising=False)
    # If the kernel imports inside the function, patch the source module instead:
    monkeypatch.setattr(sla, "cho_solve", _spy)

    rng = np.random.default_rng(5)
    gk_x_w = rng.uniform(0, 30, k); gk_y_w = rng.uniform(18, 50, k)
    w = np.full(k, 1.0 / k)
    gxx, gyy = np.meshgrid(_GRID_X, _GRID_Y, indexing="ij")
    grid_points = np.vstack([gxx.ravel(), gyy.ravel()])

    out = _kde_density_vectorized(gk_x_w, gk_y_w, w, grid_points, train_block=block)
    assert out.shape == (60, 64)
    assert len(calls) == int(np.ceil(k / block)), f"expected per-block cho_solve, got {len(calls)} calls"
    assert all(shp[1] <= block * m for shp in calls), "a cho_solve saw more than one block of work"
```

(The kernel does `from scipy.linalg import cho_factor, cho_solve` at function scope — so
`monkeypatch.setattr(scipy.linalg, "cho_solve", ...)` is the binding that takes effect; the
`g.cho_solve` patch is a harmless belt-and-suspenders. Confirm during implementation which
binding the spy must target and keep only that one.)

- [ ] **Step 4: Run + commit**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py -v`
Expected: PASS.

```bash
git add tests/tracking/test_ghost_gk_kde_vectorized.py
git commit -m "test(ghost-gk): chunking invariance + bounded peak-memory for vectorized KDE"
```

---

## Task 6: Vectorize the per-sample leaf-match (isolation-tested)

The per-sample `self._training_leaves == query_leaves[i]` is heavy on the 537k variant. Extract + block-stream it; unit-test against the loop.

**Files:**
- Modify: `silly_kicks/tracking/_ghost_gk.py` (`predict_density` loop body, lines ~1146-1154)
- Test: `tests/tracking/test_ghost_gk_kde_vectorized.py`

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

Append:

```python
def test_leaf_match_weights_matches_loop():
    """Vectorized leaf-match weights == per-sample loop weights."""
    from silly_kicks.tracking._ghost_gk import _leaf_match_weights

    rng = np.random.default_rng(3)
    n_train, n_trees, n_query = 2000, 50, 8
    training_leaves = rng.integers(0, 30, (n_train, n_trees))
    query_leaves = rng.integers(0, 30, (n_query, n_trees))

    got = _leaf_match_weights(training_leaves, query_leaves)  # (n_query, n_train)
    for i in range(n_query):
        ref = (training_leaves == query_leaves[i]).sum(axis=1).astype(np.float64) / n_trees
        np.testing.assert_array_equal(got[i], ref)


def test_predict_density_lt2_weight_returns_uniform(monkeypatch, small_model):
    """The len(w)<2 branch returns the uniform grid, identically for both backends.

    Deterministically force a single nonzero-weight sample by patching _leaf_match_weights
    (the seam predict_density now uses) — large-leaf models never naturally yield <2.
    The branch is BEFORE the kde_backend dispatch, so scipy and vectorized must both return
    the exact uniform grid 1/(GRID_NX*GRID_NY).
    """
    from silly_kicks.tracking import _ghost_gk as g

    model, X = small_model
    real = g._leaf_match_weights

    def _fake(training_leaves, query_leaves, **kw):
        wts = real(training_leaves, query_leaves, **kw)
        wts[0, :] = 0.0
        wts[0, 0] = 0.5  # exactly one nonzero weight -> len(w) == 1 -> <2 branch
        return wts

    monkeypatch.setattr(g, "_leaf_match_weights", _fake)
    uniform = 1.0 / (60 * 64)
    for backend in ("scipy", "vectorized"):
        d = model.predict_density(X.iloc[:1], kde_backend=backend)[0]
        np.testing.assert_allclose(d.probabilities, uniform, rtol=0, atol=1e-15)
```

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py::test_leaf_match_weights_matches_loop -v`
Expected: FAIL — `cannot import name '_leaf_match_weights'`.

- [ ] **Step 3: Implement `_leaf_match_weights` and wire it in**

Add near `_vectorized_leaf_indices`:

```python
def _leaf_match_weights(
    training_leaves: np.ndarray,
    query_leaves: np.ndarray,
    *,
    query_block: int = 64,
) -> np.ndarray:
    """Per-query leaf-match weight = fraction of trees with matching leaf.

    training_leaves : (n_train, n_trees); query_leaves : (n_query, n_trees).
    Returns (n_query, n_train) weights. Streams queries in blocks to bound the
    (qb, n_train, n_trees) broadcast for the 537k-train variant.
    """
    n_train, n_trees = training_leaves.shape
    n_query = query_leaves.shape[0]
    out = np.empty((n_query, n_train), dtype=np.float64)
    for start in range(0, n_query, query_block):
        sl = slice(start, min(start + query_block, n_query))
        eq = training_leaves[None, :, :] == query_leaves[sl][:, None, :]  # (qb, n_train, n_trees)
        out[sl] = eq.sum(axis=2).astype(np.float64) / n_trees
    return out
```

In `predict_density`, precompute all weights once before the per-sample loop and index
per sample. Replace the per-sample lines
`matches = self._training_leaves == query_leaves[i]` / `weights = matches.sum(...) / n_trees`
with a precomputed `all_weights = _leaf_match_weights(self._training_leaves, query_leaves)`
above the loop, and inside the loop use `weights = all_weights[i]`.

- [ ] **Step 4: Run isolation + full suite**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py tests/tracking/test_ghost_gk.py -q`
Expected: PASS (golden master still green — the weights are identical, just vectorized).

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk_kde_vectorized.py
git commit -m "perf(ghost-gk): vectorize per-sample leaf-match (block-streamed, isolation-tested)"
```

---

## Task 7: Flip the default to vectorized + structural perf guard

**Files:**
- Modify: `silly_kicks/tracking/_ghost_gk.py` (`predict_density` default)
- Test: `tests/tracking/test_ghost_gk_kde_vectorized.py`

- [ ] **Step 1: Write the structural perf guard (no wall-clock)**

Append (uses monkeypatch to spy that the default path constructs **zero** `gaussian_kde`):

```python
def test_default_backend_makes_no_scipy_kde(default_model_features, monkeypatch):
    """Default predict_density must NOT construct scipy.stats.gaussian_kde."""
    import scipy.stats as ss

    calls = {"n": 0}
    real = ss.gaussian_kde

    def _spy(*a, **k):
        calls["n"] += 1
        return real(*a, **k)

    monkeypatch.setattr(ss, "gaussian_kde", _spy)
    model, X = default_model_features
    _ = model.predict_density(X)  # default backend
    assert calls["n"] == 0, "default path still constructs scipy.gaussian_kde"
```

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py::test_default_backend_makes_no_scipy_kde -v`
Expected: FAIL (default is still `"scipy"`).

- [ ] **Step 3: Flip the default**

In `predict_density`, change the default:

```python
    def predict_density(
        self, features: pd.DataFrame, *, kde_backend: str = "vectorized"
    ) -> list[GhostGkDensity]:
```

- [ ] **Step 4: Run guard + full golden + integration**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ghost_gk_kde_vectorized.py tests/tracking/test_ghost_gk.py tests/tracking/test_ghost_gk_integration.py tests/tracking/test_ghost_gk_frame_restriction.py -q`
Expected: PASS. The golden master proves `compute_ghost_gk`/`add_ghost_gk` outputs unchanged.

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ghost_gk.py tests/tracking/test_ghost_gk_kde_vectorized.py
git commit -m "perf(ghost-gk): default predict_density to vectorized KDE; structural no-scipy guard"
```

---

## Task 8: Local benchmark + measurement report (no-contention)

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

- [ ] **Step 1: Write the benchmark/profile harness**

```python
# scripts/profile_ac1_hotpaths.py
"""Phase-0 local measurement: ghost-GK predict_density scipy vs vectorized + cProfile.

Capture with NO contending processes (the calibrate_* sweep shares this box).
Run: .venv/Scripts/python.exe scripts/profile_ac1_hotpaths.py > _phase0_report.txt 2>&1
Persist _phase0_report.txt BEFORE any cleanup.
"""
from __future__ import annotations

import cProfile
import io
import pstats
import time

import numpy as np
import pandas as pd

from silly_kicks.tracking._ghost_gk import GHOST_GK_FEATURE_NAMES, GhostGkModel


def _features(n: int) -> pd.DataFrame:
    rng = np.random.default_rng(2026)
    X = pd.DataFrame(rng.standard_normal((n, 26)), columns=GHOST_GK_FEATURE_NAMES)
    X["phase"] = rng.integers(0, 3, n).astype(float)
    return X


def main() -> None:
    model = GhostGkModel.from_variant("default")
    X = _features(300)
    # warm
    model.predict_density(X.iloc[:3], kde_backend="scipy")
    model.predict_density(X.iloc[:3], kde_backend="vectorized")
    for backend in ("scipy", "vectorized"):
        t0 = time.perf_counter()
        model.predict_density(X, kde_backend=backend)
        dt = time.perf_counter() - t0
        print(f"[bench] {backend:10s}: {dt:.3f}s over {len(X)} samples "
              f"({1000*dt/len(X):.2f} ms/sample)", flush=True)
    # cProfile the vectorized path for the call-site attribution (Task 9 input)
    pr = cProfile.Profile(); pr.enable()
    model.predict_density(X, kde_backend="vectorized")
    pr.disable()
    s = io.StringIO(); pstats.Stats(pr, stream=s).sort_stats("cumulative").print_stats(20)
    print(s.getvalue(), flush=True)


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

- [ ] **Step 2: Run the benchmark (no contention)**

Run: `.venv/Scripts/python.exe scripts/profile_ac1_hotpaths.py`
Expected: prints scipy vs vectorized ms/sample. Record the speedup in the commit message
and in the spec's §3.4 measurement note. (Local default-variant signal; the lakehouse
confirms on serverless + IDSSE.)

- [ ] **Step 3: Add an informational pytest-benchmark (nightly/local, not a CI gate)**

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

```python
def test_vectorized_vs_scipy_kde(trained_model, benchmark):
    """Informational: vectorized predict_density throughput (no hard threshold)."""
    model, X = trained_model
    batch = X.iloc[:10]
    result = benchmark(lambda: model.predict_density(batch, kde_backend="vectorized"))
    assert len(result) == 10
```

- [ ] **Step 4: Commit**

```bash
git add scripts/profile_ac1_hotpaths.py tests/tracking/test_ghost_gk_benchmark.py
git commit -m "bench(ghost-gk): Phase-0 local measurement harness + informational KDE benchmark"
```

---

## Task 9: Attribute the ~14% pandas-scalar access to call sites (measurement, gates Task 10)

**Files:**
- (none modified — produces an attribution note)

- [ ] **Step 1: Capture a chain `.pstats` with caller info**

Build a representative frames+actions batch (reuse `tests/tracking/_provider_inputs.py`
`load_provider_frames("skillcorner")` + `synthesize_actions`), run the relevant `add_*`
stages under cProfile, and print callers of `pandas...._ixs` /
`frame.__getitem__`. Add to `scripts/profile_ac1_hotpaths.py` a `--attribute` mode:

```python
    # invoked as: python scripts/profile_ac1_hotpaths.py --attribute
    # cProfile add_ghost_gk + add_elastic_sync on a real provider batch,
    # then: ps.print_callers("_ixs"); ps.print_callers("__getitem__")
```

- [ ] **Step 2: Run and record attribution**

Run: `.venv/Scripts/python.exe scripts/profile_ac1_hotpaths.py --attribute`
Expected: a ranked list of call sites responsible for the `_ixs`/`__getitem__` calls.
**Record the attributed sites** in the commit message / a short note in the spec §3.2.
Task 10 vectorizes ONLY these attributed sites (no speculative sweep — YAGNI).

- [ ] **Step 3: Commit the attribution tooling**

```bash
git add scripts/profile_ac1_hotpaths.py
git commit -m "bench: attribute pandas-scalar (_ixs) hot call sites for Phase 0b"
```

---

## Task 10: De-`iloc` `_build_player_ball_distance_lookup` (golden-checked)

**Files:**
- Modify: `silly_kicks/tracking/_elastic_sync.py:178-187`
- Test: `tests/tracking/test_elastic_sync_lookup_golden.py`

- [ ] **Step 1: Write the behaviour-preserving golden test**

```python
# tests/tracking/test_elastic_sync_lookup_golden.py
"""Behaviour-preserving check for the de-iloc'd distance lookup (Phase 0b)."""
from __future__ import annotations

import numpy as np
import pandas as pd

from silly_kicks.tracking._elastic_sync import _build_player_ball_distance_lookup


def _frames() -> pd.DataFrame:
    rng = np.random.default_rng(11)
    rows = []
    for fid in range(40):
        rows.append({"game_id": 1, "period_id": 1, "frame_id": fid,
                     "player_id": None, "team_id": None, "is_ball": True,
                     "x": 50.0 + rng.normal(), "y": 34.0 + rng.normal()})
        for pid in range(6):
            rows.append({"game_id": 1, "period_id": 1, "frame_id": fid,
                         "player_id": f"p{pid}", "team_id": 1 + pid % 2, "is_ball": False,
                         "x": rng.uniform(0, 105), "y": rng.uniform(0, 68)})
    return pd.DataFrame(rows)


def _expected_lookup(frames: pd.DataFrame) -> dict:
    """Independent oracle: the intended key/value contract, built without .iloc."""
    ball = frames[frames["is_ball"]].drop_duplicates(["game_id", "period_id", "frame_id"])
    bpos = {(r.game_id, r.period_id, r.frame_id): (r.x, r.y) for r in ball.itertuples(index=False)}
    out: dict = {}
    for r in frames[~frames["is_ball"]].itertuples(index=False):
        bx, by = bpos.get((r.game_id, r.period_id, r.frame_id), (np.nan, np.nan))
        d = float("inf") if (np.isnan(bx) or np.isnan(by)) else float(np.hypot(r.x - bx, r.y - by))
        out[(r.game_id, r.period_id, int(r.frame_id), str(r.player_id))] = d
    return out


def test_lookup_matches_oracle_and_key_dtypes():
    frames = _frames()
    lookup = _build_player_ball_distance_lookup(frames)
    assert len(lookup) == 240  # 40 frames * 6 players
    # Behaviour-preservation: dict equality (keys compare by hash/eq, values by ==),
    # robust to np.int64-vs-int key-type subtleties.
    assert lookup == _expected_lookup(frames)
    # Complete key-dtype contract (all four elements):
    k = next(iter(lookup))
    assert isinstance(k[0], (int, np.integer))  # game_id
    assert isinstance(k[1], (int, np.integer))  # period_id
    assert isinstance(k[2], int)                # frame_id -> Python int (int(...))
    assert isinstance(k[3], str)                # player_id -> Python str (str(...))
    assert all(isinstance(v, float) for v in lookup.values())
```

- [ ] **Step 2: Run to capture current behaviour (PASS on the loop impl)**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_elastic_sync_lookup_golden.py -v`
Expected: PASS (validates the contract the vectorization must preserve).

- [ ] **Step 3: Vectorize the dict build**

Replace `_elastic_sync.py:178-187` (the `for i in range(len(dist)): ... lookup[key] = ...` loop) with:

```python
    keys = list(
        zip(
            merged["game_id"].to_numpy(),             # np.int64 (matches the .iloc loop)
            merged["period_id"].to_numpy(),           # np.int64
            merged["frame_id"].astype(int).tolist(),  # Python int (matches int(...)); NOT to_numpy()
            merged["player_id"].astype(str).tolist(), # Python str (matches str(...)); NOT to_numpy()
        )
    )
    lookup: dict[tuple, float] = dict(zip(keys, dist.astype(float).tolist()))
    return lookup
```

(Delete the now-unused `lookup: dict ... = {}` initialization above the old loop.)

- [ ] **Step 4: Run golden + elastic-sync suite**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_elastic_sync_lookup_golden.py tests/tracking/test_elastic_sync.py -q`
Expected: PASS — identical lookup values + key dtypes.

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_elastic_sync.py tests/tracking/test_elastic_sync_lookup_golden.py
git commit -m "perf(elastic-sync): de-iloc distance-lookup dict build (behaviour-preserving)"
```

---

## Task 11: De-`iloc` remaining profile-attributed sites (conditional on Task 9)

**Only** for sites Task 9 attributed as hot. For EACH such site:

- [ ] **Step 1:** Write a behaviour-preserving golden test for that function's output (mirror Task 10 Step 1 against the specific function).
- [ ] **Step 2:** Run it on the current loop implementation (PASS — captures contract).
- [ ] **Step 3:** Vectorize the `.iloc[i]`/`iterrows` site (mirror Task 10 Step 3 pattern: extract columns with `.to_numpy()`, build outputs vectorially, preserve exact dtypes).
- [ ] **Step 4:** Run the golden + the stage's existing suite — Expected PASS.
- [ ] **Step 5:** Commit: `git commit -m "perf(<stage>): de-iloc <function> (behaviour-preserving)"`

If Task 9 attributes the ~14% **entirely** to `_build_player_ball_distance_lookup` (Task 10),
**skip this task** — do not vectorize cold `.iloc` sites (YAGNI; adds regression risk for no
measured benefit). Record "no further attributed sites" in the final report.

---

## Task group 0c — DAS offside hygiene + dead-ball guard (immediate win)

Spec §3.5. DAS-scoped, independent of the ghost-GK work; golden-mastered value-neutral (A/B
showed zero AS/DAS change). Best-practice wiring: carrier preserved at its source +
explicit-default forwarding + silly-kicks owns the warning UX.

### Task 0c.1: `derive_team_in_possession` preserves `ball_carrier_player_id`

**Files:**
- Modify: `silly_kicks/tracking/_ball_carrier.py:457-460`
- Test: `tests/tracking/test_ball_carrier.py` (extend; create if absent)

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

```python
# add to tests/tracking/test_ball_carrier.py
import numpy as np
import pandas as pd
from silly_kicks.tracking import derive_team_in_possession, infer_ball_carrier


def _tiny_frames() -> pd.DataFrame:
    rows = []
    for fid in range(6):
        rows.append({"game_id": 1, "period_id": 1, "frame_id": fid, "time_seconds": fid / 25,
                     "player_id": None, "team_id": None, "is_ball": True,
                     "is_goalkeeper": False, "x": 50.0 + fid, "y": 34.0})
        for pid, tid in [(10, 1), (20, 2)]:
            rows.append({"game_id": 1, "period_id": 1, "frame_id": fid, "time_seconds": fid / 25,
                         "player_id": pid, "team_id": tid, "is_ball": False,
                         "is_goalkeeper": False, "x": 50.0 + fid + tid, "y": 34.0})
    return pd.DataFrame(rows)


def test_derive_team_in_possession_preserves_carrier_player_id():
    frames = _tiny_frames()
    carrier = infer_ball_carrier(frames)
    out = derive_team_in_possession(frames, carrier)
    assert "team_in_possession" in out.columns
    assert "ball_carrier_player_id" in out.columns  # NEW: carrier id preserved
    # values match the carrier df per (game_id, period_id, frame_id)
    merged = out.merge(
        carrier[["game_id", "period_id", "frame_id", "ball_carrier_player_id"]],
        on=["game_id", "period_id", "frame_id"], suffixes=("", "_ref"),
    )
    assert (merged["ball_carrier_player_id"].fillna(-1)
            == merged["ball_carrier_player_id_ref"].fillna(-1)).all()
```

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ball_carrier.py::test_derive_team_in_possession_preserves_carrier_player_id -v`
Expected: FAIL — `assert "ball_carrier_player_id" in out.columns` (column dropped today).

- [ ] **Step 3: Preserve the carrier id in the merge**

In `silly_kicks/tracking/_ball_carrier.py`, replace lines 457-460:

```python
    merge_cols = ["game_id", "period_id", "frame_id"]
    carrier_slim = carrier[[*merge_cols, "ball_carrier_team_id", "ball_carrier_player_id"]].copy()
    carrier_slim = carrier_slim.rename(columns={"ball_carrier_team_id": "team_in_possession"})
    return frames.merge(carrier_slim, on=merge_cols, how="left")
```

Update the function's docstring `Returns` section to note both `team_in_possession` AND
`ball_carrier_player_id` are added.

- [ ] **Step 4: Run test + existing carrier/DAS suites**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_ball_carrier.py tests/tracking/test_das.py -q`
Expected: PASS (additive column; no existing behavior changes).

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_ball_carrier.py tests/tracking/test_ball_carrier.py
git commit -m "feat(tracking): derive_team_in_possession preserves ball_carrier_player_id"
```

### Task 0c.2: Forward `player_in_possession_col` from `get_das`/`get_individual_das`

**Files:**
- Modify: `silly_kicks/tracking/_das.py`
- Test: `tests/tracking/test_das_offside.py` (create)

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

```python
# tests/tracking/test_das_offside.py
"""Phase 0c — DAS carrier forwarding: value-neutral + warning silenced + validation."""
from __future__ import annotations

import warnings

import numpy as np
import pandas as pd
import pytest

pytest.importorskip("accessible_space")

from silly_kicks.tracking._das import get_das


def _frames_with_carrier() -> pd.DataFrame:
    """4 players + ball, possession=Home, carrier=10 placed clearly onside."""
    rows = []
    rng = np.random.default_rng(0)
    for fid in range(5):
        for pid, tid, x in [(10, "Home", 30.0), (11, "Home", 35.0), (20, "Away", 70.0), (21, "Away", 75.0)]:
            rows.append({"game_id": 1, "period_id": 1, "frame_id": fid, "player_id": pid,
                         "team_id": tid, "x": x + rng.normal(0, 0.5), "y": 34.0 + rng.normal(0, 1),
                         "vx": 0.0, "vy": 0.0, "is_ball": False, "team_in_possession": "Home",
                         "ball_carrier_player_id": 10})
        rows.append({"game_id": 1, "period_id": 1, "frame_id": fid, "player_id": "ball",
                     "team_id": None, "x": 30.0, "y": 34.0, "vx": 0.0, "vy": 0.0,
                     "is_ball": True, "team_in_possession": "Home", "ball_carrier_player_id": 10})
    return pd.DataFrame(rows)


def test_forwarding_is_value_neutral_and_silences_warning():
    frames = _frames_with_carrier()
    with warnings.catch_warnings(record=True) as w:
        warnings.simplefilter("always")
        r_on = get_das(frames, player_in_possession_col="ball_carrier_player_id")
    assert not any("player_in_possession_col" in str(x.message) for x in w), "library offside warning not silenced"
    r_off = get_das(frames, player_in_possession_col=None)
    np.testing.assert_allclose(r_on["AS"].to_numpy(), r_off["AS"].to_numpy(), rtol=1e-12, atol=1e-12, equal_nan=True)
    np.testing.assert_allclose(r_on["DAS"].to_numpy(), r_off["DAS"].to_numpy(), rtol=1e-12, atol=1e-12, equal_nan=True)


def test_explicit_missing_carrier_col_raises():
    frames = _frames_with_carrier().drop(columns=["ball_carrier_player_id"])
    with pytest.raises(ValueError, match="not found"):
        get_das(frames, player_in_possession_col="nope_col")


def test_default_missing_carrier_degrades_gracefully():
    frames = _frames_with_carrier().drop(columns=["ball_carrier_player_id"])
    result = get_das(frames)  # default name absent -> degrade, no crash
    assert "DAS" in result.columns and "AS" in result.columns
```

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_das_offside.py -v`
Expected: FAIL — `get_das() got an unexpected keyword argument 'player_in_possession_col'`.

- [ ] **Step 3: Add the param + resolver + forward it**

In `silly_kicks/tracking/_das.py`, add near the top (after `_COLUMN_MAP`):

```python
_DEFAULT_PLAYER_IN_POSSESSION_COL = "ball_carrier_player_id"


def _resolve_player_in_possession_col(
    frames: pd.DataFrame, player_in_possession_col: str | None
) -> str | None:
    """Resolve the carrier column to forward to accessible-space.

    - None  -> caller opted out; do not forward.
    - present on frames -> forward it (correct offside: passer excluded).
    - explicitly named (non-default) but missing -> ValueError (caller contract).
    - default name missing (e.g. old frames) -> None (degrade to prior behavior).
    """
    if player_in_possession_col is None:
        return None
    if player_in_possession_col in frames.columns:
        return player_in_possession_col
    if player_in_possession_col != _DEFAULT_PLAYER_IN_POSSESSION_COL:
        raise ValueError(
            f"player_in_possession_col={player_in_possession_col!r} not found in frames columns"
        )
    return None
```

Change `get_das`'s signature:

```python
def get_das(
    frames: pd.DataFrame,
    *,
    use_progress_bar: bool = False,
    player_in_possession_col: str | None = _DEFAULT_PLAYER_IN_POSSESSION_COL,
    **kwargs,
) -> pd.DataFrame:
```

In `get_das`, after `prepared = _prepare_frames(frames)` add:

```python
    ppc = _resolve_player_in_possession_col(frames, player_in_possession_col)
```

and add `player_in_possession_col=ppc,` to the `asmod.get_dangerous_accessible_space(...)`
call argument list (alongside `infer_attacking_direction=True`).

Apply the **same** three edits to `get_individual_das` (add the param to its signature with the
same default; compute `ppc` after `prepared = _prepare_frames(frames)`; pass
`player_in_possession_col=ppc` into `asmod.get_individual_dangerous_accessible_space(...)`).

- [ ] **Step 4: Run the new tests + DAS invariants**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_das_offside.py tests/invariants/test_das_invariants.py -v`
Expected: PASS (value-neutral; warning silenced; validation correct).

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_das.py tests/tracking/test_das_offside.py
git commit -m "feat(das): forward player_in_possession_col (carrier) to accessible-space"
```

### Task 0c.3: silly-kicks owns the offside-warning UX (one-time, not per-call)

**Files:**
- Modify: `silly_kicks/tracking/_das.py`
- Test: `tests/tracking/test_das_offside.py`

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

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

```python
def test_no_carrier_emits_one_time_silly_kicks_warning(monkeypatch):
    import silly_kicks.tracking._das as das_mod

    monkeypatch.setattr(das_mod, "_OFFSIDE_WARNED", False)
    frames = _frames_with_carrier().drop(columns=["ball_carrier_player_id"])
    with warnings.catch_warnings(record=True) as w:
        warnings.simplefilter("always")
        get_das(frames)  # default name absent -> ppc None
        get_das(frames)  # second call must NOT re-warn (one-time)
    sk = [x for x in w if "no ball-carrier column" in str(x.message)]
    lib = [x for x in w if "player_in_possession_col should be set" in str(x.message)]
    assert len(sk) == 1, f"expected exactly one silly-kicks warning, got {len(sk)}"
    assert len(lib) == 0, "library per-call offside warning must be suppressed"
```

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_das_offside.py::test_no_carrier_emits_one_time_silly_kicks_warning -v`
Expected: FAIL — no `_OFFSIDE_WARNED` attribute / library warning still emitted.

- [ ] **Step 3: Add the one-time guard + suppress the library warning**

In `silly_kicks/tracking/_das.py`, add near `_DEFAULT_PLAYER_IN_POSSESSION_COL`:

```python
_OFFSIDE_WARNED = False


def _warn_no_carrier_once() -> None:
    """Emit silly-kicks' own offside-no-carrier guidance once per process (stacklevel=2)."""
    global _OFFSIDE_WARNED
    if not _OFFSIDE_WARNED:
        _OFFSIDE_WARNED = True
        warnings.warn(
            "DAS respect_offside is on but no ball-carrier column was available to exclude "
            "the passer from the offside mask. Pass player_in_possession_col, or run "
            "derive_team_in_possession (which now preserves ball_carrier_player_id). "
            "Proceeding without passer exclusion.",
            UserWarning,
            stacklevel=2,
        )
```

Wrap **both** library calls (`get_dangerous_accessible_space` in `get_das`,
`get_individual_dangerous_accessible_space` in `get_individual_das`) to suppress the per-call
library offside warning, and emit the one-time silly-kicks message when no carrier was
forwarded. Replace the bare `ret = asmod.get_dangerous_accessible_space(...)` with:

```python
    with warnings.catch_warnings():
        warnings.filterwarnings(
            "ignore", message=".*player_in_possession_col.*", category=UserWarning
        )
        ret = asmod.get_dangerous_accessible_space(
            prepared,
            ball_player_id="ball",
            x_pitch_min=_X_PITCH_MIN, x_pitch_max=_X_PITCH_MAX,
            y_pitch_min=_Y_PITCH_MIN, y_pitch_max=_Y_PITCH_MAX,
            infer_attacking_direction=True,
            player_in_possession_col=ppc,
            use_progress_bar=use_progress_bar,
            **_COLUMN_MAP,
            **kwargs,
        )
    if ppc is None:
        _warn_no_carrier_once()
```

(Apply the analogous `catch_warnings` wrap + `if ppc is None: _warn_no_carrier_once()` to
`get_individual_das`.)

- [ ] **Step 4: Run**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_das_offside.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/_das.py tests/tracking/test_das_offside.py
git commit -m "feat(das): own offside-warning UX (suppress per-call lib warn; one-time guidance)"
```

### Task 0c.4: Dead-ball guard on the link-restricted subset

**Files:**
- Modify: `silly_kicks/tracking/features.py` (`_precompute_das_lookup`, ~1991-1998)
- Test: `tests/tracking/test_das_offside.py`

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

Append:

```python
def test_all_deadball_linked_subset_degrades_with_clear_message():
    from silly_kicks.tracking.features import add_das

    frames = _frames_with_carrier()
    # Make frames 2-4 dead-ball; keep 0-1 alive so the FULL-frame _pin guard passes.
    frames.loc[frames["frame_id"] >= 2, "team_in_possession"] = np.nan
    # Actions linked ONLY to dead-ball frames (3, 4) via a links DataFrame.
    actions = pd.DataFrame({
        "game_id": [1, 1], "action_id": [1, 2], "period_id": [1, 1],
        "team_id": ["Home", "Home"], "player_id": [10, 11],
        "start_x": [30.0, 35.0], "start_y": [34.0, 34.0],
    })
    links = pd.DataFrame({
        "action_id": [1, 2], "frame_id": [3, 4], "time_offset_seconds": [0.0, 0.0],
        "n_candidate_frames": [1, 1], "link_quality_score": [1.0, 1.0],
    })
    with warnings.catch_warnings(record=True) as w:
        warnings.simplefilter("always")
        out = add_das(actions, frames, links=links)
    assert out["das_team"].isna().all()  # NaN-degraded
    assert any("dead-ball" in str(x.message) for x in w), "clear dead-ball message expected"
```

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

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_das_offside.py::test_all_deadball_linked_subset_degrades_with_clear_message -v`
Expected: FAIL — the warning carries the *generic* library message ("Tracking data is empty …"), not "dead-ball".

- [ ] **Step 3: Guard the restricted subset**

In `silly_kicks/tracking/features.py`, in `_precompute_das_lookup`, immediately **before**
`das_frames = get_individual_das(frames, **kwargs)` (after both link-restriction branches), add:

```python
    # Cross-repo: the lakehouse runs _fill_possession_from_set_piece_actions (possession
    # back-fill for set-piece restarts) BEFORE add_das, so this guard correctly fires only
    # when the restricted subset is still all-NaN AFTER that fill — i.e. genuine dead-ball
    # (e.g. IDSSE ~33% dead frames), not a fillable set-piece gap. The two repos'
    # dead-ball handling is complementary.
    if link_frame_ids is not None and not frames["team_in_possession"].notna().any():
        msg = (
            "team_in_possession is all-NaN in the link-restricted frame subset (dead-ball "
            "window): DAS is undefined here. add_das degrades these actions to NaN."
        )
        raise ValueError(msg)
```

`add_das`'s existing `except (ValueError, ...)` catches it → NaN columns + its warning, now
carrying the clear "dead-ball" text instead of the generic library `ValueError`.

**Verify placement (P3b):** the guard must read the **link-restricted** `frames`, not the full
match. Confirmed against `_precompute_das_lookup` structure — both restriction branches
(`attacking_direction_col is not None` and `elif link_frame_ids is not None`) execute
`frames = frames[frames["frame_id"].isin(link_frame_ids)]` *before* the
`get_individual_das(frames, ...)` call, so inserting the guard immediately above that call
sees the restricted subset. (When `link_frame_ids is None`, `frames` is the full set and the
`link_frame_ids is not None` condition short-circuits the guard — correct: full-frame DAS
handles its own possession gate.) Re-confirm the variable name at implementation time.

- [ ] **Step 4: Run + the frame-restriction suite**

Run: `.venv/Scripts/python.exe -m pytest tests/tracking/test_das_offside.py -q`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add silly_kicks/tracking/features.py tests/tracking/test_das_offside.py
git commit -m "feat(das): clear dead-ball message on all-dead link-restricted subset"
```

---

## Task 12: Full regression + handoff to the decision gate

**Files:** (none)

- [ ] **Step 1: Run the full non-e2e suite**

Run: `.venv/Scripts/python.exe -m pytest tests/ -m "not e2e" -q`
Expected: PASS.

- [ ] **Step 2: Lint + type check (Shift Left)**

Run: `.venv/Scripts/python.exe -m ruff check silly_kicks/tracking/_ghost_gk.py silly_kicks/tracking/_elastic_sync.py silly_kicks/tracking/_ball_carrier.py silly_kicks/tracking/_das.py silly_kicks/tracking/features.py tests/tracking/test_ghost_gk_kde_vectorized.py tests/tracking/test_elastic_sync_lookup_golden.py tests/tracking/test_das_offside.py tests/tracking/test_ball_carrier.py`
Then: `.venv/Scripts/python.exe -m pyright silly_kicks/tracking/_ghost_gk.py silly_kicks/tracking/_elastic_sync.py silly_kicks/tracking/_ball_carrier.py silly_kicks/tracking/_das.py silly_kicks/tracking/features.py`
Expected: clean (match repo-pinned `ruff==0.15.7` / `pyright==1.1.409`).

- [ ] **Step 3: Produce the Phase-0 decision-gate note**

Append the local before/after numbers (Task 8) and the attribution result (Task 9) to the
spec's §3.4. State whether ghost-GK is still the bottleneck after vectorization (→ GPU engine
justified, §4) or collapsed (→ stop). This is the input the lakehouse pairs with its
serverless + IDSSE re-profile and the consumer-correctness diff (the cross-repo acceptance step).

Record these **explicit cross-repo handoff items** (do not leave as "the lakehouse confirms in
passing"):
- **Serverless `train_block` / UDF-memory check (lakehouse-owned):** the local benchmark can't
  see the 1 GB `applyInPandas` cap. Default `train_block=1024` (~150 MB transient/block) is the
  conservative choice; the lakehouse verifies peak memory under its `_FRAME_BATCH_SIZE=250`
  batching and raises `train_block` only if the headroom allows. State which model variant the
  serverless venue runs (the lakehouse uses the 9 MB "default" — small leaf subsets, so chunking
  rarely binds there).
- **Consumer-correctness diff (lakehouse-owned):** AC-1 `predict_density` vectorized-vs-scipy on
  one real match, diffing `ghost_gk_x/y/spread` within the mode tolerance — the Phase-0 acceptance.

- [ ] **Step 4: Final commit**

```bash
git add docs/superpowers/specs/2026-06-01-action-context-hotpath-acceleration-design.md
git commit -m "docs(das): record Phase-0 measurement + decision-gate outcome"
```

---

## Notes for the implementer
- **No new dependencies.** `scipy.linalg.cho_factor`/`cho_solve` are already available (scipy is a hard dep; `_ghost_gk` already imports `scipy.spatial`/`scipy.stats`).
- **No public API change.** `compute_ghost_gk`/`add_ghost_gk`/`ghost_gk_xfns` signatures and the `ghost_gk_x/y/spread` columns are untouched; `kde_backend` is an internal `predict_density` kwarg defaulting to the validated path.
- **The golden master is the contract.** If any continuous comparison can't hit `rtol=1e-7`, do NOT loosen it blindly — first confirm the kernel reuses scipy's exact Cholesky/bandwidth (Task 3); only then document a realistic rtol with the reason. Discrete `mode_*` is exact-or-±1-cell, never rtol.
- **Run perf captures with no contention** and persist outputs before cleanup (the `calibrate_*` sweep shares this machine — never write to the original folder / `silly_kicks/calibration/`).
- **`predict_mean` is NOT a substitute** (mode/spread are consumed; see spec Appendix B). Do not "optimize" by swapping to the regressor mean.
