Skip to content

refactor(pybind): bind in-place methods directly, drop the c__* shadow API#986

Merged
pcchen merged 2 commits into
masterfrom
fix/pybind-inplace-return-self
Jul 7, 2026
Merged

refactor(pybind): bind in-place methods directly, drop the c__* shadow API#986
pcchen merged 2 commits into
masterfrom
fix/pybind-inplace-return-self

Conversation

@yingjerkao

Copy link
Copy Markdown
Collaborator

Fixes #779, fixes #336.

What this does

In-place dunders and _-suffixed methods were bound under shadow names (c__iadd__, cConj_, c_relabel_, …) and re-exported by monkey-patching wrappers in cytnx/*_conti.py that called the shadow binding and returned self. That left three inconsistent return conventions, a wasted Python-object copy on every in-place op, and the wrong API surface in help()/stubs.

Commit 1 (efa8ea43) binds the real method names directly with lambdas that take and return the same py::object, so Python object identity is preserved and chaining (t.Conj_().Abs_(), ut.set_name("x").relabel_(["a","b"])) works without the delegation layer:

.def("Conj_", [](py::object self) {
  self.cast<Tensor &>().Conj_();
  return self;
})
  • tensor_py.cpp: __iadd__/__isub__/__imul__/__itruediv__/__ifloordiv__/__ipow__/__imatmul__, Conj_/Exp_/InvM_/Inv_/Abs_/Pow_
  • unitensor_py.cpp: Conj_/Trace_/Transpose_/normalize_/Dagger_/tag/__ipow__/Pow_/truncate_/set_name/set_label(s)/relabel_/relabels_/set_rowrank_/convert_from
  • bond_py.cpp: redirect_; getDegeneracy(qnum, return_indices=) and group_duplicates() now return their aux indices directly from C++
  • storage_py.cpp: the 11 c_pylist_<T> bindings collapse into one pylist() that switches on self.dtype()
  • The c__*/c* shadow bindings and their *_conti.py wrappers are removed; genuinely Python-side features (iterators, Network.Diagram, Qs, the UniTensor.at proxy) remain.

Commit 2 (a3367f99) fixes a bug this conversion surfaced: the string-label overload of UniTensor.truncate_ called the non-mutating truncate() and discarded the result, so ut.truncate_("label", dim) was a silent no-op. This is pre-existing on master (the old ctruncate_ shadow binding had the same call), not introduced by the refactor. It now calls the in-place C++ truncate_(label, dim), matching the bond_idx overload.

Testing

  • New pytests/binding_inplace_test.py: object-identity (r is t) and chaining guarantees, Storage.pylist round-trip, Bond.group_duplicates tuple return, and a grep-style guard that no shadow names survive on the Python classes.
  • New regression tests in pytests/unitensor_test.py for the truncate_ fix: string-label truncation actually shrinks the bond in place (was red before commit 2 — shape stayed [3, 4]), and the string-label/bond-index overloads agree.
  • Full pytest suite: 37 passed, 0 failed (master baseline 28 + this branch's 9 new tests).

Notes for reviewers

  • Behavior change: in-place methods now return the same Python object (r is t is True); previously some paths returned a fresh wrapper around the same C++ impl.
  • ut.truncate_("label", dim) now actually truncates — code that accidentally relied on the no-op will see the bond shrink.
  • Follow-up binding-hygiene work (collapsing per-dtype operator overloads, __ne__/__bool__) is planned as separate PRs stacking on this one.

🤖 Generated with Claude Code

yingjerkao and others added 2 commits July 6, 2026 08:27
…w API (#779)

In-place dunders and _-suffixed methods are now bound under their real
names with lambdas that take and return the same py::object, so python
object identity is preserved and chaining works without the
*_conti.py monkey-patch delegation layer. The c__iadd__/cConj_/...
shadow bindings and their python wrappers are removed; genuinely
python-side features (iterators, Network.Diagram, Qs, UniTensor.at
proxy) remain.

Also converts UniTensor's cInv_ (a shadow binding present in
unitensor_py.cpp with no Python-side wrapper at all, so Inv_() was
previously unreachable from Python) to Inv_, matching the pattern used
for the rest of the in-place API.

Storage's 11 c_pylist_<T> bindings collapse into one pylist() binding
that switches on self.dtype() in C++. Bond's c_redirect_,
c_getDegeneracy_refarg and c_group_duplicates_refarg fold their
Python-side wrapper logic (the return_indices flag and the
(result, mapper) tuple construction) directly into the C++ lambdas.

tests/test_apply_pybind.py used c_redirect_() directly; updated to
redirect_() to match. (Its 2 pre-existing TestApplyBlockFermionicUniTensor
signflip failures are unrelated to this change -- reproduced at baseline
before this commit.)

Preserves two pre-existing quirks bug-for-bug rather than silently
"fixing" them as drive-by changes: Tensor's c__ifloordiv__ called Div_
(not a real floor division), and UniTensor's ctruncate_(label, dim)
string-overload called the non-mutating truncate() and discarded the
result, making it a silent no-op on self (only the bond_idx overload
actually mutates).

Co-Authored-By: Claude <noreply@anthropic.com>
The string-label overload of UniTensor.truncate_ called the non-mutating
truncate() and discarded the result, so ut.truncate_("label", dim) was
a silent no-op (carried over bug-for-bug from the old ctruncate_ shadow
binding; present on master before the #779 refactor too). It now calls
the in-place C++ truncate_(label, dim), matching the bond_idx overload.

Adds regression pytests covering in-place mutation via a string label
and agreement between the string-label and bond-index overloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 28.33333% with 473 lines in your changes missing coverage. Please review.
✅ Project coverage is 30.93%. Comparing base (32079a4) to head (a3367f9).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
pybind/tensor_py.cpp 26.75% 247 Missing and 128 partials ⚠️
pybind/unitensor_py.cpp 37.00% 33 Missing and 30 partials ⚠️
pybind/storage_py.cpp 10.71% 23 Missing and 2 partials ⚠️
pybind/bond_py.cpp 50.00% 0 Missing and 10 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #986      +/-   ##
==========================================
+ Coverage   30.87%   30.93%   +0.06%     
==========================================
  Files         229      229              
  Lines       34757    34840      +83     
  Branches    14409    14383      -26     
==========================================
+ Hits        10730    10777      +47     
- Misses      16720    16752      +32     
- Partials     7307     7311       +4     
Flag Coverage Δ
cpp 30.57% <28.33%> (+0.19%) ⬆️
python 61.44% <ø> (+2.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
C++ backend 32.30% <ø> (+0.10%) ⬆️
Python bindings 18.82% <28.33%> (+1.54%) ⬆️
Python package 61.44% <ø> (+2.02%) ⬆️
Files with missing lines Coverage Δ
cytnx/Bond_conti.py 100.00% <ø> (+25.00%) ⬆️
cytnx/Storage_conti.py 44.00% <ø> (+18.00%) ⬆️
cytnx/Tensor_conti.py 65.62% <ø> (+7.29%) ⬆️
cytnx/UniTensor_conti.py 30.92% <ø> (-15.56%) ⬇️
pybind/bond_py.cpp 28.94% <50.00%> (+1.89%) ⬆️
pybind/storage_py.cpp 7.55% <10.71%> (+0.82%) ⬆️
pybind/unitensor_py.cpp 10.81% <37.00%> (+2.18%) ⬆️
pybind/tensor_py.cpp 23.23% <26.75%> (+2.11%) ⬆️

... and 8 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 32079a4...a3367f9. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@IvanaGyro

Copy link
Copy Markdown
Member

How will this interact with #912? It seems getDegeneracy may conflict against each other.

@yingjerkao

yingjerkao commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

@IvanaGyro good catch — they do touch the same surface, but it's a stub-vs-binding staleness rather than a hard code conflict. Both branches edit unitensor_py.cpp, but in disjoint regions (#912: the put_block inTin rename at ~L664–789; this PR: the in-place bindings elsewhere), so git merge-tree reports a clean merge with no conflicts — the only real interaction is the stub regeneration below.

Concretely, what this PR does to that surface in bond_py.cpp:

  • getDegeneracy(qnum) + c_getDegeneracy_refarg(qnum, indices) → a single getDegeneracy(qnum, return_indices=False) that returns the degeneracy, or (degeneracy, indices) when return_indices=True.
  • group_duplicates() + c_group_duplicates_refarg(mapper)group_duplicates() returning (Bond, mapper) directly.
  • c_redirect_ → folded into redirect_.

The interaction is that #912 ships generated, committed stubs, and its cytnx/cytnx/__init__.pyi currently contains the shadow methods this PR removes:

def c_getDegeneracy_refarg(self, qnum, indices: list) -> int: ...
def c_group_duplicates_refarg(self, mapper: list) -> Bond: ...
def c_redirect_(self) -> Bond: ...
def getDegeneracy(self, *args, **kwargs): ...
def group_duplicates(self, *args, **kwargs): ...

So once this PR lands, those c_* stub entries describe methods that no longer exist on the extension, and the real getDegeneracy(qnum, return_indices=False) / group_duplicates() signatures aren't captured. Since #912's stubs are pybind11-stubgen output (not hand-written), that's a mechanical regeneration, not a manual reconciliation.

Suggested ordering: land this PR first — it deletes the entire c__*/c* shadow surface, so regenerating #912's committed stubs afterward is strictly better (they drop the dead c_* entries and pick up the cleaned-up signatures). If #912 lands first, we should regenerate the committed stubs as part of rebasing this PR onto it. Either way the fix is a stub regen against the post-refactor extension; happy to sequence them whichever way you prefer.

@pcchen

pcchen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Removes the c__*/c* shadow bindings and their cytnx/*_conti.py monkey-patch wrappers, binding the real method/dunder names directly with the identity-preserving idiom (py::object self -> self.cast<T&>().mutate(); return self). Commit 2 fixes a real pre-existing bug where UniTensor.truncate_("label", dim) called the non-mutating truncate() and was a silent no-op.

Correctness — verified

  • The idiom is right — canonical pybind11 pattern for in-place ops with exact object identity; overload dispatch still keys off the second arg. ✅
  • Refactor is complete — no dangling references. Grepped the PR head tree: every c__*/c* name is gone from the Python package, C++ bindings, examples, and docs; the only survivors are inside the test that asserts they're gone. ✅
  • truncate_(string, dim) C++ overload exists (UniTensor.hpp:385/1071/5571), so commit 2's fix compiles and is real, and it's covered by a good regression test. ✅
  • No overloads dropped in the mechanical conversion (each operator keeps Tensor + 11 scalars + Scalar + 11 numpy_scalars), and all build/test/wheel CI is green on every platform. ✅
  • __imatmul__ assignment is sound: self.cast<Tensor&>() = Dot(self.cast<Tensor&>(), rhs) — Dot's result is computed before assignment, no aliasing hazard, identity preserved. ✅

Findings

1. @= semantics genuinely change — untested and not called out (low, but worth a note).
On master the wrapper was misspelled def __imatmul (missing the trailing __), so Tensor.__imatmul__ was never actually a special method — Python's a @= b fell back to a = a @ b, producing a new object. This PR binds __imatmul__ correctly, so @= now mutates in place and preserves identity (c = a; a @= b now makes c observe the change). That's a correct fix, but unlike the other in-place ops (whose identity was already preserved by the old wrappers) it's a genuine behavioral change. It has no test and isn't mentioned in "Notes for reviewers." Suggest adding an @= identity test and a changelog line.

2. codecov/patch fails — the new mechanical lines are largely uncovered. The many numpy_scalar overloads, Storage.pylist's default branch, and __imatmul__ aren't exercised. Not a blocker for a mechanical rename (behavior unchanged on those paths), but a couple of representative tests (one numpy-scalar in-place op, plus @=) would clear the gate and cover the one path that actually changed.

3. Storage.pylist invalid-dtype now raises differently (nit). Old code raised Python ValueError; the new default calls cytnx_error_msg -> std::logic_error -> RuntimeError (or CytnxError once #989 lands). Essentially unreachable edge case, but a minor exception-type change if anything caught ValueError.

4. getDegeneracy return type varies by flag (nit). Scalar when return_indices=False, a (deg, indices) tuple otherwise — preserved from the old wrapper, but a dynamic return type is awkward for stubs/typing. Fine to keep for compatibility.

5. Verbosity (nit, acknowledged). The per-dtype overload duplication is large; already flagged as planned follow-up.

Verdict

Strong, careful refactor: the mechanical conversion is correct and complete, it deletes a real maintenance wart, fixes a genuine truncate_ no-op bug, and passes full CI on all platforms. The one thing I'd ask for before merge is a small test + release note for the @= identity change (which also clears codecov/patch); everything else is nits. Approve-with-nits.

Posted by Claude Code on behalf of @pcchen

@pcchen pcchen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The refactor is correct and complete: the identity-preserving py::object self idiom is applied consistently, no shadow-name call sites dangle anywhere in the tree (verified against the head tree), no operator overloads were dropped, the truncate_(string, dim) C++ overload exists so commit 2's no-op fix is real, and full build/test/wheel CI is green on every platform. It also deletes a real maintenance wart and fixes a genuine silent no-op bug.

Non-blocking follow-ups (fine to do here or in a fast-follow):

  • Add an @= identity test + a changelog line. __imatmul__ was previously misspelled def __imatmul in the Python wrapper, so a @= b fell back to a = a @ b (new object); it now truly mutates in place and preserves identity. This is the one genuine behavioral change and is currently untested.
  • That same test (plus one numpy-scalar in-place op) would also clear the codecov/patch gate.

Posted by Claude Code on behalf of @pcchen

@pcchen
pcchen merged commit b1d2c2b into master Jul 7, 2026
18 of 19 checks passed
@pcchen
pcchen deleted the fix/pybind-inplace-return-self branch July 7, 2026 03:49
assert b.type() == cytnx.BD_BRA


def test_shadow_bindings_are_gone():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is useless for the future code. This should be clean up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — it's a one-time migration guard with no value for future code (and the last reference to those dead c_* names). Since #986 is already merged, I've removed it in a follow-up: #1009. That PR also adds a real @= identity test in its place (the one genuine behavioral change from this refactor, which was untested), so the file keeps only the meaningful contract tests.

yingjerkao added a commit that referenced this pull request Jul 7, 2026
test(pybind): drop shadow-name guard, cover @= identity (follow-up to #986)
yingjerkao added a commit that referenced this pull request Jul 9, 2026
…nsor signflip

Bond's public interface can no longer mutate a bond in place: a Bond bound
into a UniTensor's block structure could previously be desynchronized via
set_type/redirect_/combineBond_/clear_type, silently corrupting the derived
_blocks/_inner_to_outer_idx (#846). All four are removed; the return-new
forms (retype/redirect/combineBond) are the only way to change a bond's
type or combine bonds, so sharing the PIMPL across copies is now safe.
Non-const qnums()/syms() and the mutable getDegeneracies() overload are
removed for the same reason, as is group_duplicates_() (the in-place
counterpart of the kept group_duplicates()). UniTensor's non-const bonds()
overload is removed too; internal code that legitimately replaces which
bonds a tensor holds now goes through _bonds directly (already public on
UniTensor_base) instead of mutating a shared Bond's impl.

Bond::clone() deliberately remains a deep copy: #846's suggestion to
collapse it to an identity copy is left as a follow-up, because the
migrated combine paths now rely on clone-then-mutate of a detached impl
(via the internal Bond_impl::force_combineBond_) as their safety argument.

Internal call sites that relied on in-place bond mutation (Transpose_,
combineBonds, group_basis_ in Dense/Block/BlockFermionicUniTensor; output
bond retagging in Svd/Gesvd/Qr/Qdr/Rsvd and their truncate variants) now
build new Bond values via retype()/redirect()/combineBond() and assign them
back, or clone before calling the internal Bond_impl::force_combineBond_.

Per #841, BlockFermionicUniTensor::_signflip moves from public to private.
The public signflip_() mutable accessor (UniTensor_base virtual, the
BlockFermionicUniTensor override, and the UniTensor wrapper) is deleted;
the read-only signflip() stays. The SVD/Gesvd/QR/Eig/Eigh/ExpH/ExpM
decomposition internals across src/linalg/*.cpp are the actual
encapsulation unit that legitimately reads an input's signflip and
initializes/truncates a freshly-built output's signflip in lockstep with
its _blocks -- broader than #841's Svd_truncate.cpp-only enumeration, since
Qr/Eig/Eigh/ExpH/ExpM/Rsvd construct BlockFermionicUniTensor by hand (never
call Init()) and read/write _signflip the same way. They're granted access
via a single friend function, linalg::_fermionic_signflip_(), rather than
friending each internal helper (several are static/anonymous-namespace and
overloaded, which would mean duplicating fragile forward declarations).

pybind/bond_py.cpp drops the bindings for the removed mutators (redirect_
was bound by #986); Python users get the return-new forms only, hence the
breaking-change marker on this commit. The python group_duplicates_
binding is also removed: it was a pre-existing misbinding of the
OUT-of-place C++ Bond::group_duplicates under an in-place-mutator name
(pybind treats the mapper output-reference argument as an input, so it
never behaved as documented); the non-underscore group_duplicates binding
that returns (Bond, mapper) remains.

Tests: Bond_test.cpp pins that redirect()/combineBond()/retype() return new
values while the receiver (and anything aliasing its impl) is untouched;
BlockUniTensor_test.cpp adds a regression that a Bond obtained from a
UniTensor can't desync its block structure; Dense/Block/BlockFermionic
UniTensor tests add an aliasing regression pinning that a user-held bond
copy is not rewritten by combineBond(force=true) (guards the .clone() that
detaches the impl before the internal in-place combine); linalg_test.cpp
adds a signflip/Nblocks lockstep check on BlockFermionicUniTensor
post-truncation. Existing tests that called the removed mutators are
rewritten to the return-new forms (including tests/test_apply_pybind.py,
which sits outside the pytests/ CI path); pytests/binding_inplace_test.py
replaces the in-place redirect_ identity test with a return-new test plus
an AttributeError check for the five removed python methods.

Co-Authored-By: Claude Fable 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.

Python bindings: chaining schemantics use inconsistent return self.method() Always return reference for inplace methods

3 participants