ADR 0118: Expression-Level State Threading via ThreadedIr Preludes

Status

Implemented (2026-09-05)

Implementation Tracking

Epic: BT-3413 Status: Done

PhaseIssueDescriptionSizePR
0BT-3414Self-send position regression matrix + threading-predicate conformance testM#3712
1aBT-3415ThreadedValue, close(), StateEffectEscapesExpression; self-dispatch producer; Actor body consumer; sequencing rule for sends and binary operandsM#3717
1bBT-3416Sequencing rule for literals, interpolation, return, assignment, cascade, match: scrutineeM#3718
2aBT-3417Conditional arms, exception arms, stateful-block bodies, conditional receiver consume ThreadedValueM#3719
2bBT-3418Loop-body consumers; delete planner, HoistSink, registries, BT-3399 warningM#3720
3BT-3419ConditionalLoop condition as IRM#3723
4BT-3420Inline-threaded control flow as producer; ifNil:ifNotNil:, match: arms, ifNone:, generic Tier 2 blocksM#3722
5aBT-3421ClassVars producers via ThreadedValue, open-scope shimmedM#3730
5bBT-3422Delete the open-scope protocol and helpersM#3733
6BT-3423One state_effects fact, one selector table, gates collapsedM#3734
7BT-3424Close-out: gates removed, verify-threaded-ir, docs, ADR 0111 addendum, measurement, REPL e2eSthis PR

Dependency order: 3414 → 3415 → 3416 → 3417 → 3418 → {3419, 3420, 3421} → 3422 → 3423 → 3424.

Follow-up filed separately, not part of this epic's close-out: BT-3430 — wiring ThreadedValue::close's StateEffectEscapesExpression into check_no_unsafe_class_method_self_sends's user-facing diagnostic (phase 5b's own acceptance criteria named this and it was deliberately not completed in BT-3422).

Final measurement (BT-3424 close-out), whole epic against the pre-epic baseline — same methodology ADR 0111 Addendum 7/10 established (two separate release binaries, beamtalk build-stdlib over the real stdlib corpus, cold ebin/ each run, mean of 5 runs per side): baseline commit ff099d4ff (the last commit before this epic's phase 0, BT-3414, landed) vs. this issue's HEAD (every phase 0-7 landed):

wall-clock (s)user CPU (s)
baseline (mean of 5, ff099d4ff)6.30 (range 5.84–7.95)10.47 (range 10.16–10.67)
this epic (mean of 5, HEAD)6.46 (range 5.92–7.71)10.72 (range 10.49–11.22)
Δ+2.5%+2.4%

Inside the ≤3% gate. Consistent with every per-phase measurement across this epic (each phase's own PR measured its own incremental delta and stayed inside the gate): ThreadedValue costs one small heap-allocated Vec<ThreadedStmt> per state-effecting expression, in place of the deleted hoist-registry/open-scope-protocol bookkeeping those expressions used to cost anyway — not a new class of work, a reshaped one.

Read on user CPU per ADR 0111's own established precedent (wall-clock is noisy on this shared/virtualized environment; user CPU is the more stable signal — see ADR 0111 Addendum 3/7).

Context

Problem statement

ADR 0111 gave the state-threading subset of codegen a small lowered IR (ThreadedIr) with a verifier, and the BT-3141/BT-3155 epics made that IR the real emission input for every statement-level construct family: loops, conditionals, list-ops, exception handling, Actor and class-method bodies, and Tier 2 stateful blocks. That part of the design has held: none of the ten self-send fixes landed between 2026-08-30 and 2026-09-03 (BT-3374, BT-3382, BT-3392, BT-3396, BT-3399, BT-3402, BT-3403, BT-3405, BT-3406, BT-3385) touched threaded_ir.rs, and the verifier caught the BT-3374 shape before it shipped.

What has not held is the boundary ADR 0111 deliberately drew around it: "general expression codegen stays AST-directed." An actor self-send (self bumpCount) is a state-effecting expression. When it sits in statement position, the statement-lowering site threads its NewState through a real ThreadedStmt::Bind. When it sits anywhere inside an expression, it reaches generate_self_dispatch (dispatch_codegen.rs), whose contract is to return the reply value and discard the new state. Every one of the ten recent fixes is the same patch applied to a new syntactic position: a hoisting step that runs ahead of the statement, dispatches the nested self-send into a temp, and substitutes the temp by source span when the ordinary expression compile later reaches the same node.

That patch has now grown into a parallel, unverified sub-system:

Current state, as measured

A 47-shape probe (an Actor fixture with self bumpCount nested in one syntactic position per method, run as BUnit under the debug build on 2026-09-03) gives the honest picture. Everything the ten PRs targeted passes. The rest:

ShapeOutcome today
[i := i + 1. (self bumpCount) > 0 and: [i < 3]] whileTrue: [nil]Verifier panic UnboundVersion
[i := i + 1. (self flagTrue) and: [i < 3]] whileTrue: [nil]Verifier panic UnboundVersion
items do: [:x | x > 0 ifTrue: [(self flagTrue) and: [true]] ifFalse: [nil]]Verifier panic NonLinearVersion
[i := i + 1. (self bumpCount) + i < 5] whileTrue: [nil]Runtime crash (block arity)
[i := i + 1. (i < 3) and: [(self bumpCount) > 0]] whileTrue: [nil]Runtime crash (block arity)
self record: (flag ifTrue: [self bumpCount] ifFalse: [0])Runtime crash: {Result, State} tuple leaked into the argument
items at: (flag ifTrue: [self bumpCount] ifFalse: [1])Runtime crash: tuple leaked
items detect: [:x | x > 100] ifNone: [self bumpCount]Runtime crash: invalid argument
self record: 1; record: 2 (cascade on self)Silent drop, no warning
self record: (self bumpCount); record: 1Silent drop, no warning
v ifNil: [0] ifNotNil: [:x | x + (self bumpCount)]Silent drop, no warning
v match: [1 -> 1 + (self bumpCount); _ -> 0] and (self bumpCount) match: […]Silent drop, no warning
items sort: [:a :b | (a + (self bumpCount)) < b]Silent drop, no warning
items do: [:x | self.count := self.count + (self bumpCount)]Silent drop, no warning
items do: [:x | y := 1 + (self bumpCount)]Silent drop, BT-3399 warning
total := items size + (self bumpCount)Silent drop, BT-3399 warning
"{items size}-{self bumpCount}"Silent drop, BT-3399 warning

The cascade row is the one that should worry us most: self a; b is ordinary Smalltalk style, generate_cascade (expressions.rs) routes every message through the generic runtime send, and no actor fixture in the repository uses a cascade on self, so nothing would have caught it.

None of these shapes is exotic. Each is "a state-effecting expression in a position the planner was never wired for." The verifier panics are the good news (ADR 0111 doing its job); the silent drops are the bad news, and they are silent precisely because they happen on the side of the boundary the verifier cannot see.

Constraints

Decision

Every expression compiled in a state-threading context returns a value plus a prelude of ThreadedStmts, and every consumer must splice the prelude before using the value. State effects inside expressions stop being a special case handled by a hoisting pre-pass and become the ordinary shape of expression codegen, in A-normal form, verified by the existing ThreadedIr::verify() because the prelude is IR.

Concretely:

1. ThreadedValue: the expression-level result

/// The result of compiling one expression in a state-threading context.
/// `prelude` runs first, in source evaluation order, and may advance any
/// versioned prefix (`State`, `ClassVars`, `Self`); `value` is then a pure
/// reference to the expression's result.
#[must_use = "a ThreadedValue's prelude carries state Binds; splice it or close it"]
pub(super) struct ThreadedValue {
    pub prelude: Vec<ThreadedStmt>,
    pub value: ValueRef,
}

ThreadedValue replaces, in one type, both the State hoist registries (hoisted_self_send_results, hoisted_field_reads) and the ClassVars open-scope protocol (last_open_scope_result / OpenScopeResult). A pure expression is ThreadedValue { prelude: vec![], value }; the common case costs nothing.

2. Producers

The state-effecting expression forms produce non-empty preludes:

3. Sequencing rule (evaluation order by construction)

When generate_expression compiles a node with sub-expressions (MessageSend receiver and arguments, binary operands, Cascade messages, literal elements, StringInterpolation segments, Return and assignment values, match: scrutinee), it compiles the children in evaluation order and applies one rule:

If any child at position k has a non-empty prelude, every child at a position < k whose value is not a literal or plain variable is bound to a fresh temp (Statement(let TmpN = value in)) in the parent's prelude before child k's prelude is appended.

This is the "decide once, hoist all or none" rule BT-3406's hoist_subexpr_splits already applies to cascade arguments, made universal. It makes HoistAction::Dropped unrepresentable: (items at: idx) + (self bump) compiles to let Tmp1 = items at: idx in let SD = … in let State2 = element(2, SD) in Tmp1 + element(1, SD), which raises from at: first and threads bump's state. The self.field-read snapshot special case (hoisted_field_reads) is the same rule applied to a FieldAccess child, so it needs no separate machinery.

Because children are compiled in order and each producer advances the version counter as it emits its Bind, the version chain in a prelude is linear and the verifier's UnboundVersion/NonLinearVersion checks apply to it unchanged.

4. Consumers

Every statement-lowering site that today calls hoist_nested_self_sends or closes an open scope instead does:

let tv = self.threaded_expression(expr)?;   // ThreadedValue
stmts.extend(tv.prelude);                   // into the enclosing frame's IR
stmts.push(ThreadedStmt::Statement(render_value(tv.value), span));

The consumer set is finite and named: Actor method bodies (lower_body_exprs_with_reply), class-method bodies (lower_class_method_body), conditional branch arms (generate_conditional_branch_inline), on:do:/ensure: arms, Tier 2 stateful-block bodies (generate_block_stateful_body), loop bodies (generate_threaded_loop_body_inner, emit_non_assign_expr, generate_local_var_assignment_in_loop), and the loop condition (see §6). HoistSink and hoist_nested_self_sends are deleted once the last consumer is migrated.

5. Closing a prelude: the verifier's new obligation

A consumer that must produce a self-contained Document (a Tier 1 closure body, an Erlang FFI argument, a block passed to a class method, spec/doc codegen) calls tv.close(ctx), which renders the prelude as nested lets around the value. close() is the only way to discard a prelude, and it reports:

VerifyError::StateEffectEscapesExpression {
    prefix: VersionPrefix,   // State | ClassVars | Self
    at: Span,
}

whenever the prelude contains a Bind for a prefix the enclosing context cannot thread. In debug/CI builds that is a hard failure through the existing report_threaded_ir_verify_errors; in release builds it is an internal: diagnostic. Where today's code already emits a user-facing diagnostic for the same situation (warn_stateful_block_at_erlang_boundary, the class-method "self-send in a closure" compile errors), that diagnostic is produced from the close() result rather than from a separate predicate, so the two cannot disagree.

The #[must_use] on ThreadedValue turns "forgot to splice" from a silent drop into a compiler warning (denied in CI via clippy with warnings as errors).

6. ConditionalLoop carries its condition as IR

ThreadedStmt::ConditionalLoop today holds the loop condition as an opaque continue_header: Document. A whileTrue: condition block containing a self-send or an inline-threaded and: is the shape that panics the verifier today, because the condition is compiled outside the loop's frame. The node gains:

condition: Vec<ThreadedStmt>,   // the condition block's prelude, in the loop frame
condition_value: ValueRef,

and render_conditional_loop emits the prelude inside the loop fun before the case. The condition's Binds are then in the frame the verifier checks, and its final State version is what the loop's continue call threads.

7. One decision predicate, owned by semantic analysis

The "does this sub-tree have state effects?" question is answered once. beamtalk-core's semantic analysis already computes block_profiles per block (BT-1309). It gains a per-node state_effects: StateEffects fact ({ actor_self_send, field_write, class_var_write, class_self_send }, computed bottom-up over the whole expression tree, seeing through parentheses and into non-closure sub-expressions). Codegen's gates (control_flow_has_mutations, match_needs_mutation_threading, and_or_needs_mutation_threading, the four inlined intrinsic gates, contains_hoistable_self_send, needs_mutation_threading's self-send arm) collapse to one self.subtree_needs_threading(span) reading that fact. The four selector tables collapse to one in beamtalk-core::state_threading_selectors, with the lint's is_state_threaded_block_arg deleted in favour of it, and a conformance test that enumerates every WellKnownSelector and asserts the codegen threaded-vars map and the selector predicate agree.

What this looks like

The user-visible change is that code which silently lost state now works, and BT-3399's warning disappears because the case it warned about no longer exists.

Actor subclass: Counter
  state: count = 0
  state: log = #()

  bump =>
    self.count := self.count + 1
    self.count

  record: n =>
    self.log := self.log ++ #(n)
    n

  // Cascade on self: both mutations now land.
  twice => self record: 1; record: 2

  // Nested in an argument, after an operand that may raise: `at:` still
  // raises first, and when it doesn't, `bump`'s mutation is kept.
  pick: idx => self.log at: idx + (self bump)

  // Threaded conditional as an argument: no tuple leaks into `record:`.
  maybeRecord: flag => self record: (flag ifTrue: [self bump] ifFalse: [0])

  // Loop condition with a self-send: no verifier panic, state threads
  // through the loop's own parameter.
  drain =>
    [self bump < 3] whileTrue: [nil]
    self.count
> c := Counter spawn
> c twice. c getLog
#(1, 2)
> c maybeRecord: true. c getCount
1

Error examples

A self-send inside a block that must be a real closure still cannot thread, and now says so from the same mechanism that would otherwise thread it:

error: `self bump` inside this block cannot thread actor state
  --> counter.bt:14:31
   |
14 |     items sortBy: [:a :b | (self bump) < b]
   |                            ^^^^^^^^^ mutates `count` in a closure that runs outside this method's state
   = help: bind the value before the block: `n := self bump. items sortBy: [:a :b | n < b]`

(Whether a given selector's block is threaded or a closure is decided by the single selector table in §7, as today; the change is that the boundary is reported from close(), not from a second predicate.)

Prior Art

User Impact

Steelman Analysis

Option A: Expression-level preludes (this ADR)

Option B: Keep the planner, wire the remaining sites

Option C: Full typed Core Erlang IR (ADR 0018 Alt 3, ADR 0111 Alt 4)

Option D: Runtime state cell (process dictionary during self-dispatch)

Option E: Make nested self-sends a compile error

Tension points

Alternatives Considered

B. Keep the AST-directed planner and wire the remaining consumers

Each of the probe rows is fixable with one more hoist_nested_self_sends call site or one more is_conditional_selector entry. Rejected because the mechanism is structurally unable to reach two of the rows (BT-3399's order-unsafe operands, and any position reached through expression_doc inside a Document-only sink), because half of its output bypasses the verifier, and because the ClassVars open-scope protocol would remain a second copy of the same idea.

C. Full typed Core Erlang IR

Rejected per ADR 0018 §Alternative 3 and ADR 0111 §Alternative 4; the reasoning there still holds. This ADR's ThreadedValue is deliberately not an expression IR: value is a ValueRef (a Document or a versioned variable), and only the effectful prefix is structured.

D. Runtime state cell

Have safe_dispatch read and write the current state from the process dictionary so a nested self-send mutates "in place," and have the method prologue/epilogue load and store it. Rejected: it violates ADR 0041/0042's explicit-threading contract, breaks the {reply, R, NewState} invariant every other construct (NLR relay, ADR 0110 shadow writes, ensure: cleanup) is built on, makes the ThreadedIr verifier unable to see the effects it exists to verify, and produces generated code an Erlang developer cannot read as gen_server code.

E. Compile error for state-effecting sub-expressions

Reject any actor self-send, field write, or inline-threaded construct that is not in statement, assignment-RHS, or return position. Rejected as the primary decision: it is a language restriction motivated by a compiler limitation, and Smalltalk developers would hit it immediately (self a; b). Its honest core is adopted in §5: where a position genuinely cannot thread (a real closure, an FFI boundary), the outcome is a diagnostic derived from the verifier, never a silent drop.

Consequences

Positive

Negative

Neutral

Implementation

Phased so that each step is independently landable and leaves the build green. The regression matrix lands first so every later step flips rows from expected-fail to pass rather than adding one-off tests.

  1. Regression matrix. Turn the 47-shape probe into stdlib/test/actor_self_send_position_matrix_test.bt (+ fixture) with the currently-failing rows gated as expected-fail the way ValueTypeMutationMatrixTest gates BT-2371; add a self-send-position axis to the shared mutation_corpus_*.bt fragments so the metamorphic harness covers the positions across contexts; add the predicate conformance test that today's "must stay in sync" comment lacks.
  2. ThreadedValue and the first producer. Add the type, close(), VerifyError::StateEffectEscapesExpression; make generate_self_dispatch the first producer and the Actor method body the first consumer, with the sequencing rule in generate_expression for MessageSend, binary operands, literals, interpolation, Return, and assignment values. hoisted_self_send_results/hoisted_field_reads become dead once the Actor-body call sites stop consulting them.
  3. Remaining State consumers. Conditional arms, exception arms, Tier 2 stateful blocks, loop bodies, loop-body local/field assignment RHS, cascades on self, match: scrutinee and arms, ifNil:ifNotNil:, ifNone:. Delete the planner, HoistSink, the Dropped warning.
  4. ConditionalLoop condition as IR. The whileTrue:/whileFalse: condition prelude in the loop frame.
  5. Inline-threaded control flow as a producer. A threaded conditional / and:/or: / match: / loop in expression position returns a prelude instead of a {Result, State} tuple document; closes the tuple-leak rows.
  6. ClassVars unification. Class-method self-sends and class-var assignments produce preludes; delete last_open_scope_result, OpenScopeResult, closed_expression_doc, capture_subexpr_sequence, split_subexpr_for_preamble, hoist_subexpr_splits, bind_args_to_temps, hoist_open_scope_receiver/_argument. ShadowWriteMissing continues to see the class-var Binds because they are now in the prelude.
  7. One predicate, one table. state_effects semantic fact; collapse the codegen gates; unify the selector tables; delete the lint's copy. BT-3423 note: phases 2b/4/5b's ThreadedValue/prelude machinery already converged codegen's own "does this receiver sub-tree need threading" question onto one internal predicate (CoreErlangGenerator::conditional_receiver_needs_threading, a thin wrapper over subexpr_needs_prelude — formerly contains_hoistable_self_send) ahead of this phase, by a different mechanism than this section originally proposed (subtree_needs_threading reading state_effects directly). state_effects is added as specified for the consumers that cannot reach that codegen-internal predicate — beamtalk-core cannot depend on beamtalk-codegen (§Architecture), so the lint and any future LSP diagnostic need their own semantic-level fact — while codegen's remaining "does this literal block's body need mutation threading" duplication (four independently-inlined copies across control_flow_has_mutations, enumeration_block_needs_threading, conditional_needs_mutation_threading) collapses to one block_arg_needs_threading helper instead. The selector tables unify as planned (state_threaded_block_arg_indices, one source for get_control_flow_threaded_vars and the lint).
  8. Close-out. just verify-threaded-ir runs the matrix corpus (the bootstrap-test corpus it names today contains no actor code); docs (debugging.md verifier table, beamtalk-language-features.md's "Passing Blocks Through Class Methods"); ADR 0111 addendum pointing here; final ≤3% measurement.

Affected components: crates/beamtalk-codegen/src/core_erlang/ (all statement-lowering modules, dispatch_codegen.rs, expressions.rs, intrinsics.rs, threaded_ir.rs), crates/beamtalk-core/src/semantic_analysis/ and state_threading_selectors.rs, crates/beamtalk-lint (selector table consumer), stdlib/test/, docs/development/debugging.md.

References