Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/csrc/casts.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
63 changes: 61 additions & 2 deletions src/csrc/scalar.c
Original file line number Diff line number Diff line change
Expand Up @@ -331,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);
}
Expand All @@ -352,7 +354,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) {
Expand Down Expand Up @@ -601,11 +603,68 @@ 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 {
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;
}

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 */
};

Expand Down
9 changes: 9 additions & 0 deletions src/include/constants.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ extern "C" {
#include <sleefquad.h>
#include <stdint.h>
#include <string.h>
#include <float.h>

/* LDBL_DECIMAL_DIG: minimum decimal digits needed for a lossless
* long-double → string → long-double round-trip. Standard C11 constant in
* <float.h>, 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)
Expand Down
127 changes: 127 additions & 0 deletions tests/test_quaddtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -5338,6 +5338,133 @@ 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

@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."""
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",
Expand Down
Loading