StatifierBlocks.Assignability (StatifierBlocks v0.1.0)

Copy Markdown View Source

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

context()

@type context() :: %{optional(:entry_type) => type_expr() | :unknown}

Caller-supplied, not stored in the document (ADR-0003 decision 4).

finding()

@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}

io()

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

kind()

@type kind() :: atom()

Structural tag. :step and :interrupt_handler ship here; hosts may mint more.

produces()

@type produces() ::
  type_expr() | :unknown | {:passthrough, StatifierBlocks.Block.slot_name()}

What a block produces to its next sibling. See ADR-0003 decision 4.

target()

A position, as ADR-0001 decision 5 defines one.

type_expr()

@type type_expr() :: String.t()

Opaque to this package. Never parsed, split, or normalized.

Functions

admits?(arg1, slot, arg2)

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.

assignable?(palette, same, same)

@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:

  1. either side :unknown -> assignable (decision 5's permissive default);
  2. produced == consumed -> assignable (decision 1's identity relation);
  3. palette.assignability is nil -> not assignable (the floor a host cannot lower);
  4. 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.

check(palette, document, target, candidate, ctx)

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.

inbound_type(palette, document, arg, ctx)

@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 resolved produces of the sibling at index - 1;
  • index == 0 -> the parent block's own inbound type, found by Document.fetch_path/2 on the parent's id and recursing on its last path step;
  • the root (fetch_path returns {: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.

io(module, config)

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

kinds(module, config)

@spec kinds(module(), StatifierBlocks.Block.config()) :: [kind()]

The block's kinds, defaulting to [:step] (ADR-0003 decision 5).

produces(palette, document, block, ctx)

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.

slot_accepts(module, config, slot)

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

valid_targets(palette, document, candidate, ctx)

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.

validate(palette, document, ctx)

@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/5 is 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.