Skip to content

[Type] ndarray typing 3: Add cook_dtype calls at C++ boundaries#413

Draft
hughperkins wants to merge 12 commits intohp/typing-t4-2-ndarray-subscriptfrom
hp/typing-t4-3-cook-dtype
Draft

[Type] ndarray typing 3: Add cook_dtype calls at C++ boundaries#413
hughperkins wants to merge 12 commits intohp/typing-t4-2-ndarray-subscriptfrom
hp/typing-t4-3-cook-dtype

Conversation

@hughperkins
Copy link
Copy Markdown
Collaborator

@hughperkins hughperkins commented Mar 12, 2026

…refactor)

Add cook_dtype() calls at all points where dtype values are passed to C++ code. Make PyQuadrants.default_fp/ip/up into properties that always store DataTypeCxx. Rename shadowed dtype var in create_field_member. All changes are behavioral no-ops with current code, preparing for a future refactor of primitive dtypes into Python classes.

Issue: #

Brief Summary

Summary

Pushes cook_dtype() (the conversion from Python-side dtype objects to the C++ DataTypeCxx representation) closer to its consumers, so that:

  1. Default integer/float/unsigned types (default_fp, default_ip, default_up) on PyQuadrants are stored already-cooked. Direct attribute access is preserved via
    @property getter/setter pairs that auto-cook on assignment, so user code like qd.init(default_fp=qd.f64) keeps working.
  2. C++-bound APIs (sparse matrix/solver constructors, expr_alloca_shared_array, constant-expression makers, etc.) receive cooked dtypes at the call site rather than relying
    on the C++ binding to coerce — making the Python/C++ boundary explicit and catching un-cooked types earlier.
  3. As a side effect, fixes a real shadowing bug in create_field_member where the local checkbit dtype was being assigned to the function parameter dtype, mutating the
    caller-visible variable. Renamed to checkbit_dtype and added a debug-mode regression test.
    This PR is stacked on top of hp/typing-t4-2-ndarray-subscript (which itself stacks hp/typing-t4-1-eval-str); both are merged in.

Changes

cook_dtype boundary work

  • python/quadrants/lang/impl.pyPyQuadrants.default_fp / default_ip / default_up converted from raw attributes into properties with auto-cooking setters; backing
    fields (_default_fp etc.) are pre-cooked in __init__. expr_init_shared_array now cooks element_type before passing to the AST builder.
  • python/quadrants/lang/expr.pymake_constant_expr cooks dtype once up-front and cooks the default_fp / default_ip / u1 fallbacks. Adds an inline comment
    explaining the strategy (cook normalised input once, then cook only the runtime defaults in each branch).
  • python/quadrants/lang/matrix.pymake_matrix empty-vector path now uses cook_dtype(primitive_types.i32) instead of the raw Python type.
  • python/quadrants/linalg/sparse_matrix.py, sparse_solver.pySparseMatrix, SparseMatrixBuilder, and SparseSolver constructors cook dtype once and pass
    the cooked value to the C++ factories.

Bug fix

  • python/quadrants/lang/impl.py — In create_field_member's debug-mode branch, the local variable holding the checkbit dtype was named dtype, shadowing the function
    parameter. Renamed to checkbit_dtype. Added documentation comment explaining the rename.

Tests

  • tests/python/test_ad_basics_fwd.pytest_dual_field_dtype_preserved_in_debug_mode: regression test for the checkbit shadowing bug, gated on data64 since it uses
    qd.f64.

Merged-in work

  • All commits from hp/typing-t4-1-eval-str (get_func_signature helper, eval_str=True support for stringified annotations) and hp/typing-t4-2-ndarray-subscript
    (NDArray[i32] single-arg subscript fix) come along via merge.

Good points

  • Real bug fix. The dtype shadowing in create_field_member was silently mutating the wrong variable — caller-visible only in subtle debug-mode AD scenarios, which is
    exactly why it had gone unnoticed. Now caught by an explicit regression test.
  • Pushes type coercion to the boundary. cook_dtype calls being scattered across deep call paths makes it hard to tell where Python-side dtype objects can vs. can't
    appear. Cooking at the C++ boundary means downstream code (and types — see the DataTypeCxx annotations on the new properties) can assume cooked types from then on.
  • Backwards-compatible API surface. Converting default_fp / default_ip / default_up to properties preserves all existing reads and assignments
    (get_runtime().default_fp, qd.init(default_fp=qd.f64), etc.) — the cooking happens transparently.
  • Eliminates duplicate cook_dtype calls. A follow-up commit (215544f15) removes redundant cooks in make_constant_expr now that the up-front normalisation handles
    them.
  • Test added for the bug. Not just the refactor — the actual semantics-changing fix has coverage.

Bad / weak points (worth reviewer attention)

  • Property conversion is a subtle public-API change. default_fp/ip/up are documented attributes. Code that does runtime.default_fp = some_already_cooked_value is
    fine (cooking is idempotent), but anything that introspects via vars(runtime) or runtime.__dict__["default_fp"] will now get nothing / find _default_fp instead. Risk is
    low but not zero.
  • Type annotation says DataTypeCxx, not Any. The new properties annotate the return type as DataTypeCxx but the setter accepts Any. That's correct for the
    cook-on-write pattern, but the asymmetric annotation may confuse type checkers downstream.
  • Inconsistent cook-site coverage. Sparse matrix/solver and a few other call sites get the boundary treatment, but a grep would likely turn up other C++ bindings still
    receiving raw Python dtypes. This PR doesn't claim to be exhaustive, but reviewers should weigh whether to expand or leave a TODO.
  • Test gating. test_dual_field_dtype_preserved_in_debug_mode requires qd.extension.data64 (added after a CI failure on backends without f64 support — see commit
    dcc12164f). This means the regression won't be exercised on f32-only backends; if the bug ever recurs in a different shape there, the test won't catch it.
    [fixed]
  • Stacked branch. Reviewing against main shows the union of t4-1 + t4-2 + t4-3 changes (~150 lines). Reviewers should look at this branch after t4-2 lands, or use git diff hp/typing-t4-2-ndarray-subscript...HEAD to see only the t4-3-specific delta.
  • Noisy commit history. 12 logical commits + 3 merge commits since branching from main. Worth squash/rebase before merge.

copilot:summary

Walkthrough

copilot:walkthrough

@hughperkins hughperkins force-pushed the hp/typing-t4-3-cook-dtype branch from 89f34d8 to 72a9636 Compare March 12, 2026 03:49
@hughperkins hughperkins force-pushed the hp/typing-t4-2-ndarray-subscript branch from 219619c to 5762be5 Compare March 12, 2026 03:49
@hughperkins
Copy link
Copy Markdown
Collaborator Author

Opus 4.6 review:

PR Review: hp/typing-t4-3-cook-dtype

Branch: hp/typing-t4-3-cook-dtype (parent: hp/typing-t4-2-ndarray-subscript)

Summary

This PR adds cook_dtype() calls at C++ boundaries (expr, impl, matrix, sparse_matrix, sparse_solver) and refactors default_fp/default_ip/default_up in PyQuadrants into properties with backing _default_* attributes. It is a preparatory refactor for a future PR that will convert dtypes to Python classes; with current DataTypeCxx instances, cook_dtype() is effectively a no-op (identity for DataTypeCxx subclasses).

Issues Found

1. Bug fix (positive finding): create_field_member variable shadowing

The rename from dtype to checkbit_dtype in the adjoint checkbit block fixes a real bug. In the original code:

if prog.config().debug:
    dtype = u8
    if prog.config().arch == _qd_core.vulkan:
        dtype = i32
    x_grad_checkbit.ptr = _qd_core.expr_field(..., cook_dtype(dtype))
# ...
x_dual.ptr = _qd_core.expr_field(x_dual.ptr, dtype)  # BUG: dtype was overwritten to u8/i32!

The inner block overwrote the dtype parameter, so x_dual was incorrectly created with u8 or i32 instead of the field’s actual dtype when prog.config().debug was True. The rename correctly isolates the checkbit dtype.

2. Minor: Redundant cook_dtype in make_constant_expr

In make_constant_expr, when dtype is not None, dtype is already cooked at the top. For float/int branches, constant_dtype is then cooked again. Since cook_dtype is idempotent for DataTypeCxx, this is harmless but redundant. Consider removing the second cook_dtype(constant_dtype) for the dtype is not None path, or leave as-is for consistency and future-proofing.

Correctness

  • No-op behavior: cook_dtype() returns dtype unchanged when type(dtype) is a subclass of _qd_core.DataTypeCxx. Current primitives (f32, i32, u1, etc.) are such instances, so behavior is unchanged.
  • Property refactor: The default_fp/default_ip/default_up properties correctly preserve read/write behavior. The setters apply cook_dtype(value), so future Python dtype classes can be normalized when assigned.
  • set_default_ip: Uses self.default_ip = ip and self.default_up = u32 if ip == i32 else u64, which works with current DataTypeCxx values. A future Python dtype class would need == to compare correctly with i32/i64 for this logic to remain correct.

Edge Cases

  • cook_dtype(None): Would raise ValueError. Call sites guard with if dtype is not None where appropriate (e.g. make_constant_expr).
  • qd.default_fp = float: cook_dtype(float) returns impl.get_runtime().default_fp, i.e. the current value. Assigning float is effectively a no-op and may be surprising; acceptable for a preparatory refactor.
  • SharedArray / expr_init_shared_array: element_type is now cooked before passing to C++. Correct and consistent with other boundaries.

Style

  • Consistent use of cook_dtype() at C++ boundaries.
  • Clear variable naming (checkbit_dtype, dtype_cxx in sparse modules).
  • Imports and formatting are consistent with the rest of the codebase.

Suggestions for Improvement

  1. Document the bug fix: Add a brief comment near the checkbit_dtype rename noting that it fixes the previous shadowing of dtype, which caused x_dual to get the wrong dtype in debug mode.
  2. Optional: Add a test that exercises create_field_member with debug=True and needs_dual=True to lock in the fix and guard against regressions.
  3. Future PR: When introducing Python dtype classes, ensure set_default_ip’s ip == i32 comparison works for both DataTypeCxx and the new Python dtype wrappers (e.g. via cook_dtype(ip) == cook_dtype(i32) or an explicit equality method).

Verdict

The PR is correct and behaves as a no-op for current dtypes. It also fixes a real bug in create_field_member when debug mode is enabled. Tests pass. Ready to merge with optional minor suggestions above.

@hughperkins hughperkins force-pushed the hp/typing-t4-3-cook-dtype branch from 72a9636 to 9b5e1a8 Compare March 12, 2026 04:33
@hughperkins hughperkins force-pushed the hp/typing-t4-2-ndarray-subscript branch from 5762be5 to a402c99 Compare March 12, 2026 04:33
@hughperkins hughperkins force-pushed the hp/typing-t4-3-cook-dtype branch from 9b5e1a8 to 1adb863 Compare March 12, 2026 04:34
…refactor)

Add cook_dtype() calls at all points where dtype values are passed to C++ code.
Make PyQuadrants.default_fp/ip/up into properties that always store DataTypeCxx.
Rename shadowed dtype var in create_field_member. All changes are behavioral
no-ops with current code, preparing for a future refactor of primitive dtypes
into Python classes.
@hughperkins hughperkins force-pushed the hp/typing-t4-3-cook-dtype branch from 1adb863 to 71dcd62 Compare March 12, 2026 04:37
The previous code overwrote the outer `dtype` parameter in the debug
checkbit block, causing x_dual to be created with the wrong dtype.
Verify that forward-mode AD produces correct results when debug=True,
guarding against the previous bug where the checkbit block's local
dtype variable shadowed the outer dtype parameter.
The regression test covers this; no need for a code comment.
When dtype is provided it is already cooked at the top of the function,
so the per-branch cook_dtype(constant_dtype) was a no-op. Now only the
fallback default_fp/default_ip paths are cooked.
@hughperkins hughperkins marked this pull request as ready for review March 12, 2026 04:55
@hughperkins hughperkins marked this pull request as draft March 12, 2026 05:00
Fixes pylint C0415 (import-outside-toplevel) on `inspect` in
`exception.py` and `get_func_signature` in `_kernel_impl_dataclass.py`.

Made-with: Cursor
The Vulkan/Metal backends on macOS lack f64 support and crash when
running this test, which uses qd.f64 fields. Add require=qd.extension.data64
to skip on backends without double-precision support.

Made-with: Cursor
…type

Made-with: Cursor

# Conflicts:
#	python/quadrants/lang/_perf_dispatch.py
…ook-dtype

# Conflicts:
#	python/quadrants/lang/exception.py
The previous version used qd.f64 and was gated on qd.extension.data64,
so it was skipped on backends without f64 support (Vulkan/Metal on
macOS) -- meaning the regression wasn't exercised everywhere.

Rewrite with f32 (universally supported) and pick values whose dual is
a non-integer exactly representable in f32 (x=1.25, dual=2x=2.5). Under
the original bug the dual field was created as u8 (or i32 on Vulkan),
which would truncate 2.5 to 2 and fail the assertion -- so the test
still catches the regression on every backend.
Run the regression test on both f32 and f64, using
test_utils.skip_if_f64_unsupported to skip f64 only on backends that
don't reliably support it (Metal, Vulkan). f32 still runs everywhere
so the bug is exercised on every backend; f64 adds extra coverage
where supported.
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