Statifier.Interpreter.ExitEntry (Statifier v2.0.0)

Copy Markdown View Source

Appendix D's exit and entry blocks, ported function for function (ADR-0002) - the half of the algorithm that changes the configuration. Statifier.Interpreter.Selection answers "which transitions fire and what would they leave"; this module actually leaves and enters states.

machine_state stands in for the pseudocode's globals exactly as Statifier.Interpreter.Selection's moduledoc states for the same reason (docs/observability.md constraint 1) - every function here that reads a global takes machine_state as its first argument.

Return shapes

exit_states/2 and enter_states/2 are the two functions here that both mutate the position and emit effects, so both return {MachineState.t(), [Effect.t()]} - the shape docs/observability.md fixes for microstep/1. Statifier.Interpreter.microstep/2 is their caller for the ordinary exit/entry path. Effect order within the returned list is emission order: the trace effect first, then each block's effects in the order the blocks ran.

run_onexit_blocks/2 and donedata/2 are public for a second caller: Statifier.Interpreter.exit_interpreter/1's termination walk needs exactly the same per-state onexit body and the same <donedata> folding rule that depart/2 and raise_parent_completion/3 already use here, so this module owns both rather than the interpreter carrying a second copy.

Ordering

Exit order and entry order are never hand-sorted here: exit_states/2 pipes Statifier.Interpreter.Selection.compute_exit_set/2's MapSet through Statifier.Machine.exit_order/2 (descending index), and enter_states/2 mirrors it through Statifier.Machine.document_order/2 (ascending index).

The content seam

Every call site that would run executable content - <onexit>, <onentry>, an <initial> transition's content, default history content - goes through the private execute_block/3 seam, which delegates to Statifier.Interpreter.Content.execute_block/3: that module owns the block runner and the Trace.ContentExecuted emission that wraps it. This module owns only where and in what order blocks run, which is what the exit/entry pseudocode defines.

History recording

exit_states/2 records history in a full first pass over the exit set, reading the configuration exactly as it stood before any state exited - before the first onexit runs and before any state is removed. The pseudocode writes two consecutive for s in statesToExit loops for exactly this reason: recording inside the delete loop would let a later-exiting state's deep history see a configuration its own siblings have already been removed from.

One mechanical deviation in the recorded value itself: the pseudocode's configuration.toList().filter(f) produces an ordered list, while machine_state.history_values holds a MapSet. Order is dropped rather than preserved because nothing reads it - add_descendant_states_to_enter/3 re-enters a restored value through the same descendant and ancestor walks any other target set goes through, and enter_states/2 sorts the whole entry set into document order afterwards regardless.

The entry set

compute_entry_set/2, add_descendant_states_to_enter/3, and add_ancestor_states_to_enter/4 are pure queries: none of them touch machine_state.configuration, none of them emit an effect. The pseudocode's three statesToEnter / statesForDefaultEntry / defaultHistoryContent out-parameters become one accumulator of type entry_set(), threaded in and returned last - the minimal mechanical deviation Elixir's lack of out-parameters forces. default_history_content stores the default transition's t_index rather than its [c_index] list: strictly more information, and what the content seam actually needs to build a Content.owner().

Summary

Types

The pseudocode's three computeEntrySet out-parameters, threaded as one accumulator: the states a transition set will enter, the subset of those entered via a compound state's <initial> declaration rather than as an explicit target (so default-entry content runs only for them), and a map from a history state's parent to the t_index of the default transition whose content runs because that history was unrecorded.

Functions

addAncestorStatesToEnter (Appendix D) - every proper ancestor of state_index up to but excluding ancestor is added, each parallel ancestor also pulling in its uncovered regions.

addDescendantStatesToEnter (Appendix D) - state_index and, depending on its kind, the descendants entering with it.

for inv in s.invoke: cancelInvoke(inv) (Appendix D) - one {:cancel_invoke, %Effect.CancelInvoke{}} per live invocation of state_index: walk its compiled invoke list in document order, and for each {state_index, invoke_index} found in machine_state.active_invocations emit a cancel and delete the entry. An invocation whose arguments failed (ADR-0031), and one whose resolved type was unsupported (6.4.1), never reached active_invocations, so neither produces a cancel.

computeEntrySet (Appendix D) - the entry-set bookkeeping every enabled_transitions transition contributes, folded into one entry_set() accumulator.

returnDoneEvent's s.donedata argument (Appendix D) - state_index's <donedata>, folded to the value a raised or returned done.* event carries as data.

enterStates (Appendix D) - the entry-ordered set compute_entry_set/2 computes, added to the configuration with onentry, default-entry, and default-history content run per state, then completion events raised.

exitStates (Appendix D) - the exit-ordered set of states enabled_transitions leave, with history recorded before any state exits and onexit content run per state in exit order.

isInFinalState (Appendix D) - whether state_index is, itself or through its active descendants, "in a final state": a compound state answers true when some active child is a :final; a parallel answers true only when every region does, recursively; anything else (an atomic :final included) answers false. Ported verbatim.

exitStates's per-state for content in s.onexit: executeContent(content) (Appendix D) - state_index's onexit blocks, in document order, each through the execute_block/3 seam with {:onexit, state_index, ordinal}

Types

entry_set()

@type entry_set() ::
  {states_to_enter :: MapSet.t(non_neg_integer()),
   states_for_default_entry :: MapSet.t(non_neg_integer()),
   default_history_content :: %{
     optional(non_neg_integer()) => non_neg_integer()
   }}

The pseudocode's three computeEntrySet out-parameters, threaded as one accumulator: the states a transition set will enter, the subset of those entered via a compound state's <initial> declaration rather than as an explicit target (so default-entry content runs only for them), and a map from a history state's parent to the t_index of the default transition whose content runs because that history was unrecorded.

Functions

add_ancestor_states_to_enter(machine_state, state_index, ancestor, acc)

@spec add_ancestor_states_to_enter(
  machine_state :: Statifier.MachineState.t(),
  state_index :: non_neg_integer(),
  ancestor :: non_neg_integer() | nil,
  acc :: entry_set()
) :: entry_set()

addAncestorStatesToEnter (Appendix D) - every proper ancestor of state_index up to but excluding ancestor is added, each parallel ancestor also pulling in its uncovered regions.

Machine.proper_ancestors/2 returns all ancestors up to the :scxml root (no two-argument bound), so the pseudocode's getProperAncestors(state, ancestor) is expressed as Enum.take_while(&(&1 != ancestor)) over the one-argument helper. A nil ancestor (a targetless transition's domain, or a direct call with no bound) therefore correctly takes every ancestor, since nil never appears in proper_ancestors/2's result.

add_descendant_states_to_enter(machine_state, state_index, acc)

@spec add_descendant_states_to_enter(
  machine_state :: Statifier.MachineState.t(),
  state_index :: non_neg_integer(),
  acc :: entry_set()
) :: entry_set()

addDescendantStatesToEnter (Appendix D) - state_index and, depending on its kind, the descendants entering with it.

Four cases, matching the pseudocode's own branches:

  • history (enter_history_target/3): a recorded value in machine_state.history_values restores those states; an unrecorded one registers default_history_content and follows the history's default transition's targets instead.
  • compound: state_index is added, flagged in states_for_default_entry, and its initial targets are entered (enter_initial_targets/3). When the document wrote an <initial> element, the targets come from Machine.transition(machine, state.initial_transition).targets - the pseudocode's state.initial.transition.target - because that transition is also what carries the default-entry content run_default_entry/3 runs on the way in. Mechanical deviation: Statifier.Compiler.resolve_initial/3 only populates initial_transition for a written <initial> element; a state defaulted through the initial attribute or the first-child fallback carries its resolved default in State.initial instead, with no synthesized transition (there being no <initial> content to run in that case either). enter_initial_targets/3 reads initial_transition when set and falls back to State.initial otherwise, rather than asserting non-nil.
  • parallel: state_index is added and each child region not already covered by a states_to_enter descendant is entered (enter_uncovered_regions/3).
  • atomic / final / anything else: state_index is added and nothing else happens.

cancel_invocations_for_state(machine_state, state_index)

@spec cancel_invocations_for_state(
  machine_state :: Statifier.MachineState.t(),
  state_index :: non_neg_integer()
) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}

for inv in s.invoke: cancelInvoke(inv) (Appendix D) - one {:cancel_invoke, %Effect.CancelInvoke{}} per live invocation of state_index: walk its compiled invoke list in document order, and for each {state_index, invoke_index} found in machine_state.active_invocations emit a cancel and delete the entry. An invocation whose arguments failed (ADR-0031), and one whose resolved type was unsupported (6.4.1), never reached active_invocations, so neither produces a cancel.

Two callers, both after that state's own onexit blocks and before it leaves the configuration - both the pseudocode's own order in exitStates and exitInterpreter, and spec 6.4's "the cancel operation MUST act as if it were the final <onexit> handler in the invoking state": depart/2 (this module's own exit_states/2 path) and Statifier.Interpreter.exit_interpreter/1 (the termination walk), the same second-caller shape run_onexit_blocks/2 above already has.

compute_entry_set(machine_state, transitions)

@spec compute_entry_set(
  machine_state :: Statifier.MachineState.t(),
  transitions :: [Statifier.Machine.Transition.t()]
) :: entry_set()

computeEntrySet (Appendix D) - the entry-set bookkeeping every enabled_transitions transition contributes, folded into one entry_set() accumulator.

Per transition, in the pseudocode's own order: each written target (transition.targets, not the resolved history/effective set) through add_descendant_states_to_enter/3, then the transition's domain (Selection.get_transition_domain/2), then each effective target (Selection.get_effective_target_states/2 - already resolves both history branches, consumed rather than re-resolved here) through add_ancestor_states_to_enter/4 bounded by that domain. The written-versus-effective distinction is the pseudocode's own and is preserved here rather than collapsed into one walk.

donedata(machine_state, state_index)

@spec donedata(
  machine_state :: Statifier.MachineState.t(),
  state_index :: non_neg_integer()
) ::
  {Statifier.MachineState.t(), term()}

returnDoneEvent's s.donedata argument (Appendix D) - state_index's <donedata>, folded to the value a raised or returned done.* event carries as data.

  • No <donedata> at all, or a %Donedata{expr: nil, params: []} (no <content> or <param> child at all) - {machine_state, :undefined}: "no data", the same spelling every other absent-payload writer uses (docs/adr/0037-unbound-spelled-undefined-at-the-writer.md).
  • {:static, text} - <content>'s text body, which can only originate there (Statifier.Compiler.build_content_expr/2). Coerced through Statifier.EventData.coerce({:text, text}) (Decision 3 of the plan on this module) so <content>21</content> becomes the integer 21 (W3C test529), not the string "21".
  • {:compiled, _, _} - <content expr>. Evaluated against a fresh Statifier.Evaluator.context/1; success is coerced through EventData.coerce({:value, v}) (identity - an evaluated expression already produced a legal data value, so it is never re-run through the text ladder). Failure raises error.execution via MachineState.raise_platform/4 and returns :undefined donedata - not the empty string spec 5.6 names for <content> generally (ADR-0021 records that deviation and its scope), and not nil either: a failed evaluation produced no data, it did not produce a null value (W3C test528).
  • expr: nil with a non-empty params list - <param> children, folded against one Statifier.Evaluator.context/1 built once for the whole fold. Each param is evaluated in document order; a failure raises its own error.execution (spec 5.7: "If the evaluation of 'expr' produces an error, or if neither 'location' nor 'expr' is present, or if the value of 'location' is not a valid location for a data value in the underlying data model, the SCXML Processor MUST place the error 'error.execution' in the internal event queue and MUST ignore the name and value") and is dropped rather than aborting the remaining params. The surviving {name, value} pairs, still in document order, go through EventData.coerce({:params, pairs}), which returns :undefined for an empty result (Statifier.EventData's own moduledoc, Decision 5) - so a <donedata> whose only param fails produces :undefined data, the same shape as no donedata at all, not %{}. The <content> arm above and this params arm are mutually exclusive by validator check (Statifier.Validator.Checks.Donedata), so there is no case where both are non-empty.

Two callers: raise_parent_completion/3 (a non-top-level final's done.state.*) and Statifier.Interpreter.exit_interpreter/1 (a top-level final's terminal {:done, _} effect) - the same folding rule either way. <donedata> carries no c_index (lib/statifier/machine/content.ex:25-28), so Statifier.Interpreter.Content.raise_execution_error/4's {:content, c_index, owner} origin shape does not apply here; the raise below uses {:state, state_index} instead, the same origin shape raise_parent_completion/3 already stamps on the done.state.* event itself - for both the <content> arm and each failing <param>.

enter_states(machine_state, enabled_transitions)

@spec enter_states(
  machine_state :: Statifier.MachineState.t(),
  enabled_transitions :: [Statifier.Machine.Transition.t()]
) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}

enterStates (Appendix D) - the entry-ordered set compute_entry_set/2 computes, added to the configuration with onentry, default-entry, and default-history content run per state, then completion events raised.

Body, in the pseudocode's own order:

  1. compute_entry_set/2 over enabled_transitions.
  2. entry_order = Machine.document_order/2 over states_to_enter - ascending index, the mirror of exit_states/2's exit_order.
  3. entry_order is reduced through arrive/3, each state added to the configuration, its onentry blocks run, its default-entry / default- history content run when flagged/registered, then its completion events raised.
  4. Effect.trace(pre_entry_state, Effect.Trace.EntrySet, indexes: entry_order, configuration: machine_state.configuration) is emitted last: macrostep/microstep/round are stamped against pre_entry_state, captured as machine_state stood before step 3's arrive/3 reduce (ADR-0012, mirroring exit_states/2), while configuration is read from the post-entry machine_state because "the configuration after this entry set was applied" does not exist until step 3 has run. The payload's position in the returned list is unchanged: it is still first, since the list is concatenated at the end either way.

exit_states(machine_state, enabled_transitions)

@spec exit_states(
  machine_state :: Statifier.MachineState.t(),
  enabled_transitions :: [Statifier.Machine.Transition.t()]
) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}

exitStates (Appendix D) - the exit-ordered set of states enabled_transitions leave, with history recorded before any state exits and onexit content run per state in exit order.

Body, in the pseudocode's own order:

  1. exit_set = Selection.compute_exit_set/2 over enabled_transitions
  2. states_to_invoke loses every member of exit_set, over that same unordered set - statesToInvoke.delete(s) (Appendix D), which runs before the exit-order sort and before record_history_values/2.
  3. exit_set is sorted into exit order via Machine.exit_order/2 into states_to_exit: compute_exit_set/2 returns unordered, and ordering it is this function's job, not Selection's.
  4. record_history_values/2 runs its whole first pass over states_to_exit, reading the untouched configuration.
  5. states_to_exit is reduced through depart/2, each state running its onexit blocks and then leaving the configuration.
  6. Effect.trace(pre_exit_state, Effect.Trace.ExitSet, indexes: states_to_exit, configuration: machine_state.configuration) is emitted last: macrostep/microstep/round are stamped against pre_exit_state, captured as machine_state stood after step 2's states_to_invoke update and before the departure reduce (ADR-0012 - test/fixtures/adr_judge/0012_trace_prestate_captured.diff is the sanctioned shape), while configuration is read from the post-departure machine_state because "the configuration after this exit set was applied" does not exist until step 5 has run. The payload's position in the returned list is unchanged: it is still first, since the list is concatenated at the end either way.

in_final_state?(machine_state, state_index)

@spec in_final_state?(
  machine_state :: Statifier.MachineState.t(),
  state_index :: non_neg_integer()
) ::
  boolean()

isInFinalState (Appendix D) - whether state_index is, itself or through its active descendants, "in a final state": a compound state answers true when some active child is a :final; a parallel answers true only when every region does, recursively; anything else (an atomic :final included) answers false. Ported verbatim.

run_onexit_blocks(machine_state, state_index)

@spec run_onexit_blocks(
  machine_state :: Statifier.MachineState.t(),
  state_index :: non_neg_integer()
) ::
  {Statifier.MachineState.t(), [Statifier.Effect.t()]}

exitStates's per-state for content in s.onexit: executeContent(content) (Appendix D) - state_index's onexit blocks, in document order, each through the execute_block/3 seam with {:onexit, state_index, ordinal}

  • ordinal is the block's position in the state's own onexit list, exactly what Machine.Content.owner() documents it to be.

Two callers: depart/2 (this module's own exit_states/2 path) and Statifier.Interpreter.exit_interpreter/1 (the termination block, which runs every active state's onexit blocks the same way, in exit order, without the rest of exitStates's history-recording and configuration bookkeeping).