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. U_c / W partition: BoundaryAction forward_out ports
input_ports: list[tuple[str, str]] = []
disturbance_ports: list[tuple[str, str]] = []
for bname in boundary_blocks:
block = spec.blocks[bname]
is_disturbance = getattr(block, "tags", {}).get("role") == "disturbance"
target = disturbance_ports if is_disturbance else input_ports
for p in block.interface.forward_out:
target.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. Output space Y: ControlAction forward_out ports
output_ports: list[tuple[str, str]] = []
for bname in control_blocks:
block = spec.blocks[bname]
for p in block.interface.forward_out:
output_ports.append((bname, p.name))
# 7. 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]
# 8. Admissibility dependencies
admissibility_map: list[tuple[str, tuple[tuple[str, str], ...]]] = []
for ac_name, ac in spec.admissibility_constraints.items():
deps = tuple(tuple(pair) for pair in ac.depends_on)
admissibility_map.append((ac_name, deps)) # type: ignore[arg-type]
# 9. Transition read map
read_map: list[tuple[str, tuple[tuple[str, str], ...]]] = []
for mname, ts in spec.transition_signatures.items():
reads = tuple(tuple(pair) for pair in ts.reads)
read_map.append((mname, reads)) # type: ignore[arg-type]
return CanonicalGDS(
state_variables=tuple(state_variables),
parameter_schema=parameter_schema,
input_ports=tuple(input_ports),
disturbance_ports=tuple(disturbance_ports),
decision_ports=tuple(decision_ports),
output_ports=tuple(output_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),
admissibility_map=tuple(admissibility_map),
read_map=tuple(read_map),
)