def project_canonical(spec: GDSSpec) -> CanonicalGDS:
"""Pure function: GDSSpec → CanonicalGDS.
Deterministic, stateless. Never mutates the spec.
"""
# 1. State space X: all entity variables
state_variables: list[tuple[str, str]] = []
for entity in spec.entities.values():
for var_name in entity.variables:
state_variables.append((entity.name, var_name))
# 2. Parameter space Θ
parameter_schema = spec.parameter_schema
# 3. Classify blocks by role
boundary_blocks: list[str] = []
control_blocks: list[str] = []
policy_blocks: list[str] = []
mechanism_blocks: list[str] = []
for bname, block in spec.blocks.items():
if isinstance(block, BoundaryAction):
boundary_blocks.append(bname)
elif isinstance(block, ControlAction):
control_blocks.append(bname)
elif isinstance(block, Policy):
policy_blocks.append(bname)
elif isinstance(block, Mechanism):
mechanism_blocks.append(bname)
# 4. Input space U: BoundaryAction forward_out ports
input_ports: list[tuple[str, str]] = []
for bname in boundary_blocks:
block = spec.blocks[bname]
for p in block.interface.forward_out:
input_ports.append((bname, p.name))
# 5. Decision space D: Policy forward_out ports
decision_ports: list[tuple[str, str]] = []
for bname in policy_blocks:
block = spec.blocks[bname]
for p in block.interface.forward_out:
decision_ports.append((bname, p.name))
# 6. Mechanism update targets
update_map: list[tuple[str, tuple[tuple[str, str], ...]]] = []
for bname in mechanism_blocks:
block = spec.blocks[bname]
if isinstance(block, Mechanism):
updates = tuple(tuple(pair) for pair in block.updates)
update_map.append((bname, updates)) # type: ignore[arg-type]
return CanonicalGDS(
state_variables=tuple(state_variables),
parameter_schema=parameter_schema,
input_ports=tuple(input_ports),
decision_ports=tuple(decision_ports),
boundary_blocks=tuple(boundary_blocks),
control_blocks=tuple(control_blocks),
policy_blocks=tuple(policy_blocks),
mechanism_blocks=tuple(mechanism_blocks),
update_map=tuple(update_map),
)