Best Practices: Composition Patterns & Anti-Patterns¶
Practical guidance for building clean, verifiable GDS specifications. Covers naming, composition patterns, type system tips, verification workflow, and common mistakes to avoid.
Naming Conventions¶
Port Names and Token-Based Auto-Wiring¶
The >> operator auto-wires blocks by token overlap. Port names are tokenized by splitting on + (space-plus-space) and , (comma-space), then lowercasing each part. Plain spaces are not delimiters.
from gds import interface
# "Heater Command" is ONE token: "heater command"
interface(forward_out=["Heater Command"])
# "Temperature + Setpoint" is TWO tokens: "temperature", "setpoint"
interface(forward_out=["Temperature + Setpoint"])
# This auto-wires to "Temperature" because they share the "temperature" token
interface(forward_in=["Temperature"])
Naming rules for auto-wiring
- Use plain spaces for multi-word names that should stay as one token:
"Heat Signal","Order Status" - Use
+to combine independent signals into a compound port:"Temperature + Pressure" - Use
,as an alternative compound delimiter:"Agent 1, Agent 2" - Token matching is case-insensitive:
"Heat Signal"matches"heat signal"
Block Names¶
Choose block names that read well in verification reports and diagrams:
# Good: descriptive, verb-noun for actions
BoundaryAction(name="Data Ingest", ...)
Policy(name="Validate Transform", ...)
Mechanism(name="Update Temperature", ...)
# Bad: generic, unclear role
AtomicBlock(name="Block1", ...)
Policy(name="Process", ...)
Note
Block names appear in verification findings, Mermaid diagrams, and SpecQuery results. Clear names make debugging significantly easier.
Modeling Decisions¶
Before writing any composition, three choices shape the entire specification:
Role assignment. Which processes become BoundaryActions (exogenous inputs), Policies (decision/observation logic), or Mechanisms (state updates)? This determines the canonical decomposition h = f . g. A temperature sensor could be a BoundaryAction (external data arrives) or a Policy (compute reading from state) — the right answer depends on what you want to verify, not on the physics alone.
State identification. Which quantities are state variables and which are derived? An SIR model with three state variables (S, I, R) produces a different canonical form than one that derives R = N - S - I and tracks only two. Finer state identification lets SC-001 catch orphan variables; coarser identification creates fewer obligations.
Block granularity. One large block or several small ones? The algebra composes anything with compatible ports, but finer granularity makes the hierarchy tree more informative and gives verification more to check. A single-block model passes all structural checks trivially.
These are design choices, not discoveries. Different choices lead to different verifiable specifications — neither is "wrong." Start from the question you want to answer ("Does this system avoid write conflicts on state?") and design roles backward from there.
Composition Patterns¶
The Three-Tier Pipeline¶
The canonical GDS composition follows a tiered structure that maps directly to the h = f . g decomposition:
from gds import BoundaryAction, Mechanism, Policy, interface
# Tier 1: Exogenous inputs (boundary) and observers
ingest = BoundaryAction(
name="Data Ingest",
interface=interface(forward_out=["Raw Signal"]),
)
sensor = Policy(
name="Sensor",
interface=interface(
forward_in=["State Reading"],
forward_out=["Observation"],
),
)
# Tier 2: Decision logic (policies)
controller = Policy(
name="Controller",
interface=interface(
forward_in=["Raw Signal + Observation"],
forward_out=["Command"],
),
)
# Tier 3: State dynamics (mechanisms)
update = Mechanism(
name="Update State",
interface=interface(
forward_in=["Command"],
forward_out=["State Reading"],
),
updates=[("Plant", "value")],
)
# Compose the tiers
input_tier = ingest | sensor # parallel: independent inputs
forward = input_tier >> controller >> update # sequential: data flows forward
system = forward.loop(...) # temporal: state feeds back to observers
This pattern recurs across all five DSLs:
(exogenous inputs | observers) >> (decision logic) >> (state dynamics)
.loop(state dynamics -> observers)
When to Use Auto-Wiring vs Explicit Wiring¶
Auto-wiring (>>) works when output and input ports share tokens:
# Auto-wires because "Heat Signal" tokens overlap
heater = BoundaryAction(
name="Heater",
interface=interface(forward_out=["Heat Signal"]),
)
update = Mechanism(
name="Update Temperature",
interface=interface(forward_in=["Heat Signal"]),
updates=[("Room", "temperature")],
)
pipeline = heater >> update # auto-wired via token overlap
Explicit wiring is needed when port names do not share tokens, or when you need precise control:
from gds.blocks.composition import StackComposition, Wiring
from gds.ir.models import FlowDirection
# Ports don't share tokens -- explicit wiring required
tier_transition = StackComposition(
name="Cross-Tier",
left=policy_tier,
right=mechanism_tier,
wiring=[
Wiring(
source_block="Controller",
source_port="Control Output",
target_block="Plant Dynamics",
target_port="Actuator Input",
direction=FlowDirection.COVARIANT,
),
],
)
Tip
Start with auto-wiring and only switch to explicit wiring when the compiler raises a token overlap error. This keeps compositions readable.
Feedback vs Temporal Loop¶
Two loop operators serve different purposes:
| Operator | Direction | Timing | Use Case |
|---|---|---|---|
.feedback() |
CONTRAVARIANT | Within timestep | Backward utility/reward signals |
.loop() |
COVARIANT | Across timesteps | State fed back to observers |
from gds.blocks.composition import Wiring
from gds.ir.models import FlowDirection
# Temporal loop: state at time t feeds into observer at time t+1
system_with_loop = forward_pipeline.loop(
[
Wiring(
source_block="Update State",
source_port="State Reading",
target_block="Sensor",
target_port="State Reading",
direction=FlowDirection.COVARIANT,
)
],
)
# Feedback loop: backward signal within a single timestep
# Used in game theory for utility/payoff channels
system_with_feedback = game_pipeline.feedback(
[
Wiring(
source_block="Payoff",
source_port="Agent Utility",
target_block="Decision",
target_port="Agent Utility",
direction=FlowDirection.CONTRAVARIANT,
)
],
)
Warning
.feedback() is contravariant -- it flows backward. .loop() is covariant -- it flows forward across time. Mixing these up will cause G-003 direction consistency failures.
Parallel Composition for Independent Subsystems¶
Use | to compose blocks that operate independently at the same tier:
# Two boundary actions providing independent inputs
heater_input = BoundaryAction(
name="Heater",
interface=interface(forward_out=["Heat Signal"]),
)
setpoint_input = BoundaryAction(
name="Setpoint",
interface=interface(forward_out=["Target Temperature"]),
)
# Parallel: no validation needed, ports are independent
input_tier = heater_input | setpoint_input
Parallel composition does not validate any port relationships -- it simply places blocks side by side. The downstream >> composition handles the wiring.
Anti-Patterns¶
Don't Use ControlAction¶
ControlAction exists in the type system but is unused across all five DSLs. Every DSL maps observation and decision logic to Policy instead.
# Bad: ControlAction is unused and will confuse readers
from gds import ControlAction
controller = ControlAction(name="Controller", ...)
# Good: Use Policy for all decision/observation logic
from gds import Policy
controller = Policy(name="Controller", ...)
Don't Put State Updates in Policy¶
Policy blocks compute decisions. Only Mechanism blocks write state.
# Bad: Policy should not claim to update state
controller = Policy(
name="Controller",
interface=interface(forward_in=["Signal"], forward_out=["Command"]),
# Don't try to work around this -- Mechanism is the only writer
)
# Good: Separate decision from state mutation
controller = Policy(
name="Controller",
interface=interface(forward_in=["Signal"], forward_out=["Command"]),
)
update = Mechanism(
name="Apply Command",
interface=interface(forward_in=["Command"]),
updates=[("Plant", "value")], # only Mechanism has updates
)
Don't Skip Verification¶
Even models that compile successfully benefit from verification. The checks catch subtle structural issues that compilation alone does not.
from gds import compile_system, verify
system_ir = compile_system("My Model", root=pipeline)
# Always verify -- even for "simple" models
report = verify(system_ir)
for finding in report.findings:
if not finding.passed:
print(f"[{finding.check_id}] {finding.message}")
Don't Create Circular Sequential Composition¶
The >> operator builds a DAG. Cycles in covariant flow are caught by G-006:
# Bad: creates a cycle in the covariant flow graph
a >> b >> c >> a # G-006 will flag this
# Good: use .loop() for cross-timestep feedback
forward = a >> b >> c
system = forward.loop([...]) # temporal loop, not a cycle
Don't Mix Domain Concerns in a Single Block¶
Each block should have a single responsibility aligned with its GDS role:
# Bad: one block doing both validation and state update
mega_block = AtomicBlock(
name="Do Everything",
interface=interface(
forward_in=["Raw Data"],
forward_out=["Clean Data"],
),
)
# Good: separate concerns by role
validate = Policy(
name="Validate Data",
interface=interface(forward_in=["Raw Data"], forward_out=["Clean Data"]),
)
persist = Mechanism(
name="Persist Data",
interface=interface(forward_in=["Clean Data"]),
updates=[("Dataset", "count")],
)
Type System Tips¶
Token Overlap for Auto-Wiring¶
Understanding token splitting is essential for >> composition:
from gds.types.tokens import tokenize
# Plain spaces are NOT delimiters
tokenize("Heater Command") # -> {"heater command"}
# " + " splits into separate tokens
tokenize("Temperature + Setpoint") # -> {"temperature", "setpoint"}
# ", " also splits
tokenize("Agent 1, Agent 2") # -> {"agent 1", "agent 2"}
Two ports auto-wire when their token sets overlap (share at least one token):
from gds.types.tokens import tokens_overlap
# These overlap on "temperature"
tokens_overlap("Temperature + Setpoint", "Temperature") # True
# These do NOT overlap
tokens_overlap("Heat Signal", "Temperature Reading") # False
TypeDef Constraints Are Runtime Only¶
TypeDef constraints validate data values, not compilation structure. They are never called during >> composition or compile_system():
from gds import typedef
# The constraint is checked only when you call check_value()
Temperature = typedef("Temperature", float, constraint=lambda x: -273.15 <= x <= 1000)
Temperature.check_value(20.0) # True
Temperature.check_value(-300.0) # False -- below absolute zero
# This does NOT affect compilation or wiring
Use Spaces to Define Valid Domains¶
Spaces define the shape of data flowing between blocks. Use them to document the semantic contract:
from gds import space, typedef
Voltage = typedef("Voltage", float, units="V")
Current = typedef("Current", float, units="A")
# The space documents what flows through the wire
electrical_signal = space("ElectricalSignal", voltage=Voltage, current=Current)
Verification Workflow¶
Run checks in order from fastest/cheapest to most comprehensive:
Step 1: Domain Checks (DSL-Level)¶
If using a DSL, run its domain-specific checks first. These are the fastest and catch DSL-level errors in domain-native terms:
from stockflow.verification.engine import verify as sf_verify
report = sf_verify(model) # runs SF-001..SF-005
Step 2: Generic Checks on SystemIR¶
After compilation, run the six structural topology checks:
from gds import compile_system, verify
system_ir = compile_system("My Model", root=pipeline)
report = verify(system_ir) # runs G-001..G-006
Step 3: Semantic Checks on GDSSpec¶
For full domain property validation:
from gds import (
check_canonical_wellformedness,
check_completeness,
check_determinism,
check_parameter_references,
check_type_safety,
)
for check in [
check_completeness,
check_determinism,
check_type_safety,
check_parameter_references,
check_canonical_wellformedness,
]:
findings = check(spec)
for f in findings:
if not f.passed:
print(f"[{f.check_id}] {f.message}")
G-002 and BoundaryAction
G-002 (signature completeness) requires every block to have both inputs and outputs. BoundaryAction blocks have no inputs by design -- they are exogenous. G-002 failures on BoundaryAction blocks are expected and not a bug. When running include_gds_checks=True in DSL verification, filter G-002 findings for BoundaryAction blocks.
Parameters¶
Parameters (Theta) are structural metadata. GDS never assigns values or binds parameters to concrete data. They document what is tunable:
from gds import GDSSpec, Policy, interface, typedef
spec = GDSSpec(name="Thermostat")
# Declare the parameter
Setpoint = typedef("Setpoint", float, units="celsius")
spec.register_parameter("setpoint", Setpoint)
# Reference it from a block
controller = Policy(
name="Controller",
interface=interface(forward_in=["Temperature"], forward_out=["Command"]),
params_used=["setpoint"], # structural reference, not a binding
)
spec.register_block(controller)
Use SpecQuery.param_to_blocks() to trace which blocks depend on which parameters:
from gds import SpecQuery
query = SpecQuery(spec)
query.param_to_blocks()
# -> {"setpoint": ["Controller"]}
Tip
Parameters are for documenting tunable constants (learning rate, setpoint, threshold). Don't use them for runtime configuration -- GDS has no execution engine. Parameters exist so that structural queries like "which blocks are affected by this parameter?" can be answered without simulation.
Summary¶
| Do | Don't |
|---|---|
| Use the three-tier pattern: boundary >> policy >> mechanism | Create circular sequential compositions |
| Name ports for clear token overlap | Use generic names like "Signal" everywhere |
| Start with auto-wiring, fall back to explicit | Use explicit wiring when auto-wiring works |
Use .loop() for cross-timestep state feedback |
Use .feedback() for temporal state (it is contravariant) |
| Use Policy for all decision/observation logic | Use ControlAction (unused across all DSLs) |
| Run verification even on passing models | Skip verification -- subtle issues hide in structure |
| Separate state mutation (Mechanism) from decisions (Policy) | Put state-updating logic in Policy blocks |
| Use parameters for tunable constants | Use parameters for runtime configuration |