#!/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
},
}