Skip to content

The LoLa Component Model

LoLa organises executable logic into components: self-contained units with a stable interface, persistent internal state, and defined lifecycle semantics. Understanding the component model is essential for reading LoLa programs and for reasoning about their correctness, because composition — not just individual block logic — is the unit of assurance.

For step-by-step instructions, see how-to/compose-components.md. For the normative wiring specification, see reference/language.md §10 (FUNCTION_BLOCK composition) and §21.14 (SSFC WIRING).


Two orthogonal axes

LoLa's type system has two independent axes:

POU kind
├── FUNCTION          pure; no persistent instance state
├── FUNCTION_BLOCK    stateful; instantiable
└── PROGRAM           stateful application root; task-invoked; not instantiable

Behavior model
├── declarative LoLa  rules, assignments, expressions
└── SSFC              states, superstates, transitions, EN/DU/EX, parallel regions

These axes are orthogonal. A FUNCTION_BLOCK can express its logic declaratively or as a state machine. FUNCTION and PROGRAM use declarative logic only.

SSFC is a behavior model, not a POU kind. An SSFC declaration defines a FUNCTION_BLOCK type — one whose logic is expressed as a state machine. The user writes:

SSFC PasteurizerSequence
  VAR_INPUT abort : BOOL; END_VAR
  INITIAL STATE Idle ... END_STATE
  ...
END_SSFC

and the type PasteurizerSequence is a FUNCTION_BLOCK type. It can be instantiated anywhere a FB type is valid, composed with other FBs, and analysed by the same ownership and recovery machinery. The SSFC ... END_SSFC syntax is the canonical compact form for a FB whose behavior is state-based; declaring the behavior model separately inside a FUNCTION_BLOCK block is out of scope (see adr-lac-pou-behavior-model.md).


Why this matters for composition

Because every SSFC declaration is a FUNCTION_BLOCK type, the composition matrix is simply: any FB can host any other FB as a child instance. The fact that some FBs were declared with declarative logic and others with SSFC behavior is transparent to the composition layer. The full matrix is therefore:

Parent (FB kind) Child (FB kind) Supported
declarative FB declarative FB
declarative FB SSFC-declared FB
SSFC-declared FB declarative FB
SSFC-declared FB SSFC-declared FB

In IEC 61131-3, function blocks and sequential function charts do not stand on the same compositional footing. LoLa removes that asymmetry by grounding both in the same POU kind. A process controller naturally expressed as a state machine (startup → heating → hold → shutdown) can host a PID FB internally; a supervisory FB can delegate to a sequencer SSFC. LoLa makes both natural by making them the same thing at the type-system level.


Instance declaration and wiring

Inside a FUNCTION_BLOCK

Child instances are declared in the VAR section and called in the body:

FUNCTION_BLOCK PasteurizerController
  VAR
    pid   : PID(Kp := 2.0, Ki := 0.1, Kd := 0.05);
    valve : ValveController;
  END_VAR
  ...
  pid(measurement := temp, setpoint := target, enable := running);
  valve(open := pid.output > 0.5);
END_FUNCTION_BLOCK

The call pid(...) is a call site: it binds each input port of the child instance to a value drawn from the parent's context. All ports must be wired exactly once per scan. After the call, the child's output ports are readable as pid.output, pid.error, and so on.

Inside an SSFC — the WIRING block

Inside an SSFC, child instances are declared in the same way (VAR section), but the binding syntax is different. An SSFC body is structured as a set of states, not a sequential list of statements. Because a state's DU (During) actions are evaluated once per scan while the state is active, there is no natural "call site" in the function-block sense. Instead, LoLa introduces the WIRING block:

SSFC Pasteurizer
  VAR_INPUT temp   : REAL; END_VAR
  VAR_INPUT target : REAL; END_VAR
  VAR
    pid : PID(Kp := 2.0, Ki := 0.1);
  END_VAR
  ...
  STATE Heating
    DU: heater := pid.output > 0.5;
    WIRING
      pid(measurement := temp,
          setpoint    := target,
          enable      := Heating.X);
    END_WIRING
  END_STATE
  ...
END_SSFC

The WIRING block is declarative, not imperative. It expresses a structural binding: for as long as this wiring is in effect, these are the values presented to the child's input ports. The child instance participates in every scan regardless of which state is active. The WIRING block is not a call that occurs on state entry; it is a description of the connections that exist. When a different state's WIRING block supersedes it (because states transition), the new bindings take effect from the next scan.

Star-wiring

The shorthand pid* automatically wires all same-named, type-compatible ports between parent and child. This is useful when a parent passes through identically-named inputs without transformation:

WIRING
  pid*;
END_WIRING

Any port that does not match by name and type must be wired explicitly alongside the star.


Persistent, always-participating semantics

A child instance declared in VAR — whether inside a FUNCTION_BLOCK or an SSFC — is persistent: its internal state (integrators, registers, flags, sub-states) survives from one scan to the next across the lifetime of the parent instance. It is always participating: it runs in every scan, unconditionally.

The second property deserves emphasis, because it is the most common source of confusion when reasoning about SSFC composition.

Entering or leaving a state does not implicitly reset, pause, or restart a child instance. The child has no awareness of the parent's state machine. It receives input values each scan and produces output values each scan. The parent SSFC controls child behaviour declaratively, by choosing what values to present to the child's inputs. If the child should be effectively inactive when a particular state is not active, the parent expresses this by wiring a gating value:

enable := Heating.X

Heating.X is the activity bit of the Heating state — true while the state is active, false otherwise. When the SSFC is not in the Heating state, the child's enable input receives FALSE, and the child's own logic decides what to do with that (typically: hold output at zero, or pass through a safe default). The child is still executing; it just receives a different input.

This is a sharp contrast with object-oriented models where a constructor runs on entry and a destructor runs on exit. In LoLa there is no such lifecycle hook. There is only: the child runs, it receives these inputs, it produces these outputs. The parent chooses what inputs to present. The child's response to those inputs is entirely its own responsibility, determined by its own definition.

This separation is intentional. It makes reasoning local: the child's behaviour is predictable from its own specification, regardless of the parent's state machine topology. It also prevents a class of subtle bugs where state-entry resets cause unexpected discontinuities in integrating outputs.


What this means for assurance

The composition model is not merely a syntactic convenience. It is the foundation for how LoLa's assurance machinery operates.

Local reasoning. Each child can be verified relative to its own contract — the specification attached to its VAR_INPUT and VAR_OUTPUT declarations. A verified child is a black box: its internal implementation does not need to be re-examined when reasoning about the parent.

Stable interfaces. Wiring is explicit and complete. There are no hidden channels between parent and child: no shared globals, no implicit state coupling, no side-channel communication through memory. Everything the parent knows about the child passes through declared output ports; everything the child knows about the parent passes through declared input ports wired at each scan.

Composable assurance. If the child holds a verified contract, parent verification reduces to reasoning about the wiring values. The question becomes: given the values the parent presents to the child's inputs, are the child's outputs within the bounds the parent relies on? This is a strictly smaller proof obligation than re-verifying the child's internals.

These properties connect directly to LoLa's two primary assurance mechanisms:

  • Write Ownership analysis: The claim that a particular signal is produced by a particular component traces through explicit wiring chains. Each link in the chain is a named port binding; there is no ambiguity about provenance.
  • Recovery and Progress analysis: The marking-graph analysis that determines whether an SSFC will eventually reach a target state operates on the full SSFC structure, including which states wire which values to which child instances. The always-participating semantics means the child's state is observable at every step of the marking-graph analysis, not only in states where the child is "active."

dt forwarding

Every SSFC receives an implicit input __ssfc_dt : REAL carrying the timestep for the current scan. This value is used by temporal constructs inside the SSFC (state timers, transition guards expressed in seconds) and must also be forwarded to any child SSFC instances, because a child SSFC has the same need for a timestep but no independent source of it.

When an SSFC hosts a child SSFC, the parent must forward its own timestep explicitly in the child's WIRING block:

WIRING
  child_seq(__ssfc_dt := __ssfc_dt);
END_WIRING

The left-hand side is the child's __ssfc_dt input port; the right-hand side is the parent's own __ssfc_dt value. This is a deliberate design: making the forwarding explicit keeps the timestep visible as a wiring dependency, so that marking-graph analysis and signal-ownership traces can account for it. A missing __ssfc_dt forwarding is a static error: the child's timestep port is an unbound input, and the wiring-completeness check will reject the program.


What composition does not do

Several behaviours that might be expected from other languages or frameworks do not occur in LoLa's component model:

State entry does not reset a child. When an SSFC transitions into a state that wires to a child instance, the child's internal state is exactly what it was at the end of the previous scan. There is no reset, reinitialisation, or warm-start triggered by the transition.

Wiring enable := FALSE does not freeze or suspend a child. The child still executes every scan. Presenting FALSE on an enable input is a value — the child's own logic determines the response. A child that does not define behaviour for enable := FALSE will continue operating as if enabled.

There is no conditional instantiation. All instances declared in VAR are instantiated when the parent is instantiated and run in every scan. A declaration inside a STATE block is not valid syntax; state-local instantiation does not exist.

There is no circular composition. If component A declares an instance of B, and B declares an instance of A, this is a static error. The instance graph is a directed acyclic graph; cycles are detected and rejected at analysis time before any scan semantics apply.