DSL semantics explanation
Scope
This page explains why the FCSTM DSL behaves the way it does. It is not the syntax table; use DSL reference for exact grammar forms. It is not the first tutorial; use Write your first FCSTM DSL model for a runnable starting path. Complete simulator internals live in Execution semantics explanation.
Why variables come before one root state
A FCSTM model is one controller with one active-state stack. Persistent variables come before the root so every guard, effect, lifecycle action, import mapping, and diagnostic pass can use the same data surface.
def int temperature = 20;
state Thermostat {
[*] -> Idle;
state Idle;
}
This is not only parser convenience. Code generation needs one runtime object, one variable store, and one root active stack. Import assembly also needs one host tree into which imported modules can be rewritten.
This baseline diagram has only the authored root composite and two authored leaf states. It shows the three model surfaces that later examples build on: one variable table, one state tree, and one transition graph. Combo and forced expansion add generated structure onto those same surfaces.
Ownership tree and name resolution
States, events, lifecycle actions, and imports are owned by states. A transition can directly name endpoints visible in the state where the transition is written.
state Root {
[*] -> Parent;
state Parent {
[*] -> ChildA;
state ChildA;
state ChildB;
ChildA -> ChildB;
}
}
ChildA -> ChildB belongs inside Parent. If a parent-level rule wrote
RootOutside -> ChildB while ChildB is private to Parent, the rule
would couple the parent to a nested implementation detail. The DSL instead
encourages boundary routing: enter Parent and let Parent choose a child,
or move the child-targeting transition into Parent.
Inspect exposes the resolved paths in transitions[].from_path and
transitions[].to_path. That is the evidence to use when checking whether a
transition landed on the boundary or an internal child.
Composite entry and initial transitions
A composite state is both a boundary and a child-selection rule. Entering the boundary is not the same thing as already being in a leaf child.
Initial entry through a composite follows this conceptual order:
enter the composite boundary;
evaluate and apply the composite’s initial transition;
run plain composite
during before;enter the selected child.
A child-to-child transition is different:
exit the source child;
run the transition effect;
enter the target child.
Plain composite during before / during after does not wrap that
child-to-child movement. That boundary keeps composite entry/exit behavior from
turning into hidden behavior on every internal state change.
Use this figure to separate boundary entry from child-to-child movement. The parent and child states are authored DSL nodes; inspect lifecycle fields show whether behavior belongs to the composite boundary, a leaf, or an aspect on descendant leaf cycles.
Event scopes as ownership signals
Event spelling tells readers who owns the signal:
Spelling |
Example |
Meaning |
|---|---|---|
|
|
The source state owns a private event name. |
|
|
A containing or named state owns the event path. |
|
|
The path starts from the root-owned event namespace. |
The spelling matters during refactoring. A source-local event can be copied with
one state; a root event is public protocol. Combo terms inherit the leading
scope unless a continuation term explicitly starts with /.
Forced transitions use similar trigger spellings, but they are declaration expansion shorthand. A forced trigger still produces ordinary expanded transitions; it is not a combo chain and it cannot carry an effect.
The event-scope diagram should be read as an ownership map. Source-local
events move with their source state; root events act more like a public
protocol. Inspect event and event_scope fields when the same visual
edge could be interpreted as local or global.
Guard, effect, and expression separation
The DSL separates numeric expressions from conditions because value computation and control-flow decisions have different portability and diagnostic needs.
Sampling -> Done : if [sensor >= target] effect {
next_sample = sensor + 1;
alarm_count = (next_sample > target) ? alarm_count + 1 : alarm_count;
};
The guard is tested first. The effect runs only after the transition is chosen.
next_sample is a block-local temporary inside the effect; it does not become
persistent state after the block exits.
This separation is also where target-profile warnings must stay precise. A
numeric diagnostic about fixed-width integer range, division policy, shift count,
or float bitwise behavior is a C/C++ deployment-profile warning for c,
c_poll, cpp, and cpp_poll unless the diagnostic explicitly says
otherwise. It is not evidence that the Python generated runtime has the same
fixed-width or undefined-behavior risk.
Lifecycle, abstract hooks, and refs
Lifecycle actions attach behavior to state boundaries and active cycles:
enterbelongs to entry;plain leaf
duringbelongs to ordinary active cycles;exitbelongs to exit;named actions create stable reference targets and generated hook names;
abstractsays generated code must call a user-provided implementation;refreuses a named lifecycle action path.
state Device {
enter SharedInit {
ready = 1;
}
state Idle {
enter ref /SharedInit;
during abstract PollHardware;
}
}
ref deliberately points to a named lifecycle action, not to a state or an
event. That keeps reusable behavior explicit and avoids making state names double
as callable procedures.
This figure is a reminder that state paths and action paths are separate namespaces. Generated runtimes can expose stable hooks for abstract and named lifecycle actions because the DSL records those actions explicitly.
During before/after and aspects
Two different features use during-stage words:
plain
during before/during afterbelongs to the composite boundary;>> during before/>> during afteris an aspect contributed by an ancestor to descendant leaf-state active cycles.
The checked hierarchy_execution.fcstm example uses numeric additions to make
ordering observable. Conceptually, a leaf active cycle sees:
ancestor >> during before
parent >> during before
leaf during
parent >> during after
ancestor >> during after
A child-to-child transition does not run plain composite during before or
during after. Combo pseudo relay states also do not receive aspect actions.
Relay states are routing machinery; letting aspects observe each relay hop would
turn an implementation detail into business behavior.
Pseudo and combo relay semantics
Combo transitions solve the event-plus-guard problem without inventing an ordinary transition form that mixes separate event and guard suffixes.
Author-written transition:
Waiting -> Accepted :: Request + [ready > 0] + Confirm effect {
accepted = accepted + 1;
}
Model construction expands this into a pseudo relay chain. Inspect keeps both views:
combo_originsrecords the original trigger terms and source spans;combo_transitionsrecords generated edges with provenance;generated pseudo states are named with the reserved
__combo_prefix.
The final effect belongs to the semantic transition to Accepted. It must not
be duplicated on every relay hop. If any required event or guard term is absent
in the same cycle, the chain does not complete and the visible state should not
silently advance to the final target.
The __combo_ nodes in this diagram are generated pseudo relay states.
They have no business lifecycle actions and should not be treated as stable
business states. The authored transition from Waiting to Accepted is
split into event, guard, and final event-plus-effect hops; only the final hop
reaches the business target and runs the original effect.
Checkpoint |
Runtime meaning |
Design reason |
|---|---|---|
First event hop |
Consume |
Preserve trigger order without running business side effects early. |
Middle guard hop |
Test |
A false guard keeps the chain from masquerading as a completed business transition. |
Final event hop |
Consume |
Execute the effect exactly once after the complete trigger chain is satisfied. |
Pseudo relay state |
Carry routing only. |
Prevent generated structure from leaking into business semantics. |
W_COMBO_DUPLICATE_EVENT and combo guard diagnostics point back to the
author-written trigger terms, not merely to generated pseudo states. This is why
inspect diagnostics can guide an LLM or user back to the original DSL source.
Forced transition expansion
Forced transitions are a different kind of expansion. They duplicate one source pattern over multiple concrete sources:
!* -> ErrorHandler :: CriticalError;
The expanded transitions are ordinary transitions for runtime purposes: normal
exit actions still run, then the target’s entry behavior runs. Forced
transitions cannot carry effect blocks because a side-effectful many-source
shorthand is hard to audit. If the same update must happen for all expanded
sources, put it in the target enter action or write explicit normal
transitions.
Forced transitions also cannot carry combo + chains. Combo is ordered relay
expansion; forced is source-set expansion. Keeping them separate makes the
expanded model inspectable.
The diagram contains more expanded edges than authored forced declarations.
!* covers applicable sources in the current owner scope, while
!Running reaches the Running boundary and related child exits.
Because the expanded edges are ordinary transitions, source exits and target
entries still run normally; the forced declaration itself carries no effect.
Topic |
Combo transition |
Forced transition |
|---|---|---|
Expansion dimension |
Ordered trigger terms become a relay chain. |
One source pattern becomes multiple edges. |
Intermediate state |
Generated pseudo relay states. |
No trigger-order relay chain. |
Effect action |
Allowed, but only on the final hop. |
Disallowed to avoid hidden side-effect cloning. |
Good use case |
Event and guard terms must be satisfied in sequence. |
Many states respond to one emergency event or guard. |
Import assembly semantics
Import syntax is legal inside composite states, but file loading and module assembly run after parsing.
import "./import_worker.fcstm" as LeftWorker {
def sensor_* -> left_$1;
def speed -> plant_speed;
event /Start -> Start named "Shared Start";
}
The parser records the path, alias, optional display name, and mapping statements. The import/model layer then resolves the path, loads the imported root, checks conflicts, rewrites variable names, rewrites event paths, and adds the imported root as a child state under the alias.
Mapping templates are not arbitrary code. $0 is the whole matched imported
variable name; $1 / ${1} are capture groups from wildcard selectors;
* is the fallback template. Directory projects must import a concrete entry
file such as ./line/main.fcstm because a bare directory is not DSL source.
The imported module appears under its host alias in the state tree. Variable and event mappings are not drawn as scripts, but they affect inspect’s variable list, event table, and resolved transition paths. Use those fields as the evidence when reviewing import assembly.
Design boundaries
The DSL is intentionally narrower than a general programming language:
no loops in operation blocks;
no user-defined functions in DSL source;
event syntax and ordinary guard syntax are not combined on the same ordinary transition;
forced transitions have no effects and no combo chains;
combo relay pseudo states are pure routing helpers;
target-risk diagnostics must name their target profile.
The event-plus-guard boundary is intentionally visible. This ordinary transition shape is invalid:
Unexpected token 'if'.def int ready = 1;
state BadEventGuardMixed {
state A;
state B;
[*] -> A;
A -> B :: Go if [ready > 0];
}
Use combo syntax when both terms are required:
A -> B :: Go + [ready > 0];
The other boundaries fail at the parser or model-validation layer rather than
being silently rewritten. For example, a forced transition with an effect
block is rejected instead of cloning a side effect across many sources; a combo
relay pseudo state with lifecycle actions is rejected or warned as routing
machinery, not treated as business behavior; and numeric target-risk warnings
must stay scoped to the C/C++ deployment profiles that have fixed-width or
undefined-behavior risk.
Those boundaries keep models parseable, inspectable, simulatable, and suitable for multi-language code generation.