Canonical Single-Source/Single-Sink Form ========================================= **Design Principle:** Every structural domain has exactly one abstract source node and one abstract sink node, regardless of how many real entry/exit points exist. Why Canonical Form? ------------------- Multiple sources and sinks complicate path analysis: - "Can I reach an exit?" becomes "Can I reach any of N exits?" - "Did I enter validly?" becomes "Did I come from any of M entries?" - Reachability checks are O(sources × sinks × nodes) With canonical form: - All entries route through one abstract SOURCE - All exits route through one abstract SINK - Reachability checks are O(1) with preprocessing - Graph algorithms become standard single-source/single-sink flows Implementation -------------- **Abstract source/sink nodes are hidden from players:** .. code-block:: python scene = SceneDomain( graph=g, label="tavern", entry_nodes=[front_door, back_door, window], # Multiple entries exit_nodes=[leave_front, leave_back, arrested], # Multiple exits ) # Creates hidden structure: # SOURCE → [front_door, back_door, window] → content → # → [leave_front, leave_back, arrested] → SINK **Real entries/exits linked to abstract nodes:** .. code-block:: python # Auto-generated during domain construction ChoiceEdge(source=scene.SOURCE, destination=front_door) ChoiceEdge(source=scene.SOURCE, destination=back_door) ChoiceEdge(source=scene.SOURCE, destination=window) ChoiceEdge(source=leave_front, destination=scene.SINK) ChoiceEdge(source=leave_back, destination=scene.SINK) ChoiceEdge(source=arrested, destination=scene.SINK) Softlock Detection ------------------ With canonical form, softlock detection is simple: .. code-block:: python def is_softlocked(cursor: Node, domain: StructuralDomain) -> bool: # Just check: can cursor reach the sink? return not cursor.can_reach(domain.sink) No need to check N different exit nodes—just check the one canonical sink. Nested Domains -------------- Domains compose cleanly by linking sinks to sources: .. code-block:: python chapter = ChapterDomain(...) scene_a = SceneDomain(...) scene_b = SceneDomain(...) # Link chapter entry to first scene ChoiceEdge(source=chapter.source, destination=scene_a.source) # Link scene A exit to scene B entry ChoiceEdge(source=scene_a.sink, destination=scene_b.source) # Link scene B exit to chapter exit ChoiceEdge(source=scene_b.sink, destination=chapter.sink) Result: One clean path from chapter.source → chapter.sink, with intermediate scene structure hidden at the chapter level. Performance ----------- **Reachability caching:** After graph mutations (PLANNING, UPDATE), reachability must be recomputed: .. code-block:: python # After provisioning new nodes/edges for node in affected_nodes: node.invalidate_reachability() Lazy recomputation happens on first query: .. code-block:: python # This triggers BFS if cache is dirty can_reach = cursor.can_reach(domain.sink) Amortized cost: O(V + E) per mutation, O(1) per query. See Also -------- - :ref:`softlock-prevention` – Using canonical form for validation - :ref:`structural-domains` – Domain composition patterns