diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..96a947e --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: sim sim-local + +sim: + docker compose -f simulator/docker-compose.yml up --build + +sim-local: + python -m uvicorn simulator.app:app --reload --port 8000 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d89da61..232e515 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -286,3 +286,29 @@ Records point at files via `file_path`. Those files live on the player's drive, **not** in this git repo (the repo holds only the catalog metadata). Keep your `file_path` values consistent with wherever you mount that drive on the machine that will eventually run the room. + +## Playing with the simulator (curator's X-ray) + +The simulator is a web stand-in for the installation's control panel. It runs the +real `hef.selection` code against a synthetic catalog so you can feel whether the +dials surface fitting pieces before any hardware exists. + +**Run it (Docker):** + + make sim + +then open http://localhost:8000. + +**Run it (no Docker):** + + pip install -e ".[sim]" + make sim-local + +**What you see:** the five real dials (mode + Left/Right/Dark/Light), the model +knobs (brain/mood weights, pool size, approved-only), and the X-ray — the picked +piece, the ranked candidate pool with distances, and brain/mood coordinate maps +showing where your knob point and the candidates sit. + +By default it loads a generated fixture catalog. To point it at a real catalog, +set `HEF_SIM_CATALOG=catalog/library.jsonl` (used automatically when that file is +non-empty). diff --git a/hef/selection.py b/hef/selection.py index afef502..5be1f3f 100644 --- a/hef/selection.py +++ b/hef/selection.py @@ -58,6 +58,38 @@ def candidates_for_mode(records, mode: str, pool_size: int) -> list[Record]: from typing import Optional +def ranked_candidates( + records, + coord: Coordinate, + mode: str, + *, + pool_size: int = 4, + weights: Weights = Weights(), + approved_only: bool = False, +) -> list[tuple[Record, float]]: + """Mode-eligible records sorted nearest-first, each paired with its distance. + + Applies the same approved_only filter and 'av' pool fallback as select(), + then returns up to pool_size nearest (record, distance) pairs. This is the + single source of truth for "the pool"; select() picks from it. + """ + if mode not in CONTENT_MODES: + raise ValueError( + f"ranked_candidates expects a content mode {sorted(CONTENT_MODES)}, " + f"got {mode!r}" + ) + pool = records + if approved_only: + pool = [r for r in pool if r.review_status == "approved"] + candidates = candidates_for_mode(pool, mode, pool_size) + ranked = sorted( + candidates, + key=lambda r: (distance(coord, record_coordinate(r), weights), r.id), + ) + nearest = ranked[:pool_size] + return [(r, distance(coord, record_coordinate(r), weights)) for r in nearest] + + def select( records, coord: Coordinate, @@ -82,17 +114,17 @@ def select( ) 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), + ranked = ranked_candidates( + records, + coord, + mode, + pool_size=pool_size, + weights=weights, + approved_only=approved_only, ) - nearest = ranked[:pool_size] + if not ranked: + return None + nearest = [r for r, _ in ranked] if rng is None: return nearest[0] return rng.choice(nearest) diff --git a/pyproject.toml b/pyproject.toml index 96b5411..e948241 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,15 @@ requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [tool.setuptools] -packages = ["hef", "tools"] +packages = ["hef", "tools", "simulator"] [project.scripts] hef-ingest = "tools.ingest_cli:main" hef-review = "tools.review_cli:main" + +[project.optional-dependencies] +sim = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.29", + "httpx>=0.27", +] diff --git a/simulator/Dockerfile b/simulator/Dockerfile new file mode 100644 index 0000000..14b9b50 --- /dev/null +++ b/simulator/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim +WORKDIR /app +COPY pyproject.toml ./ +COPY hef ./hef +COPY tools ./tools +COPY simulator ./simulator +COPY catalog ./catalog +RUN pip install --no-cache-dir -e ".[sim]" +EXPOSE 8000 +CMD ["uvicorn", "simulator.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/simulator/__init__.py b/simulator/__init__.py new file mode 100644 index 0000000..3c28577 --- /dev/null +++ b/simulator/__init__.py @@ -0,0 +1 @@ +"""Web-based curator's X-ray simulator for the experience filter.""" diff --git a/simulator/app.py b/simulator/app.py new file mode 100644 index 0000000..10b6593 --- /dev/null +++ b/simulator/app.py @@ -0,0 +1,96 @@ +"""FastAPI service: dials -> real hef.selection -> X-ray (pick + ranked pool).""" + +from __future__ import annotations + +import os +from collections import Counter +from pathlib import Path +from typing import Literal, Optional + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from hef.catalog import load_catalog, record_to_dict +from hef.selection import ( + Coordinate, + Weights, + candidates_for_mode, + ranked_candidates, + select, +) +from simulator.fixtures import generate_fixture_catalog + +STATIC_DIR = Path(__file__).parent / "static" + + +class SelectRequest(BaseModel): + left: int = Field(ge=0, le=4) + right: int = Field(ge=0, le=4) + dark: int = Field(ge=0, le=4) + light: int = Field(ge=0, le=4) + mode: Literal["none", "audio", "video", "av"] + pool_size: int = Field(default=4, ge=1, le=25) + brain_weight: float = Field(default=1.0, ge=0.0) + mood_weight: float = Field(default=1.0, ge=0.0) + approved_only: bool = False + + +def load_catalog_or_fixtures() -> list: + """Use the real catalog if a non-empty one is configured/exists, else fixtures.""" + configured = os.environ.get("HEF_SIM_CATALOG") + path = Path(configured) if configured else Path("catalog/library.jsonl") + if path.exists() and path.stat().st_size > 0: + return load_catalog(path) + return generate_fixture_catalog() + + +def create_app(records: Optional[list] = None) -> FastAPI: + app = FastAPI(title="HEF Experience Simulator") + app.state.catalog = records if records is not None else load_catalog_or_fixtures() + + @app.post("/api/select") + def api_select(req: SelectRequest): + catalog = app.state.catalog + coord = Coordinate(req.left, req.right, req.dark, req.light) + weights = Weights(brain=req.brain_weight, mood=req.mood_weight) + if req.mode == "none": + return {"pick": None, "pool": [], "coverage": {"candidates_in_mode": 0}} + pool = catalog + if req.approved_only: + pool = [r for r in pool if r.review_status == "approved"] + eligible = candidates_for_mode(pool, req.mode, req.pool_size) + ranked = ranked_candidates( + catalog, coord, req.mode, + pool_size=req.pool_size, weights=weights, approved_only=req.approved_only, + ) + pick = select( + catalog, coord, req.mode, + pool_size=req.pool_size, weights=weights, approved_only=req.approved_only, + rng=None, + ) + return { + "pick": record_to_dict(pick) if pick else None, + "pool": [ + {"record": record_to_dict(r), "distance": d, "rank": i + 1} + for i, (r, d) in enumerate(ranked) + ], + "coverage": {"candidates_in_mode": len(eligible)}, + } + + @app.get("/api/catalog/meta") + def api_meta(): + catalog = app.state.catalog + return { + "total": len(catalog), + "by_mode": dict(Counter(r.mode for r in catalog)), + "by_status": dict(Counter(r.review_status for r in catalog)), + } + + if STATIC_DIR.exists(): + app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + + return app + + +app = create_app() diff --git a/simulator/docker-compose.yml b/simulator/docker-compose.yml new file mode 100644 index 0000000..390bbeb --- /dev/null +++ b/simulator/docker-compose.yml @@ -0,0 +1,7 @@ +services: + simulator: + build: + context: .. + dockerfile: simulator/Dockerfile + ports: + - "8000:8000" diff --git a/simulator/fixtures.py b/simulator/fixtures.py new file mode 100644 index 0000000..1d83344 --- /dev/null +++ b/simulator/fixtures.py @@ -0,0 +1,59 @@ +"""Deterministic synthetic catalog so the selection model can be felt everywhere. + +The real catalog (catalog/library.jsonl) is empty; this generates one record per +cell of the 5x5 brain x 5x5 mood coordinate space (625 records), with a seeded +mix of content modes and review statuses and no real media attached. +""" + +from __future__ import annotations + +import random + +from hef.catalog import Record + +MODES = ("audio", "video", "av") +ARCHIVES = ("internet_archive", "musopen", "librivox", "nasa", "freesound") + +_LEFT_WORDS = ("Treatise", "Lecture", "Field Notes", "Reading", "Documentary") +_RIGHT_WORDS = ("Reverie", "Nocturne", "Bloom", "Drift", "Aurora") + + +def _title(left: int, right: int, dark: int, light: int, mode: str) -> str: + a = _LEFT_WORDS[left] if left >= right else _RIGHT_WORDS[right] + return f"{a} ({mode}) L{left}R{right}D{dark}Li{light}" + + +def generate_fixture_catalog(seed: int = 1729) -> list[Record]: + """One valid Record per coordinate cell (625 total), deterministic for a seed.""" + rng = random.Random(seed) + records: list[Record] = [] + n = 0 + for left in range(5): + for right in range(5): + for dark in range(5): + for light in range(5): + mode = rng.choice(MODES) + status = rng.choice(("proposed", "approved")) + is_video = mode in ("video", "av") + records.append( + Record( + id=f"fx-{n:04d}", + title=_title(left, right, dark, light, mode), + source_url=f"https://example.test/fx/{n:04d}", + source_archive=rng.choice(ARCHIVES), + license="public_domain", + mode=mode, + left=left, + right=right, + dark=dark, + light=light, + duration_s=rng.choice((300, 480, 600, 720, 900)), + file_path="", + review_status=status, + resolution="1920x1080" if is_video else "", + rationale=f"fixture at ({left},{right},{dark},{light})", + reviewed_at="2026-06-04T00:00:00Z" if status == "approved" else None, + ) + ) + n += 1 + return records diff --git a/simulator/static/app.js b/simulator/static/app.js new file mode 100644 index 0000000..12a77f2 --- /dev/null +++ b/simulator/static/app.js @@ -0,0 +1,111 @@ +const DIALS = ["left", "right", "dark", "light"]; +const MODEL = ["brain_weight", "mood_weight", "pool_size"]; + +function buildGrid(el) { + el.innerHTML = ""; + // rows = first axis 0..4 top->bottom, cols = second axis 0..4 left->right + for (let a = 0; a < 5; a++) { + for (let b = 0; b < 5; b++) { + const cell = document.createElement("div"); + cell.className = "cell"; + cell.dataset.a = a; + cell.dataset.b = b; + el.appendChild(cell); + } + } +} + +function paintGrid(el, axisA, axisB, point, pool) { + // clear + el.querySelectorAll(".cell").forEach((c) => { + c.className = "cell"; + c.innerHTML = ""; + }); + const counts = {}; + pool.forEach((c) => { + const r = c.record; + const key = `${r[axisA]},${r[axisB]}`; + counts[key] = (counts[key] || 0) + 1; + }); + el.querySelectorAll(".cell").forEach((c) => { + const a = +c.dataset.a, b = +c.dataset.b; + const key = `${a},${b}`; + if (counts[key]) { + c.classList.add("cand"); + const n = document.createElement("span"); + n.className = "n"; + n.textContent = counts[key]; + c.appendChild(n); + } + if (a === point[axisA] && b === point[axisB]) c.classList.add("point"); + }); +} + +function readState() { + const s = { mode: document.getElementById("mode").value, approved_only: document.getElementById("approved_only").checked }; + DIALS.forEach((d) => (s[d] = +document.getElementById(d).value)); + s.brain_weight = +document.getElementById("brain_weight").value; + s.mood_weight = +document.getElementById("mood_weight").value; + s.pool_size = +document.getElementById("pool_size").value; + return s; +} + +function syncOutputs() { + [...DIALS, ...MODEL].forEach((id) => { + const out = document.getElementById(`${id}-out`); + if (out) out.textContent = document.getElementById(id).value; + }); +} + +async function refresh() { + syncOutputs(); + const state = readState(); + const resp = await fetch("/api/select", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + const data = await resp.json(); + + const pickEl = document.getElementById("pick"); + if (!data.pick) { + pickEl.innerHTML = '