Appendix D's outer loop, ported function for function (ADR-0002), with its
loop state reified onto %Statifier.MachineState{} per
docs/observability.md constraint 1: any %MachineState{} value is a
complete, resumable interpreter position, and this module keeps nothing
of that position on the call stack.
The map of the loop
Appendix D's interpret/mainEventLoop/microstep/exitInterpreter
collapse onto this module's functions:
| This module | Appendix D |
|---|---|
microstep/2 | microstep(enabledTransitions) verbatim |
microstep/1 | mainEventLoop's inner while running and not macrostepDone loop body, hoisted into a value so a paused position is data, not a stack frame |
macrostep/1 | that same inner loop, folded to quiescence |
main_event_loop/1 | the outer-loop iteration(s) one call of initialize/2 or handle_event/2 performs - the invoke pass, its re-entry continue, and the trailing exitInterpreter() |
exit_interpreter/1 | exitInterpreter |
initialize/2, handle_event/2 | interpret's two entry seams |
Stepping it
Constraint 1's payoff is that a step debugger is microstep/1 in iex with
no support code. Fold to quiescence, then hand it an event and drive the
next macrostep one round at a time, inspecting the position between calls:
# in iex, with `machine` already compiled
{machine_state, _effects} = Interpreter.initialize(machine, trace: true)
machine_state = MachineState.begin_macrostep(machine_state)
# one round at a time; a `{:quiescent, _, _}` return ends the macrostep
{machine_state, effects} = Interpreter.microstep(machine_state)
machine_state.configuration
Interpreter.microstep(machine_state)Every binding above is an ordinary value: keep an earlier machine_state
to rewind to it, or round-trip one through :erlang.term_to_binary/1 to
resume in another process. macrostep/1 is the same loop run to its fixed
point, so stepping and folding are the same code path, not two.
Rehydrating a position
A host driving this module directly - rather than through
Statifier.Session - already has everything it needs to resume a saved
position; no function in this module exists solely for that purpose
(ADR-0060). Use Statifier.Position.to_binary/1 and from_binary/2
instead of the raw :erlang.term_to_binary/1 shown above: the identity
check from_binary/2 runs against the supplied Statifier.Machine.t() is
the entire point - a version-1 term_to_binary blob decoded against the
wrong chart revision would silently rebuild a %MachineState{} that walks
a document it was never measured against.
from_binary/2 restores every durable field of the position, but two
fields are deliberately per-driver snapshots rather than durable position
state (Statifier.Position.import/2's own docs give the same reason) and
come back nil: re-stamp both before the first drive.
{:ok, machine_state} = Statifier.Position.from_binary(blob, machine)
machine_state =
machine_state
|> Statifier.MachineState.put_routes(routes)
|> Statifier.MachineState.put_invoke_types(invoke_types)
{:ok, machine_state, effects} = Interpreter.handle_event(machine_state, event)Then call any advance entry (handle_event/2, deliver_internal/5,
cancel/1, microstep/1, macrostep/1) exactly as if the position had
never left this process - every one of them takes a MachineState.t() and
trusts it structurally, with no state of its own outside that struct.
What a resumed position must not redo is everything initialize/2
already did the one time it ran, before the position was ever saved:
MachineState.new/2- the position already carries a realconfiguration,datamodel, and counters; callingnew/2again would discard all of it and start over.Datamodel.initialize/1- the datamodel's<data>elements have already been evaluated once; re-running that pass is<data>initialization happening twice, not resumption.run_global_scripts/2- a top-level<script>already ran during the originalinitialize/2; a resumed position's datamodel already carries whatever it wrote.enter_states/2on the initial transition - the position'sconfigurationalready reflects the states that macrostep entered; entering them again duplicatesonentrycontent and history recording.
A resumed position skips all four - there is no initialize/2 call on
this path at all, only the re-stamp above followed by the first advance
entry.
Counters
Statifier.MachineState's counter contract is the source of truth;
restated here only for where this module writes it. microstep's writer
(Statifier.MachineState's docs name it) has exactly one call site in
this module: the non-empty branch of run_selected/3, the private tail
shared by every selection round (eventless, one dequeued internal event,
and the external event handle_event/2 selects on). A selection round
that finds nothing to run never reaches that call, because no exit or
entry happened - the same "no exit or entry happened" rule
MachineState's counter contract states for why an empty round does not
advance microstep.
macrostep's writer has exactly two call sites in this module:
initialize/2 (the initialization macrostep is macrostep 1) and
handle_event/2, once per accepted external event (macrostep 2 onward).
initialize/2 additionally advances the microstep counter once, directly,
before its own enter_states/2 call - the pseudocode's
enterStates([doc.initial.transition]) is not inside microstep, so
the initial entry is microstep 1, not 0, and nothing the
initialization macrostep emits is stamped microstep: 0.
An external event is the one case that is. begin_macrostep/1 resets the
microstep counter, and handle_event/2 emits Trace.EventDequeued and
(via run_selected/3) Trace.TransitionsSelected before the round's
first entry has happened, so both carry microstep: 0 - the round has
begun but no exit or entry has occurred yet, which is exactly what
microstep: 0 means under MachineState's counter contract. The first
Trace.ExitSet of that macrostep is microstep 1.
This is also why the trace effects a selection round emits
(Trace.EventDequeued, Trace.TransitionsSelected) are always stamped
one microstep behind the Trace.ExitSet that follows them when the
round is non-empty: both are built from the machine_state selection
returned, which is still at the previous microstep's count - the
counter only advances afterward, inside run_selected/3, right before
microstep/2 runs. The counter cannot be advanced before the selection
result is known, because an empty result must not advance it.
round's writer (begin_round/1) has exactly one call site in this
module: the head of microstep/1, both clauses included - so the
running: false clause counts a round too, and the first round of a
macrostep's fold is round 1. handle_event/2's own Trace.EventDequeued
and Trace.TransitionsSelected, and everything initialize/2 emits
before main_event_loop/1 begins the fold, are stamped round: 0 for the
same reason they are stamped microstep: 0: no round of this macrostep's
fold has begun yet.
Deviations, with their reasons (ADR-0002)
microstep/1is not a pseudocode function name. It ismainEventLoop's inner loop body, hoisted so a debugger can pause between rounds without support code (constraint 1). Seemicrostep/1's own@doc.- Quiescence is a tagged return, not a bare atom.
microstep/1returns{:quiescent, machine_state, effects}so the round that ends a macrostep can carry out both the machine_state its selection returned and that selection's ownTrace.TransitionsSelected. Every selection this module makes emits that trace, with no exception for the terminal probe, sodocs/observability.md's "includes the empty set" is literally true. Seemicrostep/1's own@docfor why the machine_state half is load-bearing rather than tidiness. - The machine_state
Selectionreturns is threaded, never discarded. BothSelection.select_eventless_transitions/1andSelection.select_transitions/2return{machine_state, transitions}, and this module continues with the returnedmachine_staterather than the one it passed in -condevaluation lives insideSelection, where it reshapes that module's private walk without changing either entry point's signature or anything in this module. - The outer
while runningloop is driven by the caller. ADR-0003: the pure core takes one external event per call, and the session that drives it owns the waiting external events and their queue.main_event_loop/1is the loop's tail - fold to quiescence, thenexit_interpreter/1whenrunningwent false - not the loop itself. Seemain_event_loop/1's own@docfor the exact port-site comment. - The invoke pass runs at the end of every fold, inside
main_event_loop/3.run_invoke_pass/1walksstates_to_invokesorted into entry order viaMachine.document_order/2(the same sortenter_states/2already performs, and one the pseudocode itself specifies -statesToInvoke.sort(entryOrder)- so this is not a deviation), and within each state walks its compiledinvokelist in document order. Each invocation's arguments are resolved against one threadedEvaluator.context/1; any evaluation failure raiseserror.executionand yields noEffect.Invokefor that invocation, siblings unaffected (ADR-0031), and otherwise the invocation is recorded inmachine_state.active_invocationsand emits one{:invoke, _}effect.states_to_invokeis cleared once the pass finishes, and oneTrace.InvokePassis emitted last, carrying the states walked and the invoke ids started - a phase boundary Appendix D itself names (ADR-0012 item 2's parenthetical is illustrative, not closed) that predates this bead having any<invoke>to trace. When invoking left the internal queue non-empty, Appendix D'scontinuere-enters the outer loop - a self-call of the privatemain_event_loop/3- which is why the round budget has to span it (ADR-0032; see the deviation comment abovedefp main_event_loop/3). - The finalize/autoforward pass runs once per external event, inside
handle_event/2, before transition selection.apply_invoke_passes/2walks the fullconfiguration(ADR-0005), and for each state's compiledinvokelist, in document order: when a live invocation's invokeid equals the external event's owninvokeid, its<finalize>runs in the pure core (Statifier.Machine.Invoke.finalize's absent/empty/populated split, spec 6.5); when it autoforwards, one{:autoforward, %Effect.Autoforward{}}carries the event on, verbatim, for the session to deliver - notEffect.Send, since 6.4 requires an exact copy of every 5.10.1 field and ADR-0003 keeps effect construction pure. Neitherifis inside the other'selse: a matching, autoforwarding invocation does both, exactly as Appendix D's own two separateifs read. The walk short-circuits to a no-op whenactive_invocations == %{}- but even that no-op still emits oneTrace.FinalizeAutoforwardwith both lists empty, the same "includes the empty set" reasoningTrace.TransitionsSelectedalready carries into this vocabulary, so the pass's absence is never mistaken for a pass that found nothing to do. returnDoneEventbecomes a returned effect, appended last.exit_interpreter/1builds{:done, %Effect.Done{}}instead of performing an I/O call (ADR-0003), and appends it afterTrace.Donerather than mid-walk - a mechanical reordering, since effects are a returned list and nothing downstream observes the difference in the pseudocode's own terms. Seeexit_interpreter/1's own@doc.- The macrostep fold is bounded. Appendix D's inner loop is
unbounded; a pure core has no external entity to cancel a
non-terminating macrostep, so the fold spends a round budget and stops
with a
:budget_exhaustedeffect on exhaustion (ADR-0019). See the private fold's own comment abovedefp macrostep/3. - The round budget spans an invoke re-entry.
continue(Appendix D) is a self-call ofmain_event_loop/3in this port, and a self-call that started a fresh budget every time would reopen the same livelock ADR-0019 closed, one loop further out - two states whose<invoke>arguments deterministically error could hand each other control forever. The budget is therefore threaded across every re-entry of onemain_event_loop/1call instead of restarted per fold (ADR-0032). See the deviation comment abovedefp main_event_loop/3. - A round ordinal counts
microstep/1invocations. Appendix D's inner loop carriesmacrostepDoneand no round variable of any kind (ADR-0020);roundis a hoisting artifact ofmicrostep/1itself, like the two counters before it. See the comment abovemicrostep/1's two clauses.
Summary
Functions
mainEventLoop's cancel path (Appendix D) - running = false, then
continue, which ends the while running loop and reaches
exitInterpreter().
ADR-0039's re-entry seam: the sole path Statifier.Session uses to write
a session-detected <send> failure (6.2.4's unsupported/invalid target,
6.2.5's unsupported type) - or a <send target="#_internal"> delivery -
onto %MachineState{}'s own internal queue.
exitInterpreter (Appendix D) - every active state exited in exit order,
the top-level final's <donedata> collected, then the terminal effects.
mainEventLoop's external-event tail (Appendix D) - the external-event
counterpart to internal_round/1's dequeue-and-select, driven by the
caller instead of a blocking queue read (ADR-0003, main_event_loop/1's
own @doc). Rejects when the machine is not running - Appendix D's own
running flag, so the guard reads as the pseudocode's loop condition -
otherwise begins a new macrostep, runs the finalize/autoforward pass,
selects on event, runs whatever it enables, and folds to quiescence.
interpret's entry seam (Appendix D) - binds opts into a fresh
%MachineState{}, enters the top-level initial states, then runs the
initialization macrostep to quiescence. Documents that reach a stable
configuration or even terminate before any external event is ever sent
are corpus-normal (enter_states/2 can already set running: false on a
top-level <final> entry, and main_event_loop/1 runs
exit_interpreter/1 when it does).
mainEventLoop's inner while running and not macrostepDone loop,
folded to quiescence over microstep/1 (Decision 7) - not a pseudocode
function name itself; docs/observability.md's vocabulary for that same
loop, hoisted so a paused position is a value on %MachineState{} rather
than a stack frame (constraint 1). The fold ends one of three ways
mainEventLoop's outer while running loop (Appendix D), one call's
worth of iterations (ADR-0032). The loop itself is
driven by the caller - one call of initialize/2 or handle_event/2
reaches here having already run its own selection round - so this
function is what is left of the pseudocode's loop body after that: fold
to quiescence with the private macrostep/3, run the invoke pass, clear
states_to_invoke, and continue (a self-call of the private
main_event_loop/3) when invoking left the internal queue non-empty;
otherwise run exit_interpreter/1 when the fold left running false, or
stop and emit the one terminal effect this call produces.
mainEventLoop's inner while running and not macrostepDone loop body,
hoisted into a named, resumable round (Decision 1) - not a pseudocode
function name itself. One call makes exactly one round of progress, and
begins it: both clauses call MachineState.begin_round/1 first, so the
returned position's round names which round this one was (ADR-0020).
microstep(enabledTransitions) (Appendix D) - exit the states
enabled_transitions leave, run each transition's own content in
document order, then enter the states they reach. The three calls run in
exactly this order with nothing between them, matching the pseudocode
line for line; the effect list is each block's effects concatenated in
the same order.
Functions
@spec cancel(machine_state :: Statifier.MachineState.t()) :: {:ok, Statifier.MachineState.t(), [Statifier.Effect.t()]} | {:error, :not_running}
mainEventLoop's cancel path (Appendix D) - running = false, then
continue, which ends the while running loop and reaches
exitInterpreter().
ADR-0002 mechanical deviation: the outer loop is driven by the caller
(handle_event/2), so there is no loop here to fall out of; the two steps
the pseudocode reaches by falling out are performed directly, in the same
order. Nothing about the exit itself changes - the states exited, the
<onexit> blocks run, the <donedata> collected, and the {:done, _}
effect produced are all exit_interpreter/1's.
Rejects an already-terminated machine_state with {:error, :not_running},
matching handle_event/2: Appendix D would not be inside the loop to check
isCancelEvent at all.
@spec deliver_internal( machine_state :: Statifier.MachineState.t(), kind :: :internal | :platform, name :: String.t(), origin :: Statifier.Event.Cause.origin(), opts :: keyword() ) :: {:ok, Statifier.MachineState.t(), [Statifier.Effect.t()]} | {:error, :not_running}
ADR-0039's re-entry seam: the sole path Statifier.Session uses to write
a session-detected <send> failure (6.2.4's unsupported/invalid target,
6.2.5's unsupported type) - or a <send target="#_internal"> delivery -
onto %MachineState{}'s own internal queue.
Appendix D discovers a routing failure inside mainEventLoop and writes
the internal queue in place. ADR-0003 puts routing in the effect
interpreter, which is outside that loop by construction, so ADR-0039
gives the session one way back in: enqueue on the internal queue exactly
as an in-loop raise would, then run to quiescence. No selection or
entry/exit procedure changes.
That paragraph is ADR-0002's required citation for a deviation from the Appendix D pseudocode: the reason is mechanical (where effects run), not semantic, and the resulting queue state is what the in-loop write would have produced.
Delegates to MachineState.raise_internal/4 or raise_platform/4 by
kind - the same two functions the core's own executable content already
uses, so no third internal-queue writer is introduced - then folds
main_event_loop/1 to quiescence, returning exactly handle_event/2's own
shape.
@spec exit_interpreter(machine_state :: Statifier.MachineState.t()) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}
exitInterpreter (Appendix D) - every active state exited in exit order,
the top-level final's <donedata> collected, then the terminal effects.
Body, in the pseudocode's own order, with the deviations Decision 10 records:
- The configuration is captured before the walk (
configuration_at_exit)- both
Trace.Done.configurationandEffect.Done.configurationdocument themselves as "the configuration as it stood at exit", which the walk would otherwise leave empty.
- both
states_to_exit=Machine.exit_order/2over the configuration, andTrace.ExitSetis emitted over it, stamped againstpre_exit_state(machine_stateas it stood before the termination sweep) - the same phase-boundary rowexit_states/2emits, at the one other place this engine exits states. ADR-0012 item 2 binds the row to the boundaries Appendix D itself names, andexitInterpreternames one: itsstatesToExitis the same variableexitStatescomputes. Itsconfigurationfield is read from the post-sweepmachine_state: the sweep provably empties the configuration, so this is alwaysMapSet.new(), but it is read rather than hardcoded so it cannot drift from the walk.Trace.Donecarries the configuration as it stood at exit (non-empty, fromconfiguration_at_exit), but it arrives after the walk and means "the run ended here", so it is not a substitute for a marker that means "these are about to be exited" and its emptyTrace.ExitSet.configurationcounterpart.- Each state, in exit order, runs its
onexitblocks (ExitEntry.run_onexit_blocks/2- the same per-state bodyexit_states/2'sdepart/2runs), then each of its live invocations is cancelled (ExitEntry.cancel_invocations_for_state/2- the same shared walkdepart/2runs, so the two exit paths cannot drift), before it leaves the configuration. - No history recording. Appendix D's
exitInterpreterhas no history-recording loop at all - unlikeexitStates, which has two consecutivefor s in statesToExitloops for exactly that reason. "Port as written" therefore means recording nothing here; this walk never touchesmachine_state.history_values. returnDoneEvent(s.donedata)fires for the one state, if any, that isMachine.final?/2withparent == 0-isFinalState(s) and isSCXMLElement(s.parent), the same testExitEntry.raise_completion_events/2already makes. Since the root is compound, at most one child is active, so at most one top-level final is ever in the exit set. Its donedata (ExitEntry.donedata/2) becomes bothTrace.Done's andEffect.Done'sdonedata. A failed<content expr>raiseserror.executiononto the returnedmachine_state's internal queue - nothing ever dequeues it, since the event loop has already stopped by the time this function runs, but that is Appendix D's own consequence (5.6/5.7's error rule is unqualified, andexitInterpreterruns after the loop) rather than a deviation this port introduces. It is still observable:MachineState.internal_events/1on the returned terminal state shows it.- The terminal effects are appended last,
{:done, %Effect.Done{}}last of all -returnDoneEventbecomes a returned effect rather than an I/O call (ADR-0003), and moving its emission to the end of the list is a mechanical reordering: effects are a returned list, not an I/O call, so nothing the pseudocode's own terms observe changes order. BothTrace.DoneandEffect.Doneare populated from the sameconfiguration_at_exitanddonedatalocals, so the trace row and the core effect always agree on the terminal position.
status: :done is set only here, at the very end - the window
MachineState's moduledoc describes between running: false (from
top-level final entry) and status: :done (once this walk finishes).
@spec handle_event( machine_state :: Statifier.MachineState.t(), event :: Statifier.Event.t() ) :: {:ok, Statifier.MachineState.t(), [Statifier.Effect.t()]} | {:error, :not_running}
mainEventLoop's external-event tail (Appendix D) - the external-event
counterpart to internal_round/1's dequeue-and-select, driven by the
caller instead of a blocking queue read (ADR-0003, main_event_loop/1's
own @doc). Rejects when the machine is not running - Appendix D's own
running flag, so the guard reads as the pseudocode's loop condition -
otherwise begins a new macrostep, runs the finalize/autoforward pass,
selects on event, runs whatever it enables, and folds to quiescence.
@spec initialize(machine :: Statifier.Machine.t(), opts :: keyword()) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}
interpret's entry seam (Appendix D) - binds opts into a fresh
%MachineState{}, enters the top-level initial states, then runs the
initialization macrostep to quiescence. Documents that reach a stable
configuration or even terminate before any external event is ever sent
are corpus-normal (enter_states/2 can already set running: false on a
top-level <final> entry, and main_event_loop/1 runs
exit_interpreter/1 when it does).
opts passes straight to MachineState.new/2 - no option is interpreted
here, so a new one is a MachineState change, not an entry-point change.
Cannot fail: a %Machine{} is valid by construction
(docs/architecture.md), so this returns the same untagged
{machine_state, [effect]} pair every other loop function in this module
returns, rather than an {:ok, _, _} wrapper with one possible value.
@spec macrostep(machine_state :: Statifier.MachineState.t()) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}
mainEventLoop's inner while running and not macrostepDone loop,
folded to quiescence over microstep/1 (Decision 7) - not a pseudocode
function name itself; docs/observability.md's vocabulary for that same
loop, hoisted so a paused position is a value on %MachineState{} rather
than a stack frame (constraint 1). The fold ends one of three ways:
- Quiescence -
microstep/1returns{:quiescent, machine_state, effects}: no eventless transition is enabled and the internal queue is empty. That round's own effects (the terminal eventless probe'sTrace.TransitionsSelected) are appended like any other round's, and itsmachine_stateis the one this fold returns - never the one it passed in, which is what keeps a write made by the final selection from being dropped. The returnedmachine_stateis stillrunning, so this function then appendsTrace.MacrostepStablewith the configuration and counters as they stand. - Termination - a microstep entered a top-level
<final>, settingrunning: falsemid-fold. This is not quiescence -Trace.MacrostepStable's own moduledoc reserves that trace for reaching a stable configuration - so noTrace.MacrostepStableis emitted;Trace.Doneis the vocabulary row for this case, emitted byexit_interpreter/1. - Budget exhaustion (ADR-0019) - the private fold spent
machine_state.max_macrostep_roundswithout reaching quiescence. The position comes back exactly as the last round left it:runningstaystrueandstatusstays:running(noexit_interpreter/1runs, noEffect.Doneis built - faking termination would be a semantic lie).Trace.MacrostepStableis not emitted, and a{:budget_exhausted, %Effect.BudgetExhausted{}}core effect is appended last.
The three outcomes are therefore mutually exclusive per macrostep.
macrostep/1 is public rather than starting private, because it is
exactly microstep/1 driven to a fixed point and a stepper and the fold
should be the same code path (docs/observability.md:41-42).
@spec main_event_loop(machine_state :: Statifier.MachineState.t()) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}
mainEventLoop's outer while running loop (Appendix D), one call's
worth of iterations (ADR-0032). The loop itself is
driven by the caller - one call of initialize/2 or handle_event/2
reaches here having already run its own selection round - so this
function is what is left of the pseudocode's loop body after that: fold
to quiescence with the private macrostep/3, run the invoke pass, clear
states_to_invoke, and continue (a self-call of the private
main_event_loop/3) when invoking left the internal queue non-empty;
otherwise run exit_interpreter/1 when the fold left running false, or
stop and emit the one terminal effect this call produces.
Delegates immediately to the private main_event_loop/3, which carries
the round budget across every re-entry (ADR-0032) exactly as the private
macrostep/3 already carries it across rounds of one fold.
@spec microstep(machine_state :: Statifier.MachineState.t()) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]} | {:quiescent, Statifier.MachineState.t(), [Statifier.Effect.t()]}
mainEventLoop's inner while running and not macrostepDone loop body,
hoisted into a named, resumable round (Decision 1) - not a pseudocode
function name itself. One call makes exactly one round of progress, and
begins it: both clauses call MachineState.begin_round/1 first, so the
returned position's round names which round this one was (ADR-0020).
- Not
running- returns{:quiescent, machine_state, []}, withroundadvanced and nothing else changed. - An eventless transition is enabled - the round runs it, exactly as
microstep/2above. - No eventless transition is enabled - falls to
internal_round/1, which dequeues one internal event (if any) and selects on it.
Never runs two rounds and never inspects the call stack for a paused
position: the returned machine_state is the position, on both the
progressing and the quiescent return.
Quiescence carries a machine_state and an effect list of its own
(Decision 2, revised). The round that ends a macrostep still ran a
selection - Selection.select_eventless_transitions/1 - and that call
returns a machine_state. A bare :quiescent atom has nowhere to put it,
so the fold would fall back to the machine_state it passed in and drop
whatever the final selection wrote, on the one round every macrostep ends
with.
Be precise about which writes that loses, because the obvious candidate
is not one of them. The two Selection entry points enqueue
error.execution on a failed cond - condition_match/2
itself stays a pure query and never enqueues, so the write happens in
select_transitions/2 and select_eventless_transitions/1 on the way
out - and that enqueue is
self-rescuing: it leaves the internal queue non-empty, so
internal_round/1 takes its dequeue branch instead of the terminal one
and the write survives even under the old shape - traced end to end and
pinned by InterpreterAcceptanceTest's
"a failed cond becomes a catchable error.execution" tests. What the old
shape lost was any selection-side write that does not touch the
internal queue - a datamodel write, a memo, a diagnostic - since only a
queue write changes which branch runs. Carrying the machine_state out
closes that gap without having to predict which kind of write a future
selection-side change lands on.
The effect slot is the other half: it is what the terminal eventless
probe's own Trace.TransitionsSelected rides out on, which is why
docs/observability.md's "includes the empty set" holds with no
exception.
@spec microstep( machine_state :: Statifier.MachineState.t(), enabled_transitions :: [Statifier.Machine.Transition.t()] ) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}
microstep(enabledTransitions) (Appendix D) - exit the states
enabled_transitions leave, run each transition's own content in
document order, then enter the states they reach. The three calls run in
exactly this order with nothing between them, matching the pseudocode
line for line; the effect list is each block's effects concatenated in
the same order.