Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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);
Comment thread
ngoldbaum marked this conversation as resolved.
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.
Comment thread
ngoldbaum marked this conversation as resolved.
Outdated
{
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()."""
Comment thread
ngoldbaum marked this conversation as resolved.
Outdated
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)."""
Comment thread
ngoldbaum marked this conversation as resolved.
Outdated
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):
Comment thread
ngoldbaum marked this conversation as resolved.
Outdated
"""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