Skip to content

Module allocatables as run state, character values and dummies, stub-dropped reads, use only-lists: what ELM's PhotosynthesisMod forced - #34

Open
chenyueqi wants to merge 32 commits into
mainfrom
module-allocatable-bounds
Open

Module allocatables as run state, character values and dummies, stub-dropped reads, use only-lists: what ELM's PhotosynthesisMod forced#34
chenyueqi wants to merge 32 commits into
mainfrom
module-allocatable-bounds

Conversation

@chenyueqi

@chenyueqi chenyueqi commented Sep 5, 2026

Copy link
Copy Markdown
Member

Rules two ELM units forced, none about ELM; each with a test naming the case. With them, Photosynthesis (E3SM e508436e45, components/elm/src/biogeophys/PhotosynthesisMod.F90) translates with zero deferred sites, is bit-exact on a recording of the model's own run (61,440 points, 0 ULP, read/write sets matching on 93 blocks), and lowers -- root finders included, as fixed-count loops -- to lax kernels within 10 ULP (day 1) and 21 ULP dominant (noon) of the same recordings, every output compared, with jax.grad agreeing with forward mode to 8e-15 and forward mode with finite differences of the anchor to 3.5e-7 through the root finders (private extension a85tract/recast-elm, docs/photosynthesis.md).

Plan, adapters, recorder

  1. A module allocatable is run state with a shape: its own module's ALLOCATE (allocate (vcmax_np1 (0:mxpft))) is the shape, the extent spelled by the module variables that size it, which become recorded scalars; the Fortran adapter allocates over the original bounds; the recorder takes such an extent from size(). A reader that use-imports the array subscripts it over the companion's lower bound.
  2. A character parameter is a value (namep = 'pft'): a str node, rendered by repr.
  3. A character scalar dummy (phase = 'sun') is flat: character(len=*) in the adapter, text in the recorder and replay, admitted by the bit-exact gate under the recorded convention.
  4. The recorder's own argument is recast_phase; the allocate emitter no longer crashes on a conflicting-bounds record.
  5. The recorder guards every allocated array (associated()/allocated()), records NaN of the planned shape otherwise, and flushes after every section.
  6. A pointer assignment is an alias: a write through psn_z => photosyns_vars%psnsun_z_patch writes every target it may have, where the alias is assigned (not merely pointed). Left out, the routine's own outputs (leaf photosynthesis, stomatal resistance, ci) were planned read-only and never compared: the first bit-exact verdict stood on 20 outputs, the present one on 42 (129,024 points).

Translation and the read/write gate
7. A use ..., only: bounds what a companion exports (an associate alias c3psn resolved to pftvarcon.c3psn and wrote photosyns_vars%qe_patch into pftvarcon.qe).
8. A stubbed call's argument reads are named to the read/write gate (dropped_reads), excused only where the target does not read them; a block that is nothing but an abort is waived by name.
9. The array transforms (reshape, spread, pack, unpack, transpose, cshift, eoshift, maxloc, minloc, lbound) are intrinsics to the analysis; the vocabulary test that kept the divergence records the gate run that ended it.

The JAX port
10. A subroutine's early returns of its output names merge on the flag alone (hybrid's if (f0 == 0) return).
11. A call's positional actuals follow the emitted convention (non-OUT dummies, then OUT array buffers): hybrid(..., gs_mol, iter, atm2lnd_vars, photosyns_vars) omits the OUT scalars.
12. A pointer local pointed in a branch starts the kernel as its first target (fourteen sun/shade views a lax.cond could not carry as None).
13. An integer literal into a real local is a float (nscaler = 1).
14. The JAX runtime carries _f_sqrt and _f_cfold as the NumPy one does (mpmath exempted by the contract the same way).
15. A while True its counter caps through a break is a fixed count of passes under the done flag (and under any loop condition, the single-exit flag included): lax.fori_loop with static bounds, which reverse mode transposes, where lax.while_loop has no rule.
16. A write through a branch-pointed local reaches every target it may have pointed at: psn_z(p,iv) = ... rebinds a JAX local, and the flat output it aliased came back as it went in (d(psn)/d(psn) was exactly one; the ULP gate read 9e18 ULP once it compared those outputs). Before the return each target takes the local under the condition of its pointing.

What the hydraulic-stress unit forced (PhotosynthesisHydraulicStress, the same module under use_hydrstress = .true., recorded on a sibling case: 96 calls, 159 inputs, 50 outputs; bit-exact on 267,264 points in NumPy, and every one of them within 23 ULP as a lax kernel -- the plant-hydraulics Newton solve and both capped root finders as fixed counts of passes)
17. A module-state object is reached by its name; its type is named only where a dummy of it is declared. params_inst is public and protected while photo_params_type is private to the module: the recorder's use PhotosynthesisMod, only: photo_params_type did not compile.
18. A borrowed ALLOCATE bound is spelled in the companion's namespace. ColumnType allocates dz(begc:endc, -nlevsno+1:nlevgrnd); PhotosynthesisMod subscripts dz over that bound and imports nothing named nlevsno, so the subscript read a bare nlevsno (NameError at the first call). The name binds to the module that declares it, imported if the reader has not; module-level and derived-type component allocations alike.
19. SUM is a left fold in element order. np.sum is pairwise past eight elements; over the ten soil layers it put 925 of 267,264 recorded points up to 75 ULP off, and a sequential fold replayed on the same dumps put every one of them on the recording. _f_vsum in both runtimes, as _f_vdot already is; a DIM reduction keeps the library's order.
20. The ULP gate judges what the backend lowered; a routine forwarded to the host fails by name. The JAX module binds what it could not lower to the anchor (f = _host.f) and lists what it did lower in _JAX_KERNELS; the tolerance verifier fetched the name from the module and awarded the port a bit-exact verdict on the hydraulic-stress kernel it had never emitted. A name outside the lowered list is not compared: it fails with the backend's recorded reason unless declared ungated (issue #32, row 22).
21. A call's OUT arrays are buffers in their declared place. The anchor's positional convention is declaration order with OUT scalars omitted and an OUT array passed where it stands (spacf(p, c, x, f, qflx_sun, ...)); the rewrite appended the buffers after the other dummies, shifting every actual behind one, so calcstress handed spacF a flux for atm2lnd_inst ("object not passed" -- a misalignment, not a missing object).
22. A buffered call's OUT scalars bind to temporaries. In _out = calcstress(...); bsun = _out[1] an OUT scalar has no actual to bind to ("output bsun has no target"); it binds to a temporary the unpack reads, and an object's unpack (photosyns_inst = _out[7]) is dropped since its components bound at the call.
23. A nested function's return is its own. PhotosynthesisHydraulicStress carries its ft/fth/fth25 statement functions as nested defs; their returns counted as early returns of the kernel and the single-exit rewrite refused the routine ("an early return in a function with several outputs").
24. A nested def at the top of a body is not descended by the early-return scan (the statement functions again, as the first statements of the body: the scan entered them from the root).
25. A loop around nothing lowers to nothing. brent_PHS checks its bracket per phase and ends the run; with the raise dropped the loop carried nothing and the backend refused the kernel ("loop with no carried effects"); as a guard around nothing already did, it lowers to nothing.
26. A module's own state object is spelled by its components in a kernel. The anchor spells PhotosynthesisMod's own params_inst bare (params_inst.psi50 inside plc); the flat spelling knew dummies and companion state only, so every hydraulic-stress helper kernel took params_inst as an opaque parameter no calling kernel had -- latent while the SP recordings never ran them, NameError the first time the hydraulic-stress kernel did.
27. A kernel's state closure is what its body still names. With the object spelled flat, the record's module_state_read still said params_inst, and the closure taken from it gave plc's kernel an opaque trailing parameter that every calling kernel passed bare.
28. A while loop's done flag starts False at the top of the kernel. calcstress runs its Newton loop inside if (.not. night); the branch carried the loop's flag and the lax.cond operand tuple read _done_1 before anything had bound it (UnboundLocalError). Like the goto-region flags, every done flag is initialized at the top.
29. Consecutive guarded statements share one block. After an early return or a break each following statement was wrapped in a guard of its own, so a call and the unpack of its result became two lax.conds and the temporary the first bound was not in the second's carry (NameError: _t1 in brent_PHS); a statement that returns or breaks closes the block.
30. A Fortran-order copy is the array itself in a kernel. transpose(a) is np.asfortranarray(a.T) in the anchor, a layout the F-order reshape downstream relies on; jnp has no layouts and no asfortranarray (SimpleMathMod's kernel raised AttributeError under the hydraulic-stress kernel).
31. A name that sizes an array or bounds a range is a Python int in a kernel. brent_PHS's integer, parameter :: nphs = 2 sizes its bracket arrays; the backend strengthened the literal to jnp.int32(2) for a stable carry dtype and jnp.zeros((nphs,)) refused a traced shape.
32. An alias bound twice to one target is still an alias. The anchor binds a pointer alias twice, as the view it opens the body with and again where the Fortran's => stands; counted as two stores it was a plain local, rebound in the loops and never written back, and the hydraulic-stress kernel returned every output as it came in (the gate read 9e18 ULP). Every top-level store binding the same value is one alias.
33. Nested capped loops have flags of their own. The capped while lowering gave its counter back, so hybrid_PHS's two capped loops were both _done_1 and the inner loop's exit ended the outer after one pass (the kernel's iter1 stayed 1 where the anchor ran to 4).

What the nutrient-limited path forced (Photosynthesis under use_cn = .true., nu_com = 'ECA', recorded on a third sibling case held in accelerated decomposition; bit-exact on 129,024 points in NumPy)
34. A return inside a loop ends the pass and the loop: the statements after the flag in any block run under if not _ret, a while takes the flag in its test (its body stays a body, so the cap it breaks on stays in the capped lowering's sight and the loop remains a fixed count of passes), a for, which has no test, runs its whole body under the flag -- no break, since a for lowers to a scan whose body cannot break. brent returns from inside its bracketing loop ahead of the next ci_func evaluation; the flag alone left the rest of the pass and the following passes running, and the kernel's gs_mol was an evaluation the Fortran never made (this recording is the first on which hybrid reaches brent).
35. A store inside an inner loop, and a subscript store, is a store of the enclosing block: the carry of a while_loop is computed before lowering, and brent_PHS's c(phase) = ... inside its do phase loops was counted by neither walk (UnboundLocalError: c in the pass).

Full suite passes (tests, minus the C kernels and JAX-diff suites).

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK

chenyueqi and others added 17 commits September 5, 2026 00:50
… is a value; a character scalar dummy is flat

Four rules an ELM unit forced, none of them about ELM:

* flatten: a module allocatable's shape is its own module's ALLOCATE
  (`allocate (vcmax_np1 (0:mxpft))`), its run-time extent spelled by the
  module variables that size it, which become recorded scalars; the
  Fortran adapter allocates over the original bounds before assigning and
  the recorder takes such an extent from size(). A reader that use-imports
  the array subscripts it over the companion's lower bound, not the
  blanket one-based shift (the slatop(0:mxpft) class, for module arrays).
* use-constants: a character parameter (`namep = 'pft'`) builds as a str
  node carrying the value, rendered by repr for Python, never folded
  through integer division.
* a character scalar dummy (`phase = 'sun'`) is a flat argument: declared
  character(len=*) on the Fortran side, written as text by the recorder,
  read back as text by the replay parser, admitted by the bit-exact gate
  under the recorded convention.
* the recorder's own argument is recast_phase, so it cannot collide with
  a dummy named phase; the allocate emitter no longer crashes on a
  conflicting-bounds record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…and flushes after every section

A pointer component the configuration never allocated is not read by the
routine either, but the probe reading it is a fault. Each component or
state the plan found an ALLOCATE for is written under associated() /
allocated(), and recorded as poison of the planned shape otherwise (NaN
for reals, zero and false where there is no NaN), so the replay has a
value to hand over and any use of it shows. The unit is flushed after
each section so a crash leaves the sections before it on disk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…ument reads are named to the read/write gate; reshape is an intrinsic

* Companions carry the only-list the frontend saw (`only`), and the
  translation registers as companion globals only the names it lets in:
  under `use pftvarcon, only: vcmax_np1, ...`, ELM's Photosynthesis
  associates c3psn, qe, theta_cj, slatop while pftvarcon exports arrays of
  the same names, and the reader resolved the aliases to the wrong module
  -- writing photosyns_vars%qe_patch into pftvarcon.qe.
* A statement stub drops its call's arguments: the transform names the
  reads it dropped per block (dropped_reads), the protocol carries them,
  and static.rwset excuses exactly those the target does not read,
  counting them on the verdict (reads_excused_by_stubs). A block that is
  nothing but an abort (a raise with the stub marker) is waived by name,
  which only a pass used to be.
* reshape, spread, pack, unpack, transpose, cshift, eoshift, lbound are
  transformational intrinsics, not variables a block reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…n the evidence of a gate run

maxloc and minloc join reshape and the rest; the vocabulary test that
recorded the deliberate divergence now records why it ended: ELM's
PhotosynthesisMod readParams, 93 blocks matching with them counted as
intrinsics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…one; a call's positional actuals follow the emitted convention

ELM's hybrid/brent/ci_func: `if (f0 == 0) return` ahead of the loop,
returning the same tuple of output names at every return -- nothing to
select through jnp.where, the guarded statements after the flag leave the
outputs as they were. And the anchor's call omits OUT scalars
(`hybrid(..., gs_mol, iter, atm2lnd_vars, photosyns_vars)`), so counting
them against the callee's dummies shifted the objects ("object
photosyns_vars not passed"). With both, photosynthesis_flat, hybrid_flat,
brent_flat and ci_func_flat lower to lax kernels, root finders included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…rget

The anchor starts it as None and points it under phase == 'sun' or 'sha';
a lax.cond cannot carry None against an array (ELM's Photosynthesis:
fourteen sun/shade views, 'cond branch outputs must have the same pytree
structure'). Seeded with the first arm's target -- a pure reference, never
a computation -- which the arm the run takes overwrites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
nscaler = 1 where nscaler is real(r8): Fortran converts, the backend
typed the bare constant int32, and a lax.cond arm disagreed with the
float64 one beside it. The guard init says which strong type the local
is; the rewrite casts the literal to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…bulary spells

The NumPy runtime guards math.sqrt so a negative real is a NaN and not a
crash; a kernel that inlines quadratic() met the name undefined. jnp.sqrt
answers the same. _f_cfold is the compile-time fold the anchor spells for
a constant-argument intrinsic; a kernel meets it at trace time only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
hybrid and brent: convergence exits ahead of `if (iter > itmax) exit`; the
cap is what makes the loop finite, and a fixed count under the done flag
lowers to a scan reverse mode can transpose, where lax.while_loop has no
rule. The JAX runtime's mpmath fold is exempted by the contract the way
the NumPy runtime's is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
hybrid's loop runs as 'while not _ret' after its early returns merged;
the cap read off its 'if (iter > itmax) exit' still makes it a fixed
count, the condition joining the done flag in the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…get it may have

psn_z => photosyns_vars%psnsun_z_patch under sun, the shade array under
shade, then psn_z(p,iv) = ...: ELM's leaf photosynthesis, stomatal
resistance and ci were planned read-only -- an associate alias was
followed, a pointer assignment was not -- and the gate never compared
the routine's own outputs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…ve pointed at

In the anchor psn_z(p,iv) = ... after psn_z => psnsun_z_patch writes the
component's own storage; in a kernel it rebinds the local and the flat
output it aliased came back as it went in -- d(psn)/d(psn) was exactly one
and the ULP gate, once it compared those outputs, read 9e18 ULP. Before
the return each target takes the local under the condition of its
pointing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…ely pointed

The pointer assignment counts as a write of the alias in the read/write
sets, so every sun/shade input read through one (par_z, lai_z,
vcmaxcint) was planned as an output and echoed unchanged: 48 outputs on
Photosynthesis, 42 the call can change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwmCpkkFKaGihSPvqAQnaK
…where a dummy of it is declared

ELM's params_inst is public and protected while its type, photo_params_type,
is private to PhotosynthesisMod. The Fortran adapter and the recorder imported
every object's type; the recorder for PhotosynthesisHydraulicStress did not
compile (Symbol 'photo_params_type' not found in module). State is
use-associated by the variable's name and touched through its components;
only a dummy needs its type declared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
ELM's ColumnType allocates dz(begc:endc, -nlevsno+1:nlevgrnd) in its own
init; PhotosynthesisMod subscripts dz over that bound and imports nothing
named nlevsno. The bound's names were rendered as the reader would spell them,
i.e. bare: NameError at the first call of the hydraulic-stress kernel. A name
in a companion's bound is bound to the module that declares it -- the
companion itself, the module its USE names, or another companion -- and a
module none of the companions is gets imported. Module-level and derived-type
component allocations alike.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
Fortran's SUM accumulates element by element; np.sum is pairwise past eight
elements and rounds differently. ELM's hydraulic-stress kernel sums its ten
soil layers (root conductance, the soil-root flux balance) and the pairwise
sum put 925 of 267,264 recorded points up to 75 ULP off the recording; a
sequential fold, replayed on the same dumps, put every one of them on it.
_f_vsum in both runtimes, as _f_vdot already is; the JAX port's masked-slice
rule recognises it; a DIM reduction keeps the library's order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
…the host fails by name

The JAX module binds what it could not lower to the NumPy anchor
(f = _host.f) and lists what it did lower in _JAX_KERNELS. The tolerance
verifier fetched the name from the module and judged the anchor's own code,
awarding the port a bit-exact verdict on ELM's hydraulic-stress kernel, which
it had never emitted. A name outside the lowered list is not compared: it
fails, with the backend's recorded reason, unless declared ungated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
chenyueqi and others added 12 commits September 5, 2026 16:21
…all's OUT scalars bind to temporaries; a nested function's return is its own

Three shapes of ELM's PhotosynthesisHydraulicStress chain the port refused.
The positional convention is the anchor's: dummies in declaration order, OUT
scalars omitted, an OUT array passed in its place -- spacf(p, c, x, f, ...).
Appending the buffers after the rest shifted every actual behind one, and
calcstress handed spacF a flux for atm2lnd_inst ("object not passed"). In the
anchor's buffered form (_out = calcstress(...); bsun = _out[1]) an OUT scalar
has no actual: it binds to a temporary the unpack reads, an object's unpack
is dropped (its components bound at the call). A statement function is a
nested def whose return is not an early return of the kernel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
…hing lowers to nothing

PhotosynthesisHydraulicStress carries its statement functions as nested defs
at the top of the body: the early-return scan entered them from the root and
refused the routine. brent_PHS checks its bracket per phase and ends the run;
with the raise dropped the loop carried nothing and the backend refused the
kernel. As a guard around nothing, it lowers to nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
The anchor spells PhotosynthesisMod's own params_inst bare (params_inst.psi50
inside plc); the flat spelling knew dummies and companion state only, so every
hydraulic-stress helper kernel took params_inst as an opaque parameter that no
calling kernel had -- latent while the SP recordings never ran them, a
NameError the first time the hydraulic-stress kernel did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
The flat rewrite spells a module's own state object by its components; the
record's module_state_read still says params_inst, and a closure taken from it
gave plc's kernel an opaque trailing parameter that every calling kernel passed
bare -- NameError the first time the hydraulic-stress kernel ran.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
calcstress runs its Newton loop inside if (.not. night); the loop's done flag
is assigned in the branch, so the branch carries it, and the lax.cond operand
tuple read _done_1 before anything had bound it. Like the goto-region flags,
every done flag starts False at the top of the function.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
After an early return or a break, each following statement was wrapped in a
guard of its own; a call and the unpack of its result became two lax.conds,
and the temporary the first bound was not in the second's carry (NameError
_t1 in brent_PHS). Consecutive guarded statements share one block; a statement
that returns or breaks closes it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
transpose(a) is np.asfortranarray(a.T) in the anchor, a layout the F-order
reshape downstream relies on; jnp has no layouts and no asfortranarray, and
SimpleMathMod's kernel raised AttributeError under the hydraulic-stress kernel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
brent_PHS's integer, parameter :: nphs = 2 sizes its bracket arrays; the
backend strengthened the literal to jnp.int32(2) for a stable carry dtype and
jnp.zeros((nphs,)) refused a traced shape. A shape is a trace-time constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
The anchor binds a pointer alias twice, as the view it opens the body with
and again where the Fortran's => stands. Counted as two stores it was a plain
local, rebound in the loops and never written back to the flat output:
PhotosynthesisHydraulicStress returned every output as it came in (9e18 ULP).
Every top-level store binding the same value is one alias.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
The capped while lowering gave its counter back, so hybrid_PHS's two capped
loops were both _done_1 and the inner loop's exit ended the outer after one
pass (iter1 stayed 1 where the anchor ran to 4). The counter is a name supply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
…oops take the flag

brent returns from inside its bracketing loop ahead of the next ci_func
evaluation. The flag alone left the rest of the pass and the following passes
running, and the kernel's gs_mol was an evaluation the Fortran never made
(the nutrient-limited recording is the first where hybrid reaches brent).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
chenyueqi and others added 3 commits September 6, 2026 08:24
A for loop lowers to a scan whose body cannot break (the first form put a
break outside any loop in SimpleMathMod's kernel). The loop's body runs under
if not _ret, the statements after the flag in any block run under it too, and
a while loop takes it in its test: a guarded no-op pass is what a scan can do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
brent_PHS updates its bracket arrays inside do-phase loops; with the pass
under if not _ret those loops sat under an If, where the assigned-names walk
stopped, and the while carry lost them (UnboundLocalError: c in the pass).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
… of its base

Wrapped whole in an If, a while True was no longer forever and the cap it
breaks on was out of the capped lowering's sight: the loop became a
while_loop reverse mode cannot transpose, whose carry is computed before
lowering, where brent_PHS's c(phase) = ... is a subscript store the walk did
not count. The rest of the pass runs under the flag and the test takes it;
a for loop, which has no test, keeps the whole-body guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99K3jFzUL8MmHzwyUPNGz
@chenyueqi chenyueqi added enhancement New feature or request area:frontend Fortran parsing, interface extraction, constant folding area:flatten flat adapters for derived-type interfaces (flatten, recorder, plans) area:emitter the NumPy translation (statements, expressions, parameters) area:jax the JAX backend and tree port area:verifier the gates: static.rwset, differential.bitexact/tolerance, oracles found-by:elm surfaced on the ELM/E3SM tree labels Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:emitter the NumPy translation (statements, expressions, parameters) area:flatten flat adapters for derived-type interfaces (flatten, recorder, plans) area:frontend Fortran parsing, interface extraction, constant folding area:jax the JAX backend and tree port area:verifier the gates: static.rwset, differential.bitexact/tolerance, oracles enhancement New feature or request found-by:elm surfaced on the ELM/E3SM tree

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant