From 9141cd1e9f8130737bb5bbfae5d606ec14b8eec3 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:09:47 -0500 Subject: [PATCH] feat(content-build): validation gate (rungs + resolve) --- tools/content_build/resolve.py | 86 +++++++++++++++++ tools/content_build/rungs.py | 23 +++++ tools/content_build/tests/test_resolve.py | 107 ++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 tools/content_build/resolve.py create mode 100644 tools/content_build/rungs.py create mode 100644 tools/content_build/tests/test_resolve.py diff --git a/tools/content_build/resolve.py b/tools/content_build/resolve.py new file mode 100644 index 0000000..e778083 --- /dev/null +++ b/tools/content_build/resolve.py @@ -0,0 +1,86 @@ +"""The validation gate. Pure function over parsed entries: builds the id map and +enforces every schema rule, raising BuildError on the first violation. Emits +nothing. Validates ALL entries (candidate + canon) so authors get errors early; +emit (Task 4) filters to canon.""" + +from dataclasses import dataclass + +from .errors import BuildError +from .model import Entry, KNOWLEDGE_TYPES, LEGAL_NAMESPACES, STATUSES +from .rungs import ladder_rungs, legal_gates + + +@dataclass +class World: + entries: list # list[Entry] + by_id: dict # id -> Entry + + +def resolve(entries: list[Entry]) -> World: + by_id: dict[str, Entry] = {} + for e in entries: + if e.id in by_id: + raise BuildError("duplicate id", source=e.source, entry_id=e.id) + by_id[e.id] = e + + for e in entries: + if e.status not in STATUSES: + raise BuildError(f"illegal status '{e.status}'", + source=e.source, entry_id=e.id) + if e.namespace not in LEGAL_NAMESPACES: + raise BuildError(f"illegal id namespace '{e.namespace}'", + source=e.source, entry_id=e.id) + if e.namespace == "npc": + if e.type != "person": + raise BuildError("npc.* entries must have type 'person'", + source=e.source, entry_id=e.id) + elif e.type != e.namespace: + raise BuildError( + f"id namespace '{e.namespace}' must equal type '{e.type}'", + source=e.source, entry_id=e.id) + + for e in entries: + for rid in e.related: + if rid not in by_id: + raise BuildError(f"related id '{rid}' does not resolve", + source=e.source, entry_id=e.id) + + for e in entries: + if e.is_npc_layer: + if e.start_disposition is None: + raise BuildError("npc.* requires start_disposition", + source=e.source, entry_id=e.id) + if not e.knows: + raise BuildError("npc.* requires a non-empty knows list", + source=e.source, entry_id=e.id) + else: + if e.knows: + raise BuildError("non-npc entry must not carry a knows list", + source=e.source, entry_id=e.id) + if e.start_disposition is not None: + raise BuildError("non-npc entry must not carry start_disposition", + source=e.source, entry_id=e.id) + + gates = legal_gates(entries) # enforces the single ladder + rungs + rungs = set(ladder_rungs(entries)) + for e in entries: + if not e.is_npc_layer: + continue + if e.start_disposition not in rungs: + raise BuildError( + f"start_disposition '{e.start_disposition}' is not a legal rung", + source=e.source, entry_id=e.id) + for link in e.knows: + if link.fact_id not in by_id: + raise BuildError(f"knows fact '{link.fact_id}' does not resolve", + source=e.source, entry_id=e.id) + target = by_id[link.fact_id] + if target.type not in KNOWLEDGE_TYPES: + raise BuildError( + f"knows target '{link.fact_id}' is not a knowledge entry " + f"(is '{target.type}')", source=e.source, entry_id=e.id) + if link.gate not in gates: + raise BuildError(f"illegal gate '{link.gate}'", + source=e.source, entry_id=e.id) + + return World(entries=entries, by_id=by_id) diff --git a/tools/content_build/rungs.py b/tools/content_build/rungs.py new file mode 100644 index 0000000..518470d --- /dev/null +++ b/tools/content_build/rungs.py @@ -0,0 +1,23 @@ +"""The rung vocabulary, sourced from the bible's disposition-ladder entry. The +bible owns rung names/order; band NUMBERS live in client code and never here.""" + +from .errors import BuildError +from .model import Entry + +LADDER_ID = "rule.disposition-ladder" +NEVER = "never" + + +def ladder_rungs(entries: list[Entry]) -> list[str]: + ladders = [e for e in entries if e.id == LADDER_ID] + if len(ladders) != 1: + raise BuildError( + f"exactly one '{LADDER_ID}' entry required, found {len(ladders)}") + if not ladders[0].rungs: + raise BuildError("disposition-ladder needs a non-empty 'rungs:' list", + source=ladders[0].source, entry_id=LADDER_ID) + return list(ladders[0].rungs) + + +def legal_gates(entries: list[Entry]) -> set[str]: + return set(ladder_rungs(entries)) | {NEVER} diff --git a/tools/content_build/tests/test_resolve.py b/tools/content_build/tests/test_resolve.py new file mode 100644 index 0000000..de17399 --- /dev/null +++ b/tools/content_build/tests/test_resolve.py @@ -0,0 +1,107 @@ +import pytest + +from content_build.errors import BuildError +from content_build.model import Entry, KnowsLink +from content_build.resolve import resolve + + +def ladder(): + return Entry(id="rule.disposition-ladder", type="rule", status="canon", + secrecy=0, related=[], body="x", source="t:1", + rungs=["hostile", "cold", "neutral", "warm", "trusted"]) + + +def town(): + return Entry(id="town.a", type="town", status="canon", secrecy=0, + related=[], body="A town.", source="t:2") + + +def rumor(): + return Entry(id="rumor.x", type="rumor", status="canon", secrecy=1, + related=["town.a"], body="A rumor.", source="t:3") + + +def npc(**over): + base = dict(id="npc.t", type="person", status="canon", secrecy=0, + related=["town.a"], body="Persona.", source="t:4", + start_disposition="cold", + knows=[KnowsLink("rumor.x", "neutral")]) + base.update(over) + return Entry(**base) + + +def test_valid_world_resolves(): + world = resolve([ladder(), town(), rumor(), npc()]) + assert world.by_id["npc.t"].id == "npc.t" + + +def test_duplicate_id(): + with pytest.raises(BuildError): + resolve([ladder(), town(), town()]) + + +def test_illegal_namespace(): + bad = Entry(id="shrine.s", type="place", status="canon", secrecy=0, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_id_type_mismatch(): + bad = Entry(id="place.s", type="town", status="canon", secrecy=0, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_npc_type_must_be_person(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(type="town")]) + + +def test_dangling_related(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(related=["town.nowhere"])]) + + +def test_person_record_with_knows_rejected(): + rec = Entry(id="person.p", type="person", status="canon", secrecy=0, + related=[], body="x", source="t:9", + knows=[KnowsLink("rumor.x", "warm")]) + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), rec]) + + +def test_npc_missing_start_disposition(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(start_disposition=None)]) + + +def test_knows_target_must_be_knowledge_entry(): + # points at the town (a canon entity), not a knowledge entry + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("town.a", "cold")])]) + + +def test_illegal_gate(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("rumor.x", "besties")])]) + + +def test_never_is_a_legal_gate(): + world = resolve([ladder(), town(), rumor(), + npc(knows=[KnowsLink("rumor.x", "never")])]) + assert world.by_id["npc.t"].knows[0].gate == "never" + + +def test_missing_ladder(): + with pytest.raises(BuildError): + resolve([town()]) + + +def test_illegal_start_disposition(): + with pytest.raises(BuildError): + resolve([ladder(), town(), rumor(), npc(start_disposition="besties")])