# `Statifier.Chart`
[🔗](https://github.com/riddler/statifier-ex/blob/v2.10.0/lib/statifier/chart.ex#L1)

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`).

# `accepts_check`

```elixir
@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`

```elixir
@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`

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

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

# `diff_reason`

```elixir
@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.

# `check_accepts`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
