Statifier.Interpreter.Datamodel (Statifier v2.0.0)

Copy Markdown View Source

Datamodel creation and early/top-level binding - the interpret preamble hook (Statifier.Interpreter.initialize/2).

No Appendix D procedure body to port

initializeDatamodel/initializeDataModel have no procedure body anywhere in Appendix D. A case-insensitive grep over spec-cache/appendix-d.txt returns exactly two hits, both call sites, and they do not even agree with each other: :102, inside interpret, calls initializeDatamodel(datamodel, doc); :312, inside enterStates, calls initializeDataModel(datamodel.s, doc.s) - different capitalization, different arity meaning (whole-document vs. per-state). new Datamodel(doc) (:101) is never defined either. So there is nothing to diff this module's bodies against; ADR-0002's port obligation here is the two call sites (this module's initialize/1, and Phase 5's enter_state/2), not a pseudocode block. This module's semantics instead come from the prose of clauses 5.3.2, 5.3.3, and B.2.2:

  • 5.3.2 (environment override, scoped to top-level): "The SCXML Processor MUST use any values provided by the environment at instantiation time in place of those contained in the top-level <data> elements. (Top-level data elements are those that are children of the <datamodel> element that is a child of <scxml>)."
  • 5.3.2 (the failure clause): "If the value specified for a <data> element (by 'src', children, or the environment) is not a legal data value, the SCXML Processor MUST raise place error.execution in the internal event queue and MUST create an empty data element in the data model with the specified id." ("MUST raise place" is the REC's own typo.) An expr failure is 5.9.3's case instead: "If a value expression does not return a legal data value, the SCXML Processor MUST place the error 'error.execution' in the internal event queue."
  • 5.3.3 (binding): "When 'binding' is assigned the value "early" (the default), the SCXML Processor MUST create all data elements and assign their initial values at document initialization time. When 'binding' is assigned the value "late", the SCXML Processor MUST create the data elements at document initialization time, but MUST assign the specified initial value to a given data element only when the state that contains it is entered for the first time, before any <onentry> markup."
  • B.2.2 (no ordering dependencies): "Ordering dependencies between <data> elements are not permitted... the SCXML Processor MUST evaluate all <data> elements at initialization time but MAY do so in any order it chooses."

Algorithm (initialize/1)

  1. env_ids = MapSet.new(Map.keys(machine_state.datamodel)), captured first, before any seeding - MachineState.new/2's only writers of datamodel at this point are the :datamodel option and SystemVariables.initial/2, so key presence here is definitionally "provided by the environment at instantiation time" (5.3.2's own phrase).
  2. Seed: Map.put_new(datamodel, id, :undefined) for every %Machine.Data{} in machine.data_elements, regardless of binding - 5.3.3 makes creation unconditional under both bindings, and writing :undefined directly means a seeded-but-unbound id answers === undefined/ !== undefined rather than raising UndefinedVariableError (Decision 3).
  3. Bind, in ascending d_index order: under :early, every d_index; under :late, only the root state's own (Machine.at(machine, 0).data)
    • the top-level <data>, which 5.3.3's per-state deferral has no state to defer to (Decision 7, and this module's own initialize/1 @doc below). A top-level d_index whose id is in env_ids is skipped entirely, leaving its seeded :undefined in place - Decision 1's environment-wins rule. A state-scoped <data> is never skipped this way: 5.3.2 scopes the override to top-level only.

Binding a d_index: {:invalid, error} (a <data expr> that failed to compile, Decision 2) and {:src, uri} (never fetched, ADR-0003) both short-circuit straight to the failure branch with no evaluation attempted; any other Machine.expr() goes through Statifier.Evaluator.evaluate/2 and takes the failure branch on {:error, _}. Either way the id keeps the :undefined step 2 already wrote - 5.3.2's "MUST create an empty data element" is satisfied by not overwriting the seed, never by writing :undefined after an error (Decision 3) - and MachineState.raise_platform(machine_state, "error.execution", {:data, d_index}, data: reason) runs: raise_platform/4, not raise_internal/4, because spec 5.10.1 classifies error.* as a platform event, matching the two existing raise sites (Statifier.Interpreter.Content.raise_execution_error/4, Statifier.Interpreter.Selection.raise_cond_errors/2).

Summary

Functions

enterStates's per-state datamodel step (Appendix D :312, prose in the moduledoc's "no Appendix D procedure body" section above) - binds state_index's own <datamodel> (Machine.at(machine, state_index).data) under binding == :late.

interpret's datamodel preamble (Appendix D :101-102, prose above), plus the top-level half of enterStates' per-state binding (:312) that late binding would otherwise defer forever - see the moduledoc's "no Appendix D procedure body" section and spec 5.3.3's binding rule for why the if doc.binding == "early": guard that wraps the interpret call site in the pseudocode moves off that call site and into this function instead: a top-level <data> is contained in no state, so under binding="late" it never reaches enterStates' statesToEnter loop at all, and deferring its binding to a state entry that never happens would leave it permanently unassigned - a spec violation, not a conforming late-binding delay.

Writes value at path_source (a raw, uncompiled path expression such as "foo.bar.baz" or "items[0]") against machine_state's datamodel, rebinding the written root into datamodel_context for callers that thread a context across a block (ADR-0028). Extracted from Statifier.Machine.Content.Assign's defimpl - see that module's moduledoc for the vivification and system-variable reasoning this function's five steps carry out unchanged; this is a pure mechanics move, not a new decision.

Functions

enter_state(machine_state, state_index)

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

enterStates's per-state datamodel step (Appendix D :312, prose in the moduledoc's "no Appendix D procedure body" section above) - binds state_index's own <datamodel> (Machine.at(machine, state_index).data) under binding == :late.

This function does not itself test "is this the first time state_index has been entered" - Appendix D's s.isFirstEntry (appendix-d.txt:312). Statifier.Interpreter.ExitEntry.arrive/3 is the only caller, and it tests membership in MachineState.entered_states (the ADR-0002 substitute for s.isFirstEntry, documented at that field) before calling this function, so by the time this function runs, "first entry" has already been decided. Splitting it this way keeps this module free of MachineState.configuration/ entered_states mutation, which arrive/3 already owns for every other step of the same pseudocode body.

A no-op, returning {machine_state, []} unchanged, in three cases: under binding == :early (every d_index was already bound at initialize/1, before any state was entered), under binding == :late on a state whose own <datamodel> is empty (data == [] - nothing to bind), and on state_index == 0 for the reason below.

Why state_index == 0 is a no-op

Index 0 is the document root, and it is in configuration like any other state, so arrive/3 calls this function for it. But the root's own data list is the top-level data list - the <datamodel> that is a child of <scxml> - which initialize/1 has already bound (Decision 3: top-level <data> binds at initialization under both bindings, since 5.3.3's "the state that contains it" has no answer for a <data> contained in no state). Binding it a second time here is duplication, and not harmless duplication: this function deliberately applies no environment override, so the second pass would overwrite an environment-supplied value with the document's own and violate 5.3.2's

"The SCXML Processor MUST use any values provided by the environment
at instantiation time in place of those contained in the top-level
<data> elements."

Only binding == "late" reaches this path at all, and only a document whose environment seeds a top-level id observes it - which is why no corpus file catches it. Spec 6.4.3 makes it reachable in practice: an invoked session's <param>/namelist values arrive as exactly this environment seed, so without this guard a late-bound invoked child would silently discard the values its parent passed it - the child-session half of <invoke> that would deliver them does not exist yet, per docs/architecture.md's "Sessions and invoke" section.

Otherwise: one Evaluator.context/1 for state_index's whole data list (B.2.2's "no ordering dependencies" licenses this exactly as it does in initialize/1), each d_index bound through the same bind_value/4 this module's initialize/1 uses - same seeded-:undefined-on-failure, raise_platform/4 shape, Decision 2/3 unchanged, and the same {:datamodel_change, %Effect.DatamodelChange{}} emission per successful binding (decision 3). There is no environment override on this path: Decision 1 scopes that skip to top-level <data> only, and a state-scoped <data> on any state but the root is never top-level.

initialize(machine_state)

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

interpret's datamodel preamble (Appendix D :101-102, prose above), plus the top-level half of enterStates' per-state binding (:312) that late binding would otherwise defer forever - see the moduledoc's "no Appendix D procedure body" section and spec 5.3.3's binding rule for why the if doc.binding == "early": guard that wraps the interpret call site in the pseudocode moves off that call site and into this function instead: a top-level <data> is contained in no state, so under binding="late" it never reaches enterStates' statesToEnter loop at all, and deferring its binding to a state entry that never happens would leave it permanently unassigned - a spec violation, not a conforming late-binding delay.

Returns {MachineState.t(), [Effect.t()]}, like every other function in Statifier.Interpreter: the first effect on the list is always the {:datamodel_init, %Effect.DatamodelInit{}} baseline, built from machine_state right after seed/2 and before the binding fold - the datamodel as it stands the instant every declared <data> exists but before any of them has a value (see Statifier.Effect.DatamodelInit's own moduledoc for what the map does and does not carry). Every effect after it, in ascending d_index order, is a {:datamodel_change, %Effect.DatamodelChange{}} for one successful <data> binding (bind_value/4, decision 3) - a failed or environment-skipped binding emits nothing (decision 5). No trace effect is produced either way (docs/observability.md's minimum trace vocabulary is a closed table and datamodel binding is not a member of it); the one observable event a failed binding can produce, error.execution, is already an ordinary internal event that travels on machine_state's own queue.

write_location(machine_state, datamodel_context, path_source, value)

@spec write_location(
  machine_state :: Statifier.MachineState.t(),
  datamodel_context :: Predicator.Context.t(),
  path_source :: String.t(),
  value :: term()
) ::
  {:ok, Statifier.MachineState.t(), Predicator.Context.t(),
   Statifier.Interpreter.Datamodel.Write.t()}
  | {:error, term()}

Writes value at path_source (a raw, uncompiled path expression such as "foo.bar.baz" or "items[0]") against machine_state's datamodel, rebinding the written root into datamodel_context for callers that thread a context across a block (ADR-0028). Extracted from Statifier.Machine.Content.Assign's defimpl - see that module's moduledoc for the vivification and system-variable reasoning this function's five steps carry out unchanged; this is a pure mechanics move, not a new decision.

  1. Resolve path_source against datamodel_context.data (the normalized view, so a bracket key such as items[i] reads i exactly as an expression would).
  2. Reject a resolved root beginning with "_" (spec 5.10 - a system variable).
  3. Require the resolved root to already be a key of machine_state.datamodel - auto-vivification only ever creates intermediate containers, never an undeclared top-level variable.
  4. Write into the raw machine_state.datamodel, never the normalized .data read in step 1.
  5. Bind just the written root into datamodel_context - O(size of that root), not O(size of the datamodel).

Every predicator failure returns as a bare {:error, term()} - the caller is the sole error.execution conversion site (ADR-0003).

On success, the fourth element is a Statifier.Interpreter.Datamodel.Write report of the write - the resolved path, the prior_value read at that full path immediately before the write, and the new_value written. Reporting is all this function does; building an effect from the report is each caller's own job.