ADR-0028: Executable-content blocks thread one context and bind/3 each write
Copy Markdown View SourceStatus: accepted (2026-08-14)
Context
Statifier.Evaluator.context/1 (lib/statifier/evaluator.ex:108-113) is the
sole constructor of a %Predicator.Context{} over live machine data. Three
sites rebuild it during a macrostep rather than reusing the context already
built for the block they run inside: <assign> (once per assign, after the
write, lib/statifier/machine/content/assign.ex:76-91), <script> (once in
rebind/2 plus a second build inside Evaluator.execute/2,
lib/statifier/machine/content/script.ex:95-103), and <foreach> (once
before the loop plus once per iteration, lib/statifier/machine/content/ foreach.ex:276-285).
docs/plans/260814-st-sdh-context-rebuild-vs-bind-benchmark.md built the
benchmark this decision needed and pre-registered the rule it would be
judged against before any number existed:
- Branch A - rebuilding is fine.
S_time < 5%andS_mem < 5%at realistic scale, and the<foreach>stress case stays under 5% on both axes for N <= 100 iterations.- Branch B - the change is justified.
S_time >= 5%orS_mem >= 5%at realistic scale, or the<foreach>case crosses 5% on either axis at N <= 100.- Modifier C, independent of A/B. If
T_fixedis >= 50% ofT_fullat the realistic datamodel size, the fixed per-call provider re-validation dominates and the root fix is upstream memoization in predicator, not statifier glue. Phase 3 then also files a predicator-ex bead and cross-links it per ADR-0025'smirrors:protocol. This fires or does not fire on its own evidence; it does not change which of A or B is taken.
bench/results/260814-context-build.md (Phase 1) measured the three-term
decomposition and the block-level A/B at a realistic corpus-shaped datamodel
(:corpus: 5 roots, matching the observed maximum across
test/scxml_tests/ and test/scion_tests/) and at three stress points. At
:corpus: T_full (Evaluator.context/1) = 2.28 us / 10.92 KB, T_fixed
(Context.new/2 over empty data, isolating resolve_functions/1) = 1.29 us
/ 5.51 KB. T_fixed / T_full = 56.6%, which is >= 50% - Modifier C
fires, independently of the branch decision below.
bench/results/260814-macrostep.md (Phase 2) drove real documents through
Statifier.compile/1, Statifier.initialize/2, and Statifier.send_event/2
and derived the share of one macrostep's cost spent building contexts, at
realistic scale and across a <foreach> sweep:
- Realistic (the point that decides the bead):
S_time= 62.04%,S_mem= 67.10%. <foreach>, N in[1, 10, 100, 1000]:S_timeranges 63.89%-80.17%,S_memranges 71.73%-93.47%; every point up to and including N=100 (the rule's own bound) is far over 5% on both axes, starting at N=1.
Both figures are more than twelve times the rule's 5% threshold, and the
<foreach> curve clears 5% on both axes at N=1, well under the N<=100 bar.
Branch A's requirements (S_time < 5% and S_mem < 5% at realistic scale,
and the <foreach> case staying under 5% for N<=100) fail on every count;
Branch B's disjuncts (S_time >= 5% or S_mem >= 5%, or the <foreach>
case crossing 5% at N<=100) are each independently satisfied by a wide
margin. There is no tie: the rule's "ties and ambiguity resolve to Branch A"
fallback does not apply, because nothing here is close to the 5% line.
Decision
Branch B is selected. Executable-content blocks thread one context and
bind/3 each write.
Within one executable-content block - the interval Interpreter.Content. execute_block/3 (lib/statifier/interpreter/content.ex:140-162) already
builds once - <assign>, <script>, and <foreach> bind each write into
the block's existing threaded context with Predicator.Context.bind/3
rather than calling Evaluator.context/1 again. This is within-block
threading only. It is explicitly not a stored context on
Statifier.MachineState: nothing changes about what a %MachineState{}
holds, and nothing widens the interval a context stays valid across.
Widening it across blocks - the shape a MachineState field would need -
is out of scope here, same as the plan's D3 scoped it, and is a future
decision, not this one.
Modifier C also fired (Context, above): Phase 3 filed and cross-linked a
predicator-ex bead for memoizing resolve_functions/1's per-call
Code.ensure_loaded?/1 and function_exported?/3 validation
(deps/predicator/lib/predicator/context.ex:158-207) - px-rnc, carrying
mirrors: st-sdh as its description's first line; st-sdh's own
description now carries mirrors: px-rnc. This fires independently of the
branch decision above and does not change it; predicator owns whatever
shape the memoization takes, per ADR-0025's rule 1.
Consequences
docs/datamodel.md's "once per evaluation site" commitment (docs/datamodel.md:54-59) is unchanged. Within-block threading is still one context per evaluation site - the site is the whole block, not the individual write inside it, and that was already the boundary the commitment names.bind/3changes how a write inside that interval updates the context, not how long the interval lasts.- ADR-0012 constraint 1 (
docs/observability.md:26-49, a resumable%MachineState{}) is untouched, because nothing is stored onMachineState. The context a block threads lives on the stack of the block's own execution, the same as it does today; only the three rebuild sites inside that stack change from a freshEvaluator.context/1call to abind/3on the context already in hand. - The next phase (Phase 4 of the plan) implements the change: a public
Evaluator.bind/3helper, and<assign>/<foreach>(and<script>if it proves safe) replaced to bind rather than rebuild. Phase 4 is executed because this record names Branch B; had it named Branch A, Phase 4 would not run. - Phase 4 shipped, and
<script>threading proved safe: it did not go the rebuild-fallback route this record left open.Evaluator.run_program/2(a new function alongside the unchangedexecute/2) returns the post-runPredicator.Context.t()Predicator.execute/3already builds, andStatifier.Machine.Content.Scriptthreads it straight onto the block'sdatamodel_contextinstead of callingcontext/1again -execute/2itself, and its one other caller (Statifier.Interpreter.run_global_script/3, Appendix D's global-script step, untouched by this phase), keep their original two/three-element shape.mix test --include scion --include scxml_w3passed at exactly the same counts before and after (1661 tests, 107 failures, both runs;mix test.regressionat 1522/0 both times), so the threaded context is observationally identical to a rebuilt one across the whole corpus. One difference was found outside the corpus, by direct construction: a script assigning the null literal (x = null;) and readingxback later in the same block now seesnil, where a fresh rebuild would have round-tripped thatnilthroughundefine_nils/1a second time and flipped it to:undefined- the exact gapStatifier.Evaluator's ownundefine_nils/1note already names as latent and not something any corpus document exercises. Threading fixes that gap for<script>writes as a side effect rather than introducing a new one; it is recorded here because it is the one place this phase's behavior is not bit-for-bit identical to the rebuild it replaced. bench/results/260814-macrostep.md's "Phase 4 verification" section has the before/after numbers: every corpus-reachable document is faster and lighter, from ~1.2x atassign-heavy n=1to ~64x atforeach n=1000, consistent with an O(1)-in-datamodel-sizebind/3replacing an O(datamodel size) rebuild at each write.cond-bearing selection is unchanged within noise, as expected - Branch B is scoped to executable content and the selection round still callsEvaluator.context/1directly.- Widening the threaded interval across blocks - so a context could
survive a whole macrostep or live on
MachineState- remains future work, gated on the same two groundsStatifier.Evaluator's moduledoc already states under "Why the built context is not aMachineStatefield": the closure-in-functionsnon-resumability ground (dissolvable by thepx-8iiprovider seam, already landed upstream but not taken here) and the staleness ground (structural, survives any provider seam, becauseIn/1still answers against whatever configuration it captured at build time). Neither ground is contradicted by within-block threading, because a block never outlives the microstep it runs inside. - Two things would reopen this decision rather than being folded in
silently: a datamodel materially larger than the realistic
:corpuspoint this record measured, orcondon transitions becoming reachable from the corpus (todayFeatureDetectormarksconditional_transitions:unsupported,lib/statifier/interpreter/selection.ex:277-280), which would put a context build on every selection round rather than only inside executable content.bench/is the way to re-decide either question, not a re-argument of this record.