From 5b69baad687f2c057ff7e6c96a194eecb377046f Mon Sep 17 00:00:00 2001 From: swayaminsync Date: Tue, 19 May 2026 17:15:16 +0530 Subject: [PATCH 1/5] added __reduce__ + exp_digits = 4 + tests --- src/csrc/casts.cpp | 2 +- src/csrc/scalar.c | 54 +++++++++++++++++++- tests/test_quaddtype.py | 109 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/csrc/casts.cpp b/src/csrc/casts.cpp index 1465a6f..8f15e46 100644 --- a/src/csrc/casts.cpp +++ b/src/csrc/casts.cpp @@ -416,7 +416,7 @@ quad_to_string_adaptive_cstr(Sleef_quad *sleef_val, npy_intp unicode_size_chars) // Use scientific notation with full precision const char *scientific_str = Dragon4_Scientific_QuadDType_CStr(sleef_val, DigitMode_Unique, SLEEF_QUAD_DECIMAL_DIG, 0, 1, - TrimMode_LeaveOneZero, 1, 2); + TrimMode_LeaveOneZero, 1, 4); if (scientific_str == NULL) { PyErr_SetString(PyExc_RuntimeError, "Float formatting failed"); return NULL; diff --git a/src/csrc/scalar.c b/src/csrc/scalar.c index 140e768..58b489f 100644 --- a/src/csrc/scalar.c +++ b/src/csrc/scalar.c @@ -352,7 +352,7 @@ QuadPrecision_repr_dragon4(QuadPrecisionObject *self) .sign = 1, .trim_mode = TrimMode_LeaveOneZero, .digits_left = 1, - .exp_digits = 3}; + .exp_digits = 4}; PyObject *str; if (self->backend == BACKEND_SLEEF) { @@ -601,11 +601,63 @@ QuadPrecision_as_integer_ratio(QuadPrecisionObject *self, PyObject *Py_UNUSED(ig return PyTuple_Pack(2, numerator, denominator); } +static PyObject * +QuadPrecision_reduce(QuadPrecisionObject *self, PyObject *Py_UNUSED(ignored)) +{ + Dragon4_Options opt = {.scientific = 1, + .digit_mode = DigitMode_Unique, + .cutoff_mode = CutoffMode_TotalLength, + .precision = SLEEF_QUAD_DECIMAL_DIG, + .sign = 1, + .trim_mode = TrimMode_LeaveOneZero, + .digits_left = 1, + .exp_digits = 4}; + + PyObject *str_value; + if (self->backend == BACKEND_SLEEF) { + str_value = Dragon4_Scientific_QuadDType(&self->value.sleef_value, opt.digit_mode, + opt.precision, opt.min_digits, opt.sign, + opt.trim_mode, opt.digits_left, opt.exp_digits); + } + else { + Sleef_quad sleef_val = Sleef_cast_from_doubleq1(self->value.longdouble_value); + str_value = Dragon4_Scientific_QuadDType(&sleef_val, opt.digit_mode, opt.precision, + opt.min_digits, opt.sign, opt.trim_mode, + opt.digits_left, opt.exp_digits); + } + if (str_value == NULL) { + return NULL; + } + + PyObject *backend_obj = PyUnicode_FromString( + self->backend == BACKEND_SLEEF ? "sleef" : "longdouble"); + if (backend_obj == NULL) { + Py_DECREF(str_value); + return NULL; + } + + PyObject *args = PyTuple_Pack(2, str_value, backend_obj); + Py_DECREF(str_value); + Py_DECREF(backend_obj); + if (args == NULL) { + return NULL; + } + + PyObject *type_obj = (PyObject *)Py_TYPE(self); + Py_INCREF(type_obj); + PyObject *result = PyTuple_Pack(2, type_obj, args); + Py_DECREF(type_obj); + Py_DECREF(args); + return result; +} + static PyMethodDef QuadPrecision_methods[] = { {"is_integer", (PyCFunction)QuadPrecision_is_integer, METH_NOARGS, "Return True if the value is an integer."}, {"as_integer_ratio", (PyCFunction)QuadPrecision_as_integer_ratio, METH_NOARGS, "Return a pair of integers whose ratio is exactly equal to the original value."}, + {"__reduce__", (PyCFunction)QuadPrecision_reduce, METH_NOARGS, + "Support pickling: return (QuadPrecision, (str_value, backend))."}, {NULL, NULL, 0, NULL} /* Sentinel */ }; diff --git a/tests/test_quaddtype.py b/tests/test_quaddtype.py index 83ec1f8..4f0085c 100644 --- a/tests/test_quaddtype.py +++ b/tests/test_quaddtype.py @@ -5338,6 +5338,115 @@ def test_pickle_fortran_order(self, backend): assert unpickled.dtype == original.dtype assert unpickled.flags.f_contiguous == original.flags.f_contiguous + +class TestScalarPickle: + """Regression tests for issue #99: bare QuadPrecision scalars (not wrapped + in an array) must round-trip through pickle.dumps / pickle.loads without + raising and must preserve value, type, and backend.""" + + @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) + def test_pickle_scalar_issue_repro(self, backend): + """The exact repro from #99: was RuntimeError on loads().""" + import pickle + original = QuadPrecision("123.456", backend=backend) + loaded = pickle.loads(pickle.dumps(original)) + assert isinstance(loaded, QuadPrecision) + assert loaded == original + assert str(loaded) == str(original) + + @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) + @pytest.mark.parametrize("value", [ + "0.0", "-0.0", "1.0", "-1.0", "42.0", "-42.0", + "3.141592653589793238462643383279502884197", # ~quad-precision pi + "2.718281828459045235360287471352662497757", + "1e100", "1e-100", "-1e100", "-1e-100", + "1.23456789012345678901234567890e30", + ]) + def test_pickle_scalar_finite_roundtrip(self, backend, value): + """Finite values must round-trip exactly (Dragon4-Unique is lossless).""" + import pickle + original = QuadPrecision(value, backend=backend) + loaded = pickle.loads(pickle.dumps(original)) + assert isinstance(loaded, QuadPrecision) + assert loaded.dtype == QuadPrecDType(backend=backend) + # Exact equality: pickle/unpickle should not lose any bits. + assert loaded == original + # And the canonical repr should match. + assert str(loaded) == str(original) + + @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) + def test_pickle_scalar_inf(self, backend): + import pickle + for s in ["inf", "-inf"]: + original = QuadPrecision(s, backend=backend) + loaded = pickle.loads(pickle.dumps(original)) + assert isinstance(loaded, QuadPrecision) + assert loaded == original # inf == inf, -inf == -inf + assert float(loaded) == float(original) + + @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) + def test_pickle_scalar_nan(self, backend): + import pickle + original = QuadPrecision("nan", backend=backend) + loaded = pickle.loads(pickle.dumps(original)) + assert isinstance(loaded, QuadPrecision) + # NaN != NaN, so we check isnan instead. + import math + assert math.isnan(float(loaded)) + assert loaded.dtype == QuadPrecDType(backend=backend) + + @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) + @pytest.mark.parametrize("protocol", [0, 1, 2, 3, 4, 5]) + def test_pickle_scalar_all_protocols(self, backend, protocol): + """Round-trip must work across every pickle protocol version.""" + import pickle + original = QuadPrecision("3.14159265358979323846", backend=backend) + data = pickle.dumps(original, protocol=protocol) + loaded = pickle.loads(data) + assert isinstance(loaded, QuadPrecision) + assert loaded == original + assert loaded.dtype == QuadPrecDType(backend=backend) + + def test_pickle_scalar_preserves_type(self): + import pickle + loaded = pickle.loads(pickle.dumps(QuadPrecision("1.0"))) + assert type(loaded) is QuadPrecision + + def test_pickle_scalar_preserves_backend_across_mix(self): + """Each backend pickle must come back as the same backend, not silently + defaulting to sleef.""" + import pickle + ld = QuadPrecision("1.5", backend="longdouble") + sl = QuadPrecision("1.5", backend="sleef") + ld_loaded = pickle.loads(pickle.dumps(ld)) + sl_loaded = pickle.loads(pickle.dumps(sl)) + assert ld_loaded.dtype == QuadPrecDType(backend="longdouble") + assert sl_loaded.dtype == QuadPrecDType(backend="sleef") + + def test_pickle_scalar_in_list(self): + """Composite container of scalars also pickles cleanly.""" + import pickle + original = [QuadPrecision("1.5"), QuadPrecision("2.5"), + QuadPrecision("nan"), QuadPrecision("inf")] + loaded = pickle.loads(pickle.dumps(original)) + import math + assert len(loaded) == 4 + assert loaded[0] == original[0] + assert loaded[1] == original[1] + assert math.isnan(float(loaded[2])) + assert loaded[3] == original[3] + + @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) + def test_pickle_scalar_loads_does_not_raise(self, backend): + """Direct regression on the exact failure mode in the bug report + (RuntimeError from numpy's legacy SETITEM path).""" + import pickle + try: + pickle.loads(pickle.dumps(QuadPrecision("123.456", backend=backend))) + except RuntimeError as exc: + pytest.fail(f"pickle.loads raised RuntimeError: {exc}") + + @pytest.mark.parametrize("dtype", [ "bool", "byte", "int8", "ubyte", "uint8", From 9cd56742b05a63aa164e5e0da3a287b9fb241c37 Mon Sep 17 00:00:00 2001 From: swayaminsync Date: Tue, 19 May 2026 17:46:36 +0530 Subject: [PATCH 2/5] use snsprintf for longdouble --- src/csrc/scalar.c | 14 ++++++++++---- tests/test_quaddtype.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/csrc/scalar.c b/src/csrc/scalar.c index 58b489f..a9a3252 100644 --- a/src/csrc/scalar.c +++ b/src/csrc/scalar.c @@ -2,6 +2,7 @@ #include #include #include +#include #define PY_ARRAY_UNIQUE_SYMBOL QuadPrecType_ARRAY_API #define NPY_NO_DEPRECATED_API NPY_2_0_API_VERSION @@ -620,10 +621,15 @@ QuadPrecision_reduce(QuadPrecisionObject *self, PyObject *Py_UNUSED(ignored)) opt.trim_mode, opt.digits_left, opt.exp_digits); } else { - Sleef_quad sleef_val = Sleef_cast_from_doubleq1(self->value.longdouble_value); - str_value = Dragon4_Scientific_QuadDType(&sleef_val, opt.digit_mode, opt.precision, - opt.min_digits, opt.sign, opt.trim_mode, - opt.digits_left, opt.exp_digits); + char buffer[128]; + int written = snprintf(buffer, sizeof(buffer), "%.*Le", + LDBL_DECIMAL_DIG - 1, self->value.longdouble_value); + if (written < 0 || written >= (int)sizeof(buffer)) { + PyErr_SetString(PyExc_RuntimeError, + "Failed to format long double for pickle"); + return NULL; + } + str_value = PyUnicode_FromString(buffer); } if (str_value == NULL) { return NULL; diff --git a/tests/test_quaddtype.py b/tests/test_quaddtype.py index 4f0085c..56888dc 100644 --- a/tests/test_quaddtype.py +++ b/tests/test_quaddtype.py @@ -5412,6 +5412,24 @@ def test_pickle_scalar_preserves_type(self): loaded = pickle.loads(pickle.dumps(QuadPrecision("1.0"))) assert type(loaded) is QuadPrecision + @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) + def test_pickle_scalar_preserves_full_precision(self, backend): + """Diagnostic precision-loss test. Two values with the same printed + repr can still differ at the full bit width, so check via subtraction + (which preserves precision) — `loaded - original` must be exactly zero. + Catches the regression where the longdouble path went through (double).""" + import pickle + # A value with more than 16 significant digits — exercises precision + # beyond what double can represent. + original = QuadPrecision("3.14159265358979323846264338327950288", + backend=backend) + loaded = pickle.loads(pickle.dumps(original)) + diff = loaded - original + assert diff == QuadPrecision("0.0", backend=backend), ( + f"pickle round-trip lost precision on {backend}: " + f"loaded - original = {diff!r}" + ) + def test_pickle_scalar_preserves_backend_across_mix(self): """Each backend pickle must come back as the same backend, not silently defaulting to sleef.""" From 4c32b91a103ddc148834506d1cc6b2750311330b Mon Sep 17 00:00:00 2001 From: swayaminsync Date: Tue, 19 May 2026 20:02:42 +0530 Subject: [PATCH 3/5] defining LDBL_DECIMAL_DIG for MSVC --- src/csrc/scalar.c | 5 +++-- src/include/constants.hpp | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/csrc/scalar.c b/src/csrc/scalar.c index a9a3252..0ff1f71 100644 --- a/src/csrc/scalar.c +++ b/src/csrc/scalar.c @@ -2,7 +2,6 @@ #include #include #include -#include #define PY_ARRAY_UNIQUE_SYMBOL QuadPrecType_ARRAY_API #define NPY_NO_DEPRECATED_API NPY_2_0_API_VERSION @@ -332,13 +331,15 @@ QuadPrecision_str_dragon4(QuadPrecisionObject *self) static PyObject * QuadPrecision_str(QuadPrecisionObject *self) +// This is just define here for debugging, we actually use QuadPrecision_str_dragon4 for __str__ method. { char buffer[128]; if (self->backend == BACKEND_SLEEF) { Sleef_snprintf(buffer, sizeof(buffer), "%.*Qe", SLEEF_QUAD_DIG, self->value.sleef_value); } else { - snprintf(buffer, sizeof(buffer), "%.35Le", self->value.longdouble_value); + snprintf(buffer, sizeof(buffer), "%.*Le", LDBL_DECIMAL_DIG - 1, + self->value.longdouble_value); } return PyUnicode_FromString(buffer); } diff --git a/src/include/constants.hpp b/src/include/constants.hpp index e9733f0..8886731 100644 --- a/src/include/constants.hpp +++ b/src/include/constants.hpp @@ -9,6 +9,15 @@ extern "C" { #include #include #include +#include + +/* LDBL_DECIMAL_DIG: minimum decimal digits needed for a lossless + * long-double → string → long-double round-trip. Standard C11 constant in + * , but MSVC omits it. On MSVC long double is the same width as + * double, so DBL_DECIMAL_DIG (17) is the exact correct fallback. */ +#ifndef LDBL_DECIMAL_DIG +# define LDBL_DECIMAL_DIG DBL_DECIMAL_DIG +#endif // Quad precision constants using sleef_q macro #define QUAD_PRECISION_ZERO sleef_q(+0x0000000000000LL, 0x0000000000000000ULL, -16383) From 34c9c6d553108bb38be792fb910cf21b4cfbddc5 Mon Sep 17 00:00:00 2001 From: SwayamInSync Date: Fri, 5 Jun 2026 17:09:11 +0000 Subject: [PATCH 4/5] fix asymmetric check for rounding intervals + tests --- src/csrc/dragon4.c | 2 +- tests/test_quaddtype.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/csrc/dragon4.c b/src/csrc/dragon4.c index f86f269..bf37f8c 100644 --- a/src/csrc/dragon4.c +++ b/src/csrc/dragon4.c @@ -1929,7 +1929,7 @@ Dragon4_PrintFloat_Sleef_quad(Sleef_quad *value, Dragon4_Options *opt) /* mantissa_lo is unchanged */ exponent = floatExponent - 16383 - 112; mantissaBit = 112; - hasUnequalMargins = (floatExponent != 1) && (mantissa_hi == 0 && mantissa_lo == 0); + hasUnequalMargins = (floatExponent != 1) && (mantissa_hi == (1ull << 48) && mantissa_lo == 0); } else { /* subnormal */ diff --git a/tests/test_quaddtype.py b/tests/test_quaddtype.py index 56888dc..ca08f63 100644 --- a/tests/test_quaddtype.py +++ b/tests/test_quaddtype.py @@ -308,6 +308,39 @@ def test_string_roundtrip(): ) +def test_string_roundtrip_all_powers_of_two(): + """Every exact power of two from the smallest subnormal up to overflow must + round-trip through str() and repr(). Powers of two are the only values whose + rounding interval is asymmetric, so they are the sole trigger for Dragon4 + margin bugs and are otherwise unreachable by random or decimal fuzzing.""" + two = QuadPrecision("2.0", backend="sleef") + maxv = numpy_quaddtype.max_value + p = numpy_quaddtype.smallest_subnormal + + str_fails = [] + repr_fails = [] + tested = 0 + while True: + tested += 1 + if QuadPrecision(str(p), backend="sleef") != p: + str_fails.append(str(p)) + repr_inner = repr(p).split("'")[1] + if QuadPrecision(repr_inner, backend="sleef") != p: + repr_fails.append(repr(p)) + nxt = p * two + if not (abs(nxt) <= maxv): + break + p = nxt + + assert tested > 30000, f"expected the full power-of-two sweep, only tested {tested}" + assert not str_fails, ( + f"{len(str_fails)} powers of two failed str() round-trip, e.g. {str_fails[:5]}" + ) + assert not repr_fails, ( + f"{len(repr_fails)} powers of two failed repr() round-trip, e.g. {repr_fails[:5]}" + ) + + class TestBytesSupport: """Test suite for QuadPrecision bytes input support.""" From 9d57423e20416aa44cf17147d8e53af488bf5fce Mon Sep 17 00:00:00 2001 From: SwayamInSync Date: Fri, 5 Jun 2026 17:13:04 +0000 Subject: [PATCH 5/5] applying other reviews --- src/csrc/scalar.c | 15 --------------- tests/test_quaddtype.py | 24 +++--------------------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/src/csrc/scalar.c b/src/csrc/scalar.c index 0ff1f71..e14854c 100644 --- a/src/csrc/scalar.c +++ b/src/csrc/scalar.c @@ -329,21 +329,6 @@ QuadPrecision_str_dragon4(QuadPrecisionObject *self) } } -static PyObject * -QuadPrecision_str(QuadPrecisionObject *self) -// This is just define here for debugging, we actually use QuadPrecision_str_dragon4 for __str__ method. -{ - char buffer[128]; - if (self->backend == BACKEND_SLEEF) { - Sleef_snprintf(buffer, sizeof(buffer), "%.*Qe", SLEEF_QUAD_DIG, self->value.sleef_value); - } - else { - snprintf(buffer, sizeof(buffer), "%.*Le", LDBL_DECIMAL_DIG - 1, - self->value.longdouble_value); - } - return PyUnicode_FromString(buffer); -} - static PyObject * QuadPrecision_repr_dragon4(QuadPrecisionObject *self) { diff --git a/tests/test_quaddtype.py b/tests/test_quaddtype.py index ca08f63..9fb8c20 100644 --- a/tests/test_quaddtype.py +++ b/tests/test_quaddtype.py @@ -5379,7 +5379,6 @@ class TestScalarPickle: @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) def test_pickle_scalar_issue_repro(self, backend): - """The exact repro from #99: was RuntimeError on loads().""" import pickle original = QuadPrecision("123.456", backend=backend) loaded = pickle.loads(pickle.dumps(original)) @@ -5402,9 +5401,7 @@ def test_pickle_scalar_finite_roundtrip(self, backend, value): loaded = pickle.loads(pickle.dumps(original)) assert isinstance(loaded, QuadPrecision) assert loaded.dtype == QuadPrecDType(backend=backend) - # Exact equality: pickle/unpickle should not lose any bits. assert loaded == original - # And the canonical repr should match. assert str(loaded) == str(original) @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) @@ -5414,7 +5411,7 @@ def test_pickle_scalar_inf(self, backend): original = QuadPrecision(s, backend=backend) loaded = pickle.loads(pickle.dumps(original)) assert isinstance(loaded, QuadPrecision) - assert loaded == original # inf == inf, -inf == -inf + assert loaded == original assert float(loaded) == float(original) @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) @@ -5423,7 +5420,6 @@ def test_pickle_scalar_nan(self, backend): original = QuadPrecision("nan", backend=backend) loaded = pickle.loads(pickle.dumps(original)) assert isinstance(loaded, QuadPrecision) - # NaN != NaN, so we check isnan instead. import math assert math.isnan(float(loaded)) assert loaded.dtype == QuadPrecDType(backend=backend) @@ -5447,13 +5443,9 @@ def test_pickle_scalar_preserves_type(self): @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) def test_pickle_scalar_preserves_full_precision(self, backend): - """Diagnostic precision-loss test. Two values with the same printed - repr can still differ at the full bit width, so check via subtraction - (which preserves precision) — `loaded - original` must be exactly zero. - Catches the regression where the longdouble path went through (double).""" + """Compare via subtraction, not repr: two values with the same printed + repr can still differ at the full bit width.""" import pickle - # A value with more than 16 significant digits — exercises precision - # beyond what double can represent. original = QuadPrecision("3.14159265358979323846264338327950288", backend=backend) loaded = pickle.loads(pickle.dumps(original)) @@ -5487,16 +5479,6 @@ def test_pickle_scalar_in_list(self): assert math.isnan(float(loaded[2])) assert loaded[3] == original[3] - @pytest.mark.parametrize("backend", ["sleef", "longdouble"]) - def test_pickle_scalar_loads_does_not_raise(self, backend): - """Direct regression on the exact failure mode in the bug report - (RuntimeError from numpy's legacy SETITEM path).""" - import pickle - try: - pickle.loads(pickle.dumps(QuadPrecision("123.456", backend=backend))) - except RuntimeError as exc: - pytest.fail(f"pickle.loads raised RuntimeError: {exc}") - @pytest.mark.parametrize("dtype", [ "bool",