# PR-S23 — silly-kicks 3.0.1 — Sportec + Metrica per-period direction-of-play fix — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> **Project policy:** ONE git commit per branch (no WIP commits). Per-task "Commit checkpoint" steps in this plan are *task completion gates* — verify the task is done, do NOT run `git commit`. The single `git commit` happens once at the end of Task 16 after `/final-review`.

**Goal:** Fix the silly-kicks 3.0.0 bug where native `spadl/sportec.py` + `spadl/metrica.py` declare `ABSOLUTE_FRAME_HOME_RIGHT` while raw bronze events ship `PER_PERIOD_ABSOLUTE`; add per-period invariant test coverage on real fixtures; harden the convention detector for sparse-shot per-period-absolute matches; ship as 3.0.1.

**Architecture:** Mirror the existing PFF events-side API on the two broken converters (`home_team_start_left: bool | None` + `home_team_start_left_extratime: bool | None` + `home_attacks_right_per_period: Mapping[int, bool] | None`, mutually exclusive, loud-fail when none set). Internal dispatch routes to `to_spadl_ltr(... PER_PERIOD_ABSOLUTE, home_attacks_right_per_period=flips)`. After detector hardening, re-enable `validate_input_convention(... declared=PER_PERIOD_ABSOLUTE)` at the sportec / metrica / pff converter call sites. Extend the existing `tests/invariants/test_direction_of_play.py` (built in PR-S22) with a per-(team, period) assertion using newly committed dense per-period fixtures from the lakehouse session.

**Tech Stack:** Python 3.10–3.12, pandas 2.x, numpy 2.x, pytest 9.x, ruff 0.15.x, pyright 1.1.395, uv. Windows host (PowerShell + Bash both available).

**Spec:** `docs/superpowers/specs/2026-05-02-sk3.0.1-direction-of-play-fix-design.md`

**Branch:** `pr-s23-sportec-metrica-per-period-fix` (already created off `main`).

---

## File Structure

| Path | Action | Purpose |
|---|---|---|
| `silly_kicks/spadl/orientation.py` | Modify | TF-22 detector hardening: classify sparse per-team-period-asymmetric data as `convention=None, confidence="low"` instead of false-positiving ABSOLUTE_FRAME_HOME_RIGHT. |
| `silly_kicks/spadl/sportec.py` | Modify | API change: 3 new kwargs on `convert_to_actions`; route to `PER_PERIOD_ABSOLUTE`; re-enable validator. |
| `silly_kicks/spadl/metrica.py` | Modify | Same as sportec.py. |
| `silly_kicks/spadl/pff.py` | Modify | Re-enable `validate_input_convention(... declared=PER_PERIOD_ABSOLUTE)` (currently absent per ADR-006 § 2 footnote). |
| `silly_kicks/__init__.py` | Modify | Bump `__version__` "1.0.2" → "3.0.1" (also fixes the stale 1.0.2 bug). |
| `pyproject.toml` | Modify | Bump `version` "3.0.0" → "3.0.1". |
| `tests/datasets/idsse/per_period_match.parquet` | Create | Lakehouse `idsse_J03WMX.parquet` (Bassek et al. CC-BY 4.0). |
| `tests/datasets/idsse/README.md` | Modify | Append section for new fixture + provenance + license. |
| `tests/datasets/metrica/per_period_match.parquet` | Create | Lakehouse `metrica_sample_game_1.parquet` (CC-BY-NC-4.0). |
| `tests/datasets/metrica/README.md` | Modify | Append section for new fixture. |
| `scripts/extract_provider_fixtures.py` | Modify | Add `--variant {default, per_period}` flag. |
| `tests/invariants/_loaders.py` | Modify | Add `load_sportec_native_per_period()` + `load_metrica_native_per_period()`. |
| `tests/invariants/test_direction_of_play.py` | Modify | Add `test_per_team_per_period_shots_attack_high_x` function. |
| `tests/invariants/test_input_convention_detector.py` | Modify | Add 3 unit tests for TF-22 detector hardening (sparse, dense per-period, dense absolute). |
| `tests/atomic/test_atomic_orientation.py` | Create | Smoke test: Sportec → SPADL → atomic-SPADL preserves canonical-LTR. |
| `tests/spadl/test_sportec.py` | Modify | Add `home_team_start_left=True` to all `convert_to_actions(...)` call sites (~30 locations). |
| `tests/spadl/test_metrica.py` | Modify | Add `home_team_start_left=True` to all `convert_to_actions(...)` call sites (~30 locations). |
| `NOTICE` | Modify | Append "Test Data Sources" section attributing Bassek et al. + Metrica Sample Game 1. |
| `docs/superpowers/adrs/ADR-006-direction-of-play-handling.md` | Modify | Append "Erratum (silly-kicks 3.0.1)" + "Lessons learned" sections. |
| `CHANGELOG.md` | Modify | Add 3.0.1 entry with code-snippet migration. |
| `TODO.md` | Modify | Archive PR-S22 / TF-22 in Shipped block; add PR-S23 in Shipped; add TF-23 in On-Deck. |

---

## Task 1: TF-22 detector hardening (RED)

**Files:**
- Test: `tests/invariants/test_input_convention_detector.py`

- [ ] **Step 1.1: Read the existing test file**

Run: `cat tests/invariants/test_input_convention_detector.py | head -80`
Confirm the helper test patterns used (parametrize, fixture-build helpers). Note any existing helper that builds synthetic events with shots — reuse if present.

- [ ] **Step 1.2: Write three new failing tests at end of the file**

Append to `tests/invariants/test_input_convention_detector.py`:

```python
# ---------------------------------------------------------------------------
# TF-22 (PR-S23 / silly-kicks 3.0.1): detector must NOT false-positive
# ABSOLUTE_FRAME_HOME_RIGHT on sparse per-team-period-asymmetric per-period-
# absolute data. The IDSSE J03WMX fixture (4/5/8/3 shots distribution) is
# the canonical empirical example — see lakehouse SK3-MIG bug report.
# ---------------------------------------------------------------------------


def _build_synthetic_events(
    *,
    p1_home_x_mean: float,
    p1_home_n: int,
    p1_away_x_mean: float,
    p1_away_n: int,
    p2_home_x_mean: float,
    p2_home_n: int,
    p2_away_x_mean: float,
    p2_away_n: int,
    x_max: float = 105.0,
) -> pd.DataFrame:
    """Synthetic 1-match events with controlled per-period-per-team shot counts + means.

    Shot x values are tightly clustered around each requested mean (mean ± 1.0)
    so the detector's >mid_x classification is unambiguous.
    """
    import numpy as np

    rng = np.random.default_rng(seed=42)
    rows: list[dict[str, object]] = []
    eid = 0
    for period, side, n, x_mean in (
        (1, "home", p1_home_n, p1_home_x_mean),
        (1, "away", p1_away_n, p1_away_x_mean),
        (2, "home", p2_home_n, p2_home_x_mean),
        (2, "away", p2_away_n, p2_away_x_mean),
    ):
        for _ in range(n):
            eid += 1
            x = float(np.clip(rng.normal(loc=x_mean, scale=0.5), 0.0, x_max))
            rows.append(
                {
                    "match_id": "synth-001",
                    "team": side,
                    "period": period,
                    "x": x,
                    "is_shot": True,
                }
            )
    return pd.DataFrame(rows)


def test_detector_sparse_per_team_period_asymmetric_returns_ambiguous():
    """IDSSE J03WMX shape: home reliable in P1 only, away reliable in P2 only.

    Each team has reliable data in 1 distinct period — detector cannot
    distinguish ABSOLUTE_FRAME_HOME_RIGHT from PER_PERIOD_ABSOLUTE on
    shot-only signal. Must return convention=None, confidence='low'.
    """
    from silly_kicks.spadl.orientation import detect_input_convention

    events = _build_synthetic_events(
        p1_home_x_mean=13.0, p1_home_n=5,
        p1_away_x_mean=11.0, p1_away_n=4,   # below medium threshold (n<5)
        p2_home_x_mean=92.0, p2_home_n=3,   # below medium threshold (n<5)
        p2_away_x_mean=95.0, p2_away_n=8,
    )
    result = detect_input_convention(
        events, match_col="match_id", x_max=105.0,
        x_col="x", team_col="team", period_col="period", is_shot_col="is_shot",
    )
    assert result.convention is None, (
        f"expected convention=None for sparse per-team-period-asymmetric data, "
        f"got {result.convention!r}; diagnostics={result.diagnostics}"
    )
    assert result.confidence == "low", (
        f"expected confidence='low', got {result.confidence!r}"
    )


def test_detector_dense_per_period_absolute_classifies_correctly():
    """Dense per-period-absolute: each team has ≥5 shots in EACH period.

    Both teams alternate sides between periods — must classify
    PER_PERIOD_ABSOLUTE with confidence='high'.
    """
    from silly_kicks.spadl.orientation import InputConvention, detect_input_convention

    events = _build_synthetic_events(
        p1_home_x_mean=15.0, p1_home_n=10,    # home attacks left in P1
        p1_away_x_mean=90.0, p1_away_n=10,    # away attacks right in P1
        p2_home_x_mean=92.0, p2_home_n=10,    # home attacks right in P2 (switched)
        p2_away_x_mean=13.0, p2_away_n=10,    # away attacks left in P2 (switched)
    )
    result = detect_input_convention(
        events, match_col="match_id", x_max=105.0,
        x_col="x", team_col="team", period_col="period", is_shot_col="is_shot",
    )
    assert result.convention is InputConvention.PER_PERIOD_ABSOLUTE, (
        f"expected PER_PERIOD_ABSOLUTE, got {result.convention!r}; "
        f"diagnostics={result.diagnostics}"
    )
    assert result.confidence == "high"


def test_detector_dense_absolute_frame_home_right_classifies_correctly():
    """Dense absolute-frame-home-right: each team consistently on one side across both periods.

    Must classify ABSOLUTE_FRAME_HOME_RIGHT with confidence='high'. Sanity
    that the TF-22 hardening didn't regress the dense-data classification.
    """
    from silly_kicks.spadl.orientation import InputConvention, detect_input_convention

    events = _build_synthetic_events(
        p1_home_x_mean=92.0, p1_home_n=10,    # home attacks right in P1
        p1_away_x_mean=13.0, p1_away_n=10,    # away attacks left in P1
        p2_home_x_mean=92.0, p2_home_n=10,    # home STILL attacks right in P2 (no switch)
        p2_away_x_mean=13.0, p2_away_n=10,    # away STILL attacks left in P2 (no switch)
    )
    result = detect_input_convention(
        events, match_col="match_id", x_max=105.0,
        x_col="x", team_col="team", period_col="period", is_shot_col="is_shot",
    )
    assert result.convention is InputConvention.ABSOLUTE_FRAME_HOME_RIGHT, (
        f"expected ABSOLUTE_FRAME_HOME_RIGHT, got {result.convention!r}; "
        f"diagnostics={result.diagnostics}"
    )
    assert result.confidence == "high"
```

If `pd` is not yet imported in the file, add `import pandas as pd` at the top. If a `_build_synthetic_events`-equivalent helper already exists in the file, reuse it instead of duplicating (DRY).

- [ ] **Step 1.3: Run the three new tests — verify the SPARSE one fails (RED), the two dense ones already pass**

Run: `uv run python -m pytest tests/invariants/test_input_convention_detector.py::test_detector_sparse_per_team_period_asymmetric_returns_ambiguous tests/invariants/test_input_convention_detector.py::test_detector_dense_per_period_absolute_classifies_correctly tests/invariants/test_input_convention_detector.py::test_detector_dense_absolute_frame_home_right_classifies_correctly -v`

Expected output for sparse: FAIL — current detector false-positives `ABSOLUTE_FRAME_HOME_RIGHT` instead of None. The two dense tests should already PASS (TF-22 hardening doesn't touch the dense paths).

- [ ] **Step 1.4: Task 1 checkpoint**

Sparse test fails as expected (RED), two dense tests pass. Do NOT commit. Continue to Task 2.

---

## Task 2: TF-22 detector hardening (GREEN)

**Files:**
- Modify: `silly_kicks/spadl/orientation.py:410-457` (the `detect_input_convention` per-match analysis loop and verdict aggregation)

- [ ] **Step 2.1: Locate the per-match analysis block**

Run: `Grep -n "Per-match analysis for absolute" silly_kicks/spadl/orientation.py`
Expected: line 410 (or thereabouts) shows `# Per-match analysis for absolute vs per-period`.

- [ ] **Step 2.2: Modify `detect_input_convention` to gate the absolute-no-switch verdict on ≥2-period coverage per team**

Replace the loop body and verdict aggregation (`silly_kicks/spadl/orientation.py:410-457` — the block starting `# Per-match analysis for absolute vs per-period` through the final ambiguous-fallback `return DetectionResult(...)`):

```python
    # Per-match analysis for absolute vs per-period
    match_verdicts: list[InputConvention | None] = []
    for _match_id, match_grp in reliable.groupby(match_col, sort=False):
        n_periods = match_grp[period_col].nunique()
        if n_periods < 2:
            # Single-period match: cannot distinguish absolute_no_switch from per_period
            continue
        per_team_sides = match_grp.groupby(team_col)["side"].nunique()
        # TF-22 (silly-kicks 3.0.1): the absolute_frame_home_right branch
        # additionally requires that AT LEAST ONE team has reliable data in
        # ≥2 distinct periods. Without this guard, sparse per-team-period-
        # asymmetric data (e.g., IDSSE J03WMX where home is reliable only
        # in P1 and away only in P2) trivially satisfies (per_team_sides
        # == 1).all() and gets classified ABSOLUTE_FRAME_HOME_RIGHT even
        # when the data is genuinely PER_PERIOD_ABSOLUTE. See ADR-006
        # erratum (PR-S23) for the empirical evidence.
        per_team_periods = match_grp.groupby(team_col)[period_col].nunique()
        any_team_spans_both_periods = bool((per_team_periods >= 2).any())
        if (per_team_sides == 1).all() and any_team_spans_both_periods:
            # Each team on same side across periods → absolute_no_switch
            team_means = match_grp.groupby(team_col)["mean_x"].mean()
            if team_means.nunique() == 2 and (team_means > x_mid).any() and (team_means < x_mid).any():
                match_verdicts.append(InputConvention.ABSOLUTE_FRAME_HOME_RIGHT)
                continue
        if (per_team_sides == 2).all():
            # Each team alternates side between periods → per_period_absolute
            match_verdicts.append(InputConvention.PER_PERIOD_ABSOLUTE)
            continue
        match_verdicts.append(None)
```

The only structural change is the addition of the `per_team_periods` line and the `any_team_spans_both_periods` conjunct on the `(per_team_sides == 1).all()` condition. Everything else stays identical.

- [ ] **Step 2.3: Adjust the final ambiguous-fallback to report the correct confidence tier**

Locate the final ambiguous fallback at `silly_kicks/spadl/orientation.py:449-457`:

```python
    return DetectionResult(
        convention=None,
        confidence="ambiguous",
        diagnostics={
            "reason": "no clean classification (mixed signals across matches or single-period data)",
            "groups": reliable.to_dict("records"),
            "match_verdicts": [v.value if v else None for v in match_verdicts],
        },
    )
```

Replace with:

```python
    # TF-22 (silly-kicks 3.0.1): when match_verdicts is non-empty but none
    # produced a clean verdict (e.g., sparse per-team-period-asymmetric
    # shape where the absolute-no-switch guard rejected the verdict), this
    # is the per-team-period-asymmetric case. Use confidence="low" so the
    # caller's strict-mode validator silently defers to the declared
    # convention rather than raising.
    asymmetric_only = bool(match_verdicts) and all(v is None for v in match_verdicts)
    return DetectionResult(
        convention=None,
        confidence="low" if asymmetric_only else "ambiguous",
        diagnostics={
            "reason": (
                "per-team-period-asymmetric (each team has reliable data in only one period)"
                if asymmetric_only
                else "no clean classification (mixed signals across matches or single-period data)"
            ),
            "groups": reliable.to_dict("records"),
            "match_verdicts": [v.value if v else None for v in match_verdicts],
        },
    )
```

- [ ] **Step 2.4: Run the three TF-22 tests — verify all pass (GREEN)**

Run: `uv run python -m pytest tests/invariants/test_input_convention_detector.py -v`

Expected: all tests pass, including the three new TF-22 tests.

- [ ] **Step 2.5: Run the broader detector test suite — verify no regression**

Run: `uv run python -m pytest tests/invariants/test_input_convention_detector.py tests/spadl/ -v -k "convention or orientation or detect"`

Expected: all pass. If any test that previously asserted ABSOLUTE_FRAME_HOME_RIGHT on a sparse fixture now fails, that's a fixture problem — investigate the fixture's design intent before reverting the detector hardening.

- [ ] **Step 2.6: Task 2 checkpoint**

Detector hardened. Do NOT commit.

---

## Task 3: Pull lakehouse fixtures into silly-kicks

**Files:**
- Create: `tests/datasets/idsse/per_period_match.parquet`
- Create: `tests/datasets/metrica/per_period_match.parquet`

- [ ] **Step 3.1: Copy the IDSSE per-period fixture from lakehouse**

Run (PowerShell): `Copy-Item "D:\Development\karstenskyt__luxury-lakehouse\src\tests\fixtures\silly_kicks_boundary\idsse_J03WMX.parquet" "tests\datasets\idsse\per_period_match.parquet"`

Verify the file is now in the silly-kicks tree: `Test-Path "tests\datasets\idsse\per_period_match.parquet"` → `True`.

- [ ] **Step 3.2: Copy the Metrica per-period fixture from lakehouse**

Run (PowerShell): `Copy-Item "D:\Development\karstenskyt__luxury-lakehouse\src\tests\fixtures\silly_kicks_boundary\metrica_sample_game_1.parquet" "tests\datasets\metrica\per_period_match.parquet"`

- [ ] **Step 3.3: Verify shot density on both new fixtures**

Run: `uv run python -c "import pandas as pd; df = pd.read_parquet('tests/datasets/idsse/per_period_match.parquet'); shots = df[df['event_type'] == 'ShotAtGoal']; print('IDSSE shots per (period, team):'); print(shots.groupby(['period', 'team']).size().to_string())"`

Expected: 4 groups with counts roughly matching the lakehouse probe (P1 home 5, P1 away 4, P2 home 3, P2 away 8 — actual counts may differ slightly if the fixture is a subset).

Run: `uv run python -c "import pandas as pd; df = pd.read_parquet('tests/datasets/metrica/per_period_match.parquet'); shots = df[df['type'] == 'SHOT']; print('Metrica shots per (period, team):'); print(shots.groupby(['period', 'team']).size().to_string())"`

Expected: 4 groups with counts matching the lakehouse probe (P1 home 11, P1 away 2, P2 home 7, P2 away 4).

- [ ] **Step 3.4: Verify per-period orientation signature confirms the per-period-absolute hypothesis**

Run: `uv run python -c "import pandas as pd; df = pd.read_parquet('tests/datasets/idsse/per_period_match.parquet'); shots = df[df['event_type'] == 'ShotAtGoal']; xc = 'x_source_position' if 'x_source_position' in df.columns else 'x_position_from_tracking'; print(shots.groupby(['period','team'])[xc].mean().round(1).to_string())"`

Expected for IDSSE: P1 both teams have mean_x near 10-15 (low-x); P2 both teams mean_x near 90-100 (high-x). Confirms `home_team_start_left=False` (home attacks LEFT in P1).

Run the equivalent for Metrica with `xc='start_x'` and `df['type'] == 'SHOT'`. Expected: P1 both near 90 (high-x), P2 both near 10 (low-x). Confirms `home_team_start_left=True` (home attacks RIGHT in P1).

- [ ] **Step 3.5: Task 3 checkpoint**

Both fixtures are in the tree with confirmed per-period density. Do NOT commit.

---

## Task 4: Update fixture READMEs

**Files:**
- Modify: `tests/datasets/idsse/README.md`
- Modify: `tests/datasets/metrica/README.md`

- [ ] **Step 4.1: Append the per-period-fixture section to `tests/datasets/idsse/README.md`**

Append to the end of the file:

```markdown

---

## `per_period_match.parquet` (PR-S23 / silly-kicks 3.0.1)

Full match `idsse_J03WMX` retained at the bronze schema level — exercises
the per-(team, period) direction-of-play invariant that PR-S22's
`tests/invariants/test_direction_of_play.py` could not exercise on the
2-shot `sample_match.parquet` fixture.

### Provenance

- Source: same as `sample_match.parquet` (DFL DataHub free-sample data
  via the luxury-lakehouse SK3-MIG migration session). Match identifier:
  `idsse_J03WMX`.
- Subset rule: full match (no row-count cap; events filtered for the
  silly-kicks bronze input shape only).
- Per-period orientation signature (from lakehouse empirical probe):
  - P1: 5 home + 4 away = 9 shots; both teams cluster near x ≈ 10–15.
  - P2: 3 home + 8 away = 11 shots; both teams cluster near x ≈ 90–100.
  - Home team attacks LEFT in P1 → `home_team_start_left=False`.
- Citation: see `NOTICE` "Test Data Sources" section (Bassek et al. 2025
  DFL Bundesliga Match Tracking Data — Open Match Data 2022/23 — CC-BY 4.0).

### Regenerating

```bash
uv run python scripts/extract_provider_fixtures.py --provider idsse --variant per_period
```
```

- [ ] **Step 4.2: Append the per-period-fixture section to `tests/datasets/metrica/README.md`**

Append:

```markdown

---

## `per_period_match.parquet` (PR-S23 / silly-kicks 3.0.1)

Full Metrica Sample Game 1 retained at the bronze schema level —
exercises the per-(team, period) direction-of-play invariant. Sample
Game 2 (the existing `sample_match.parquet` fixture) is period-1-only;
Sample Game 1 has both periods.

### Provenance

- Source: [`metrica-sports/sample-data`](https://github.com/metrica-sports/sample-data)
  Sample Game 1 — full match. CC-BY-NC-4.0 (same license as Sample Game 2).
- Per-period orientation signature (from lakehouse empirical probe):
  - P1: 11 home + 2 away = 13 shots; both teams cluster near x ≈ 90–100.
  - P2: 7 home + 4 away = 11 shots; both teams cluster near x ≈ 10–20.
  - Home team attacks RIGHT in P1 → `home_team_start_left=True`.

### Regenerating

```bash
uv run python scripts/extract_provider_fixtures.py --provider metrica --variant per_period
```
```

- [ ] **Step 4.3: Task 4 checkpoint**

Both READMEs updated. Do NOT commit.

---

## Task 5: Extend `extract_provider_fixtures.py` with `--variant` flag

**Files:**
- Modify: `scripts/extract_provider_fixtures.py`

- [ ] **Step 5.1: Read the current `__main__` argparse block**

Run: `Grep -n "argparse" scripts/extract_provider_fixtures.py`
Read the `def main()` section and the `if __name__ == "__main__":` block.

- [ ] **Step 5.2: Add `--variant` argument**

Update the argparse setup to include:

```python
parser.add_argument(
    "--variant",
    choices=("default", "per_period"),
    default="default",
    help="'default' produces a small ~300-row contract-test fixture (sample_match.parquet); "
         "'per_period' produces a full-match fixture (per_period_match.parquet) used by "
         "tests/invariants/test_direction_of_play.py for per-period orientation assertions.",
)
```

- [ ] **Step 5.3: Wire `--variant` into the per-extractor output paths**

For each extractor function (`_extract_idsse`, `_extract_metrica`), accept a `variant: str` parameter and switch the output path + row cap based on it. Concretely:

```python
# At module level — new constants for per-period output paths
_IDSSE_PER_PERIOD_OUT = _REPO_ROOT / "tests" / "datasets" / "idsse" / "per_period_match.parquet"
_METRICA_PER_PERIOD_OUT = _REPO_ROOT / "tests" / "datasets" / "metrica" / "per_period_match.parquet"

# Update _extract_idsse signature:
def _extract_idsse(out_path: Path, *, variant: str) -> None:
    """..."""
    # When variant='per_period', skip the _IDSSE_MAX_ROWS cap so the full match
    # is preserved with per-period shot density intact.
    row_cap = None if variant == "per_period" else _IDSSE_MAX_ROWS
    # ... rest of the function uses `row_cap` instead of the old _IDSSE_MAX_ROWS constant.
```

Same shape for `_extract_metrica`. The default `variant='default'` preserves existing behaviour bit-for-bit.

In `main()`, dispatch on `args.variant`:

```python
out_path_idsse = _IDSSE_PER_PERIOD_OUT if args.variant == "per_period" else _IDSSE_OUT
out_path_metrica = _METRICA_PER_PERIOD_OUT if args.variant == "per_period" else _METRICA_OUT
```

- [ ] **Step 5.4: Update the module docstring**

Update the docstring (lines 1-33) to mention the new `--variant` flag and the two output paths per provider.

- [ ] **Step 5.5: Verify the script's usage help renders cleanly**

Run: `uv run python scripts/extract_provider_fixtures.py --help`
Expected: shows the new `--variant {default,per_period}` flag in the help output. No regen-from-Databricks actually performed (just `--help`).

- [ ] **Step 5.6: Task 5 checkpoint**

Script extended. Do NOT commit.

---

## Task 6: Add per-period invariant test (RED)

**Files:**
- Modify: `tests/invariants/_loaders.py`
- Modify: `tests/invariants/test_direction_of_play.py`

- [ ] **Step 6.1: Add new loaders to `tests/invariants/_loaders.py`**

Append after `load_metrica_native()` (currently at `tests/invariants/_loaders.py:256-268`):

```python
# ---------------------------------------------------------------------------
# Sportec native — PER-PERIOD fixture (PR-S23 / silly-kicks 3.0.1)
# ---------------------------------------------------------------------------


def load_sportec_native_per_period() -> LoaderResult:
    """Load the dense per-period IDSSE fixture for the per-(team, period) invariant.

    Per-period orientation signature: home attacks LEFT in P1 →
    home_team_start_left=False.
    """
    from silly_kicks.spadl import sportec

    parquet_path = _REPO_ROOT / "tests" / "datasets" / "idsse" / "per_period_match.parquet"
    events = pd.read_parquet(parquet_path)
    home_team_id = "home"
    gk_ids: set[str] | None = None
    if "play_goal_keeper_action" in events.columns:
        gk_ids = set(events.loc[events["play_goal_keeper_action"].notna(), "player_id"].dropna().astype(str).tolist())
    actions, _ = sportec.convert_to_actions(
        events,
        home_team_id=home_team_id,
        home_team_start_left=False,   # PR-S23: empirical from lakehouse probe
        goalkeeper_ids=gk_ids,
    )
    return actions, home_team_id


# ---------------------------------------------------------------------------
# Metrica native — PER-PERIOD fixture (PR-S23 / silly-kicks 3.0.1)
# ---------------------------------------------------------------------------


def load_metrica_native_per_period() -> LoaderResult:
    """Load the dense per-period Metrica fixture for the per-(team, period) invariant.

    Per-period orientation signature: home attacks RIGHT in P1 →
    home_team_start_left=True.
    """
    from silly_kicks.spadl import metrica

    parquet_path = _REPO_ROOT / "tests" / "datasets" / "metrica" / "per_period_match.parquet"
    events = pd.read_parquet(parquet_path)
    home_team = str(events["team"].dropna().iloc[0])
    home_passes = events[(events["type"] == "PASS") & (events["team"] == home_team) & events["player"].notna()]
    if home_passes.empty:
        gk_ids: set[str] | None = None
    else:
        gk_ids = {str(home_passes["player"].iloc[0])}
    actions, _ = metrica.convert_to_actions(
        events,
        home_team_id=home_team,
        home_team_start_left=True,    # PR-S23: empirical from lakehouse probe
        goalkeeper_ids=gk_ids,
    )
    return actions, home_team
```

The literal `"home"` team_id and `events["team"].dropna().iloc[0]` patterns mirror the existing `load_sportec_native` / `load_metrica_native` exactly to preserve the established test-fixture contract.

- [ ] **Step 6.2: Add the per-period test function to `tests/invariants/test_direction_of_play.py`**

Append after `test_at_least_one_provider_has_two_teams_with_shots`:

```python
# ---------------------------------------------------------------------------
# PR-S23 / silly-kicks 3.0.1: per-(team, period) orientation invariant.
# Closes the test-density gap that let the silly-kicks 3.0.0 native Sportec
# + Metrica per-period-absolute bug ship through PR-S22's invariant suite.
# Existing sample_match.parquet fixtures had only 2 shots / 1 period — the
# per-period invariant could not be physically exercised until now.
# ---------------------------------------------------------------------------


_PER_PERIOD_CASES = [
    # (provider_label, loader, n_min_shots_per_team_period_group)
    ("sportec_native_per_period", _loaders.load_sportec_native_per_period, 3),
    ("metrica_native_per_period", _loaders.load_metrica_native_per_period, 3),
]


@pytest.mark.parametrize("provider,loader,n_min_shots_per_group", _PER_PERIOD_CASES)
def test_per_team_per_period_shots_attack_high_x(
    provider: str, loader, n_min_shots_per_group: int
):
    """SPADL canonical LTR holds per-(period, team), not just in aggregate.

    A per-period-absolute bug (silly-kicks 3.0.0's Sportec + Metrica
    declaration error) is invisible to the aggregate test when one
    period's wrong-end signal cancels the other's. The per-(period,
    team) groupby exposes the bug.
    """
    actions, _home_team_id = loader()
    shots = actions[actions["type_id"] == spadlconfig.actiontype_id["shot"]]
    if len(shots) == 0:
        pytest.skip(f"{provider}: fixture has no shots")

    by_group = shots.groupby(["period_id", "team_id"]).agg(
        n=("start_x", "size"), mean_x=("start_x", "mean")
    )
    reliable = by_group[by_group["n"] >= n_min_shots_per_group]
    if reliable.empty:
        pytest.skip(
            f"{provider}: no (period, team) group has >= {n_min_shots_per_group} shots; "
            f"got {by_group.to_dict('index')}"
        )

    failing = reliable[reliable["mean_x"] <= spadlconfig.field_length / 2]
    assert failing.empty, (
        f"{provider}: per-(period, team) orientation violated. Groups failing canonical LTR:\n"
        f"{failing.to_string()}\n"
        f"Expected: each (period, team) group's mean shot start_x > "
        f"{spadlconfig.field_length / 2}. Full breakdown:\n{by_group.to_string()}"
    )
```

- [ ] **Step 6.3: Run the new test — verify both provider rows fail (RED)**

Run: `uv run python -m pytest tests/invariants/test_direction_of_play.py::test_per_team_per_period_shots_attack_high_x -v`

Expected: BOTH `sportec_native_per_period` and `metrica_native_per_period` FAIL with the diagnostic showing per-(period, team) groups at the wrong end of the pitch. This is the RED state confirming the bug is reproducible.

ALSO expected: `test_per_team_per_period_shots_attack_high_x` will likely fail on `sportec.convert_to_actions(... home_team_start_left=False)` itself, because the API change in Tasks 7-8 hasn't landed yet → the kwarg doesn't exist. This intermediate failure mode is OK; it'll resolve to the correct RED-with-diagnostic state after Tasks 7-8.

- [ ] **Step 6.4: Task 6 checkpoint**

Per-period test in place; either RED with orientation diagnostic OR pre-API-change kwarg-not-recognized error. Do NOT commit.

---

## Task 7: Sportec converter API change (GREEN for sportec)

**Files:**
- Modify: `silly_kicks/spadl/sportec.py:448-560` (the `convert_to_actions` function)

- [ ] **Step 7.1: Update imports at top of `silly_kicks/spadl/sportec.py`**

Locate the existing imports (around `silly_kicks/spadl/sportec.py:1-50`). Find the line:

```python
from .orientation import ABSOLUTE_FRAME_HOME_RIGHT, validate_input_convention
```

(or similar). Replace with:

```python
from collections.abc import Mapping  # add if not present

from ..tracking import _direction
from .orientation import PER_PERIOD_ABSOLUTE, validate_input_convention, to_spadl_ltr
```

(Adjust to merge with whatever import lines already exist — DRY: don't duplicate existing imports. The `to_spadl_ltr` symbol may already be imported; verify before adding.)

- [ ] **Step 7.2: Update the `convert_to_actions` signature**

Locate `silly_kicks/spadl/sportec.py:448-454`. Replace:

```python
def convert_to_actions(
    events: pd.DataFrame,
    home_team_id: str,
    *,
    preserve_native: list[str] | None = None,
    goalkeeper_ids: set[str] | None = None,
) -> tuple[pd.DataFrame, ConversionReport]:
```

With:

```python
def convert_to_actions(
    events: pd.DataFrame,
    home_team_id: str,
    *,
    home_team_start_left: bool | None = None,
    home_team_start_left_extratime: bool | None = None,
    home_attacks_right_per_period: Mapping[int, bool] | None = None,
    preserve_native: list[str] | None = None,
    goalkeeper_ids: set[str] | None = None,
) -> tuple[pd.DataFrame, ConversionReport]:
```

- [ ] **Step 7.3: Update the docstring's Parameters + Examples sections**

Locate the Parameters section in the existing docstring. After the `home_team_id` parameter and before `preserve_native`, insert:

```rst
    home_team_start_left : bool or None, default ``None``
        If True, the home team attacks LEFT in period 1 (and RIGHT in period
        2 after teams switch ends). Derived from DFL ``MatchInformation.xml``
        ``Environment.HomeTeamStartLeftSide`` attribute (or the equivalent
        field in the upstream loader). Pass exactly one of
        ``home_team_start_left`` or ``home_attacks_right_per_period``.
    home_team_start_left_extratime : bool or None, default ``None``
        Same flag for ET periods 3/4. Required only when the input contains
        ``period_id`` 3 or 4 rows. None otherwise.
    home_attacks_right_per_period : Mapping[int, bool] or None, default ``None``
        Escape hatch: pass the per-period flip mapping directly (e.g.,
        ``{1: True, 2: False}``). Useful for non-canonical period structures
        (futsal halves, friendly-match formats). Pass exactly one of
        ``home_team_start_left`` or ``home_attacks_right_per_period``.
```

Replace the existing Examples section with:

```rst
    Examples
    --------
    Convert a Sportec/IDSSE bronze events DataFrame to SPADL — preferred path::

        from silly_kicks.spadl import sportec

        actions, report = sportec.convert_to_actions(
            events,
            home_team_id="DFL-CLU-XXXXX",
            home_team_start_left=True,   # from DFL MatchInformation.xml
            goalkeeper_ids={"DFL-OBJ-..."},
        )

    Same conversion via the explicit-mapping escape hatch::

        actions, report = sportec.convert_to_actions(
            events,
            home_team_id="DFL-CLU-XXXXX",
            home_attacks_right_per_period={1: False, 2: True},
        )
```

- [ ] **Step 7.4: Replace the function body (validator + dispatch block)**

Locate the existing block at `silly_kicks/spadl/sportec.py:500-545` (the `_validate_input_columns(...)` through end of the `if len(raw_actions) > 0:` block). Replace the validator + dispatch portion with:

```python
    _validate_input_columns(events, EXPECTED_INPUT_COLUMNS, provider="Sportec")
    _validate_preserve_native(events, preserve_native, provider="Sportec", schema=KLOPPY_SPADL_COLUMNS)

    event_type_counts = Counter(events["event_type"])

    # PR-S23 / silly-kicks 3.0.1: native Sportec/DFL bronze events ship
    # PER_PERIOD_ABSOLUTE (teams switch ends after halftime). The kloppy
    # gateway path remains ABSOLUTE_FRAME_HOME_RIGHT — kloppy normalises
    # via Orientation.HOME_AWAY upstream. See ADR-006 erratum.
    flips = _resolve_per_period_flips_sportec(
        events=events,
        home_team_start_left=home_team_start_left,
        home_team_start_left_extratime=home_team_start_left_extratime,
        home_attacks_right_per_period=home_attacks_right_per_period,
    )

    # Convention sanity check: declared PER_PERIOD_ABSOLUTE; validator
    # surfaces upstream loader regressions (e.g. caller accidentally feeds
    # absolute-frame data). Strict mode under SILLY_KICKS_ASSERT_INVARIANTS=1.
    _detector_input = events.assign(
        _sk_match_id=events["match_id"] if "match_id" in events.columns else 0,
        _sk_is_shot=(events["event_type"] == "ShotAtGoal"),
    )
    _x_col = "x_source_position" if "x_source_position" in events.columns else "x_position_from_tracking"
    _period_col = "period" if "period" in events.columns else "period_id"
    if _x_col in events.columns and "team" in events.columns and _period_col in events.columns:
        validate_input_convention(
            _detector_input,
            declared=PER_PERIOD_ABSOLUTE,
            match_col="_sk_match_id",
            x_col=_x_col,
            team_col="team",
            period_col=_period_col,
            is_shot_col="_sk_is_shot",
            x_max=105.0,
        )

    raw_actions = _build_raw_actions(events, preserve_native, goalkeeper_ids=goalkeeper_ids)

    if len(raw_actions) > 0:
        actions = _fix_clearances(raw_actions)
        actions = to_spadl_ltr(
            actions,
            input_convention=PER_PERIOD_ABSOLUTE,
            home_team_id=home_team_id,
            home_attacks_right_per_period=flips,
        )
        actions["action_id"] = range(len(actions))
        actions = _add_dribbles(actions)

        # Clamp coords to SPADL pitch frame.
        actions["start_x"] = actions["start_x"].clip(0, spadlconfig.field_length)
        actions["start_y"] = actions["start_y"].clip(0, spadlconfig.field_width)
        actions["end_x"] = actions["end_x"].clip(0, spadlconfig.field_length)
        actions["end_y"] = actions["end_y"].clip(0, spadlconfig.field_width)
    else:
        actions = raw_actions
```

- [ ] **Step 7.5: Add the `_resolve_per_period_flips_sportec` helper**

Add immediately above the `convert_to_actions` function (so the helper is module-private but easily greppable):

```python
_MIGRATION_3_0_1_MESSAGE_SPORTEC = (
    "silly_kicks.spadl.sportec.convert_to_actions requires explicit per-period "
    "direction info as of silly-kicks 3.0.1. Sportec/DFL bronze events ship "
    "per-period-absolute (teams switch ends after halftime). Pass "
    "`home_team_start_left=True/False` from DFL MatchInformation.xml "
    "(Environment.HomeTeamStartLeftSide), OR pass "
    "`home_attacks_right_per_period={1: bool, 2: bool, ...}` directly. "
    "See ADR-006 erratum + CHANGELOG 3.0.1 for the migration snippet."
)


def _resolve_per_period_flips_sportec(
    *,
    events: pd.DataFrame,
    home_team_start_left: bool | None,
    home_team_start_left_extratime: bool | None,
    home_attacks_right_per_period: Mapping[int, bool] | None,
) -> dict[int, bool]:
    """Resolve the bool-pair-OR-mapping kwargs to a concrete per-period flip dict.

    Mutual exclusion + loud failure on missing-input. Mirrors PFF + tracking-Sportec.
    """
    if home_attacks_right_per_period is not None and (
        home_team_start_left is not None or home_team_start_left_extratime is not None
    ):
        raise ValueError(
            "sportec.convert_to_actions: pass home_team_start_left[+_extratime] OR "
            "home_attacks_right_per_period, not both."
        )

    if home_attacks_right_per_period is not None:
        return dict(home_attacks_right_per_period)

    if home_team_start_left is None and home_team_start_left_extratime is not None:
        raise ValueError(
            "sportec.convert_to_actions: home_team_start_left_extratime supplied without "
            "home_team_start_left. ET flag is meaningful only as a refinement of the "
            "regular-time flag."
        )

    if home_team_start_left is None:
        raise ValueError(_MIGRATION_3_0_1_MESSAGE_SPORTEC)

    period_col = "period" if "period" in events.columns else "period_id"
    if period_col in events.columns and events[period_col].isin([3, 4]).any() and home_team_start_left_extratime is None:
        raise ValueError(
            "sportec.convert_to_actions: events contain ET periods (period_id ∈ {3, 4}) "
            "but home_team_start_left_extratime was not provided. Set it explicitly to "
            "match metadata (DFL HomeTeamStartLeftSideExtraTime equivalent), or filter "
            "ET events out before calling."
        )

    return _direction.home_attacks_right_per_period(
        home_team_start_left=home_team_start_left,
        home_team_start_left_extratime=home_team_start_left_extratime,
    )
```

- [ ] **Step 7.6: Run the per-period invariant test for sportec — verify GREEN**

Run: `uv run python -m pytest "tests/invariants/test_direction_of_play.py::test_per_team_per_period_shots_attack_high_x[sportec_native_per_period-load_sportec_native_per_period-3]" -v`

Expected: PASS. Both periods' both teams now cluster at high-x in the SPADL output.

- [ ] **Step 7.7: Run the existing aggregate test for sportec — verify still GREEN**

Run: `uv run python -m pytest "tests/invariants/test_direction_of_play.py::test_per_team_shots_attack_high_x" -v -k "sportec"`

Expected: all sportec_* cases still PASS (the per-period fix shouldn't regress the aggregate invariant).

- [ ] **Step 7.8: Task 7 checkpoint**

Sportec converter fixed. Do NOT commit.

---

## Task 8: Metrica converter API change (GREEN for metrica)

**Files:**
- Modify: `silly_kicks/spadl/metrica.py:1-220` (full module update mirroring Task 7's sportec changes)

- [ ] **Step 8.1: Update imports at top of `silly_kicks/spadl/metrica.py`**

Same pattern as Task 7 Step 7.1 — import `Mapping`, `_direction`, `PER_PERIOD_ABSOLUTE`, `to_spadl_ltr`. Replace `ABSOLUTE_FRAME_HOME_RIGHT` import. DRY.

- [ ] **Step 8.2: Update `convert_to_actions` signature**

Same shape as sportec — three new keyword-only kwargs.

- [ ] **Step 8.3: Update docstring**

Same pattern as sportec, but the "from match metadata" pointer is `metadata.xml` (Metrica's `Period` entity carries the team-direction info; the bronze loader extracts it).

- [ ] **Step 8.4: Replace function body's validator + dispatch block**

Locate `silly_kicks/spadl/metrica.py:161-200`. Apply the same transformation as Task 7 Step 7.4: resolve flips, declare `PER_PERIOD_ABSOLUTE` in `validate_input_convention`, dispatch to `to_spadl_ltr(... PER_PERIOD_ABSOLUTE, home_attacks_right_per_period=flips)`.

Note that Metrica's `_x_col` is `"start_x"` (not `"x_source_position"`) and the period column is just `"period"`. Adapt the validator kwargs accordingly:

```python
    validate_input_convention(
        events.assign(_sk_is_shot=(events["type"] == "SHOT")),
        declared=PER_PERIOD_ABSOLUTE,
        match_col="match_id",
        team_col="team",
        period_col="period",
        is_shot_col="_sk_is_shot",
        x_max=spadlconfig.field_length,
    )
```

- [ ] **Step 8.5: Add the `_resolve_per_period_flips_metrica` helper + migration message**

Mirror Task 7 Step 7.5. Adjust the migration message to point at Metrica's `metadata.xml` rather than DFL XML:

```python
_MIGRATION_3_0_1_MESSAGE_METRICA = (
    "silly_kicks.spadl.metrica.convert_to_actions requires explicit per-period "
    "direction info as of silly-kicks 3.0.1. Metrica bronze events ship "
    "per-period-absolute (teams switch ends after halftime). Pass "
    "`home_team_start_left=True/False` from Metrica metadata.xml "
    "(Period entity team-direction attribute), OR pass "
    "`home_attacks_right_per_period={1: bool, 2: bool, ...}` directly. "
    "See ADR-006 erratum + CHANGELOG 3.0.1 for the migration snippet."
)
```

The `_resolve_per_period_flips_metrica` function body is identical to `_resolve_per_period_flips_sportec` except for the migration constant name. Per project DRY conventions, factor the shared logic if it reads cleanly — but if duplication is small enough that abstraction adds rather than removes complexity, keep it inline. Use judgment.

- [ ] **Step 8.6: Run the per-period invariant test for metrica — verify GREEN**

Run: `uv run python -m pytest "tests/invariants/test_direction_of_play.py::test_per_team_per_period_shots_attack_high_x[metrica_native_per_period-load_metrica_native_per_period-3]" -v`

Expected: PASS.

- [ ] **Step 8.7: Run aggregate metrica tests — verify no regression**

Run: `uv run python -m pytest "tests/invariants/test_direction_of_play.py::test_per_team_shots_attack_high_x" -v -k "metrica"`

Expected: all metrica_* cases still PASS.

- [ ] **Step 8.8: Task 8 checkpoint**

Metrica converter fixed. Do NOT commit.

---

## Task 9: Re-enable validator on PFF + sanity check

**Files:**
- Modify: `silly_kicks/spadl/pff.py` — add a `validate_input_convention` call

- [ ] **Step 9.1: Locate the PFF dispatch block**

Read `silly_kicks/spadl/pff.py:295-320` (after the `_validate_input_columns` + `_validate_preserve_native` calls, before the `home_attacks_right_per_period = _direction.home_attacks_right_per_period(...)` line).

- [ ] **Step 9.2: Add `validate_input_convention` call**

After the `_validate_preserve_native` line (around `silly_kicks/spadl/pff.py:297`) and before the ET-presence check, insert:

```python
    # PR-S23 / silly-kicks 3.0.1: validator re-enabled after TF-22 detector
    # hardening. PFF events ship PER_PERIOD_ABSOLUTE; validator surfaces
    # loader regressions in strict mode (SILLY_KICKS_ASSERT_INVARIANTS=1).
    if "x" in events.columns and "team_id" in events.columns and "period_id" in events.columns:
        _detector_input = events.assign(
            _sk_match_id=events["game_id"] if "game_id" in events.columns else 0,
            _sk_is_shot=(events.get("possession_event_type", pd.Series(dtype="object")) == "SH"),
        )
        validate_input_convention(
            _detector_input,
            declared=PER_PERIOD_ABSOLUTE,
            match_col="_sk_match_id",
            x_col="x",
            team_col="team_id",
            period_col="period_id",
            is_shot_col="_sk_is_shot",
            x_max=spadlconfig.field_length,
        )
```

Adjust column names if the PFF input schema differs — read `EXPECTED_INPUT_COLUMNS` in the same file to confirm the actual input column names (likely `x`, `team_id`, `period_id`, `possession_event_type`).

Add the `validate_input_convention` import at the top of `pff.py` if not already present. Add `from .orientation import validate_input_convention` next to the existing `from .orientation import PER_PERIOD_ABSOLUTE, to_spadl_ltr` line.

- [ ] **Step 9.3: Run PFF test suite — verify no regression**

Run: `uv run python -m pytest tests/spadl/test_pff.py -v`

Expected: all PASS. The synthetic PFF fixtures should be either dense enough for the detector to confirm PER_PERIOD_ABSOLUTE or sparse enough that the detector defers (`convention=None`). Either way, no false-positive warnings.

- [ ] **Step 9.4: Run with strict-mode invariants — verify still no failures**

Run (PowerShell): `$env:SILLY_KICKS_ASSERT_INVARIANTS="1"; uv run python -m pytest tests/spadl/test_pff.py -v; $env:SILLY_KICKS_ASSERT_INVARIANTS=$null`

Expected: all PASS.

- [ ] **Step 9.5: Task 9 checkpoint**

PFF validator re-enabled. Do NOT commit.

---

## Task 10: Sweep existing test_sportec.py call sites

**Files:**
- Modify: `tests/spadl/test_sportec.py` (~30 `convert_to_actions` call sites)

- [ ] **Step 10.1: Inventory the call sites**

Run: `Grep -n "sportec_mod.convert_to_actions" tests/spadl/test_sportec.py`

Confirm all `convert_to_actions(...)` invocations are listed.

- [ ] **Step 10.2: Add `home_team_start_left=True` to every call site**

For each `sportec_mod.convert_to_actions(events, home_team_id=...)` call in `tests/spadl/test_sportec.py`, append `, home_team_start_left=True`. Example transformation:

Before:
```python
actions, report = sportec_mod.convert_to_actions(events, home_team_id="DFL-CLU-A")
```
After:
```python
actions, report = sportec_mod.convert_to_actions(events, home_team_id="DFL-CLU-A", home_team_start_left=True)
```

`home_team_start_left=True` is the value that produces bit-identical SPADL output to the pre-PR-S23 ABSOLUTE_FRAME_HOME_RIGHT path for synthetic single-period (P1-only) fixtures. Existing test expectations stay valid.

If any existing test fixture uses period_id=2 only (rare — verify by `Grep -n "period_id.*2\|period_id.*=.*2" tests/spadl/test_sportec.py`), `home_team_start_left=True` will produce a flipped output relative to the old behaviour. For those tests, audit each individually: either swap to `home_team_start_left=False` (matches old behaviour for P2-only) or update the expected output. The mechanical default is `True`.

- [ ] **Step 10.3: Run the full sportec test suite — verify all pass**

Run: `uv run python -m pytest tests/spadl/test_sportec.py -v --tb=short`

Expected: all PASS. If any fail with an orientation diff, identify the period_id used in the fixture and adjust `home_team_start_left` accordingly.

- [ ] **Step 10.4: Negative-path tests — assert ValueError on missing kwarg**

Add a new test at the end of `tests/spadl/test_sportec.py` (in whichever test class is appropriate, or as a top-level function):

```python
def test_convert_to_actions_raises_when_no_per_period_kwarg_supplied():
    """PR-S23 / silly-kicks 3.0.1: missing home_team_start_left raises ValueError."""
    events = _df_pass_default()  # any minimally valid synthetic fixture
    with pytest.raises(ValueError, match="3\\.0\\.1.*home_team_start_left"):
        sportec_mod.convert_to_actions(events, home_team_id="T-HOME")


def test_convert_to_actions_raises_when_both_kwargs_supplied():
    """PR-S23: bool-pair AND mapping both supplied → ValueError."""
    events = _df_pass_default()
    with pytest.raises(ValueError, match="not both"):
        sportec_mod.convert_to_actions(
            events,
            home_team_id="T-HOME",
            home_team_start_left=True,
            home_attacks_right_per_period={1: True, 2: False},
        )


def test_convert_to_actions_accepts_explicit_mapping_path():
    """PR-S23: mapping escape hatch works as documented."""
    events = _df_pass_default()
    actions, _ = sportec_mod.convert_to_actions(
        events,
        home_team_id="T-HOME",
        home_attacks_right_per_period={1: True, 2: False},
    )
    assert not actions.empty
```

Adjust `match=` regex to whichever portion of the migration message is stable.

- [ ] **Step 10.5: Run the negative-path tests**

Run: `uv run python -m pytest tests/spadl/test_sportec.py -v -k "raises_when or accepts_explicit_mapping"`

Expected: 3 PASS.

- [ ] **Step 10.6: Update the `load_sportec_native()` loader in `tests/invariants/_loaders.py`**

The existing `load_sportec_native()` (around `tests/invariants/_loaders.py:213`) calls `sportec.convert_to_actions(events, home_team_id=home_team_id, goalkeeper_ids=gk_ids)` — add `home_team_start_left=True`. The existing IDSSE `sample_match.parquet` fixture is sparse but mostly P1 events; `True` is the safest default. After the change:

```python
    actions, _ = sportec.convert_to_actions(
        events,
        home_team_id=home_team_id,
        home_team_start_left=True,
        goalkeeper_ids=gk_ids,
    )
```

Verify the existing aggregate invariant test still passes:

Run: `uv run python -m pytest "tests/invariants/test_direction_of_play.py::test_per_team_shots_attack_high_x[sportec_native-load_sportec_native-1]" -v`

Expected: PASS or skip-with-no-shots. Adjust `home_team_start_left` if it fails on orientation diff.

- [ ] **Step 10.7: Task 10 checkpoint**

All sportec call sites migrated. Do NOT commit.

---

## Task 11: Sweep existing test_metrica.py call sites

**Files:**
- Modify: `tests/spadl/test_metrica.py` (~30 `convert_to_actions` call sites)
- Modify: `tests/invariants/_loaders.py:load_metrica_native()`

- [ ] **Step 11.1: Inventory + add `home_team_start_left=True` to every test_metrica.py call site**

Same mechanical change as Task 10 Steps 10.1-10.2, applied to `tests/spadl/test_metrica.py`. The default `home_team_start_left=True` works for all P1-only Metrica synthetic fixtures.

- [ ] **Step 11.2: Add the same three negative-path tests**

Mirror Task 10 Step 10.4, replacing `sportec_mod` with `metrica_mod` and using a Metrica-shaped `_df_metrica("PASS")` fixture or equivalent.

- [ ] **Step 11.3: Run the full metrica test suite**

Run: `uv run python -m pytest tests/spadl/test_metrica.py -v --tb=short`

Expected: all PASS.

- [ ] **Step 11.4: Update `load_metrica_native()` in `_loaders.py`**

Same as Task 10 Step 10.6, but for Metrica:

```python
    actions, _ = metrica.convert_to_actions(
        events,
        home_team_id=home_team,
        home_team_start_left=True,
        goalkeeper_ids=gk_ids,
    )
```

- [ ] **Step 11.5: Run the existing aggregate invariant test for metrica_native**

Run: `uv run python -m pytest "tests/invariants/test_direction_of_play.py::test_per_team_shots_attack_high_x[metrica_native-load_metrica_native-1]" -v`

Expected: PASS or skip.

- [ ] **Step 11.6: Task 11 checkpoint**

All metrica call sites migrated. Do NOT commit.

---

## Task 12: Atomic-SPADL smoke test

**Files:**
- Create: `tests/atomic/test_atomic_orientation.py`

- [ ] **Step 12.1: Confirm the atomic-SPADL conversion entry point**

Run: `Grep -n "def " silly_kicks/atomic/spadl/__init__.py silly_kicks/atomic/spadl/base.py`
Identify the canonical wrapper that converts SPADL → atomic-SPADL (likely `convert_to_atomic` or `from_spadl` in `silly_kicks.atomic.spadl`). Note the exact name.

- [ ] **Step 12.2: Create `tests/atomic/test_atomic_orientation.py`**

```python
"""PR-S23 / silly-kicks 3.0.1: atomic-SPADL inherits the per-period direction-of-play fix.

The atomic namespace converts SPADL → atomic-SPADL post-hoc — there is
no native sportec/metrica converter inside silly_kicks/atomic/spadl/.
This smoke test verifies the per-period orientation fix survives the
atomic decomposition.
"""

from __future__ import annotations

import pytest

from silly_kicks.spadl import config as spadlconfig

from tests.invariants import _loaders


def test_atomic_inherits_sportec_per_period_orientation():
    """Convert IDSSE per-period match through Sportec → SPADL → atomic-SPADL.

    Both teams' atomic-shot rows must cluster at high-x (canonical LTR),
    confirming the PER_PERIOD_ABSOLUTE dispatch fix propagates through
    the atomic-SPADL conversion.
    """
    # Replace `from_spadl` with the actual atomic conversion entry point
    # confirmed in Step 12.1. If the canonical wrapper is different,
    # adjust the import + function call accordingly.
    from silly_kicks.atomic.spadl import convert_to_atomic  # CONFIRM NAME IN STEP 12.1

    actions, _home_team_id = _loaders.load_sportec_native_per_period()
    atomic = convert_to_atomic(actions)

    # In atomic-SPADL, shot rows have type 'shot' (atomic-SPADL preserves
    # SPADL action type names per silly_kicks/atomic/spadl/schema.py).
    if "type_name" in atomic.columns:
        atomic_shots = atomic[atomic["type_name"] == "shot"]
    else:
        atomic_shots = atomic[atomic["type_id"] == spadlconfig.actiontype_id["shot"]]

    if len(atomic_shots) == 0:
        pytest.skip("atomic conversion produced no shot rows for IDSSE per-period fixture")

    # Atomic-SPADL coordinates are typically `x`, not `start_x`. Confirm
    # the actual column name in silly_kicks/atomic/spadl/schema.py during
    # implementation; if the column is `start_x` (legacy), use that.
    x_col = "x" if "x" in atomic.columns else "start_x"

    by_group = atomic_shots.groupby(["period_id", "team_id"]).agg(
        n=(x_col, "size"), mean_x=(x_col, "mean")
    )
    reliable = by_group[by_group["n"] >= 3]
    if reliable.empty:
        pytest.skip(f"no reliable per-period shot groups in atomic output: {by_group.to_dict('index')}")

    failing = reliable[reliable["mean_x"] <= spadlconfig.field_length / 2]
    assert failing.empty, (
        f"atomic-SPADL: per-(period, team) orientation violated. Failing groups:\n"
        f"{failing.to_string()}\n"
        f"Full breakdown:\n{by_group.to_string()}"
    )
```

- [ ] **Step 12.3: Run the atomic smoke test**

Run: `uv run python -m pytest tests/atomic/test_atomic_orientation.py -v`

Expected: PASS. If it fails on import (`convert_to_atomic` doesn't exist), grep `silly_kicks/atomic/` for the actual wrapper name and update the import.

- [ ] **Step 12.4: Task 12 checkpoint**

Atomic smoke test passing. Do NOT commit.

---

## Task 13: Documentation — NOTICE + ADR-006 erratum + CHANGELOG + TODO

**Files:**
- Modify: `NOTICE`
- Modify: `docs/superpowers/adrs/ADR-006-direction-of-play-handling.md`
- Modify: `CHANGELOG.md`
- Modify: `TODO.md`

- [ ] **Step 13.1: Append "Test Data Sources" section to `NOTICE`**

Append at the end of `NOTICE`:

```
Test Data Sources
-----------------

Test fixtures under tests/datasets/ are excluded from the published
silly-kicks wheel via the [tool.hatch.build.targets.wheel] packages
config in pyproject.toml. They exist solely to exercise the converter
pipelines in CI.

The IDSSE per-period orientation fixture
(tests/datasets/idsse/per_period_match.parquet) is derived from the
DFL Bundesliga Match Tracking Data — Open Match Data 2022/23,
published by Bassek et al. (2025) under CC-BY 4.0. Match identifier:
idsse_J03WMX. Citation: Bassek, M., Skinner, J., Niemann, J., et al.
(2025). "An Open Bundesliga Match Tracking Dataset." DFL DataHub.

The Metrica per-period orientation fixture
(tests/datasets/metrica/per_period_match.parquet) is Sample Game 1
from metrica-sports/sample-data
(https://github.com/metrica-sports/sample-data), published under
CC-BY-NC-4.0.

The existing IDSSE contract-test fixture
(tests/datasets/idsse/sample_match.parquet) is from DFL DataHub
free-sample data (non-commercial redistribution permitted). The
existing Metrica contract-test fixture
(tests/datasets/metrica/sample_match.parquet) is Sample Game 2 from
the same source as the per-period fixture, same CC-BY-NC-4.0 license.

The kloppy test fixtures (tests/datasets/kloppy/*.xml + .json) are
vendored from the kloppy project (BSD-3-Clause). The metrica_events.json
within is originally from metrica-sports/sample-data Sample Game 2
(CC-BY-NC-4.0).
```

- [ ] **Step 13.2: Append erratum + lessons-learned sections to `docs/superpowers/adrs/ADR-006-direction-of-play-handling.md`**

Append at the end of the file:

```markdown

## Erratum (silly-kicks 3.0.1, 2026-05-02)

Two rows of the per-converter input convention assignment table (§ 2)
were incorrect in 3.0.0:

| Converter | declared (3.0.0 — incorrect) | declared (3.0.1 — corrected) |
|---|---|---|
| `spadl/sportec.py` | `ABSOLUTE_FRAME_HOME_RIGHT` | `PER_PERIOD_ABSOLUTE` |
| `spadl/metrica.py` | `ABSOLUTE_FRAME_HOME_RIGHT` | `PER_PERIOD_ABSOLUTE` |

Native Sportec and Metrica bronze events ship per-period-absolute
(teams switch ends after halftime — empirically verified against
lakehouse production fixtures in PR-S23). The kloppy-gateway path
(`spadl/kloppy.py`) remains `ABSOLUTE_FRAME_HOME_RIGHT` — kloppy
normalises upstream via `Orientation.HOME_AWAY`.

The 3.0.1 fix mirrors the existing PFF events-side and tracking-side
Sportec API exactly: callers pass `home_team_start_left: bool` (or
the escape-hatch `home_attacks_right_per_period` mapping), the
converter derives flips and dispatches to `to_spadl_ltr` with
`PER_PERIOD_ABSOLUTE`. See CHANGELOG 3.0.1 for the migration snippet.

The detector heuristic (`detect_input_convention`) was simultaneously
hardened (TF-22): when no team has reliable shots in ≥ 2 distinct
periods, the detector returns `convention=None, confidence="low"`
rather than false-positiving `ABSOLUTE_FRAME_HOME_RIGHT` on
sparse-shot per-period-absolute matches. Validator re-enabled at
sportec, metrica, and pff converter call sites with
`declared=PER_PERIOD_ABSOLUTE`.

## Lessons learned (silly-kicks 3.0.1)

PR-S22 reported "1707 passed, 12 skipped, 0 failed" with strict-mode
invariants enabled, yet the Sportec + Metrica per-period bug shipped.
Root cause: `tests/datasets/idsse/sample_match.parquet` (2 shots
total) and `tests/datasets/metrica/sample_match.parquet` (period 1
only) had insufficient per-period shot density to physically exercise
the per-period orientation invariant. The invariant test layer was
present but blind on these two providers.

Lesson: a physical-invariant test is only load-bearing if the fixture
density allows the invariant to actually be evaluated. Future
provider-coverage PRs should verify the invariant suite's fixture
density on a per-provider basis. PR-S23 adds explicit per-period
fixtures (`tests/datasets/{idsse,metrica}/per_period_match.parquet`)
with documented shot density requirements (≥ 5 shots per (team,
period) group is the validator's medium-confidence threshold; the
new fixtures meet this).
```

- [ ] **Step 13.3: Add 3.0.1 entry to `CHANGELOG.md`**

Locate the existing 3.0.0 entry. Insert above it:

```markdown
## 3.0.1 — 2026-05-02 — Direction-of-play correctness fix (Sportec + Metrica)

### Breaking-correctness fix

`silly_kicks.spadl.sportec.convert_to_actions` and
`silly_kicks.spadl.metrica.convert_to_actions` now correctly handle
per-period-absolute bronze events (teams switching ends after
halftime). silly-kicks 3.0.0 declared these converters as
`ABSOLUTE_FRAME_HOME_RIGHT`, producing wrong-end SPADL output for
half of every match. ADR-006 erratum documents the corrected
per-converter declaration table.

Callers must now pass per-period direction info via one of two paths
(otherwise `ValueError` with migration guidance):

```python
# Path A — bool pair (preferred; matches PFF events + tracking-Sportec API)
actions, report = sportec.convert_to_actions(
    events,
    home_team_id="DFL-CLU-XXXXX",
    home_team_start_left=True,                     # from DFL MatchInformation.xml
    home_team_start_left_extratime=False,          # only when ET periods present
)

# Path B — explicit mapping (escape hatch for arbitrary periods)
actions, report = metrica.convert_to_actions(
    events,
    home_team_id="HomeTeam",
    home_attacks_right_per_period={1: True, 2: False},
)
```

Trained VAEP / HybridVAEP / xT models on Sportec or Metrica data from
silly-kicks 3.0.0 must be re-trained on 3.0.1 output.

### Test infrastructure

- New per-period orientation fixtures committed at
  `tests/datasets/idsse/per_period_match.parquet` (Bassek et al.
  CC-BY 4.0) and `tests/datasets/metrica/per_period_match.parquet`
  (CC-BY-NC-4.0; same precedent as existing Metrica fixture).
- New `test_per_team_per_period_shots_attack_high_x` parametrized
  over both new fixtures in `tests/invariants/test_direction_of_play.py`.
  Closes the invariant-density gap that let PR-S22's bug ship.

### Detector hardening (TF-22)

`silly_kicks.spadl.orientation.detect_input_convention` no longer
false-positives `ABSOLUTE_FRAME_HOME_RIGHT` on sparse-shot
per-period-absolute matches. Validator re-enabled at sportec /
metrica / pff converter call sites declaring `PER_PERIOD_ABSOLUTE`.

### Atomic-SPADL pathway

Smoke test added at `tests/atomic/test_atomic_orientation.py`
verifying the SPADL → atomic-SPADL composition preserves canonical-LTR.
No converter changes (atomic has no native sportec/metrica).

```

- [ ] **Step 13.4: Update `TODO.md`**

In `TODO.md`:
- Move PR-S22 from active cycle to Shipped block (already there per recent commits — confirm).
- Add to Shipped block: `PR-S23 — silly-kicks 3.0.1 — Sportec + Metrica per-period direction-of-play fix (2026-05-02; SHA TBD-after-commit).`
- Move TF-22 from On-Deck (if listed) to Shipped (closed by PR-S23).
- Add to On-Deck: `TF-23 — Native Sportec-XML / Metrica-CSV loader inside silly-kicks (deferred from PR-S23 if/when consumer demand surfaces). Architecture ref: spadl/sportec.py docstring intent vs kloppy gateway.`
- If TODO.md has a SHIPPED header version block (per recent commit `5b078f7 docs(todo): archive PR-S21 active cycle, bump SHIPPED header to 2.9.0`), bump it to 3.0.1.

- [ ] **Step 13.5: Task 13 checkpoint**

All four docs updated. Do NOT commit.

---

## Task 14: Version bumps

**Files:**
- Modify: `silly_kicks/__init__.py`
- Modify: `pyproject.toml`

- [ ] **Step 14.1: Bump `silly_kicks/__init__.py:__version__`**

Replace `__version__ = "1.0.2"` with `__version__ = "3.0.1"`.

This also fixes the stale 1.0.2 value (out of sync with pyproject.toml's 3.0.0 since at least PR-S22).

- [ ] **Step 14.2: Bump `pyproject.toml`**

Replace `version = "3.0.0"` with `version = "3.0.1"`.

- [ ] **Step 14.3: Verify the two values match**

Run: `Grep -n "__version__|^version" silly_kicks/__init__.py pyproject.toml`
Expected: both show `3.0.1`.

- [ ] **Step 14.4: Task 14 checkpoint**

Version bumped and consistent. Do NOT commit.

---

## Task 15: Pre-commit gates (Shift-Left)

All gates must pass cleanly before `/final-review`.

- [ ] **Step 15.1: `ruff format --check`**

Run: `uv run ruff format --check .`
Expected: "X files already formatted" with no diffs requested. If diffs reported, run `uv run ruff format .` and re-run `--check`.

- [ ] **Step 15.2: `ruff check`**

Run: `uv run ruff check .`
Expected: "All checks passed!".

- [ ] **Step 15.3: `pyright`**

Run: `uv run pyright`
Expected: 0 errors. If type errors emerge from the new code (especially the `Mapping[int, bool]` parameter), fix inline. The new helpers should be statically analysable without `# type: ignore`.

- [ ] **Step 15.4: Full pytest suite (warn mode)**

Run: `uv run python -m pytest tests/ -m "not e2e" -v`
Expected: all PASS or skip with documented reason. Specifically verify:
- Both new sportec_native_per_period + metrica_native_per_period invariant rows pass.
- All ~30 sportec call sites pass with the new kwarg.
- All ~30 metrica call sites pass.
- Detector tests pass.
- Atomic smoke test passes.

- [ ] **Step 15.5: Full pytest suite (strict mode)**

Run (PowerShell): `$env:SILLY_KICKS_ASSERT_INVARIANTS="1"; uv run python -m pytest tests/ -m "not e2e" -v; $env:SILLY_KICKS_ASSERT_INVARIANTS=$null`
Expected: all PASS. Strict mode promotes detector warnings to errors — this verifies the validator does not false-positive on any committed fixture.

- [ ] **Step 15.6: Task 15 checkpoint**

All gates GREEN. Do NOT commit.

---

## Task 16: `/final-review` and single commit

- [ ] **Step 16.1: Invoke `/final-review`**

Use the `mad-scientist-skills:final-review` skill (per project policy that `/final-review` is mandatory before the single commit).

Address every concern raised. If `/final-review` requests additional doc / test / lint cleanups, apply them inline.

- [ ] **Step 16.2: Final pyright + ruff + pytest re-run after `/final-review` cleanups**

Run all of Steps 15.1-15.5 again. Expected: still all GREEN.

- [ ] **Step 16.3: Stage all PR-S23 changes**

Stage explicit paths (avoid `git add -A`). At minimum:

```bash
git add silly_kicks/spadl/orientation.py
git add silly_kicks/spadl/sportec.py
git add silly_kicks/spadl/metrica.py
git add silly_kicks/spadl/pff.py
git add silly_kicks/__init__.py
git add pyproject.toml
git add tests/datasets/idsse/per_period_match.parquet
git add tests/datasets/idsse/README.md
git add tests/datasets/metrica/per_period_match.parquet
git add tests/datasets/metrica/README.md
git add scripts/extract_provider_fixtures.py
git add tests/invariants/_loaders.py
git add tests/invariants/test_direction_of_play.py
git add tests/invariants/test_input_convention_detector.py
git add tests/atomic/test_atomic_orientation.py
git add tests/spadl/test_sportec.py
git add tests/spadl/test_metrica.py
git add NOTICE
git add docs/superpowers/adrs/ADR-006-direction-of-play-handling.md
git add CHANGELOG.md
git add TODO.md
git add docs/superpowers/specs/2026-05-02-sk3.0.1-direction-of-play-fix-design.md
git add docs/superpowers/plans/2026-05-02-sk3.0.1-direction-of-play-fix.md
```

(Add any additional files the implementation actually touched. Do NOT add probe scripts under `scripts/probe_*.py` — those are local-only per the existing pattern and gitignored.)

- [ ] **Step 16.4: Single commit**

Run (use HEREDOC syntax for the multiline message):

```bash
git commit -m "$(cat <<'EOF'
fix(spadl): Sportec + Metrica per-period direction-of-play correctness -- silly-kicks 3.0.1 (PR-S23)

Native silly_kicks.spadl.sportec + silly_kicks.spadl.metrica converters
declared ABSOLUTE_FRAME_HOME_RIGHT in 3.0.0 but raw bronze events from
both providers ship PER_PERIOD_ABSOLUTE (teams switch ends after
halftime). Empirically verified against lakehouse production fixtures
during the SK3-MIG migration session.

API change: convert_to_actions() on both modules gains three new
keyword-only kwargs (home_team_start_left, home_team_start_left_extratime,
home_attacks_right_per_period) mirroring the existing PFF events-side
and tracking-side Sportec API. Mutual exclusion + loud-failure default.

Detector hardening (TF-22): detect_input_convention no longer
false-positives ABSOLUTE_FRAME_HOME_RIGHT on sparse-shot
per-period-absolute matches (each team reliable in only one period).
Validator re-enabled at sportec / metrica / pff converter call sites
with declared=PER_PERIOD_ABSOLUTE.

Test layer: new per-(team, period) invariant
test_per_team_per_period_shots_attack_high_x parametrized over the
two newly-committed dense fixtures (Bassek et al. CC-BY 4.0 IDSSE +
Metrica Sample Game 1 CC-BY-NC-4.0). Atomic-SPADL smoke test
confirms the fix propagates through atomic decomposition.

ADR-006 erratum + Lessons learned section document why PR-S22's
invariant suite missed this bug class (insufficient fixture density)
and how PR-S23 closes it.

Spec: docs/superpowers/specs/2026-05-02-sk3.0.1-direction-of-play-fix-design.md
Plan: docs/superpowers/plans/2026-05-02-sk3.0.1-direction-of-play-fix.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 16.5: Verify the commit succeeded**

Run: `git status` and `git log -1 --stat`

Expected: clean working tree; one new commit on `pr-s23-sportec-metrica-per-period-fix` listing all modified files.

- [ ] **Step 16.6: Push and open PR**

Run: `git push -u origin pr-s23-sportec-metrica-per-period-fix`

Then: `gh pr create --title "fix(spadl): Sportec + Metrica per-period direction-of-play correctness -- silly-kicks 3.0.1 (PR-S23)" --body "$(cat <<'EOF'
## Summary

- Fixes the silly-kicks 3.0.0 bug where native Sportec + Metrica converters declare ABSOLUTE_FRAME_HOME_RIGHT while raw bronze events ship PER_PERIOD_ABSOLUTE (teams switch ends after halftime).
- Adds three new keyword-only kwargs on both converters mirroring the existing PFF + tracking-Sportec API.
- Hardens detect_input_convention to handle sparse-shot per-period-absolute matches (TF-22).
- Adds dense per-period fixtures + per-(team, period) invariant test, closing the PR-S22 invariant-density gap.

## Spec + plan

- Design: docs/superpowers/specs/2026-05-02-sk3.0.1-direction-of-play-fix-design.md
- Plan: docs/superpowers/plans/2026-05-02-sk3.0.1-direction-of-play-fix.md
- ADR-006 erratum + Lessons learned: docs/superpowers/adrs/ADR-006-direction-of-play-handling.md

## Test plan

- [x] All ~30 test_sportec.py + ~30 test_metrica.py call sites pass with the new kwarg.
- [x] tests/invariants/test_direction_of_play.py::test_per_team_per_period_shots_attack_high_x GREEN for both new fixtures.
- [x] tests/invariants/test_input_convention_detector.py 3 new TF-22 unit tests GREEN.
- [x] tests/atomic/test_atomic_orientation.py GREEN.
- [x] Full pytest -m "not e2e" GREEN both with and without SILLY_KICKS_ASSERT_INVARIANTS=1.
- [x] ruff format --check, ruff check, pyright all GREEN.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"`

- [ ] **Step 16.7: Task 16 checkpoint — DONE**

PR opened. Plan complete.

---

## Self-review (writing-plans skill internal check)

**1. Spec coverage check:**

| Spec § | Task |
|---|---|
| 2.1 (PER_PERIOD_ABSOLUTE declaration) | Tasks 7, 8 |
| 2.2 (API-additive kwargs) | Tasks 7, 8 |
| 2.3 (Real-fixture invariant test) | Tasks 3, 4, 6 |
| 2.4 (Lakehouse fixtures committed) | Task 3 |
| 2.5 (TF-22 detector hardening) | Tasks 1, 2 |
| 2.6 (Atomic-SPADL smoke test) | Task 12 |
| 2.7 (ADR-006 erratum) | Task 13 |
| 2.8 (NOTICE attribution) | Task 13 |
| 2.9 (CHANGELOG 3.0.1) | Task 13 |
| 2.10 (`/final-review` mandatory) | Task 16 |
| 4.5 (Fixture extraction script extension) | Task 5 |
| 4.6 (Defensive parquet column projection) | Task 6 (loader implementation) |

All spec requirements have a corresponding task. ✓

**2. Placeholder scan:** No "TBD", "TODO", "implement later", or "fill in details" remain in load-bearing steps. The two genuine implementation-time lookups (atomic conversion entry point in Step 12.1; exact PFF input-column names in Step 9.2) are flagged as confirm-by-grep steps with the relevant grep command, not as design ambiguities.

**3. Type consistency:** `home_team_start_left: bool | None`, `home_team_start_left_extratime: bool | None`, `home_attacks_right_per_period: Mapping[int, bool] | None` used consistently across Tasks 7, 8, 9, 10, 11. `_resolve_per_period_flips_sportec` / `_resolve_per_period_flips_metrica` return `dict[int, bool]` matching `to_spadl_ltr`'s declared parameter type at `silly_kicks/spadl/orientation.py:107`. ✓

Plan ready for execution.
