fix(pybind): collapse operator overloads per Python type; add __ne__/__bool__; fix numpy dtype loss - #991
Conversation
…_bool__ (#928/#916/#692) Every Tensor/UniTensor arithmetic dunder (__add__, __iadd__, ...) was bound once per C++ scalar type (12 native cytnx_* types) and once per numpy scalar type (11 more), even though most collapse to the same Python-visible signature (~5600 stub overload-cannot-match errors once stubs exist, #928). Collapsed each operator to the keep-set: Tensor/UniTensor, numpy_scalar<float>, numpy_scalar<complex64>, numpy_scalar<int64/uint64/int32/uint32/int16/uint16/ bool>, py::int_ (dispatch_pyint), cytnx_double, cytnx_complex128, Scalar. Applied to __add__/__radd__/__iadd__, __sub__/__rsub__/__isub__, __mul__/__rmul__/__imul__, __truediv__/__rtruediv__/__itruediv__, and, as a consistency extension beyond the plan's literal inventory (these share the identical per-dtype shape and __ifloordiv__ was already in scope), __floordiv__/__rfloordiv__/__ifloordiv__ and __mod__/__rmod__ (Tensor only; no __imod__ exists). UniTensor's __mod__/__rmod__ were dead code (wrapped in a block comment) and were left untouched. Counts per operator: 24 -> 14 (forward/in-place, Tensor operand present) or 23 -> 13 (reverse, no Tensor operand); __eq__ 23 -> 14 (gained the previously-commented-out Scalar overload for consistency with the rest of the keep-set). dispatch_pyint (single-arg variant of the helper introduced for ExpH/ExpM in PR #915) and the canonical KEEP-SET ORDERING rationale live in a new shared header pybind/pyint_dispatch.hpp, included by tensor_py.cpp and unitensor_py.cpp; the per-operator-group comment blocks are one-line pointers to it. linalg_py.cpp's two-arg copy should fold into this header after #915 merges. Root cause for a real numpy integer-dtype-preservation bug (not just missing coverage): pybind11's plain arithmetic type_caster accepts any object satisfying __index__ even in the no-convert pass (pybind11 3.x cast.h), and every numpy integer scalar implements __index__, so a fixed-width cytnx_intNN overload registered before the matching numpy_scalar<intNN> overload wins first and silently collapses e.g. np.int32/np.uint32/np.int16/np.uint16/ np.uint64 to Int64. Fixed for every operator group by registering numpy scalars before py::int_/cytnx_double/cytnx_complex128, per the plan's overload-order discipline (full rationale in pyint_dispatch.hpp). Tensor's __setitem__ had a worse case of the same bug: it had ZERO numpy_scalar overloads, and np.float32 matched the FIRST-registered cytnx_complex128 overload (Python's complex() falls back to __float__), so `t[0] = np.float32(x)` on a real-dtype Tensor raised a hard RuntimeError ("cannot assign complex element to real container") instead of merely mis-preserving dtype. Fixed by adding the numpy-scalar keep-set ahead of double/complex128. Recon during this task found UniTensor's own __setitem__/set_elem (both the vector-locator and single-int-locator/ diagonal-UniTensor overload families) had the identical bug, so -- since "UniTensor's existing coverage" the plan asked Tensor to mirror was itself broken -- UniTensor's __setitem__/set_elem got the same fix rather than propagating the bug forward; this is a modest scope extension beyond the plan's literal Tensor-only instruction. __ne__ was unbound: cytnx has no elementwise operator!=/Neq/logical-not kernel (checked include/linalg.hpp and src/linalg/), so Python's default `not (self == rhs)` collapsed __eq__'s elementwise Bool Tensor result through __bool__/__len__ to a single bare `False` for any non-empty operand -- a silently wrong scalar instead of an elementwise comparison. Implemented __ne__ by composing two EXISTING kernels instead of adding a new one: `(1 - self.Cpr(rhs)).astype(Type.Bool)` (Cpr is the same call __eq__ uses; arithmetic negation on the Bool result promotes to Int64 0/1, and astype casts back to a proper Bool Tensor). The keep-set mirrors __eq__ exactly, including the trailing cytnx::Scalar overload -- without it, `t != Scalar(x)` only resolved through lossy Scalar.__float__/__complex__ implicit-conversion fallbacks that printed cytnx error noise to stderr mid-dispatch and lost precision for integer Scalars beyond 2**53. Verified against numpy elementwise != semantics for 1-D and 2-D contiguous tensors. __bool__ was unbound, so truthiness fell through to __len__ (`if tensor:` just checked shape()[0] != 0): never raised for a multi-element tensor, and gave a RuntimeError instead of a meaningful truth value for an uninitialized (Void-dtype) Tensor. Implemented with numpy semantics: ValueError for size > 1 ("truth value ... is ambiguous"), ValueError for an uninitialized Tensor, and bool(item) for exactly one element. BEHAVIOR CHANGE: `if tensor:` used to be equivalent to `if len(tensor):` and now raises ValueError for any multi-element tensor instead. __pow__/__ipow__ (Tensor and UniTensor) only accepted a plain cytnx_double exponent; pybind11's implicit conversion already made Python int/np.float32 work by accident (Pow's output dtype follows the base tensor, not the exponent), but explicit py::int_ and numpy_scalar<float> overloads were added anyway so a precise (non-implicit-conversion) signature is available once the stub pipeline (#915) lands. @= (Tensor.__imatmul__) is now genuinely in-place: T2's binding does `self.cast<Tensor&>() = Dot(...)` on the caller's own py::object, so `a is ref` holds after `a @= b`. On unmodified master (before T2), `@=` only appeared to work because the old python wrapper method was misspelled `__imatmul` (missing trailing underscore) instead of `__imatmul__`, making the real C++ __imatmul__ binding unreachable dead code; Python's `@=` silently fell back to `t = t.__matmul__(x)`, which rebinds the *name* in the caller's scope rather than mutating the object. Added a pinning regression test for this (test_imatmul_preserves_identity). Storage().pylist() on a Void-dtype Storage now raises RuntimeError (via cytnx_error_msg), where the old Python wrapper (removed in T2) raised ValueError -- noting this pre-existing T2 behavior change for the PR record since it hadn't been written down yet. Also: removed the now-orphaned `from beartype.typing import List` import in cytnx/Bond_conti.py (dead since T2 folded all of Bond's Python-side delegation into the C++ bindings, leaving nothing in this module to type-hint against). Stacks on fix/pybind-inplace-return-self (T2); merge after that PR. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request collapses redundant per-dtype operator overloads for both Tensor and UniTensor down to a canonical "keep-set" of Python-distinguishable types, resolving several numpy interop and type-preservation bugs. It also introduces the missing __ne__ and __bool__ operators for Tensor, and adds a helper dispatch_pyint to safely handle Python arbitrary-precision integers. The feedback suggests optimizing the dispatch_pyint function to immediately catch negative overflow and check for conversion exceptions earlier. Additionally, it recommends optimizing the __ne__ implementation to use self.Cpr(rhs).Cpr(0) instead of (1 - self.Cpr(rhs)).astype(cytnx::Type.Bool) to avoid unnecessary type promotion and memory allocations.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const long long ia = PyLong_AsLongLongAndOverflow(a.ptr(), &overflow); | ||
| if (overflow == 0) return fn(static_cast<cytnx::cytnx_int64>(ia)); | ||
| const unsigned long long ua = PyLong_AsUnsignedLongLong(a.ptr()); | ||
| if (PyErr_Occurred()) { | ||
| PyErr_Clear(); | ||
| cytnx_error_msg(true, "[ERROR] integer scalar out of the supported int64/uint64 range.%s", | ||
| "\n"); | ||
| } | ||
| return fn(static_cast<cytnx::cytnx_uint64>(ua)); |
There was a problem hiding this comment.
In dispatch_pyint, if overflow < 0 (negative overflow), calling PyLong_AsUnsignedLongLong is guaranteed to fail and set a Python exception because negative values cannot be converted to unsigned integers. We can immediately check if (overflow < 0) and throw the error without calling PyLong_AsUnsignedLongLong.
Additionally, we should check PyErr_Occurred() right after PyLong_AsLongLongAndOverflow to handle any conversion exceptions (e.g., if __index__ raises an exception) before proceeding.
const long long ia = PyLong_AsLongLongAndOverflow(a.ptr(), &overflow);
if (PyErr_Occurred()) {
PyErr_Clear();
cytnx_error_msg(true, "[ERROR] integer scalar conversion failed.%s", "\n");
}
if (overflow == 0) return fn(static_cast<cytnx::cytnx_int64>(ia));
if (overflow < 0) {
cytnx_error_msg(true, "[ERROR] integer scalar out of the supported int64/uint64 range.%s",
"\n");
}
const unsigned long long ua = PyLong_AsUnsignedLongLong(a.ptr());
if (PyErr_Occurred()) {
PyErr_Clear();
cytnx_error_msg(true, "[ERROR] integer scalar out of the supported int64/uint64 range.%s",
"\n");
}
return fn(static_cast<cytnx::cytnx_uint64>(ua));| [](cytnx::Tensor &self, const cytnx::Tensor &rhs) { | ||
| return (1 - self.Cpr(rhs)).astype(cytnx::Type.Bool); | ||
| }) |
There was a problem hiding this comment.
The current implementation of __ne__ uses (1 - self.Cpr(rhs)).astype(cytnx::Type.Bool). This triggers type promotion of the Bool tensor to Int64 (or Double) during the subtraction, allocates a temporary tensor, and then casts it back to Bool via .astype().
A much more efficient and elegant approach is to use self.Cpr(rhs).Cpr(0). Since self.Cpr(rhs) returns a Bool tensor, comparing it elementwise with 0 (which represents false) directly yields the logical NOT of the equality comparison as a Bool tensor, avoiding any type promotion, extra allocations, or casting. This optimization can be applied to all other __ne__ overloads in this file.
| [](cytnx::Tensor &self, const cytnx::Tensor &rhs) { | |
| return (1 - self.Cpr(rhs)).astype(cytnx::Type.Bool); | |
| }) | |
| [](cytnx::Tensor &self, const cytnx::Tensor &rhs) { | |
| return self.Cpr(rhs).Cpr(0); | |
| }) |
Code ReviewCollapses every Tensor/UniTensor arithmetic operator from ~24 per-C++-dtype overloads to a ~14-entry per-Python-distinguishable-type "keep-set" with a load-bearing registration order (centralized in the new Correctness — verified
Findings (all low / nits — none blocking)
The left-of-Tensor numpy-scalar gap ( VerdictStrong, unusually well-documented PR that fixes several genuine dtype bugs and centralizes a subtle, easy-to-regress ordering invariant with excellent tests. Findings are all notes, not blockers. Approve-with-nits, pending a fresh full-test CI run. Posted by Claude Code on behalf of @pcchen |
pcchen
left a comment
There was a problem hiding this comment.
Approving. This fixes several genuine numpy dtype-loss bugs (silent Int64/complex coercion, the t[0] = np.float32(x) hard error), replaces the always-False != with a correct elementwise __ne__, and adds numpy-semantics __bool__ — while collapsing the ~24x per-dtype overloads behind a well-documented, centralized keep-set ordering. The ordering rationale (the __index__ no-convert and __float__-fallback traps) is accurate, __ne__/__eq__ share one kernel, UniTensor's omission of __eq__/__ne__/__bool__ is a sound identity-fallback choice, and the 20-test suite is thorough (including the @= identity test that also covers the #986 gap). Compilation is green on all wheel platforms.
Non-blocking follow-ups (fine here or in a fast-follow):
- Add a changelog/release-note line for the
bool(t)behavior change (if tensor:now raises ValueError for multi-element/Void instead of meaninglen > 0). - Optional: note the Python-
bool-operand dtype shift (now routed through the int64py::int_path), and add a short comment/static_assertondispatch_pyint's same-return-type requirement.
Please confirm a fresh full BuildAndTest/pytest run is green on the current master-merged state before merging (the visible test run predates the retarget).
Posted by Claude Code on behalf of @pcchen
…hon surface (#934) Maintainer ruling (yingjerkao, 2026-07-06, phase2-api-semantics plan T3): UniTensor is a tensor-network object, not a raw array. Elementwise UniTensor(+)UniTensor arithmetic has no general tensor-network meaning -- for BlockUniTensor/BlockFermionicUniTensor it either destroys the block structure or is basis-dependent (#934's enumeration); #753/#675 additionally document it silently discarding labels. Audit (required before any removal): grepped src/linalg/*Ut*.cpp, src/linalg/Lanczos*, and all python pytests/examples for UniTensor elementwise +/-. - src/linalg/Lanczos_Gnd_Ut.cpp (backs cytnx.linalg.Lanczos(method="Gnd"), used by the DMRG examples) uses `new_psi -= alpha*psi_1`, `new_psi -= (alpha*psi_1 + beta*psi_0)`, and `eV += kryVg.at(n)*psi_s.at(n)` as genuine Krylov-subspace axpy. - src/linalg/Lanczos_Exp.cpp (backs cytnx.linalg.Lanczos_Exp, used by the TDVP example) uses UniTensor +/-/+=/-= extensively inside its Lanczos and BiCGSTAB inner solvers (Gram-Schmidt projection, residual updates, etc). - No python pytest or example (DMRG/TDVP/iTEBD/ED) calls `ut1 + ut2` or `ut1 - ut2` directly; they only reach the solvers through cytnx.linalg.Lanczos(...)/Lanczos_Exp(...) entry points. iTEBD's `Hx*TFterm + J*ZZterm` operates on plain Tensor (from physics.pauli/Kron), not UniTensor, and is unaffected. - Conclusion: remove only the python surface; keep the C++ operators (they live in include/linalg.hpp, not include/UniTensor.hpp as initially assumed -- corrected during the audit) because the Krylov solvers genuinely depend on them as vector-space arithmetic. Removed (pybind/unitensor_py.cpp): the UniTensor-vs-UniTensor overload in each of __add__, __iadd__, __sub__, __isub__, __mul__, __imul__, __truediv__, __itruediv__ now raises TypeError via a new raise_unitensor_elementwise_removed() helper. Three named guidance constants (kUniTensorAddSubRemovedGuidance / kUniTensorMulRemovedGuidance / kUniTensorDivRemovedGuidance) cover the eight call sites: the +/- family names Contract()/Kron()/scalar alternatives and points at cytnx.linalg.Lanczos()/Lanczos_Exp()/Arnoldi() for Krylov-style axpy; the * and / messages are remedy-first and name ut.get_block() Tensor-level arithmetic as the escape hatch for genuinely-elementwise use. All scalar<->UniTensor overloads in these same dunder groups (numpy scalars, py::int_, cytnx_double, cytnx_complex128, cytnx::Scalar, both directions, in-place and out-of-place) are untouched and keep working, per the decision record. __radd__/__rsub__/__rmul__/__rtruediv__ never had a UniTensor-vs- UniTensor overload to begin with (Python never reaches them for two UniTensor operands since __add__/etc. handle that case) so nothing changes there. Mod/Pow/Inv (also flagged in #934) are out of scope for this task's literal enumeration and are left untouched. Kept (include/linalg.hpp, include/UniTensor.hpp): the free operator+/-/*/ (UniTensor,UniTensor) overloads and the UniTensor::Add/Sub/Mul/Div(_) member functions (plus the UniTensor_base virtuals) are undecorated (no [[deprecated]] -- the task scope is the python surface only). The canonical rationale lives as a doxygen note on operator+(const UniTensor&, const UniTensor&) in include/linalg.hpp (load-bearing +/- for the Krylov solvers vs. unused-but-not-yet-removed *// Hadamard product/division); every other entry point carries a one-line "internal/advanced ... see operator+ ... for the full rationale" cross-ref. TDD: pytests/binding_unitensor_elementwise_test.py added first (red at base ecb1fbd -- all 4 ops currently succeed instead of raising; green after). Raise-tests pin ordered two-token regexes ((?s)934.*Contract / .*Kron / .*get_block). binding_dtype_test.py's existing UniTensor coverage (test_unitensor_numpy_int32_preserves_dtype et al.) only exercises scalar<->UniTensor arithmetic and needed no changes. Gates: pytest 55 (base) + 16 (new file) = 71 passed, no regressions, including the DMRG/TDVP/iTEBD/ED example suite (8 passed) which exercises the surviving C++ Krylov-solver arithmetic end-to-end. build_py (C++ + pybind) rebuilds clean with zero warnings across the full library and extension module. BREAKING CHANGE (python users): `ut1 + ut2`, `ut1 - ut2`, `ut1 * ut2`, `ut1 / ut2`, and their in-place forms, now raise TypeError when both operands are UniTensor. Use Contract()/Kron() for tensor-network composition, cytnx.linalg.Lanczos()/Lanczos_Exp()/Arnoldi() for Krylov-style vector-space linear combinations, ut.get_block() Tensor arithmetic for genuinely-elementwise block manipulation, or scalar arithmetic (unaffected) for the common "scale by a number" case. Stacked on #991 (fix/pybind-collapse-operator-overloads, tip ecb1fbd); branched as refactor/unitensor-drop-elementwise per the phase2-api-semantics plan's T3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
Addresses #928/#916 and finishes the core of #692. Every Tensor/UniTensor arithmetic operator was bound ~24× (one per C++ scalar type + numpy scalars). Beyond the stub blowup this caused live dtype bugs: pybind11's arithmetic caster accepts any
__index__-bearing object even in the no-convert pass, so plain fixed-width int overloads registered ahead of the numpy-scalar ones silently atenp.int32/uint64/…and produced Int64 results;t[0] = np.float32(x)raised a hard RuntimeError (Tensor__setitem__had no numpy coverage andcomplex128registered first). Separately,t1 != t2returned a single always-Falsebool for non-empty tensors (Python's default__ne__negated the elementwise__eq__Tensor via__len__), andbool(t)fell through to__len__.Fix
py::int_with int64/uint64 runtime dispatch,double,complex128,Scalar— ordering is load-bearing and documented once in the new sharedpybind/pyint_dispatch.hpp("KEEP-SET ORDERING" note, cross-referencing PR Bind ExpH/ExpM with per-dtype overloads + Python-layer fixes (toward clean stubtest) #915 whose ExpH/ExpM work pioneered the pattern).__setitem__/set_elemnumpy coverage + ordering fixed on both classes;__pow__/__ipow__accept ints and numpy scalars.__ne__(composed from existing kernels, Bool result, mirrors__eq__including theScalaroperand) and numpy-semantics__bool__(ValueError for multi-element or Void; truthiness of the single element otherwise). Behavior change:if tensor:previously meantlen(tensor) > 0.Testing
pytests/binding_dtype_test.py(20 tests) with per-test baseline red/green documented — 10 genuinely red pre-fix (dtype loss, setitem error,__ne__/__bool__traps). Full pytest 55 passed. Known pre-existing gap (numpy scalar on the LEFT of Tensor hits__iter__) documented, out of scope.Stacking
Base branch is
fix/pybind-inplace-return-self(PR #986). Merge #986 first, then retarget tomaster.🤖 Generated with Claude Code