fix(format): decode floats by their datatype fields, not their size

Every 2-byte float was decoded as IEEE half, so bfloat16 (HDF5 2.0's
H5T_FLOAT_BFLOAT16*, or any custom 8-bit-exponent type) read wrong:
1.5 as 1.9375, +inf as NaN. 1-byte FP8 floats were refused.

Read the exponent/mantissa location and size and the bias from the
datatype message: IEEE half/single/double keep their existing paths
(half still through clawhdf5_format::float16), any other IEEE-style
layout up to 64 bits whose values fit f64 (bfloat16, FP8 E4M3/E5M2, ...)
is decoded generically, and the bulk-copy and zero-copy fast paths now
require the IEEE layout rather than just the size. Datatypes with fields
that describe no float still fall back to IEEE by size; layouts that
cannot be represented in f64 (x87 80-bit, binary128) remain an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-25 21:08:37 -05:00
co-authored by Claude Opus 5.5
parent 53dbddb07b
commit 417c9516ca
2 changed files with 320 additions and 63 deletions
@@ -188,3 +188,88 @@ with h5py.File("{path}", "r") as f:
let i8be = file.dataset("i8be").unwrap();
assert_eq!(i8be.read_u64().unwrap(), vec![(1 << 40) + 5, 0, 7, 0]);
}
#[test]
fn floats_decode_by_their_datatype_fields() {
// Every 2-byte float used to decode as IEEE half, so bfloat16 1.5 read as
// 1.9375 and +inf as NaN; 1-byte FP8 floats were refused.
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("float_layouts.h5");
let script = format!(
r#"{PRELUDE}
def custom(base, fields, bias, size):
# fields: (sign pos, exponent pos, exponent size, mantissa pos, mantissa size)
t = base.copy()
t.set_fields(*fields)
t.set_ebias(bias)
t.set_precision(size * 8)
t.set_size(size)
return t
def write(f, name, ftype, raw):
raw = np.ascontiguousarray(raw)
space = h5py.h5s.create_simple(raw.shape)
ds = h5py.h5d.create(f.id, name.encode(), ftype, space)
ds.write(h5py.h5s.ALL, h5py.h5s.ALL, raw, mtype=ftype)
# bfloat16: 1.5, -2.25, +inf, 0, 3.140625, 1, -0, smallest subnormal,
# largest finite, NaN
bf16 = np.array([0x3FC0, 0xC010, 0x7F80, 0x0000, 0x4049, 0x3F80, 0x8000, 0x0001,
0x7F7F, 0x7FC1], "<u2")
# 8-bit patterns, all 256 of them
fp8 = np.arange(256, dtype="u1")
types = {{
"bf16_le": (custom(h5py.h5t.IEEE_F32LE, (15, 7, 8, 0, 7), 127, 2), bf16),
"bf16_be": (custom(h5py.h5t.IEEE_F32BE, (15, 7, 8, 0, 7), 127, 2), bf16.byteswap()),
"e4m3": (custom(h5py.h5t.IEEE_F32LE, (7, 3, 4, 0, 3), 7, 1), fp8),
"e5m2": (custom(h5py.h5t.IEEE_F32LE, (7, 2, 5, 0, 2), 15, 1), fp8),
}}
with h5py.File("{path}", "w") as f:
for name, (t, raw) in types.items():
write(f, name, t, raw)
vals = np.array([1.5, -2.25, np.inf, -np.inf, 0.0, -0.0, 6e-8, 65504, 1e-40, 1e300])
f.create_dataset("f2_be", data=vals.astype(">f2"))
f.create_dataset("f4_be", data=vals.astype(">f4"))
f.create_dataset("f8_le", data=vals.astype("<f8"))
with h5py.File("{path}", "r") as f:
for name in list(types) + ["f2_be", "f4_be", "f8_le"]:
v = f[name].astype("<f8")[()]
# NaN payloads differ between converters; compare NaN as one value.
v[np.isnan(v)] = np.nan
emit(name, v.view("<u8"))
"#,
path = path.display()
);
let expected = run_python(&script);
let canonical = |v: f64| if v.is_nan() { f64::NAN } else { v };
let file = File::open(&path).unwrap();
for name in [
"bf16_le", "bf16_be", "e4m3", "e5m2", "f2_be", "f4_be", "f8_le",
] {
let ds = file.dataset(name).unwrap();
let want: Vec<u64> = parse(&expected[name]);
let got: Vec<u64> = ds
.read_f64()
.unwrap()
.into_iter()
.map(|v| canonical(v).to_bits())
.collect();
assert_eq!(got, want, "{name} as f64");
// f32 reads agree too (every value here is exact in f32 except the
// f64 dataset, which rounds like `as f32`).
let got32: Vec<u32> = ds
.read_f32()
.unwrap()
.into_iter()
.map(|v| if v.is_nan() { f32::NAN } else { v }.to_bits())
.collect();
let want32: Vec<u32> = want
.iter()
.map(|&b| canonical(f64::from_bits(b)) as f32)
.map(|v| if v.is_nan() { f32::NAN } else { v }.to_bits())
.collect();
assert_eq!(got32, want32, "{name} as f32");
}
}