StatifierBlocks.Core.Branch (StatifierBlocks v0.28.0)

Copy Markdown View Source

core.branch: one slot per condition arm, plus otherwise (ADR-0002 decision 10) and undecided (ADR-0012 decision 2).

The config-parameterized case ADR-0001 decision 5 exists for. config carries an ordered "arms" list, each arm a %{"slot" => name, "cond" => expression} pair, and slots/1 returns one slot per arm in that order followed by otherwise and undecided:

config = %{"arms" => [%{"slot" => "arm_approved", "cond" => "budget_remaining > amount"}]}

slots(config)
#=> [{"arm_approved", :at_least_one, ~s(When "approved")},
#=>  {"otherwise", :any, "Otherwise"},
#=>  {"undecided", :any, "Cannot be decided"}]

The third slot is the reading, not a third destination

A predicator condition has three readings and the compiled chart used to have two destinations. A comparison against an operand it cannot compare

  • an unbound datamodel path being the ordinary case - produces neither true nor false but predicator's :undefined sentinel, which the engine turns into "the transition is not taken, plus one error.execution". So an undecided condition behaved exactly as a false one. ADR-0012 gives that reading a slot of its own, and the whole of the compiled difference is one synthesized guard transition - see emit/2.

Wiring is what turns the difference on: a branch that leaves undecided empty compiles to the bytes it compiled to at 0.20.0, error.execution included (ADR-0012 decision 3).

Three details worth naming, because each is a place a reader would reasonably guess the other way:

  • undecided is a slot, not an outcome. outcomes/1 is untouched: slots/1 says where children live and outcomes/1 says how finishing can differ (ADR-0002 amendment A2). An undecided condition changes which children run; it does not change how the branch finishes.
  • An arm stores its whole slot name, "arm_approved", not the suffix. ADR-0002 decision 10's table says "slot suffix"; the ADR-0001 worked example stores the full name, and the stored bytes are what this type has to read.
  • Arms are :at_least_one, otherwise is :any. ADR-0002 decision 6's arity table names "a branch arm that must do something" as :at_least_one's motivating case, and an empty arm compiles to a condition guarding nothing. An empty otherwise is the ordinary "and if not, carry on".
  • produces is :unknown, not a join of the arms. ADR-0003 decision 4 is explicit: combining the arms' outputs is a type lattice, and this package does not build one.

The schema is one :expression field per arm

config_schema/1 returns a condition field per arm, keyed by that arm's slot name - which is unique within the block, and is the same key validate_config/1 reports findings against, so a finding routes to the field it is about (ADR-0005 decision 11's {:config, block_id, key} anchor).

The condition itself is not stored under that key, though: it lives at config["arms"][i]["cond"], so each field also declares the value_path ADR-0002 decision 7 was amended to carry (2026-08-27) - ["arms", i, "cond"], with i the arm's index in the stored list. Key and value path are two different questions about one field, and this is the core type that has to answer them differently: the editor reads and writes the condition through the path while findings and the form control keep addressing the arm by name.

The "arms" list itself is deliberately not a field. Adding and removing an arm changes the block's slot set, which makes it an editor command over the document rather than a value typed into a form.

Summary

Functions

One :expression condition field per arm, keyed by the arm's slot name and reading through value_path: ["arms", index, "cond"].

A compound state whose initial is a transient pick state carrying one conditional transition per arm, in config order, then an unconditional one for otherwise (ADR-0004 decision 2 names this shape).

Two datasets and one condition evaluated against both, so a palette panel can show what an arm's expression does before the author commits to it.

This block as one line of prose (ADR-0002's 2026-09-07 amendment).

One slot per well-formed arm, in config order, then otherwise, then undecided.

N arms + otherwise, counting the well-formed arms (ADR-0002 amendment H6).

Functions

config_schema(config)

One :expression condition field per arm, keyed by the arm's slot name and reading through value_path: ["arms", index, "cond"].

index is the arm's position in the stored list rather than among the well-formed ones, so an arm below a malformed one still addresses its own condition while an author is mid-edit. See the moduledoc for why the key and the path answer two different questions.

emit(block, context)

A compound state whose initial is a transient pick state carrying one conditional transition per arm, in config order, then an unconditional one for otherwise (ADR-0004 decision 2 names this shape).

<state id="s_BR" initial="s_BR__pick">
  <state id="s_BR__pick">
    <transition cond="budget_remaining &gt; amount" target="s_blk_A"/>
    <transition target="s_blk_B"/>
  </state>
  <transition event="done.state.s_blk_A" target="s_BR__done"/>
  ...
  <final id="s_BR__done"/>
</state>

An arm's cond is the author's :expression config passed through verbatim into predicator's datamodel - the compiler ships no expression checking of its own (ADR-0004 decision 9), so a typo there surfaces as an upstream compile error routed back through provenance by sb-qz0.

Each arm's steps are sequenced the same way a core.sequence's are, and every arm's last step transitions to the block's own <final>, so a branch is done when whichever arm it took is done. An empty otherwise transitions there directly.

The undecided guard, when the slot is wired

A branch whose undecided slot holds at least one child emits one extra transition, and it sits after every arm and before otherwise (ADR-0012 decision 4). Its condition is composed from the arms' own sources:

not ((c1) === false and (c2) === false and ... and (cn) === false)

Strict equality is the one comparison predicator answers with a boolean when handed its :undefined sentinel, so each conjunct is true exactly when that arm decided false, and the negation is true exactly when at least one arm did not decide (ADR-0012 decision 5). Ordering is what keeps the guard that simple: by the time it is reached no arm decided true, so "some arm was undecided" is all that is left to test, and an earlier undecided arm never shadows a later decided one (decision 6).

Two things the guard deliberately does not do. It does not rewrite the arms' own transitions into (ci) === true, because a prefix on the author's source would offset every provenance span composed inside it; the arms keep the author's bytes. And it carries no cond_key, because it is the type's composition rather than an author's :expression field - a typo in ci surfaces on that arm's own transition, which does carry the key.

An arm that errors rather than going undecided makes the guard error the same way, so the guard is not taken and the block falls to otherwise - which is where that arm already sent it. Wiring the slot therefore adds a second error.execution for such an arm and routes nothing differently (ADR-0012 decisions 5 and 7). A branch with no usable condition emits no guard at all.

fixtures()

Two datasets and one condition evaluated against both, so a palette panel can show what an arm's expression does before the author commits to it.

Provisional: the accepted spellings are not settled

PROVISIONAL - see ADR-0002 decision 9. The atom-keyed spelling below comes from an amendment to that decision which has not been accepted. Until it is, treat the shape as the intended target rather than a settled contract. That this callback exists, and returns term(), is settled either way.

This is the one core type whose examples earn a bundle on their own: every other structural type arranges blocks and has nothing to evaluate, and statifier-ui's own docs/fixture-bundles.md names core.sequence as its example of a fragment that ships no examples.

sentence(config)

This block as one line of prose (ADR-0002's 2026-09-07 amendment).

The first arm's label and the fallback, which is what a reader scanning a list needs to know a decision happens here and where the untaken paths go. It reads slots/1 rather than the arm list directly, so the label a line draws and the label the slot draws can never disagree.

A branch with no well-formed arm takes otherwise every time, and the sentence says so rather than naming an arm that is not there.

iex> StatifierBlocks.Core.Branch.sentence(%{"arms" => [%{"slot" => "arm_approved", "cond" => "x"}]})
~s(Decide: When "approved", otherwise)

iex> StatifierBlocks.Core.Branch.sentence(%{})
"Decide: otherwise"

slots(config)

One slot per well-formed arm, in config order, then otherwise, then undecided.

Total for any config, including config validate_config/1 rejects: malformed arms are skipped rather than raised on, which is what keeps ADR-0002 decision 6's stability rule true while an author is mid-edit.

undecided is ADR-0012 decision 2: a slot rather than an outcome, :any for the same reason otherwise is, and labelled "Cannot be decided" because that label is the whole of the explanation an author gets in the editor. It cannot collide with an arm, whose slot name must match arm_[a-z][a-z0-9_]*.

summary(config)

N arms + otherwise, counting the well-formed arms (ADR-0002 amendment H6).

A count rather than a list of conditions: an arm's condition is an expression, and an expression is not a chip. otherwise is named rather than counted because it is always there - slots/1 appends it to every branch, including one with no arms at all, and a card reading 1 arm would be under-reporting the paths out of the block by one.

Counted through the same filter slots/1 and config_schema/1 read arms through, so a malformed arm an author is mid-edit on is not counted and the card agrees with the slot list.

undecided is not named here, in either direction. ADR-0012 decision 9 asks for "1 arm + otherwise + undecided" on a branch that wires the slot, and this callback cannot answer that: @callback summary(Block.config()) is handed the config alone (lib/statifier_blocks/block_type.ex:609), and whether a slot holds children is a fact about the block's slots map rather than its config. Widening the callback to see the block is a contract change no record has asked for, so the card reads exactly as it did at 0.20.0 - which is decision 9's own answer for every unwired branch, and an under-report by one for a wired one.

iex> StatifierBlocks.Core.Branch.summary(%{"arms" => [%{"slot" => "arm_approved", "cond" => "x"}]})
"1 arm + otherwise"

iex> StatifierBlocks.Core.Branch.summary(%{})
nil