May this block land in this slot? Answered by two independent gates - structural admission by kind tag, and data-flow compatibility by opaque string identity plus a host-supplied widening relation (ADR-0003).
One implementation, consulted by both the editor (sb-w50) and the
compiler (sb-iwz), because both are passed the same palette (ADR-0003
decision 6).
The contract
ADR-0003's "The relation as typespecs" section is this module's contract,
taken verbatim - the seven types above (type_expr/0, kind/0,
produces/0, io/0, context/0, target/0, finding/0) and five
functions:
@spec check(Palette.t(), Document.t(), target(), Block.t(), context()) ::
:ok | {:error, [finding()]}
@spec valid_targets(Palette.t(), Document.t(), Block.t(), context()) :: [target()]
@spec validate(Palette.t(), Document.t(), context()) :: :ok | {:error, [finding()]}
@spec inbound_type(Palette.t(), Document.t(), target(), context()) ::
type_expr() | :unknown
@spec assignable?(Palette.t(), type_expr() | :unknown, type_expr() | :unknown) ::
boolean()Everything else this module exports is built out of that contract, not alongside it.
The deliberate widening
This module ships five more public functions than the record lists:
io/2, kinds/2, slot_accepts/3, and admits?/3 (the structural
kind-admission gate), plus produces/4 (data-flow resolution, including
{:passthrough, slot}). None of these is a second implementation of the
relation - they are the one implementation check/5, valid_targets/4
and validate/3 are built out of, exposed because the record's own
acceptance criteria need them independently testable: the kind gate has
to be assertable in isolation to state the both-directions placement
property over the core vocabulary, produces/4 has to be assertable on
its own to state the termination property below, and
StatifierBlocks.CoreFixtures's stand-in document walk has to delegate
to the shipped rule rather than keep a private copy of it. A host that
calls only check/5, valid_targets/4, validate/3, inbound_type/4
and assignable?/3 sees exactly ADR-0003's contract; the five extra
functions widen what is callable, never what is decided.
Why produces/4 terminates
{:passthrough, slot} resolves recursively - to the last block in
slot, or, when slot is empty, to the resolving block's own inbound
type. Every such step moves to a strictly earlier position in the
document's pre-order than the position that asked: descending into a
slot still lands before the position that asked (everything under a
block is ordered before that block's own next sibling), and falling back
to a block's own inbound type moves to its previous sibling or its
parent, both earlier than the block itself. Pre-order rank over a finite
tree has no infinite descending chain, so resolution cannot cycle even
over a document built entirely of empty passthrough sequences. See
produces/4 for the full argument.
Defaults, per ADR-0003 decision 5: an absent io/1 callback, or a module
that is not loadable, is %{}; an absent :kinds key is [:step]; an
absent :slot_accepts entry for a given slot is :any.
Summary
Types
Caller-supplied, not stored in the document (ADR-0003 decision 4).
The return shape of StatifierBlocks.BlockType.io/1. Every key optional.
Structural tag. :step and :interrupt_handler ship here; hosts may mint more.
What a block produces to its next sibling. See ADR-0003 decision 4.
A position, as ADR-0001 decision 5 defines one.
Opaque to this package. Never parsed, split, or normalized.
Functions
ADR-0003 decision 3's structural verdict for placing child in slot of
parent: :any admits everything, otherwise the slot's accepted kinds
and the child's own kinds must intersect.
ADR-0003 decision 6's ordered relation. Four steps, checked in this order, and the order is the contract
ADR-0003 decision 7's one decision function: kind admission for
candidate at target, plus the seams whose verdict a placement at
target can change.
The inbound type at target (ADR-0003 decision 4), computed by walking -
never stored
module.io(config), or %{} when io/1 is absent or module is not
loadable (ADR-0003 decision 5). Checked with Code.ensure_loaded?/1 plus
function_exported?/3, the pattern StatifierBlocks.Palette.resolve/2
already uses.
The block's kinds, defaulting to [:step] (ADR-0003 decision 5).
block's produces, resolved (ADR-0003 decision 4): a type expression
resolves to itself; :unknown resolves to :unknown;
{:passthrough, slot} resolves to the resolved produces of the last
block in slot, or, when slot is empty (or is not a slot block
declares - decision 4 is silent on that, and treating it as empty is the
permissive, total reading decision 5 already establishes elsewhere),
block's own inbound type.
The accepted kinds for slot on module, defaulting to :any when the
slot has no entry in io/1's :slot_accepts map (ADR-0003 decision 5).
Every position in document where candidate may be dropped, per
ADR-0003's valid_targets/4: for every block in Document.blocks/1, for
every slot that block's module declares (module.slots(config), not
the slot keys the stored document happens to carry), for every index from
0 to that slot's current child count inclusive, the position is kept
when check/5 returns :ok for it.
Every finding in document, per ADR-0003's validate/3: every seam
already present in the document, checked with the same rules check/5
checks a seam with - the whole-document counterpart to check/5's
single-position query.
Types
@type context() :: %{optional(:entry_type) => type_expr() | :unknown}
Caller-supplied, not stored in the document (ADR-0003 decision 4).
@type finding() :: {:kind_not_admitted, StatifierBlocks.Block.id(), StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), [kind()], [kind()] | :any} | {:type_mismatch, StatifierBlocks.Block.id(), StatifierBlocks.Block.id() | :slot_entry, type_expr() | :unknown, type_expr() | :unknown}
@type io() :: %{ optional(:kinds) => [kind()], optional(:consumes) => type_expr() | :unknown, optional(:produces) => produces(), optional(:slot_accepts) => %{ optional(StatifierBlocks.Block.slot_name()) => [kind()] | :any } }
The return shape of StatifierBlocks.BlockType.io/1. Every key optional.
@type kind() :: atom()
Structural tag. :step and :interrupt_handler ship here; hosts may mint more.
@type produces() :: type_expr() | :unknown | {:passthrough, StatifierBlocks.Block.slot_name()}
What a block produces to its next sibling. See ADR-0003 decision 4.
@type target() :: {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), non_neg_integer()}
A position, as ADR-0001 decision 5 defines one.
@type type_expr() :: String.t()
Opaque to this package. Never parsed, split, or normalized.
Functions
@spec admits?( {module(), StatifierBlocks.Block.config()}, StatifierBlocks.Block.slot_name(), {module(), StatifierBlocks.Block.config()} ) :: boolean()
ADR-0003 decision 3's structural verdict for placing child in slot of
parent: :any admits everything, otherwise the slot's accepted kinds
and the child's own kinds must intersect.
@spec assignable?( StatifierBlocks.Palette.t(), type_expr() | :unknown, type_expr() | :unknown ) :: boolean()
ADR-0003 decision 6's ordered relation. Four steps, checked in this order, and the order is the contract:
- either side
:unknown-> assignable (decision 5's permissive default); produced == consumed-> assignable (decision 1's identity relation);palette.assignabilityisnil-> not assignable (the floor a host cannot lower);- otherwise
module.assignable?(produced, consumed).
Reflexivity holds regardless of the host, because step 2 short-circuits
before step 4 is ever reached - the host callback is consulted only after
identity has already failed, so it can only widen the relation, never
narrow it. Step 4 is guarded the same way every other optional seam in
this package is: a module that is not loadable, or does not export
assignable?/2, yields false rather than raising, so a misconfigured
palette degrades to the floor instead of turning a validation pass into
an exception.
@spec check( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), target(), StatifierBlocks.Block.t(), context() ) :: :ok | {:error, [finding()]}
ADR-0003 decision 7's one decision function: kind admission for
candidate at target, plus the seams whose verdict a placement at
target can change.
candidate not yet in document (Document.fetch_path/2 returns
:error) is an insert: two seams, inbound_type(target) against
candidate's consumes, and candidate's resolved produces against
the consumes of whatever block currently sits at target's index
(nothing to check when the slot ends there).
candidate already in document is a move: the same two seams, plus
the seam it vacates - at its current position {p, s, i}, removing it
makes i - 1 and i + 1 adjacent, so the resolved produces of the
block at i - 1 (or the slot's own inbound type when i == 0) is
checked against the consumes of the block at i + 1 (nothing to check
when the slot has no i + 1).
Findings are decision 8's vocabulary verbatim, in this order when more
than one applies: kind admission first, then the insertion seams, then
the vacated seam. :ok when the list is empty.
Degradation, per decision 5: a block that fails Palette.resolve/2 - the
candidate, the parent, or any block on a seam - contributes the
permissive default rather than a finding, the same way io/2 already
degrades a module that is not loadable.
@spec inbound_type( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), target(), context() ) :: type_expr() | :unknown
The inbound type at target (ADR-0003 decision 4), computed by walking -
never stored:
index > 0-> the resolvedproducesof the sibling atindex - 1;index == 0-> the parent block's own inbound type, found byDocument.fetch_path/2on the parent's id and recursing on its last path step;- the root (
fetch_pathreturns{:ok, []}) ->ctx[:entry_type], defaulting to:unknown.
Total: a parent_id no block in document carries, or an index past
the end of the slot's children, resolves to :unknown rather than
raising, consistent with decision 5's permissive default. See
produces/4 for the termination argument this function's own recursion
(through own_inbound_type/4 and back) relies on - every step here moves
to a strictly earlier pre-order position too: a previous sibling or a
parent, both earlier than target itself.
@spec io(module(), StatifierBlocks.Block.config()) :: io()
module.io(config), or %{} when io/1 is absent or module is not
loadable (ADR-0003 decision 5). Checked with Code.ensure_loaded?/1 plus
function_exported?/3, the pattern StatifierBlocks.Palette.resolve/2
already uses.
@spec kinds(module(), StatifierBlocks.Block.config()) :: [kind()]
The block's kinds, defaulting to [:step] (ADR-0003 decision 5).
@spec produces( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), StatifierBlocks.Block.t(), context() ) :: type_expr() | :unknown
block's produces, resolved (ADR-0003 decision 4): a type expression
resolves to itself; :unknown resolves to :unknown;
{:passthrough, slot} resolves to the resolved produces of the last
block in slot, or, when slot is empty (or is not a slot block
declares - decision 4 is silent on that, and treating it as empty is the
permissive, total reading decision 5 already establishes elsewhere),
block's own inbound type.
A block that fails Palette.resolve/2 contributes :unknown rather than
a finding (ADR-0003 decision 5's degradation rule; unresolvability is
ADR-0002 decision 3's finding, reported by the walk that owns it).
Why this terminates
Every recursive step lands at a strictly earlier position, in the
document's pre-order, than the position that made the original call.
Resolving {:passthrough, slot} on block B either descends to the last
child of B's own slot - later than B, but still strictly before
whatever position asked for B's produces in the first place, since
that position comes after B and every descendant of B is ordered
before B's next sibling - or, when the slot is empty, falls back to
B's own inbound type, which is either B's previous sibling or, at
index 0, B's parent - both strictly earlier than B. Pre-order rank
over a finite tree is a well-founded measure with no infinite descending
chain, so this cannot cycle even for a tree of nothing but empty
passthrough sequences.
@spec slot_accepts( module(), StatifierBlocks.Block.config(), StatifierBlocks.Block.slot_name() ) :: [kind()] | :any
The accepted kinds for slot on module, defaulting to :any when the
slot has no entry in io/1's :slot_accepts map (ADR-0003 decision 5).
@spec valid_targets( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), StatifierBlocks.Block.t(), context() ) :: [target()]
Every position in document where candidate may be dropped, per
ADR-0003's valid_targets/4: for every block in Document.blocks/1, for
every slot that block's module declares (module.slots(config), not
the slot keys the stored document happens to carry), for every index from
0 to that slot's current child count inclusive, the position is kept
when check/5 returns :ok for it.
Deterministic: Document.blocks/1 is already pre-order, slots/1 is
visited in the order it declares slots, and indices ascend within each
slot - so the same call always returns the same list, in the same order.
A block whose type fails Palette.resolve/2 contributes no positions:
there is no module to ask for a declared slot set, so this is an absence
of positions rather than a refusal. Offering a target inside a slot that
is not even declared would be offering a position the document walk that
owns undeclared-slot findings (ADR-0002 decision 6) then rejects.
@spec validate(StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), context()) :: :ok | {:error, [finding()]}
Every finding in document, per ADR-0003's validate/3: every seam
already present in the document, checked with the same rules check/5
checks a seam with - the whole-document counterpart to check/5's
single-position query.
A document's seams are exactly its blocks' own upstream seams: the slot's
own inbound (or the previous sibling's resolved produces, when there is
one) against the block's consumes, plus the same kind-admission check
check/5 runs for a candidate at a target. Walking every block in
Document.blocks/1 other than the root (which occupies no slot and has
no seam of its own) and checking each one this way visits every seam in
the document exactly once - a block never shares its upstream seam with
any other block, and nothing in a slot has a seam after its last child
that some other block's own upstream check does not already cover.
This is deliberately not check/5 called with a block already
sitting at its own current position as the candidate: check/5's
downstream and vacated seams are defined relative to a target the
candidate is being placed at or removed from, and a block that already
occupies target makes both of those seams check the block against
itself, or against neighbors as though the block were not there at all -
neither is a seam that exists in document as given. validate/3
reaches the same two checks check/5 runs for kind admission and the
upstream seam - kind_admission_finding/5 and upstream_seam_finding/5
- directly, so the decision stays the one
check/5is built from without running its candidate-already-there degenerate case.
:ok when the document has no findings; otherwise {:error, findings}
with every block's findings concatenated, in Document.blocks/1's
pre-order.