diff --git a/docs/superpowers/plans/2026-06-04-catalog-and-selection-core.md b/docs/superpowers/plans/2026-06-04-catalog-and-selection-core.md new file mode 100644 index 0000000..f05c352 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-catalog-and-selection-core.md @@ -0,0 +1,965 @@ +# Catalog & Selection 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 tested Python library that defines the content catalog (load/save/validate JSONL records) and the nearest-match selection algorithm that maps a knob coordinate to a media record. + +**Architecture:** A small dependency-free package `hef/` with two modules — `catalog.py` (the `Record` data model + JSONL IO + validation) and `selection.py` (coordinate distance + mode filtering + nearest-match `select`). Everything is plain stdlib Python, fully unit-tested with pytest. The actual media files and hardware are out of scope here; this is the spine that the ingest tools, review tool, and Pi player (later sub-projects) all import. + +**Tech Stack:** Python 3.11+, stdlib only (`dataclasses`, `json`, `math`, `pathlib`), pytest for tests. + +**Where this sits:** This is sub-project **1 of 5** from the design spec (`docs/superpowers/specs/2026-06-04-human-experience-filter-design.md`). The others get their own plans later: (2) ingest & tagging tools + review CLI, (3) Pi player runtime, (4) Arduino firmware, (5) procedural side walls. This plan must be completed first because every other plan depends on `hef.catalog` and `hef.selection`. + +--- + +## File Structure + +``` +pyproject.toml project + pytest config +conftest.py empty; puts repo root on sys.path so `import hef` works +.gitignore python artifacts + .venv +hef/__init__.py empty package marker +hef/catalog.py Record model, validation, JSONL load/save/append +hef/selection.py Coordinate, Weights, distance, mode filter, select() +catalog/library.jsonl the real catalog data file (starts empty) +tests/test_smoke.py package imports +tests/test_catalog.py Record validation + dict round-trip + JSONL IO +tests/test_selection.py distance + mode filtering + select() +``` + +`hef/` holds the shared library (imported by the future player and tools). `catalog/` holds data only. This is a small, intentional refinement of the spec's §10 layout: the spec put "selection algorithm" under `player/`, but selection is also needed by the tagging tools, so it lives in a shared `hef/` package rather than inside the Pi-only `player/`. `player/`, `tools/`, `firmware/`, and `sidewalls/` arrive in later sub-projects. + +--- + +### Task 1: Project scaffolding + smoke test + +**Files:** +- Create: `pyproject.toml` +- Create: `conftest.py` +- Create: `.gitignore` +- Create: `hef/__init__.py` +- Create: `catalog/library.jsonl` +- Test: `tests/test_smoke.py` + +- [ ] **Step 1: Create the Python environment and install pytest** + +Run: +```bash +cd /Users/benstull/git/benstull.org/benstull/human-experience-filter-art +python3 -m venv .venv +source .venv/bin/activate +pip install pytest +``` +Expected: pytest installs without error. Use this `.venv` (activated) for every later `pytest` command. + +- [ ] **Step 2: Create `pyproject.toml`** + +```toml +[project] +name = "human-experience-filter" +version = "0.1.0" +description = "Coordinate-tuned public-domain media installation" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["hef"] +``` + +- [ ] **Step 3: Create `conftest.py` (empty)** + +An empty `conftest.py` at the repo root makes pytest insert the repo root into `sys.path`, so `import hef` resolves without `pip install -e .`. + +```python +``` +(The file is intentionally empty.) + +- [ ] **Step 4: Create `.gitignore`** + +```gitignore +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +``` + +- [ ] **Step 5: Create `hef/__init__.py` (empty) and the empty catalog file** + +`hef/__init__.py`: +```python +``` +(Empty package marker.) + +Create the empty data file: +```bash +mkdir -p hef catalog +touch hef/__init__.py catalog/library.jsonl +``` + +- [ ] **Step 6: Write the smoke test** + +`tests/test_smoke.py`: +```python +def test_package_imports(): + import hef + import hef.catalog + import hef.selection + assert hef is not None +``` + +- [ ] **Step 7: Run the smoke test (expect it to fail — modules don't exist yet)** + +Run: `python -m pytest tests/test_smoke.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'hef.catalog'`. + +- [ ] **Step 8: Create empty module stubs to make the smoke test pass** + +`hef/catalog.py`: +```python +"""Catalog data model, validation, and JSONL IO.""" +``` + +`hef/selection.py`: +```python +"""Nearest-match selection of a catalog record for a knob coordinate.""" +``` + +- [ ] **Step 9: Run the smoke test (expect pass)** + +Run: `python -m pytest tests/test_smoke.py -v` +Expected: PASS (1 passed). + +- [ ] **Step 10: Commit** + +```bash +git add pyproject.toml conftest.py .gitignore hef/ catalog/library.jsonl tests/test_smoke.py +git commit -m "chore: scaffold catalog+selection core package" +``` + +--- + +### Task 2: Record model + validation + +**Files:** +- Modify: `hef/catalog.py` +- Test: `tests/test_catalog.py` + +- [ ] **Step 1: Write the failing tests for `Record` + `validate`** + +`tests/test_catalog.py`: +```python +import pytest + +from hef.catalog import Record, validate, CatalogError + + +def make_record(**overrides): + base = dict( + id="nasa-apollo-earthrise", + title="Earthrise", + source_url="https://archive.org/details/earthrise", + source_archive="internet_archive", + license="public_domain", + mode="video", + left=1, + right=4, + dark=1, + light=3, + duration_s=600, + file_path="/media/earthrise.mp4", + ) + base.update(overrides) + return Record(**base) + + +def test_valid_record_passes(): + validate(make_record()) # must not raise + + +def test_defaults_are_set(): + record = make_record() + assert record.review_status == "proposed" + assert record.attribution == "" + assert record.reviewed_at is None + + +def test_invalid_mode_raises(): + with pytest.raises(CatalogError): + validate(make_record(mode="hologram")) + + +def test_invalid_license_raises(): + with pytest.raises(CatalogError): + validate(make_record(license="all_rights_reserved")) + + +def test_coordinate_out_of_range_raises(): + with pytest.raises(CatalogError): + validate(make_record(left=5)) + with pytest.raises(CatalogError): + validate(make_record(dark=-1)) + + +def test_boolean_coordinate_rejected(): + with pytest.raises(CatalogError): + validate(make_record(right=True)) + + +def test_cc_by_requires_attribution(): + with pytest.raises(CatalogError): + validate(make_record(license="cc_by", attribution="")) + validate(make_record(license="cc_by", attribution="Jane Doe, CC BY 4.0")) + + +def test_empty_id_raises(): + with pytest.raises(CatalogError): + validate(make_record(id="")) + + +def test_negative_duration_raises(): + with pytest.raises(CatalogError): + validate(make_record(duration_s=-1)) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_catalog.py -v` +Expected: FAIL with `ImportError: cannot import name 'Record' from 'hef.catalog'`. + +- [ ] **Step 3: Implement `Record`, constants, and `validate`** + +Replace the contents of `hef/catalog.py` with: +```python +"""Catalog data model, validation, and JSONL IO.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +MODES = frozenset({"audio", "video", "av"}) +LICENSES = frozenset({"public_domain", "cc0", "cc_by", "cc_by_nc"}) +REVIEW_STATUSES = frozenset({"proposed", "approved"}) +COORD_FIELDS = ("left", "right", "dark", "light") +COORD_MIN = 0 +COORD_MAX = 4 +ATTRIBUTION_LICENSES = frozenset({"cc_by", "cc_by_nc"}) + + +class CatalogError(ValueError): + """Raised when a catalog record is structurally invalid.""" + + +@dataclass +class Record: + id: str + title: str + source_url: str + source_archive: str + license: str + mode: str + left: int + right: int + dark: int + light: int + duration_s: int + file_path: str + review_status: str = "proposed" + attribution: str = "" + resolution: str = "" + dominant_color: str = "" + rationale: str = "" + reviewed_at: Optional[str] = None + notes: str = "" + + +def validate(record: Record) -> None: + """Raise CatalogError if the record is structurally invalid.""" + if not record.id: + raise CatalogError("record id must be non-empty") + if record.mode not in MODES: + raise CatalogError( + f"invalid mode {record.mode!r}; expected one of {sorted(MODES)}" + ) + if record.license not in LICENSES: + raise CatalogError( + f"invalid license {record.license!r}; expected one of {sorted(LICENSES)}" + ) + if record.review_status not in REVIEW_STATUSES: + raise CatalogError( + f"invalid review_status {record.review_status!r}; " + f"expected one of {sorted(REVIEW_STATUSES)}" + ) + for axis in COORD_FIELDS: + value = getattr(record, axis) + if isinstance(value, bool) or not isinstance(value, int): + raise CatalogError(f"coordinate {axis} must be an int, got {value!r}") + if not (COORD_MIN <= value <= COORD_MAX): + raise CatalogError( + f"coordinate {axis}={value} out of range {COORD_MIN}..{COORD_MAX}" + ) + if record.duration_s < 0: + raise CatalogError("duration_s must be non-negative") + if record.license in ATTRIBUTION_LICENSES and not record.attribution: + raise CatalogError(f"license {record.license} requires attribution") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_catalog.py -v` +Expected: PASS (all tests in `test_catalog.py` pass). + +- [ ] **Step 5: Commit** + +```bash +git add hef/catalog.py tests/test_catalog.py +git commit -m "feat: catalog Record model and validation" +``` + +--- + +### Task 3: Record <-> dict conversion + +**Files:** +- Modify: `hef/catalog.py` +- Test: `tests/test_catalog.py` + +- [ ] **Step 1: Add failing tests for dict conversion** + +Append to `tests/test_catalog.py`: +```python +from hef.catalog import record_to_dict, record_from_dict + + +def test_dict_round_trip(): + record = make_record() + restored = record_from_dict(record_to_dict(record)) + assert restored == record + + +def test_record_from_dict_rejects_unknown_field(): + data = record_to_dict(make_record()) + data["bogus"] = 1 + with pytest.raises(CatalogError): + record_from_dict(data) + + +def test_record_from_dict_rejects_missing_required_field(): + data = record_to_dict(make_record()) + del data["title"] + with pytest.raises(CatalogError): + record_from_dict(data) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_catalog.py -k "dict" -v` +Expected: FAIL with `ImportError: cannot import name 'record_to_dict'`. + +- [ ] **Step 3: Implement the conversion functions** + +Add to the imports at the top of `hef/catalog.py`: +```python +from dataclasses import dataclass, asdict, fields +``` +(Replace the existing `from dataclasses import dataclass` line.) + +Append to `hef/catalog.py`: +```python +def record_to_dict(record: Record) -> dict: + """Convert a Record to a plain dict suitable for JSON serialization.""" + return asdict(record) + + +def record_from_dict(data: dict) -> Record: + """Build a Record from a dict, rejecting unknown or missing fields.""" + known = {f.name for f in fields(Record)} + unknown = set(data) - known + if unknown: + raise CatalogError(f"unknown fields: {sorted(unknown)}") + try: + return Record(**data) + except TypeError as exc: + raise CatalogError(str(exc)) from exc +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_catalog.py -v` +Expected: PASS (all `test_catalog.py` tests, including the new dict tests). + +- [ ] **Step 5: Commit** + +```bash +git add hef/catalog.py tests/test_catalog.py +git commit -m "feat: catalog Record dict conversion" +``` + +--- + +### Task 4: JSONL load / save / append + +**Files:** +- Modify: `hef/catalog.py` +- Test: `tests/test_catalog.py` + +- [ ] **Step 1: Add failing tests for JSONL IO** + +Append to `tests/test_catalog.py`: +```python +from hef.catalog import load_catalog, save_catalog, append_record + + +def test_save_then_load_round_trip(tmp_path): + path = tmp_path / "library.jsonl" + records = [make_record(id="a"), make_record(id="b", mode="audio")] + save_catalog(records, path) + loaded = load_catalog(path) + assert loaded == records + + +def test_load_skips_blank_lines(tmp_path): + path = tmp_path / "library.jsonl" + save_catalog([make_record(id="a")], path) + with path.open("a", encoding="utf-8") as fh: + fh.write("\n \n") + loaded = load_catalog(path) + assert len(loaded) == 1 + + +def test_load_empty_file_returns_empty_list(tmp_path): + path = tmp_path / "library.jsonl" + path.write_text("", encoding="utf-8") + assert load_catalog(path) == [] + + +def test_load_invalid_json_raises(tmp_path): + path = tmp_path / "library.jsonl" + path.write_text("{not json}\n", encoding="utf-8") + with pytest.raises(CatalogError): + load_catalog(path) + + +def test_load_validates_records(tmp_path): + path = tmp_path / "library.jsonl" + save_catalog([make_record(id="a")], path) + import json + bad = record_to_dict(make_record(id="bad")) + bad["left"] = 9 + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(bad) + "\n") + with pytest.raises(CatalogError): + load_catalog(path) + + +def test_append_record(tmp_path): + path = tmp_path / "library.jsonl" + save_catalog([make_record(id="a")], path) + append_record(make_record(id="b"), path) + loaded = load_catalog(path) + assert [r.id for r in loaded] == ["a", "b"] + + +def test_save_rejects_invalid_record(tmp_path): + path = tmp_path / "library.jsonl" + with pytest.raises(CatalogError): + save_catalog([make_record(mode="hologram")], path) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_catalog.py -k "load or save or append" -v` +Expected: FAIL with `ImportError: cannot import name 'load_catalog'`. + +- [ ] **Step 3: Implement the JSONL IO functions** + +Add these two imports to `hef/catalog.py` **below** the existing +`from dataclasses import ...` / `from typing import Optional` lines (they must come +after `from __future__ import annotations`, which has to stay the first statement): +```python +import json +from pathlib import Path +``` + +Append to `hef/catalog.py`: +```python +def load_catalog(path) -> list[Record]: + """Load and validate every record from a JSONL file. Blank lines skipped.""" + path = Path(path) + records: list[Record] = [] + with path.open("r", encoding="utf-8") as fh: + for lineno, raw in enumerate(fh, start=1): + line = raw.strip() + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError as exc: + raise CatalogError(f"line {lineno}: invalid JSON: {exc}") from exc + record = record_from_dict(data) + validate(record) + records.append(record) + return records + + +def save_catalog(records, path) -> None: + """Validate and write all records to a JSONL file (overwrites).""" + path = Path(path) + with path.open("w", encoding="utf-8") as fh: + for record in records: + validate(record) + fh.write(json.dumps(record_to_dict(record), ensure_ascii=False) + "\n") + + +def append_record(record, path) -> None: + """Validate and append a single record to a JSONL file.""" + validate(record) + path = Path(path) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record_to_dict(record), ensure_ascii=False) + "\n") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_catalog.py -v` +Expected: PASS (all `test_catalog.py` tests). + +- [ ] **Step 5: Commit** + +```bash +git add hef/catalog.py tests/test_catalog.py +git commit -m "feat: catalog JSONL load/save/append" +``` + +--- + +### Task 5: Coordinate + distance + +**Files:** +- Modify: `hef/selection.py` +- Test: `tests/test_selection.py` + +- [ ] **Step 1: Write failing tests for distance** + +`tests/test_selection.py`: +```python +import math + +import pytest + +from hef.catalog import Record +from hef.selection import Coordinate, Weights, distance, record_coordinate + + +def make_record(**overrides): + base = dict( + id="r", + title="t", + source_url="u", + source_archive="internet_archive", + license="public_domain", + mode="video", + left=0, + right=0, + dark=0, + light=0, + duration_s=600, + file_path="/media/r.mp4", + ) + base.update(overrides) + return Record(**base) + + +def test_distance_zero_for_same_point(): + c = Coordinate(2, 2, 2, 2) + assert distance(c, c) == 0.0 + + +def test_distance_is_euclidean_by_default(): + a = Coordinate(0, 0, 0, 0) + b = Coordinate(1, 1, 1, 1) + assert distance(a, b) == pytest.approx(2.0) # sqrt(1+1+1+1) + + +def test_weights_scale_planes_independently(): + a = Coordinate(0, 0, 0, 0) + brain_only = Coordinate(1, 0, 0, 0) + mood_only = Coordinate(0, 0, 1, 0) + w = Weights(brain=4.0, mood=1.0) + assert distance(a, brain_only, w) == pytest.approx(2.0) # sqrt(4*1) + assert distance(a, mood_only, w) == pytest.approx(1.0) # sqrt(1*1) + + +def test_record_coordinate_extracts_axes(): + record = make_record(left=1, right=2, dark=3, light=4) + assert record_coordinate(record) == Coordinate(1, 2, 3, 4) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_selection.py -v` +Expected: FAIL with `ImportError: cannot import name 'Coordinate' from 'hef.selection'`. + +- [ ] **Step 3: Implement Coordinate, Weights, distance, record_coordinate** + +Replace the contents of `hef/selection.py` with: +```python +"""Nearest-match selection of a catalog record for a knob coordinate.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from hef.catalog import Record + +SELECTOR_MODES = frozenset({"none", "audio", "video", "av"}) +CONTENT_MODES = frozenset({"audio", "video", "av"}) + + +@dataclass(frozen=True) +class Coordinate: + left: int + right: int + dark: int + light: int + + +@dataclass(frozen=True) +class Weights: + brain: float = 1.0 + mood: float = 1.0 + + +def distance(a: Coordinate, b: Coordinate, weights: Weights = Weights()) -> float: + """Weighted Euclidean distance; brain plane and mood plane weighted separately.""" + brain_sq = (a.left - b.left) ** 2 + (a.right - b.right) ** 2 + mood_sq = (a.dark - b.dark) ** 2 + (a.light - b.light) ** 2 + return math.sqrt(weights.brain * brain_sq + weights.mood * mood_sq) + + +def record_coordinate(record: Record) -> Coordinate: + """The (left, right, dark, light) coordinate of a catalog record.""" + return Coordinate(record.left, record.right, record.dark, record.light) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_selection.py -v` +Expected: PASS (the distance tests pass). + +- [ ] **Step 5: Commit** + +```bash +git add hef/selection.py tests/test_selection.py +git commit -m "feat: selection Coordinate and weighted distance" +``` + +--- + +### Task 6: Mode filtering with A+V fallback + +**Files:** +- Modify: `hef/selection.py` +- Test: `tests/test_selection.py` + +- [ ] **Step 1: Add failing tests for `candidates_for_mode`** + +Append to `tests/test_selection.py`: +```python +from hef.selection import candidates_for_mode + + +def test_candidates_filter_to_exact_mode(): + records = [make_record(id="v", mode="video"), make_record(id="a", mode="audio")] + result = candidates_for_mode(records, "audio", pool_size=4) + assert [r.id for r in result] == ["a"] + + +def test_av_does_not_fall_back_when_enough_av(): + records = [make_record(id=f"av{i}", mode="av") for i in range(4)] + records.append(make_record(id="a", mode="audio")) + result = candidates_for_mode(records, "av", pool_size=4) + assert {r.id for r in result} == {"av0", "av1", "av2", "av3"} + + +def test_av_falls_back_to_audio_and_video_when_thin(): + records = [ + make_record(id="av0", mode="av"), + make_record(id="a", mode="audio"), + make_record(id="v", mode="video"), + ] + result = candidates_for_mode(records, "av", pool_size=4) + assert {r.id for r in result} == {"av0", "a", "v"} + + +def test_candidates_rejects_none_mode(): + with pytest.raises(ValueError): + candidates_for_mode([], "none", pool_size=4) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_selection.py -k "candidates or av" -v` +Expected: FAIL with `ImportError: cannot import name 'candidates_for_mode'`. + +- [ ] **Step 3: Implement `candidates_for_mode`** + +Append to `hef/selection.py`: +```python +def candidates_for_mode(records, mode: str, pool_size: int) -> list[Record]: + """Records eligible for a content mode. + + For 'av', if fewer than pool_size native 'av' records exist, fall back to + including 'audio' and 'video' records so the pool is never starved. + """ + if mode not in CONTENT_MODES: + raise ValueError( + f"candidates_for_mode expects a content mode {sorted(CONTENT_MODES)}, " + f"got {mode!r}" + ) + primary = [r for r in records if r.mode == mode] + if mode == "av" and len(primary) < pool_size: + extra = [r for r in records if r.mode in {"audio", "video"}] + return primary + extra + return primary +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_selection.py -v` +Expected: PASS (mode-filtering tests pass). + +- [ ] **Step 5: Commit** + +```bash +git add hef/selection.py tests/test_selection.py +git commit -m "feat: selection mode filtering with av fallback" +``` + +--- + +### Task 7: The `select` function + +**Files:** +- Modify: `hef/selection.py` +- Test: `tests/test_selection.py` + +- [ ] **Step 1: Add failing tests for `select`** + +Append to `tests/test_selection.py`: +```python +import random + +from hef.selection import select + + +def test_none_mode_returns_none(): + records = [make_record(id="v", mode="video")] + assert select(records, Coordinate(0, 0, 0, 0), "none") is None + + +def test_empty_pool_returns_none(): + assert select([], Coordinate(0, 0, 0, 0), "video") is None + + +def test_select_returns_nearest_by_default(): + near = make_record(id="near", mode="video", left=2, right=2, dark=2, light=2) + far = make_record(id="far", mode="video", left=0, right=0, dark=0, light=0) + result = select([far, near], Coordinate(2, 2, 2, 2), "video") + assert result.id == "near" + + +def test_select_is_deterministic_without_rng(): + records = [ + make_record(id="b", mode="video", left=1), + make_record(id="a", mode="video", left=1), + ] + # Equal distance -> tie broken by id, so "a" wins deterministically. + result = select(records, Coordinate(1, 0, 0, 0), "video") + assert result.id == "a" + + +def test_select_with_rng_picks_within_nearest_pool(): + records = [make_record(id=f"r{i}", mode="video", left=i % 5) for i in range(10)] + coord = Coordinate(2, 0, 0, 0) + rng = random.Random(0) + chosen = {select(records, coord, "video", pool_size=3, rng=rng).id for _ in range(50)} + # Only records inside the 3-nearest pool may ever be chosen. + ranked = sorted(records, key=lambda r: abs(r.left - 2)) + allowed = {r.id for r in ranked[:3]} + assert chosen <= allowed + + +def test_approved_only_excludes_proposed(): + proposed = make_record(id="p", mode="video", review_status="proposed") + approved = make_record(id="ok", mode="video", review_status="approved") + result = select([proposed, approved], Coordinate(0, 0, 0, 0), "video", + approved_only=True) + assert result.id == "ok" + + +def test_invalid_selector_mode_raises(): + with pytest.raises(ValueError): + select([], Coordinate(0, 0, 0, 0), "telepathy") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_selection.py -k "select or none or approved or rng" -v` +Expected: FAIL with `ImportError: cannot import name 'select'`. + +- [ ] **Step 3: Implement `select`** + +Append to `hef/selection.py`: +```python +from typing import Optional + + +def select( + records, + coord: Coordinate, + mode: str, + *, + pool_size: int = 4, + weights: Weights = Weights(), + approved_only: bool = False, + rng=None, +) -> Optional[Record]: + """Pick the record nearest `coord` for the given selector `mode`. + + - 'none' selector mode returns None (the void/rest state). + - With rng=None, returns the single nearest record (ties broken by id) for + deterministic behavior. Pass a random.Random to shuffle within the + pool_size nearest records. + - approved_only restricts to records the human has blessed. + """ + if mode not in SELECTOR_MODES: + raise ValueError( + f"invalid selector mode {mode!r}; expected one of {sorted(SELECTOR_MODES)}" + ) + if mode == "none": + return None + pool = records + if approved_only: + pool = [r for r in pool if r.review_status == "approved"] + candidates = candidates_for_mode(pool, mode, pool_size) + if not candidates: + return None + ranked = sorted( + candidates, + key=lambda r: (distance(coord, record_coordinate(r), weights), r.id), + ) + nearest = ranked[:pool_size] + if rng is None: + return nearest[0] + return rng.choice(nearest) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_selection.py -v` +Expected: PASS (all selection tests). + +- [ ] **Step 5: Commit** + +```bash +git add hef/selection.py tests/test_selection.py +git commit -m "feat: nearest-match select function" +``` + +--- + +### Task 8: End-to-end integration test + empty catalog check + +**Files:** +- Test: `tests/test_integration.py` + +- [ ] **Step 1: Write the integration test** + +This proves the full spine: build records, write them to JSONL, load them back, and select against a knob coordinate. It also asserts the committed empty `catalog/library.jsonl` loads cleanly to an empty list. + +`tests/test_integration.py`: +```python +from pathlib import Path + +from hef.catalog import Record, save_catalog, load_catalog +from hef.selection import Coordinate, select + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _rec(id, mode, left, right, dark, light): + return Record( + id=id, + title=id, + source_url=f"https://example.org/{id}", + source_archive="internet_archive", + license="public_domain", + mode=mode, + left=left, + right=right, + dark=dark, + light=light, + duration_s=600, + file_path=f"/media/{id}.mp4", + ) + + +def test_catalog_round_trip_then_select(tmp_path): + library = [ + _rec("lecture", "av", left=4, right=0, dark=1, light=2), + _rec("nebula", "video", left=0, right=4, dark=2, light=3), + _rec("storm", "av", left=0, right=3, dark=4, light=0), + _rec("sunrise", "av", left=1, right=3, dark=0, light=4), + ] + path = tmp_path / "library.jsonl" + save_catalog(library, path) + loaded = load_catalog(path) + + # A right-brain, very-light tuning should land on "sunrise". + chosen = select(loaded, Coordinate(left=1, right=3, dark=0, light=4), "av") + assert chosen.id == "sunrise" + + # The void state returns nothing regardless of library. + assert select(loaded, Coordinate(0, 0, 0, 0), "none") is None + + +def test_committed_empty_catalog_loads(): + path = REPO_ROOT / "catalog" / "library.jsonl" + assert load_catalog(path) == [] +``` + +- [ ] **Step 2: Run the integration test** + +Run: `python -m pytest tests/test_integration.py -v` +Expected: PASS (2 passed). + +- [ ] **Step 3: Run the full suite** + +Run: `python -m pytest -v` +Expected: PASS (every test across smoke, catalog, selection, integration). + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_integration.py +git commit -m "test: end-to-end catalog+selection integration" +``` + +--- + +## Done criteria + +- `python -m pytest -v` passes from the repo root inside the `.venv`. +- `hef.catalog` exposes `Record`, `validate`, `record_to_dict`, `record_from_dict`, `load_catalog`, `save_catalog`, `append_record`, `CatalogError`. +- `hef.selection` exposes `Coordinate`, `Weights`, `distance`, `record_coordinate`, `candidates_for_mode`, `select`, `SELECTOR_MODES`. +- `catalog/library.jsonl` exists, is empty, and loads to `[]`. + +This spine is what sub-project 2 (ingest & review tools) will write records into, and what sub-project 3 (the Pi player) will call `select()` on each time a knob moves.