feat(content-build): validation gate (rungs + resolve)

This commit is contained in:
2026-07-11 18:09:47 -05:00
parent f21d7be4d9
commit 9141cd1e9f
3 changed files with 216 additions and 0 deletions

View File

@@ -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)