Statifier.Chart (Statifier v2.8.0)

Copy Markdown View Source

The questions a host asks about a chart - a compiled Statifier.Machine.t() - without running it. Three are answered here: its versioned binary contract (to_binary/1, from_binary/1), its event vocabulary (events/1), with the check of a declaration against that vocabulary (check_accepts/2), and what changed between two charts (diff/3).

The binary contract

The versioned binary contract for a chart is a Statifier.Machine.t() reduced to the inputs that reproduce it: its SCXML source, the persisted subset of the options it was compiled with, and its Statifier.Machine.Identity.t(). No compiled term is written - from_binary/1 rebuilds a Machine.t() by recompiling the stored source with the stored options through Statifier.compile/2, the same pipeline any other caller runs, rather than by deserializing compiler output directly.

This is boundary work, not core work (docs/architecture.md principle 2), and it could not live on Machine even if that boundary argument were set aside: from_binary/1 calls Statifier.compile/2 to rebuild its result, and Statifier.compile/2 itself builds a Machine.t() (ADR-0003's layering - the thing produced does not call back into its own producer). Putting the pair here instead keeps the dependency pointing one direction: Statifier.Chart depends on Statifier and Statifier.Machine, never the reverse. It also keeps lib/statifier/machine.ex's moduledoc - already carrying the full 100% Doctor burden for the compiled struct itself - free of a second concern (persisting a chart across a process or storage boundary) that has nothing to do with what the struct means once compiled.

to_binary/1 refuses to encode a Machine carrying no identity or no source ({:error, :unidentified_chart}): a Machine built without either has nothing for a future from_binary/1 to recompile from or check against, so no blob is produced for it at all. from_binary/1 decodes safely, checks the envelope's tag and shape, checks its format version, recompiles the stored source under the stored options, and only then compares the recompiled Machine's identity against the blob's - in that order, for the same reason Statifier.Position checks version before identity: a future format whose identity representation changed should report the version mismatch, not a confusing identity one.

The event vocabulary

events/1 answers which event descriptors the chart listens for: every descriptor on a transition whose source state can be active, each returned as authored, a pattern reported as a pattern and never expanded. "Can be active" is a static rule over the chart's structure, stated on events/1; it over-counts and never under-counts, and it reads no cond. The function reads only the compiled machine - no source text, no identity - and runs nothing.

check_accepts/2 compares a declaration of the event names a chart accepts with that vocabulary, under the descriptor matching transition selection uses, and answers the names the chart can never select on (unreachable) and the descriptors the declaration does not state (undeclared). It reports and refuses nothing: which list a host refuses a publish on, if either, is the host's decision. With no declaration (nil) the computed vocabulary is the contract, so both lists are empty; asked with a one-name declaration, it is the membership answer for a receiver that declares nothing.

Both live here, not in Statifier.Validator: validate/3 judges a document against the spec and takes no deployment state, and the vocabulary is what a host compares a deployment's claims against before any execution starts - the same posture as Statifier.Send.Types.unsupported_sends/2 (ADR-0071, after ADR-0069 decision 3). They keep this module's layering: they depend on Statifier.Machine (and check_accepts/2 on Statifier.Interpreter.NameMatch), never the reverse.

The diff

diff/3 classifies a pair of compiled charts as identical, compatible, mapped or breaking, and names the reasons (ADR-0072). It is structural: it says what the two charts are, never what an execution will do, and it moves nothing. A rename the engine cannot see is supplied by the caller as a plain mapping: from old state ids to new ones. It shares events/1's "can be active" rule, which stays private to this module (ADR-0072 decision 5).

No I/O

No function here performs I/O; encoding and decoding a binary in memory, recompiling source already held in memory, and walking a compiled machine are not effects a caller has to route around (ADR-0003 does not apply here, and this module is not listed in @effect_interpreter_paths).

Summary

Types

What check_accepts/2 answers: the declared names no descriptor in the vocabulary matches, and the vocabulary's descriptors that match no declared name.

What diff/3 answers: the pair's class and its reasons, in diff/3's order.

One of the four classes diff/3 answers (ADR-0072 decision 1).

One reason diff/3 reports. The ones marked breaking in diff/3's doc make a pair :breaking; the rest report without changing the class.

Functions

Compares declared, the event names a chart is declared to accept, with the chart's event vocabulary (events/1).

Classifies two compiled charts, from (the chart an execution is pinned to) and to (a candidate), into one of four classes and returns the reasons (ADR-0072 decision 1). diff/2 is the head with opts defaulted to [].

The chart's event vocabulary: every event descriptor on a transition whose source state can be active, computed from the compiled machine alone.

The version tag to_binary/1 writes and from_binary/1 checks. A bare integer, so a future format change is a version bump here rather than an inference from the blob's shape.

Decodes a to_binary/1 envelope and recompiles it into a Machine.t().

Encodes machine as a tagged, versioned binary envelope carrying its SCXML source, its persisted compile_opts, and its Statifier.Machine.Identity.t()

Types

accepts_check()

@type accepts_check() :: %{unreachable: [String.t()], undeclared: [String.t()]}

What check_accepts/2 answers: the declared names no descriptor in the vocabulary matches, and the vocabulary's descriptors that match no declared name.

diff()

@type diff() :: %{class: diff_class(), reasons: [diff_reason()]}

What diff/3 answers: the pair's class and its reasons, in diff/3's order.

diff_class()

@type diff_class() :: :identical | :compatible | :mapped | :breaking

One of the four classes diff/3 answers (ADR-0072 decision 1).

diff_reason()

@type diff_reason() ::
  {:state_nameless, non_neg_integer()}
  | {:state_unresolved, String.t()}
  | {:state_changed, String.t(),
     [:kind | :parent | :atomic | :regions | :history_type]}
  | {:state_mapped, String.t(), String.t()}
  | {:state_removed, String.t()}
  | {:state_added, String.t()}
  | {:transition_removed, String.t(), non_neg_integer()}
  | {:transition_added, String.t(), non_neg_integer()}
  | {:event_removed, String.t()}
  | {:event_added, String.t()}
  | {:data_removed, String.t()}
  | {:data_added, String.t()}
  | {:mapping_unused, String.t()}

One reason diff/3 reports. The ones marked breaking in diff/3's doc make a pair :breaking; the rest report without changing the class.

Functions

check_accepts(machine, declared)

@spec check_accepts(machine :: Statifier.Machine.t(), declared :: [String.t()] | nil) ::
  accepts_check()

Compares declared, the event names a chart is declared to accept, with the chart's event vocabulary (events/1).

A descriptor matches a declared name under the descriptor semantics transition selection uses: Statifier.Interpreter.NameMatch.name_match?/2 over the descriptor's tokens and the name's tokenize/1 tokens, on token boundaries. A declared loan.renew is matched by the descriptor loan.renew, by loan.*, by loan., by loan and by *, and not by loan.renewal or loan.renew.late. One relation answers both lists:

  • unreachable - each declared name that no descriptor in the vocabulary matches, in the declaration's order and without duplicates: a name the declaration promises and the chart can never select on.
  • undeclared - each descriptor in the vocabulary that matches no declared name, in events/1's order: a name the chart listens for that the declaration does not state.

A declared entry is a name, not a descriptor: a * in it is an ordinary token and never a pattern, so a declared loan.* is matched by the descriptor loan but not by loan.renew. An empty list is a declaration that the chart accepts nothing: unreachable is [] and undeclared is the whole vocabulary.

With nil - no declaration - the computed vocabulary is the contract, which cannot disagree with itself, so both lists are empty. A host asking whether one name n is in a chart's computed vocabulary calls check_accepts(machine, [n]) and reads unreachable: [] means some reachable descriptor matches n, and [n] means none does.

The function reports and refuses nothing; which list a host refuses a publish on, if either, is the host's decision. Pure and total over a %Statifier.Machine{} and a list of strings or nil; like events/1 it reads no source text and needs no identity or source on the machine.

diff(from, to, opts \\ [])

@spec diff(
  from :: Statifier.Machine.t(),
  to :: Statifier.Machine.t(),
  opts :: keyword()
) :: diff()

Classifies two compiled charts, from (the chart an execution is pinned to) and to (a candidate), into one of four classes and returns the reasons (ADR-0072 decision 1). diff/2 is the head with opts defaulted to [].

  • :identical - Statifier.Machine.Identity.matches?/2 holds for the two identities, name and version included (ADR-0052 decision 1). Nothing structural is compared and reasons is []. A machine with no identity is never identical to anything.
  • :compatible - the structural comparison found no breaking reason and no mapped state. Additions are allowed and reported.
  • :mapped - no breaking reason, and at least one state of from absent from to is resolved by opts[:mapping].
  • :breaking - at least one breaking reason.

The structural comparison

Two states correspond when they carry the same id, or when the mapping resolves a from state to a to state; the roots always correspond. A state of from is held when it can be active under the rule events/1 states, or it is a history pseudo-state whose parent can be active. The reasons:

  • {:state_nameless, index} (breaking) - a held state of from, not the root, with no id. A nameless state that is not held is ignored, and a nameless state of to is never reported.
  • {:state_unresolved, id} (breaking) - a held state of from with no corresponding state in to.
  • {:state_changed, id, fields} (breaking) - a held state of from whose corresponding state differs in any of fields, in this order: :kind; :parent (the parent's corresponding id); :atomic; :regions (both parallel, and the corresponding ids of their child states differ); :history_type.
  • {:state_mapped, from_id, to_id} - a state of from, held or not, resolved by the mapping and not reported as changed.
  • {:state_removed, id} - a state of from that is not held and has no corresponding state in to.
  • {:state_added, id} - a state of to with an id that corresponds to no state of from.
  • {:transition_removed, source_id, t_index} (breaking) - a selectable transition of a held state of from that matches no transition of the corresponding state. A transition of an unresolved or nameless state is covered by the state's own reason.
  • {:transition_added, source_id, t_index} - a selectable transition of a state of to that corresponds to a state of from and matches no transition of that state. A transition of an added state is covered by the state's own reason.
  • {:event_removed, descriptor} (breaking) and {:event_added, descriptor} - a descriptor in one side's events/1 and not in the other's, compared as strings. A pattern replaced by a wider one still reports the removal: nothing reasons about which names a pattern stands for.
  • {:data_removed, id} (breaking) and {:data_added, id} - a <data> id declared anywhere in one chart and nowhere in the other. A <data> element's value is not compared.
  • {:mapping_unused, from_id} - a mapping entry the comparison did not read.

The equality per element is never struct equality and never a source slice. A state compares by its id through correspondence, kind, its parent's corresponding id, whether it is atomic, its child states' corresponding ids when parallel, and history_type; its executable content, initial, donedata and invoke list are not compared. A transition matches another when its source's corresponding id, its events joined as events/1 joins them, its targets' corresponding ids in the order written, its type, and its cond as authored (a static value, or a compiled expression's source text) are all equal; its content, t_index and locations are not compared, and a state's transitions match as a multiset, so a reordering is not reported. A datamodel key compares as the <data> element's id.

Order. The from-side state reasons in from's document order, one per state at most; then :state_added in to's document order; then :transition_removed in from's t_index order; then :transition_added in to's t_index order; then :event_removed and :event_added in each side's events/1 order; then :data_removed and :data_added in each side's d_index order; then :mapping_unused, sorted by id.

The mapping (ADR-0072 decision 2)

opts[:mapping] is a plain map from a from state id to a to state id. An entry is read only when its key is the id of a state of from that is absent from to and its value is the id of a state of to; that state then corresponds to the one the value names. Every other entry is reported as :mapping_unused and changes no class. A mapped pair is still compared, so a mapping onto a state of another kind or under another parent is :state_changed and breaking.

Raises ArgumentError when opts holds anything but mapping:, when the mapping is not a map from strings to strings, or when it would make one state of to correspond to two states of from: two read entries naming the same value, or a read entry whose value is also the id of a state of from. Each is a caller's programming error, not data.

What the classes do not say (ADR-0072 decision 3)

The classes are structural: they say what the charts are, never what an execution will do. A compatible pair can still behave differently (a transition's content, an <onentry>, a condition's meaning, the document order between two enabled transitions), and a breaking pair can be harmless to every execution a host holds, since "held" over-approximates. Nothing here moves an execution.

Pure over two %Statifier.Machine{}s: it reads no source text, needs no source on either machine, and runs nothing.

events(machine)

@spec events(machine :: Statifier.Machine.t()) :: [String.t()]

The chart's event vocabulary: every event descriptor on a transition whose source state can be active, computed from the compiled machine alone.

Each descriptor is returned as authored - its dot-split tokens joined back with ., so loan.renew returns loan.renew and loan. returns loan.. A pattern is reported as a pattern, never expanded: * and loan.* come back as written, and the function never guesses which names a pattern stands for. Platform and internal descriptors (done.state., error., a name the chart raises itself) are descriptors the chart listens for and are included. An eventless transition contributes nothing; a chart with no transition carrying an event answers [].

Descriptors appear in t_index order (states in document order, each state's own transitions before its children's), and within one event attribute in the order written; a descriptor equal, as a string, to one already returned is dropped. A document given inline to <invoke> is its own chart and is not read.

"Can be active" is a static rule over the chart's structure: a state some path enters, its ancestors included. It follows Appendix D's addDescendantStatesToEnter and addAncestorStatesToEnter. The root is entered by its default. Entering a state by its default enters it and then its initial states as targets (a compound state or the root), every child that is not a history by its default (a parallel state), or its history_default transition's targets as targets (a history pseudo-state). Entering a state as a target enters it by its default, enters each of its proper ancestors, and, for each parallel ancestor, enters by its default every child region that holds none of the transition's targets. Every transition in an entered state's transitions enters its targets as targets. A transition's cond and event are not read, so a transition whose condition is never true in practice still counts. A state's descriptors join the vocabulary when it is entered and is not a history pseudo-state.

So a transition on an ancestor of an active state is in the vocabulary, a descriptor on a state no path enters is not, a history's default target counts as entered, and every region of a reachable parallel state is reachable. The rule over-counts and never under-counts: a descriptor missing from the answer is one the chart can never select on.

Pure and total over a %Statifier.Machine{}; it reads no source text and needs no identity or source on the machine.

format_version()

@spec format_version() :: pos_integer()

The version tag to_binary/1 writes and from_binary/1 checks. A bare integer, so a future format change is a version bump here rather than an inference from the blob's shape.

from_binary(blob)

@spec from_binary(blob :: binary()) ::
  {:ok, Statifier.Machine.t()}
  | {:error, :not_a_statifier_blob}
  | {:error, {:unsupported_format_version, term()}}
  | {:error, {:compile_failed, [Statifier.error()]}}
  | {:error,
     {:identity_mismatch, expected :: Statifier.Machine.Identity.t(),
      actual :: Statifier.Machine.Identity.t() | nil}}

Decodes a to_binary/1 envelope and recompiles it into a Machine.t().

Checks run in this order, and the order matters: decode safely, then check the envelope's tag and shape, then its format version, then recompile the stored source under the stored options through Statifier.compile/2, then compare the recompiled Machine's identity against the blob's own. Version before recompile before identity, because the identity being checked is the recompiled Machine's - there is no identity to compare until the recompile has run, and a version this build cannot read at all should report as a version mismatch rather than failing to compile for reasons that have nothing to do with the source.

{:error, {:compile_failed, errors}} carries Statifier.compile/2's own [Statifier.error()] list unchanged - a blob whose source no longer compiles under this build (for instance a validator check tightened across a library upgrade) is a real, distinct failure and must not be flattened into :not_a_statifier_blob.

{:error, {:identity_mismatch, expected, actual}}'s expected is the blob's own stored identity and actual is the recompiled Machine's - Position's own argument order. Both are compared with Statifier.Machine.Identity.matches?/2, never ==/2 on the struct (ADR-0052 decision 1): a future identity field addition should not silently change what "the same chart" means at this call site either.

Returns {:error, :not_a_statifier_blob} for anything that is not this module's tagged envelope - a foreign term_to_binary blob, garbage bytes, or a well-formed envelope whose source is not a binary or whose opts are not a keyword list.

to_binary(machine)

@spec to_binary(machine :: Statifier.Machine.t()) ::
  {:ok, binary()} | {:error, :unidentified_chart}

Encodes machine as a tagged, versioned binary envelope carrying its SCXML source, its persisted compile_opts, and its Statifier.Machine.Identity.t()

  • never a compiled term.

Returns {:error, :unidentified_chart} when machine.identity or machine.source is nil - a Machine built without either (for instance one that came straight from Statifier.Compiler.compile/1 rather than Statifier.compile/2) has nothing for from_binary/1 to recompile from or check a future load against, so no blob is produced for it at all.

The payload is machine.source and machine.compile_opts verbatim, never machine itself - the whole point of this module is that a chart's binary form holds nothing Statifier.compile/2 cannot reproduce, which is what keeps the blob far smaller than term_to_binary(machine) for the same chart: the compiled states, transitions, and expressions are the overwhelming majority of a Machine's bytes.