Statifier.Interpreter.Selection (Statifier v2.0.0)

Copy Markdown View Source

Appendix D transition selection, ported function for function (ADR-0002).

Every function here that reads a global takes machine_state (or, when it needs only topology, machine) as its first argument - the mechanical deviation docs/observability.md constraint 1 sanctions for reifying the pseudocode's configuration and historyValue globals onto %Statifier.MachineState{}. Each function's own @doc says which global its first argument stands in for; this paragraph states the rule once so they do not each re-argue it.

compute_exit_set/2 returns a MapSet of indexes, not an OrderedSet: remove_conflicting_transitions wants set intersection and gets it directly, and a caller that wants exit order pipes the result through Statifier.Machine.exit_order/2, which already exists for exactly that. No function in this module orders an exit set itself.

Every function is a pure query - plain values in, plain values out, no hidden context, callable standalone in iex (docs/observability.md constraint 5). The block divides in two. The domain half - find_lcca/2 (delegated), get_effective_target_states/2, get_transition_domain/2, and compute_exit_set/2 - answers "which states does this transition leave". The selection half - condition_match/2, select_transitions/2, select_eventless_transitions/1, and remove_conflicting_transitions/2 - answers "which transitions fire", and calls into the domain half to do it.

select_transitions/2 and select_eventless_transitions/1 are the two functions in this module that thread machine_state through their return value as well as their first argument: both return {MachineState.t(), [Transition.t()]} rather than a bare list, so that a failed cond discovered mid-walk has a machine_state to enqueue error.execution onto. The machine_state comes back unchanged when no cond fails during that round, and carries one error.execution event per failed cond - in walk order - when one or more do. This is the deliberate, signature-preserving edit the paragraph above used to anticipate; it has landed. Every other query in this module, including remove_conflicting_transitions/2 itself, still returns a plain value with no machine_state, because none of them can raise.

The two walks and the conflict filter are Appendix D's two nested loops with labelled breaks, decomposed into named private helpers to stay under Credo's cyclomatic-complexity and nesting limits - each helper's doc names the pseudocode lines it stands in for, so "diff against the pseudocode" still works one level down. The enabled set is deduplicated by t_index, keeping the first occurrence, in place of the pseudocode's OrderedSet.

Summary

Functions

computeExitSet (Appendix D) - every index in machine_state.configuration that a proper descendant of any transition in transitions's domain, unioned across transitions.

conditionMatch (Appendix D) - the one cond seam. nil cond always passes; a written cond is evaluated through Statifier.Evaluator against a Predicator.Context.t() built from machine_state - once per call here, and once per selection round in the private walk below (the "once per evaluation site" contract Statifier.Evaluator's own moduledoc states). {:ok, true} enables the transition; {:ok, false} and {:error, _} both do not.

findLCCA (Appendix D) - see Statifier.Machine.lcca/2 for the body.

getEffectiveTargetStates (Appendix D) - transition's targets, with every :history target resolved to a concrete set of indexes.

getTransitionDomain (Appendix D) - the single state whose descendants (in the configuration) transition exits, or nil for a transition with no effective targets.

removeConflictingTransitions (Appendix D) - enabled_transitions, filtered down to a conflict-free set, ported exactly for its filter order: two transitions conflict iff their exit sets intersect; on conflict, a t1 sourced in a descendant of t2's source preempts t2 (t2 is marked for removal, t1 keeps checking the rest of the filtered set); otherwise t1 itself is preempted and its inner loop stops immediately - this is not "the earlier transition always wins", it is "the earlier transition wins unless the later one is the more specific (descendant) source". A surviving t1 has every transition it marked for removal dropped from the filtered set and is appended to it.

selectEventlessTransitions (Appendix D) - select_transitions/2's sibling for transitions with no event attribute (events == []). Same walk, same dedupe, same trailing remove_conflicting_transitions/2 call; the pseudocode writes these as two functions rather than one parameterized by "is there an event" and this port keeps them that way (Credo.Check.Design.DuplicatedCode is disabled in .credo.exs for exactly this reason).

selectTransitions (Appendix D) - the transitions event enables, one per atomic state in machine_state.configuration at most (child preempts ancestor), deduplicated by t_index, and filtered through remove_conflicting_transitions/2 per the pseudocode's own last line.

Functions

compute_exit_set(machine_state, transitions)

@spec compute_exit_set(
  machine_state :: Statifier.MachineState.t(),
  transitions :: [Statifier.Machine.Transition.t()]
) :: MapSet.t(non_neg_integer())

computeExitSet (Appendix D) - every index in machine_state.configuration that a proper descendant of any transition in transitions's domain, unioned across transitions.

Two literal-port details that must survive review:

  • The guard is if t.target - written targets (transition.targets == []), not the effective ones get_transition_domain/2 resolves. A transition with written targets whose effective targets resolve empty still enters the loop; its domain is then nil and it is handled explicitly below, contributing nothing rather than being filtered out earlier by a merged test.
  • Machine.descendant?/3 is proper, so a transition's domain is never a member of its own exit set - the whole reason that predicate's strictness exists (Machine.descendant?/3's own @doc).

condition_match(machine_state, transition)

@spec condition_match(
  machine_state :: Statifier.MachineState.t(),
  transition :: Statifier.Machine.Transition.t()
) :: {:ok, boolean()} | {:error, term()}

conditionMatch (Appendix D) - the one cond seam. nil cond always passes; a written cond is evaluated through Statifier.Evaluator against a Predicator.Context.t() built from machine_state - once per call here, and once per selection round in the private walk below (the "once per evaluation site" contract Statifier.Evaluator's own moduledoc states). {:ok, true} enables the transition; {:ok, false} and {:error, _} both do not.

A non-boolean {:ok, value} - anything other than true or false - is treated as an {:error, {:non_boolean_cond, value}} rather than as a falsy value, because spec 5.9.1 makes it the same case as an evaluation error: "If a conditional expression cannot be evaluated as a boolean value ('true' or 'false') or if its evaluation causes an error, the SCXML Processor MUST treat the expression as if it evaluated to 'false' and MUST place the error 'error.execution' in the internal event queue." The spec joins the two with one or and gives them one consequence, so collapsing a non-boolean quietly to false would satisfy half of that MUST and drop the other half. This is not a deviation from Appendix D's conditionMatch; it is the normative clause conditionMatch evaluates under.

The {:error, _} spelling is how a pure query carries both halves at once: docs/architecture.md principle 3 forbids this leaf from raising or rescuing, so it reports the failure and the two entry points below turn it into "not enabled" plus the enqueue. ADR-0004 is why there is no third option - predicator is the datamodel and has no ECMAScript truthiness to borrow, so "cannot be evaluated as a boolean" is decidable here rather than being a matter of taste.

This function never enqueues anything itself - it is a pure query, plain values in and out, per this module's own moduledoc. The {:error, _} path's error.execution enqueue lives in the two entry points below, not here.

Unreachable from the corpus today: FeatureDetector marks conditional_transitions :unsupported, so no compiled document can carry a cond-bearing transition through selection yet. Reachable, and tested, from a machine_state and transition built by hand.

find_lcca(machine, indexes)

findLCCA (Appendix D) - see Statifier.Machine.lcca/2 for the body.

One implementation, two names: Machine.lcca/2 is the port under Statifier.Machine's own convention of naming its query helpers after the spec operation they serve rather than after the spec function itself; this defdelegate puts the spec's own name at the interpreter's port surface, where mix adr.check looks and where ADR-0002 expects to find it.

get_effective_target_states(machine_state, transition)

@spec get_effective_target_states(
  machine_state :: Statifier.MachineState.t(),
  transition :: Statifier.Machine.Transition.t()
) :: [non_neg_integer()]

getEffectiveTargetStates (Appendix D) - transition's targets, with every :history target resolved to a concrete set of indexes.

machine_state stands in for two pseudocode globals: historyValue (machine_state.history_values) and, indirectly through machine_state.machine, the topology Machine.at/2 reads. A non-history target passes through unchanged. A :history target with a recorded entry in history_values contributes that entry's members. An unrecorded :history target recurses through its own history_default transition's targets - the pseudocode's getEffectiveTargetStates(s.transition) - which is itself ported as a call back into this function, since a history default's targets are ordinary transition targets that may (pathologically, but representably) include another history state.

Returns a plain list where the pseudocode accumulates into an OrderedSet, so the result is not deduplicated: target="hs b1a", where hs resolves to b1a, yields that index twice. Every consumer is insensitive to it - get_transition_domain/2 feeds the list to Enum.all?/2 and to find_lcca/2, neither of which changes answer on a repeat, and Statifier.Interpreter.ExitEntry's entry-set construction absorbs repeats into the set it is building - so the dedupe would be dead work at every call site that exists. Deduplicate at the consumer that needs it, if one ever does, rather than here.

get_transition_domain(machine_state, transition)

@spec get_transition_domain(
  machine_state :: Statifier.MachineState.t(),
  transition :: Statifier.Machine.Transition.t()
) :: non_neg_integer() | nil

getTransitionDomain (Appendix D) - the single state whose descendants (in the configuration) transition exits, or nil for a transition with no effective targets.

machine_state stands in for the same globals as get_effective_target_states/2, which this function calls first. Two literal-port details:

  • The guard here is on effective targets (an empty resolution, e.g. an unrecorded history with no default reachable, yields nil), distinct from compute_exit_set/2's guard on written targets - the pseudocode keeps the two tests separate and so does this port.
  • type == :internal only ever narrows the domain to source when source is compound (Machine.compound?/2) and every effective target is a proper descendant of it (Machine.descendant?/3); every other case, including every :external transition, falls through to find_lcca/2 on [source | effective_targets].

remove_conflicting_transitions(machine_state, enabled_transitions)

@spec remove_conflicting_transitions(
  machine_state :: Statifier.MachineState.t(),
  enabled_transitions :: [Statifier.Machine.Transition.t()]
) :: [Statifier.Machine.Transition.t()]

removeConflictingTransitions (Appendix D) - enabled_transitions, filtered down to a conflict-free set, ported exactly for its filter order: two transitions conflict iff their exit sets intersect; on conflict, a t1 sourced in a descendant of t2's source preempts t2 (t2 is marked for removal, t1 keeps checking the rest of the filtered set); otherwise t1 itself is preempted and its inner loop stops immediately - this is not "the earlier transition always wins", it is "the earlier transition wins unless the later one is the more specific (descendant) source". A surviving t1 has every transition it marked for removal dropped from the filtered set and is appended to it.

This is the function ADR-0002 was partly adopted to fix: v1's conflict resolution reduced every transition's exit set to a boolean "does it leave the nearest parallel ancestor" and collapsed an entire microstep to one transition whenever both a leaving and a non-leaving transition were enabled, discarding unrelated, genuinely non-conflicting transitions from other parallel regions. This port computes a real exit set per transition (compute_exit_set/2) and only ever removes a transition that actually intersects another's exit set.

The insertion order enabled_transitions arrives in is select_transitions/2/select_eventless_transitions/1's document-order, dedup-by-t_index walk - the pseudocode's own comment about toList's ordering "the order of the states that selected them" is exactly that walk, not anything this function itself orders.

select_eventless_transitions(machine_state)

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

selectEventlessTransitions (Appendix D) - select_transitions/2's sibling for transitions with no event attribute (events == []). Same walk, same dedupe, same trailing remove_conflicting_transitions/2 call; the pseudocode writes these as two functions rather than one parameterized by "is there an event" and this port keeps them that way (Credo.Check.Design.DuplicatedCode is disabled in .credo.exs for exactly this reason).

Returns {machine_state, transitions}, matching select_transitions/2: unchanged when no cond fails, carrying one error.execution per failed cond (in walk order) when one does.

select_transitions(machine_state, event)

@spec select_transitions(
  machine_state :: Statifier.MachineState.t(),
  event :: Statifier.Event.t()
) ::
  {Statifier.MachineState.t(), [Statifier.Machine.Transition.t()]}

selectTransitions (Appendix D) - the transitions event enables, one per atomic state in machine_state.configuration at most (child preempts ancestor), deduplicated by t_index, and filtered through remove_conflicting_transitions/2 per the pseudocode's own last line.

event.name is tokenized once via NameMatch.tokenize/1 before the walk and threaded down to every atomic state's search - a hoist out of the per-transition matcher - rather than re-split per transition.

Returns {machine_state, transitions}: the machine_state comes back unchanged when no cond fails during this round, and carries one error.execution per failed cond (in walk order) when one does - see the moduledoc.