#!/usr/bin/env python3 """caption_template.py — THE caption rule of record for the house arms. `PREREG-HOUSE-ARMS.md` §4.2 (the template) and §4.3 (de-camelisation), and nothing else. D-20260906-23 rules that the §4.2 template binds **with** §4.3. **Why a second caption module exists in this directory.** `intake/captions.py` renders a *different* template — a different family order, no per-family caps, and a `DISPLAY` map that RENAMES tokens (`idm` → `IDM`, `rnb` → `R&B`, `ambiental` → `ambient`). §4.3 forbids renaming outright ("no tag is invented, renamed or merged — only spaced") and §4.5 settles the conflict: *"The template of record is §4.2, this lane's, and the intake lane renders against it after the rebuild or files a delta."* This file is that template. `intake/captions.py` stays where it is as the intake lane's own artifact; nothing downstream reads it. **The template, verbatim from §4.2:** , , , 1. ```` = ``deep house`` if the track carries ``genre---deephouse``, else ``house``. 2. ```` = every ``genre---*`` tag except ``house`` and ``deephouse``, de-camelised, sorted **alphabetically by the raw tag string**, **capped at 3**. 3. ```` = every ``instrument---*`` tag, de-camelised, alphabetical, **capped at 3**. 4. ```` = every ``mood/theme---*`` tag, de-camelised, alphabetical, **capped at 2**. 5. Terms joined with ``", "``. Empty families contribute nothing (no empty commas). 6. No title. No artist. No album. No bpm. No key. 7. Deterministic: alphabetical sort on the raw tag string, fixed caps, fixed family order. 8. The trigger word is NOT in the caption text — the preprocessor prepends it from ``dataset.json``'s ``metadata.custom_tag`` (§4.6). **The template degrades honestly.** A track with no instrument tag gets no instrument word; a track with only its head tag gets a one-term caption. The renderer may not substitute a genre-typical instrument, infer a mood from a genre, copy a sibling track's tags, or fall back to the title. """ from __future__ import annotations import json from collections import Counter from pathlib import Path from typing import Mapping # §4.2 rules 2-4: the per-family caps, in the registered family order. FAMILY_ORDER = ("genre", "instrument", "mood_theme") CAPS = {"genre": 3, "instrument": 3, "mood_theme": 2} # §4.2 rule 1: these two never appear in - one of them IS # the head, and the other is subsumed by it. HEAD_TAGS = ("house", "deephouse") DEFAULT_TAG_WORDS = Path.home() / "ace-house-data" / "house-wide" / "tag_words.json" class UnknownTag(KeyError): """A tag absent from ``tag_words.json``. §4.3: *"a tag absent from the table is a HARD ERROR, never a silent pass-through, so the vocabulary cannot drift between manifest and captions."* """ class TagWords: """The §4.3 de-camelisation table, loaded once and asked per tag.""" def __init__(self, table: Mapping[str, Mapping[str, str]]): self._table = {f: dict(table[f]) for f in FAMILY_ORDER} @classmethod def load(cls, path: Path | str = DEFAULT_TAG_WORDS) -> "TagWords": with open(path, encoding="utf-8") as fh: return cls(json.load(fh)) def word(self, family: str, tag: str) -> str: try: return self._table[family][tag] except KeyError as exc: raise UnknownTag( f"tag {tag!r} of family {family!r} is not in tag_words.json — " "PREREG §4.3 makes this a hard error, never a pass-through" ) from exc def families(self) -> dict[str, int]: return {f: len(self._table[f]) for f in FAMILY_ORDER} def _family_terms(tags: Mapping[str, list[str]], family: str, words: TagWords) -> list[str]: """The de-camelised, alphabetically-sorted, capped terms for one family. The sort is on the **raw** tag string (§4.2 rule 7), so the cap selects the same tags whatever the table maps them to — the ordering is a property of the source vocabulary, not of the display words. """ raw = sorted(tags.get(family) or []) if family == "genre": raw = [t for t in raw if t not in HEAD_TAGS] return [words.word(family, t) for t in raw[: CAPS[family]]] def render(tags: Mapping[str, list[str]], words: TagWords) -> str: """Render one caption from one track's tag families. Pure; no I/O.""" head = "deep house" if "deephouse" in (tags.get("genre") or ()) else "house" terms = [head] for family in FAMILY_ORDER: terms.extend(_family_terms(tags, family, words)) return ", ".join(terms) def census(captions: list[str], tag_rows: list[Mapping[str, list[str]]]) -> dict: """The §4.4 census: what the template actually produced, for the freeze.""" counts = Counter(captions) modal, modal_n = (counts.most_common(1) or [(None, 0)])[0] runner = counts.most_common(2)[1] if len(counts) > 1 else (None, 0) n = len(captions) with_instr = sum(1 for t in tag_rows if t.get("instrument")) with_mood = sum(1 for t in tag_rows if t.get("mood_theme")) genre_only = sum( 1 for t in tag_rows if not t.get("instrument") and not t.get("mood_theme") ) return { "n": n, "distinct_captions": len(counts), "used_exactly_once": sum(1 for c in counts.values() if c == 1), "modal_caption": modal, "modal_count": modal_n, "runner_up_caption": runner[0], "runner_up_count": runner[1], "mean_terms_per_caption": ( round(sum(c.count(",") + 1 for c in captions) / n, 4) if n else 0.0 ), "tracks_with_instrument_tag": with_instr, "tracks_with_instrument_tag_pct": round(100 * with_instr / n, 2) if n else 0.0, "tracks_with_mood_tag": with_mood, "tracks_with_mood_tag_pct": round(100 * with_mood / n, 2) if n else 0.0, "genre_only_captions": genre_only, "genre_only_captions_pct": round(100 * genre_only / n, 2) if n else 0.0, "distinct_tags_available": { f: len({t for row in tag_rows for t in (row.get(f) or ())}) for f in FAMILY_ORDER }, }