diff --git a/compiler/splitter/category_splitter.py b/compiler/splitter/category_splitter.py new file mode 100644 index 0000000..eb21f14 --- /dev/null +++ b/compiler/splitter/category_splitter.py @@ -0,0 +1,58 @@ +"""The Category Splitter (MASTER_SPEC §18). + +Organizes Resolved Tags into the 19 canonical categories, preserving order and +without modifying, generating, or removing any tag. Each tag is placed in the +category recorded on it by the Resolver (which is the category of its Knowledge +Base Entry). + +Category ordering is deterministic (the shared canonical order); tags within a +category keep Resolver order; empty categories are omitted from the Category Map +(§18.4). +""" + +from __future__ import annotations + +from types import MappingProxyType + +from compiler.common.categories import CANONICAL_CATEGORIES, is_valid_category +from compiler.common.log import StructuredLogger +from compiler.common.message_codes import message +from compiler.common.result import CompilerResult +from schemas.models import CategoryMap, ResolvedTag + + +def split_into_categories( + tags: tuple[ResolvedTag, ...], + logger: StructuredLogger | None = None, +) -> CompilerResult: + """Group Resolved Tags into a Category Map. + + Returns: + A CompilerResult whose ``data`` is the :class:`CategoryMap` on success, + or ``None`` with an SC0008 error if any tag carries a category outside + the 19 canonical categories. + """ + buckets: dict[str, list[ResolvedTag]] = {} + for resolved in tags: + if not is_valid_category(resolved.category): + return CompilerResult().add_error( + message( + "SC0008", + f"Tag '{resolved.tag}' has invalid category '{resolved.category}'.", + tag=resolved.tag, + category=resolved.category, + ) + ) + buckets.setdefault(resolved.category, []).append(resolved) + + # Deterministic category order; empty categories omitted. Original ResolvedTag + # objects are kept verbatim (tags are never modified). + ordered = { + category: tuple(buckets[category]) + for category in CANONICAL_CATEGORIES + if category in buckets + } + category_map = CategoryMap(categories=MappingProxyType(ordered)) + if logger is not None: + logger.basic("categories_split", categories=len(ordered), tags=len(tags)) + return CompilerResult(data=category_map) diff --git a/tests/test_category_splitter.py b/tests/test_category_splitter.py new file mode 100644 index 0000000..14b7a79 --- /dev/null +++ b/tests/test_category_splitter.py @@ -0,0 +1,69 @@ +"""Tests for the Category Splitter (issue #13, MASTER_SPEC §18).""" + +from __future__ import annotations + +from compiler.common.categories import CANONICAL_CATEGORIES +from compiler.splitter.category_splitter import split_into_categories +from schemas.models import CategoryMap, ResolvedTag + + +def tag(name: str, category: str) -> ResolvedTag: + return ResolvedTag(tag=name, category=category, source_concept=name, knowledge_base_entry=name) + + +def codes(messages) -> list[str]: + return [m.code for m in messages] + + +def test_output_is_a_category_map() -> None: + result = split_into_categories((tag("1girl", "character"),)) + assert result.success + assert isinstance(result.data, CategoryMap) + + +def test_tags_land_in_their_entry_category() -> None: + tags = (tag("1girl", "character"), tag("blue eyes", "eyes"), tag("long hair", "hair")) + result = split_into_categories(tags) + assert [t.tag for t in result.data.tags_for("character")] == ["1girl"] + assert [t.tag for t in result.data.tags_for("eyes")] == ["blue eyes"] + assert [t.tag for t in result.data.tags_for("hair")] == ["long hair"] + + +def test_order_within_category_preserves_resolver_order() -> None: + tags = (tag("long hair", "hair"), tag("blonde hair", "hair"), tag("bangs", "hair")) + result = split_into_categories(tags) + assert [t.tag for t in result.data.tags_for("hair")] == ["long hair", "blonde hair", "bangs"] + + +def test_empty_categories_are_omitted() -> None: + result = split_into_categories((tag("1girl", "character"),)) + present = list(result.data.categories.keys()) + assert present == ["character"] # only the non-empty category + + +def test_category_ordering_is_canonical() -> None: + # Provide tags out of canonical order; the map must follow CANONICAL_CATEGORIES. + tags = (tag("sunset", "lighting"), tag("1girl", "character"), tag("long hair", "hair")) + result = split_into_categories(tags) + present = list(result.data.categories.keys()) + assert present == ["character", "hair", "lighting"] + assert present == [c for c in CANONICAL_CATEGORIES if c in present] + + +def test_tags_are_not_modified() -> None: + original = tag("1girl", "character") + result = split_into_categories((original,)) + assert result.data.tags_for("character")[0] is original + + +def test_empty_input_produces_empty_map() -> None: + result = split_into_categories(()) + assert result.success + assert len(result.data.categories) == 0 + + +def test_invalid_category_stops_with_sc0008() -> None: + result = split_into_categories((tag("x", "not_a_category"),)) + assert not result.success + assert result.data is None + assert "SC0008" in codes(result.errors)