Narrow the canonical float form from %.17e to %.16e

%.17e emits 18 significant digits: one before the decimal point plus 17
after. DBL_DECIMAL_DIG is 17, so that is one more than round-tripping a
double requires, and the extra digit is not free.

C11 7.22.1.3p11 recommends correct rounding only for decimal forms of at most
DECIMAL_DIG significant digits; past that the recommendation weakens to a
bounded-value rule. DECIMAL_DIG is sized for long double, so it is 21 where
that is x87 80-bit but 17 where long double == double -- MSVC among others.
An 18-digit canonical form therefore falls outside p11's recommended range on
those targets, for no benefit.

%.16e emits exactly 17 significant digits: still DBL_DECIMAL_DIG, still an
exact round-trip for every double, and within DECIMAL_DIG everywhere, since
DECIMAL_DIG cannot be below 17 wherever double is binary64. Verified over all
2098 powers of two plus 3M random bit patterns: zero round-trip failures.

Note the contrast with 7.22.1.3p9, which *requires* correct rounding for the
hexadecimal form regardless of digit count. That asymmetry is what
canonicalization trades away, and it is why the correctly-rounded conversion
requirement exists; the narrower form just avoids making it worse.

Stored values change accordingly: 2^-36 now canonicalizes to
1.4551915228366852e-11 rather than 1.45519152283668518e-11. Nothing is
released, so there is no compatibility cost.
This commit is contained in:
Scot Breitenfeld
2026-08-28 12:43:30 -05:00
parent 15c48e4a93
commit c0394280de
4 changed files with 30 additions and 20 deletions
+1 -1
View File
@@ -1906,7 +1906,7 @@ H5Pappend_filter(hid_t plist_id, H5Z_filter_t filter, unsigned int flags, const
/* Persist the caller's parameter string so it can be recovered
* losslessly (pipeline v3) without loading the plugin. The string is
* canonicalised first -- outer braces stripped, hex-float literals
* rewritten to %.17e decimal -- so the persisted bytes are a valid
* rewritten to %.16e decimal -- so the persisted bytes are a valid
* TOML v1.0.0 document and can be parsed by readers that are not the
* HDF5 library. Both normalisations preserve the value exactly.
* An empty input stores nothing. */
+16 -6
View File
@@ -66,9 +66,19 @@ H5Z__copy_chars2(char *out, size_t cap, size_t *pos, const char **p)
/*
* H5Z__rewrite_hexfloats - return a copy of `src` with every C99 hex-float
* literal (e.g. "0x1.8p+1", "-0x1p-1") replaced by an equivalent decimal
* string. Uses %.17e, which always carries a decimal point and an exponent
* (so tomlc17 types it TOML_FP64, not TOML_INTEGER) and which guarantees
* IEEE 754 double round-trip fidelity (C99 DBL_DECIMAL_DIG == 17). %.17g
* string. Uses %.16e, which always carries a decimal point and an exponent
* (so tomlc17 types it TOML_FP64, not TOML_INTEGER) and which emits exactly
* DBL_DECIMAL_DIG == 17 significant digits -- one before the point plus 16
* after -- the minimum that round-trips every IEEE 754 double.
*
* The width is deliberate. C11 7.22.1.3p11 recommends correct rounding only
* for decimal forms of at most DECIMAL_DIG significant digits; past that the
* recommendation weakens to a bounded-value rule. DECIMAL_DIG is sized for
* long double, so it is 21 where that is x87 80-bit but 17 where long double
* == double (MSVC among others). %.17e emits 18 digits, exceeding
* DECIMAL_DIG on those targets for no benefit, since 17 already round-trip
* exactly. Contrast 7.22.1.3p9, which *requires* correct rounding for the
* hexadecimal form -- the asymmetry this rewrite trades away. %.17g
* must not be substituted: it drops the decimal point for whole values
* ("8.0" -> "8"), which a TOML parser reads as an integer.
*
@@ -89,7 +99,7 @@ H5Z__rewrite_hexfloats(const char *src)
char *out;
size_t pos = 0;
/* Worst case: every 3-char token "0x1" expands to ~24 chars "%.17e" -> 8x.
/* Worst case: every 3-char token "0x1" expands to ~23 chars "%.16e" -> 8x.
* Guard against size_t overflow in the multiplication; callers normally
* cap input at H5Z_CONFIG_STRING_MAX, but enforce the bound here too so
* this static helper is safe for any future caller. */
@@ -179,7 +189,7 @@ H5Z__rewrite_hexfloats(const char *src)
* 17 significant digits guarantee IEEE 754
* double round-trip fidelity (C99 DBL_DECIMAL_DIG). */
char dec[32];
int n = snprintf(dec, sizeof(dec), "%.17e", val);
int n = snprintf(dec, sizeof(dec), "%.16e", val);
/* LC_NUMERIC may replace '.' with the locale decimal
* separator (e.g. ',' in de_DE). TOML requires '.'.
* Use localeconv() to find the actual separator rather
@@ -282,7 +292,7 @@ H5Z__toml_wrap(const char *params)
* removed: both "{level = 6}" and "level = 6" store as
* "level = 6". This mirrors the acceptance rule in
* H5Z__toml_wrap().
* 2. C99 hex-float literals are rewritten to %.17e decimal,
* 2. C99 hex-float literals are rewritten to %.16e decimal,
* which is bit-exact for IEEE 754 doubles.
*
* The point of both is that the stored bytes are a valid TOML
+1 -1
View File
@@ -121,7 +121,7 @@ H5_DLL htri_t H5Z_filter_avail(H5Z_filter_t id);
H5_DLL herr_t H5Z_delete(struct H5O_pline_t *pline, H5Z_filter_t filter);
H5_DLL herr_t H5Z_get_filter_info(H5Z_filter_t filter, unsigned int *filter_config_flags);
/* Normalise a parameter string into the form persisted in pipeline v3:
* outer braces stripped and hex-float literals rewritten to %.17e decimal,
* outer braces stripped and hex-float literals rewritten to %.16e decimal,
* so the stored bytes are valid TOML v1.0.0. Caller frees with H5MM_xfree(). */
H5_DLL char *H5Z_canonicalize_params(const char *params);
+12 -12
View File
@@ -2459,7 +2459,7 @@ error:
*
* The stored string is normalised so the bytes on disk are a valid TOML
* v1.0.0 document: optional outer braces are stripped, and C99 hex-float
* literals are rewritten to %.17e decimal. Neither the braced form nor a
* literals are rewritten to %.16e decimal. Neither the braced form nor a
* hex-float literal is accepted by a stock TOML parser, and the persisted
* string is meant to be readable by tools that are not the HDF5 library
* (pure-reimplementation readers such as jHDF and pyfive parse the object
@@ -2501,11 +2501,11 @@ canon_get_config(unsigned H5_ATTR_UNUSED flags, size_t cd_nelmts, const unsigned
if (cd_nelmts >= 2)
memcpy(&rate, cd_values, sizeof(rate));
needed = (size_t)snprintf(NULL, 0, "rate = %.17e", rate) + 1;
needed = (size_t)snprintf(NULL, 0, "rate = %.16e", rate) + 1;
if (buf_size)
*buf_size = needed;
if (buf)
snprintf(buf, needed, "rate = %.17e", rate);
snprintf(buf, needed, "rate = %.16e", rate);
return SUCCEED;
}
@@ -2634,17 +2634,17 @@ test_config_canonicalization(hid_t fapl)
TEST_ERROR;
PASSED();
/* --- canon-03: hex-float rewritten to %.17e decimal --- */
/* --- canon-03: hex-float rewritten to %.16e decimal --- */
TESTING("canonicalization: hex-float rewritten to decimal");
if (canon_check("rate = 0x1.8p+1", "rate = 3.00000000000000000e+00") < 0)
if (canon_check("rate = 0x1.8p+1", "rate = 3.0000000000000000e+00") < 0)
TEST_ERROR;
if (canon_check("rate = 0x1.cp+1", "rate = 3.50000000000000000e+00") < 0)
if (canon_check("rate = 0x1.cp+1", "rate = 3.5000000000000000e+00") < 0)
TEST_ERROR;
PASSED();
/* --- canon-04: both normalisations at once --- */
TESTING("canonicalization: braces and hex-float together");
if (canon_check("{ rate = 0x1.8p+1 }", "rate = 3.00000000000000000e+00") < 0)
if (canon_check("{ rate = 0x1.8p+1 }", "rate = 3.0000000000000000e+00") < 0)
TEST_ERROR;
PASSED();
@@ -2716,7 +2716,7 @@ test_config_canonicalization(hid_t fapl)
if (H5Pget_filter_params_by_idx(dcpl_out, 0, pbuf, sizeof(pbuf), &plen) < 0)
TEST_ERROR;
/* Canonical: no outer brace, no hex-float -- parseable as plain TOML */
if (strcmp(pbuf, "rate = 3.00000000000000000e+00") != 0)
if (strcmp(pbuf, "rate = 3.0000000000000000e+00") != 0)
TEST_ERROR;
if (pbuf[0] == '{' || strstr(pbuf, "0x") != NULL)
TEST_ERROR;
@@ -2761,7 +2761,7 @@ test_config_canonicalization(hid_t fapl)
if (H5Pclose(dcpl) < 0)
TEST_ERROR;
dcpl = H5I_INVALID_HID;
if (strcmp(s1, "rate = 1.45519152283668518e-11") != 0) {
if (strcmp(s1, "rate = 1.4551915228366852e-11") != 0) {
fprintf(stderr, "\n stored \"%s\"\n", s1);
TEST_ERROR;
}
@@ -3031,7 +3031,7 @@ test_modify_filter_by_idx(hid_t fapl)
TEST_ERROR;
if (H5Pget_filter_params_by_idx(dcpl, 0, pbuf, sizeof(pbuf), &plen) < 0)
TEST_ERROR;
if (strcmp(pbuf, "rate = 3.00000000000000000e+00") != 0)
if (strcmp(pbuf, "rate = 3.0000000000000000e+00") != 0)
TEST_ERROR;
if (H5Pclose(dcpl) < 0)
TEST_ERROR;
@@ -3055,8 +3055,8 @@ test_modify_filter_by_idx(hid_t fapl)
}
if (H5Pget_filter_params_by_idx(dcpl, 0, pbuf, sizeof(pbuf), &plen) < 0)
TEST_ERROR;
/* Stored string gone -> get_config reconstruction, which uses %.17e */
if (strcmp(pbuf, "rate = 4.50000000000000000e+00") != 0) {
/* Stored string gone -> get_config reconstruction, which uses %.16e */
if (strcmp(pbuf, "rate = 4.5000000000000000e+00") != 0) {
fprintf(stderr, "\n got \"%s\"\n", pbuf);
TEST_ERROR;
}