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.

First thermostat model diagram

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:

  1. enter the composite boundary;

  2. evaluate and apply the composite’s initial transition;

  3. run plain composite during before;

  4. enter the selected child.

A child-to-child transition is different:

  1. exit the source child;

  2. run the transition effect;

  3. 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.

Hierarchy execution example diagram

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:

Event ownership

Spelling

Example

Meaning

::

Idle -> Heating :: Heat;

The source state owns a private event name.

:

Idle -> Running : Start;

A containing or named state owns the event path.

: /

Worker -> Active : /Start;

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.

Event scoping example diagram

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:

  • enter belongs to entry;

  • plain leaf during belongs to ordinary active cycles;

  • exit belongs to exit;

  • named actions create stable reference targets and generated hook names;

  • abstract says generated code must call a user-provided implementation;

  • ref reuses 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.

Abstract and reference action example diagram

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 after belongs to the composite boundary;

  • >> during before / >> during after is 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_origins records the original trigger terms and source spans;

  • combo_transitions records 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.

Combo expansion semantics diagram

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.

Combo semantic checkpoints

Checkpoint

Runtime meaning

Design reason

First event hop

Consume Request and move into a relay.

Preserve trigger order without running business side effects early.

Middle guard hop

Test ready > 0.

A false guard keeps the chain from masquerading as a completed business transition.

Final event hop

Consume Confirm, enter Accepted, and run the original effect.

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.

Forced expansion semantics diagram

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.

Forced versus combo expansion

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.

Imported module assembled into a host model

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:

Invalid ordinary event-plus-guard suffix; expected parser excerpt: 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.