Skip to content

Something#2352

Draft
tbennun wants to merge 149 commits into
mainfrom
pyfrontend2
Draft

Something#2352
tbennun wants to merge 149 commits into
mainfrom
pyfrontend2

Conversation

@tbennun

@tbennun tbennun commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator
  • Phase 1: Implementation
  • Phase 2: Test without changes
  • Phase 3: Test parallel path
  • Phase 4: Test complete replacement

tbennun and others added 7 commits July 18, 2026 23:46
The legacy schedule-tree frontend package is slated for removal in favor
of nextgen, so nextgen must not depend on it. The utilities nextgen used
move to their canonical new homes - structure member-access helpers to
nextgen/semantics/structures.py and CallbackOutliner to
nextgen/lowering/outliner.py - and the dependency inverts: the legacy
structure_support/callback_support modules now re-export them (their
legacy-only helpers stay put), so erasing the old package later only
deletes shims.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
visit_ConsumeScope was an unimplemented stub (for SDFG-derived trees
too). It now mirrors visit_MapScope: a fresh ConsumeEntry/ConsumeExit
pair around the body, the consumed stream (the scope's Stream-typed
tasklet read) sourced into the entry's fixed IN_stream connector through
the shared input machinery, and popped-element reads routed through
OUT_stream. All other reads and writes use the same IN_/OUT_ passthrough
scheme as maps - the map exit-connection loop is extracted into a shared
_connect_scope_exit helper, whose single-use access-node collapse
reproduces the hand-built reference shape of tests/consume_test.py
(tasklet-to-exit edges carrying the dynamic/WCR memlets). Tasklet scope
wiring generalizes from MapEntry to any EntryNode.

Leading state boundaries inside consume scopes are skipped (they encode
hazards against pre-scope producers such as the stream push); interior
boundaries still raise. The nextgen fibonacci consume program compiles
and computes fib(10)=55 across 4 processing elements, and the
consumescope statement-body form executes as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every interpreter-fallback site now carries a stable kebab-case category,
rendered as a '[category]' prefix on the PythonCallbackNode reason (no new
node field; batching's '; ' joins preserve one prefix per merged statement
run). UnsupportedFeatureError gained an optional 'category' attribute so
raise sites own their classification; fallback_to_callback resolves
exception category > call-site default > 'uncategorized'. The registry
totality net tags uncategorized escapes as 'safety-net' (highest bug
suspicion), MarkOpaque tags non-CPA statements as 'opaque-syntax:<type>',
and call dispatch distinguishes 'detected-callback' (preprocessing-wrapped,
intended) from 'unknown-call:<qualname>' (missing-lowering worklist) and
'pyobject-propagation'. Taxonomy documented in lowering/dispatch.py.

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

_check_schedule_tree_parallel_lowering now compares the classic frontend's
callback count (sdfg.callback_mapping, cross-checked against callback-typed
symbols) with the nextgen tree's PythonCallbackNode count. The classic
frontend is the ground truth for intent — callback auto-detection happens
in shared preprocessing — and nextgen batching only merges adjacent
callbacks, so more nextgen nodes than classic callback sites proves excess
interpreter fallbacks: a nextgen lowering gap by definition. The error
lists each node's category-prefixed reasons.

New config frontend.stree_callback_check = error|warn|off (default error;
warn is the triage escape hatch, off the transition emergency valve).

Known discrepancies surfaced by the check (the initial gap list, real
nextgen gaps, recorded in plan/plan.md):
- tests/schedule_tree/schedule_test.py::test_for_in_map_in_for
  (unknown-call:dace.define_local + broadcast)
- ::test_nesting_view/::test_nesting_nview (unknown-call:subset.reshape)
- ::test_dyn_map_range (data-dependent-subscript in implicit loop body)
- parallel_schedule_tree_test.py::test_parallel_path_pythonclass_argument
  (structure-member: PythonClass dynamic member assignment)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New config frontend.stree_report (string path, default off; env
DACE_frontend_stree_report): when set, the parallel lowering check appends
one JSON line per parsed program — program, owning test
(PYTEST_CURRENT_TEST), classic/nextgen callback counts, discrepancy flag,
category-prefixed reasons, per-category counts, and the legacy diagnostic
counts (statement nodes, refsets, PythonClass containers). Single-line
O_APPEND writes, pytest-xdist-safe; records are written before any raise so
error-mode runs still contribute.

New CLI: python -m dace.frontend.python.nextgen.coverage report <jsonl>
aggregates by program (max over specializations), prints discrepant
programs first, then top gap categories and unknown-call qualified names —
the prioritized gap worklist. Triage recipe:
DACE_frontend_stree_report=$TMP/stree.jsonl \
DACE_frontend_stree_callback_check=warn pytest ... ; then the report CLI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Broader-suite triage (tests/python_frontend/ + tests/numpy/, check=warn +
report): 1052 programs, 313 discrepant — 303 of 1209 to_sdfg-exercising
tests across 70 files would fail under the committed error default. Top
categories: unknown-call 239 (dominated by the define_local/ndarray local-
array declaration family), pyobject-propagation 68, memlet-parse 40,
opaque-syntax:Assign 35 (tuple unpacking), opaque-syntax:For 24,
type-inference 23, data-dependent-subscript 19. Full record and burn-down
priority in plan/plan.md; default stays error per spec.

Also hardens the check/report tests against ambient DACE_frontend_* env
vars, which override set_temporary (config.py:255) and broke 4 tests
during the env-driven triage run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every General Tests matrix job now runs with
DACE_frontend_stree_callback_check=warn (the escape hatch from the
committed error default, so the known discrepancy wave does not fail
the full suite) and DACE_frontend_stree_report pointed at a jsonl the
in-path parallel lowering check appends to across all test steps. At
the end of each job the nextgen coverage CLI aggregates the collected
lines into the GitHub job summary and uploads the raw jsonl plus the
rendered report as a per-matrix-combination artifact.

Also triggers General Tests on pushes to pyfrontend2 so the report is
produced without an open PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tbennun and others added 22 commits July 19, 2026 14:59
Co-authored-by: Opus 4.8
Co-authored-by: Opus 4.8
Step 1 keyed write-conflict resolution off the AugAssign token alone,
matching classic. Every other spelling of the same reduction inside a
map -- b = b + x, b = x + b, b = x + y + b -- still lowered as a silent
data race.

New CPA pass DetectAccumulations, ordered before DesugarStatements (whose
AugAssign desugaring produces the very shape it matches) and before
ANFTransform (which hoists compound operands into temporaries and can move
the self-reference out of the statement entirely). That ordering constraint
is why the detector belongs in canonicalization rather than at lowering.

The pass only annotates, never rewrites: rewriting b = x + b into b += x
swaps the operands of +, which concatenates Python sequences, where it does
not commute. The swap happens at lowering instead, after resolve_access
succeeds on the target -- something a compile-time list or string never
does, so the swap only ever reaches numeric data.

Forms with no WCR equivalent (b = b + x + y, needing inexact
re-association; b = x - b, accumulator in a non-fold position;
b = max(b, x), a call) are marked conflict_hazard and reported as warnings.
They still lower as races, matching classic, but no longer silently: a
racing read-modify-write produces a callback-free tree, which is exactly
why this defect class was invisible to the callback discrepancy check.

Policy moves out of rules/assign.py into lowering/mechanisms/conflict.py,
so every write path consults one function instead of re-deriving the rule.

For the normalized forms nextgen now emits WCR where classic emits none;
test_matches_classic_wcr_decision is restricted to the += spelling
accordingly.

nextgen suite 286 passed. Targeted regression set (11 blast-radius files
found by AST-walking tests/ for a self-referential write inside a map,
plus tests/python_frontend and tests/schedule_tree): 67 failures before
and after, identical sets, zero regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJHNEtgKvbFSKmBHXKZcZ5
Reported as five advanced_indexing_test failures caused by the accumulation
work. Bisect says otherwise: they fail identically at HEAD~2, ~4, ~10 and
~20. The investigation found a worse defect underneath.

_indirect_subscript built the rewritten access by string round-trip,
ast.parse(f'{connector}[{index_code}]'). An unparsed index tuple carries
parentheses and a slice is only legal in a bare subscript, so
A[ind, 2:7:2, [15,10,1]] produced __in0[(__in1, 2:7:2, __in2)] and a raw
SyntaxError that escaped the categorized-fallback machinery. Now built as
an ast.Subscript directly.

That fix alone made five tests pass -- with trees that cannot become valid
SDFGs. indirect_index_reads accepted any index resolving to a container, so
a whole-array index (NumPy advanced indexing) was treated as scalar
indirection and given the base array's subset. A[ind] + B with A: int32[M],
ind: int32[N] emits ind[0:M]: it compiles and segfaults. A[indices, 4] and
A[rows, columns] fail SDFG validation as out-of-bounds memlets. Two of
those scored as successes under the callback discrepancy check -- the same
blind spot as the WCR bug, which measures whether we fell back, never
whether the dataflow we emitted instead was correct.

indirect_index_reads now collects only scalar index reads. Advanced
indexing falls through to resolve_access, which already rejects it as
arrdims with a categorized data-dependent-subscript error. Genuine
indirection is untouched -- x[A_col[j]] and A[ptr[i]:ptr[i+1]] index with
single elements.

Failure counts rise on purpose: advanced_indexing_test 21 -> 23, and
test_augmented_assignment_to_indirect_access joins them. All three were
passing with invalid trees, verified individually. Seven honest
data-dependent-subscript gap entries in place of two silent
miscompilations and five hard crashes.

nextgen advanced_indexing_test.py pins the boundary in both directions and
fails 4/5 without this change. nextgen suite 291 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJHNEtgKvbFSKmBHXKZcZ5
advanced_indexing_test.py: 21 -> 6 failures, 19 tests fixed across the
numpy and python_frontend suites with zero regressions.

New mechanism lowering/mechanisms/advanced_indexing.py, ported from the
classic frontend, which is the only working reference (see below).

Reads (emit_gather): one map over the result index space, a pointer
connector on the base array with basic dimensions pinned and advanced
dimensions left whole, one element-read connector per index array, and a
tasklet __out = __arr[__inp0, ...]. Implements the full NumPy shape
algebra -- index-array broadcasting, the advanced chunk landing in place
when contiguous and at the front when not, scalar-indexed dimensions
counting as advanced, and newaxis insertion on either side of the chunk.

Writes (emit_scatter): the mirror, with the base array as an output
pointer connector and the element access in the tasklet code. An
accumulating scatter takes WCR unconditionally -- a stronger rule than
conflict.accumulation_wcr applies elsewhere, because the index array may
name the same element twice (A[[0, 0]] += 1) and no subset inspection can
rule that out.

Boolean masks (emit_masked_write): a mask makes the count of written
elements data-dependent but not their positions, so a masked write lowers
as a guarded update over the full array, needing neither a gather nor
dynamic allocation nor conflict resolution. Masked reads are rejected with
a categorized error, matching classic.

Nested occurrences (dispatch._materialize_advanced_indices): A[ind] + B
gathers into a temporary first. ANF cannot do this -- a data subscript is
a legal operand and canonicalization has no type information to tell
A[scalar_i] from A[index_array].

inference.parse_access restores ANF-hoisted literal index sequences before
parsing: A[:, (1, 2, 3)] becomes A[:, __anf0], and the memlet parser
recognizes an array index only from a literal or a registered container,
so it would otherwise emit a subset naming a value with no runtime
existence.

The deprecated schedule-tree frontend's advanced indexing turned out to be
modelling only: its boolean gather emits FrontendLibrary('boolean_mask_gather')
and tree_to_sdfg.visit_LibraryCall raises NotImplementedError. Verified end
to end. Its shape algebra was useful as a cross-check, nothing more.

The new gate executes and compares against NumPy rather than only
inspecting the tree, because the previous generation of this bug produced
trees that were callback-free and segfaulted. That caught a shared subset
object between the read and write memlets of the masked update that the
tree-only checks passed over.

Renamed from advanced_indexing_test.py: pytest cannot collect two
same-named test modules in one run, and the collision broke full-suite
collection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJHNEtgKvbFSKmBHXKZcZ5
cpa.is_dace_map_iterator tested rname(node.value) in ('dace.map', 'map')
textually, so `import dace as dc; for i in dc.map[0:H]:` was never
recognized: MarkOpaque wrapped the whole loop body as one interpreter
callback instead of a real MapScope. Widen the check (nextgen-side only,
per the plan's fallback option since preprocessing.py is shared with the
classic frontend and out of scope here) to also resolve the subscript's
root through the canonicalization context's global_vars, mirroring the
alias resolution passes._refers_to already performs for dace.tasklet/
dace.map decorators. Thread global_vars from PipelineContext down through
verify_canonical/_violations_in_statement (post-hoc contract check) and
from MarkOpaque.transform_statement (the actual decision point during
canonicalization), and from ProgramContext.globals into the lowering
rule (control_flow.lower_for).

Also widen the grammar to accept `dace.map[...] @ ScheduleType`, a BinOp
with ast.MatMult wrapping the map subscript (nested_name_accesses_test.py
::test_issue_2100's pattern) — is_dace_map_iterator previously required
an outer ast.Subscript. control_flow._lower_map_loop now splits the
iterator into its range subscript and an optional resolved schedule type
(mirroring newast.py's classic handling) and applies it to the emitted
Map; ANFTransform's For handling flattens the inner subscript's slice for
this shape too.

Before/after on the alias-bearing files (indirection_with_scalars_test.py,
indirections_test.py, nested_name_accesses_test.py) plus the full nextgen
suite: 31 failed/311 passed -> 18 failed/324 passed, 13 newly passing
(all opaque-syntax:For discrepancies), 0 regressions (identical failure
set across two runs). test_issue_2100's For statement is no longer
opaque, but the test still fails on unrelated pre-existing gaps
(dace.frontend.python.wrappers.ndarray/.fill() call dispatch, a c[:, :]
memlet-dimensionality bug) tracked separately in the plan. Coverage
report over the three files confirms opaque-syntax:For no longer appears
in the gap categories.

Note: this worktree was provisioned from main rather than frontend2-wip
(dace/frontend/python/nextgen/ didn't exist at its base commit); a prior
commit here merges frontend2-wip in to pick up the nextgen frontend
before this fix.
Root cause A (plan/eventual-sleeping-spring.md): `_lower_replacement_call`
only ever consulted `Replacements.get(name)` (free functions), so every
`A.copy()`/`A.fill(0)`/`A.astype(t)`/`A.transpose()`/`A.mean()`/`A.all()`/
`A.any()`/`A.conj()`/`p.copy()`-style bound-method call was typed (inference
already consulted `get_method_descriptor_inference`) but never lowered,
falling back to a Python callback every time.

- `tn.ReplacementCallNode` gains an optional `receiver` field (the bound
  container's name); `arguments` carries the receiver as its first entry,
  mirroring classic's `newast.py` Call-visitor convention of prepending
  `self`.
- `dispatch._lower_replacement_call` detects a bound-method call (an
  `ast.Attribute` callee whose base resolves, via `resolve_access`, to a
  whole `Name`/structure-member container -- never a `Subscript`, since a
  `_method_rep` replacement receives the full container by name and a
  subset receiver would silently operate on the whole array) and looks it
  up via `Replacements.get_method`/`get_method_descriptor_inference`
  instead. The existing `_expansion_viable` trial-run gate is reused
  unchanged, so view-producing methods (reshape/transpose/ravel/flatten's
  axis-less form) still correctly degrade to a callback rather than being
  forced through `ReplacementCallNode`.
- `tree_to_sdfg.visit_ReplacementCallNode` dispatches through
  `Replacements.get_method` when `receiver` is set.
- `verify.py` checks the receiver is a registered data argument.
- `inference._bound_descriptor_of` admits one more level (`ast.Subscript`/
  `ast.Attribute`, e.g. `A[0].sum()`) so such calls are at least typed; the
  early `return None` when no method-family inference matches now falls
  through to ufunc/free-function inference instead of hard-aborting the
  call.

Verified via before/after runs of tests/numpy/ndarray_attributes_methods_test.py
(methods only; the attribute tests -- .T/.real/.imag/.flat -- are a separate,
untouched gap) and tests/python_frontend/nextgen/ under the default (strict)
`stree_callback_check=error` config: nextgen suite stays green at 299 passed;
the numpy gate file goes from 22 to 12 failing (10 newly passing: copy,
astype, transpose x3, conj, mean, all, any, argmax_dim), with the after set a
strict subset of the before set (zero regressions). Confirmed via the
coverage report: unknown-call:<method> program count 18 -> 8, discrepant
programs 22 -> 12.

Remaining unfixed, all pre-existing/out-of-scope, not method-plumbing gaps:
- .T/.real/.imag/.flat: the attribute family (root cause A's separate,
  unwired `_attr_rep` keyspace) -- explicitly out of scope per task.
- A.fill(...): a bare (unassigned) call statement lowers with `target=None`,
  which `_lower_registry_call` rejects for ALL replacement families (not
  method-specific) as "unused result, preserve semantics via callback".
- A.reshape()/.flatten()/.ravel()/.argmax()/.argmin() (axis-less): reshape
  is intentionally left to `_lower_reshape_call`'s view path; flatten/ravel
  and the axis-less argmax/argmin internally call the array-manipulation
  `flat()` helper, which records a view binding for contiguous arrays --
  the SAME `_expansion_viable` gate this change reuses (not modified)
  correctly rejects these and falls back to a callback, as intended.
…/.imag/.flat)

Inference (semantics/inference.py::_infer_attribute) and lowering
(lowering/dispatch.py) previously never consulted the replacement
registry's ATTRIBUTE family (Replacements.get_attribute /
get_attribute_descriptor_inference), so A.T/A.real/A.imag/A.flat always
fell back to a Python callback, triggering the schedule-tree parallel
lowering discrepancy check on ordinary classic-frontend tests (classic
supports these attributes natively).

- inference.py: typed a small allowlisted set (SUPPORTED_DATA_ATTRIBUTES
  in nextgen/common.py) through get_attribute_descriptor_inference,
  scoped tightly to what lowering actually covers -- an attribute typed
  as 'data' with no lowering path would otherwise reach the generic
  elementwise mechanism and embed the unresolved attribute literally in
  generated tasklet code instead of degrading to a callback.
- dispatch.py: resolve_attribute_data materializes the attribute into a
  fresh container, mirroring the _lower_reshape_call precedent. A.flat on
  a contiguous array binds a ViewNode directly (NumPy's flatiter aliases
  the source, and _expansion_viable correctly rejects view-producing
  deferred calls). Everything else (.T/.real/.imag, and non-contiguous
  .flat) defers to a ReplacementCallNode running the classic ATTRIBUTE
  implementation, after a scratch-SDFG viability trial
  (_run_attribute_trial, mirroring _expansion_viable).
- op_repository.py/tree_to_sdfg.py: ReplacementCallNode.qualname only
  resolved through the free-function keyspace; added a small
  classname.@attr qualname convention (attribute_qualname/
  ATTRIBUTE_QUALNAME_MARKER) so visit_ReplacementCallNode can also
  resolve ATTRIBUTE-family entries.
- assign.py/returns.py: wired the new dedicated entry points at the
  points that matter for the gate tests -- bare `b = A.<attr>`,
  `return A.<attr>`, and `A.flat[idx] = ...` (rewrites the subscript
  base to the materialized view via rewrite_flat_subscript_base).

Fixed (previously failing only via the discrepancy check):
tests/numpy/ndarray_attributes_methods_test.py::test_T/test_real/
test_imag/test_lhs_flat, tests/numpy/advanced_indexing_test.py::
test_flat/test_flat_noncontiguous. Full nextgen suite stays green
(299 passed) plus tests/schedule_tree/ (84 passed).

Known remaining gap (deliberately out of scope): a nested subscript
assignment target (A.flat[10:15][0:2] += 5, advanced_indexing_test.py::
test_aug_implicit_attribute) and an attribute base that isn't a plain
dataref ((a @ b).T, attribute_test.py::test_attribute_of_expr) both fail
at canonicalization, before semantic binding/lowering ever runs
(_flatten_target/_as_dataref in canonical/passes.py only hoist
Name-rooted attribute chains) -- a canonicalization-level fix, not a
registry-wiring one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mlet_parser

A[:, (1, 2, 3)] and A[(1, 2, 3, 4, 5, 7)] as one dimension among several were
misparsed: _fill_missing_slices had no case for a raw ast.Tuple literal
surviving as a single dimension slot, so it fell into the scalar catch-all,
got eval()'d into a plain Python tuple, and was then reinterpreted by
_ndslice_to_subset as a (start, stop, step) range -- silently collapsing
"columns 1,2,3" into "column 1" for 3-tuples, or raising "Expected 3-tuple or
4-tuple" for longer ones.

Python's grammar makes "A[(1,2,3)]" and "A[1,2,3]" identical ASTs, so a bare
tuple that is the *entire* subscript is already unpacked into separate
per-dimension entries before _fill_missing_slices ever runs (see
astutils.subscript_to_ast_slice). The only way an ast.Tuple node can survive
as a single `dim` is if the source had an explicit nested tuple among
multiple indices/slices ("A[:, (1,2,3)]") or a trailing comma
("A[(1,2,3),]") -- both of which are unambiguously advanced (array-valued)
indexing in NumPy, never slice-range sugar. Classic's newast.py already
reached the right answer via a private preprocessing trick
(_parse_subscript_slice converts a nested literal tuple to a Python list
before calling ParseMemlet); nextgen's inference.py calls ParseMemlet
directly and had no equivalent, so it hit the shared bug. Fixing it in
_fill_missing_slices itself closes the gap for both frontends without
relying on caller-side preprocessing.

Fixes tests/numpy/advanced_indexing_test.py::test_multidim_tuple_index[False]
and ::test_multidim_tuple_index_longer. Full advanced_indexing_test.py goes
from 6 failed/29 passed to 4 failed/31 passed; the remaining 4 (test_flat,
test_flat_noncontiguous, test_aug_implicit, test_aug_implicit_attribute) are
pre-existing, unrelated gaps (A.flat attribute support and chained-subscript
augmented assignment). No regressions in tests/python_frontend/nextgen/ (299
passed before and after) or in the shared-parser consumer set (memlet_parser
direct consumers' test files: memlet_parser_test.py, slice_test.py,
negative_indices_test.py, slice_subset_aliasing_test.py, python_slice_test.py,
augmented_assignment_to_slice_test.py, subscript_regression_test.py --
identical failing-test-name sets before/after).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_flatten had no ast.Dict branch, so any call with a dict-literal argument
(e.g. a plain-Python callback call callback({'a': x})) raised
_ShortCircuitHazard and the entire enclosing statement was left
non-canonical, later swallowed whole into one opaque-syntax callback.

Add a Dict branch mirroring the existing List/Tuple literal handling:
flatten keys/values to atom level and hoist the literal to a temporary
(dict-unpacking **rest entries, which have no atom form, still raise the
hazard and stay opaque). Deliberately does NOT touch cpa.is_flat, so a
bare dict assignment (d = {...}) is still marked opaque as before -- only
a dict flowing through a call argument position now separates into a
small opaque dict-construction statement plus a properly canonicalized
call, which resolves through the normal detected-callback path instead of
one coarse opaque-syntax:Assign blob.

Tests: tests/python_frontend/nextgen/callback_category_test.py gains
test_dict_literal_call_argument_category, asserting the call now carries
detected-callback provenance. tests/python_frontend/callback_autodetect_test.py
(the existing dict-literal-argument blast radius, incl. array-valued dict
entries and dict-typed kwargs) is unaffected -- identical failure set
before/after (2 pre-existing unrelated failures: test_gpu_callback, no GPU
in this env; test_two_callbacks, a pre-existing batching discrepancy).
Full nextgen suite: 299 -> 300 passed (the one new test), zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
inference.call_arguments and dispatch._replacement_arguments both refused
a static Python sequence (list/tuple) whose elements resolved to data
containers -- exactly the (A, B) in numpy.concatenate((A, B)), and
likewise for stack/vstack/hstack/dstack/select. The rejection comment
("Data elements cannot be passed by value") was written for scalars, not
for name lists: the classic frontend and the registry replacements
(_concat's `arrays: Tuple[Any]`, checked via `arrays[i] not in
sdfg.arrays`) already expect exactly this shape -- a tuple/list of
container name strings.

Both sites now resolve each element the same way a top-level argument is
resolved (reusing the existing per-argument `convert` closure), so a
data-container element is recorded by name into input_descs/data_arguments
instead of failing the whole conversion.

Tests: tests/python_frontend/nextgen/replacement_call_test.py gains
concatenate/hstack structure+execution tests. tests/numpy/concat_test.py
(the existing blast radius): 12 failed/1 passed -> 4 failed/9 passed.
The 4 remaining failures are unrelated, already-documented gaps verified
by direct probing of the registry replacements: numpy.stack and
numpy.vstack(1-D) record internal ArrayView bindings that the deferred
replacement-expansion trial deliberately rejects (view state cannot be
deferred); numpy.concatenate(..., out=c) is a bare statement call, which
hits the separate `target is None` short-circuit. None of these three are
in this item's scope. Full nextgen suite: 300 -> 303 passed, zero
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…registry

infer_call only recognized a bare `numpy.add(...)` call (isinstance(callee,
numpy.ufunc)) -- an ast.Attribute call like `numpy.add.reduce(...)` resolves
its callee to a bound method object, not the ufunc itself, so it was never
typed at all. get_ufunc_descriptor_inference()/get_ufunc() were also always
called with no method argument, so only the base 'ufunc' registry key ever
resolved -- the registered reduce/accumulate/outer inference entries were
dead code for nextgen.

- InferenceService.resolve_ufunc_call (inference.py), shared by inference and
  dispatch: recognizes a direct ufunc call OR an ast.Attribute call whose
  attr is reduce/accumulate/outer and whose *base* resolves to a numpy.ufunc,
  returning (ufunc, method). infer_call now routes through
  get_ufunc_descriptor_inference(method) instead of the no-argument form.
- dispatch.py: reduce/accumulate/outer calls need real reduction/scan/
  broadcast dataflow that the lightweight single-tasklet elementwise
  mechanism cannot express, so they defer to the actual registry
  implementation (dace.frontend.python.replacements.ufunc) through the same
  deferred ReplacementCallNode expansion mechanism already used for other
  registry replacements (reused, not duplicated: _replacement_arguments for
  argument resolution, a shared _replacement_trial_scratch helper factored
  out of _expansion_viable for the build-time viability trial).
- treenodes.ReplacementCallNode gains ufunc_name/ufunc_method fields;
  tree_to_sdfg.visit_ReplacementCallNode dispatches through
  Replacements.get_ufunc(method) with the ufunc calling convention
  (visitor, ast_node, sdfg, state, ufunc_name, args, kwargs) when set, and
  normalizes its List[UfuncOutput] (single-element) return to the same
  bare-string form the generic replacement convention already handles.
- Direct (non-method) ufunc calls with keywords are UNCHANGED here (still
  rejected) -- that relaxation is the next commit (item 2); this commit
  only wires up the reduce/accumulate/outer *methods*, which need some
  keyword support (axis=/keepdims=/initial=) to do anything at all.

BUG FOUND AND FIXED while wiring this up: _infer_ufunc_reduce_descriptor/
_infer_ufunc_accumulate_descriptor (dace/frontend/python/replacements/ufunc.py,
pre-existing, previously dead code for nextgen and untested by any existing
test) computed NumPy's actual integer-promotion dtype (e.g. int32 -> int64
for numpy.add.reduce), but the paired *implementation*
(implement_ufunc_reduce/implement_ufunc_accumulate) always uses the input
dtype verbatim, with no such promotion. Once reduce/accumulate became
reachable, this mismatch pre-allocated the target container too wide; the
raw (uncast) memlet copy between the narrower actual output and the wider
declared target then read garbage from the extra bytes -- a silent
miscompilation, not merely a precision difference, caught only by executing
the nextgen-compiled SDFG directly and comparing against NumPy (the existing
discrepancy-check gate cannot see it, being blind to numeric correctness).
Fixed by dropping the promotion in the two inference functions to match
what execution actually produces (removed the now-dead _infer_reduce_dtype
helper). Confirmed no other call sites of get_ufunc_descriptor_inference
exercise these two functions with any existing test coverage
(dace/frontend/python/schedule_tree_frontend.py and
.../schedule_tree/type_inference.py also consult them, but no test in
tests/ reaches reduce/accumulate/outer through that alternate frontend).

Tests: tests/python_frontend/nextgen/replacement_call_test.py gains
reduce/accumulate/outer structure+execution tests (accumulate uses a 2-D
array; 1-D degenerates implement_ufunc_accumulate's map to zero parameters,
a separate pre-existing gap in the shared registry implementation, not a
nextgen routing issue -- matches ufunc_support_test.py's own accumulate
coverage, which is also never 1-D).

Regression gates: tests/numpy/ufunc_support_test.py 30 failed/7 passed ->
11 failed/26 passed (the 11 are all keyword-argument forms, item 2's scope,
unchanged here by design). tests/numpy/ufunc_nested_call_test.py (reduce/
accumulate via nested calls) 2 failed -> 0 failed.
tests/numpy/ufunc_replicated_input.py: 1 pre-existing failure unchanged
(unrelated target-is-None bare-statement gap, verified via backup/restore
diff of failing-test-name sets). Full nextgen suite: 303 -> 307 passed (4
new tests), zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dispatch.py rejected ANY ufunc call with a keyword argument outright
(out=/where=/axis=/keepdims=/initial=), even though the registry ufunc
implementation (dace.frontend.python.replacements.ufunc.implement_ufunc)
already supports all of them -- the rejection existed only because the
lightweight local elementwise mechanism (a single tasklet expression) has
no way to honor a keyword at all, not because the registry can't.

The previous commit already built the machinery a keyworded ufunc call
needs (deferred ReplacementCallNode expansion through the ufunc registry
keyspace, for reduce/accumulate/outer). This commit just widens the
routing condition so a direct call also takes that path whenever it carries
a keyword, instead of returning False outright: `if ufunc_method is None
and not call.keywords` picks the fast no-keyword elementwise path;
everything else (any keyword, or a reduce/accumulate/outer method) defers
to _lower_ufunc_replacement_call, which already rejects genuinely
unsupported keywords via _SUPPORTED_UFUNC_KEYWORDS and falls back to a
callback on any other viability failure.

Tests: tests/python_frontend/nextgen/replacement_call_test.py gains
test_ufunc_keyword_argument_execution (where=) and
test_ufunc_unsupported_keyword_falls_back (an unrecognized keyword still
degrades to a callback rather than being silently dropped).

Regression gate: tests/numpy/ufunc_support_test.py 11 failed/26 passed
(previous commit's state) -> 5 failed/32 passed. The 5 remaining failures
are two unrelated, already-documented gaps: test_ufunc_add_out/out2/out3
(np.add(A, B, out=C) as a bare statement, no assignment -- the separate
`target is None` short-circuit) and test_ufunc_add_reduce_simple2/simple3
(np.add.reduce on a scalar constant, not an array -- a pre-existing
limitation of _infer_ufunc_reduce_descriptor's operand resolution, not a
keyword-argument issue). Full nextgen suite: 307 -> 309 passed (2 new
tests), zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The result's element count is unknown until the mask is read at
runtime, unlike every other advanced-indexing read this frontend
supports. Resolves this with symbolic.UndefinedSymbol as the
deferred-symbol placeholder: inference answers with an honestly
deferred shape (replacing a previously silently-wrong one, which
treated the mask as an ordinary same-shape index array), and a new
fast-tracked lowering path (rules/assign.py::_lower_boolean_gather_assign,
tried before generic inference runs) mints a real SDFG symbol from a
runtime-computed element count and performs typing and lowering
together for the bare top-level assignment form. Nested or combined
boolean-mask uses are untouched and still fall back to a callback.

Two configurable strategies (frontend.boolean_index_strategy):
'view' (default) does one pass into an upper-bound backing buffer,
exposed as a view over the first M elements; 'exact' does two passes
(a count-only reduction, then a compaction sized exactly to the
count), never over-allocating. Both proven correct standalone first
in tests/sdfg/deferred_symbol_boolean_filter_test.py before any
frontend code was written.

Building this surfaced two narrow, pre-existing DaCe bugs, fixed here
since the feature depends on them:
- dtypes.pointer never set self.typename (its __init__ doesn't call
  typeclass.__init__), so to_string()'s documented fallback raised a
  raw AttributeError instead of degrading gracefully -- crashed
  DeadDataflowElimination the first time it needed to stringify a
  Stream-typed connector while removing genuinely dead code.
- tree_to_sdfg.py::visit_CopyNode only ever cached read access nodes,
  never writes, so a write immediately followed by a same-state read
  of the same name created a disconnected access node instead of
  reusing the one just written. Invisible to execution (per-state
  sequencing still orders things correctly) but made a live
  computation look like an unread dead end to DeadDataflowElimination's
  node-local liveness analysis, which then removed it. Fixed by
  mirroring the write-node caching idiom _connect_scope_exit already
  uses for the identical problem.

New tests: tests/python_frontend/nextgen/boolean_gather_test.py (18),
tests/sdfg/deferred_symbol_boolean_filter_test.py (2). Regression:
nextgen suite 327 passed (+18), tests/sdfg/ 214 passed (4 pre-existing
failures, confirmed identical on a temporarily-restored clean-HEAD
version of the two changed core files), tests/passes/ 314 passed (8
pre-existing, same verification), tests/schedule_tree/ +
tests/python_frontend/schedule_tree/ 420 passed / 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ndices, bounded subscript nesting

Step 10: new lower_indirect_write mirrors the existing read-side indirection
mechanism for write targets (hist[bin] += 1, scatter), wired into both plain
and accumulating (WCR) subscript assignment.

Step 11: guard resolve_access in the subscript-assign copy probe so a
scalar-container index inside a dataflow scope falls through to the
elementwise path instead of escaping to the top-level callback fallback.

Step 12: bound the CPA grammar's subscript-nesting depth (previously the only
unbounded recursion in an otherwise depth-1 grammar), restoring canonicalization
totality; ANF now hoists deeper nesting into the scalar-container-index shape
step 11 handles.

Also fixes three bugs surfaced while extending coverage to a newaxis/Ellipsis/
advanced-indexing stress test: nested-SDFG connector cloning in
visit_MapScope, basic-indexing newaxis/Ellipsis shape and stride squeeze
(kept_dimensions), and advanced-indexing's AST-based (not range-based)
kept-dimension computation. A fourth, deeper, pre-existing bug in
_place_advanced_chunk (newaxis + multiple kept non-advanced dims) was found,
confirmed independent of this change, and documented rather than fixed
(see plan/plan.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_reshape_operands checked the <x>.reshape(...) attribute-method branch
before the numpy.reshape(...) free-function branch. Since
numpy.reshape(A, s) is itself an Attribute call ending in .reshape
(Attribute(value=Name('numpy'), attr='reshape')), the method branch
fired first and returned (numpy-module, (A, s)) as (base, shape) --
treating the module as the reshaped array and misrouting every
numpy.reshape(A, s) call to a callback fallback. Check the
numpy.reshape qualname first so the free-function branch is reachable.

Also updates test_nonviable_replacement_falls_back, whose np.reshape(A,
(2, 3)) case now resolves through the fixed dedicated view path instead
of falling back; it's changed to a genuinely non-viable (element-count
mismatch) reshape that still exercises the deferred-replacement
viability rejection it was written to test. Adds
test_reshape_view_execution, executing both call forms and comparing
against NumPy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_lower_registry_call unconditionally required target is not None,
so a bare-statement call like A.fill(0) could never lower through the
registry path, even though A.fill's registered descriptor inference
already signals a zero-output/pure-side-effect result (`()`).

Two gaps stacked here, both fixed:

- _registry_inference conflated "registry function returned () (a
  confirmed zero-output signature)" with "no registry entry matched"
  -- both produced Optional[Inferred] = None. Inferred now has a
  'none' kind for the former, via a new is_none_output property, so
  callers can tell "typed call with nothing to assign" apart from
  "untyped call, fall back to a callback".

- _lower_replacement_call only ever resolved the free-function
  keyspace (Replacements.get(qualname)), so a method call like
  A.fill(0) (registered under (class, method), not a qualname) was
  unreachable regardless of target. It now falls back to the method
  keyspace (Replacements.get_method) specifically for the bare
  statement/zero-output case, passing the receiver as the first
  argument (matching classic's convention). tree_to_sdfg's
  visit_ReplacementCallNode gains the matching (class, method) lookup
  fallback so the qualname it stores ('Array.fill') resolves the same
  way at expansion time, and skips the result-copy when a mutating
  replacement returns its own target container unchanged (the common
  case for a receiver used as a bare statement's stand-in target).

A.fill(0) and dace.comm.Bcast(A) (a zero-output free function) now
lower without a callback; a discarded, ordinarily data-valued call
result (e.g. bare np.sum(A, axis=...)) still falls back, unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Single shared normalize_qualname() helper collapses a callee's real defining
module path to the replacement-registry's shorter key (e.g.
dace.frontend.python.wrappers.ndarray -> dace.ndarray), applied at the one
source of truth (resolve_callee) instead of ad-hoc two-name retries
duplicated across dispatch.py's creation and replacement-lookup call sites.

Fixes previously-broken reachable-via-non-literal-spelling forms: aliased
module imports (wrappers.ndarray via `import ... as wrappers`), `from ...
import X as Y`, and stored callee references (`_nd = dace.ndarray; _nd(...)`)
— the direct dace.X spelling already worked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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