# Player Alteration Core 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:** Build the pure-logic core of sub-project 3 (the Player Runtime + alteration engine) — a new `player/` package that turns control-panel state into a render plan and playback transitions, fully unit-tested with all I/O (video/audio/serial) behind injected interfaces.

**Architecture:** Four small, pure modules mirroring `hef/` conventions (frozen dataclasses, `from __future__ import annotations`, string-constant frozensets, thorough unit tests, no heavy deps). `controls` models the panel state (the serial-contract data shape shared with sub-project 4); `content` resolves the 7-way content dial; `alteration` is the heart — knob vector → `RenderPlan` per design §4/§5; `state` is the player state machine that diffs successive controls into playback transitions (crossfade / fade-to-black / live-update). No mpv, no GPU, no real serial, no white-noise DSP, no v2v pipeline — those are later integration slices.

**Tech Stack:** Python 3.11+, stdlib only (`dataclasses`, `math`), pytest. Reuses `hef.selection.Coordinate` for the knob vector.

**Design reference:** `docs/superpowers/specs/2026-06-05-machine-altered-perception-design.md` (§4 alteration engine, §5 mood grade, §6 7-way content dial, §7 intensity) and `docs/ROADMAP.md` §3 (Player Runtime "done when").

**Calibration decision (flagged for operator):** brain knobs use `strength = value/4` (0=off, 4=max); mood uses `tone = (light−dark)/4` (equal dark/light = identity per §5). This reads §3's "(2,2,2,2) neutral" as a vestige of the old coordinate-grid center. The calibration lives in three one-line helpers so the convention can be flipped trivially. See the session 0006 transcript's Deferred decisions.

---

## File Structure

- `player/__init__.py` — package marker (empty).
- `player/controls.py` — `Controls` frozen dataclass (content position + 4 experience knobs + volume + brightness), `CONTENT_POSITIONS`, `validate_controls`, `parse_controls` (from a decoded mapping; wire framing is the separate 3⇄4 serial contract).
- `player/content.py` — `ContentResolution` (audio source + video flag), `AUDIO_SOURCES`, `resolve_content` (the §6 7-row table, single source of truth).
- `player/alteration.py` — `ColorGrade`, `AnalyticalOverlay`, `Restyle`, `RenderPlan`, `plan_alteration(coord)`, and the calibration helpers `_overlay_intensity`, `_restyle_blend`, `_mood_tone`.
- `player/state.py` — `Playback`, `TransitionKind`, `Transition`, `Player` (consumes a stream of `Controls`, emits a `Transition` per update; output sink is injected/duck-typed; base-clip choice is an injected callable).
- `pyproject.toml` — add `"player"` to `[tool.setuptools] packages`.

Tests (one per module): `tests/test_player_controls.py`, `tests/test_player_content.py`, `tests/test_player_alteration.py`, `tests/test_player_state.py`.

---

## Task 1: Controls model (the serial-contract data shape)

**Files:**
- Create: `player/__init__.py`
- Create: `player/controls.py`
- Test: `tests/test_player_controls.py`

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

```python
# tests/test_player_controls.py
import pytest

from player.controls import (
    Controls,
    CONTENT_POSITIONS,
    ControlsError,
    validate_controls,
    parse_controls,
)


def test_content_positions_are_the_seven_from_the_spec():
    assert CONTENT_POSITIONS == frozenset(
        {"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"}
    )


def test_valid_controls_pass_validation():
    c = Controls(content="video", left=0, right=4, dark=2, light=2, volume=3, brightness=4)
    validate_controls(c)  # does not raise


def test_invalid_content_position_rejected():
    c = Controls(content="bogus", left=0, right=0, dark=0, light=0, volume=0, brightness=0)
    with pytest.raises(ControlsError):
        validate_controls(c)


@pytest.mark.parametrize("field", ["left", "right", "dark", "light", "volume", "brightness"])
@pytest.mark.parametrize("bad", [-1, 5, True])
def test_out_of_range_or_non_int_knob_rejected(field, bad):
    kwargs = dict(content="off", left=0, right=0, dark=0, light=0, volume=0, brightness=0)
    kwargs[field] = bad
    with pytest.raises(ControlsError):
        validate_controls(Controls(**kwargs))


def test_parse_controls_from_mapping():
    c = parse_controls(
        {"content": "music_video", "left": 1, "right": 2, "dark": 3, "light": 0, "volume": 2, "brightness": 1}
    )
    assert c == Controls("music_video", 1, 2, 3, 0, 2, 1)


def test_parse_controls_rejects_unknown_keys():
    with pytest.raises(ControlsError):
        parse_controls({"content": "off", "left": 0, "right": 0, "dark": 0,
                        "light": 0, "volume": 0, "brightness": 0, "bogus": 1})


def test_parse_controls_rejects_missing_keys():
    with pytest.raises(ControlsError):
        parse_controls({"content": "off", "left": 0})
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_player_controls.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'player'`.

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

```python
# player/__init__.py
```
(empty file)

```python
# player/controls.py
"""Control-panel state: the data shape read from the Arduino over serial.

This is the serial-contract payload shared with sub-project 4 (firmware). It
models the full panel from design §6/§7: the 7-way content dial, the four
experience knobs (0..4), and the two intensity levels (volume, brightness).
The wire framing itself is the separate 3<->4 serial contract; this module is
the *decoded* form.
"""

from __future__ import annotations

from dataclasses import dataclass, fields

# The seven positions of the content dial (design §6).
CONTENT_POSITIONS = frozenset(
    {"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"}
)

KNOB_FIELDS = ("left", "right", "dark", "light", "volume", "brightness")
KNOB_MIN = 0
KNOB_MAX = 4


class ControlsError(ValueError):
    """Raised when a Controls payload is structurally invalid."""


@dataclass(frozen=True)
class Controls:
    content: str
    left: int
    right: int
    dark: int
    light: int
    volume: int
    brightness: int


def validate_controls(c: Controls) -> None:
    """Raise ControlsError if the payload is structurally invalid."""
    if c.content not in CONTENT_POSITIONS:
        raise ControlsError(
            f"invalid content position {c.content!r}; "
            f"expected one of {sorted(CONTENT_POSITIONS)}"
        )
    for name in KNOB_FIELDS:
        value = getattr(c, name)
        if isinstance(value, bool) or not isinstance(value, int):
            raise ControlsError(f"knob {name} must be an int, got {value!r}")
        if not (KNOB_MIN <= value <= KNOB_MAX):
            raise ControlsError(
                f"knob {name}={value} out of range {KNOB_MIN}..{KNOB_MAX}"
            )


def parse_controls(data: dict) -> Controls:
    """Build a validated Controls from a decoded mapping, rejecting unknown or
    missing keys."""
    known = {f.name for f in fields(Controls)}
    unknown = set(data) - known
    if unknown:
        raise ControlsError(f"unknown keys: {sorted(unknown)}")
    missing = known - set(data)
    if missing:
        raise ControlsError(f"missing keys: {sorted(missing)}")
    c = Controls(**data)
    validate_controls(c)
    return c
```

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

Run: `pytest tests/test_player_controls.py -v`
Expected: PASS (all cases).

- [ ] **Step 5: Commit**

```bash
git add player/__init__.py player/controls.py tests/test_player_controls.py
git commit -m "feat(player): Controls panel model (serial-contract data shape)

Sub-project 3 slice 1, per ROADMAP.md §3 and design §6/§7."
```

---

## Task 2: Content-dial resolution (§6)

**Files:**
- Create: `player/content.py`
- Test: `tests/test_player_content.py`

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

```python
# tests/test_player_content.py
import pytest

from player.content import AUDIO_SOURCES, ContentResolution, resolve_content


def test_audio_sources_are_the_four_distinct_sources():
    assert AUDIO_SOURCES == frozenset({"none", "white_noise", "music", "audio_track"})


# The §6 table, row by row: position -> (audio_source, video).
@pytest.mark.parametrize(
    "position,audio_source,video",
    [
        ("off", "none", False),
        ("white_noise", "white_noise", False),
        ("music", "music", False),
        ("audio_track", "audio_track", False),
        ("video", "none", True),
        ("music_video", "music", True),
        ("audio_video", "audio_track", True),
    ],
)
def test_resolve_content_matches_spec_table(position, audio_source, video):
    assert resolve_content(position) == ContentResolution(audio_source=audio_source, video=video)


def test_off_is_void_state_black_and_silent():
    r = resolve_content("off")
    assert r.video is False and r.audio_source == "none"


def test_resolve_content_rejects_unknown_position():
    with pytest.raises(ValueError):
        resolve_content("bogus")
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_player_content.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'player.content'`.

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

```python
# player/content.py
"""Resolve the 7-way content dial into an audio source + video on/off (§6).

The single source of truth for design §6's table: which audio source plays and
whether the projector shows video, for each dial position.
"""

from __future__ import annotations

from dataclasses import dataclass

# The four distinct audio sources. "white_noise" is generated at runtime;
# "music" is the public-domain classical pool; "audio_track" is the clip's own
# field audio; "none" is silence.
AUDIO_SOURCES = frozenset({"none", "white_noise", "music", "audio_track"})


@dataclass(frozen=True)
class ContentResolution:
    audio_source: str
    video: bool


# Design §6, one row per dial position.
_TABLE = {
    "off": ContentResolution("none", False),
    "white_noise": ContentResolution("white_noise", False),
    "music": ContentResolution("music", False),
    "audio_track": ContentResolution("audio_track", False),
    "video": ContentResolution("none", True),
    "music_video": ContentResolution("music", True),
    "audio_video": ContentResolution("audio_track", True),
}


def resolve_content(position: str) -> ContentResolution:
    """Map a content-dial position to its audio source and video flag (§6)."""
    try:
        return _TABLE[position]
    except KeyError:
        raise ValueError(
            f"unknown content position {position!r}; expected one of {sorted(_TABLE)}"
        ) from None
```

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

Run: `pytest tests/test_player_content.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add player/content.py tests/test_player_content.py
git commit -m "feat(player): 7-way content-dial resolution (design §6)"
```

---

## Task 3: Alteration engine — knob vector to RenderPlan (§4, §5)

**Files:**
- Create: `player/alteration.py`
- Test: `tests/test_player_alteration.py`

The engine maps a knob vector `(left, right, dark, light)` to a layered `RenderPlan` per design §4.2:
- **Substrate** transforms (alter pixels): `Restyle` (Right, v2v) blended with `ColorGrade` (mood, Dark/Light).
- **Overlay** on top: `AnalyticalOverlay` (Left, HUD/labels).

`ColorGrade.tone` is signed: `>0` warm yellow→white (light pole), `<0` cool blue→black (dark pole), `0` identity/raw (§5). Calibration (flagged decision): `overlay = left/4`, `restyle = right/4`, `tone = (light − dark)/4`.

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

```python
# tests/test_player_alteration.py
import pytest

from hef.selection import Coordinate
from player.alteration import (
    AnalyticalOverlay,
    ColorGrade,
    RenderPlan,
    Restyle,
    plan_alteration,
)


def _coord(left=0, right=0, dark=0, light=0):
    return Coordinate(left=left, right=right, dark=dark, light=light)


def test_all_zero_knobs_is_the_unaltered_base():
    plan = plan_alteration(_coord())
    assert plan.is_identity
    assert plan.overlay.intensity == 0.0
    assert plan.restyle.blend == 0.0
    assert plan.grade.tone == 0.0
    assert plan.grade.is_identity


def test_left_drives_the_analytical_overlay_only():
    plan = plan_alteration(_coord(left=4))
    assert plan.overlay.intensity == 1.0
    assert plan.restyle.blend == 0.0  # Left does not touch the substrate
    assert plan.grade.tone == 0.0


def test_right_drives_the_restyle_substrate_only():
    plan = plan_alteration(_coord(right=2))
    assert plan.restyle.blend == 0.5
    assert plan.overlay.intensity == 0.0  # Right does not add overlay


def test_left_and_right_stack_not_cancel():
    # design §4.2: whole-brain corner = dreamlike substrate WITH labels on top
    plan = plan_alteration(_coord(left=4, right=4))
    assert plan.overlay.intensity == 1.0
    assert plan.restyle.blend == 1.0


def test_light_pole_grades_warm_positive_tone():
    plan = plan_alteration(_coord(light=4))
    assert plan.grade.tone == 1.0
    assert not plan.grade.is_identity


def test_dark_pole_grades_cool_negative_tone():
    plan = plan_alteration(_coord(dark=4))
    assert plan.grade.tone == -1.0


def test_equal_dark_and_light_is_identity_grade():
    # design §5: the mood center is the raw, ungraded footage
    assert plan_alteration(_coord(dark=3, light=3)).grade.is_identity
    assert plan_alteration(_coord(dark=2, light=2)).grade.is_identity


def test_dark_minus_light_sets_intermediate_tone():
    assert plan_alteration(_coord(dark=4, light=2)).grade.tone == pytest.approx(-0.5)
    assert plan_alteration(_coord(dark=1, light=3)).grade.tone == pytest.approx(0.5)


def test_whole_brain_dark_corner_stacks_grade_substrate_and_overlay():
    # design §4.2 "Dark + analytical": cold measurement over a melancholy scene
    plan = plan_alteration(_coord(left=4, right=2, dark=4, light=0))
    assert plan.overlay.intensity == 1.0
    assert plan.restyle.blend == 0.5
    assert plan.grade.tone == -1.0
    assert not plan.is_identity


def test_render_plan_is_frozen():
    plan = plan_alteration(_coord())
    with pytest.raises(Exception):
        plan.grade.tone = 0.5  # type: ignore[misc]
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_player_alteration.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'player.alteration'`.

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

```python
# player/alteration.py
"""The alteration engine: a knob vector -> a layered RenderPlan (design §4, §5).

Replaces the 2026-06-04 nearest-match *selection* with a *transformation* of a
neutral base clip. Given the four experience knobs, it produces three layers
that compose per §4.2:

  - Substrate transforms (alter pixels, blend with each other):
      * Restyle  -- the Right axis: a pre-baked generative v2v dreamlike restyle.
      * ColorGrade -- the mood axis (Dark/Light): a deterministic color grade.
  - Overlay (composited on top of the substrate):
      * AnalyticalOverlay -- the Left axis: HUD/labels/measurement.

Left and Right are NOT opposites; they live on different layers and stack
(§4.2). Dark and Light are the two poles of one mood grade whose center is the
identity (§5).

Calibration note: the knob->strength curves below are the single source of
truth for how a 0..4 position maps to a transform strength. See the session
0006 transcript Deferred decisions for the §3-vs-§4.2/§5 reconciliation.
"""

from __future__ import annotations

from dataclasses import dataclass

from hef.selection import Coordinate

KNOB_MAX = 4  # knob full-scale (0..4)


@dataclass(frozen=True)
class ColorGrade:
    """Mood-axis grade (§5). `tone` is signed: >0 warm yellow->white (light),
    <0 cool blue->black (dark), 0 = identity (raw, ungraded)."""

    tone: float

    @property
    def is_identity(self) -> bool:
        return self.tone == 0.0


@dataclass(frozen=True)
class AnalyticalOverlay:
    """Left axis (§4.1): analytical HUD/annotation/labels, composited on top.
    `intensity` 0..1 (0 = no overlay)."""

    intensity: float


@dataclass(frozen=True)
class Restyle:
    """Right axis (§4.1): pre-baked generative v2v dreamlike substrate.
    `blend` 0..1 (0 = raw substrate, no restyle)."""

    blend: float


@dataclass(frozen=True)
class RenderPlan:
    """The full layered alteration for one knob vector (§4.2)."""

    grade: ColorGrade
    overlay: AnalyticalOverlay
    restyle: Restyle

    @property
    def is_identity(self) -> bool:
        """True when the plan leaves the neutral base un-altered."""
        return (
            self.grade.is_identity
            and self.overlay.intensity == 0.0
            and self.restyle.blend == 0.0
        )


def _overlay_intensity(left: int) -> float:
    """Left knob -> analytical-overlay intensity (0..1)."""
    return left / KNOB_MAX


def _restyle_blend(right: int) -> float:
    """Right knob -> v2v restyle blend (0..1)."""
    return right / KNOB_MAX


def _mood_tone(dark: int, light: int) -> float:
    """(dark, light) -> signed mood grade in [-1, 1]; equal -> 0 identity (§5)."""
    return (light - dark) / KNOB_MAX


def plan_alteration(coord: Coordinate) -> RenderPlan:
    """Map a knob vector to its layered RenderPlan (design §4)."""
    return RenderPlan(
        grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light)),
        overlay=AnalyticalOverlay(intensity=_overlay_intensity(coord.left)),
        restyle=Restyle(blend=_restyle_blend(coord.right)),
    )
```

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

Run: `pytest tests/test_player_alteration.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add player/alteration.py tests/test_player_alteration.py
git commit -m "feat(player): alteration engine — knob vector to RenderPlan (design §4/§5)"
```

---

## Task 4: Player state machine — controls stream to transitions

**Files:**
- Create: `player/state.py`
- Test: `tests/test_player_state.py`

The `Player` holds a base-clip library and the current `Playback`. Each `update(controls)` resolves the desired `Playback` (chosen base clip + RenderPlan + content resolution + volume/brightness) and returns the `Transition` from the previous playback:

- target video off, was on → `FADE_TO_BLACK`
- target video on, was off → `FADE_FROM_BLACK`
- both video on, clip or restyle variant changed → `CROSSFADE`
- both video on, only grade/overlay/level changed → `LIVE_UPDATE` (§4.3: grade + overlay are continuous runtime ops)
- both video off, audio/level changed → `LIVE_UPDATE`
- nothing changed → `NONE`

Base-clip choice is an injected callable (default: first clip); knobs no longer *select* (the base is neutral) — they *transform*. The library is duck-typed objects with an `.id`.

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

```python
# tests/test_player_state.py
from dataclasses import dataclass

import pytest

from player.controls import Controls
from player.state import Player, Playback, Transition, TransitionKind


@dataclass(frozen=True)
class FakeClip:
    id: str


LIB = [FakeClip("base-a"), FakeClip("base-b")]


def _controls(content="video", left=0, right=0, dark=0, light=0, volume=2, brightness=2):
    return Controls(content, left, right, dark, light, volume, brightness)


def test_first_update_to_video_fades_in_from_black():
    p = Player(LIB)
    t = p.update(_controls(content="video"))
    assert t.kind == TransitionKind.FADE_FROM_BLACK
    assert t.playback.clip_id == "base-a"
    assert t.playback.content.video is True


def test_off_from_video_fades_to_black_and_silences():
    p = Player(LIB)
    p.update(_controls(content="video"))
    t = p.update(_controls(content="off"))
    assert t.kind == TransitionKind.FADE_TO_BLACK
    assert t.playback.clip_id is None
    assert t.playback.content.audio_source == "none"


def test_no_change_yields_none_transition():
    p = Player(LIB)
    p.update(_controls(content="video", left=1))
    t = p.update(_controls(content="video", left=1))
    assert t.kind == TransitionKind.NONE


def test_grade_change_is_a_live_update_not_a_crossfade():
    # design §4.3: the Dark/Light grade is a continuous runtime op
    p = Player(LIB)
    p.update(_controls(content="video", dark=0, light=0))
    t = p.update(_controls(content="video", dark=4, light=0))
    assert t.kind == TransitionKind.LIVE_UPDATE
    assert t.playback.plan.grade.tone == -1.0


def test_overlay_change_is_a_live_update():
    p = Player(LIB)
    p.update(_controls(content="video", left=0))
    t = p.update(_controls(content="video", left=4))
    assert t.kind == TransitionKind.LIVE_UPDATE
    assert t.playback.plan.overlay.intensity == 1.0


def test_restyle_change_crossfades_the_substrate():
    # design §4.3: the Right v2v substrate is a pre-baked variant swap
    p = Player(LIB)
    p.update(_controls(content="video", right=0))
    t = p.update(_controls(content="video", right=4))
    assert t.kind == TransitionKind.CROSSFADE
    assert t.playback.plan.restyle.blend == 1.0


def test_volume_only_change_is_a_live_update():
    p = Player(LIB)
    p.update(_controls(content="video", volume=1))
    t = p.update(_controls(content="video", volume=4))
    assert t.kind == TransitionKind.LIVE_UPDATE
    assert t.playback.volume == 4


def test_audio_source_change_while_black_is_a_live_update():
    p = Player(LIB)
    p.update(_controls(content="white_noise"))
    t = p.update(_controls(content="music"))
    assert t.kind == TransitionKind.LIVE_UPDATE
    assert t.playback.content.audio_source == "music"


def test_injected_base_chooser_is_used():
    p = Player(LIB, choose_base=lambda lib: lib[1])
    t = p.update(_controls(content="video"))
    assert t.playback.clip_id == "base-b"


def test_empty_library_with_video_raises():
    p = Player([])
    with pytest.raises(ValueError):
        p.update(_controls(content="video"))


def test_off_with_empty_library_is_fine():
    p = Player([])
    t = p.update(_controls(content="off"))
    assert t.kind == TransitionKind.NONE  # already black at init
    assert t.playback.clip_id is None
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_player_state.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'player.state'`.

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

```python
# player/state.py
"""The player state machine: a stream of Controls -> playback transitions.

Pure decision logic — no mpv, no audio, no serial. Each update() resolves the
desired Playback (which neutral base clip, how it is altered, what audio plays,
at what levels) and returns the Transition from the previous Playback. The
transition KIND encodes design §4.3: the Dark/Light grade and the Left overlay
are continuous runtime ops (LIVE_UPDATE), whereas swapping the clip or the
pre-baked Right v2v variant needs a CROSSFADE, and toggling video on/off
fades to/from black.

Knobs no longer *select* a clip (the base library is neutral by construction);
they *transform* it. Which neutral base to show is an injected policy
(`choose_base`), defaulting to the first clip; richer rotation is a later slice.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Callable, Optional

from hef.selection import Coordinate
from player.alteration import RenderPlan, plan_alteration
from player.content import ContentResolution, resolve_content
from player.controls import Controls


class TransitionKind:
    NONE = "none"
    LIVE_UPDATE = "live_update"
    CROSSFADE = "crossfade"
    FADE_TO_BLACK = "fade_to_black"
    FADE_FROM_BLACK = "fade_from_black"


@dataclass(frozen=True)
class Playback:
    """What should currently be playing."""

    clip_id: Optional[str]            # None = black walls
    plan: Optional[RenderPlan]        # None when black
    content: ContentResolution
    volume: int
    brightness: int


@dataclass(frozen=True)
class Transition:
    kind: str
    playback: Playback


def _first(library):
    if not library:
        raise ValueError("no base clips available to play video")
    return library[0]


_BLACK = Playback(
    clip_id=None,
    plan=None,
    content=ContentResolution("none", False),
    volume=0,
    brightness=0,
)


class Player:
    def __init__(
        self,
        base_library,
        *,
        choose_base: Callable = _first,
        approved_only: bool = False,
    ):
        self._library = list(base_library)
        self._choose_base = choose_base
        self._approved_only = approved_only  # reserved for catalog-backed libs
        self._current = _BLACK

    def _resolve(self, controls: Controls) -> Playback:
        content = resolve_content(controls.content)
        if not content.video:
            return Playback(
                clip_id=None,
                plan=None,
                content=content,
                volume=controls.volume,
                brightness=controls.brightness,
            )
        clip = self._choose_base(self._library)
        coord = Coordinate(controls.left, controls.right, controls.dark, controls.light)
        return Playback(
            clip_id=clip.id,
            plan=plan_alteration(coord),
            content=content,
            volume=controls.volume,
            brightness=controls.brightness,
        )

    def _classify(self, prev: Playback, nxt: Playback) -> str:
        prev_video = prev.clip_id is not None
        next_video = nxt.clip_id is not None
        if prev == nxt:
            return TransitionKind.NONE
        if prev_video and not next_video:
            return TransitionKind.FADE_TO_BLACK
        if next_video and not prev_video:
            return TransitionKind.FADE_FROM_BLACK
        if next_video and prev_video:
            if nxt.clip_id != prev.clip_id or nxt.plan.restyle != prev.plan.restyle:
                return TransitionKind.CROSSFADE
            return TransitionKind.LIVE_UPDATE
        # both black: only audio/levels could have changed
        return TransitionKind.LIVE_UPDATE

    def update(self, controls: Controls) -> Transition:
        nxt = self._resolve(controls)
        kind = self._classify(self._current, nxt)
        self._current = nxt
        return Transition(kind=kind, playback=nxt)
```

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

Run: `pytest tests/test_player_state.py -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add player/state.py tests/test_player_state.py
git commit -m "feat(player): player state machine — controls stream to transitions"
```

---

## Task 5: Register the package and run the full suite

**Files:**
- Modify: `pyproject.toml` (`[tool.setuptools] packages`)

- [ ] **Step 1: Add `"player"` to the packages list**

In `pyproject.toml`, change:

```toml
[tool.setuptools]
packages = ["hef", "tools", "simulator"]
```
to:
```toml
[tool.setuptools]
packages = ["hef", "tools", "simulator", "player"]
```

- [ ] **Step 2: Run the entire test suite**

Run: `pytest -q`
Expected: all prior tests still pass plus the new `test_player_*` files; no failures.

- [ ] **Step 3: Commit**

```bash
git add pyproject.toml
git commit -m "build(player): register the player package"
```

---

## Task 6: Update the roadmap to reflect slice 1 shipped

**Files:**
- Modify: `docs/ROADMAP.md` (§3 Player Runtime)

- [ ] **Step 1: Update §3 status and deliverables**

Under "## 3. Player Runtime (Pi)", note that the **alteration-engine + player-core slice** (pure logic) is done and tested, link this plan, and list the remaining slices (mpv/ffmpeg runtime integration + GPU grade/overlay shaders; real USB-serial reader + the 3⇄4 framing contract; white-noise DSP; offline v2v variant build pipeline; catalog model changes for audio-source + neutral-base flag). Keep the table's status marker for sub-project 3 as in-progress (⏳), not done.

- [ ] **Step 2: Commit**

```bash
git add docs/ROADMAP.md
git commit -m "docs(roadmap): sub-project 3 alteration-engine + player-core slice shipped"
```

---

## Self-Review

**Spec coverage:**
- §4.1 per-axis alteration → Task 3 (overlay=Left, restyle=Right, grade=mood). ✓
- §4.2 composition rule (substrate vs overlay; Left+Right stack) → Task 3 tests `test_left_and_right_stack_not_cancel`, `test_whole_brain_dark_corner_*`. ✓
- §4.3 runtime vs pre-baked (grade/overlay continuous; restyle variant swap) → Task 4 transition kinds (`LIVE_UPDATE` vs `CROSSFADE`). ✓
- §5 mood grade, center = identity → Task 3 `test_equal_dark_and_light_is_identity_grade`, signed tone. ✓
- §6 7-way content dial → Task 2 full table. ✓
- §7 volume + brightness intensity → Task 1 (modeled) + Task 4 (`test_volume_only_change_is_a_live_update`). ✓
- Roadmap §3 "done when: plays correct segment, loops, crossfades, goes dark on None, testable with serial mocked" → Task 4 (transitions incl. fade-to-black; serial mocked as a `Controls` stream). Looping is implicit (a clip plays until the desired playback changes); explicit loop timing belongs to the mpv integration slice (deferred, noted in Task 6). ✓ (with the runtime-loop portion deferred and documented).

**Deferred (documented, not gaps):** mpv/ffmpeg + GPU; real serial + framing; white-noise DSP; v2v offline pipeline; catalog audio-source/neutral-base fields; base-clip rotation policy; `approved_only` enforcement against a real catalog (the flag is plumbed but inert here).

**Placeholder scan:** none — every step has concrete code/commands.

**Type consistency:** `Controls(content,left,right,dark,light,volume,brightness)` used identically in Tasks 1 & 4; `RenderPlan.{grade,overlay,restyle}` and `.is_identity` consistent across Tasks 3 & 4; `ContentResolution(audio_source,video)` consistent across Tasks 2 & 4; `TransitionKind` constants consistent. `Coordinate(left,right,dark,light)` matches `hef/selection.py`.
