From 5b69baad687f2c057ff7e6c96a194eecb377046f Mon Sep 17 00:00:00 2001 From: swayaminsync Date: Tue, 19 May 2026 17:15:16 +0530 Subject: [PATCH 1/3] 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/3] 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/3] 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)