Skip to content

Commit ff42a55

Browse files
committed
Add tests comparing Ryū output to the reference dtoa
Export _Py_dg_dtoa() and _Py_dg_freedtoa() via PyAPI_FUNC and add _testinternalcapi.compare_ryu_dtoa(), which calls both backends on the same double and reports any difference in the (digits, decpt, sign) result. The new test_ryu_matches_dtoa exercises ~13k values including all powers of two and ten, IEEE-754 boundary values, the upstream Ryū regression-test cases, and random bit patterns. Add several exact-string assertions to test_short_repr that pin behaviour discussed at https://discuss.python.org/t/2466: the 1/2**24 'shortest is not round-to-N' corner case; 1/2**25 and 6/2**25 ties-to-even cases; and the smallest subnormals 5e-324 and 1e-323 (one-digit shortest). Add test_alternate_form_shortest covering format(x, '#') for single-significant-digit values in exponential range, which had no prior coverage. Add Lib/test/test_float_property.py with hypothesis-based property tests: float and complex repr() round-trip, repr() is the shortest round-tripping decimal, and Ryū matches dtoa for arbitrary doubles. Without hypothesis installed these run only the @example cases; the test-hypothesis CI job runs them with shrinking and a persistent failure database.
1 parent 793a720 commit ff42a55

4 files changed

Lines changed: 221 additions & 3 deletions

File tree

Include/internal/pycore_dtoa.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ extern "C" {
2525
#endif
2626

2727
extern double _Py_dg_strtod(const char *str, char **ptr);
28-
extern char* _Py_dg_dtoa(double d, int mode, int ndigits,
29-
int *decpt, int *sign, char **rve);
30-
extern void _Py_dg_freedtoa(char *s);
28+
// Export for '_testinternalcapi' shared extension.
29+
PyAPI_FUNC(char*) _Py_dg_dtoa(double d, int mode, int ndigits,
30+
int *decpt, int *sign, char **rve);
31+
PyAPI_FUNC(void) _Py_dg_freedtoa(char *s);
3132

3233

3334
extern PyStatus _PyDtoa_Init(PyInterpreterState *interp);

Lib/test/test_float.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,21 @@ def test_issue35560(self):
858858
self.assertEqual(format(-123.34, '00.10e'), '-1.2334000000e+02')
859859
self.assertEqual(format(-123.34, '00.10g'), '-123.34')
860860

861+
@unittest.skipUnless(getattr(sys, 'float_repr_style', '') == 'short',
862+
"applies only when using short float repr style")
863+
def test_alternate_form_shortest(self):
864+
# '#' with no type code goes through the shortest-repr conversion;
865+
# for single-significant-digit values in exponential range it must
866+
# still force the decimal point.
867+
self.assertEqual(format(1e16, '#'), '1.e+16')
868+
self.assertEqual(format(1e300, '#'), '1.e+300')
869+
self.assertEqual(format(5e-324, '#'), '5.e-324')
870+
self.assertEqual(format(-1e16, '+#'), '-1.e+16')
871+
self.assertEqual(format(1e16, ''), '1e+16')
872+
self.assertEqual(format(1.5, '#'), '1.5')
873+
self.assertEqual(format(100000.0, '#'), '100000.0')
874+
875+
861876
class ReprTestCase(unittest.TestCase):
862877
def test_repr(self):
863878
with open(os.path.join(os.path.split(__file__)[0],
@@ -912,6 +927,22 @@ def test_short_repr(self):
912927
'2.86438000439698e+28',
913928
'8.89142905246179e+28',
914929
'3.08578087079232e+35',
930+
# 1/2**24: a value whose shortest repr differs from
931+
# "round x to N significant digits" for every N (rounding
932+
# to 16 digits gives ...062 which doesn't round-trip;
933+
# to 17 gives ...0625 which does, but ...063 is shorter
934+
# and also round-trips).
935+
'5.960464477539063e-08',
936+
# Ties to even: both ...12 and ...13 round-trip to
937+
# 1/2**25 and are exactly equidistant from it; the repr
938+
# must end in the even digit.
939+
'2.9802322387695312e-08',
940+
'1.7881393432617188e-07',
941+
# Smallest subnormal: '5e-324' (one digit) is shortest;
942+
# '4.9e-324' is closer to the true value but longer.
943+
'5e-324',
944+
# Same one-vs-two-digit choice for the next subnormal up.
945+
'1e-323',
915946
]
916947

917948
for s in test_strings:
@@ -922,6 +953,51 @@ def test_short_repr(self):
922953
self.assertEqual(repr(float(s)), str(float(s)))
923954
self.assertEqual(repr(float(negs)), str(float(negs)))
924955

956+
@unittest.skipUnless(getattr(sys, 'float_repr_style', '') == 'short',
957+
"applies only when using short float repr style")
958+
@support.cpython_only
959+
def test_ryu_matches_dtoa(self):
960+
# Shortest float repr is computed by the Ryū algorithm; check
961+
# that it produces the same digits, decimal-point position and
962+
# sign as the reference dtoa implementation for a variety of
963+
# values, including random bit patterns.
964+
from test.support import import_helper
965+
_testinternalcapi = import_helper.import_module('_testinternalcapi')
966+
compare = _testinternalcapi.compare_ryu_dtoa
967+
968+
cases = [
969+
0.0, -0.0, 1.0, -1.0, 0.1, 0.5, 1.5, 2.5,
970+
5e-324, # smallest subnormal
971+
2.2250738585072014e-308, # smallest normal
972+
1.7976931348623157e+308, # largest finite
973+
INF, -INF, NAN,
974+
# Regression cases from the upstream Ryū test suite.
975+
2.98023223876953125e-8, # LotsOfTrailingZeros
976+
-2.109808898695963e16, 4.940656e-318, 1.18575755e-316,
977+
2.989102097996e-312, 9.0608011534336e15,
978+
4.708356024711512e18, 9.409340012568248e18,
979+
float.fromhex('0x1.0F0CF064DD592p+131'), # LooksLikePow5
980+
float.fromhex('0x1.0F0CF064DD592p+132'),
981+
float.fromhex('0x1.0F0CF064DD592p+133'),
982+
4.294967294, 4.294967295, 4.294967296, 4.294967297,
983+
]
984+
# Powers of two include the cases where the shortest repr is
985+
# not equal to "round x to N significant digits" for any N
986+
# (e.g. 1/2**24, 2**89, 2**-44).
987+
cases.extend(2.0 ** e for e in range(-1074, 1024))
988+
cases.extend(float(f'1e{e}') for e in range(-323, 309))
989+
rng = random.Random(96313)
990+
for _ in range(10_000):
991+
bits = rng.getrandbits(64)
992+
cases.append(struct.unpack('<d', struct.pack('<Q', bits))[0])
993+
994+
for x in cases:
995+
mismatch = compare(x)
996+
if mismatch is not None and not math.isnan(x):
997+
self.fail(f'ryu/dtoa mismatch for {x.hex()} ({x!r}): '
998+
f'dtoa={mismatch[0]} ryu={mismatch[1]}')
999+
1000+
9251001
@support.requires_IEEE_754
9261002
class RoundTestCase(unittest.TestCase, FloatsAreIdenticalMixin):
9271003

Lib/test/test_float_property.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import math
2+
import sys
3+
import unittest
4+
from test import support
5+
from test.support.hypothesis_helper import hypothesis
6+
7+
floats = hypothesis.strategies.floats
8+
given = hypothesis.given
9+
example = hypothesis.example
10+
11+
12+
@unittest.skipUnless(getattr(sys, 'float_repr_style', '') == 'short',
13+
"applies only when using short float repr style")
14+
class ShortFloatReprProperties(unittest.TestCase):
15+
16+
@given(x=floats())
17+
@example(x=0.0)
18+
@example(x=-0.0)
19+
@example(x=5e-324)
20+
@example(x=sys.float_info.min)
21+
@example(x=sys.float_info.max)
22+
def test_repr_round_trip(self, x):
23+
s = repr(x)
24+
if math.isnan(x):
25+
self.assertTrue(math.isnan(float(s)))
26+
else:
27+
self.assertEqual(float(s), x)
28+
self.assertEqual(str(x), s)
29+
30+
@given(x=floats(allow_nan=False, allow_infinity=False))
31+
@example(x=2.0**-24)
32+
@example(x=1e-323)
33+
@example(x=2.0**89)
34+
@example(x=2.0**-44)
35+
@example(x=1.0)
36+
@example(x=0.0)
37+
def test_repr_is_shortest(self, x):
38+
# Steele & White criterion (1): no shorter decimal string round-trips.
39+
s = repr(x)
40+
mant = s.partition('e')[0]
41+
digits = ''.join(c for c in mant if c.isdigit())
42+
if '.' in mant:
43+
digits = digits.rstrip('0')
44+
digits = digits.lstrip('0') or '0'
45+
n = len(digits)
46+
if n > 1:
47+
shorter = format(x, f'.{n - 1}g')
48+
if float(shorter) == x:
49+
self.fail(f'repr({x.hex()}) = {s!r} ({n} digits) '
50+
f'but {shorter!r} ({n - 1} digits) also '
51+
f'round-trips')
52+
53+
@support.cpython_only
54+
@given(x=floats())
55+
@example(x=2.0**-24)
56+
@example(x=2.0**-25)
57+
@example(x=5e-324)
58+
@example(x=2.98023223876953125e-8)
59+
@example(x=float.fromhex('0x1.0F0CF064DD592p+132'))
60+
@example(x=4.940656e-318)
61+
@example(x=9.0608011534336e15)
62+
@example(x=4.708356024711512e18)
63+
@example(x=-0.0)
64+
def test_ryu_matches_dtoa(self, x):
65+
# Ryu and the reference dtoa must produce identical
66+
# (digits, decpt, sign) triples for the shortest representation.
67+
from test.support import import_helper
68+
_testinternalcapi = import_helper.import_module('_testinternalcapi')
69+
mismatch = _testinternalcapi.compare_ryu_dtoa(x)
70+
if mismatch is not None and not math.isnan(x):
71+
self.fail(f'ryu/dtoa mismatch for {x.hex()} ({x!r}): '
72+
f'dtoa={mismatch[0]} ryu={mismatch[1]}')
73+
74+
@given(re=floats(), im=floats())
75+
@example(re=-0.0, im=-0.0)
76+
@example(re=0.0, im=-0.0)
77+
@example(re=math.inf, im=-math.inf)
78+
@example(re=1e308, im=1e-308)
79+
@example(re=5e-324, im=5e-324)
80+
def test_complex_repr_round_trip(self, re, im):
81+
c = complex(re, im)
82+
s = repr(c)
83+
c2 = complex(s)
84+
if math.isnan(re):
85+
self.assertTrue(math.isnan(c2.real))
86+
else:
87+
self.assertEqual(c2.real, re)
88+
if math.isnan(im):
89+
self.assertTrue(math.isnan(c2.imag))
90+
else:
91+
self.assertEqual(c2.imag, im)
92+
93+
94+
if __name__ == '__main__':
95+
unittest.main()

Modules/_testinternalcapi.c

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "pycore_compile.h" // _PyCompile_CodeGen()
2020
#include "pycore_context.h" // _PyContext_NewHamtForTests()
2121
#include "pycore_dict.h" // PyDictValues
22+
#include "pycore_dtoa.h" // _Py_dg_dtoa()
2223
#include "pycore_fileutils.h" // _Py_normpath()
2324
#include "pycore_flowgraph.h" // _PyCompile_OptimizeCfg()
2425
#include "pycore_frame.h" // _PyInterpreterFrame
@@ -37,6 +38,7 @@
3738
#include "pycore_pylifecycle.h" // _PyInterpreterConfig_InitFromDict()
3839
#include "pycore_pystate.h" // _PyThreadState_GET()
3940
#include "pycore_runtime_structs.h" // _PY_NSMALLPOSINTS
41+
#include "pycore_ryu.h" // _Py_ryu_dtoa()
4042
#include "pycore_unicodeobject.h" // _PyUnicode_TransformDecimalAndSpaceToASCII()
4143

4244
#include "clinic/_testinternalcapi.c.h"
@@ -2852,6 +2854,47 @@ _pyerr_setkeyerror(PyObject *self, PyObject *arg)
28522854
}
28532855

28542856

2857+
#if _PY_SHORT_FLOAT_REPR == 1
2858+
static PyObject *
2859+
compare_ryu_dtoa(PyObject *self, PyObject *arg)
2860+
{
2861+
double d = PyFloat_AsDouble(arg);
2862+
if (d == -1.0 && PyErr_Occurred()) {
2863+
return NULL;
2864+
}
2865+
2866+
int g_decpt, g_sign;
2867+
char *g_end;
2868+
char *g_digits = _Py_dg_dtoa(d, 0, 0, &g_decpt, &g_sign, &g_end);
2869+
if (g_digits == NULL) {
2870+
return PyErr_NoMemory();
2871+
}
2872+
2873+
char r_buf[_Py_RYU_DTOA_BUFSIZE];
2874+
int r_decpt, r_sign;
2875+
int r_len = _Py_ryu_dtoa(d, r_buf, &r_decpt, &r_sign);
2876+
2877+
int g_len = (int)(g_end - g_digits);
2878+
int ok = (g_decpt == r_decpt
2879+
&& g_sign == r_sign
2880+
&& g_len == r_len
2881+
&& memcmp(g_digits, r_buf, (size_t)r_len) == 0);
2882+
2883+
PyObject *result;
2884+
if (ok) {
2885+
result = Py_NewRef(Py_None);
2886+
}
2887+
else {
2888+
result = Py_BuildValue("((sii)(sii))",
2889+
g_digits, g_decpt, g_sign,
2890+
r_buf, r_decpt, r_sign);
2891+
}
2892+
_Py_dg_freedtoa(g_digits);
2893+
return result;
2894+
}
2895+
#endif
2896+
2897+
28552898
static PyMethodDef module_functions[] = {
28562899
{"get_configs", get_configs, METH_NOARGS},
28572900
{"get_eval_frame_stats", get_eval_frame_stats, METH_NOARGS, NULL},
@@ -2974,6 +3017,9 @@ static PyMethodDef module_functions[] = {
29743017
{"test_threadstate_set_stack_protection",
29753018
test_threadstate_set_stack_protection, METH_NOARGS},
29763019
{"_pyerr_setkeyerror", _pyerr_setkeyerror, METH_O},
3020+
#if _PY_SHORT_FLOAT_REPR == 1
3021+
{"compare_ryu_dtoa", compare_ryu_dtoa, METH_O},
3022+
#endif
29773023
{NULL, NULL} /* sentinel */
29783024
};
29793025

0 commit comments

Comments
 (0)