WIP/RFC: Unify all our various IR data structures into one by Keno · Pull Request #62334 · JuliaLang/julia

40 min read Original article ↗

and others added 5 commits

July 11, 2026 00:55
UnifiedIR is one IR data structure intended to span the compiler pipeline
and external compilers (design specification in UnifiedIR/docs/design.md):
a flat statement table with hybrid regions over a shared storage core
(AttrGraph: kind column + packed two-mode operand words + one tagged
operand pool + open attribute-column universes), layout states
(builder/dense/editable/floating) with exactly two renaming points, a
namespaced kind registry with statically reserved dialect ids for the
bootstrap stack, a generic tree porcelain (Tree/NodeList cursors,
construction, mapchildren/copy_ast, provenance walks over graph-qualified
:source chains), compact!-as-GC (compact_graph!/collect_syntax!), verifier,
printer/parser, and a reference interpreter. Zero dependencies.

Wiring: UnifiedIR joins TOP_LEVEL_PKGS (symlinked to the julia share
directory), is bootstrapped into Base immediately before JuliaSyntax
(base/Base.jl) — JuliaSyntax's SyntaxGraph runs on this substrate — and its
sources join COMPILER_FRONTEND_SRCS so sysbase rebuilds on change. The
Base-baked instance keeps its kind registry clean of the test dialect
(registration is gated to package mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One storage core: SyntaxGraph wraps UnifiedIR.AttrGraph — kind is a real
core column (the :kind attribute is a Dict-shaped view of it), children are
STMT-tagged words in the shared operand pool addressed by packed range
words, and the historical edge_ranges/edges/attributes properties are
preserved exactly (identity-stable views; is_compatible_graph semantics
kept). compact_graph! gives lowering the GC over syntax graphs it always
wanted.

One porcelain: SyntaxTree{Attrs} = UnifiedIR.Tree{SyntaxGraph{Attrs}} and
SyntaxList = UnifiedIR.NodeList — child indexing, attribute properties,
newnode/newleaf/mknode/mkleaf/mktree, copy_attrs!/mapchildren/copy_ast,
provenance walks, structural isapprox and printing are UnifiedIR generics;
JuliaSyntax keeps only the genuinely syntax-specific parts (the Kind
registry wrapper, SourceRef/source-text machinery, the leaf payload
convention, parser integration, prune/unalias/@stm).

One kind registry: the historical module-id mechanism re-plumbs into the
shared UnifiedIR registry through register_kinds! — kind modules become
dialects claiming contiguous opcode blocks (range predicates and
BEGIN_/END_ markers preserved), the bootstrap stack claims statically
reserved dialect ids (JuliaSyntax=1, JuliaLowering=2, formatter=3; core=0)
so K"..." literals stay compile-time constants, and syntax K"call" and
core K"call" are distinct kinds sharing one numbering space. Kinds
re-register in __init__ (the registry is UnifiedIR session state);
numbering is deterministic, keeping baked constants valid.

Dual-mode: bootstrapped into Base it binds Base.UnifiedIR (included just
before it); as a package it depends on the UnifiedIR package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JuliaLowering.UnifiedBackend (additive; zero changes to existing sources):
reuses the front half unchanged — macro expansion, desugaring, scope
analysis, closure conversion — and emits structured UnifiedIR region form
directly (if/loop/try region ops, cells for frame variables, sealed exit
terminators) instead of goto linear IR, with graph-qualified provenance:
every emitted statement carries a :source column entry holding the
originating syntax tree cursor, so the generic provenance walk crosses from
optimized IR statements back to surface text (highlightable diagnostics),
and collect_syntax! can GC the lowering graph against live IR.

Pure definitions; nothing calls it during bootstrap, and it bakes cleanly
in the optional sys-JL sysimage stage. Bindings resolve through the
enclosing JuliaLowering (same JuliaSyntax and hence same UnifiedIR instance
in both Base-baked and package modes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on UnifiedIR

The provider side of the substrate (package-mode ONLY; the sysimage
bootstrap never sees it): CodeInfo <-> UnifiedIR boundary converters
(cfg-wrap entry incl. exception handlers; goto/typed exits with phi
synthesis), an inference port running natively on UnifiedIR (differentially
validated at 100% equal-or-better return types against the stock pipeline
on session MethodInstances), optimizer passes (SROA, inlining via
splice_body!, ADCE, structurization, cell promotion), the Queries API, and
activation through the ordinary compiler-replacement mechanism
(Core.OptimizedGenerics.CompilerPlugins.typeinf owner hook +
with_unified_compiler), with every cached result round-tripped through a
verified UnifiedIR shadow.

Loading: Compiler.load_unified!() includes the port on demand into a
Main-rooted carrier module and binds it as Compiler.Unified. On demand
because the stdlib stub fast-path bypasses module evaluation when the
package matches the sysimage copy; Main-rooted because this baremodule
rebinds getproperty to raw getfield for its nested tree, which would break
property-forwarding types. The plugin overload pins invoke_in_world to an
end-of-module sentinel world so the shadow hooks are visible under both
pkgimage and interpreted loading. Tests in Compiler/test/unified/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UnifiedIR/test wired into the test harness (choosetests + the
  runtests_vendored project-activation pattern of the other top-level
  packages): substrate, porcelain, kind registry, provenance walk,
  compact!/collect_syntax! GC, acceptance/fuzz/splice suites.
- UnifiedIR/demo/provenance_demo.jl — the one-stack demonstration under the
  built binary: parse -> JuliaLowering front half ->
  UnifiedBackend.lower_to_ir (graph-qualified :source cursors) ->
  Compiler.Unified inference + UnifiedIR optimization -> highlight() of the
  exact surface text an optimized statement came from -> print_ir with
  per-statement source excerpts -> collect_syntax! (conservative and prune
  policies) -> the same highlight, byte-identical after AST GC.
- NEWS.md entry under compiler/runtime improvements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@Keno Keno mentioned this pull request

Jul 11, 2026

@Keno @claude

typed_ir returned the IR handle but its REPL display was the compact
one-liner, which is useless for exploration. IR now shows as the full
print_ir listing under MIME text/plain by default; truncation is opt-in,
globally via display_maxlines!(n) or per-stream via
IOContext(io, :ir_maxlines => n), with a tail note. Core.Const lattice
types render as Const(value) instead of the opaque lattice fallback.
@code_unified is the @code_typed-shaped entry point: argument values or
bare ::T annotations, optimize=false for inference-only IR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Keno and others added 20 commits

July 11, 2026 03:36
In Julia, `yield` reads as task suspension (Base.yield), and the name
should stay available for genuine suspension points in the eventual
await/continuation form (design doc §5.6). The region-value terminator is
now `result`, pairing with `region_arg`: regions take region_args and
feed their owner through result terminators. Mechanical rename across the
core dialect, printer/parser, converters, optimizer, the lowering backend,
and the design document; no semantic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add promote_arm_cells!, the if-join leg of §6 cell promotion, by store
sinking: conditional stores in sibling arms become per-arm result values
plus one unconditional post-join cell_set (extract-indexed when several
cells tuple through one if), with the incoming value materialized as a
cell_get before the if under definite assignment. Diverging arms
contribute nothing; handler-observable, maybe-undef and token cells
refuse per the §6 inviolables. In-arm reads reached by arm stores are
rewritten through the reaching store, re-read at transform time so the
rewrite is order-invariant across cells.

optimize_ir! now runs arm and loop promotion to a joint fixpoint: arm
sinking produces exactly the unconditional store shapes the dominating
and loop passes consume, so each round can expose new cases for the
others. This makes the conditional-swap shape in Base.gcd (a, b = b, a
under an if) promote fully: typed_ir(gcd, (Int,Int)) retains zero cell
ops, and the corpus optimizer wall drops ~18% (the earlier promotion
shrinks the IR for every later round).

The passes record their join placements in PROMOTION_TRACE when the
completeness harness asks for them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the fourth §6 join-point class: liveness-pruned, IDF-placed SSA
construction for frame cells over cfg-op block graphs. Phis are block
region args; incoming values ride the edge bundles of block terminators
and of sealed exits of nested islands that land on our blocks mid-block
(§5.5) — a mid-block edge exports the reaching definition at the sealed
sub-op's member position, which stays well-defined through handler-
nested exits because candidate stores are all direct block members and
cannot execute mid-try. The incoming value is a cell_get inserted
before the cfg op under definite assignment, folded by the dominating
pass next round. Entry-leading cell_new (NewvarNode form) is honored by
invalidating the incoming value.

Soundness rule found by the DF harness: when a loop body lies strictly
between the cfg op and the cell declaration, cell memory is carried
ACROSS iterations through the island's sealed continue/break exits, so
deleting island stores would leave the next iteration's incoming read
stale — such cells refuse unless the island is store-free for them.
Dissolving that class needs exit-value threading through sealed exits
(deferred; the dominant remaining stock-oracle exception).

promote_arm_cells! now also fires inside island blocks — its transform
is local to the if's region subtree, and its post-join store is exactly
what the island and block passes consume — and optimize_ir!'s promotion
fixpoint includes the island pass. Stock-oracle violations on the
Base/stdlib corpus drop from 38 bodies to 16, all in the documented
exit-threading class; corpus cell-op elimination rises from 20% to 55%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Substantiate that the §6 join vocabulary — if results, loop region args,
loop break values, island block args — projects the iterated dominance
frontier onto the region tree, three ways:

- classify_residual_cells: every cell surviving the isolated promotion
  fixpoint (promotion_fixpoint!, the five promotion passes to a joint
  fixpoint plus dead-cell dropping) carries a documented exception class
  (escape/token, throw-edge/handler, maybe-undef, island exit-threading,
  refused multi-level exit); UNCLASSIFIED is a completeness bug. The
  stock oracle demands the classes match stock's own outcome: where
  code_typed fully mem2reg's (slot-, Box-, and undef-guard-free), no
  stray residuals may remain.
- CellFuzz: a structured fuzzer emitting interpretable core-dialect
  bodies with random nested if/loop/try, sibling-arm swaps, multi-level
  exits, guarded maybe-undef reads, and handler interactions; each case
  runs a semantic differential (same values, same thrown errors) across
  the fixpoint, plus classification totality. 10k cases clean.
- df_correspondence: flatten the pre-promotion body through the exit
  converter, compute stock slot2ssa's liveness-pruned IDF per cell, and
  check the recorded promotion placements (PROMOTION_TRACE, id-translated
  round-by-round through the compaction RemapSets, region ids for island
  phis) land exactly on those blocks; statically dead code is excluded on
  both sides. Zero missing placements on the corpus and fuzz samples.

The DF leg caught two real bugs during bring-up: an order-dependent
stale-operand rewrite in the arm pass and the island backedge-staleness
unsoundness — both fixed in the pass commits.

bench/unified_completeness.jl runs all three legs at scale (600-body
corpus, 10k fuzz, seeded); test/unified/completeness.jl carries the
regression shapes: the gcd swap, nested depth 3, all-diverging-but-one,
maybe-undef and throw-edge refusals, island phi placement and the
staleness refusal, and a seeded 800-case battery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The claim, the five promotion passes and their IDF projection, the
backedge-staleness rule, the machine-checkable exception classes, and
the three-legged verification harness (classifier + stock oracle,
structured differential fuzzing, dominance-frontier correspondence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extract index was 0-based (an LLVM extractvalue habit). This is
Julia: the index is now exactly getfield's, so the extract<->getfield
mapping at every boundary (canonicalization, converters, tfunc) is the
identity instead of a +-1 that each site had to remember. Producers,
consumers, tests, and the design document flipped together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
promote_cells! refused any cell touched inside a cfg island wholesale.
The dominating case is region-local, so the refusal was stronger than
needed: dominates_for_cell only walks region nesting, meaning a store
proves dominance either from outside the island (it executes before the
whole cfg) or from the same block subtree (blocks execute their members
in order, and re-entry re-executes from the top, so the store
re-executes before the get on every iteration). Cross-block flow never
establishes region-static dominance, so nothing unsound gets through.

The one genuine island hazard is a positionally-later store reaching an
earlier get across island back edges — invisible to shares_loop, which
only knows structured loop bodies. The blanket refusal was load-bearing
for exactly that case; it is now guarded precisely by shares_island in
the backedge-reach test.

This lets single-arm store/read clusters inside island blocks promote,
composing with the arm and island passes. The interpreter's
undefined-cell-read diagnostic now names the reading statement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one deferred capability behind every remaining stock-oracle
violation: a loop whose backedge originates inside a cfg island (the
escape_string shape — body collapsed to a goto island, continues
carrying no values) kept its cells in memory because deleting the
island stores would leave the next iteration's incoming read stale.

Island side (promote_island_cells!):
- When the island-entry value is OBSERVED (a symbolic resolution over
  reads, live phi edges, and the entry phi decides), the value THREADS:
  the loop grows a carried arg whose init is the store reaching the
  loop, every continue targeting it appends the reaching definition at
  its exit point (the pass's own per-block dataflow, including
  mid-block sealed exits and exits through handlers), and island reads
  take the arg. An UNOBSERVED entry value needs no threading at all.
- Post-loop reads keep memory, fed by ONE unconditional store of the
  loop's exit value (the exit result is the carried tuple; the loop's
  result must be otherwise unconsumed, and breaks refuse).
- cell_new generalizes from entry-leading to per-block KILL events in
  the dataflow; outside cell_news in the declaration pattern are
  accepted.
- The island entry block counts its implicit in-edge as a join: with a
  single internal backedge the entry phi was previously missed, reads
  collapsed to the pre-island constant, and the island spun forever.

Loop side (promote_loop_cells!), the compositional half:
- Loops that already carry values compose: new args/inits/continue
  values append after the existing ones; the exit tuple indexes past
  slots an earlier promotion claimed (breaks pad with nothing); a loop
  whose result some user consumes only takes pure carried promotion.
- Post-loop reads that cannot anchor for direct exit threading — and
  post-loop STORES, previously a wholesale refusal — SINK one
  unconditional store of the exit value right after the loop, keeping
  pre-loop stores for the paths that skip it. Each fixpoint round then
  consumes the sunk store at the enclosing level (countlines' inner
  levels resolve this way).
- The carried-arg INIT gets a backedge-hazard rule: any other store
  sharing an enclosing loop with L reaches L across that backedge, so
  the region-static init is stale from iteration 2 unless it re-executes
  inside the INNERMOST loop that store shares with L. Pregets use the
  same per-store innermost rule (a dominating store inside the shared
  loop shadows the carried-back value). Doubly-carried cells (the inner
  init would need the outer carried value) refuse, classified
  :refused_multilevel_exit, and execute correctly through memory.

Soundness rules the extended fuzzer differential forced, all with
seeded regressions:
- store sinking refuses when the rewritten region sits in a try body
  while the cell has uses outside it (§6 throw-edge: a swallowed
  exception exposes mid-try memory to post-try readers) — applied
  uniformly to the arm, loop, and island passes;
- a JOINING arm can leak mid-arm through a nested sealed continue/break
  whose target lies outside the arm: its stores stay in memory (the
  post-join store merely re-stores the same value on the join path);
  exits to loops inside the arm and frame-ending returns do not leak;
- _reach_ed's diverge-cut (a diverging terminator on the store's side
  cuts fall-through) applies only to the loop pass's per-iteration
  queries, where the carried arg covers backedge flow — memory-semantics
  callers keep seeing diverged stores.

Stock-oracle violations drop from 16 bodies to 2, each an analyzed
boundary: code_lowered (definite assignment provable only
path-sensitively — stock proves it during inference constprop, and §6's
maybe-undef rule is inviolable) and countlines (one doubly-carried
cell). escape_string's kwbody reaches ZERO residual cells; corpus cell
elimination rises from 55% to 70%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CellFuzz grows genisland!: cfg islands of 2-3 blocks with cell traffic,
forward br_if/goto edges, bounded internal backedges, result joins,
structured ifs/loops inside blocks (islands nest through them), and —
under an enclosing loop — sealed continue exits straight out of a block
(the escape_string shape). Termination stays guaranteed: private
counters gate every backedge, and backedge blocks always keep a forward
false edge.

The differential immediately earned its keep, catching four real
promotion miscompiles (the missed entry phi on single-backedge islands,
the stale carried-arg init on doubly-carried cells, store sinking
across swallowed-exception boundaries, and joining arms leaking through
nested sealed exits) — fixed in the pass commit, pinned here as seeded
regressions. Interpreter diagnostics carry statement ids now, so
outcome() strips them before differential comparison.

completeness.jl adds the threading testset (the escape_string textual
shape, the single-internal-backedge entry-phi regression, the
doubly-carried refusal) and the seeded miscompile-regression battery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move exit-value threading from deferred to implemented: the threading
rules, the init backedge hazard, the unobserved-entry shortcut, loop
store sinking, and the narrowed exception rows (:island = path-sensitive
definite assignment; :refused_multilevel_exit = doubly-carried cells).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
promote_cells!'s backedge-reach hazards (a later store reaching an
earlier get across a loop backedge or island re-entry) learn the
innermost-shadowing rule: the hazard is void only when the get's
reaching store re-executes before the get on every such re-entry —
inside the INNERMOST loop, and under the INNERMOST cfg island, that the
interfering store shares with the get. Matching an outer island was not
enough: an island counter initialized in an outer island's block and
carried around an inner island's backedge was constant-folded to its
initializer, spinning the inner island forever (caught by the fuzzer
differential as a live non-termination, seed 24242 case 153).

fold_constant_branches! only inlines JOIN (result-terminated) arms:
splicing a diverging arm mid-region leaves two terminators, and
truncating the tail behind it silences the verifier exactly when a fold
acted on unstable mid-round inference.

The interpreter gains an optional execution budget (_FUEL): harnesses
set it per run so a non-terminating miscompile becomes a diagnosable
failure naming the statement instead of a hung batch. Undefined-cell
reads throw UndefVarError(:cell) — the same error the
definedness-as-data guards throw — so differentials compare thrown
errors exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
structurize!'s structural rebuilds move statements wholesale, and the
moved statements' order keys could end up inconsistent with the
(correct) edit lists: comes_before then lied about pre-loop stores vs
in-loop reads, silently corrupting every order-based analysis
downstream. On real bodies this manifested as bogus maybe-undef
verdicts, unbounded definedness re-splitting (67 carried args on a
2-arg loop), premature constant folds of definedness guards, and a
miscompiled s_mypow. One relabel_okeys! from the edit lists at the end
of the pass restores the invariant.

This was a latent landmine for every editable-order consumer since
structurize! existed; the promotion machinery merely made it visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d args

Three mechanisms close the remaining ordinary-slot residual classes;
the 600-body stock-oracle violation list is EMPTY.

promote_undef_cells! (definedness as data): maybe-undef cells rewrite
into a definitely-assigned value cell (dummy-initialized at the
declaration) plus a parallel Bool definedness cell — stores set it,
cell_new clears it, cell_isdefined reads it, and undominated reads
acquire a joining guard (if !def; throw(UndefVarError); end). Both
cells dissolve through the ordinary join machinery, placing the Bool
joins on exactly the classical dominance-frontier points; guards
constant-fold wherever definedness is provable. Kill-aware (with
non-declaration cell_news every read is guarded) and idempotent (cells
split only when the declaration-point initializer will dominate every
rewritten use — editable order, not dense ids).

Island totality: non-dominating outside uses BEFORE a threaded loop are
plain memory traffic (conditional pre-loop stores just poison the
carried init); outside declaration-pattern cell_news accepted;
store→site dominance may cross guard regions (a guard evaluates
entirely after the store whenever the site runs at all).

Nested carried args (MEMORY-INIT): a carried init that is stale across
an enclosing backedge becomes a cell_get immediately before the loop,
with the exit store keeping that memory current — the enclosing loop's
next fixpoint round consumes the get/store pair as ordinary body
traffic, so doubly-carried cells (countlines) dissolve one nesting
level per round. Refused when a multi-level exit inside the body can
skip the exit store (caught as a live miscompile by the differential).
Loops whose result some user already consumes rebase scalar consumers
through extract(L, 1) before the exit tuple grows.

optimize_ir! runs the promotion fixpoint once more after the round
budget (late-inlined callee cells were arriving after the last round),
and the round-internal fixpoint includes the undef split.

The residual taxonomy is two-category (completeness.jl): the v1
representation choices :handler_crossing (pending exception SSA),
:gc_token (pending pairing-rule values), :box_capture (producerless) —
everything else is a bug the harness fails on. The DF harness credits
the definedness Bool's placements to the original cell, counts
NewvarNode kills as stock defs, and the stock oracle machine-checks
PhiC/Upsilon (or stock undef guards) for :handler_crossing and Box for
:box_capture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…able

CellFuzz gains uninitialized cells and mid-flow cell_new re-undefines
(the definedness-as-data shapes); interpretation runs under a fuel
budget so hangs become diagnosable failures; statement ids are stripped
from error strings before differential comparison. Severity split:
classes the machinery eliminates by design (:maybe_undef_read, :escape,
:UNCLASSIFIED) fail the run; :island/:refused_multilevel_exit — the two
open classes on adversarial fuzz shapes, zero corpus-wide — are counted
and reported as open work.

bench/unified_fresh.jl prints the acceptance table from one cold
process: all ten escape_string kwbody specializations, gcd/_gcd,
countlines, code_lowered — every headline number reproducible by a
single command, with an in-process determinism check. Tests updated to
the new contracts, and the seeded miscompile-regression battery pins
every bug family found this cycle (stale carried inits, sealed-exit
skips, order-key corruption, inner-island constant-folds, definedness
kill-awareness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`if %6 :: Float64 {` read as an assertion about the condition (and
`loop (inits) :: T {` as one about the init tuple). The statement's
result type now trails the final closing brace — `} :: Float64` —
where it unambiguously belongs to the owner. Printer, parser, golden
tests, and the design document moved together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
index (orientation + where things live), tour (hands-on from
@code_unified through the builder, text round-trip, and the reference
interpreter), concepts (statements/regions/layout states/kinds/columns/
cells), the text-format reference, and a pass-writing guide (mutation
vocabulary per layout state, the RemapSet discipline, visibility rules,
a checklist). The normative spec remains docs/design.md; these pages
are the accessible entry points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the join-point cell-promotion passes out of Compiler/src/unified/sroa.jl
into UnifiedIR/src/promote.jl: forward_if_results!, promote_block_cells!,
promote_arm_cells!, promote_loop_cells!, promote_island_cells!,
promote_undef_cells!, their dominance/reachability helpers, PROMOTION_TRACE,
and is_diverge_kind (from structurize.jl), plus a new promote_fixpoint!
driver (the promotion-only joint fixpoint optimize_ir! previously inlined).

The passes are Julia-semantics-free; the one type-lattice consultation
(loop-carried promotion's unconditional-continue check) becomes a
stmt_value hook, with a substrate-default operand_static_value that reads
inline/pool/global constants only. Compiler.Unified binds the suite back
under its old names and passes its inferred-Const reader as the hook, so
the optimizer pipeline is unchanged; lowering (JuliaLowering's capture
analysis) can now run the SAME machinery — shared functions, not a copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AecTAWDkYTxeaTQYT8NEhM
The exception-join slice of the PhiC/Upsilon successor plan (§6): a frame
cell stored in a try's body and/or handler gains one unconditional post-try
store of the try's threaded result. Nothing moves or disappears — on every
path reaching the join the threaded value equals the cell's memory (last
direct arm store, or the incoming value for a storeless joining body under
definite assignment), so the inserted store is a semantic no-op whose only
effect is giving promote_cells! a dominating definition below the throw
edge. Handler reads keep observing memory (§6 throw-edge rule untouched);
joining handlers without a store are refused while the body stores (an
exception between body stores and the terminator would reach the join with
memory the thread cannot name); the idempotence trigger requires a post-try
read the new store would dominate, guaranteeing one firing per (try, cell).

Wired into optimize_ir!'s joint promotion fixpoint, promote_fixpoint!, and
the completeness harness's promotion_fixpoint! (the cellfuzz differential
covers it: handler-store-read-after-try shapes now promote — fuzz residual
:handler_crossing 1037 -> 1031 per 800 cases, execution differential green).
This is what turns try/catch definite assignment into value captures for
the closure-capture analysis (julia#15276).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AecTAWDkYTxeaTQYT8NEhM
Decide boxed-vs-value capture with the shared UnifiedIR cell-promotion
machinery instead of the syntactic assigned-once rule. resolve_scopes now
runs analyze_captures_precise! after the flisp-parity def/use pass; it only
WIDENS BindingInfo.unboxed (monotone over stock — never a regression). Per
lambda, per captured native variable v and creation site C, value capture is
legal iff (a) no lambda capturing v stores to it (tree fact), (b) no store
to v can execute after C — forward order via the shared comes_before /
_may_reach helpers plus the multi-shot loop-backedge rule, where a
re-declaration inside the shared loop (fresh binding per iteration) cancels
the hazard, and (c) a single defined value reaches C, joins included — which
is precisely cell promotion: the lambda body is lowered to throwaway
analysis IR by the UnifiedBackend emitter in a new capture-analysis mode
(candidates forced to frame cells; creation sites emit CAPTURE_SITE markers
holding one cell_get per captured candidate; nested lambda bodies skipped;
unknown forms become checked-opaque calls), and the verdict is whether
UnifiedIR.promote_fixpoint! — the SAME fixpoint Compiler.Unified runs —
resolved the site's cell_get to a reaching definition. The fixpoint runs
without definedness-as-data so maybe-undef captures keep the shared
container and use-time UndefVarError semantics.

Newly value-captured (boxes gone, type instability with them): variables
assigned in both if-arms before capture, try/catch definite assignment
(via the new promote_try_cells!), conditional assignment feeding
comprehensions/do-blocks. Stays shared, exactly as before: mutation after
capture, multi-shot loops, closure-written captures, maybe-undef captures,
recursive local functions. Any form the analysis emitter does not model
bails to the stock verdicts (UnsupportedForm; ACP_STRICT rethrows internal
errors for tests). Test suite parity: 8624 pass / 113 broken / 0 fail, both
sides; the new capture_analysis testset seeds the must-stay-shared cases as
executable soundness sentinels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AecTAWDkYTxeaTQYT8NEhM

Keno and others added 19 commits

July 12, 2026 10:40
When the capture analysis proves a variable must stay shared, type the
container: BindingInfo.box_type carries a container type (Base.RefValue{T})
that closure conversion threads everywhere Core.Box appeared — the closure
type field (eval_closure_type accepts a Type alongside the box flags), the
declaration-point allocation, setfield!/getfield/isdefined accesses (field
:x instead of :contents). Eligibility is decided on the same analysis IR as
the boxing verdicts: T must be provable at lowering time (a declared type
resolving to a constant — conversion already funnels every store through
convert — or the join of all store RHS literal types, collected across
nested lambda bodies too), and the variable must be undef-safe (every home
read/query and capture site store-dominated per the shared §6
dominates_for_cell checker, declaration-position cell_news only) because a
typed container's empty state can be unobservable — maybe-undef captures
keep Core.Box and their use-time UndefVarError.

This types the boxes precision cannot remove: mutation-after-capture with
literal stores becomes RefValue{Int} (the whole function then infers
concretely), declared-type counters and multi-shot loop captures become
RefValue{T} while keeping shared-mutation semantics. Untypeable stores
(x = x + 1 on an undeclared variable) honestly keep Core.Box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AecTAWDkYTxeaTQYT8NEhM
lower_to_ir already covers ordinary closures (conversion lifts closure types
and methods to the toplevel thunk, so enclosing bodies and closure bodies
lower as plain methods); the module docstring claimed otherwise — fix it and
seed an in-tree corpus (test/unified_backend.jl): 16 sources, closure cases
included (value captures, Core.Box, typed RefValue containers, generator,
do-block), each lowered, L1-verified, and differentially executed on the
reference interpreter against native definitions, plus undef-semantics and
no-Box-in-IR assertions.

UnifiedIR/demo/capture_zoo.jl is the cold-runnable julia#15276 demonstration:
per-case capture decisions and inferred return types (stock flisp vs the
mem2reg analysis), a 12-point execution differential (mutation visibility,
multi-shot loops, undef errors — must match), and microbenchmarks (escaping
join capture 29x, typed counter 14x on this machine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AecTAWDkYTxeaTQYT8NEhM
Document the implemented pre-closure-op slice of §5.7: the exact
value-capture legality criterion (no closure-internal stores; no store
executable after the creation site, with sibling-arm exclusivity and the
multi-shot backedge/fresh-declaration rule; definite assignment through
joins = cell promotion), where it runs, and how maybe-undef and typed
containers behave. docs/closures.md is the worked julia#15276 zoo: per-case
decisions, why each holds, the regression sentinels, and the honest limits
of lowering-time typing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AecTAWDkYTxeaTQYT8NEhM
Add JuliaLowering.ACP_TRACE (an observation hook, nothing = off): the
capture analysis hands the callable its throwaway UnifiedIR as emitted and
again after UnifiedIR.promote_fixpoint!. The demo prints both listings for
three instructive cases: the if-arm join whose CAPTURE_SITE marker operand
resolves to the if result (value capture), mutation-after-capture — where
the marker also resolves but the criterion-(b) pre-check on the emitted IR
(the cell_set visibly AFTER the marker) vetoes value capture — and the
maybe-undef capture whose cell survives the fixpoint outright (no
definedness-as-data in analysis mode), keeping the shared container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AecTAWDkYTxeaTQYT8NEhM
…ure ops

The obvious question, answered where people will look: in the §5.7 closure
region form the captured reads sit behind an activation boundary the §6
promotion rules refuse by design (ACT_IMMEDIATE gates), so decisions only
fall out structurally once the P3 late-capture visibility machinery exists.
The CAPTURE_SITE marker is the degenerate immediate form — snapshot reads at
the creation point, judgeable by the v1 fixpoint as-is — and collapses into
the closure-op formulation when that lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AecTAWDkYTxeaTQYT8NEhM
Give the already-registered K"closure" kind its activation semantics: one
ACT_DEFERRED REGION_BODY region whose leading region_args are the params,
an optional INLINE flags operand (bit 1 = isva), and the closure value as
the op result. The L1 verifier gains the closure deltas: the result-feeding
class (a `result` whose region is owned by a closure is an error — bodies
exit via return/unreachable), the cell-class boundary rule (a cell op
reaching a frame `cell` across an ACT_DEFERRED boundary requires
cell_shared; the ACT_RESUME variant is edge-defined for cfg await and
deliberately not keyed), closure-op operand/region discipline, an
activation check on cfg edge bundles, and the rule that only closures own
deferred regions. Dead regions become invisible to verification until
compact! drops them, which lets dense-state DCE tombstone whole closure
subtrees.

Effect composition is now mode-aware (§3.3): closure creation registers as
REMOVABLE, UnifiedIR dce! deletes unused closures on their creation-site
flags alone, and Compiler.Unified's adce_region_ops!/removable_subtree
skip deferred regions instead of refusing them (resume regions stay
refused).

The reference interpreter executes closures via a callable UClosure: the
creation-time snapshot takes the free slots from the new
closure_environment(ir, s) analysis (the single source of truth for the
derived environment — ordered values and cells referenced from the
deferred subtree but defined outside), values by value and CellBox cells
by reference, so cell_shared sharing and multi-shot loop snapshots fall
out. Call-time exceptions unwind to the call site's handler for free.
The textual format prints/parses the closure form (isva as a trailing
`...`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xpoint

The mem2reg suite gains the capture-promotion pass (§5.7): a `cell_shared`
cell read inside deferred regions is rewritten to value captures when the
precise criterion holds. (a) no write to the cell inside any deferred
region and (b) no write can execute after any home-frame closure site —
the criterion-(b) machinery (comes_before + _may_reach sibling-arm
exclusivity + the multi-shot backedge rule) relocated from the
capture-analysis sidecar onto IR positions, with the fresh-binding-per-
iteration cancellation in its structural form: the backedge hazard is
cancelled only when the CELL ITSELF is declared inside the shared loop (a
fresh shared box per iteration). A `cell_new` deliberately does not cancel
— on a shared cell it re-undefines the one box every extant closure
aliases, so it counts as an observable write instead. (c) — a single
defined value reaches each site, joins included — is judged by the
standard fixpoint itself on a throwaway scratch copy: the candidate is
demoted to a frame cell there, one probe cell_get per site is planted at
the creation point under a marker call (the sidecar's device relocated
onto real IR), and the fixpoint runs without definedness-as-data; an
unresolved probe means maybe-undef or ambiguity and the cell keeps the
shared container with use-time UndefVarError. Committing mirrors the
judged copy — probe before each site, in-deferred reads rewritten to it (a
legal by-visibility capture), cell demoted to frame class — and later
rounds finish the home promotion, so a fully-resolved cell disappears.
The `boundary` parameter documents the await seam (resume = live-at-
suspension snapshot replacing (b)); only :deferred is implemented.

The activation-guard audit found one wrong-altitude gate:
_promote_cells_of_loop! refused whenever the loop's OWN region was
non-immediate, blocking all activation-LOCAL promotion inside closure
bodies; the gate now applies to regions the enclosing-arm chain crosses.
All other helpers hold: dominates_for_cell/_cell_dominates_ed/
_body_path_ok/_post_anchor gate boundary CROSSINGS only, and the frame-
cell boundary rule (L1) plus the cell_shared kind filter keep every other
pass from ever comparing positions across activations.

Tests: IR-level mirrors of the JuliaLowering capture sentinels (arm/try
joins resolve; store-after-creation, store-inside-closure, multi-shot
backedge, maybe-undef all stay shared; fresh-cell-in-loop cancellation;
nested transitive captures; activation-local promotion inside a deferred
body; no-op on closure-free IR) plus a seeded closure-shape fuzz battery
(300 iterations, 517 closures) with interpreter differentials before and
after the fixpoint in both configurations, through a textual round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ization

lower_to_ir's default path now consumes the PRE-closure-conversion tree
(post-resolve_scopes): supported local functions emit real `closure` region
ops (params as deferred-region args), and the emitter stops deciding
captures — captured mutable locals get `cell_shared` plans (created at
their K"local" site when one exists, so a loop-local declaration yields a
fresh shared box per iteration), assigned-once/dominating captured locals
stay plain SSA values that in-deferred reads reference by visibility, and
unassigned captured arguments capture their region_arg directly. The shared
promotion fixpoint (promote_capture_cells! included) then decides
value-vs-shared structurally. Scan/planning cover nested method lambdas as
conditional subpaths; frames are entered/exited with their own handler,
break-target, sparam, and return-type scopes; is_ssa desugaring temps (which
scope analysis keeps out of locals_capt) are owned by the frame the scan saw
them in.

Materialization (materialize.jl) turns residual closure regions back into
runtime closures: the capture set IS closure_environment; the type comes
from the existing eval_closure_type machinery (value captures
type-parameterized, surviving shared cells Core.Box or the binding's proven
Base.RefValue{T}); the deferred region is extracted into a standalone
method IR (region 1 = #self# + params, captures via getfield, shared-cell
ops via container field ops, nested closures recursively); the op becomes
apply_type + `new`; and a trampoline method on the new type interprets the
extracted IR, so instances are callable and differentials cross the call
boundary inside UnifiedIR with no aliasing to natively-created types.
Surviving shared cells in the frame lower to the same containers, keeping
use-time UndefVarError with the right variable name through the emitted
guards. promote_capture_cells! learned to accept in-deferred cell_isdefined
guards: a resolved probe proves definedness at the creation site, which
under (a)+(b) equals definedness at call time, so the guards rewrite to
`true` on commit (maybe-undef cells still keep them and the shared
container).

v1 bails (multi-method/kwargs local functions, `where` sparams, varargs,
declared return types on local functions, opaque closures, captured_local,
signatures referencing locals, enclosing-sparam captures) fall back
per-top-level-statement to the eager convert_closures path — loudly, per
the fidelity rule. The whole existing corpus lowers through the region path
(REGION=15/EAGER=0), with new region-corpus differentials (joins, try
joins, counters, typed containers, loop-fresh, multi-shot, recursion via
the Box'd self-capture fallback, nested closures, undef-at-use) and bail
tests asserting the fallback fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions

analyze_captures_precise! keeps its entry point and BindingInfo/box_type
outputs, but the decision machinery is no longer a sidecar: the analysis IR
now emits REAL `closure` region ops at creation sites — each deferred body
is the site's capture footprint (one cell_get per captured candidate, plus
a synthetic cell_set for candidates some capturing lambda stores to, making
criterion (a) a structural fact) — with candidates as `cell_shared` cells
created at their K"local" site (a fresh shared box per execution, the
structural form of per-iteration rebinding the backedge rule keys on).
The shared fixpoint's promote_capture_cells! then decides captures on this
IR exactly as it does on real lowering IR, and the per-variable verdict is
read off structurally: a candidate whose cell_shared survives with an
in-deferred use stays shared; a resolved candidate's reads were rewritten
to value captures.

CaptureSiteMarker, the marker emission, the sidecar's private criterion-(b)
pre-check (_acp_store_observable_after + the unsafe_after machinery), and
decl_regions are deleted — that logic lives inside the pass now. The
undef-safety check for typed containers reformulates capture reads as
"store-dominated at the creation site" (in-deferred reads execute after the
site and home stores never un-define), counting home-side stores only.
ACP_TRACE stays; the capture-zoo demo's IR section shows the closure-region
form before/after the fixpoint, with the verdict table unchanged (same
wins, same honest limits, execution differential all-MATCH).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
design.md §5.7 (both copies stay byte-identical with the workspace copy):
the scoping note now records the implemented v1 closure-op slice — the
derived environment (closure_environment), the op discipline, and the L1
activation rules (result-feeding class, the ACT_DEFERRED-keyed cell-class
boundary rule with the ACT_RESUME copy-semantics carve-out, exit/edge
boundary rules, mode-aware effect composition, call-site throw binding) —
plus the structural capture decision (promote_capture_cells!'s (a)/(b)/(c)
with the fresh-cell-in-loop cancellation), lowering materialization, the
remaining eager fallbacks, and the await sharing seam. §3.3/§5.8 closure
mentions updated from "enters at P3" to the implemented slice.

closures.md replaces the marker story with the closure-region
representation; concepts.md's residual-cell taxonomy now names the
must-share classes instead of "pending closure optimization".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ibility)

Lowering is not allowed to type a shared capture container: resolving a
declared type expression reads the binding table, and joining store RHS
literal types is inference — both outside what lowering may do (the
envelope rule, now recorded verbatim in UnifiedIR/docs/closures.md:
"isdefined->Bool, sinking closure definitions..., and anything else that's
visible structurally"). Remove the BindingInfo.box_type mechanism
end-to-end: the typed-container eligibility analysis (declared-type
resolution, literal-store joins, the undef-safety checker) in
capture_analysis.jl, the container-type threading in closure conversion
(field flags back to Bool, :contents everywhere), eval_closure_type's Type
branch, and the RefValue paths in the region-path materializer. Shares are
untyped Core.Box, exactly as flisp emits today; the precise capture
DECISIONS (structural mem2reg on the closure-region IR) are untouched —
they are squarely inside the envelope. Typed-cell materialization for
unavoidable shares belongs to the compiler's late-materialization pipeline
after inference.

Test migration keeps the semantic content: the must-stay-shared sentinels
still assert sharing plus executable semantics (mutation visibility,
multi-shot loops, use-time UndefVarError, the declared-type convert
funnel); the RefValue field-layout assertions and the typed-counter
return_types check are deleted from the lowering suite (they belong to the
compiler pipeline). The capture-zoo demo's shared rows honestly show
Core.Box again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The envelope rule allows exactly one code motion: "sinking closure
definitions (as long as they haven't been used yet)". Creation is pure, so
moving a whole pure creation statement down its block to just before the
first statement that mentions any binding it assigns is unobservable — and
a store that sat between creation and first use now lands BEFORE the sunk
creation, where capture criterion (b) holds: `x = 1; f = () -> x; x = 2;
f()` becomes a VALUE capture of the post-store 2 (strictly better than the
shared box: one snapshot, no allocation, precise inference downstream).

`sink_closure_definitions!` runs on the scoped tree inside
`resolve_scopes`, before `analyze_captures_precise!` and before EITHER
lowering path reads the tree — the capture decision and the emitted
creation position cannot disagree, because every consumer (the
capture-analysis IR, `convert_closures`, the UnifiedBackend region
emitter) consumes the same already-sunk statement order; the position rule
has exactly one implementation. v1 is deliberately conservative: whole
pure creation statements only (`_sink_match` whitelist over the
closure-conversion machinery), same block only, method bodies only (never
toplevel thunks, which pin closure-type definition and world-age effects);
confinement requires every outside occurrence of the created bindings to
sit in a later statement of the same block; skipped statements may not
mention them anywhere in their subtree (a capture is a use, `@isdefined`
is a use, an alias is a use), may not carry `@label`/`@goto`, and a
skipped `function_decl` counts as mentioning every variable its closure
CAPTURES — instance materialization happens at the decl and reads each
captured binding there, a mention outside the decl's subtree when the
closure's methods sit in separate `method_defs` statements (kwargs
closures split this way: sinking the kw-body's bare decl past the
sorter's decl would make the sorter capture an undefined variable —
regression-tested both here and by the pre-existing kwargs boxing test
the gap broke). Skipping
may-throw/early-exit statements is sound: on such a path control leaves
the block and, by confinement, nothing that can observe the binding is
reachable; loops re-enter from the top and re-run the sunk creation.

Tests assert both directions with executable differentials: the sinking
wins (zoo3-class, store inside a skipped `if`, early-exit skip, assigned
argument) and the must-stay-shared blockers (use-before-store, `@isdefined`
between, mutually-capturing pair), plus a 200-program seeded source-level
fuzz battery run differentially against stock lowering with an asserted
shape histogram (store-before-first-use ≥ 50 cases) — a decision/emission
position mismatch would surface as a differential, never a green run. The
capture-zoo demo gains the zoo3b counter-case and shows the sunk closure op
in the before/after IR section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbNqJDB4j8dy6GqMRUX6DT
The authorized Part 3 representation: a mutably-captured variable owned
solely by ONE closure merges its shared container into that closure as an
untyped MUTABLE field (the closure type becomes a mutable struct; value
captures stay const type-parameterized fields), eliminating the Core.Box —
one allocation and one indirection instead of two. Fields are never
type-annotated: a later assignment in the closure body could read an
updated world table, so the type is unknowable to lowering.

analyze_merged_captures! (end of resolve_scopes, after sinking and the
precise capture analysis) proves applicability purely structurally, per
variable: exactly one capturing closure (tree-checked, so opaque closures
and global methods refuse too); not itself a closure binding (recursion
keeps Box: the container must exist before the instance); the closure
binding a plain local assigned only by its unique function_decl; every
home occurrence classified pre-creation (earlier sibling at the ancestor-
path divergence, uses the local slot that initializes the field at `new`)
or dominated-post-creation (later sibling of a block with a completion-
transparent chain down to the decl — compiled to field ops through the
closure binding); the nearest enclosing loop of the variable declaration
equal to the creation site (one activation, at most one instance — the
multi-shot loop shapes keep Box); no labels/gotos in the frame. Maybe-undef
variables merge as uninitialized trailing fields: `new` initializes a
prefix (ninitialized covers it), creation conditionally setfield!s from
the local, guarded reads keep the use-time UndefVarError with the right
variable name, and `@isdefined` maps to field isdefined. Declared types
keep the store-side convert funnel through setfield!; reads stay
unasserted (the deliberate Part 1 parity choice — stock asserts reads of
declared boxed locals; runtime semantics identical, typing is the compiler
pipeline's job).

Both lowering paths apply the verdicts: eval_closure_type takes field-kind
codes (value/box/mutable/mutable-undef; const attrs on non-merged fields
of mutable structs), convert_closures emits the field operations, and the
region path's materializer turns a merged residual cell_shared into the
mutable field — in-body cell ops become field ops on #self#, the `new`
takes the cell's value at creation, later home cell ops become field ops
on the instance, and the demoted init-phase cell is dissolved by a final
promotion fixpoint. The region path conservatively keeps Box for
maybe-undef merges (conditional init needs mid-surgery if regions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbNqJDB4j8dy6GqMRUX6DT
The must-stay-shared sentinels keep asserting sharing semantics; the
representation probe becomes two-fold (acp_capture_repr: Core.Box refs +
merged mutable-field kinds read off eval_closure_type calls), splitting
the shared class into merged (usestore, isdefblocks, mutual, counter,
maybe-undef) and Box-keeping (multi-shot loops, backedge stores,
recursion) shapes. New Part 3 assertions cover the applicability edges
(per-iteration loop merge, cross-closure Box fallback, if-arm creation,
argument write captures, label refusal) and named stock differentials run
every affected semantic against flisp lowering (mutation visibility both
directions, multi-shot loops, UndefVarError naming, @isdefined through
the share, convert-funnel InexactError, mutual identity round-trip). A
second seeded source fuzz battery (200 programs, fixed seed, histogram
asserted) generates the mutable-field shapes heavily: write captures,
maybe-undef writes, cross-closure sharing, per-iteration loop mutation,
post-creation home traffic, declared types. Golden IR fixtures
regenerated for the field-kind codes and merged emission; the region-path
corpus gains the :merged expectation (box-free, setfield! on the
instance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbNqJDB4j8dy6GqMRUX6DT
closures.md records the Part 3 authorization verbatim, the merged
mutable-field representation and its structural applicability rules, what
still keeps Core.Box and why (cross-closure shares, multi-instance loops,
recursion, region-path maybe-undef), the untyped-fields world argument,
and the read-typeassert parity choice. design.md §5.7 materialization note
updated to the two share representations. The zoo table shows the merged
rows honestly (x::Any (mutable field) vs x::Core.Box), keeps every
differential MATCHing, and adds bench_mutcap: escaping shared-capture
closures in a hot loop, demonstrating the allocation win (Box + closure
vs one object) and the removed indirection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbNqJDB4j8dy6GqMRUX6DT
The experimental §5.7 follow-up: `typed_region_ir!` consumes the
UnifiedBackend's PRE-materialization region IR (closure ops +
cell_shared cells, post capture fixpoint) and runs the inference port
extended in place — no second engine, no λ-lattice element:

- `infer_closure!` types deferred bodies "as if" inside the same frame
  fixpoint (value captures SSA, cell captures the content join, params
  joined over visible call sites; refinement barrier + rettype/effects
  isolation per §3.3 mode-aware composition);
- shared-cell contents are a structural fixpoint over every cell_set
  site, home and deferred alike — the same monotone `celltypes`
  accumulator and widening escalator frame cells already use;
- calls whose callee def is a visible closure op take the body's
  return-type join + effects mask; arity-mismatched calls of
  unshifted closures are (Union{}, THROWS);
- NO typed containers, deliberately: a nominal closure's field cannot
  be typed — a later-world assignment can produce a type unknowable
  at the type's (world-persistent) definition time; that optimization
  is reserved for await's late-generated closures. The discipline
  that keeps the in-world answer sound: an ESCAPING closure (any use
  besides call-callee position) degrades its params and poisons every
  cell it captures (reads Any even with monomorphic visible stores —
  any holder can set the untyped mutable capture field); a
  `latestworld` barrier that may execute after creation makes the
  closure unrefinable (`world_hazard`, reusing the capture
  criterion-(b) position machinery). Poisoned cells still compute
  joins (meta channel) for diagnostics and the future await consumer.
- shared cells are excluded from Conditional/typeassert refinement
  subjects (a visible call between test and use may store), matching
  the existing store-forwarding-overlay exclusion.

zoo5b's undeclared counter (`x = 0; inc = () -> (x = x + 1)`) infers
Int64 end-to-end (today: Any). Tests: precision + the load-bearing
escape negative + world-barrier trio + arity/isva/unstable-store/
maybe-undef/nested/multi-shot cases, a 300-case seeded
runtime-soundness fuzz (interpreted values inhabit inferred types),
and a source-level corpus differential (region IR interpreted
natively as UClosures vs stock lowering).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbNqJDB4j8dy6GqMRUX6DT
zoo5b / declared-zoo5 / escape variant through
Compiler.Unified.typed_region_ir!: inferred rettypes, cell joins, and
the execution differential against stock — containers unchanged by
design (see closures.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbNqJDB4j8dy6GqMRUX6DT
closures.md gains the late-pipeline section (the world argument for
why generic closure fields stay untyped, verbatim; the escape/world
refinement discipline; honest limits). design.md §5.7 note extended
in both copies (byte-identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbNqJDB4j8dy6GqMRUX6DT
The issue's own example (also the manual's "performance of captured
variable" section): an argument conditionally reassigned before the
closure exists. Stock's assigned-once test boxes it; the mem2reg
criterion proves every store precedes the creation and captures the
if-join by value — Core.Box/Any becomes r::Int64, no `let r = r`
workaround. Hot-loop bench: ~2000x (the inferred body vectorizes).

Also make the bench harness consume results through a global sink: a
discarded effect-free loop is dead code once it infers — and ours
infers, so the old harness timed DCE, not the loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbNqJDB4j8dy6GqMRUX6DT