Skip to content

Disable broken/unsafe Scalar operations [also a bit of memory corruption]#937

Closed
ianmccul wants to merge 1 commit into
Cytnx-dev:masterfrom
ianmccul:scalar-integer-arithmetic-guard
Closed

Disable broken/unsafe Scalar operations [also a bit of memory corruption]#937
ianmccul wants to merge 1 commit into
Cytnx-dev:masterfrom
ianmccul:scalar-integer-arithmetic-guard

Conversation

@ianmccul

Copy link
Copy Markdown
Collaborator

This PR disables, via throwing std::logic_error, a few (but definitely not all) of the Scalar arithmetic operations that are capable of returning incorrect results. I have no idea whether any downstream code uses these operations, but since they are exposed via Python, it seems that leaving them in the library as they are is dangerous. At least this PR will make the code give a sensible error message rather than silently producing nonsense. The disabled arithmetic is mainly mixed signed/unsigned arithmetic and mixed integer/floating-point. Probably no one ever uses this anyway.

If any code is affected by this, the fix is to replace in-place arithmetic by out-of-place arithmetic. Eg

Scalar a(cytnx_int64(3));
Scalar b(cytnx_double(2.0));

// Incorrect: Disabled by this PR. This used to preserve a's Int64 dtype and perform integer division.
a /= b;

// The fix is to use out-of-place arithmetic:
a = a / b;

This was uncovered by a cursory examination of Scalar.hpp source code in the aftermath of #935. But this PR does not in any way resolve #935.

This PR also adds tests/Scalar_tests.cpp, since there were no tests of Scalar before. It tests that some simple cases of Scalar arithmetic work as expected, and also verifies that some broken arithmetic combinations are in fact disabled. I expect that there remain some broken combinations not fixed by this PR.

As some drive-by cleanups, this PR removes a few of the unnecessary overloaded functions and replaces them with constrained templates, using concept CytnxType. This is a pattern that could be used elsewhere:

template <CytnxType T>
void foo(T x)   // foo(T) overload family only when T is one of the 11 non-void cytnx_XXXX types
{
  ///
}

Unfortunately it is not possible to remove the vast majority of pointless overloads, because they are virtual and in C++ templates cannot be virtual.

Also discovered in the course of writing this PR message:

Tensor t(..., Type.Int64);
t /= cytnx_double(2.0);

This actually works: it uses a completely different code path to Scalar: src/Tensor.cpp:799, which calls linalg::Div(*this, rc) and replaces the storage. Div<Tensor, cytnx_double> allocates output storage with Type.type_promote(Type.Double, Lt.dtype()), so the result dtype becomes Double.

But if the RHS is a Tensor whose dtype is Double,

Tensor t(..., Type.Int64);
Tensor d(..., Type.Double);
t /= d;

then on CPU, it goes through linalg::iDiv at src/Tensor.cpp:779. On CPU that dispatches true in-place kernels, writing back into the original integer storage. So 3 / 2.0 becomes integer-truncated 1, and t.dtype() stays integer.

On GPU the behavior is quite different:

Tensor t(..., Type.Int64).to(Device.cuda);
Tensor d(..., Type.Double).to(Device.cuda);
t /= d;

The GPU goes through the old cuAri_ii path in src/linalg/iDiv.cpp:34. For Int64 / Double, it creates tmpo = Lt.clone(), so tmpo is still Int64, but dispatches cuDiv_internal_i64td, whose output pointer is cast to cytnx_double* in src/backend/linalg_internal_gpu/cuDiv_internal.cu:2096.

The result is memory corruption since the double* data is written into a buffer with dtype Int64.

For integers of size smaller than the right hand side, it is also memory-unsafe and will overwrite the buffer.

I haven't submitted an issue on this. Just another drop in the ocean...

@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 Scalar class to utilize templates and C++20 concepts, significantly reducing code duplication across various data types. It also introduces strict validation checks to prevent unsupported arithmetic and comparison operations on mixed signedness or boolean types, and adds a comprehensive unit test suite. The review feedback highlights a potential out-of-bounds array access when initializing cv-qualified types, recommends implementing an RAII guard for Google Test's stream capturing to prevent state leaks, and suggests refactoring self-assignment tests to avoid compiler 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 on lines +2616 to 2621
template <CytnxType T>
void Init_by_number(const T &in) {
if (this->_impl != nullptr) delete this->_impl;
this->_impl = new Int16Scalar(in);
}
void Init_by_number(const cytnx_uint16 &in) {
if (this->_impl != nullptr) delete this->_impl;
this->_impl = new Uint16Scalar(in);
}
void Init_by_number(const cytnx_bool &in) {
if (this->_impl != nullptr) delete this->_impl;
this->_impl = new BoolScalar(in);
this->_impl = __ScII.UScIInit[Type.cy_typeid(in)]();
this->_impl->assign_selftype(in);
}

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.

security-high high

Using Type.cy_typeid(in) to index __ScII.UScIInit can lead to out-of-bounds array access (undefined behavior or crash) if T is a cv-qualified type (such as volatile cytnx_double). The CytnxType concept allows cv-qualified types because it uses std::remove_cvref_t, but Type.cy_typeid does not strip cv-qualifiers, resulting in an index of N_Type (12) which is out of bounds for UScIInit (size 12). Use Type.cy_typeid_v<std::remove_cvref_t<T>> instead to resolve the correct index safely at compile time.

    template <CytnxType T>
    void Init_by_number(const T &in) {
      if (this->_impl != nullptr) delete this->_impl;
      this->_impl = __ScII.UScIInit[Type.cy_typeid_v<std::remove_cvref_t<T>>]();
      this->_impl->assign_selftype(in);
    }

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.

In the statement const T, the base type T will never be const-qualified. It could be volatile I guess, but really?!?!

The proper fix is that constructing cy_typeid_v<T> on an invalid T should not compile. I'm very surprised that you think it does .... ARGH ... another PR coming ...

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.

Fixed in #938

Comment thread tests/Scalar_test.cpp
Comment on lines +45 to +91
template <typename Fn>
void ExpectCorrectDoubleOrDisabled(Fn &&fn, double expected) {
testing::internal::CaptureStdout();
testing::internal::CaptureStderr();
try {
const Scalar value = fn();
static_cast<void>(testing::internal::GetCapturedStderr());
static_cast<void>(testing::internal::GetCapturedStdout());
ExpectDoubleScalarEq(value, expected);
} catch (const std::logic_error &) {
static_cast<void>(testing::internal::GetCapturedStderr());
static_cast<void>(testing::internal::GetCapturedStdout());
SUCCEED();
}
}

template <typename Fn>
void ExpectCorrectInt64OrDisabled(Fn &&fn, cytnx_int64 expected) {
testing::internal::CaptureStdout();
testing::internal::CaptureStderr();
try {
const Scalar value = fn();
static_cast<void>(testing::internal::GetCapturedStderr());
static_cast<void>(testing::internal::GetCapturedStdout());
EXPECT_EQ(static_cast<cytnx_int64>(value), expected);
} catch (const std::logic_error &) {
static_cast<void>(testing::internal::GetCapturedStderr());
static_cast<void>(testing::internal::GetCapturedStdout());
SUCCEED();
}
}

template <typename Fn>
void ExpectDisabled(Fn &&fn) {
testing::internal::CaptureStdout();
testing::internal::CaptureStderr();
try {
static_cast<void>(fn());
static_cast<void>(testing::internal::GetCapturedStderr());
static_cast<void>(testing::internal::GetCapturedStdout());
FAIL() << "Expected std::logic_error.";
} catch (const std::logic_error &) {
static_cast<void>(testing::internal::GetCapturedStderr());
static_cast<void>(testing::internal::GetCapturedStdout());
SUCCEED();
}
}

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

The helper functions ExpectCorrectDoubleOrDisabled, ExpectCorrectInt64OrDisabled, and ExpectDisabled capture stdout and stderr using Google Test's internal APIs. However, if an unexpected exception is thrown, or if FAIL() is executed (which returns early from the function), the captured streams are never released. This leaks the captured state and breaks the output of all subsequent tests in the suite. Implementing a simple RAII CaptureGuard ensures that the captured streams are always safely released when the guard goes out of scope.

  struct CaptureGuard {
    CaptureGuard() {
      testing::internal::CaptureStdout();
      testing::internal::CaptureStderr();
    }
    ~CaptureGuard() {
      static_cast<void>(testing::internal::GetCapturedStdout());
      static_cast<void>(testing::internal::GetCapturedStderr());
    }
  };

  template <typename Fn>
  void ExpectCorrectDoubleOrDisabled(Fn &&fn, double expected) {
    CaptureGuard guard;
    try {
      const Scalar value = fn();
      ExpectDoubleScalarEq(value, expected);
    } catch (const std::logic_error &) {
      SUCCEED();
    }
  }

  template <typename Fn>
  void ExpectCorrectInt64OrDisabled(Fn &&fn, cytnx_int64 expected) {
    CaptureGuard guard;
    try {
      const Scalar value = fn();
      EXPECT_EQ(static_cast<cytnx_int64>(value), expected);
    } catch (const std::logic_error &) {
      SUCCEED();
    }
  }

  template <typename Fn>
  void ExpectDisabled(Fn &&fn) {
    CaptureGuard guard;
    try {
      static_cast<void>(fn());
      FAIL() << "Expected std::logic_error.";
    } catch (const std::logic_error &) {
      SUCCEED();
    }
  }

Comment thread tests/Scalar_test.cpp

TEST(ScalarTest, SelfAssignmentPreservesValue) {
Scalar value(cytnx_double(3.5));
value = value;

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

Direct self-assignment like value = value; can trigger compiler warnings (such as -Wself-assign or -Wself-assign-overloaded) which can break builds when -Werror is enabled. Assigning through a reference is a standard way to test self-assignment while avoiding compiler warnings.

Suggested change
value = value;
Scalar &ref = value;
value = ref;

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a780b595e7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

void Init_by_number(const cytnx_bool &in) {
if (this->_impl != nullptr) delete this->_impl;
this->_impl = new BoolScalar(in);
this->_impl = __ScII.UScIInit[Type.cy_typeid(in)]();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid __ScII in one-argument Scalar construction

This path is now used by every Scalar(cytnx_...) constructor, but __ScII.UScIInit is populated by the dynamically initialized __ScII object in src/backend/Scalar.cpp. If a user or another translation unit has a namespace-scope/static Scalar s(1.0), its dynamic initialization can run before __ScII, leaving these function pointers zero-initialized and calling a null pointer; the previous per-type constructors did not depend on this global factory.

Useful? React with 👍 / 👎.

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.

True, but Tensor and UniTensor have exactly the same problem, actually much worse.

Cytnx already uses dynamically initialized global dispatch tables for Storage, Tensor storage allocation, Scalar(in, dtype), and Scalar::astype(dtype). Namespace-scope Scalar/Tensor/UniTensor objects with nontrivial initialization are therefore already unsafe. I do not think preserving direct allocation for only the one-argument Scalar constructors is worth keeping the old overload boilerplate in this PR.

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.

related: #673

@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

❌ 16 Tests Failed:

Tests completed Failed Passed Skipped
1069 16 1053 11
View the top 3 failed test(s) by shortest run time
DenseUniTensorTest.Mul__UT_UT::DenseUniTensorTest.Mul__UT_UT
Stack Traces | 0.0615s run time
Failed
DenseUniTensorTest.Add__UT_Scalar::DenseUniTensorTest.Add__UT_Scalar
Stack Traces | 0.0616s run time
Failed
DenseUniTensorTest.Sub__UT_Scalar::DenseUniTensorTest.Sub__UT_Scalar
Stack Traces | 0.0619s run time
Failed
DenseUniTensorTest.Sub__UT_UT::DenseUniTensorTest.Sub__UT_UT
Stack Traces | 0.0623s run time
Failed
DenseUniTensorTest.Add_UT_UT::DenseUniTensorTest.Add_UT_UT
Stack Traces | 0.063s run time
Failed
DenseUniTensorTest.Div__UT_UT::DenseUniTensorTest.Div__UT_UT
Stack Traces | 0.0631s run time
Failed
DenseUniTensorTest.Add__UT_UT::DenseUniTensorTest.Add__UT_UT
Stack Traces | 0.0631s run time
Failed
DenseUniTensorTest.Add_UT_Scalar::DenseUniTensorTest.Add_UT_Scalar
Stack Traces | 0.0633s run time
Failed
DenseUniTensorTest.Mul_UT_Scalar::DenseUniTensorTest.Mul_UT_Scalar
Stack Traces | 0.0633s run time
Failed
DenseUniTensorTest.Mul__UT_Scalar::DenseUniTensorTest.Mul__UT_Scalar
Stack Traces | 0.0637s run time
Failed
DenseUniTensorTest.Sub_UT_UT::DenseUniTensorTest.Sub_UT_UT
Stack Traces | 0.0639s run time
Failed
DenseUniTensorTest.Sub_UT_Scalar::DenseUniTensorTest.Sub_UT_Scalar
Stack Traces | 0.0641s run time
Failed
DenseUniTensorTest.Div_UT_Scalar::DenseUniTensorTest.Div_UT_Scalar
Stack Traces | 0.0642s run time
Failed
DenseUniTensorTest.Div__UT_Scalar::DenseUniTensorTest.Div__UT_Scalar
Stack Traces | 0.0646s run time
Failed
DenseUniTensorTest.Mul_UT_UT::DenseUniTensorTest.Mul_UT_UT
Stack Traces | 0.0647s run time
Failed
DenseUniTensorTest.Div_UT_UT::DenseUniTensorTest.Div_UT_UT
Stack Traces | 0.0654s run time
Failed

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@ianmccul

ianmccul commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Ok, so there are a bunch of DenseUniTensor test failures here.

The tests are not testing UniTensor specifically, but comparing the result of UniTensor arithmetic against broken Scalar arithmetic that has now been disabled.

For example:

EXPECT_EQ(ut1.at({i, j, k}) + ut2.at({i, j, k}), out.at({i, j, k}));

ut1.at(...) returns a Scalar, so the expected value is computed through Scalar::operator+. If UniTensor::Add and Scalar::operator+ share the same incorrect dtype behavior for the ut1 and ut2 dtypes, this test passes. It is an internal-consistency test, not an independent correctness test.

There is an especially clear example in Sub_UT_UT:

// issue: for dtype = Uint64, the overload of operator- may not correct => 2 - 2 = 4.
EXPECT_EQ(ut1.at({i, j, k}) - ut2.at({i, j, k}), out.at({i, j, k}));

That comment already says that there are known incorrect results! Are Cytnx developers happy to accept 2 - 2 = 4?! The test passes because it only compares UniTensor arithmetic against Scalar arithmetic, and both are wrong! A meaningful regression test should check results against a known-correct expectation. For example:

Scalar a(cytnx_int64(3));
Scalar b(cytnx_double(2.0));
a /= b;
EXPECT_DOUBLE_EQ(double(a), 1.5);  // old behavior gives integer result 1

or, for UniTensor/Tensor arithmetic, explicitly check both the result dtype and the numeric value:

UniTensor ut(..., Type.Int64);
// set an element to 3

Scalar two(cytnx_double(2.0));
ut.Div_(two);

EXPECT_EQ(ut.dtype(), Type.Double);       // or expect this operation to be disabled
EXPECT_DOUBLE_EQ(ut.at<double>({0}), 1.5);

I really have no suggestions for what to do at this point. The entire arithmetic type conversion system in Cytnx is irretrievably broken. If anyone cares about this, an emergency fix is to probably disable the bool/int dtypes completely, and convert integer types straight to Double. Even funnelling all integer types into cytnx_int64 is not a fix, because mixed int/double arithmetic is broken. This is essentially suggestion No. 1 from #933 anyway.

Scalar a(cytnx_int64(3));
Scalar b(cytnx_double(2.0));
a /= b;

results in a==1.

Likewise for UniTensor,

cytnx::Tensor a = cytnx::arange(3, 7, 1, cytnx::Type.Int64);   // [3,4,5,6]
cytnx::Tensor b = cytnx::ones({4}, cytnx::Type.Double) * 2.0;
a /= b;
// Expected: [1.5, 2.0, 2.5, 3.0], dtype Double
// Actual:   [1,   2,   2,   3],   dtype Int64

That is on CPU. The same example on GPU is memory corruption. (I didn't test that, but it is obvious from the bad pointer casts in the code).

Codex suggests a reasonable emergency fix is to disable all mixed-type in-place arithmetic completely. Almost all of it is broken. The PR does this in a limited way for Scalar, but not all cases. Tensor and UniTensor have essentially the same problems (with subtle differences).

I am not going to work on this any further. Happy to consult with anyone who wants to work on fixes, but it is up to the people who actually use this library to determine what to do with it.

@IvanaGyro

Copy link
Copy Markdown
Member

We can make Scalar mainly serve for Python if Scalar is still a type-less class. This means we will allow to change dtype by assignment.

int a = 3;
double b = 2.0;
 a /= b; // 1

This is well defined in C++.

a = 3
b = 2.0
a /= b  # 1.5

Python has a different result.

@ianmccul

Copy link
Copy Markdown
Collaborator Author

The whole purpose of Scalar is that it can change type under assignment, and today this code works:

Scalar a(cytnx_int64(3));
Scalar b(cytnx_double(2.0));

a = a / b;   // works as expected: a.dtype is Double and value is 1.5

The problem is:

Scalar a(cytnx_int64(3));
Scalar b(cytnx_double(2.0));
a /= b; 
// Incorrect: Disabled by this PR. This used to preserve a's Int64 dtype
// and perform integer division, giving a.dtype = Int64 and value 1

@ianmccul

Copy link
Copy Markdown
Collaborator Author

A Python version.

# Demonstrates that in-place division (a /= b) silently truncates and fails to
# promote dtype in cytnx, for both Scalar and Tensor, while out-of-place (a = a/b)
# is correct. Pure Python gives the correct 1.5 in both cases.
#
# Verified output (cytnx 1.1.0, CPU):
#   python a=a/b : 1.5
#   python a/=b  : 1.5
#   Scalar a=a/b : 1.5  (Double)
#   Scalar a/=b  : 1.0  (Int64)            <-- wrong
#   Tensor a=a/b : [1.5, 2.0, 2.5, 3.0]  (Double)
#   Tensor a/=b  : [1, 2, 2, 3]  (Int64)   <-- wrong
#
# (The same Tensor case on GPU is expected to be memory corruption, not just a
# wrong result, due to the output-pointer cast in the in-place CUDA kernels.)

import cytnx, numpy as np
name = lambda x: cytnx.Type(x.dtype()).name

a = 3; b = 2.0;  print("python a=a/b :", a / b)
a = 3; a /= b;   print("python a/=b  :", a)

b = cytnx.Scalar(np.float64(2.0))
a = cytnx.Scalar(np.int64(3)); a = a / b; print(f"Scalar a=a/b : {float(a)}  ({name(a)})")
a = cytnx.Scalar(np.int64(3)); a /= b;    print(f"Scalar a/=b  : {float(a)}  ({name(a)})")

b = cytnx.ones(4, dtype=cytnx.Type.Double) * 2.0
a = cytnx.arange(3, 7, 1, dtype=cytnx.Type.Int64); a = a / b; print(f"Tensor a=a/b : {a.numpy().tolist()}  ({name(a)})")
a = cytnx.arange(3, 7, 1, dtype=cytnx.Type.Int64); a /= b;    print(f"Tensor a/=b  : {a.numpy().tolist()}  ({name(a)})")

@ianmccul

Copy link
Copy Markdown
Collaborator Author

Confirmed on GPU. Reproducing Int* tensor [3,4,5,6] /= Double 2.0 on CUDA — the in-place divide writes double-sized values into the integer buffer:

  • Int64 /= Double[4609434218613702656, 4611686018427387904, 4612811918334230528, 4613937818241073152]
    These are the IEEE-754 bit patterns of 1.5, 2.0, 2.5, 3.0 reinterpreted as int64 (0x3FF8…, 0x4000… = 2.0, etc.). Same width, so no overflow — just silent garbage.
  • Int32 /= Double → 8-byte doubles written into 4-byte slots: a 2× buffer overflow; the values are the interleaved halves of the double bit-patterns.
  • Int16 /= Double4× overflow; the tensor's dtype came back Void and the C++ layer dumped a stack trace. Corrupted beyond readback.

Under compute-sanitizer --tool memcheck (the Int16 case):

========= Invalid __global__ write of size 8 bytes
========= Invalid __global__ write of size 8 bytes
========= Invalid __global__ write of size 8 bytes
========= Target application returned an error
========= ERROR SUMMARY: 5 errors

Verified on cytnx 1.1.0, CUDA 12.9, Quadro GV100 (sm_70).

Overwriting the tensor metadata end ending up with dtype == Void is genuinely odd. Memory corruption of a GPU buffer is corrupting host memory. I am confused, so is Claude and Codex, they both have no idea here. They both suggest it is some kind of secondary corruption.

Further investigation proves that this is not related to overwriting the Tensor data buffer on GPU (demonstrated by patching the memory allocator to get a buffer 8x larger than requested, so no buffer overrun). It is an independent bug that happens deterministically for Int16 /= Double on GPU. And with some more investigating via Claude, it turns out to be:

The .numpy() binding's dtype chain (tensor_py.cpp:114–128) handles ComplexDouble, ComplexFloat, Double, Float, Uint64, Int64, Uint32, Int32, and Bool — but omits Int16 and Uint16. They fall through to the else, which emits a misleadingly worded error:

  } else if (tmpIN.dtype() == cytnx::Type.Bool) { ... }
  } else {
      cytnx_error_msg(true, "[ERROR] Void Type Tensor cannot convert to numpy ndarray%s", "\n");
  }

The tensor isn't Void — it's Int16, a dtype the converter doesn't handle. The catch-all message lies about the cause, and that sent Claude chasing corruption.

I suspect this means that no Python users have ever created a Cytnx Tensor with Int16 or UInt16, which is hardly surprising. Yet more API surface that is unused, untested, and not even useful.

.numpy() is effectively untested too, by the way.

@yingjerkao yingjerkao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@ianmccul

ianmccul commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Don't merge this, it is obsoleted by #1010 (and partly #1015 and followups)

@ianmccul ianmccul closed this Jul 8, 2026
@ianmccul
ianmccul deleted the scalar-integer-arithmetic-guard branch July 8, 2026 07:16
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>
pcchen pushed a commit that referenced this pull request Jul 8, 2026
…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>
pcchen pushed 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>
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.

Replace Scalar raw-pointer polymorphism with value semantics

3 participants