docs: implementation plan for catalog+selection core (sub-project 1/5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user