The fourth arrow of the parser pipeline: a validated %Statifier.Document{}
in, {:ok, %Statifier.Machine{}} | {:error, [Statifier.Compiler.Error.t()]}
out (docs/architecture.md:47-51). Nothing downstream of this pass ever
sees a %Statifier.Document{} again - the interpreter accepts only a
Machine (docs/architecture.md principle 4).
This phase interns every state to a flat, document-order tuple with parent
pointers and self-inclusive descendant ranges (ADR-0005), resolves every
initial to indexes, compiles every <transition> element - including an
<initial> element's own transition and a :history state's default -
into Statifier.Machine.Transition via its own transition pass, and
compiles every executable-content node reachable through onentry,
onexit, or a transition's own content into Statifier.Machine.Content,
plus every :final state's <donedata> into Statifier.Machine.Donedata,
via its own executable-content pass.
One walk, not two (states); a real second pass (transitions, content,
donedata)
The "numbering walk" and "reference resolution" are, for initial
specifically, one traversal rather than two: a state's initial can only
legally name a descendant of that state (validator checks 3 and 7), and
descendants are always finished - numbered, and their own ids entered into
id_to_index - before their ancestor is, because this walk is post-order
on the way out of each subtree. So id_to_index already holds every id
resolve_initial/3 could legally need by the time it runs, and resolving
inline avoids threading the raw Statifier.Document.State tree through a
second pass just to re-ask questions the first pass already had the answer
to.
Transition target resolution cannot make the same move: a transition's
target is not constrained to be a descendant of its source, so it can be a
forward reference to a state the walk has not reached yet
(id_to_index incomplete at the point the transition is visited). t_index
and c_index assignment themselves are done inline, in the same walk
that numbers states - each state's own <onentry>/<onexit> content, then
its own transitions (its plain transitions, then its <initial> element's
transition, in that order - :history's own transitions are its default)
and each transition's own content, are assigned dense index values the
moment the state itself is visited, before the walk descends into that
state's children - mirroring how the state's own index is assigned
before its children are numbered, and matching the source's own
onentry*, onexit*, transition*, initial? element order (spec 3.3-3.9).
That is enough to keep every t_index/c_index dense and correctly
interleaved in document order without a second walk. What genuinely waits
for the whole walk to finish is turning each transition's raw target id
list into resolved indexes and compiling every expression (a transition's
cond, a <log expr=...>, a <content>'s folded value) - all of that
needs either the complete id_to_index or nothing but the raw node
itself, so build_transitions/3, build_contents/2, build_donedata_map/1,
and build_data_elements/2 each run once, after walk_siblings/4 returns.
The first three's errors accumulate together, sorted by
location.start_offset, mirroring Statifier.Lowering.finalize/2
(lib/statifier/lowering.ex:137-141) - a document with a bad cond on one
transition and a bad <log expr=...> elsewhere reports both.
build_data_elements/2 never joins that merge - a <data expr> that
fails to compile is captured onto the compiled Statifier.Machine.Data
node as {:invalid, error} rather than failing compile/1, so a
document with a bad cond and a bad <data expr> reports only the
cond's error.
<data> gets its own dense index space, d_index, assigned the same way
t_index/c_index are: a state's own <datamodel>'s <data> children
are assigned the moment the state itself is visited, before the walk
descends into its children (assign_data/3). The root's own top-level
<datamodel> is assigned before walk_siblings/4 is first called, so
top-level <data> occupy d_index 0..n-1, preceding every state-scoped
one.
All of the walk's mutable state - id_to_index, the states accumulator,
the next unused t_index/c_index/d_index, the
transitions/contents/donedata/data accumulators - travels together as one
acc() map, rather than as seven-plus positional arguments:
Credo.Check.Refactor.FunctionArity's limit is 8, and a numbering walk
that also assigns c_index and d_index genuinely needs more independent
pieces of state than that once each is its own argument.
Summary
Types
The numbering walk's threaded state (see moduledoc). id_to_index and
states_acc belong to the interning pass; t_next/transitions_acc to
the transition pass; c_next/contents_acc/donedata_acc to the
executable-content pass; d_next/data_acc to the <data> pass -
data_acc keyed by d_index, each entry carrying its raw
%Statifier.Document.Data{} and the index of the state whose own
<datamodel> declared it, so build_data_elements/2 can compile in
d_index order and compile/1 can later group entries back by state
index for with_data/2.
Functions
Compiles an already-validated %Statifier.Document{} into a flat
%Statifier.Machine{}: every state interned to a document-order index with
parent pointers and self-inclusive descendant ranges, every initial
resolved to indexes, every transition (including an <initial> element's
own and a :history state's default) compiled to
Statifier.Machine.Transition, and every reachable executable-content node
compiled to Statifier.Machine.Content or Statifier.Machine.Donedata.
Types
@type acc() :: %{ id_to_index: %{optional(String.t()) => non_neg_integer()}, states_acc: %{optional(non_neg_integer()) => Statifier.Machine.State.t()}, t_next: non_neg_integer(), transitions_acc: %{optional(non_neg_integer()) => map()}, c_next: non_neg_integer(), contents_acc: %{ optional(non_neg_integer()) => Statifier.Document.Raise.t() | Statifier.Document.Log.t() | Statifier.Document.Assign.t() | Statifier.Document.Script.t() | Statifier.Document.Send.t() | Statifier.Document.Cancel.t() | %{if: Statifier.Document.If.t(), branches: [[non_neg_integer()]]} | %{foreach: Statifier.Document.Foreach.t(), content: [non_neg_integer()]} }, donedata_acc: %{ optional(non_neg_integer()) => Statifier.Document.Donedata.t() }, d_next: non_neg_integer(), data_acc: %{ optional(non_neg_integer()) => %{ data: Statifier.Document.Data.t(), state_index: non_neg_integer() } }, invoke_acc: %{optional(non_neg_integer()) => [Statifier.Machine.Invoke.t()]}, invoke_errors: [Statifier.Compiler.Error.t()] }
The numbering walk's threaded state (see moduledoc). id_to_index and
states_acc belong to the interning pass; t_next/transitions_acc to
the transition pass; c_next/contents_acc/donedata_acc to the
executable-content pass; d_next/data_acc to the <data> pass -
data_acc keyed by d_index, each entry carrying its raw
%Statifier.Document.Data{} and the index of the state whose own
<datamodel> declared it, so build_data_elements/2 can compile in
d_index order and compile/1 can later group entries back by state
index for with_data/2.
contents_acc's value type is asymmetric on purpose: <raise>, <log>,
<assign>, and <send> are stored raw, since none of the four has
children of its own to number (<send>'s own <param>/<content> fold
onto its own fields rather than becoming further numbered content nodes,
the same way <invoke>'s do) - but an <if> and a <foreach> do, so
their entries carry the raw node alongside the c_index list(s)
assign_content_nodes/2's own recursion assigned: %{if: DIf.t(), branches: [[non_neg_integer()]]} for an <if> (one list per branch),
%{foreach: DForeach.t(), content: [non_neg_integer()]} for a <foreach>
(one flat list, one nesting level shallower) - mirroring
transitions_acc's own %{transition: ..., source: ..., content: ...}
shape (:396-408).
invoke_acc/invoke_errors belong to the <invoke> pass - the one pass
that does not fit the "numbered during the walk, compiled in a deferred
pass" split every other field above follows. A Machine.Invoke's
<finalize> is executable content, so its Machine.Block needs the same
c_next counter every <onentry>/<onexit> block draws from
(assign_blocks/2), which only exists while the walk is threading acc
through walk_siblings/4 - so the whole %Statifier.Machine.Invoke{},
finalize block included, is built complete during the walk and
accumulated into invoke_acc, keyed by owning state index, rather than
deferred the way donedata_acc is. Building inline means a typeexpr/
srcexpr/<content>/<param> compile failure cannot join the deferred
passes' own collect/1 merge the way build_transitions/3's and
build_contents/2's do - invoke_errors is where those land instead,
concatenated into compile/1's own error merge before the final sort. A
namelist entry's compile failure never reaches invoke_errors: it defers
to runtime instead, captured as {:invalid, error} on the entry's own
%Machine.Param{} (build_namelist_param/5, 5.9.4).
Functions
@spec compile(document :: Statifier.Document.t()) :: {:ok, Statifier.Machine.t()} | {:error, [Statifier.Compiler.Error.t()]}
Compiles an already-validated %Statifier.Document{} into a flat
%Statifier.Machine{}: every state interned to a document-order index with
parent pointers and self-inclusive descendant ranges, every initial
resolved to indexes, every transition (including an <initial> element's
own and a :history state's default) compiled to
Statifier.Machine.Transition, and every reachable executable-content node
compiled to Statifier.Machine.Content or Statifier.Machine.Donedata.
Returns {:ok, machine} on success. A Document that reached this stage
is expected to already be structurally valid - compile/1 does not
re-run validator checks - so {:error, errors} here has two possible
sources: a compiler defect (an id that fails to resolve during the
numbering walk), or a malformed input document whose element class still
rejects at load time under docs/datamodel.md's per-element-class
deferral policy (cond, <log expr>, <content expr>, <foreach array>, and <param>, among others - see that policy paragraph for the
full, current list). Callers should not assume the tuple is always
unexpected: routine error-event handling still applies to the
load-time-rejecting classes, while a defect is genuinely unexpected.