The compiled, valid-by-construction interpreter input
(docs/architecture.md:47-51, principle 4). Statifier.Compiler.compile/1
is the only producer; the interpreter is the only consumer that matters -
every other layer stops at %Statifier.Document{}.
Layout (ADR-0005)
states is a tuple of Statifier.Machine.State.t() in document order,
index 0 being the synthesized :scxml root - the spec treats <scxml> as
a state for LCCA and transition-domain purposes, so giving it a real index
removes the special case from get_transition_domain rather than adding
one; its parent and id are both nil. Every compiled state carries its
own index, its parent index (nil only at the root), and last - the
highest index in its own subtree, making the range index..last
self-inclusive and contiguous by construction. descendant?/3, ancestor?/3,
lcca/2, document_order/2, and exit_order/2 below are exactly the
integer/range comparisons ADR-0005 was adopted for - no precomputed cache,
no ancestor-path table.
The stored range is self-inclusive, but descendant?/3 is not: it is
Appendix D's isDescendant, which is a proper-descendant test, so it
compares ancestor < descendant against that range. The predicates here
carry Elixir's ? form rather than the spec's isFoo, per ADR-0002's
2026-08-09 amendment, and each names its Appendix D counterpart in its own
@doc.
id_to_index is partial: an entry only for a state with a non-nil,
non-empty id, mirroring Statifier.Validator.Checks.Ids's own uniqueness
set. There is no index_to_id map - elem(states, i).id is already that
function, total with nils, exposed here as id/2.
transitions, contents, and data_elements are dense tuples indexed by
t_index/c_index/d_index (ADR-0012 item 3). The compiler's transition
pass populates transitions (transition/2 below is its elem/2 reader,
mirroring at/2); the compiler's executable-content pass populates
contents (content/2 below, the same elem/2 reader shape); the
compiler's <data> pass populates data_elements (data/2 below, same
shape again) - see Statifier.Machine.Data's moduledoc for what a
d_index names. It lives in both places because the two carry different
access patterns: here in document order as a dense list for whole-document
passes, and on Statifier.Machine.State.data as per-state membership for
entry-time binding.
Expressions
Every cond, every <log expr=...>, every <content expr=...> or
<content> text body compiles once into expr()
(docs/architecture.md:76-82, ADR-0014). Nothing before the compiler's
expression-compilation seam builds one; the type is declared here because
it is Machine data, not because anything yet produces a {:compiled, ...}
value.
No top-level initial
The root state at index 0 already carries resolved initial indexes -
giving it a real index in the first place is what lets the root need no
special case here - so a second, Machine-level initial field would
duplicate that fact the same way storing both ends of a self-inclusive
descendant range would. initial/1 reads it off index 0.
Why global_scripts is different: not derivable from contents
global_scripts (below) looks like it should be the same kind of
redundancy this section just ruled out, and it is not, for a structural
reason rather than a policy one. Every contents entry is addressed by a
c_index that names its owning block or transition (Content.owner/0) -
that address space is the block runner's, built for a node some
<onentry>/<onexit>/transition/<if>-branch/<foreach>-body walks
and executes. A top-level <script> (spec 5.8) is a child of <scxml>
itself, run once at load time by Statifier.Interpreter.initialize/2
directly (Phase 3, ADR-0026) - no block ever runs it, so it has no
c_index, no block, and no owner to be derived from. global_scripts
is not a cache of a fact contents already states; it is the only place
the fact is stated at all.
warnings: a document-conformance finding, not a validity finding
warnings (ADR-0033) is a list of Statifier.Validator.Warning.t() the
validator produced while checking the source document, stamped onto the
Machine by Statifier.compile/1. It defaults to [] and is not in
@enforce_keys, the same way global_scripts is not: a Machine with no
warnings is exactly as valid as one with some.
This is deliberately not a second validity axis. Design principle 4
("make invalid states unrepresentable") is about the Document ->
Machine boundary: a Machine only exists because the validator's error
checks all passed, and that boundary is untouched by this field. A warning
is a statement about the document's conformance to a spec MUST the engine
nonetheless has defined behavior for either way (ADR-0033's own rationale
for why the finding does not gate compilation) - it says nothing about
whether this Machine is well-formed. A %Machine{} with a non-empty
warnings list is just as valid-by-construction as one with warnings: [].
The field exists here rather than as a third element on compile/1's
return because ADR-0012 item 3 already puts every other retained diagnostic
- locations on states, transitions, and executable content, span tables on compiled expressions - on the Machine, and a warning with a location is the same kind of thing. It is also, per ADR-0033, the only surfacing seam: no trace effect, no logger, no telemetry - a caller (or a debugger) that wants a warning finds it here or nowhere.
identity: the chart revision, not the document's validity
identity (see ADR-0052) is the Statifier.Machine.Identity.t()
Statifier.compile/2 stamps from the source it compiled, or nil for a
Machine built without going through that boundary -
Statifier.Compiler.compile/1 called directly, or a Machine an embedder's
:invoke_source resolver returned rather than one Statifier.compile/2
produced (Statifier.Invoke.Source.resolve/2's src clause). It defaults
to nil and is not in @enforce_keys, the same reasoning as
global_scripts and warnings above: a Machine with no identity is exactly
as valid as one with one. identity/1 below is the reader.
source and compile_opts: what identity was computed from
source (the SCXML binary Statifier.compile/2 compiled) and
compile_opts (the persisted subset of the opts it was called with) are
stamped onto the Machine together with identity, by compile/2 and only
by compile/2 - the same three fields, the same one producer, so
source: nil and identity: nil always co-occur, and a non-nil identity
always has the exact source it was hashed from sitting beside it.
compile_opts is never the caller's whole keyword list: it is filtered
through a closed allowlist (Statifier.compile/2's @persisted_compile_opts)
before it is stored, so an option this library does not recognize is never
carried onto the Machine. source/1 and compile_opts/1 below are the
readers.
Summary
Types
Every foo/fooexpr slot in the Machine holds one of these
(docs/architecture.md:76-82). {:static, term()} is a literal value with
no expression to evaluate; {:compiled, %Predicator.Compiled{}, source}
carries the compiled instructions and span table (ADR-0014 items 1-2)
alongside the original source string for diagnostics.
A compiled predicator statement program - a <script> body
(ADR-0026). This is a sibling of expr(), never one of its arms:
Predicator.evaluate/3 rejects a statement program outright
(deps/predicator/lib/predicator.ex:213-217), so a program has no path
through Statifier.Evaluator.evaluate/2 and is run instead through
Statifier.Evaluator.execute/2. source is carried alongside the
compiled instructions for the same reason expr()'s {:compiled, ...}
arm carries it - diagnostics on a run-time failure.
Functions
Whether ancestor is one of descendant's proper ancestors -
descendant?/3 with its arguments swapped, spelled for the reader who
wants "is X an ancestor of Y" rather than "is Y a descendant of X".
The state at index, raised if out of range - every index this module
hands back came from the Machine itself, so an out-of-range index is
always a caller bug.
Whether index's state is atomic - no children. A :final is atomic:
kind and atomicity are independent facts.
getChildStates(state1) (Appendix D): "a list containing all <state>,
<final>, and <parallel> children of state1" - index's direct
children with any :history pseudo-state child excluded, in document
order.
index's direct children, in document order - every one, :history
pseudo-states included. This is not getChildStates (Appendix D):
Appendix D's getChildStates(state1) is defined (### function getChildStates(state1)) as "a list containing all <state>, <final>,
and <parallel> children of state1" - :history is excluded by
definition. This function returns the raw children field as compiled,
because that field is also what lets State.history_children be a lookup
rather than a scan over children for the :history ones. Callers that
want the spec operation want child_states/2 instead.
The persisted subset of the options Statifier.compile/2 was called
with - filtered through its closed allowlist, in the allowlist's order,
never the caller's whole opts list. [] for a Machine built without
going through that boundary, or for a compile that passed none of the
allowlisted keys.
Whether index's state is compound: a :state or :scxml with at least
one child - derived, never stored. A :parallel is never compound even
though it has children: it enters every region simultaneously rather than
defaulting into one, so it has no positional default entry the way a
compound :state does.
The executable-content node at c_index, raised if out of range - every
c_index this module hands back (via a block's content or a
transition's content) came from the Machine itself, so an out-of-range
index is always a caller bug (mirrors at/2 and transition/2).
The <data> element at d_index, raised if out of range - every
d_index this module hands back (via a state's data) came from the
Machine itself, so an out-of-range index is always a caller bug (mirrors
at/2, transition/2, and content/2).
Whether descendant is one of ancestor's descendants - isDescendant
(Appendix D), under ADR-0002's predicate-naming amendment.
indexes sorted ascending - document order, an integer sort (ADR-0005).
indexes sorted descending - exit order, the exact reverse of document order.
Whether index's state is a <final>.
Whether index's state is a <history> pseudo-state.
The id index's state was written with, or nil for the root and for
every nameless state - the total reverse of index/2, ADR-0005's "both
directions".
The chart identity Statifier.compile/2 stamped, or nil for a Machine
built without a source: Statifier.Compiler.compile/1 called directly, or a
Machine an embedder's :invoke_source resolver returned
(Statifier.Invoke.Source.resolve/2's src clause).
The index a written state id was interned to, or :error when unknown.
The root's resolved initial indexes - there is no Machine-level
initial field; index 0 already carries it.
The least common compound ancestor of every index in indexes -
findLCCA (Appendix D): the nearest proper ancestor of the first index
that is compound (compound?/2) and is a proper ancestor of every index in
the list. Walking up from the first index's parent and stopping at the first
ancestor whose range covers every index is O(depth) with no
precomputation (ADR-0005) - the :scxml root always qualifies, so this is
total over any non-empty list of indexes belonging to one machine.
Whether index's state is a <parallel>.
index's proper ancestors, nearest first, root last - getProperAncestors
(Appendix D) called with no root bound, i.e. every ancestor up to and
including the :scxml root. index itself is excluded.
The SCXML source Statifier.compile/2 compiled this Machine from, or nil
for a Machine built without going through that boundary (mirrors
identity/1's nil cases).
The transition at t_index, raised if out of range - every t_index this
module hands back (via a state's transitions, initial_transition, or
history_default) came from the Machine itself, so an out-of-range index
is always a caller bug (mirrors at/2).
Types
@type expr() :: {:static, term()} | {:compiled, Predicator.Compiled.t(), source :: String.t()}
Every foo/fooexpr slot in the Machine holds one of these
(docs/architecture.md:76-82). {:static, term()} is a literal value with
no expression to evaluate; {:compiled, %Predicator.Compiled{}, source}
carries the compiled instructions and span table (ADR-0014 items 1-2)
alongside the original source string for diagnostics.
@type program() :: {:program, Predicator.Compiled.t(), source :: String.t()}
A compiled predicator statement program - a <script> body
(ADR-0026). This is a sibling of expr(), never one of its arms:
Predicator.evaluate/3 rejects a statement program outright
(deps/predicator/lib/predicator.ex:213-217), so a program has no path
through Statifier.Evaluator.evaluate/2 and is run instead through
Statifier.Evaluator.execute/2. source is carried alongside the
compiled instructions for the same reason expr()'s {:compiled, ...}
arm carries it - diagnostics on a run-time failure.
@type t() :: %Statifier.Machine{ binding: :early | :late, compile_opts: keyword(), contents: tuple(), data_elements: tuple(), datamodel: String.t() | nil, global_scripts: [program() | {:invalid, Statifier.Compiler.Error.t()}], id_to_index: %{optional(String.t()) => non_neg_integer()}, identity: Statifier.Machine.Identity.t() | nil, location: Statifier.Parser.Location.t(), name: String.t() | nil, source: binary() | nil, states: tuple(), transitions: tuple(), warnings: [Statifier.Validator.Warning.t()] }
Functions
@spec ancestor?( machine :: t(), ancestor :: non_neg_integer(), descendant :: non_neg_integer() ) :: boolean()
Whether ancestor is one of descendant's proper ancestors -
descendant?/3 with its arguments swapped, spelled for the reader who
wants "is X an ancestor of Y" rather than "is Y a descendant of X".
@spec at(machine :: t(), index :: non_neg_integer()) :: Statifier.Machine.State.t()
The state at index, raised if out of range - every index this module
hands back came from the Machine itself, so an out-of-range index is
always a caller bug.
@spec atomic?(machine :: t(), index :: non_neg_integer()) :: boolean()
Whether index's state is atomic - no children. A :final is atomic:
kind and atomicity are independent facts.
@spec child_states(machine :: t(), index :: non_neg_integer()) :: [non_neg_integer()]
getChildStates(state1) (Appendix D): "a list containing all <state>,
<final>, and <parallel> children of state1" - index's direct
children with any :history pseudo-state child excluded, in document
order.
@spec children(machine :: t(), index :: non_neg_integer()) :: [non_neg_integer()]
index's direct children, in document order - every one, :history
pseudo-states included. This is not getChildStates (Appendix D):
Appendix D's getChildStates(state1) is defined (### function getChildStates(state1)) as "a list containing all <state>, <final>,
and <parallel> children of state1" - :history is excluded by
definition. This function returns the raw children field as compiled,
because that field is also what lets State.history_children be a lookup
rather than a scan over children for the :history ones. Callers that
want the spec operation want child_states/2 instead.
The persisted subset of the options Statifier.compile/2 was called
with - filtered through its closed allowlist, in the allowlist's order,
never the caller's whole opts list. [] for a Machine built without
going through that boundary, or for a compile that passed none of the
allowlisted keys.
@spec compound?(machine :: t(), index :: non_neg_integer()) :: boolean()
Whether index's state is compound: a :state or :scxml with at least
one child - derived, never stored. A :parallel is never compound even
though it has children: it enters every region simultaneously rather than
defaulting into one, so it has no positional default entry the way a
compound :state does.
@spec content(machine :: t(), c_index :: non_neg_integer()) :: Statifier.Machine.Content.t()
The executable-content node at c_index, raised if out of range - every
c_index this module hands back (via a block's content or a
transition's content) came from the Machine itself, so an out-of-range
index is always a caller bug (mirrors at/2 and transition/2).
@spec data(machine :: t(), d_index :: non_neg_integer()) :: Statifier.Machine.Data.t()
The <data> element at d_index, raised if out of range - every
d_index this module hands back (via a state's data) came from the
Machine itself, so an out-of-range index is always a caller bug (mirrors
at/2, transition/2, and content/2).
@spec descendant?( machine :: t(), descendant :: non_neg_integer(), ancestor :: non_neg_integer() ) :: boolean()
Whether descendant is one of ancestor's descendants - isDescendant
(Appendix D), under ADR-0002's predicate-naming amendment.
Proper, exactly as the spec defines it ("a child, or a child of a child,
or a child of a child of a child, etc."): descendant?(m, i, i) is false.
The stored range index..last is self-inclusive by construction, so the
lower bound is strict here to exclude the ancestor itself. Appendix D relies
on that strictness - compute_exit_set must not exit the transition domain,
and find_lcca must reject a candidate that is itself in the list.
@spec document_order(machine :: t(), indexes :: Enumerable.t()) :: [non_neg_integer()]
indexes sorted ascending - document order, an integer sort (ADR-0005).
@spec exit_order(machine :: t(), indexes :: Enumerable.t()) :: [non_neg_integer()]
indexes sorted descending - exit order, the exact reverse of document order.
@spec final?(machine :: t(), index :: non_neg_integer()) :: boolean()
Whether index's state is a <final>.
@spec history?(machine :: t(), index :: non_neg_integer()) :: boolean()
Whether index's state is a <history> pseudo-state.
@spec id(machine :: t(), index :: non_neg_integer()) :: String.t() | nil
The id index's state was written with, or nil for the root and for
every nameless state - the total reverse of index/2, ADR-0005's "both
directions".
@spec identity(machine :: t()) :: Statifier.Machine.Identity.t() | nil
The chart identity Statifier.compile/2 stamped, or nil for a Machine
built without a source: Statifier.Compiler.compile/1 called directly, or a
Machine an embedder's :invoke_source resolver returned
(Statifier.Invoke.Source.resolve/2's src clause).
@spec index(machine :: t(), id :: String.t()) :: {:ok, non_neg_integer()} | :error
The index a written state id was interned to, or :error when unknown.
@spec initial(machine :: t()) :: [non_neg_integer()]
The root's resolved initial indexes - there is no Machine-level
initial field; index 0 already carries it.
@spec lcca(machine :: t(), indexes :: [non_neg_integer()]) :: non_neg_integer()
The least common compound ancestor of every index in indexes -
findLCCA (Appendix D): the nearest proper ancestor of the first index
that is compound (compound?/2) and is a proper ancestor of every index in
the list. Walking up from the first index's parent and stopping at the first
ancestor whose range covers every index is O(depth) with no
precomputation (ADR-0005) - the :scxml root always qualifies, so this is
total over any non-empty list of indexes belonging to one machine.
Appendix D tests stateList.tail(); testing the whole list is equivalent
and is what the pipeline below does, because a candidate is drawn from the
head's proper ancestors and so always passes descendant?/3 for the head.
The strictness matters for the rest of the list: when a later index is
itself an ancestor of the head - a transition targeting its own ancestor -
that index is not its own proper descendant, so the candidate equal to it is
rejected and the walk continues outward, which is the domain such a
transition must get.
Statifier.Interpreter.Selection.find_lcca/2 is the spec-named entry point
at the interpreter's surface, a defdelegate to this function - one
implementation, two names.
@spec parallel?(machine :: t(), index :: non_neg_integer()) :: boolean()
Whether index's state is a <parallel>.
@spec proper_ancestors(machine :: t(), index :: non_neg_integer()) :: [ non_neg_integer() ]
index's proper ancestors, nearest first, root last - getProperAncestors
(Appendix D) called with no root bound, i.e. every ancestor up to and
including the :scxml root. index itself is excluded.
The SCXML source Statifier.compile/2 compiled this Machine from, or nil
for a Machine built without going through that boundary (mirrors
identity/1's nil cases).
@spec transition(machine :: t(), t_index :: non_neg_integer()) :: Statifier.Machine.Transition.t()
The transition at t_index, raised if out of range - every t_index this
module hands back (via a state's transitions, initial_transition, or
history_default) came from the Machine itself, so an out-of-range index
is always a caller bug (mirrors at/2).