The reified interpreter position - every Appendix D global and loop
variable this core keeps, per docs/observability.md constraint 1
(ADR-0012): any %MachineState{} value is a complete, inspectable,
resumable position. Statifier.Machine is the compiled document this
struct walks; interpreter modules alias Statifier.MachineState as
MachineState and Statifier.Machine.State as State, never
abbreviating either further, because the two names are one character
apart and mean very different things - a compiled state versus a runtime
position.
The configuration is full, not leaf-only (ADR-0005)
configuration holds every active state's index, ancestors included -
the same "full configuration" ADR-0005 commits to everywhere else in this
codebase. Leaf states (the ones a consumer usually wants: "what is the
machine actually doing") are active_leaf_states/1, a derived view over
configuration, never a second field to keep in sync.
The external queue is deliberately absent
Appendix D's main_event_loop owns both the internal and the external
event queue. This struct only owns the internal one. The pure core takes
one external event per call (ADR-0003); the session that drives it owns
queueing the waiting external events. This is a mechanical deviation in
ADR-0002's sense - the semantics of processing one external event are
unchanged, only the storage of the waiting ones moves outward - so it
is legal, but the divergence must carry its reason at the port site: the
main_event_loop port (Statifier.Interpreter.main_event_loop/1)
repeats this comment at its own site, where it matters.
states_to_invoke is every state entered since the last invoke pass
states_to_invoke mirrors Appendix D's statesToInvoke global: a
MapSet of state indexes, added to by Statifier.Interpreter.ExitEntry.arrive/3
(enterStates's per-state entry body) and deleted from by
Statifier.Interpreter.ExitEntry.exit_states/2 (exitStates's
for s in statesToExit: statesToInvoke.delete(s)).
Population is unconditional - a state with no <invoke> children still
lands its index here on entry, exactly as entered_states below is
populated unconditionally and for the same reason: a field whose contents
depend on whether the document happens to carry <invoke> children has a
meaning a reader cannot state without the document in hand, and
ADR-0012 constraint 1 wants a %MachineState{} that inspects as a
complete position regardless of which document produced it.
The entry-order sort Appendix D's own pseudocode performs
(statesToInvoke.sort(entryOrder)) happens at the invoke pass itself, not
here - this field stores an unordered MapSet, the same pattern
configuration uses everywhere in this module.
It has two writers by design, matching the two Appendix D procedures that
touch it: exit_states/2 deletes any state that exits before the invoke
pass runs, and the invoke pass empties whatever remains once it has walked
it. Neither is a counter function - unlike macrostep/microstep/round
above, membership here is not monotonic within a macrostep.
active_invocations is inv.invokeid hoisted off compiled data
Appendix D stores an invocation's generated identifier on the invocation
object itself (inv.invokeid), read back by the cancel walks
(cancelInvoke(inv)) and the finalize pass
(if inv.invokeid == externalEvent.invokeid: applyFinalize(...)).
Statifier.Machine.Invoke is immutable compiled data - the same "nowhere
on the state to flip a mutable flag" situation entered_states above
describes for s.isFirstEntry - so the live invokeid is hoisted onto this
struct instead, keyed to match the pseudocode's own access pattern:
active_invocations: %{{state_index, invoke_index} => invoke_id}state_index and invoke_index together name one compiled <invoke>
element (Statifier.Compiler.Expressions.owner_ref/0's {:invoke, _, _}
shape); document order for a walk over one state's invocations always
comes from the compiled invoke list itself, never from iterating this
map. This is the same ADR-0002 mechanical deviation entered_states
already declares: the semantics are unchanged (an invocation's id is still
readable by exactly the same lookups Appendix D performs), only the
storage moves off the immutable compiled document.
This is not ADR-0027 decision 3's parent-held session table. That
table maps an invokeid to {child_session_id, pid, monitor_ref} - process
identity the session layer owns. active_invocations holds none of that:
no pid, no monitor ref, no child session id, only the generated or
author-written invokeid string. The session's own table is built on top of
this one, not in place of it, by later work this struct does not depend on.
Entries are written by the invoke pass (one entry per invocation that
successfully started) and deleted by the cancel walks (a later phase's
depart/2/exit_interpreter/1 seams) when the owning invocation is
cancelled.
entered_states is s.isFirstEntry moved off the state
Appendix D tracks first entry as a mutable flag on the state itself,
s.isFirstEntry, set false the moment late binding consumes it
(enterStates, appendix-d.txt:307-315). A compiled %Machine.State{}
is immutable compile-time data in this port (docs/architecture.md
principle 4), so there is nowhere on the state to flip that flag - the set
moves to the runtime struct instead, keyed by state_index. This is an
ADR-0002 mechanical deviation: the semantics are unchanged (a state's
first entry is still detected exactly once, before any second entry could
read it), only the storage moves from the immutable compiled document to
the position that changes every microstep.
entered_states is populated unconditionally - every entered state's
index is added here, not only under binding == :late - even though only
late binding ever reads it (Statifier.Interpreter.Datamodel.enter_state/2
is a no-op under :early). Considered and rejected: gating the MapSet.put
on machine.binding == :late, which would save one put per state entry
under early binding at the cost of making the field's meaning conditional
on the document being interpreted - exactly the "not a complete,
inspectable position" failure ADR-0012 constraint 1 exists to prevent. A
%MachineState{} value must be a complete, inspectable, resumable
position regardless of which document produced it; entered_states is
that same field on every document, not only late-binding ones.
Not a substitute for states_for_default_entry
(Statifier.Interpreter.ExitEntry.entry_set()): that set is recomputed
fresh per enter_states/2 call and answers "was this state entered via its
<initial> default this time", while entered_states accumulates across
the whole session and answers "has this state ever been entered before" -
a state re-entered through history restoration has no default entry at
all, so the two sets diverge exactly where late binding's "first time"
question needs the session-long answer.
invoke_counter is the session-global platformid sequence (ADR-0008)
Spec 6.4.1 mandates a generated invoke id of the form
stateid.platformid, and its own MUST binds uniqueness to the
platformid alone - "platformid MUST be unique within the current
session" - not to the composite. invoke_counter is that platformid
source: one auto-increment for the whole session, read from and written
back to this struct by Statifier.Interpreter.generate_invoke_id/3
(lib/statifier/interpreter.ex), never per-state or per-<invoke>
element. A per-state counter would let s0.1 and s1.1 collide on
platformid 1 while still satisfying composite uniqueness, which 6.4.1
as written does not permit; one session-global counter makes every
platformid distinct by construction and also keeps the anonymous-state
fallback (inv_<counter>, no qualifier - Machine.State.id is nil for
a state with no author-written id) unique with no special case.
This field exists, instead of generating an id at the generation site,
because ADR-0003 wins where ADR-0008's identifier aesthetics collide with
the core's (state, event) -> {state, [effect]} contract: generating one
reads the wall clock and a CSPRNG, and <invoke idlocation> writes the
result into the datamodel, so minting it with entropy would make a
recorded run and its replay diverge in program state. A counter that is a
pure field on %MachineState{} replays identically and satisfies
ADR-0012 constraint 1 (inspectable, not hidden generator state). See
ADR-0008's 2026-08-15 amendment for the full argument, including why
session ids are still generated from entropy (uniqueness across
sessions, which entropy buys and a session-local counter cannot).
Starts at 0 in new/2 (no invocation has generated an id yet) and is
incremented by generate_invoke_id/3 immediately before use, so the
first generated platformid is inv_1 - the same "increment before
read" idiom begin_macrostep/1 etc. use below, though this field is not
part of that trio's enforced-by-review contract: it is written directly
at its one call site rather than through a dedicated MachineState
function, matching how active_invocations and states_to_invoke are
already mutated directly from lib/statifier/interpreter.ex and
lib/statifier/interpreter/exit_entry.ex rather than through setters.
An author-written <invoke id="..."> is used verbatim and never
composed, so it does not advance this counter - only a generated id
consumes the sequence.
send_counter is the session-global send_ id sequence (ADR-0035)
Spec 3.14 leaves <send>'s generated-id format unconstrained ("The SCXML
processor MAY generate all other ids in any format, as long as they are
unique"), unlike <invoke>'s spec-pinned stateid.platformid form.
send_counter is the one auto-increment behind that free format: one
session-global counter, read from and written back to this struct at
<send>'s own generation site (a later phase's job, not this field's),
producing ids send_1, send_2, ... in the order the processor mints
them. Same reasoning as invoke_counter above for why this is a plain
counter and not a generated id: ADR-0003's core is
(state, event) -> {state, [effect]}, and generating one reads the wall
clock and a CSPRNG, which <send idlocation> writing the result into the
datamodel would turn into an observable replay divergence. A counter that
is a pure field on %MachineState{} replays identically instead.
Not shared with invoke_counter. ADR-0035 gives two reasons: the two
sequences answer to different governing clauses (<invoke>'s format is
pinned by 6.4.1; <send>'s is free per the 3.14 sentence above, so
coupling a free-format sequence to a pinned one buys nothing), and sharing
would let an <invoke> advance the send-id sequence, making a document's
send_ ids depend on how many invocations happened first - still
deterministic and replayable, but no longer locally readable the way
idlocation wants them to be. See ADR-0035 for the full argument.
Starts at 0 in new/2 (no send has generated an id yet). An
author-written <send id="..."> is used verbatim and never advances this
counter, exactly as an author-written <invoke id="..."> leaves
invoke_counter untouched - only a generated id consumes the sequence,
and a fresh id is generated on every execution of the element (3.14: "not
at load time but each time the element is executed"), never memoized on
it. No setter function, matching invoke_counter's own precedent: written
directly at its one call site rather than through a dedicated
MachineState function.
timer_counter is the session-global durable-timer ordinal (ADR-0059)
%Effect.SendDelayed{} and %Effect.Cancel{} both carry the position
fields macrostep/microstep/round/c_index/owner, and a
<foreach> body re-executes the same content node - the same c_index -
once per iteration, in the same microstep, under the same author-written
id. That collision is what a durable host's dedup key cannot resolve on
its own; timer_counter mints the disambiguator, read as ordinal onto
each effect at the moment it is built.
Starts at 0 in new/2 (no durable-timer effect has been built yet). It
advances on every construction of a %SendDelayed{} or a %Cancel{}
- author-written id or not, inside a
<foreach>or not - unlikesend_counter, which only a generated id consumes. It is never reset, and the increment-before-read idiom is the same oneinvoke_counterandsend_counteruse: the first minted ordinal is1. No setter function, matchinginvoke_counter's andsend_counter's own precedent - but unlike either of those, this field is written directly at two call sites rather than one:lib/statifier/machine/content/send.ex's delayed-send construction andlib/statifier/machine/content/cancel.ex's<cancel>construction, since one session-global sequence spans both effect kinds (ADR-0059).
caller_context is the current macrostep's caller (ADR-0063)
caller_context :: term() is transient per-macrostep fold state naming
the opaque host term the macrostep's triggering external event carried -
nil when nothing sent one (initialization, cancellation, an event sent
without a context). Every macrostep-opening core entry point overwrites
it, so it never holds a stale value, and there are exactly three
writers, all in Statifier.Interpreter: handle_event/2 writes the
triggering event's caller_context at the macrostep's head, and
initialize/2 and cancel/1 write nil - their macrosteps have no
sending caller. Internal-event rounds never touch it: an internal event
was raised by the chart inside the same macrostep, so the macrostep's
attribution stands. The two durable-timer effect constructors
(lib/statifier/machine/content/send.ex's delayed branch and
lib/statifier/machine/content/cancel.ex) copy it onto
%Effect.SendDelayed{}/%Effect.Cancel{} at the same sites that read
timer_counter. Because the field is pure fold state, replay re-mints
it byte-identically from the recorded events (ADR-0034). No setter
function, matching timer_counter's precedent: written directly at its
three call sites. It is deliberately absent from Statifier.Position's
export - a position is written at quiescence, where no macrostep is
open and the value attributes nothing (ADR-0063 decision 5).
running and status differ only across exit_interpreter
running is Appendix D's running flag verbatim: the interpreter loop's
continue condition, set false when a top-level final is entered.
status is :running | :done and only becomes :done after
exit_interpreter has finished and the terminal {:done, _} effect has
been produced. The two therefore differ for exactly the window between
top-level final entry and the end of exit_interpreter - the window in
which exit_interpreter still has to run onexit content on a machine
that is no longer running. new/2 produces running: true, status: :running; a machine_state that has not been initialized yet is
indistinguishable from a running one, which is harmless because new/2
is only ever called by Statifier.Interpreter.initialize/2, immediately
before it enters the initial states.
The counter contract
new/2setsmacrostep: 0, microstep: 0, round: 0. Zero means "no macrostep has begun", "no microstep of the current macrostep has begun", and "no round of the current macrostep's fold has begun", respectively; none of the three is ever the number of a real step or round.begin_macrostep/1is the only writer ofmacrostep: it increments it by one and resets bothmicrostepandroundto0. Its callers areStatifier.Interpreter.initialize/2, once (so the initialization macrostep is macrostep 1), andStatifier.Interpreter.handle_event/2, once per accepted external event (so the first external event is macrostep 2).begin_microstep/1is the only writer ofmicrostep: it increments it by one. It is called once per pseudocode microstep - one exit/execute/enter round - so the first microstep of a macrostep is microstep 1. Its callers areStatifier.Interpreter.initialize/2, directly, once, immediately before its ownenter_states/2call (the initial entry is itself a microstep even though the pseudocode'senterStates([doc.initial.transition])sits outsidemicrostep), andrun_selected/3, the private tail shared by every selection round, on the branch where the selected transition set is non-empty.- A selection round that dequeues an internal event enabling no
transitions does not advance
microstep: no exit or entry happened, so there was no microstep. The consumed event is still visible, because the event-dequeued trace effect is emitted at the current counters. begin_round/1is the only writer ofround: it increments it by one. Its single call site isStatifier.Interpreter.microstep/1's head, both clauses included, so the first round of a macrostep's fold is round 1 and every round the fold spends is counted, whether or not it advancesmicrostep- which is what makesrounddefined even undermax_macrostep_rounds: :infinity, where it counts up rather than deriving from the budget (ADR-0020). Effects emitted before the fold begins -handle_event/2's ownEventDequeuedand everythinginitialize/2emits before entering the fold - are stampedround: 0for the same reason they are stampedmicrostep: 0: no round has begun yet.- Cause metadata and trace effects are both stamped with the counters
as they stand at the moment of the stamp, i.e. after the
begin_*call for the step they belong to.
No later function may assign macrostep, microstep, or round
directly; the contract above is enforced by review (there being exactly
three writer functions, begin_macrostep/1, begin_microstep/1, and
begin_round/1), not mechanically.
System variables live in the datamodel
Spec 5.10 places _sessionid, _name, _ioprocessors, and _event
alongside the author's own data in the datamodel - datamodel therefore
is not "the author's data only". Statifier.Evaluator.SystemVariables
owns the shape of each; this module owns when each is written, and each
has exactly one writer:
_sessionid,_name, and_ioprocessorsare written exactly once, bynew/2, fromSystemVariables.initial/2merged over the:datamodeloption's map - an author-supplied datamodel can never shadow a system variable._eventis seeded tonilbynew/2from the sameSystemVariables.initial/2map, and thereafter written only byput_event/2. That is one writer per phase rather than two writers of one value: the seed is what makes_eventdeclared for the session's whole lifetime with no value yet (spec 5.10, and seeSystemVariables.initial/2's own docs for why an absent key would say something different and wrong), andput_event/2is the only thing that ever gives it one. The same enforced-by-review status the counter contract above states formacrostep/microstepapplies to that second half.
Every key in datamodel is a string, at every level, for every reachable
%MachineState{} value - not merely "in practice today" but by
construction. Every writer except the :datamodel option produces string
keys because every other key this codebase ever writes comes from an SCXML
id, which is a string by construction; the :datamodel option is the one
source whose keys a caller chooses, and new/2 checks it (checked_datamodel!/1
below) before it ever reaches the struct. The invariant exists because the
expression context built over this map (Statifier.Evaluator.context/1)
binds each root by string name - a map that failed the invariant would
either crash at the first evaluation site or be silently rewritten under a
precedence rule the caller never saw, neither of which this module lets
happen.
Seeding the system variables in new/2 rather than at
interpret's own datamodel-initialization step is a mechanical deviation
from Appendix D's ordering, not a semantic one (ADR-0002):
Statifier.Interpreter.initialize/2 calls new/2 as its first statement,
so the variables are bound before anything can read them, and new/2 is
the only writer of the struct's initial fields regardless.
== is not a position-equality test
internal_queue is an :queue.queue/0. Two :queue values holding the
same events in the same order can differ structurally - the front/rear
split depends on the push/pop history - so == on two %MachineState{}
values is not a reliable "same position" test. A comparison that needs
to know whether two machine_states are at the same interpreter position (a
fold-to-quiescence-versus-step-by-step acceptance test, for one) must
compare a normalized view - configuration, internal_events/1,
history_values, the counters, status - never raw struct equality.
internal_events/1 is exactly that normalized, inspection-and-assertion
view of the queue; no code outside this module touches :queue directly.
Summary
Types
The datamodel slot - a map today (docs/datamodel.md:33-41's evaluation
context is a predicator context, i.e. a map), typed as map() rather
than term()/any() so that filling in real datamodel evaluation only
adds content, not shape, and dialyzer has something to check in the
meantime.
The caller-declared registered <invoke type> set (ADR-0051), or nil.
nil means "the built-in set only": no :invoke_handlers were passed at
session start, so Statifier.Invoke.Types.registered?/2 answers exactly
Statifier.Send.Target.supported_invoke_type?/1 - nil, "scxml", and
the bare http://www.w3.org/TR/scxml/ URI. Unlike routes/0, this is
stamped once per session rather than per drive: the registered set is a
start_link/2 option, fixed for the session's whole lifetime.
The round budget one macrostep's fold may spend - pos_integer(), or
:infinity for the spec's literal unbounded behavior (ADR-0019). Set once
in new/2 and read-only thereafter, like trace. One round is one
Statifier.Interpreter.microstep/1 call inside the fold, empty rounds
included; it is not a microstep count, because a livelocked fold can run
forever without advancing the microstep counter at all. The round
counter counts the same rounds this budget bounds, so at exhaustion
round equals the spent budget (ADR-0020).
The caller-declared route snapshot (ADR-0048), or nil. nil means "no
determination made": the driver stamped nothing before this drive, so
Statifier.Machine.Content.Send's reachability arm makes no judgment and
the effect is emitted exactly as it was before ADR-0048 - the session's
deliver/5 boundary stays the detector on that path (ADR-0048 decision
5). A non-nil value is a point-in-time claim, not a subscription: it
carries no obligation to track anything between writes.
Whether trace effects are emitted. A plain boolean, not a level: the
Effect.trace/3 gate's contract is one field read and nothing built when
off, and a later level would arrive as a separate field so this one
never turns into a comparison.
Functions
The active leaf states - configuration filtered by
Statifier.Machine.atomic?/2. A :final is atomic - kind and
atomicity are independent facts (Machine.atomic?/2) - and therefore
appears in this view. :history pseudo-states never enter
configuration in the first place, so this filter never has to exclude
them - there is nothing history-shaped to filter out. The string-id
translation of this view belongs to a future API boundary, not this
module's: everything here stays integer indexes (ADR-0005).
Begins a new macrostep: increments macrostep by one and resets both
microstep and round to 0. The only writer of macrostep (the
counter contract above).
Begins a new microstep: increments microstep by one, leaving
macrostep unchanged. The only writer of microstep (the counter
contract above) - called once per exit/execute/enter round, never for a
selection round that enabled no transitions.
Begins a new round of this macrostep's fold: increments round by one,
leaving macrostep and microstep unchanged. The only writer of round
(the counter contract above) - called once per
Statifier.Interpreter.microstep/1 invocation, empty rounds and the
terminal probe included, which is the same definition of a round that
max_macrostep_rounds uses (ADR-0020).
Dequeues the oldest event on the internal queue - {:ok, event, machine_state} with the event removed, or :empty when the queue holds
nothing.
Enqueues event on the internal queue, FIFO. The one and only writer of
internal_queue alongside dequeue_internal/1.
Mints a fresh sess_ session id (ADR-0008 as amended): a 48-bit
big-endian millisecond timestamp followed by 80 bits of CSPRNG output,
Crockford base32-encoded. Public so Statifier.Session can resolve the
id it must put in an ADR-0048 route snapshot's own session set before
Statifier.Interpreter.initialize/2 runs - a snapshot has to name the
declaring session's own id before that id is otherwise known, since
new/2 would not generate it until initialize/2 calls it.
The pending internal events as a plain list, oldest first - the FIFO
order the queue's own opaque structure does not directly expose. This is
the inspection and assertion path for every test and every future
debugger; no interpreter code path needs it, since dequeue_internal/1
alone drives selection.
Whether the internal queue holds no pending events - internalQueue.isEmpty()
(Appendix D), the post-invoke re-check's own test. Cheaper than
internal_events/1 == []: :queue.is_empty/1 never materializes the
queue into a list, so this is the predicate the interpreter's hot path
uses, and internal_events/1 stays the inspection-and-assertion view.
A fresh position over machine: empty configuration, empty internal
queue, no history values, counters at zero, running: true,
status: :running. Does not enter any state - entering the initial
configuration is the not-yet-implemented initialize/2's job, not this
constructor's.
datamodel["_event"] = event (Appendix D) - the one and only writer of
the _event system variable (spec 5.10.1). Both of mainEventLoop's
assignments, the external one (Statifier.Interpreter.handle_event/2)
and the internal one (Statifier.Interpreter.internal_round/1), go
through here.
Stamps invoke_types onto machine_state - ADR-0051's registered-type
snapshot, re-supplied by the driver rather than carried as durable position
state (Statifier.Position.import/2 sets it nil for exactly this reason).
Stamps routes onto machine_state - ADR-0048 decision 2's per-drive
snapshot write, called by Statifier.Session immediately before each
core drive. Pass nil to clear a snapshot back to "no determination
made" (routes/0).
Raises an internal event: builds its Cause from origin and the
machine_state's own counters as they stand right now, builds the
:internal event, and enqueues it - so cause metadata cannot be
forgotten at a call site. This is the function <raise>'s
executable-content implementation (Statifier.Machine.Content.Raise)
calls. done.state.* is a :platform event per spec 5.10.1 and is
raised through raise_platform/4 instead - both enqueue on this same
internal queue, type is provenance, not routing.
Raises a platform event: identical to raise_internal/4 except it builds
an Event.platform/3 event instead of Event.internal/3 - so type is
stamped :platform per spec 5.10.1. This is the function
Statifier.Interpreter.ExitEntry.raise_parent_completion/3 calls for
done.state.*: Statifier.Event's moduledoc classifies done.state.*
as a platform-raised event, not one raised by executable content, and
raise_internal/4 would stamp the wrong type.
Types
@type datamodel() :: map()
The datamodel slot - a map today (docs/datamodel.md:33-41's evaluation
context is a predicator context, i.e. a map), typed as map() rather
than term()/any() so that filling in real datamodel evaluation only
adds content, not shape, and dialyzer has something to check in the
meantime.
@type invoke_types() :: Statifier.Invoke.Types.t() | nil
The caller-declared registered <invoke type> set (ADR-0051), or nil.
nil means "the built-in set only": no :invoke_handlers were passed at
session start, so Statifier.Invoke.Types.registered?/2 answers exactly
Statifier.Send.Target.supported_invoke_type?/1 - nil, "scxml", and
the bare http://www.w3.org/TR/scxml/ URI. Unlike routes/0, this is
stamped once per session rather than per drive: the registered set is a
start_link/2 option, fixed for the session's whole lifetime.
@type max_macrostep_rounds() :: pos_integer() | :infinity
The round budget one macrostep's fold may spend - pos_integer(), or
:infinity for the spec's literal unbounded behavior (ADR-0019). Set once
in new/2 and read-only thereafter, like trace. One round is one
Statifier.Interpreter.microstep/1 call inside the fold, empty rounds
included; it is not a microstep count, because a livelocked fold can run
forever without advancing the microstep counter at all. The round
counter counts the same rounds this budget bounds, so at exhaustion
round equals the spent budget (ADR-0020).
@type routes() :: Statifier.Send.Routes.t() | nil
The caller-declared route snapshot (ADR-0048), or nil. nil means "no
determination made": the driver stamped nothing before this drive, so
Statifier.Machine.Content.Send's reachability arm makes no judgment and
the effect is emitted exactly as it was before ADR-0048 - the session's
deliver/5 boundary stays the detector on that path (ADR-0048 decision
5). A non-nil value is a point-in-time claim, not a subscription: it
carries no obligation to track anything between writes.
@type t() :: %Statifier.MachineState{ active_invocations: %{ optional({non_neg_integer(), non_neg_integer()}) => String.t() }, caller_context: term(), configuration: MapSet.t(non_neg_integer()), datamodel: datamodel(), entered_states: MapSet.t(non_neg_integer()), history_values: %{optional(non_neg_integer()) => MapSet.t(non_neg_integer())}, internal_queue: :queue.queue(Statifier.Event.t()), invoke_counter: non_neg_integer(), invoke_types: invoke_types(), machine: Statifier.Machine.t(), macrostep: non_neg_integer(), max_macrostep_rounds: max_macrostep_rounds(), microstep: non_neg_integer(), round: non_neg_integer(), routes: routes(), running: boolean(), send_counter: non_neg_integer(), states_to_invoke: MapSet.t(non_neg_integer()), status: :running | :done, timer_counter: non_neg_integer(), trace: trace() }
@type trace() :: boolean()
Whether trace effects are emitted. A plain boolean, not a level: the
Effect.trace/3 gate's contract is one field read and nothing built when
off, and a later level would arrive as a separate field so this one
never turns into a comparison.
Functions
@spec active_leaf_states(machine_state :: t()) :: MapSet.t(non_neg_integer())
The active leaf states - configuration filtered by
Statifier.Machine.atomic?/2. A :final is atomic - kind and
atomicity are independent facts (Machine.atomic?/2) - and therefore
appears in this view. :history pseudo-states never enter
configuration in the first place, so this filter never has to exclude
them - there is nothing history-shaped to filter out. The string-id
translation of this view belongs to a future API boundary, not this
module's: everything here stays integer indexes (ADR-0005).
This is O(n) in the configuration size and is meant for the API
boundary and for inspection, not for a per-microstep interpreter path.
Begins a new macrostep: increments macrostep by one and resets both
microstep and round to 0. The only writer of macrostep (the
counter contract above).
Begins a new microstep: increments microstep by one, leaving
macrostep unchanged. The only writer of microstep (the counter
contract above) - called once per exit/execute/enter round, never for a
selection round that enabled no transitions.
Begins a new round of this macrostep's fold: increments round by one,
leaving macrostep and microstep unchanged. The only writer of round
(the counter contract above) - called once per
Statifier.Interpreter.microstep/1 invocation, empty rounds and the
terminal probe included, which is the same definition of a round that
max_macrostep_rounds uses (ADR-0020).
@spec dequeue_internal(machine_state :: t()) :: {:ok, Statifier.Event.t(), t()} | :empty
Dequeues the oldest event on the internal queue - {:ok, event, machine_state} with the event removed, or :empty when the queue holds
nothing.
@spec enqueue_internal(machine_state :: t(), event :: Statifier.Event.t()) :: t()
Enqueues event on the internal queue, FIFO. The one and only writer of
internal_queue alongside dequeue_internal/1.
@spec generate_session_id() :: String.t()
Mints a fresh sess_ session id (ADR-0008 as amended): a 48-bit
big-endian millisecond timestamp followed by 80 bits of CSPRNG output,
Crockford base32-encoded. Public so Statifier.Session can resolve the
id it must put in an ADR-0048 route snapshot's own session set before
Statifier.Interpreter.initialize/2 runs - a snapshot has to name the
declaring session's own id before that id is otherwise known, since
new/2 would not generate it until initialize/2 calls it.
@spec internal_events(machine_state :: t()) :: [Statifier.Event.t()]
The pending internal events as a plain list, oldest first - the FIFO
order the queue's own opaque structure does not directly expose. This is
the inspection and assertion path for every test and every future
debugger; no interpreter code path needs it, since dequeue_internal/1
alone drives selection.
Whether the internal queue holds no pending events - internalQueue.isEmpty()
(Appendix D), the post-invoke re-check's own test. Cheaper than
internal_events/1 == []: :queue.is_empty/1 never materializes the
queue into a list, so this is the predicate the interpreter's hot path
uses, and internal_events/1 stays the inspection-and-assertion view.
@spec new(machine :: Statifier.Machine.t(), opts :: keyword()) :: t()
A fresh position over machine: empty configuration, empty internal
queue, no history values, counters at zero, running: true,
status: :running. Does not enter any state - entering the initial
configuration is the not-yet-implemented initialize/2's job, not this
constructor's.
Options: :trace (default false), :datamodel (default %{}),
:session_id (default a freshly generated sess_ id, ADR-0008),
:max_macrostep_rounds (default 10_000), :routes (default nil,
ADR-0048 - see the routes/0 typedoc for what nil means), and
:invoke_types (default nil, ADR-0051 - see the invoke_types/0
typedoc for what nil means). All four system variables
(SystemVariables.initial/2) are merged over the :datamodel
option's map, so author-supplied data can never shadow a system variable.
:datamodel must be string-keyed at every level: raises ArgumentError
if any key, in the map itself or in a map nested inside it (directly or
inside a list, at any depth), is an atom other than true/false -
exactly the keys Predicator.Context's own normalization would rewrite.
Boolean keys and non-atom keys (integers, for one) are accepted, and a
struct value's fields are never inspected, since predicator's walk does
not descend into one either.
A nil value anywhere in :datamodel means predicator's null (ADR-0037);
an embedder that means "declared, no value yet" passes :undefined
instead.
@spec put_event(machine_state :: t(), event :: Statifier.Event.t()) :: t()
datamodel["_event"] = event (Appendix D) - the one and only writer of
the _event system variable (spec 5.10.1). Both of mainEventLoop's
assignments, the external one (Statifier.Interpreter.handle_event/2)
and the internal one (Statifier.Interpreter.internal_round/1), go
through here.
@spec put_invoke_types(machine_state :: t(), invoke_types :: invoke_types()) :: t()
Stamps invoke_types onto machine_state - ADR-0051's registered-type
snapshot, re-supplied by the driver rather than carried as durable position
state (Statifier.Position.import/2 sets it nil for exactly this reason).
Stamps routes onto machine_state - ADR-0048 decision 2's per-drive
snapshot write, called by Statifier.Session immediately before each
core drive. Pass nil to clear a snapshot back to "no determination
made" (routes/0).
@spec raise_internal( machine_state :: t(), name :: String.t(), origin :: Statifier.Event.Cause.origin(), opts :: keyword() ) :: t()
Raises an internal event: builds its Cause from origin and the
machine_state's own counters as they stand right now, builds the
:internal event, and enqueues it - so cause metadata cannot be
forgotten at a call site. This is the function <raise>'s
executable-content implementation (Statifier.Machine.Content.Raise)
calls. done.state.* is a :platform event per spec 5.10.1 and is
raised through raise_platform/4 instead - both enqueue on this same
internal queue, type is provenance, not routing.
origin is Cause.origin/0: <raise> passes {:content, c_index, owner} (the raising node and its owning onentry/onexit/transition
block). The round is stamped as it stood at the raise - same rule as the
counters.
@spec raise_platform( machine_state :: t(), name :: String.t(), origin :: Statifier.Event.Cause.origin(), opts :: keyword() ) :: t()
Raises a platform event: identical to raise_internal/4 except it builds
an Event.platform/3 event instead of Event.internal/3 - so type is
stamped :platform per spec 5.10.1. This is the function
Statifier.Interpreter.ExitEntry.raise_parent_completion/3 calls for
done.state.*: Statifier.Event's moduledoc classifies done.state.*
as a platform-raised event, not one raised by executable content, and
raise_internal/4 would stamp the wrong type.
Both raise_internal/4 and this function enqueue on the same internal
queue - type is provenance, not routing - so nothing about ordering or
dequeue changes between the two.
origin is Cause.origin/0: done.state.* on entering a final state
passes {:state, state_index} (no content node backs it). The round is
stamped as it stood at the raise - same rule as the counters.