Skip to content

refactor(Scalar)!: replace PIMPL virtual dispatch with std::variant (#847, fixes #935)#1011

Merged
yingjerkao merged 5 commits into
masterfrom
refactor/scalar-variant
Jul 14, 2026
Merged

refactor(Scalar)!: replace PIMPL virtual dispatch with std::variant (#847, fixes #935)#1011
yingjerkao merged 5 commits into
masterfrom
refactor/scalar-variant

Conversation

@yingjerkao

Copy link
Copy Markdown
Collaborator

Problem

Fixes #847 and #935. Scalar was a raw-pointer PIMPL over 11 virtual subclasses (108 virtuals, ~2,900 LOC) with hand-rolled enum-order promotion everywhere. #935 (@ianmccul) cataloged the consequences: self-assignment UB, a double-allocating copy constructor, in-place promotion inconsistent with type_promote, minval returning the wrong value for floats, abs/iabs dtype inconsistency.

What changed

Scalar now holds a by-value std::variant (monostate = Void). 61% code reduction (3,669 → 1,415 lines), heavy instantiation confined to one TU, sizeof(Scalar) 8 B + heap → 24 B flat. Copy/move/assign are = default (the UB and double-alloc are structurally gone). All promotion routes through Type.type_promotestatic_asserts pin the variant layout to Type_list index-for-index, so dtype() is literally the variant index and adding a dtype fails compilation until handled. Type_class::norm_result_dtype centralizes the Norm output-dtype policy (previously derived by hand in three files).

Behavior changes (BREAKING)

  • Mixed-type in-place ops throw when the promoted dtype differs from the LHS (maintainer ruling 2026-07-07, adopting the semantics of PR Disable broken/unsafe Scalar operations [also a bit of memory corruption] #937 — which this PR supersedes; its test suite is absorbed with credit to @ianmccul). Same-width signed/unsigned mixes (e.g. Int64 += Uint64) follow the type_promote table and remain allowed by explicit maintainer ruling, keeping a += ba = a + b; the resulting Scalar(int64(-1)) == Scalar(uint64_max) quirk is test-pinned and inherent to the sanctioned table.
  • minval for floating dtypes now returns lowest(); abs() on complex returns the real dtype; iabs() keeps the dtype (in-place ops never change dtype) — each documented and pinned.

Testing

516 new gtests (two 121-pair programmatic promotion/throw matrices + named behavior pins) and 43 pytests. Full suite: 1602 passed / 11 skipped / only the 4 pre-existing failures addressed by #977; pytest 100. Four in-tree callers migrated (normalize_ seeding, diag-loop accumulation, linalg header aliases), each with a why-comment. The pybind surface is untouched; two pre-existing binding quirks (numpy-int ctor collapse — value-corrupting for large uint64 — and missing Scalar.__bool__) are pinned as known quirks and tracked separately.

🤖 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 replaces the Scalar class's PIMPL and virtual-dispatch hierarchy with a std::variant-backed value type, centralizing type promotion and enforcing strict rules against lossy in-place operations. Callers like UniTensor and Norm are updated to align with these rules, and comprehensive tests are added. The review feedback highlights critical C++ compilation and compatibility issues: binary_cmp must use forbid_complex as a template parameter with if constexpr to avoid compilation failures with complex types, a custom type_identity helper is needed to maintain C++17 compatibility instead of using C++20's std::type_identity, and an explicit static_cast should be added in isqrt to prevent implicit narrowing warnings.

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.

Comment thread src/backend/Scalar.cpp Outdated
Comment on lines +188 to +190
template <typename CmpOp>
bool binary_cmp(const Scalar &lhs, const Scalar &rhs, CmpOp &&op, const char *opname,
bool forbid_complex) {

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.

high

The forbid_complex parameter is currently a runtime bool variable. Because of this, both branches of if (forbid_complex) must be compiled by the compiler. When binary_cmp is instantiated for ordering comparisons (like < or <=) with CmpOp as [](auto &&a, auto &&b) { return a < b; }, the compiler attempts to compile lv < rv for std::complex types, which fails to compile because std::complex does not define operator<.

To fix this, forbid_complex should be made a template parameter so that if constexpr (forbid_complex) can be used to discard the invalid comparison branch at compile time.

    template <bool forbid_complex, typename CmpOp>
    bool binary_cmp(const Scalar &lhs, const Scalar &rhs, CmpOp &&op, const char *opname) {

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.

Applied in 2f09a7d. binary_cmp now takes forbid_complex as a template parameter and branches with if constexpr, and the four ordering callers use binary_cmp<true> / equality uses binary_cmp<false>.

Worth recording why this was latent rather than an outright build break: with the runtime bool, the op(complex, complex) in the ordering branch did compile — but only because complex128 < complex128 silently resolves to operator<(const Scalar&, const Scalar&) via the implicit Scalar converting constructors, i.e. it recursed back into Scalar::less. So CI was green, but the intended "complex has no ordering" contract was expressed only at runtime and rode on an accidental conversion. if constexpr discards the branch for the ordering lambdas, so op(complex,complex) is never instantiated for them and the recursion path is gone. Verified with a standalone probe (fails to compile without Scalar.hpp in scope; the accidental operator<(Scalar,Scalar) is what made it compile with it).

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.

oh - conversions to scalar should be explicit!

Comment thread src/backend/Scalar.cpp Outdated
Comment on lines +193 to +197
if (forbid_complex) {
cytnx_error_msg(Type.is_complex(lhs.dtype()) || Type.is_complex(rhs.dtype()),
"[ERROR] Scalar %s: comparison not supported for complex type%s", opname,
"\n");
}

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.

high

Use if constexpr with the template parameter forbid_complex to perform compile-time branching.

Suggested change
if (forbid_complex) {
cytnx_error_msg(Type.is_complex(lhs.dtype()) || Type.is_complex(rhs.dtype()),
"[ERROR] Scalar %s: comparison not supported for complex type%s", opname,
"\n");
}
if constexpr (forbid_complex) {
cytnx_error_msg(Type.is_complex(lhs.dtype()) || Type.is_complex(rhs.dtype()),
"[ERROR] Scalar %s: comparison not supported for complex type%s", opname,
"\n");
}

Comment thread src/backend/Scalar.cpp Outdated
Comment on lines +218 to +224
if (forbid_complex) {
cytnx_error_msg(true,
"[ERROR] Scalar %s: comparison not supported for complex type%s",
opname, "\n");
} else {
result = op(lv, rv);
}

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.

high

Use if constexpr with the template parameter forbid_complex to discard the invalid comparison branch at compile-time for complex types.

Suggested change
if (forbid_complex) {
cytnx_error_msg(true,
"[ERROR] Scalar %s: comparison not supported for complex type%s",
opname, "\n");
} else {
result = op(lv, rv);
}
if constexpr (forbid_complex) {
cytnx_error_msg(true,
"[ERROR] Scalar %s: comparison not supported for complex type%s",
opname, "\n");
} else {
result = op(lv, rv);
}

Comment thread src/backend/Scalar.cpp
Comment on lines +505 to +524
bool Scalar::less(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a < b; }, "less-than (<)", true);
}
bool Scalar::leq(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a <= b; }, "less-equal (<=)", true);
}
bool Scalar::greater(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a > b; }, "greater-than (>)", true);
}
bool Scalar::geq(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a >= b; }, "greater-equal (>=)", true);
}
bool Scalar::eq(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a == b; }, "equal (==)", false);
}

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.

high

Update the calls to binary_cmp to pass forbid_complex as a template argument.

Suggested change
bool Scalar::less(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a < b; }, "less-than (<)", true);
}
bool Scalar::leq(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a <= b; }, "less-equal (<=)", true);
}
bool Scalar::greater(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a > b; }, "greater-than (>)", true);
}
bool Scalar::geq(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a >= b; }, "greater-equal (>=)", true);
}
bool Scalar::eq(const Scalar &rhs) const {
return binary_cmp(
*this, rhs, [](auto &&a, auto &&b) { return a == b; }, "equal (==)", false);
}
bool Scalar::less(const Scalar &rhs) const {
return binary_cmp<true>(
*this, rhs, [](auto &&a, auto &&b) { return a < b; }, "less-than (<)");
}
bool Scalar::leq(const Scalar &rhs) const {
return binary_cmp<true>(
*this, rhs, [](auto &&a, auto &&b) { return a <= b; }, "less-equal (<=)");
}
bool Scalar::greater(const Scalar &rhs) const {
return binary_cmp<true>(
*this, rhs, [](auto &&a, auto &&b) { return a > b; }, "greater-than (>)");
}
bool Scalar::geq(const Scalar &rhs) const {
return binary_cmp<true>(
*this, rhs, [](auto &&a, auto &&b) { return a >= b; }, "greater-equal (>=)");
}
bool Scalar::eq(const Scalar &rhs) const {
return binary_cmp<false>(
*this, rhs, [](auto &&a, auto &&b) { return a == b; }, "equal (==)");
}

Comment thread src/backend/Scalar.cpp
namespace cytnx {

cytnx_complex128 complex128(const Scalar& in) { return in._impl->to_cytnx_complex128(); }
namespace {

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.

medium

To ensure compatibility with C++17 (since std::type_identity is a C++20 feature), we can define a simple type_identity helper struct in the anonymous namespace.

Suggested change
namespace {
namespace {
template <typename T>
struct type_identity {
using type = T;
};

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.

The project targets C++20 (CMAKE_CXX_STANDARD 20), so std::type_identity was never a compatibility problem here. But per maintainer direction we removed the hand-written switch dispatch entirely rather than keep tagging types: astype() now dispatches by visiting the variant — it materializes a value-initialized prototype ScalarVariant at the target index and std::visits the (source, destination) pair to recover both compile-time types. That drops std::type_identity/VisitByDtype altogether (2f09a7d), and the dtype→type mapping is now the variant’s own index↔alternative correspondence, already pinned by the static_asserts in Scalar.hpp, so it can’t drift from Type_list.

Comment thread src/backend/Scalar.cpp Outdated
Comment on lines +118 to +148
fn(std::type_identity<cytnx_complex128>{});
return;
case Type.ComplexFloat:
fn(std::type_identity<cytnx_complex64>{});
return;
case Type.Double:
fn(std::type_identity<cytnx_double>{});
return;
case Type.Float:
fn(std::type_identity<cytnx_float>{});
return;
case Type.Int64:
fn(std::type_identity<cytnx_int64>{});
return;
case Type.Uint64:
fn(std::type_identity<cytnx_uint64>{});
return;
case Type.Int32:
fn(std::type_identity<cytnx_int32>{});
return;
case Type.Uint32:
fn(std::type_identity<cytnx_uint32>{});
return;
case Type.Int16:
fn(std::type_identity<cytnx_int16>{});
return;
case Type.Uint16:
fn(std::type_identity<cytnx_uint16>{});
return;
case Type.Bool:
fn(std::type_identity<cytnx_bool>{});

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.

medium

Use the custom type_identity helper instead of std::type_identity to maintain C++17 compatibility.

          case Type.ComplexDouble:
            fn(type_identity<cytnx_complex128>{});
            return;
          case Type.ComplexFloat:
            fn(type_identity<cytnx_complex64>{});
            return;
          case Type.Double:
            fn(type_identity<cytnx_double>{});
            return;
          case Type.Float:
            fn(type_identity<cytnx_float>{});
            return;
          case Type.Int64:
            fn(type_identity<cytnx_int64>{});
            return;
          case Type.Uint64:
            fn(type_identity<cytnx_uint64>{});
            return;
          case Type.Int32:
            fn(type_identity<cytnx_int32>{});
            return;
          case Type.Uint32:
            fn(type_identity<cytnx_uint32>{});
            return;
          case Type.Int16:
            fn(type_identity<cytnx_int16>{});
            return;
          case Type.Uint16:
            fn(type_identity<cytnx_uint16>{});
            return;
          case Type.Bool:
            fn(type_identity<cytnx_bool>{});
            return;

Comment thread src/backend/Scalar.cpp Outdated
} else if constexpr (std::is_same_v<T, cytnx_bool>) {
cytnx_error_msg(true, "[ERROR] isqrt not supported for Bool type%s", "\n");
} else {
v = std::sqrt(v);

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.

medium

When T is an integer type, std::sqrt(v) returns a double. Assigning it directly to v causes an implicit narrowing conversion. Adding an explicit static_cast<T> avoids compiler warnings.

Suggested change
v = std::sqrt(v);
v = static_cast<T>(std::sqrt(v));

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.

Applied in 2f09a7dv = static_cast<T>(std::sqrt(v));. isqrt() is a unary in-place transform and stays dtype-preserving, so the narrow-back is the intended behavior (now explicit).

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.20690% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.85%. Comparing base (33f5c23) to head (95e2c46).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/backend/Scalar.cpp 86.09% 26 Missing ⚠️
src/BlockFermionicUniTensor.cpp 0.00% 1 Missing ⚠️
src/BlockUniTensor.cpp 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1011      +/-   ##
==========================================
+ Coverage   57.71%   59.85%   +2.14%     
==========================================
  Files         229      229              
  Lines       33468    31737    -1731     
  Branches       71       71              
==========================================
- Hits        19315    18997     -318     
+ Misses      14132    12719    -1413     
  Partials       21       21              
Flag Coverage Δ
cpp 59.81% <86.20%> (+2.16%) ⬆️
python 63.35% <ø> (ø)

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

Components Coverage Δ
C++ backend 57.64% <86.20%> (+2.16%) ⬆️
Python bindings 73.32% <ø> (+1.33%) ⬆️
Python package 63.35% <ø> (ø)
Files with missing lines Coverage Δ
include/Type.hpp 97.77% <100.00%> (+0.21%) ⬆️
include/backend/Scalar.hpp 86.53% <ø> (+61.37%) ⬆️
src/DenseUniTensor.cpp 86.31% <100.00%> (ø)
src/backend/StorageImplementation.cpp 67.05% <100.00%> (-3.33%) ⬇️
src/backend/linalg_internal_cpu/Add_internal.hpp 100.00% <ø> (ø)
src/backend/linalg_internal_cpu/Cpr_internal.hpp 41.37% <ø> (ø)
...ackend/linalg_internal_cpu/Gemm_Batch_internal.hpp 100.00% <ø> (ø)
src/backend/linalg_internal_cpu/Mul_internal.hpp 43.33% <ø> (ø)
src/backend/linalg_internal_cpu/Sub_internal.hpp 43.33% <ø> (ø)
src/linalg/Norm.cpp 93.33% <100.00%> (-1.41%) ⬇️
... and 3 more

... and 4 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 33f5c23...95e2c46. 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.

@ianmccul

ianmccul commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

I'm not understanding why Scalar in-place arithmetic is required to preserve the LHS dtype here.

#937 diabled a bunch of Scalar in-place arithmetic, only because of dtype bugs that would produce results different to Python and different to the equivalent out-of-place operation. This PR should fix that, which makes #937 irrelevant in that respect. (#937 also shows some memory corruption in Tensor operations, but that is separate problem.)

Python behavior allows in-place arithmetic to change type, and in principle it is fine here too, it just changes the type held in the variant. There is a general principle in Python that a = a op b can change the type of a, and a op= b has the same effect.

There almost certainly are bugs still in Tensor op Scalar in-place arithmetic which need to be fixed anyway, but that is beyond the scope of this PR (and not addressed in #937, only discovered in the later comments on that PR).

As a check, #937 includes a few tests that are of the pattern ExpectCorrectDoubleOrDisabled, where it tries a calculation, and expects that it will either produce the correct result or it will throw an error. You could try some of these, with and without the type_promote(lhs, rhs) == lhs.dtype() check. My expectation is that they would succeed in both cases.

  • It was not “in-place Scalar ops must preserve dtype.”
  • It was “the existing implementation gives wrong answers for some mixed in-place Scalar ops, so as an emergency fix disable those dangerous paths until there is a real fix.”
  • For Scalar, the concrete bad path was LHS-typed virtual dispatch converting RHS to LHS type.
  • For Tensor, it is worse because storage-backed dtype/buffer mismatches can become memory corruption.
  • refactor(Scalar)!: replace PIMPL virtual dispatch with std::variant (#847, fixes #935) #1011’s variant rewrite is exactly the kind of real fix where Scalar a /= b can work properly.

@pcchen

pcchen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Replaces Scalar's raw-pointer PIMPL over 11 virtual subclasses with a by-value std::variant (monostate = Void), resolving the #935 defect catalog and #847. Approve-with-nits — a high-quality, faithful refactor.

Core design — verified

API surface & caller migrations — verified item-by-item

All 11 dtype ctors + assignments, the 9 explicit cast operators, astype/conj/real/imag/abs/iabs/sqrt/isqrt, comparisons, radd/rmul/rsub/rdiv, static maxval/minval (floating minval = lowest() is the documented #935 fix), Sproxy member-for-member, all free operators, and byte-identical print/ostream format — preserved. Norm.cpp, the linalg header aliases (Scalar_init_interface::type_promote_tType_class::type_promote_t is the same inherited metafunction), and StorageImplementation's std::visit forward all check out behavior-preserving (only error-message wording changes).

Findings (none blocking)

  1. 🟡 normalize_ int-dtype CPU/GPU divergence (new, edge case). The migrated seed makes the divisor a Double scalar (BlockUniTensor.cpp:1240, BlockFermionicUniTensor.cpp:1813), so an integer-dtype UniTensor now hits the pre-existing iDiv asymmetry: CPU keeps Int blocks (truncating), GPU promotes the tensor to Double (the Scalar in-place Tensor arithmetic detaches storage-sharing views #906-tracked rebind). Old code's int-dtype divisor never changed dtype on either device. Degenerate use case (the old result was truncated garbage anyway) — please add a comment at the seed site or a line in Scalar in-place Tensor arithmetic detaches storage-sharing views #906.
  2. 🟡 Dropped member-template operators. The eight template<class T> operator+/-/*// and </>/<=/>= members are gone; s + 3 now resolves via implicit Scalar(3) into the free operators. Results are identical for all 11 dtypes and non-listed types didn't compile before either — but diagnostics and SFINAE-visible surface changed. One line in the PR description confirming intent would do.
  3. 🟢 Diag-loop nit (DenseUniTensor.cpp:1386,1443): v = v + rhs truncates the sum where old v += rhs truncated the operand — bit-identical for same-dtype (the normal case); mixed int/float can differ by 1 with the new value arguably more correct.
  4. Cross-PR coordination:
  5. Changelog: the breaking changes (in-place throws, floating minval, abs dtype) are well-flagged here but need the release-notes entry, ideally combined with the stacked fix(pybind): keep-set ordering for Scalar constructors, add __bool__ #1014's constructor-dtype fixes.

Verdict

Structurally eliminates the #935 bug class (self-assignment UB, double-alloc copy — gone by = default), 61% less code, one-TU instantiation, compile-time dtype completeness, and an exhaustively pinned behavior contract. Approve-with-nits.

Posted by Claude Code on behalf of @pcchen

@ianmccul

ianmccul commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

It would be interesting to see some benchmarks. Scalar arithmetic is probably 100-1000x faster now...

yingjerkao added a commit that referenced this pull request Jul 8, 2026
… instead of throw

Addresses review on #1011.

Dispatch (@gemini-code-assist): replace the hand-written dtype->type switch
(which used std::type_identity tags) in astype() with a std::visit over a
value-initialized "prototype" variant materialized at the target index. The
dtype<->type mapping is now the variant's own index<->alternative
correspondence (already pinned by the static_asserts in Scalar.hpp), so it
can't drift from Type_list. This also removes std::type_identity entirely
(the project is C++20, so it was never a compatibility problem -- the switch
is simply gone).

In-place ops (@ianmccul): +=/-=/*=//= no longer throw on a mixed dtype. #937
disabled those paths only because the old PIMPL implementation gave wrong
answers; the variant rewrite fixes that, so in-place arithmetic now follows
Python value-type semantics -- `a op= b` == `a = a op b`, promoting the
destination via Type.type_promote (maintainer ruling 2026-07-08). Delegates
to radd/rsub/rmul/rdiv, keeping in-place and out-of-place bit-for-bit
consistent; the fresh out-of-place result makes `a += a` alias-safe.

binary_cmp (@gemini-code-assist): make forbid_complex a template parameter so
the complex-ordering branch is discarded with `if constexpr`. Previously the
runtime bool forced the compiler to type-check `op(complex, complex)` for the
ordering lambdas, which only compiled by accidentally routing through
operator<(Scalar, Scalar) via the implicit Scalar ctors -- a latent
infinite-recursion hazard, now removed. isqrt narrows via explicit
static_cast.

iabs()/isqrt() stay dtype-preserving: they are unary in-place value
transforms with no second operand to promote against; the abs()/iabs()
consistency (#935) remains by value.

Tests: convert the in-place throw pins (4 named + the 121-pair matrix in
Scalar_test.cpp; 5 in scalar_variant_test.py) to assert promotion, matching
out-of-place. Migrated UniTensor callers keep their out-of-place computation;
stale "in-place throws" comments updated. Full C++ suite: 1606 passed, 0
failed, 11 pre-existing skips; 537 Scalar gtests green.

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

Copy link
Copy Markdown
Collaborator Author

Pushed 2f09a7d in response to the review.

In-place arithmetic no longer throws (@ianmccul)

You're right — "in-place ops must preserve the LHS dtype" was never the actual goal. #937 only disabled those paths because the old PIMPL gave wrong answers; the variant rewrite fixes that, so there's no reason to throw. In-place arithmetic now follows Python value-type semantics: a op= b is exactly a = a op b, promoting the destination via Type.type_promote (it just changes the type held in the variant). Implemented by delegating +=/-=/*=//= to radd/rsub/rmul/rdiv, so in-place and out-of-place are bit-for-bit consistent, and the fresh out-of-place result keeps a += a alias-safe.

Concretely:

  • Int64 += DoubleDouble; Double *= ComplexDoubleComplexDouble; Uint64 -= Int64Int64 (per the sanctioned table).
  • Lossless-widening / same-dtype cases still keep the LHS dtype, because type_promote(L,R) == L there — so nothing regresses for those.

I re-checked against your ExpectCorrectDoubleOrDisabled-style pattern: with the throw removed, the previously-"disabled" mixed in-place cases now produce the correct promoted result (same as the out-of-place op), which is exactly what you predicted. The tests/Scalar_test.cpp in-place throw matrix and the four named "…IsDisabled" tests are converted to assert promotion (they now check a += b == a = a + b in both dtype and value); same for the five Python pins. The Tensor op Scalar in-place issues you mention are indeed separate and out of scope here.

Ruling recorded in-code (maintainer, 2026-07-08). The removed guard's #937 credit is retained in the test-suite header.

std::visit dispatch + binary_cmp

  • astype() now dispatches by visiting the variant (prototype-at-index + std::visit), removing the hand-written switch/std::type_identity — replies on the Gemini threads have detail.
  • binary_cmp's forbid_complex is now a template parameter (if constexpr), which removes a latent infinite-recursion path where complex < complex only compiled by routing through operator<(Scalar,Scalar).

Benchmarks (@ianmccul)

Agreed it'd be nice to quantify — with the heap PIMPL + virtual dispatch gone in favor of a 24 B inline variant, scalar arithmetic should be dramatically cheaper. I'd rather land the correctness change first and add a microbenchmark as a follow-up than expand this PR's surface; happy to file an issue to track it.

@pcchen

Your approve-with-nits findings still stand and are unaffected by this change (the normalize_ seed / diag-loop notes are documentation/coordination; the seed comment is updated to drop the now-removed "would throw" wording). The in-place-throw-vs-promote question your review flagged as a policy divergence is resolved in the promote direction here.

Full C++ suite after the change: 1606 passed, 0 failed, 11 pre-existing skips (537 Scalar gtests green).

yingjerkao and others added 2 commits July 8, 2026 20:33
…847, fixes #935)

Replace Scalar's Scalar_base* PIMPL (11 derived classes, 108 virtuals,
~2900 LOC, one heap allocation per Scalar) with a std::variant held by
value, per #847's proposed layout:

  std::variant<std::monostate, cytnx_complex128, cytnx_complex64,
               cytnx_double, cytnx_float, cytnx_int64, cytnx_uint64,
               cytnx_int32, cytnx_uint32, cytnx_int16, cytnx_uint16,
               cytnx_bool>

std::monostate represents the Void/uninitialized state (dtype() returns
Type.Void). ScalarVariant is index-aligned with Type_list -- pinned by
static_asserts in the header -- so dtype() is simply the active
alternative's index. All public signatures are preserved: the 11 typed
ctors, Scalar(T, dtype), explicit conversion operators,
astype/conj/abs/sqrt (+ in-place forms), real()/imag(), dtype(),
maxval/minval(dtype), print/ostream, all arithmetic and comparison
operators, and Scalar::Sproxy. sizeof(Scalar) goes from 8 B (pointer)
+ a heap object to 24 B fully inline with true value semantics
(copy/move/assign are `= default`, trivially correct).

- self-assignment UB in operator=: eliminated by `= default`.
- double-allocation copy ctor: eliminated by `= default`.
- broken in-place promotion (Int64 /= Double silently doing integer
  division, RHS converted to destination dtype): in-place ops now throw
  instead of silently truncating (Ruling 1 below).
- mixed-precision in-place float loss (Float += Double): now throws.
- minval(Type.Double/Float) returned numeric_limits::min() (smallest
  positive normal); now returns ::lowest() (most negative finite).
- abs()/iabs() dtype inconsistency on complex: abs() always returns the
  real dtype magnitude (Double/Float). iabs() keeps the complex dtype
  (no in-place op changes dtype) and stores the magnitude with zero
  imaginary part; its value now provably matches abs()'s. Documented in
  the header and pinned by tests.
- hand-rolled enum-order promotion everywhere: every promotion decision
  (binary ops, comparisons, in-place guard) now routes through the
  single constexpr Type.type_promote() via std::visit; this also makes
  ComplexFloat + Double correctly yield ComplexDouble.

Ruling 1 (maintainer decision 2026-07-07; BREAKING, also visible from
Python where it raises RuntimeError): the in-place operators += -= *= /=
throw cytnx::error when the RHS dtype does not convert into the LHS
dtype, i.e. when Type.type_promote(lhs, rhs) != lhs.dtype().
Throwing classes: unsigned-LHS mixed with signed ints, int-LHS with
float RHS, real-LHS with complex RHS, Bool-LHS with non-Bool.
Conversions that are value-preserving per the sanctioned type_promote
table (see include/Type.hpp type_promote; maintainer sign-off
2026-07-07) remain valid: same dtype; Int64 += Int32; Double += Float;
ComplexDouble += Double; signed-LHS with a same-or-narrower unsigned
RHS. Same-width signed/unsigned in-place mixes follow the type_promote
table by explicit maintainer ruling (2026-07-07): in-place and
out-of-place stay consistent, and any future tightening happens in the
table. The thrown message carries only the actionable core (both dtype
names, the would-be result dtype, the out-of-place/astype() remedy);
the provenance lives in the guard's comment block.

PR #937 (credit: @ianmccul for the guard semantics and the original
tests/Scalar_test.cpp) is superseded by this commit; its test suite is
absorbed and extended here. Deliberate divergences from #937's broader
guard, following the ruling and the Phase-3 plan: out-of-place binary
arithmetic and comparisons never throw for real dtypes (they promote
via type_promote; #937 disabled mixed signed/unsigned and Bool cases
outright); in-place ops whose RHS-to-LHS conversion follows the
type_promote table (e.g. Int64 += Int32) are allowed because the
ruling's throw categories exclude them -- #937 had disabled any
differing integer dtype; and integer abs()/sqrt() remain functional
(#937 disabled them). Ordering comparisons on complex still throw;
complex equality is allowed (as before). A known consequence of the
table -- Scalar(Int64(-1)) == Scalar(Uint64 max) evaluating true via
the Int64 promotion -- is pinned by test with maintainer sign-off.

Storage_base::set_item(idx, const Scalar&) is re-implemented via
std::visit on the Scalar's active alternative, forwarding the concrete
value into the existing typed SetItem<T> (monostate throws instead of
writing garbage). get_item already constructed the Scalar from the
typed at<T>() and is unchanged.

Migrated in-tree callers that relied on lossy in-place mixes (none were
covered by existing tests; test tally is baseline + new, nothing
removed):
- BlockUniTensor::normalize_() and BlockFermionicUniTensor::
  normalize_(): the norm^2 accumulator is now seeded with the floating
  dtype linalg::Norm() actually produces instead of this->dtype(),
  which for an integer-dtype UniTensor was an Int += Double lossy mix.
  The Norm output-dtype policy is centralized in a new constexpr
  Type_class::norm_result_dtype() (include/Type.hpp), used by both
  normalize_ sites and by src/linalg/Norm.cpp itself (replacing its
  hand-rolled enum-order dtype checks).
- DenseUniTensor::Add_/Sub_ (dense += diagonal element loop): switched
  to out-of-place `v = v +/- rhs` since the two UniTensors' dtypes may
  legitimately differ (per #937's own migration guidance).
- linalg_internal_cpu/{Add,Sub,Mul,Cpr}_internal.hpp: replaced the
  incidental Scalar_init_interface::type_promote_t alias (inherited
  from Type_class by the deleted PIMPL factory class) with
  Type_class::type_promote_t directly.
- linalg_internal_cpu/Gemm_Batch_internal.hpp: replaced direct
  x._impl->to_cytnx_complex128()/64() with the public complex128()/
  complex64() accessors.

Tests: absorbs tests/Scalar_test.cpp (based on #937's, adjusted to the
ruling's matrix) and extends it with a programmatic 11x11 dtype matrix
over + - * / and comparisons (asserting result dtype ==
Type.type_promote(a,b) and value correctness), an 11x11 in-place
throw matrix (throws iff type_promote(lhs,rhs) != lhs), and a pinned
mixed-signedness equality quirk test. Adds
pytests/scalar_variant_test.py covering Python arithmetic across
dtypes, the new in-place RuntimeError behavior, Storage indexed
assignment from Scalar, and numpy-scalar constructor paths (pinning a
pre-existing pybind quirk where non-int64 integer numpy scalars resolve
to the Int64 ctor overload). pybind/scalar_py.cpp is unchanged: none of
its 84 bindings touched Scalar internals.

Gates: C++ 1617 ran / 1602 passed (1086 baseline + 516 new) / 11
skipped / same 4 known linalg_Test.BkUt_* failures (#977). pytest 100
passed (57 baseline + 43 new). grep Scalar_base in include/ src/
pybind/ -> zero hits. build_py warning-clean.

BREAKING CHANGE: mixed-dtype in-place Scalar arithmetic that would
change or truncate the LHS dtype now throws cytnx::error (Python:
RuntimeError) instead of silently truncating. Use out-of-place
arithmetic or astype().

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

Addresses review on #1011.

Dispatch (@gemini-code-assist): replace the hand-written dtype->type switch
(which used std::type_identity tags) in astype() with a std::visit over a
value-initialized "prototype" variant materialized at the target index. The
dtype<->type mapping is now the variant's own index<->alternative
correspondence (already pinned by the static_asserts in Scalar.hpp), so it
can't drift from Type_list. This also removes std::type_identity entirely
(the project is C++20, so it was never a compatibility problem -- the switch
is simply gone).

In-place ops (@ianmccul): +=/-=/*=//= no longer throw on a mixed dtype. #937
disabled those paths only because the old PIMPL implementation gave wrong
answers; the variant rewrite fixes that, so in-place arithmetic now follows
Python value-type semantics -- `a op= b` == `a = a op b`, promoting the
destination via Type.type_promote (maintainer ruling 2026-07-08). Delegates
to radd/rsub/rmul/rdiv, keeping in-place and out-of-place bit-for-bit
consistent; the fresh out-of-place result makes `a += a` alias-safe.

binary_cmp (@gemini-code-assist): make forbid_complex a template parameter so
the complex-ordering branch is discarded with `if constexpr`. Previously the
runtime bool forced the compiler to type-check `op(complex, complex)` for the
ordering lambdas, which only compiled by accidentally routing through
operator<(Scalar, Scalar) via the implicit Scalar ctors -- a latent
infinite-recursion hazard, now removed. isqrt narrows via explicit
static_cast.

iabs()/isqrt() stay dtype-preserving: they are unary in-place value
transforms with no second operand to promote against; the abs()/iabs()
consistency (#935) remains by value.

Tests: convert the in-place throw pins (4 named + the 121-pair matrix in
Scalar_test.cpp; 5 in scalar_variant_test.py) to assert promotion, matching
out-of-place. Migrated UniTensor callers keep their out-of-place computation;
stale "in-place throws" comments updated. Full C++ suite: 1606 passed, 0
failed, 11 pre-existing skips; 537 Scalar gtests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pcchen
pcchen force-pushed the refactor/scalar-variant branch from 2f09a7d to ea42698 Compare July 8, 2026 12:37
@pcchen

pcchen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Review follow-up — delta re-review of ea426983 + rebase note

My review above was written against the throw-semantics head; the new commit partially supersedes it. Delta verified:

1. In-place promote (supersedes my "in-place throw rule" section and the cross-PR divergence note).
The implementation is exactly a op= ba = a op b:

void Scalar::operator+=(const Scalar &rhs) { *this = this->radd(rhs); }

— delegating to the already-verified out-of-place radd/rsub/rmul/rdiv, which guarantees bit-for-bit in-place/out-of-place consistency and makes a += a alias-safe (fresh result before the copy-assign, which is =default on the variant). The 07-07 throw ruling is cleanly superseded by the 07-08 promote ruling, recorded in the commit. And the batch-wide semantic map is now more coherent, resolving the divergence I flagged: Scalar ⊕= Scalar promotes, Tensor ⊕= Tensor promotes (#1015), Tensor ⊕= python-scalar preserves LHS (#980) — strong operands promote, weak Python scalars don't. iabs/isqrt staying dtype-preserving (unary, nothing to promote against) is the right line, and keeps the #935 abs/iabs consistency.

2. astype via prototype-variant visit — the dtype→type switch (with std::type_identity tags) is replaced by materializing the target alternative at its variant index, so the mapping is the index correspondence already pinned by the static_asserts — it cannot drift from Type_list. Good simplification.

3. Bonus real fix: making forbid_complex a template parameter so the complex-ordering branch is if constexpr-discarded removes a latent infinite-recursion hazard (the runtime-bool version type-checked op(complex, complex) through the implicit Scalar ctors into operator<(Scalar, Scalar)). Verified removed.

4. Tests: the 4 named throw pins + the 121-pair throw matrix + 5 pytests are converted to promotion assertions, matching out-of-place.

Still standing from my review: the normalize_ int-dtype CPU/GPU edge (migrated callers keep out-of-place computation, so the finding is unchanged — wants a comment or a #906 line), the dropped member-template operators note, and the changelog item (now: in-place promotes + floating minval + abs dtype — a different breaking-change list than before).

Rebase done: the branch is now master + 2 commits (head ea426983) — the only conflict was Scalar.hpp vs the merged #984, resolved by taking this PR's rewrite (whose type_promote routing subsumes #984's hunks, per my review); Type.hpp/UniTensor/tests/CMakeLists.txt auto-merged with both sides verified intact. Stacked #1014/#1015 will need their own rebases onto this head.

Verdict unchanged in strength — approve-with-nits once the fresh CI on ea426983 is green. (@ianmccul's benchmark request would be a nice addition to the PR body but isn't blocking.)

Posted by Claude Code on behalf of @pcchen


// init!!
/// @brief init a Scalar with a cytnx::cytnx_complex128
Scalar(const cytnx_complex128 &in) : _impl(new Scalar_base()) { this->Init_by_number(in); }
Scalar(const cytnx_complex128 &in) : _val(in) {}

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.

All of these scalar value constructors should be explicit and can be replaced with one constrained constructor:

template <CytnxType T>
explicit Scalar(T x) : _val(x) {}

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.

Agree on the consolidation — one constrained constructor is strictly better than the 11 spelled-out value ctors (less code, and a single crisp "no matching constructor" for non-cytnx types instead of an 11-way ambiguity). Two mechanical notes: a CytnxType concept doesn't exist on this branch yet (one lives in unmerged #979, so it needs a local definition here or coordination), and the two-arg Scalar(v, dtype), Sproxy, default, and copy constructors stay as they are.

On explicit, though, there's a collision with two things already in this PR that needs a deliberate choice rather than a drive-by:

  1. This PR dropped the member template operators (s + 3, s < 3, …) — those expressions currently compile only via the implicit Scalar(3) conversion into the free operator+(const Scalar&, const Scalar&). Making the ctors explicit as-is breaks every scalar ⊙ number call site (and every raw number passed to a const Scalar& parameter).
  2. Conversely, the implicit ctors are exactly what caused the operator< infinite-recursion hazard that ea426983 just fixed — which is a genuine argument for explicit.

So the two coherent end-states are:

  • (a) explicit + constrained mixed-mode operators: the single explicit Scalar(T) ctor plus template <CytnxType T> free operators (+ - * / < > <= >= == against raw values), so s + 3 keeps working by design instead of by implicit conversion. This is the cleanest end-state — no accidental conversion routes anywhere — and effectively reinstates (constrained, intentionally) the operator surface the PR dropped.
  • (b) one implicit constrained ctor: variant-style converting behavior — the consolidation win with zero other changes, deferring the explicit question.

Not viable: explicit alone — it silently breaks the arithmetic surface.

Timing-wise, if explicit is wanted at all, this PR is the moment (it's already !-breaking; doing it later is a second break). My lean is (a) as one more commit here.

Posted by Claude Code on behalf of @pcchen

@ianmccul ianmccul Jul 8, 2026

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.

Yes, (a) is the only sensible option here.

Adding one-line concept CytnxType from #979 is a trivial addition and should merge trivially when #979 lands. Whether #979 or #1011 lands first, the other can rebase.

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.

C++20 operator <=> makes (a) a bit easier,

#include <compare>

// only these two need to be written:
std::partial_ordering operator<=>(const Scalar& lhs, const Scalar& rhs);
bool operator==(const Scalar& lhs, const Scalar& rhs);

// the rest are easy:
template <CytnxType T>
std::partial_ordering operator<=>(const Scalar& lhs, T rhs) {
  return lhs <=> Scalar(rhs);
}

template <CytnxType T>
std::partial_ordering operator<=>(T lhs, const Scalar& rhs) {
  return Scalar(lhs) <=> rhs;
}

template <CytnxType T>
bool operator==(const Scalar& lhs, T rhs) {
  return lhs == Scalar(rhs);
}

template <CytnxType T>
bool operator==(T lhs, const Scalar& rhs) {
  return Scalar(lhs) == rhs;
}

Comment on lines +199 to +209
void Init_by_number(const cytnx_complex128 &in) { this->_val = in; }
void Init_by_number(const cytnx_complex64 &in) { this->_val = in; }
void Init_by_number(const cytnx_double &in) { this->_val = in; }
void Init_by_number(const cytnx_float &in) { this->_val = in; }
void Init_by_number(const cytnx_int64 &in) { this->_val = in; }
void Init_by_number(const cytnx_uint64 &in) { this->_val = in; }
void Init_by_number(const cytnx_int32 &in) { this->_val = in; }
void Init_by_number(const cytnx_uint32 &in) { this->_val = in; }
void Init_by_number(const cytnx_int16 &in) { this->_val = in; }
void Init_by_number(const cytnx_uint16 &in) { this->_val = in; }
void Init_by_number(const cytnx_bool &in) { this->_val = in; }

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.

These are not needed. Remove.

this->_impl->assign_selftype(in);
};
Scalar(const T &in, const unsigned int &dtype) {
this->Init_by_number(in);

@ianmccul ianmccul Jul 8, 2026

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.

this->_val = in;

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.

replace all of these with

template <CytnxType T>
Scalar &operator=(T x) {
  this->_val = x;
  return *this;
}

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.

The analogous Sproxy consolidation (11 → 1 constrained template) is done in bfc105c. The Scalar assignment operators here are deferred to the item-A follow-up PR together with the explicit-ctor work, since they share the same overload-resolution surface — see the plan in the PR comment.

Posted by Claude Code on behalf of @pcchen

Comment thread include/backend/Scalar.hpp Outdated

// casting
/// @brief The explicit casting operator of the Scalar class to cytnx::cytnx_double.
explicit operator cytnx_double() const { return this->_impl->to_cytnx_double(); }
explicit operator cytnx_double() const;

@ianmccul ianmccul Jul 8, 2026

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.

Replace these with

template <CytnxType To>
explicit operator To() const { 
  return std::visit(
    []<typename U>(const U &from) -> To {
      if constexpr (requires { static_cast<To>(from); }) {
        return static_cast<To>(from);
      } else {
        cytnx_error_msg(true,
                      "[ERROR] Cannot convert Scalar from dtype [%s] to dtype [%s].%s",
                      Type_enum_name<U>, Type_enum_name<To>, "\n");
        return To{};
      }
    },
    _val);
}

This also requires extending Type_enum_name<std::monostate>, which is a good idea anyway.

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.

Done in bfc105c — implemented exactly per this blueprint (one constrained explicit operator To() with requires { static_cast<To>(from); }, Type_enum_name-based error text, and Type_enum_name<std::monostate> added). complex→real keeps the real()/imag() hint in its error branch.

Posted by Claude Code on behalf of @pcchen

Comment thread src/backend/Scalar.cpp Outdated
Comment on lines +226 to +227
// Void source stays Void regardless of target: astype() of an
// uninitialized Scalar is a no-op (matches the pre-refactor behavior).

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.

This seems like a missed opportunity for a cleanup. I can't imagine why we want an explicit converion of a Scalar to some particular type to be a no-operation if it represents void.

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 done in bfc105c: astype() on a Void source now errors (Cannot astype a Void (uninitialized) Scalar) instead of the silent no-op, with a regression test.

Posted by Claude Code on behalf of @pcchen

Comment thread src/backend/Scalar.cpp Outdated
Comment on lines +49 to +60
template <typename TDst, typename TSrc>
TDst convert_value(const TSrc &src) {
if constexpr (is_complex_v<TDst> || !is_complex_v<TSrc>) {
return static_cast<TDst>(src);
} else {
cytnx_error_msg(true,
"[ERROR] Cannot convert a complex Scalar to a real dtype. Use real() or "
"imag() to extract a component first.%s",
"\n");
return TDst{};
}
}

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.

I think this isn't used after my suggested refactor of the explicit conversion operators

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.

Confirmed dead after the refactor — convert_value removed in bfc105c.

Posted by Claude Code on behalf of @pcchen

Comment thread src/backend/Scalar.cpp Outdated
Comment on lines +305 to +322
namespace {
template <typename TDst>
TDst explicit_cast(const Scalar &s) {
TDst out{};
std::visit(
[&](auto &&v) {
using T = std::decay_t<decltype(v)>;
if constexpr (std::is_same_v<T, std::monostate>) {
cytnx_error_msg(
true, "[ERROR] Cannot cast a Void (uninitialized) Scalar to any type.%s", "\n");
} else {
out = convert_value<TDst>(v);
}
},
s._val);
return out;
}
} // namespace

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.

I think this isn't used after my suggested refactor of the explicit conversion operators?

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.

Confirmed dead — explicit_cast and the 11 out-of-line cast definitions removed in bfc105c (the operators are now the inline constrained template in Scalar.hpp).

Posted by Claude Code on behalf of @pcchen

@ianmccul

ianmccul commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Some of the comments above depend on concept CytnxType which is added to Type.hpp in #979.

One of them also requires extending Type_enum_name<std::monostate> to be the same as Type_enum_name<void> (or something different, it really doesn't matter).

Something to look at after this, is whether it makes sense to replace the void in Type.hpp Type_list with std::monostate, which would make it the same as ScalarVariant, which could then be removed (or probably better, a type alias). I think this would probably allow removing a bunch of special cases associted with void being in Type_list.

Some things spotted by codex:

  • The main missed correctness one is Scalar::conj() on Void. In PR head ea42698, conj() just visits and no-ops unless the active type is complex, so Scalar().conj() silently returns Void. On upstream, conj() calls Scalar_base::conj_(), and the Void base implementation throws. This should probably call ensure_not_void(*this, "conj").
  • Sproxy has the same 11 assignment overloads as Scalar::operator=, and every implementation just forwards to set_item. This can be one constrained template <CytnxType T> Sproxy& operator=(T x).
  • Type_class::norm_result_dtype(Type.Void) currently returns Type.Double. That seems accidental; Void should probably stay Void or be rejected explicitly.
  • isqrt() now rejects Bool, whereas upstream effectively made sqrt(bool) a no-op-ish bool result. Probably fine.
  • There’s also a stale test comment saying only lossy in-place combinations throw; current PR semantics say in-place promotes and should not throw for non-Void operands.

@pcchen

pcchen commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Consolidated checklist to land this PR

Gathered from the review threads (@ianmccul's ruling + codex findings + CI). Nothing else is outstanding.

A. Constructor/operator surface — option (a), per @ianmccul's ruling

  • Replace the 11 value ctors with one explicit constrained ctor (template <CytnxType T> explicit Scalar(T x)); add the one-line CytnxType concept locally (copyable from refactor(Storage): collapse at<T>/back<T>/data<T> into single template bodies #979; merges trivially in either order).
  • Add the constrained mixed-mode arithmetic operators (+ - * / against raw values) so s + 3 works by design rather than via implicit conversion.
  • Comparisons via the C++20 blueprint: operator<=>(const Scalar&, const Scalar&) -> std::partial_ordering + operator==(const Scalar&, const Scalar&), plus the four constrained forwarding templates.
  • ⚠️ Decide one semantic point explicitly: partial_ordering::unordered makes complex < > <= >= quietly return false, whereas current cytnx throws on complex ordering. Either keep the throw inside <=> or adopt unordered semantics — deliberate choice + test pin either way.

B. Conversion-operator refactor (@ianmccul)

  • Visit-based explicit conversions with Type_enum_name<U>-based error text; extend Type_enum_name<std::monostate>.
  • Void conversion must error, not no-op (Scalar.cpp:227 thread).
  • Remove the then-dead helpers (Scalar.cpp:60, :322).

C. Codex-spotted correctness items

  • Scalar().conj() silently returns Void — regression vs. the PIMPL (Void base threw); add ensure_not_void(*this, "conj") + test.
  • Sproxy's 11 assignment overloads → one template <CytnxType T> Sproxy& operator=(T).
  • Type_class::norm_result_dtype(Type.Void) returns Double — reject Void explicitly (or return Void) + test.
  • Fix the stale test comment still describing throw-on-lossy in-place semantics (current: promote).
  • isqrt() rejecting Bool — blessed as fine, no change.

D. CI (currently 1 pytest red)

Follow-ups (not this PR)

Posted by Claude Code on behalf of @pcchen

@ianmccul

ianmccul commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

partial_ordering::unordered makes complex < > <= >= quietly return false

I think that is not correct. operator<=> can do whatever you want it to, including throw if the arguments are complex.

The reason for partial_ordering::unordered is for NaN values, not complex.

@pcchen

pcchen commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Sequencing note: #1015 stacks its Storage work on a snapshot of this PR (its commit ba822de1 — now one iteration behind this branch). Per the plan posted in #1015 (comment), this PR merges first; the combined #1015 iteration (rebase + review fixes) then follows and drops the embedded Scalar copy. Practical implication for reviewers: concentrate all Scalar-layer review here — this is the canonical copy — and this PR is now on the critical path for both #1015 and #1003.

Posted by Claude Code on behalf of @pcchen

…oid guards, Sproxy consolidation

Implements items B, C and D of the consolidated review checklist
(A -- explicit constrained ctor + operator<=> -- is split into a
follow-up PR, since it changes overload resolution codebase-wide and
carries an unresolved complex-ordering semantic decision):

B. Conversion surface (review threads on Scalar.hpp:354, Scalar.cpp:227/60/322):
- One constrained template  replaces the
  9 per-type cast operators; conversions the language cannot perform
  error with Type_enum_name-based text (complex->real adds the
  real()/imag() hint). to_complex128()/to_complex64() delegate to it.
- Type_enum_name<std::monostate> added (displays as "Void").
- astype() on a Void source now errors instead of silently staying
  Void; the dead convert_value/explicit_cast helpers are removed.

C. Correctness items (codex findings):
- conj() on a Void Scalar throws (old PIMPL contract) instead of
  silently returning Void.
- Sproxy's 11 per-type assignment overloads collapse to one
  CytnxType-constrained template delegating through Scalar.
- Type_class::norm_result_dtype(Void) rejects instead of mapping to
  Double. The CytnxType concept is the documented one-liner from #979.
- Stale "lossy in-place combinations throw" comment updated to the
  promote semantics.

D. pytest: test_inplace_same_dtype_keeps_dtype pins Int32 operands via
astype() to sidestep the preexisting pybind ctor-collapse quirk that
#1014 fixes (workaround documented for removal).

Verified: C++ suite 1676 ran / 1665 passed / 11 skipped / 0 failed
(4 new Void-guard tests); pytests/scalar_variant_test.py 43/43 against
the freshly built extension; clang-format 14.0.6.

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

pcchen commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Checklist items B+C+D implemented — pushed as bfc105cd

B. Conversion-operator refactor ✅

  • The 9 per-type cast operators are replaced by one constrained template <CytnxType To> explicit operator To() const (per @ianmccul's blueprint), with requires { static_cast<To>(from); } admitting exactly the conversions the language can perform; failures error with Type_enum_name-based text, and complex→real keeps the real()/imag() hint. to_complex128()/to_complex64() now delegate to it.
  • Type_enum_name<std::monostate> added (displays as "Void").
  • astype() on a Void source now errors instead of the silent no-op (Scalar.cpp:227 thread).
  • convert_value and explicit_cast are gone (Scalar.cpp:60/:322 threads — confirmed dead after the refactor).

C. Correctness items ✅

  • Scalar().conj() now throws (old PIMPL contract) instead of silently returning Void — with test.
  • Sproxy's 11 per-type assignments collapse to one CytnxType-constrained template delegating through Scalar (their bodies were all identical set_item calls).
  • Type_class::norm_result_dtype(Type.Void) rejects instead of returning Double — with test.
  • Stale "lossy in-place combinations throw" comment updated to the promote semantics.
  • The CytnxType concept is added as the documented one-liner from refactor(Storage): collapse at<T>/back<T>/data<T> into single template bodies #979 (merges trivially in either order).

D. CI ✅

Verified locally: C++ suite 1676 ran / 1665 passed / 11 skipped / 0 failed (includes 4 new Void-guard tests: conj, astype, cast, norm_result_dtype); pytests/scalar_variant_test.py 43/43 against the freshly built extension; clang-format 14.0.6.

A is split into a follow-up PR — deliberately

The explicit constrained ctor + mixed-mode operators + operator<=> blueprint changes overload resolution codebase-wide (every raw-value → Scalar call site), and carries one unresolved semantic decision (complex ordering under <=>: keep the throw vs partial_ordering::unordered silently returning false — needs a ruling before code). Bolting that onto this PR would hold the Scalar critical path (#1015#1003) hostage to an unbounded-blast-radius change. The blueprint from the hpp:122/hpp:309 threads is agreed and will be the follow-up's starting point; Init_by_number (hpp:188/:209) goes with it since it interlocks with the ctor surface. @ianmccul — flagging the split for your nod since option (a) was your ruling; the what is unchanged, only the where.

Posted by Claude Code on behalf of @pcchen

@ianmccul

ianmccul commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

complex ordering under <=>: keep the throw vs partial_ordering::unordered silently returning false — needs a ruling before code

Note that I never said anything about complex returning partial_ordering::unordered, that was a Claude invention. The reason why operator <=> is a std::partial_ordering is because floating point NaN's are unordered. Attempts to compare complex can still throw, I never said otherwise.

Please make sure Claude reads the comments!

@ianmccul

Copy link
Copy Markdown
Collaborator

For the third time, I never said anything about changing the comparison behavior of complex. Returning std::partial_orering::unordered would be very weird. It is only Claude's "imagination" that ever suggested it.

partial_ordering::unordered is required for proper comparison of NaN data. The two options are not exclusive! Complex comparisons throw. Real comparisons follow the behaviour of normal operator<=>, which is unorded for floating point NaN.

@ianmccul

Copy link
Copy Markdown
Collaborator

Is it possible to talk to a human here?

@pcchen

pcchen commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

yes.

…iant

# Conflicts:
#	src/DenseUniTensor.cpp
#	src/linalg/Norm.cpp
… master

master's #979 landed `concept CytnxType` in include/Type.hpp, while this
branch had added its own equivalent one-liner in include/backend/Scalar.hpp
(checklist item C). Merging master in left both definitions present, so every
TU that pulls in Scalar.hpp (via Storage.hpp) failed to compile with
"redefinition of template<class T> concept cytnx::CytnxType" -- the
DownstreamFindPackage CI failure.

The two definitions are semantically identical (Type.hpp's
`variant_contains_v<T, Type_list> && !is_void_v<T>` == the 11 concrete types
this branch enumerated). Scalar.hpp already includes Type.hpp, so drop the
local copy and rely on the canonical one, exactly the "the other rebases"
resolution flagged in the #1011 review when #979 lands first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yingjerkao
yingjerkao merged commit 9d9bf05 into master Jul 14, 2026
19 checks passed
@yingjerkao
yingjerkao deleted the refactor/scalar-variant branch July 14, 2026 06:42
yingjerkao added a commit that referenced this pull request Jul 14, 2026
master advanced past the previous merge when PR #1011 (refactor/scalar-variant:
Scalar PIMPL -> std::variant) landed. Re-merge to keep #1000 mergeable.

Conflict resolution:
- src/linalg/Norm.cpp: #1000 computed the rank-0 norm output dtype with an
  explicit if/else (ComplexDouble->Double, ComplexFloat->Float, else input-or-
  Double); #1011 introduced Type_class::norm_result_dtype() encapsulating the
  identical policy (is_float ? to_real : Double) and made it the single home of
  that rule, shared with the UniTensor normalize_ accumulators. Adopted the
  helper (Void is already guarded upstream at norm_impl's is_void check). Verified
  byte-for-byte equivalent dtype for every input type.

Follow-through (keep the suite clean under -W error::DeprecationWarning): migrated
the two remaining .Norm().item() callers the PR's own migration had missed --
pytests/unitensor_test.py and pytests/binding_inplace_test.py -- to .norm() (a
native float in Python; same value). Surfaced because the rebuilt module shifted
the DeprecationWarning-registry dedup order; harmless on CI (plain pytest) but now
deterministically clean under -W error.

Verification (CI-matching openblas-cpu, non-ASan):
- C++ test_main: 1768 tests, 1757 passed / 11 skipped / 0 failed.
- pybind rebuilds clean; full pytests/ suite passes under plain pytest; the
  merge-relevant files pass under -W error::DeprecationWarning.
- clang-format v14 clean.

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.

refactor(Scalar): replace PIMPL + virtual dispatch with std::variant

3 participants