Skip to content

refactor(Bond)!: make Bond immutable; encapsulate BlockFermionicUniTensor signflip#1001

Merged
yingjerkao merged 7 commits into
masterfrom
refactor/bond-immutable
Jul 13, 2026
Merged

refactor(Bond)!: make Bond immutable; encapsulate BlockFermionicUniTensor signflip#1001
yingjerkao merged 7 commits into
masterfrom
refactor/bond-immutable

Conversation

@yingjerkao

@yingjerkao yingjerkao commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fixes #846 and #841 per the consensus reached in both threads (full public immutability; "correctness guarded by design, not only by tests"). Bond's in-place mutators let user code silently break the block-tensor symmetry invariant of any UniTensor holding that bond, and the public mutable signflip_() accessor could desynchronize BlockFermionicUniTensor's _signflip/_blocks lockstep.

What changed

  • Bond public surface is now immutable: set_type, redirect_, combineBond_, clear_type, group_duplicates_, operator*=, non-const qnums()/syms(), and the mutable getDegeneracies() are removed. Return-new forms (retype, redirect, combineBond, group_duplicates) remain and are pinned by new tests.
  • UniTensor::bonds() is const-only; internals migrated.
  • _signflip is private (Remove public mutable accessor UniTensor::signflip_(); enforce _signflip invariants inside BlockFermionicUniTensor #841). The issue's enumeration turned out incomplete — the direct-access pattern existed across 8 linalg files, and the per-helper friend design was infeasible (file-static helpers can't be friended) — so a single documented gateway linalg::_fermionic_signflip_() is friended once, with the invariant story in its header comment.
  • Real bug found and fixed during migration: the combine-bonds helpers copied a bond handle that shared the impl with the tensor's own bond, then mutated it in place — silently rewriting any user-held bond copy. Now clones first; regression tests pin that a user-held Bond survives a force=true combine untouched.
  • Python: the removed mutators now raise AttributeError (their replacements are same-named siblings — redirect, retype, combineBond); the misleading group_duplicates_ binding (which never behaved as documented) is also removed.

BREAKING CHANGE

C++ and Python code calling the removed Bond mutators must switch to the return-new forms. Release notes should list: set_typeretype, redirect_redirect, combineBond_combineBond (out-of-place), clear_type (construct anew), group_duplicates_group_duplicates.

Testing

C++: 1071 passed / 11 skipped / only the 4 pre-existing failures addressed by #977 (8 new tests incl. a truncation test pinning signflip().size()==Nblocks() through real Svd/Gesvd truncation, and the aliasing regression pair). pytest: 47 passed. The two known pre-existing failures in tests/test_apply_pybind.py are fixed here (d4c07dc): the _are_nearly_eq helper compared get_blocks_() pairwise in storage order, which is not canonical, so it zipped mismatched sectors; it now matches blocks by quantum-number sector via get_qindices(), mirroring the C++ AreEqUniTensor. Unlike the C++ helper it compares per-block signflip() flags instead of compensating them — compensation would let a no-op apply_() still compare equal, making the in-place test vacuous. The file also moved to pytests/apply_test.py so CI's pytest pytests/ actually runs it (it was silently skipped under tests/).

Stacking

Base is fix/pybind-inplace-return-self (PR #986) — this branch removes pybind mutator bindings that #986 rewrote. Merge #986 first, then retarget to master.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the Bond class to make it immutable, removing all public in-place mutators (such as set_type, redirect_, clear_type, combineBond_, and group_duplicates_) from both C++ and Python APIs. Methods that modify a bond now return a new Bond instance. Additionally, UniTensor's bonds() and signflip_() non-const accessors have been removed or restricted, with internal linalg routines accessing fermionic sign-flips via a friend helper _fermionic_signflip_ to prevent desynchronization. Extensive tests have been updated and added to verify these immutability and desynchronization invariants. I have no additional feedback to provide as there are no review comments to assess.

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.

Base automatically changed from fix/pybind-inplace-return-self to master July 7, 2026 03:49
Comment thread include/UniTensor.hpp Outdated
// blocks (see #841). Not part of the public API: _signflip must stay in lockstep with
// _blocks/_inner_to_outer_idx and the qnums on the contracted bond, so no other code may
// touch it.
std::vector<cytnx_bool> &_fermionic_signflip_(BlockFermionicUniTensor &self);

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.

It is discouraged to use variable name prefixed with _. BTW, will we set the style rules in AGENT.md or CLAUDE.md for bots?

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.

Resolved in a59e3fb by removing the gateway altogether: the _fermionic_signflip_ name no longer exists. The replacements (reset_signflip_() / erase_signflip_()) follow the codebase's trailing-underscore in-place-mutator convention with no leading underscore. (On the side question: #1008 proposes a CLAUDE.md/AGENTS.md with style rules for bots.)

Posted by Claude Code on behalf of @pcchen

Comment thread include/UniTensor.hpp Outdated
// additional information for fermions:
std::vector<cytnx_bool>
_signflip; // if true, the sign of the corresponding block needs to be flipped
friend std::vector<cytnx_bool> &linalg::_fermionic_signflip_(BlockFermionicUniTensor &self);

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.

If others may call this function directly, this should be public not private.

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.

Agreed, and a59e3fb lands on the public side of this: the friend/private gateway is gone, and the two replacement mutators (reset_signflip_(), erase_signflip_()) are public members -- safe to expose because each preserves the _signflip/_blocks lockstep (erase_signflip_ actively throws on size divergence). _signflip itself stays private with no remaining escape hatch.

Posted by Claude Code on behalf of @pcchen

@pcchen

pcchen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Removes Bond's public in-place mutators (Bond is now immutable, so user code can't silently break a UniTensor's symmetry invariant), makes BlockFermionicUniTensor::_signflip private behind a single friended gateway, and fixes a real aliasing bug surfaced during the migration.

Correctness — verified (high confidence)

  • Bond immutability is clean. Public mutators (set_type, redirect_, combineBond_, clear_type, group_duplicates_, operator*=, non-const qnums()/syms(), mutable getDegeneracies()) are removed from the wrapper; the impl keeps them for internal use; return-new forms (retype/redirect/combineBond) call out._impl->set_type(...) on a deep clone(), so they're fully independent. ✅
  • The aliasing bug fix is real and correct. The old Bond tmp = this->_bonds[i] copied a wrapper that shared the intrusive_ptr impl, then mutated it in place — silently rewriting any user-held bond copy. Now Bond tmp = this->_bonds[i].clone() isolates it; the force branch mutating tmp._impl is safe because tmp is now a clone. Regression tests pin it. ✅
  • group_duplicates_() -> group_duplicates(mapper) preserves behavior — the mapper is still captured into idx_mappers, and _bonds[i] is replaced with the deduplicated bond. ✅
  • Signflip migration verified end-to-end (dedicated pass over all 11 linalg files): the gateway is a transparent alias, read/write semantics are identical to the old _signflip/signflip_(), and every truncation branch (Gesvd/Svd/Rsvd) erases blocks, qnums, and signflip with the same to_be_removed index set in the same order, keeping signflip().size() == Nblocks() consistent. No un-migrated mutators, no staleness/ordering/clone differences. ✅
  • The Python _are_nearly_eq fix is a genuine correctness improvement — it matches blocks by quantum-number sector (get_qindices) instead of non-canonical storage order (fixing the two pre-existing failures that zipped mismatched sectors) and compares signflips rather than compensating them (so a no-op apply_() can't vacuously pass). Moving it to pytests/ so CI actually runs it (it was silently skipped under tests/) is a good catch. ✅
  • Compiles green on all wheel platforms.

Findings (low / notes — none blocking correctness)

1. The signflip gateway is a transparent mutable-ref alias, not an invariant-enforcing interface.
_fermionic_signflip_ just return self._signflip; (full std::vector<cytnx_bool>&). This does achieve #841's goal — external/user code can no longer touch _signflip (private + one internal friend), so the "public signflip_() desync" bug is genuinely eliminated. But the _signflip/_blocks lockstep is still maintained by hand inside the backend, not guaranteed by the gateway. That's fine and honestly documented; I'd just calibrate the "correctness guarded by design" framing — the Bond half is truly design-guarded, the signflip half is friend-restricted + discipline. Follow-up idea: narrow the gateway to the specific ops the backend needs (append-false, erase-by-index) so lockstep becomes hard to break, not just hard to reach.

2. CI gap — the test evidence is local-only. Wheels compile green, but no BuildAndTest (C++ gtest) or pytest job ran on this commit — so "1071 C++ / 47 pytest passed," the new signflip().size()==Nblocks() truncation test, and the aliasing regression tests are unverified in CI. Given this PR's entire thesis is correctness-by-invariant, a fresh full-test CI run is important before merge (same note as #997).

3. Breaking change — needs a changelog/release note + version signal. Well-flagged (title !, BREAKING section, and the removed->replacement migration table). That table is a good basis for the release notes.

Verdict

High-quality, careful refactor: Bond immutability is cleanly executed, the signflip encapsulation meets the #841 goal, the surfaced aliasing bug is a genuine fix, and the whole linalg migration is behavior-preserving (independently verified, including the truncation lockstep). Findings are notes, not defects. Approve-with-nits, pending a fresh full-test CI run (the one real gate given the correctness-focused nature of the change).

Posted by Claude Code on behalf of @pcchen

@pcchen

pcchen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Review update — folding in @IvanaGyro's two comments on the signflip gateway

Both land on linalg::_fermionic_signflip_, and both are fair. Precise take on each:

1. Naming: leading/trailing underscores on the gateway (include/UniTensor.hpp:1827)

Agree this is worth changing. To be precise on the reserved-identifier rule: _fermionic_signflip_ is declared inside namespace cytnx::linalg and starts with _ + a lowercase letter, so it is not UB — a single leading underscore is reserved only at global scope, and there's no __/_[A-Z]. So it compiles legally. But the style objection stands, and it's actually doubly confusing here:

  • cytnx uses a leading _ for member variables (_signflip, _blocks, _bonds) — so a leading _ on a free function reads like a member.
  • cytnx uses a trailing _ for in-place mutator methods (redirect_, combineBond_, tag_) — but this is an accessor gateway, not a mutator, so the trailing _ is misapplied.

Suggest renaming to something that signals "internal accessor" without borrowing either convention, e.g. put it in a detail/internal namespace: cytnx::linalg::internal::fermionic_signflip_ref(BlockFermionicUniTensor&).

On the meta question (style rules in CLAUDE.md/AGENT.md): +1, worth doing. Codifying the three conventions — leading _ = member, trailing _ = in-place mutator returning self, no leading _ on free functions — would keep both humans and bots consistent; this PR is a concrete case where it would have helped.

2. friend in the private: section (include/UniTensor.hpp:1844) — "make it public not private"

One C++ clarification first: the access section a friend declaration sits in is irrelevant — friendship is granted regardless of whether it's written under public: or private:. And the gateway itself is already public: it's a namespace-scope function in linalg (line 1827), callable by anyone who includes the header. So nothing here is actually "private" except the _signflip member.

So the design is: _signflip is private (no code can touch it) + exactly one friend gateway is the only door. That's deliberate and it's what satisfies #841 — making _signflip (or a plain accessor) public would let any code mutate it again and reintroduce the very _signflip/_blocks desync #841 removes. So I'd push back on "make it public": private-member + single-friend is strictly more encapsulated and is the right choice.

That said, this connects to finding #1 in my review above: the gateway returns a raw mutable std::vector<cytnx_bool>&, so it's "one door, but the door is wide open" — any linalg code can resize/reorder it out of lockstep with _blocks. The stronger version (which also softens IvanaGyro's concern) is to narrow the gateway to the specific operations the backend needs — e.g. append_false(n) and erase_at(indices) that mutate _signflip and _blocks/qnums together — instead of handing out the bare vector. That keeps the single-friend encapsulation and makes the invariant hard to break, rather than merely hard to reach.

Net

These are clarity/design-polish items, consistent with my earlier finding #1 — they don't change the correctness verdict (approve-with-nits, pending a fresh full-test CI run), but the rename is cheap and worth doing, and the "narrow the gateway" refactor would be a good follow-up (or a small addition here).

Posted by Claude Code on behalf of @pcchen

@IvanaGyro

IvanaGyro commented Jul 7, 2026

Copy link
Copy Markdown
Member

std::vector<cytnx_bool>&, so it's "one door, but the door is wide open"

The correct or better design is to only modify _signflip while preserving consistency and integrity. This means the current usage of the accessor should be changed. Not only change the var name. Make the public member var to private friend method seems narrow a little bit the accessibility but it didn't explicitly avoid developers to do this dangerous action.

The stronger version (which also softens IvanaGyro's concern) is to narrow the gateway to the specific operations the backend needs — e.g. append_false(n) and erase_at(indices) that mutate _signflip and _blocks/qnums together — instead of handing out the bare vector. That keeps the single-friend encapsulation and makes the invariant hard to break, rather than merely hard to reach.

I see the bot said what mean here.

For the naming problem, prefix var names with _ is Python semantics of "private". C++ can just put things under private section to make the var private, so there is no need to apply this semantics. We are using AI agents to land more code. Changing all vars names of the whole repo becomes easier, we can decide if want to use snake case for function name or follow the Google Style guide which uses pascal case instead. We have landed many breaking changes since last release, so the next release must be v2.0.0. It is a good time to unify the var naming rules.

yingjerkao and others added 3 commits July 9, 2026 10:07
…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>
Mirror the CPU-side tests/Bond_test.cpp fixes: replace the removed
Bond::set_type() mutator with retype() in gpu_SimpleBondOperation and
gpu_CombineBondNoSymmBraKet, asserting the original bonds stay
untouched.

tests/gpu/test.cpp:178 still calls set_type, but that file is not
referenced by any CMake target (it also uses the long-removed CyTensor
class), so it is dead code and left as-is.

Verified by clang++ -fsyntax-only against the branch headers (no CUDA
toolchain available for a full GPU build): the old file fails with
exactly the three set_type errors, the new file compiles clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests/test_apply_pybind.py compared get_blocks_() pairwise in storage
order, but block storage order is not canonical: the tensor produced by
BFUT3.permute([3,1,4,2,0]).contiguous().apply() enumerates the same
sectors in a different _inner_to_outer_idx order than the manually
constructed BFUT3PERM, so the naive zip compared mismatched sectors and
the two signflip tests failed. Match blocks by get_qindices() instead,
mirroring AreEqUniTensor in tests/test_tools.cpp, and compare (rather
than compensate) per-block signflip flags so a tensor with pending
flips stays unequal to its applied form and the apply()/apply_() tests
cannot pass vacuously.

Also move the file to pytests/apply_test.py: CI only runs
`pytest pytests/`, so under tests/ it was never executed and had rotted
unnoticed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yingjerkao
yingjerkao force-pushed the refactor/bond-immutable branch from d4c07dc to 18e1218 Compare July 9, 2026 02:07
@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.96552% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.98%. Comparing base (be363d3) to head (764337b).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/linalg/Gesvd_truncate.cpp 62.50% 9 Missing ⚠️
src/linalg/Rsvd.cpp 68.96% 9 Missing ⚠️
src/linalg/Qdr.cpp 0.00% 7 Missing ⚠️
src/linalg/Gesvd.cpp 37.50% 5 Missing ⚠️
src/linalg/Qr.cpp 37.50% 5 Missing ⚠️
src/linalg/Svd.cpp 37.50% 5 Missing ⚠️
src/linalg/Svd_truncate.cpp 83.33% 4 Missing ⚠️
src/BlockUniTensor.cpp 83.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1001      +/-   ##
==========================================
+ Coverage   57.76%   57.98%   +0.21%     
==========================================
  Files         229      229              
  Lines       33479    33459      -20     
  Branches       71       71              
==========================================
+ Hits        19340    19401      +61     
+ Misses      14117    14036      -81     
  Partials       22       22              
Flag Coverage Δ
cpp 57.91% <68.96%> (+0.21%) ⬆️
python 63.61% <ø> (ø)

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

Components Coverage Δ
C++ backend 55.78% <68.96%> (+0.26%) ⬆️
Python bindings 72.02% <ø> (-0.06%) ⬇️
Python package 63.61% <ø> (ø)
Files with missing lines Coverage Δ
include/Bond.hpp 96.06% <100.00%> (+2.18%) ⬆️
include/UniTensor.hpp 72.28% <100.00%> (+0.03%) ⬆️
pybind/bond_py.cpp 90.90% <ø> (+0.55%) ⬆️
pybind/unitensor_py.cpp 71.89% <ø> (ø)
src/BlockFermionicUniTensor.cpp 64.87% <100.00%> (+0.13%) ⬆️
src/DenseUniTensor.cpp 86.80% <100.00%> (+0.48%) ⬆️
src/UniTensor_base.cpp 49.53% <ø> (+0.30%) ⬆️
src/linalg/Eig.cpp 96.79% <100.00%> (ø)
src/linalg/Eigh.cpp 98.90% <100.00%> (ø)
src/linalg/ExpH.cpp 93.93% <100.00%> (ø)
... and 9 more

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 be363d3...764337b. 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.

…ng mutators

Remove the friended linalg::_fermionic_signflip_() accessor that handed
out a bare vector<cytnx_bool>& (review feedback: narrowing the access
path is not enough if the door it opens is still wide open). Replace it
with two members on BlockFermionicUniTensor that cannot desynchronize
the #841 _signflip/_blocks lockstep:

- reset_signflip_(): sizes the ledger to _blocks.size() right after the
  decomposition internals (re)build _blocks (13 sites).
- erase_signflip_(positions): erases ledger entries with the same index
  list that erased the blocks during truncation, then throws if the
  ledger and _blocks disagree in size (18 sites) -- the invariant is now
  enforced at the hazard point instead of merely hidden.

Read-only uses (8 sites) switch to the public signflip() const accessor.
No friend declarations remain and _signflip is private with no escape
hatch; the reserved-looking gateway name disappears with the gateway.

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

pcchen commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Gateway redesign pushed — _fermionic_signflip_() is gone

Implemented the narrowing @IvanaGyro asked for (and that the earlier review update sketched): the friended bare-vector& accessor is removed entirely, replaced by two invariant-preserving members on BlockFermionicUniTensor:

  • reset_signflip_()_signflip.assign(_blocks.size(), false). Used by the decomposition internals right after (re)building _blocks, so the ledger is sized to the blocks by construction (12 construction sites in Svd/Gesvd/Rsvd/Qr/Eig/Eigh/ExpH/ExpM + 1 internal site).
  • erase_signflip_(positions) — erases ledger entries with the same index list that erased the blocks, then throws if _signflip.size() != _blocks.size() afterwards. All 18 truncation sites (Svd_truncate/Gesvd_truncate/Rsvd) erase blocks immediately before flips, so the postcondition actively enforces the Remove public mutable accessor UniTensor::signflip_(); enforce _signflip invariants inside BlockFermionicUniTensor #841 lockstep instead of merely hiding the vector — a caller that forgets to erase blocks first now fails loudly.
  • Plain reads (8 sites) switch to the existing public signflip() const accessor — they never needed mutable access.

Consequences:

  • No friend declarations left; _signflip is private with no escape hatch.
  • The reserved-looking _fermionic_signflip_ name is gone with the gateway, resolving the naming thread as a by-product; the new names follow the codebase's trailing-underscore mutator convention.
  • On the "public vs private" question: the two mutators are public members — safe to expose because each preserves the lockstep invariant (they can change values, which is already possible through get_blocks_(), but cannot desynchronize the bookkeeping).

Verified locally (Release, no HPTT): 1166 tests ran, 1155 passed, 11 skipped, 0 failed -- including the #841 regression test BkFUt_TruncateKeepsSignflipInLockstepWithNblocks. Formatted with clang-format 14.0.6. Pushed as a59e3fb.

@IvanaGyro would you re-review? I believe this addresses both inline comments and the follow-up.

Posted by Claude Code on behalf of @pcchen

@pcchen
pcchen requested a review from IvanaGyro July 10, 2026 07:43
Resolves the 4 conflicts from 98 commits of master divergence:

- pytests/apply_test.py: take master's canonical `_are_nearly_eq` (the #985
  sector-matching helper, functionally identical to this branch's copy), and
  drop this branch's duplicate. Adapt `_make_bfut3` to the immutable Bond API:
  `.redirect_()` -> `.redirect()`, since #1001 removes the in-place `redirect_`
  binding (Python bound only `redirect` on this branch).

- src/{DenseUniTensor,BlockUniTensor,BlockFermionicUniTensor}.cpp Transpose_():
  keep master's cleaner reverse-index computation (std::ranges iota/reverse),
  but replace master's in-place `bond.redirect_()` with the immutable
  `bond = bond.redirect()` this branch requires. Numerically identical
  (idxnum+1 == rank == _bonds.size()).

Verified: the only public Bond mutators #1001 removes are `redirect_` and
`combineBonds_`; neither has any remaining live caller after these fixes.
openblas-cpu build is clean; full pytests 139 passed / 1 skipped; Transpose_
on tagged/block tensors behaves correctly (bonds redirected, order reversed,
original left intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yingjerkao

Copy link
Copy Markdown
Collaborator Author

Merged master (98 commits) into this branch — now MERGEABLE (was CONFLICTING). Four conflicts resolved (merge commit c663c2f3):

  • pytests/apply_test.py — dropped this branch's duplicate _are_nearly_eq in favor of master's canonical copy (landed via fix(tests): match blocks by qindices in apply() pybind test helper #985; functionally identical). Kept the immutable-Bond adaptation: _make_bfut3 now uses .redirect() instead of .redirect_(), since this PR removes the in-place redirect_ binding (Python binds only redirect here).
  • Transpose_() in DenseUniTensor / BlockUniTensor / BlockFermionicUniTensor — took master's cleaner std::ranges reverse-index computation, but swapped its in-place bond.redirect_() for this PR's immutable bond = bond.redirect(). Numerically identical (idxnum+1 == rank).

Verified the only public Bond mutators this PR removes (redirect_, combineBonds_) have no remaining live callers. openblas-cpu build clean; full pytests/ 139 passed / 1 skipped; Transpose_ on tagged/block tensors checked directly (bonds redirected, order reversed, original intact).

Note for whoever lands this: the apply-test move + helper rewrite are now on master via #985, so this PR no longer carries them — the collision flagged earlier is resolved.

@IvanaGyro IvanaGyro left a comment

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.

Better than the original. Other encapsulation issue can be fixed in the future.

Comment thread include/UniTensor.hpp Outdated
std::vector<cytnx_bool>
_signflip; // if true, the sign of the corresponding block needs to be flipped

private:

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.

private section should at the end of the class by the style guide.

Comment on lines +73 to +81
def test_bond_removed_mutators_raise_attribute_error():
"""#846: Bond's in-place mutators (set_type, redirect_, combineBond_,
clear_type) are removed from the public interface; only the return-new
forms (retype, redirect, combineBond) remain. group_duplicates_ is also
gone: it was a misbinding of the out-of-place C++ group_duplicates under
an in-place-mutator name (the non-underscore group_duplicates stays)."""
b = cytnx.Bond(2, cytnx.BD_KET)
for name in ("set_type", "redirect_", "combineBond_", "clear_type", "group_duplicates_"):
assert not hasattr(b, name), f"Bond.{name} should have been removed"

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 does not help for the future code.

Comment thread src/linalg/Gesvd.cpp
Cy_U._impl->_bonds[i] = Cy_U._impl->_bonds[i].retype(Tin.bonds()[i].type());
}
Cy_U.bonds().back().set_type(cytnx::BD_BRA);
Cy_U._impl->_bonds.back() = Cy_U._impl->_bonds.back().retype(cytnx::BD_BRA);

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 is still dangerous operation. Changing the bond direction on a block tensor may break symmetries.

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.

Need to address that block tensors are not fully encapsulated on bond directions.

Comment on lines 309 to +312
vec_erase_(S.get_blocks_(), to_be_removed);
vec_erase_(S.bonds()[0].qnums(), to_be_removed);
vec_erase_(S._impl->_bonds[0]._impl->_qnums, to_be_removed);
if (Tin.uten_type() == UTenType.BlockFermionic) {
vec_erase_(S.signflip_(), to_be_removed);
static_cast<BlockFermionicUniTensor *>(S._impl.get())->erase_signflip_(to_be_removed);

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.

Allowing to remove blocks in public API of block tensors is also suspicious. Symmetry consistency is not checked by internal functions. It is easy to get wrong for developers. The better workaround may be creating a new one and shadow copy the blocks that need to be preserve. Need to check the shape of block internally while copying.

Comment on lines +64 to +72
void BlockFermionicUniTensor::erase_signflip_(const std::vector<cytnx_uint64> &positions) {
vec_erase_(this->_signflip, positions);
cytnx_error_msg(this->_signflip.size() != this->_blocks.size(),
"[ERROR][BlockFermionicUniTensor][erase_signflip_] after erasing, _signflip "
"(len %d) is out of lockstep with _blocks (len %d); erase the blocks with the "
"same index list before erasing the signflips.%s",
(int)this->_signflip.size(), (int)this->_blocks.size(), "\n");
}

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.

The better API is remove_blocks, which will also remove signflips. Although, it is still dangerous....

Comment on lines +1151 to +1167
====================*/
TEST_F(linalg_Test, BkFUt_TruncateKeepsSignflipInLockstepWithNblocks) {
const double tol = 1e-8;
UniTensor Msf = permute_with_signflips(make_rank4_herm_dt(false));

// keepdim=1 forces the truncation path to actually drop blocks/qnums (not just a no-op pass
// through), which is when U/S/vT's _signflip must be re-erased in lockstep with _blocks.
for (auto trunc : {linalg::Svd_truncate(Msf, 1, 0., true, 0),
linalg::Gesvd_truncate(Msf, 1, 0., true, true, 0)}) {
ASSERT_EQ(trunc.size(), 3u); // S, U, vT (return_err=0)
const UniTensor &S = trunc[0];
const UniTensor &U = trunc[1];
const UniTensor &vT = trunc[2];

EXPECT_EQ(S.signflip().size(), S.Nblocks());
EXPECT_EQ(U.signflip().size(), U.Nblocks());
EXPECT_EQ(vT.signflip().size(), vT.Nblocks());

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.

If there is remove_blocks function in BlockUniTensor, this should be tested on BlockUniTensor instead.

yingjerkao and others added 2 commits July 13, 2026 20:05
Brings in #1039, #1047 (39 commits). Clean textual merge, but #1026's new
test `UniTensor_baseTest.rank_detects_label_bond_mismatch` desyncs bonds/labels
via `bonds().pop_back()` -- which no longer compiles now that this PR makes the
mutable `bonds()` accessor const (immutable Bond, #846). That desync is
unreachable through the public API by design, so the test now pokes the
internal `_bonds` vector (const_cast) to still exercise rank()'s defensive
size-mismatch guard.

Verified: openblas-cpu + RUN_TESTS build clean (CI's editable-install compile
gate reproduced locally); UniTensor_base/BlockFermionic/Bond gtests 38 passed.

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

Review comment (@IvanaGyro): the single `private:` section (the `_signflip`
member) was sandwiched between two `public:` blocks. Google style keeps one
private section at the end of the class -- move `_signflip` there. Pure
reordering; `_signflip` stays private and mutable only via the
reset_signflip_()/erase_signflip_() gateway (#841 lockstep unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yingjerkao

Copy link
Copy Markdown
Collaborator Author

Brought the branch up to date with master (#1039, #1047) and addressed the review comments:

CI failure fixed (was UNSTABLE). Merging current master surfaced a compile break that the clean textual merge didn't flag: #1026 added UniTensor_baseTest.rank_detects_label_bond_mismatch, which desyncs bonds/labels via bonds().pop_back() — no longer valid now that this PR makes bonds() const. Since that desync is unreachable through the public API by design (the point of this PR), the test now const_casts the internal _bonds vector to still exercise rank()'s defensive size-mismatch guard. Reproduced CI's RUN_TESTS editable-install compile locally; 38 UniTensor_base/BlockFermionic/Bond gtests pass.

@IvanaGyro "private section should be at the end of the class" — fixed in 764337b: moved BlockFermionicUniTensor's _signflip private: block (it was sandwiched between two public: sections) to the end. Pure reorder; the reset_/erase_signflip_ gateway and #841 lockstep are unchanged.

Deferred encapsulation comments (Gesvd bond-direction change; Gesvd_truncate/BlockFermionicUniTensor block removal; a remove_blocks API; moving the linalg test to BlockUniTensor) — these are the "other encapsulation issue can be fixed in the future" items from your approval. They're genuine follow-up work (block tensors aren't yet fully encapsulated on bond directions / block removal), better scoped to a dedicated PR than bundled here. Happy to open a tracking issue capturing them.

@yingjerkao

Copy link
Copy Markdown
Collaborator Author

The deferred block-tensor encapsulation comments (Gesvd bond retype, Gesvd_truncate block removal, remove_blocks API, and the test-location note) are now tracked in #1051 — to be tackled in a dedicated PR rather than expanding #1001's scope.

@yingjerkao
yingjerkao merged commit 98de76f into master Jul 13, 2026
17 checks passed
@yingjerkao
yingjerkao deleted the refactor/bond-immutable branch July 13, 2026 12:50
yingjerkao added a commit that referenced this pull request Jul 14, 2026
Brings in the latest master (98de76f): #1001 (Bond made immutable +
BlockFermionicUniTensor signflip encapsulated via reset_signflip_/
erase_signflip_), #1047 (Exp/Expf consolidated into one dtype-preserving
Exp; Expf/Expf_ deprecated), #1049 (UniTensor//UniTensor floor-division
guarded), and the build-test-workflow / cross-revision-benchmark skills.

Conflict resolution:
- tests/DenseUniTensor_test.cpp: both sides added a test after
  combineBond_is_in_place -- this PR's combineBond_is_out_of_place and
  master's #846 aliasing regression (CombineBondForceDoesNotRewriteUserHeldBond).
  Kept both.

Adaptations required by the merge (master's incoming tests were written
against master's in-place combineBond(); this PR redefined bare combineBond()
as out-of-place per #421/#422):
- tests/DenseUniTensor_test.cpp + tests/BlockUniTensor_test.cpp: the #846
  CombineBondForceDoesNotRewriteUserHeldBond tests assert the receiver itself
  mutates (ut.bonds()[0].dim() == combined), so they now call the in-place
  combineBond_() spelling instead of bare combineBond() (which now returns a
  new tensor and leaves the receiver unchanged). The #846 aliasing invariant
  is unchanged -- the in-place force path still clones the bond entry before
  force_combineBond_.
- pytests/apply_test.py: master's newly-moved file called the deprecated
  .Norm().item(); migrated to .norm() (a native float in Python) to keep the
  suite clean under -W error::DeprecationWarning, matching this PR's migration
  of every other in-tree Norm() caller.

Auto-merged cleanly (verified as a proper union, no dropped hunks): the PR's
norm()->Scalar + underscore renames (set_name_/set_label_/tag_) coexist with
master's Bond-immutable retype()/signflip refactor across the linalg
decomposition files (Svd/Gesvd/Qr/Qdr/Rsvd/*_truncate); the floordiv guard
(UniTensor//UniTensor raises, scalar // kept) sits with the amended #934
elementwise policy in unitensor_py.cpp; Exp consolidation + norm/Norm bindings
coexist in linalg.hpp/linalg_py.cpp.

Verification (CI-matching openblas-cpu, non-ASan):
- C++ test_main: 1248 tests ran, 1237 passed / 11 skipped / 0 failed.
- pybind module compiles clean; full pytests/ suite 185 passed / 1 skipped.
- merge-adapted test files pass under -W error::DeprecationWarning.
- clang-format v14 clean.

Note (pre-existing, out of scope; flagged for a follow-up): under an ASan
build, BlockUniTensor::combineBonds (src/BlockUniTensor.cpp:1958,1990) trips
memcpy-param-overlap -- it memcpy()s an overlapping intra-vector shift where
memmove() is required. This is latent UB unchanged by this merge (identical on
master); the forward copy yields correct results, so it is benign on the
non-ASan CI build and only ASan flags it. master's #846 test (combining two
bonds of a 4-bond tensor, leaving a trailing bond) is the first case to
surface it.

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

Make Bond immutable: in-place bond mutation breaks the derived block-tensor symmetry invariant

3 participants