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