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
@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 onesget_transition_domain/2resolves. A transition with written targets whose effective targets resolve empty still enters the loop; its domain is thenniland it is handled explicitly below, contributing nothing rather than being filtered out earlier by a merged test. Machine.descendant?/3is 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).
@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.
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.
@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.
@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 fromcompute_exit_set/2's guard on written targets - the pseudocode keeps the two tests separate and so does this port. type == :internalonly ever narrows the domain tosourcewhensourceis compound (Machine.compound?/2) and every effective target is a proper descendant of it (Machine.descendant?/3); every other case, including every:externaltransition, falls through tofind_lcca/2on[source | effective_targets].
@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.
@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.
@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.