Skip to content

Add the solver subtrack, and gate its page on what the kernel is - #27

Merged
ThrudPrimrose merged 108 commits into
mainfrom
solver-subtrack-kernels-and-skill
Sep 13, 2026
Merged

ThrudPrimrose merged 108 commits into
mainfrom
solver-subtrack-kernels-and-skill

Conversation

@ThrudPrimrose

Copy link
Copy Markdown
Collaborator

Thirteen solver kernels (fourteen manifests -- the Runge-Kutta pair splits, since only the adaptive variant carries a data-dependent step count) land under a new solvers subtrack, each with a Canonical NumPy Form reference, an initializer, a manifest and acceptance gates that assert the property rather than the output: preconditioner iteration ratios, per-cycle residual drop, grid-independence, basis orthogonality, order of accuracy, operator complexity, pivot positivity and an unchanged sparsity pattern. The 244-line solver skill ships only to those kernels through a new subtrack gate -- the first gate keyed on what a kernel IS rather than on how its answer is written -- and two size paths that could not see constraints living only in initialize() are fixed along the way, plus the constraints:/fuzzed: TypeError that no manifest had previously triggered.

🤖 Generated with Claude Code

ThrudPrimrose and others added 30 commits September 11, 2026 15:26
A padded allocation extent (mg_vcycle's `flat`, amg_setup's `npad`/`zpad`) is never subscripted,
so the Fortran emitter's usage-role inference never saw it and declared it real(c_double) -- which
gfortran rejects as "Legacy Extension: REAL array index" at every allocate under -std=f2018. The C
backend already typed such a local from its VALUE; that inference moves to numpyto_common.lowering
as `integer_valued_locals` and the Fortran classifier consults it, so the two backends share one
rule instead of drifting.

With the compile error gone amg_setup's fortran leg returned a wrong level_n: A_indices carried
`index_array: true`, but the kernel also COMPARES those column ids against 0-based row ids
(`a_indices[k] == i`), so the one-based seam shifted a buffer that has no single base. The tag is
dropped, which changes nothing for c/cpp (both are zero-based).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eader owns

A pinned config knob was declared as a file-scope constexpr under its own name, so
rk45_ensemble's `atol` landed beside <stdlib.h>'s `long atol(const char *)`: gcc -std=c23
called it an underspecified declaration of a name already in scope, g++ a redeclaration
as a different kind of entity, and the C++ leg then failed again on `atol + rtol`. The
declaration now takes an emitter-owned name when a standard header already declares that
identifier, with a #define mapping the reference's own spelling onto it, so every use --
body, helper, VLA bound in the signature -- still reads as the kernel wrote it. `atol` is
the only collision across the 118 knob names in the corpus, so every other emitted source
is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Emitting a kept helper as its own @dc.program moved 36 generated programs from parsing to refused
and 10 from agreeing with numpy to disagreeing. Every cause is in the shape a helper's parameters
are DECLARED with, and each is fixed where it arises:

- two parameters bound to ONE caller symbol aliased only the first, so `_conv2d(.., kh, kw)` called
  with `kernel_size` twice declared its return in `kh` while its body computed in `kw`;
- a scalar divisor (`acc / (kh * kw)`) counted as an array the sweep could not size, so the
  out-param fell back to a broadcast join over the ARGUMENTS and took the pool's INPUT shape;
- integer scalar PARAMETERS were not symbols to the slice-span inliner, leaving
  `span_h = (oh - 1) * stride + 1` un-spliced against `ceiling(__sym_span_h/__sym_stride)`;
- an out-param declared in the caller's vocabulary while the body allocated in the helper's own;
- a symbol that CANCELS out of an extent (`(ci + 1) * 4 - ci * 4`) still demanded as an argument;
- `np.newaxis` CONSUMED a source dimension instead of inserting one, so `x1[:, None, :]` over a
  rank-2 array inferred rank 1;
- the caller's recipe for an extent and the helper's parameter for it left standing as two names;
- DivisibleStridedSpan matched `lower + span` on the top node only, so the left-associative
  `oy0 + (h - 1) * stride + 1` carried the idiom past the rewrite that exists for it.

Two are not DaCe's alone. The divisor sized `_avgpool2d`'s out-param off the pool's input, and the
newaxis walk mis-ranked every helper argument written with one: C and Fortran allocate from the
same descriptors and reported neither.

conv_standard_2d_square_input_square_kernel pins its stride/dilation/groups as config knobs, which
is what its `out` extent already assumed by spelling the stride as a literal 4.

Separately, four gates that the taxonomy retirement in 80fbd17 left asserting what is gone.
`min_precision: fp64` was nested inside the deleted `taxonomy:` block, so both mandelbrots and
mixed_precision_ir lost the fp64 floor they are chaotic enough to need and were being graded at
fp32; the key is still schema-legal, so it moves to the top level. The solver roster selected on a
`spec.tags` that no longer exists, for a tag now spelled `solvers`. cp2k_grid_integrate's manifest
test asserted the retired `kind` beside the `level` that replaced it. And the annotation baseline
counted a gitignored file, which no checkout can ever clear, so the ratchet now counts only what
git tracks -- 5896 across 356 files, lib_nodes.py's 485 among what came off.

A generated module now names its kernel program. Kept helpers are @dc.programs too, so a reader
can no longer take the sole one, and the name matches neither the file stem (lenet -> lenet5) nor
a fixed word (nussinov -> kernel): three resolvers were answering "no program" for modules that
have one.

Left refused, with causes rather than excuses: two conv_transpose kernels still spell one extent
two ways; jfnk_bratu and sgs_pcg cannot infer their symbols; and hotspot_rodinia passes float
scalars computed from a symbol (`0.016 / N`) into a nested program, where DaCe's dtype mapping has
no entry for sympy.Float -- upstream, not an emitter bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pinned config knob whose name a standard header already declares (rk45_ensemble's atol against
<stdlib.h>'s long atol(const char *)) is now declared under an emitter-owned name, with a #define
mapping the reference's own spelling onto it. atol is the only collision across the corpus's 118
knob names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A padded allocation extent is never subscripted, so the Fortran emitter's usage-role inference
never saw it and declared it real(c_double) -- a REAL array index gfortran rejects. The C backend's
value-based inference moves to numpyto_common.lowering so both backends share one rule. Behind that
compile error, amg_setup's A_indices carried index_array: true while the kernel also compares those
column ids against 0-based row ids, so the one-based seam shifted a buffer with no single base.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and fortran

Four causes, three of them in the shared translator path: a contiguous sub-array handed
to a kept helper now becomes a pointer offset instead of meeting a bare ast.Slice; a
reduction helper (array in, scalar out) is no longer mistaken for an array-returning one
and broadcast over its own operand; the Krylov basis is indexed leading-axis-first,
because a trailing-axis slice is a strided view no pointer can carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…native legs

Four causes behind one reported Slice refusal. A contiguous sub-array argument to a kept helper
(qu[k, :, :]) met the general expression emitter and now emits a pointer offset. A reduction
helper -- array in, scalar out, which is every 2-norm and dot product in both kernels -- was typed
as array-returning, so the caller allocated an operand-shaped buffer and broadcast the call over
it, one invocation per element reduced; the rank-0 decision is now gated on a PROVABLE rank-0 body
rather than on None, which also means 'could not size'. jfnk_bratu's Q is transposed to match the
convention bdf already used. And bdf's lagrange_weights divided a row by a pivot it was
overwriting mid-loop -- numpy reads the pivot once, the scalarized C loop did not -- which is why
that kernel reported a timeout rather than a compile error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
conv3d_softmax_max_pool_max_pool and densenet121_transition_layer were listed as broadcast
refusals and both return verdict ok. The ratchet runs both ways -- an entry that no longer refuses
is slack a real regression can hide in -- so the gate fails on a stale entry exactly as it fails
on a new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A nested @dc.program call is bound by SOLVING the callee's symbols from the shapes the
call site passes, and these two kernels sat on opposite failures of that solve.

sgs_apply's three CSR parameters were declared (3*NX-2)*(3*NY-2)*(3*NZ-2) and NX*NY*NZ+1
over a body naming only N: one equation per extent however many symbols it spells, so
three equations for four unknowns and sympy answered with a one-parameter family of
quadratics. with_solvable_extents gives an extent naming anything nothing else supplies
one symbol of its own, and retires what that leaves in no shape.

bratu_jvp(u, Q[:, :, k], ...) handed a rank-3 array's trailing-index view -- strides
(N*(m+1), m+1) -- to a parameter declared [N, N], whose strides are (N, 1). dace equates
strides as well as shapes, so the system said __SOLVE_N = N and __SOLVE_N = 51*N at once
and had no solution; the view would have read the wrong elements had it been accepted.
materialize_strided_helper_args copies such an argument through a contiguous temp, and
copies it back when the callee writes it. A leading-index plane is already contiguous and
is left alone.

Fixing that exposed a third defect, in the shared frontend: bratu_dot returns a scalar
accumulator, _helper_return_shape_from_body spells "carries no extent" and "could not
size" both as (None, None), and the caller kept the call-site guess in the second case --
a broadcast join over the call's own (N, N) arguments, so a helper returning one number
got an (N, N) out-param and the caller stored a whole grid into H[p, k].
helper_returns_a_scalar_local proves the by-value case instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sgs_apply's CSR parameters were declared as compound extents over a body that names only N, so
dace had three equations for four unknowns and sympy returned a one-parameter family; an extent
whose names the callee never reads now becomes one minted symbol and the system is square.
bratu_jvp was handed Q[:, :, k] -- a trailing-index view whose strides are not the declared
parameter's -- and dace equates strides as well as shapes, so the system had no solution at all;
the argument is copied through a contiguous temp, because accepting the view would have walked the
wrong elements rather than refused.

Both agents that touched this independently found the same defect underneath: a body that returns
a scalar and a body that could not be sized were both reported as None, so a reduction helper got
an operand-shaped out-param. One predicate is kept -- the more general one, which handles an
expression return and not only a bare name -- and its call into _extent_operands_resolved is
corrected to pass the scalar set that function now requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A kept helper's descriptors are spelled in the caller's vocabulary while its
body speaks its own parameter names, and the two can use one word twice or two
words once. Collapse a by-value extent parameter onto the caller symbol the
descriptors already carry (_conv_transpose2d's `stride` against
`conv_transpose_stride`), never adopt a helper name the descriptors already
spell for the caller's own quantity, and refuse a helper whose captured name
has no other spelling so the existing inline fallback emits it instead.

conv_transpose2d_max_pool_hardtanh_mean_tanh,
conv_transpose3d_leaky_relu_multiply_leaky_relu_max and
conv_transpose3d_batch_norm_avg_pool_avg_pool go fail -> ok; 669 of the 679
corpus kernels emit byte-identical source and no kernel goes ok -> fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d rather than mis-emitted

A helper that receives one quantity twice -- as a by-value scalar parameter and as the caller
symbol its descriptor is written in -- computed its extents in the scalars while declaring them in
the symbols, and dace had no equation relating the two. A scalar parameter now collapses onto the
caller symbol its own argument already names.

The 3d case is not that. Its descriptor spells the CONV kernel and stride while the call passes the
POOL ones, so the module-level dc.symbol is shadowed by the callee's same-named parameter and one
symbol stands for two extents. Neither name has an un-captured spelling, so the emitter refuses the
kept-helper form and the existing inline fallback emits the flattened program -- the refusal is
skipped where helpers are already inlined, so a helper that resists inlining keeps its emission
rather than losing the kernel.

Evidence for no regression is a source diff, not a re-parse: all 679 kernels were emitted before and
after and 669 are byte-identical, so their verdicts cannot move. The 10 that changed were probed
both ways -- three fail -> ok, none ok -> fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ash agree with numpy again

Two causes, both opened by emitting a kept helper as its own @dc.program instead of inlining it.
Both were silent: the five parse, compile, run, and return different numbers.

The first four are ONE defect, and it is DACE'S, not the emitter's -- what lands here is a rewrite
that structures around it, not a fix for it. DaCe's frontend lowers any `return` into a ReturnBlock,
and its codegen emits a nested program's blocks INLINE in the caller's generated function, so a
helper's bare return becomes a literal `return;` out of __program_<kernel>_internal. The helper's
own write lands; everything in the CALLER after the call site is skipped. eigh_test came back with
wout/vout at their input values because the whole eigensolve after the first
hermitian_from_triangle call never ran. Measured standalone: a caller-local temp is correct without
the return and wrong with it, and writing the caller's own parameter is ALSO wrong with it as soon
as one statement follows the call -- so the buffer is not the variable, the return is. Survives
simplify=True and simplify=False; the SDFG is well-formed either way.

That return is not gratuitous. _rewrite_returns_to_outparam adds it so the C and Fortran legs emit
the helper as a void out-param procedure, and it must keep working there -- so render_program
structures it away for the dace leg alone, with two exact source-level rewrites: a tail-position
bare return is dropped (it says what falling off the end already says), and a guard that exits
(`if c: <A>; return` with <B> after it) becomes `if c: <A> else: <B>`. A return inside a loop, or
under a guard that already carries an else, is left alone rather than guessed at.

spgemm_hash is a second, unrelated cause. A helper's scalar parameter is typed from the CALL-SITE
argument, and an element of a kernel LOCAL array resolves to nothing and falls to float64 --
row_bin[row] is int64 and arrived as a double. Inlined that cost nothing, because the body was
spliced into the caller and the value kept its own type; as a kept @dc.program the body counts and
subscripts with it, and g++ refuses the generated code with `invalid types 'int64_t*[double]' for
array subscript`. widen_counting_scalar_params reads the helper's own body instead: a range() bound
and a subscript index are integers in all three languages. C and Fortran emit correctly with it,
gfortran adding INT(..., c_int64_t) at the call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… exits the caller

A return inside a nested @dace.program returns from the CALLER. dace lowers it to a ReturnBlock and
codegen emits a nested program's blocks inline in the caller's function, so the bare return becomes a
C return; out of __program_<kernel>_internal and every statement after the call site is skipped. The
helper's write lands -- the rest of the kernel never runs, which is why eigh_test came back with its
outputs still at their input values.

This is a DaCe defect. The emitter STRUCTURES THE CONSTRUCT AWAY for the dace leg rather than fixing
it: a tail-position bare return is dropped, and a guard that exits becomes an else. The construct is
not gratuitous -- _rewrite_returns_to_outparam adds it for the C and Fortran void-out-param form and
must keep working there.

spgemm_hash was a second, unrelated cause: a helper scalar the body uses as a range bound or a
subscript index was typed from a call-site argument that resolves to nothing and fell to float64,
giving int64_t*[double] subscripts. Such a parameter is an integer in all three languages.

Worth recording for whoever reads this next: 89 corpus kernels emit a second @dc.program, all in
machine_learning/, and test_dace_numeric_agreement excludes that track by design -- so every one of
them with a promoted return was miscompiling in silence and nothing was measuring it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lence again

89 corpus kernels emit a second @dc.program and every one is in machine_learning, which this gate
excludes by design. So when a nested return started returning from the CALLER, layer_norm was out by
2.45e+01, max_pooling_2d by 1.54e+01 and instance_norm by 9.43e+00, and nothing said so -- the parse
ratchet stayed green the whole time, because parsing was never the property that mattered for them.

The four added here are witnesses for the construct, not a sample of the track: each emits a kept
helper, and all three of the named ones go RED with the return rewrite disabled and green with it
(verified in place, not argued). They cost 28 s together, against the ten minutes the docstring says
the full track would.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…her than only read

492a367 broke tests/test_dace_helper_programs.py and I shipped it, because two agents reported
that test as pre-existing and I took their word: pre-existing at THEIR base, which was already
downstream of the break. It passes at 9505e6a and fails from 492a367 on.

caller_side_recipe only recognised a name it could trace to a single assignment, and a free symbol
has none -- so a helper whose extent argument is a bare symbol got no recipe, kept the caller's
spelling in its descriptors and its own parameter's in its body, and refused to parse:
declared [N] and computed in n. A name the owner never assigns stands for itself.

The convention test could not have caught it -- the broken emission satisfies every assertion it
makes, and I checked rather than assumed. What caught it was the parse, so the parse is now a test:
it goes red on the old emission naming the offending call site. The keyword assertion is also made
exact, because demanding that SOMETHING be passed said more than the contract does -- a helper all
of whose symbols are inferable needs no keyword at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hat is waiting

Full-corpus sweep at this head: 679 kernels, one refusal nothing on the list excuses, and nothing
on the list that parses. This is that one.

It is not the emitter. DaCe folds a scalar whose symbols CANCEL -- hotspot computes Rx from two
grid spacings that are both chip_extent / N -- into a sympy.Float carrying no free symbols, which
issymbolic reports as not-symbolic and dtype_to_typeclass holds no key for. The fix demotes such a
value where it is PRODUCED (patching the consumer only moves the refusal to the next gate, which
asks the same two questions); it is committed as hotspot-const-fold 0bd65f83c with 765 frontend
tests green, and is not pushed because local extended carries 25 commits belonging to other work.

The entry comes off the moment that reaches the tip CI installs from, and the ratchet fails until
it does -- which is the property that makes this list worth keeping rather than a place to put
things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170adec made a bare caller symbol its own recipe, so a helper whose extent argument is one would
collapse its parameter name onto it. That fixes test_dace_helper_programs, and it stops
max_pooling_2d and conv_transpose2d_max_pool_hardtanh_mean_tanh from parsing: the rename reaches the
body and only some of the descriptors, leaving one shape spelled two ways on either side of a write.
Narrowing it to symbols the callee's own array shapes already mention did not help -- measured, both
still failed identically.

The underlying emitter bug is real, and test_dace_helper_programs stays red until it is fixed
properly, with the rename landing on descriptors and body at once. A red test naming a real defect is
worth more than a green one bought by breaking two kernels that were right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ogether

170adec made caller_side_recipe answer for a bare free Name, and that answer landed in the SAME
map the alias pass writes, in the opposite direction. The alias pass keeps the helper's own name and
retires the caller's (channels -> c); the recipe pass then read the same pair the other way round
(c -> channels). with_helper_vocabulary sends aliases plus collapse through respell() onto the
DESCRIPTORS and collapse alone through RenameNames onto the BODY, so a map holding both edges moves
the two sides apart: max_pooling_2d declared [batch_size, c, h, w] over a body computing in
channels/height/width, and the frontend refused it.

The seam is the one the alias pass was missing rather than a better predicate for when to try. Its
scalar half already existed and already had the right guards; what stood between it and _scale was
an exclusion for a symbol the descriptors state OUTRIGHT, on the reading that dace solves such a
symbol from the argument while the body's own name rides along as a keyword. Both are bound, but
bound is not EQUAL -- _scale declared [N] over a body allocating np.empty(n) and its closing write
was refused -- so the exclusion goes and caller_side_recipe returns to declining a free Name.

Measured over the whole corpus by emitting every kernel before and after and parse-probing only what
changed: 66 kernels move, all machine_learning, 0 regressions, 37 fail -> ok, 29 fail -> fail and
every one of those already on REFUSED. The tip was broken far past the two kernels that were known:
28 of the 37 died on the broadcast this fixes. Two REFUSED kernels change their cause and keep
failing (conv_depthwise_2d_square_input_asymmetric_kernel broadcast -> reassign,
conv_depthwise_separable_2d broadcast -> a dace const-fold KeyError on sympy Zero); the labels are
left as they stand rather than relabelled off one probe.

The fixture that catches this class gets a second shape: a helper whose own parameters SHADOW the
caller's names for the same dimensions, which is what the corpus carries. Both new tests go red on
the old emission -- the structural one because the annotations and the body then name two different
module symbols per dimension, the parse one because dace says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The alias pass and the recipe loop were both answering for the same (parameter, argument) pair, in
opposite directions. The alias pass keeps the helper's own name and retires the caller's; the recipe
loop, once a bare free Name was made its own recipe, recorded the inverse edge. with_helper_vocabulary
sends the merged map through respell() onto the DESCRIPTORS and collapse alone through RenameNames onto
the BODY -- so a map holding both directions is not a function, and the two sides moved apart:
max_pooling_2d declared one vocabulary over a body computing in the other.

That is why narrowing the predicate could not help. The predicate was never the problem; the second
authority was. A free symbol has no recipe again, and the scalar half of the alias pass drops an
exclusion that assumed dace would solve a bare declared dimension from the argument while the body's
own name rode along as a keyword -- bound is not equal, and dace refused the closing write.

The tip was broken well past the two kernels that surfaced it: of 66 kernels whose emitted text
changes, 37 go from refused to parsing and 28 of those died on exactly this. None of the 37 is on
REFUSED, so the shrink direction is untouched and nothing was added to any list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fused slice assignment stores one element per iteration, so an RHS read of a scalar
element of the array it writes could be served from a slot an earlier iteration had
already overwritten -- A[k, k:] = A[k, k:] / A[k, k] divided by the 1.0 it had just
stored, and c / cpp / fortran all disagreed with numpy by 1.86e+00 on a 6x6. The pivot
is now staged into a temp ahead of the loop nest, which is where numpy reads it.

Invariance is structural (full rank, no Slice, no newaxis, no index array), so a
non-aliasing kernel keeps its value exactly and trades a trip count's worth of loads
for one: cholesky / lu / ludcmp / gaussian are the only four corpus kernels whose
lowered source moves, all four still numerically green on c / cpp / fortran.
…ze a shape-only name

WORK IN PROGRESS, built against cff3913 and not yet re-verified on the current tip.

conv_depthwise_2d_square_input_asymmetric_kernel and conv_depthwise_separable_2d were both
recorded as "broadcast" refusals, but the cause had drifted. Two repairs, one kernel each:

version_rebound_names -- an accumulator's `+=` no longer declines the reshape that rebinds it,
so the name carries one shape per version instead of two shapes under one name.

freeze_shape_only_parameters -- a manifest name that only a DECLARED shape spells is frozen to
its pinned value rather than promoted to a dc.symbol the body can never mention, which is what
left two extents the frontend could not prove equal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SliceFusion stores one element per iteration, so an RHS read of a scalar element of the
array the statement writes could be served from a slot an earlier iteration had already
stored to. A[k, k:] = A[k, k:] / A[k, k] divided by the 1.0 it had just written, and c,
cpp and fortran all disagreed with numpy by 1.86e+00 on a 6x6. numpy evaluates the whole
RHS against the pre-assignment array, so the pivot is now staged into a temp ahead of the
loop nest.

Invariance is decided structurally -- full rank, no Slice, no newaxis, no index array --
so a non-aliasing kernel keeps its value exactly and trades a trip count's worth of loads
for one. cholesky, lu, ludcmp and gaussian are the only four corpus kernels whose lowered
source moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nine tests merged with the pivot-aliasing fix carried no return annotation, so
test_no_file_gains_an_unannotated_function reported the file at (0, 9). Annotated rather
than re-baselined: the baseline records debt that predates the rule, not new debt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…versions

`version_rebound_names` counted `acc += tap` as a foreign store, which declined every accumulator a
later `acc = acc.reshape(..)` rebinds: one dace descriptor asked to hold two shapes, reported as
`Cannot reassign value to variable`. An `+=` reads and writes the buffer the name already holds
without touching its shape, so it is owned like a read and renamed like one.

Two kernels come off the refusal list on that repair --
conv_depthwise_2d_square_input_asymmetric_kernel and conv2d_min_tanh_tanh. Both were recorded as
`broadcast` and neither was; re-measured at the tip they raised `Cannot reassign value to variable`,
so a stale label came off with them. conv_depthwise_separable_2d stays refused under its measured
cause, `dace_const_fold`: at this tip it raises `KeyError: sympy.core.numbers.Zero` out of dace's own
dtype mapping, which is not ours to fix here.

Measured by rendering all 679 corpus kernels before and after and parse-probing every kernel whose
text moved, one process each: 42 changed, 0 ok -> fail, 2 fail -> ok.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An `acc += tap` was counted as a foreign store, so every accumulator a later
`acc = acc.reshape(..)` rebinds had its versioning declined. An `+=` reads and writes the
buffer the name already holds without touching its shape, so it is now owned like a read
and renamed like one. conv_depthwise_2d_square_input_asymmetric_kernel and
conv2d_min_tanh_tanh come off the refusal list; both were recorded as broadcast and both
were really refusing on a reassignment.

conv_depthwise_separable_2d stays refused, now under the cause that was measured rather
than the one inherited: dace const-folds an extent whose symbols cancel to sympy.Zero and
its dtype mapping has no key for it. Same DaCe defect already recorded for hotspot_rodinia.

The refusal tally in the header was wrong before this change -- it claimed 64 entries and
52 broadcast against a list holding 63 and 50 -- and is corrected to the true numbers.

42 of 679 kernels change emitted text, 0 ok to fail, 2 fail to ok. The other 40 are
uniform renames of a scalar accumulator rebound after its loop, verdict unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The JFNK step eps = sqrt(macheps)*(1+||u||)/||v|| is a round-off bound, so it
has to follow the width the solve runs at; bratu_jvp pinned macheps to
np.finfo(np.float64).eps. The translators already fold np.finfo(...).eps to the
emitted precision, but the numpy reference runs as plain Python with no such
rewrite, so the fp32 sweep compared a correct native solve against a reference
using an eps ~23000x too small -- which divides the residual difference by
1.5e-08 and amplifies u's own fp32 representation error into the
Jacobian-vector product. The reference DIVERGED (||F|| 1.8e+02 -> 3.7e+03,
|u|max 15.5 against a true 0.795); that gap, not fp32 noise, was the reported
d=1.09e+01 on c, cpp and fortran alike.

Not fp32-undecidable: with the bound read off u.dtype, exact-arithmetic
reorderings of the reference (row-sum forward vs reversed, C- vs F-order) agree
to 9.5e-07 and 8.9e-07, and the fp32 solve lands 1.4e-06 from the fp64 answer.
Under the pinned bound those same reorderings diverge by 2.95e+01 and 1.63e+01,
so the apparent undecidability was manufactured by the defect.

Newton step count is identical (20) at both precisions and was never the cause.
The new corpus lint keeps the next kernel from pinning a round-off bound the
same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WORK IN PROGRESS, NOT VERIFIED

A nested @dc.program's symbols are solved by sympy from the shapes its call
site passes, one equation per declared extent. `//` reaches that solver as
`int_floor(a, b)`, a two-argument Function head sympy cannot invert -- and it
does not decline: matched against the caller's own `int_floor` it raises
`NotImplementedError: equal function with more than 1 argument` and the parse
dies. `render_program` redeclares a kept helper's out-param with the extent
its own body allocates, which is where such an extent enters the signature.
Those equations are redundant anyway (every symbol inside the floor division
is already determined by an input parameter's own extent), so the whole
floor-divided extent now carries a minted symbol, substituted into the body as
well as the declaration so the closing `hret[:] = out` still compares one
expression with itself.

NOT RUN: the corpus-wide emit-diff regression check. The BEFORE sweep (all 679
kernels emitted at efdca8c) completed; the AFTER sweep and the diff did not,
so kernels-changed and any ok->fail are UNMEASURED. No test was added either.

Verified so far, against a dace whose relax_int_floor is neutered to match the
spcl/dace@extended tip CI installs (upstream has no relax_int_floor at all --
the local /home/primrose/Work/dace tree carries that fix unpushed):
conv_standard_1d_dilated_strided and
conv_transpose2d_max_pool_hardtanh_mean_tanh both FAIL before and parse ok
after, and both return `ok` from the S-preset numeric oracle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The solve count is decided by the arithmetic ordering, not by the algorithm.
Every GMRES call exits on `rel < gmres_tol` and every Newton sweep exits on
`resnorm < 1.0`; both are hard thresholds on a floating-point residual, and
reassociating the row dot products -- which every backend is free to do --
carries those residuals across a threshold several times per run.

Measured at N=64 over four orderings of the same dot product (BLAS ddot,
pairwise sum(a*b), left fold, right fold): the solve count lands on 909, 912,
909, 908 while nsteps, njev, t_final and the whole 188-entry order history come
out bit-identical. Tightening gmres_tol shrinks the count without removing the
sensitivity -- 719/718/719/720 at 1e-2, 670/670/670/669 at 1e-3 -- and even a
gmres_tol the GMRES residual can never reach, the move that makes jfnk_bratu's
Newton count deterministic, still gives 616/615/616/616: the flip relocates to
the Newton test, where one ordering reads resnorm = 1.0452 and corrects once
more while the other reads 0.9949 and stops. A BDF corrector has to stop when
the corrector has converged, so that threshold cannot be spent the way an inner
tolerance can, and no setting of the knobs makes the solve count a testable
output. The sibling jfnk_bratu already exposes no such counter.

diagnostics is now [nsteps, njev, t_final], shape (3,). Everything the
acceptance gates read stays: the order history (order adaptation engaged, order
>= 3 and >= 2 changes), njev against nsteps (the frozen Jacobian reused, not
refreshed on a schedule), t_final (the integration reached t_end), and the two
solution fields. CI's own failure line named diagnostics alone, so those
outputs already agree across c/cpp/fortran at 188 order decisions and two full
grids -- the gate keeps its teeth.

Verified locally: tests/ports/bdf_newton_krylov 7 passed (57s), and the single
e2e leg tests/test_e2e_numerical.py::test_e2e_numerical_correctness
[bdf_newton_krylov-c] 1 passed (29s). The cpp and fortran legs were NOT run
locally (out of scope for this worktree) and are left to CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThrudPrimrose and others added 28 commits September 12, 2026 19:15
…ls-and-skill

# Conflicts:
#	experiments/merge_results.py
#	hpcagent_bench/harness/recording.py
…ze symbols

Kernels handle float or integer arrays of fixed rank and nothing else. A scalar is a rank-0 tensor passed
by copy, and a size symbol is a named integer scalar whose meaning is an extent. The canonical NumPy form,
the ABI contract and the add-a-benchmark guide now say so in the same words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he xdist worker

The translator op-suite oracle forked a possibly multithreaded pytest-xdist worker to
run jax, which can deadlock the child and let it wedge the session through the execnet
pipe. Route it through run_forked(mp_context="spawn") like numerical_oracle's jax leg,
keep the verdict strings, the JAX_PLATFORMS=cpu pin and the timeout override, and drop
the skip:jax-in-parent escape hatch so that case is now graded.
…ion baseline

conv2d_min_add_multiply and conv_transpose3d_avg_pool_clamp_softmax_multiply parse against
the current dace extended, so the refusal ratchet (55 of 652, broadcast 44) no longer excuses
them. The spawned jax leg annotated _op_oracle.py, 42 -> 34 unannotated functions.
tuple_desugar's fold_list_accumulators and numpy_desugar's curve_fit list
prelude fold recognized the same seed/while/cut idiom with duplicated
matchers. One fold_list_accumulators now lives in numpy_desugar: DS's
segment grammar (append, +=, name + [...], for-range stride, while fill)
in every block, TD's mutation-count guard, called from both frontend sites.

A while with no cut leaves max(len(seed), E) elements; the old TD fold
emitted length E. That case is now refused unless offset <= E is known,
or the cut binds a fresh name that is the list's only reader (raman).
Also refused or fixed: an element reading the list itself, an int seed
grown by a float rule (was int64), and pinned stores past the cut length.
LowerCallsDaceCannotReplace now takes rank_table, so advanced indices
broadcast instead of summing (a scatter over J[ia, ib, :] opened a third
loop), gather temporaries built by np.empty(x.shape) get a rank, and
np.ravel(x) is one axis. expr_rank gains method flatten/conj/conjugate.
Emitted dace programs of all 22 kernels that call a lowered function are
byte-identical. Drop numpy_desugar's shadowed _const_int import (F811).
# Conflicts:
#	hpcagent_bench/harness/efficacy.py
#	hpcagent_bench/harness/harbor_grade.py
#	hpcagent_bench/harness/metric.py
#	hpcagent_bench/numpy_translators/src/numpyto_c/emit.py
#	hpcagent_bench/numpy_translators/src/numpyto_common/lib_nodes.py
#	hpcagent_bench/numpy_translators/src/numpyto_common/lowering.py
#	hpcagent_bench/numpy_translators/src/numpyto_common/numpy_desugar.py
#	hpcagent_bench/stats/figures/signed.py
#	hpcagent_bench/support/collect/sweep.py
#	tests/annotation_baseline.json
#	tests/test_memory_metric.py
#	tests/test_packaging.py
…added

The names guard flagged lines origin/main added or that the merge rewrote: frontend.py and
numpy_desugar.py take lib_nodes' public iter_extent_of alias, read_axis_keepdims, slice_axes,
parse_einsum_subscripts and the nine desugar *Inline classes drop their underscore, and the
discarded bindings in efficacy, ablation_stats and two tests get names or index the one value read.
…_common.statement_desugar

dace, native lowering and jax now share DesugarArrayIteration and SplitChainedAssign. A chained
value is evaluated once: a scalar goes through a temp, dace still repeats a numeric literal (issue 05),
and an array binds one name that the other targets are renamed to, up to a rebinding. Native lowering
repeated the right-hand side, which gave each name its own buffer and re-read rebound targets.
# Conflicts:
#	hpcagent_bench/numpy_translators/src/numpyto_common/numpy_desugar.py
#	hpcagent_bench/numpy_translators/src/numpyto_common/tuple_desugar.py
#	tests/annotation_baseline.json
…PF route tests

Box and rule-line comments (# --- title --- #, # ====) become single # text lines across
the tree; every rewritten file parses to an identical AST. The CPF route tests typed the
make_judge fixture as Any (three ANN401 hits over a baseline of 2), now a Callable alias.
A mask bound to a local has no shape-table entry, so index_rank reported it as one position and
tab[1, :nb, 0][match] composed the mask into the view's kept axis as an integer index.
…ed axis

xsbench's num_nucs[mat][:, None] flattened to num_nucs[mat, None], which the C scalarizer
read as a second gathered axis (and a leftover None literal it could not emit). Moving the
newaxis into the index array, num_nucs[mat[:, None]], still indexed mat with the column
iterator on the np.where path. The chain now stays as it was before the flatteners were
fused; a 26-kernel census emits byte-identical C, dace and jax to the pre-fusion base.
# Conflicts:
#	hpcagent_bench/numpy_translators/src/numpyto_common/lowering.py
# Conflicts:
#	hpcagent_bench/numpy_translators/src/numpyto_common/lowering.py
ChainedSubscriptFlattener.visit finds the outermost A[i][j] chains with one
leaf-skipping stack scan and hands only those subtrees to the transformer.
99.7% of the pass was NodeTransformer dispatch over ~195k nodes for 57 chains.

46 kernels (11 with ][ + vexx_k + 34 others), lower() x3, depth-0 visit
timers, 5 interleaved rounds, medians:
  pass: A (5ca74a3, three old passes) 0.903 s, B-before (5bf69a9)
  0.836 s, B-after 0.217 s (4.2x faster than A).
  lower() x3 total: A 51.5 s, B-before 47.0 s, B-after 52.6 s (box
  shared, +-15% noise; the pass is ~2% of lower).
Equality: sha1 of emit_c(lower), emit_dace, emit_jax identical to
5bf69a9 on 46 kernels x 3 backends. CC: outermost_chains 10, visit 5.
… they replaced

DesugarArrayIteration and SplitChainedAssign now walk statements only (StatementTransformer):
no statement sits inside an expression, so the per-node NodeTransformer visit of every
expression is gone. rank_table indexes its single-Name bindings and first body bindings
once per call (name_binding_index, one walk) instead of re-walking the tree twice per
fixpoint round, and reads() stops once every queried name is found.

Interleaved, 5 rounds, 57 kernels (all 12 with a chained assignment or `for x in arr`,
plus 45 others), medians in seconds, A = 19830c5, B-before = f65ef3a:
  pass-level sum    A 0.577  B-before 1.107  B-after 0.290
  dace chain        0.098    0.329           0.065
  dace iter         0.097    0.096           0.007
  lower chain       0.091    0.304           0.049
  lower iter        0.088    0.087           0.006
  jax chain         0.111    0.210           0.079
  lower+emit total  53.27    52.47           48.70
emit_c(lower), emit_dace and emit_jax sha1 identical to f65ef3a on all 57 kernels, every round.
# Conflicts:
#	docs/DESIGN_perf_protocol_configs_shapes.md
#	hpcagent_bench/benchmarks/scientific_computing/map_reduce/mandelbrot2/mandelbrot2_numpy.py
#	hpcagent_bench/harness/recording.py
#	hpcagent_bench/harness/scoring.py
#	hpcagent_bench/harness/timing.py
#	hpcagent_bench/numpy_translators/src/numpyto_c/dace_emit.py
#	hpcagent_bench/numpy_translators/src/numpyto_common/numpy_desugar.py
#	hpcagent_bench/numpy_translators/tests/_op_oracle.py
#	hpcagent_bench/numpy_translators/tests/test_jax_semantics_fixes.py
#	hpcagent_bench/numpy_translators/tests/test_meshgrid_ix.py
#	hpcagent_bench/osinfo.py
#	scripts/plot_single_shot_score.py
#	tests/test_canonical_parallel_form.py
#	tests/test_harness_hot_paths.py
#	tests/test_inference_audit.py
#	tests/test_recording.py
#	tests/test_timing_backend.py
The per_arm_summary.csv values for qwen38-c, qwen38-fortran and kimi27sglang-c against numba
(8.648 / 5.886 / 7.852) do not reproduce from the llr40 observations with either the current or
main's reduction; the observations give 7.511 / 4.601 / 8.289, and the five c cells match exactly.
@ThrudPrimrose
ThrudPrimrose merged commit 4bdcb08 into main Sep 13, 2026
13 of 18 checks passed
@ThrudPrimrose
ThrudPrimrose deleted the solver-subtrack-kernels-and-skill branch September 19, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant