fix(format): files we write now open in h5py and libhdf5

Two write-side bugs, both present in every release (the first at least
since v2.1.0), made libhdf5 refuse files written by clawhdf5. Our own
reader ignores both fields, and the interop suites only ever wrote f64
from our side, so nothing here caught them.

- Every f32 dataset: "sign bit position out of bounds". The float
  datatype encoder hard-coded the sign bit's position (bits 8-15 of the
  class bit field) to 63, which is right only for f64. It is now derived
  from the type: bit_offset + bit_precision - 1. This covered every
  agent store's embeddings, norms and activation weights.
- Every empty dataset: "invalid dataset size, likely file corruption".
  It was written with a real address and size 0, which trips libhdf5's
  `addr + size <= addr` overflow check. An empty contiguous dataset now
  gets the undefined address, as libhdf5 writes it. This covered every
  agent store without sessions or a knowledge graph.

Agent stores are rewritten in full at each checkpoint, so they become
readable at their next checkpoint on a fixed build; other files with f32
or empty datasets need rewriting. Both are recorded in
docs/known-issues.md.

Tests: the sign position byte for f32/f64, and h5py reading our f32
datasets (plain and chunked + deflate) bit for bit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-24 12:00:26 -05:00
co-authored by Claude Opus 5.5
parent 1cceb930b2
commit 5e4aa1c6bf
4 changed files with 121 additions and 4 deletions
+24 -4
View File
@@ -640,7 +640,8 @@ impl Datatype {
mantissa_size,
exponent_bias,
} => {
let mut bf0 = 0x20u8; // bit 5: sign location bit (standard IEEE 754)
// Bits 4-5: mantissa normalization = 2 (implied leading 1, IEEE 754).
let mut bf0 = 0x20u8;
match byte_order {
DatatypeByteOrder::BigEndian => {
bf0 |= 0x01;
@@ -650,9 +651,14 @@ impl Datatype {
}
_ => {}
}
// bf[1] bits 0-1: mantissa normalization = 2 (MSB not stored, IEEE 754)
let bf1 = 0x3fu8; // matching what h5py generates
let mut buf = Self::build_header(1, 1, [bf0, bf1, 0], *size);
// Bits 8-15: the sign bit's position, the top bit of the value.
// This was hard-coded to 63, which is right only for f64: the
// HDF5 library rejects any other float with "sign bit position
// out of bounds", so every f32 dataset and attribute we wrote
// was unreadable by h5py and libhdf5.
let sign_location =
(u32::from(*bit_offset) + u32::from(*bit_precision)).saturating_sub(1) as u8;
let mut buf = Self::build_header(1, 1, [bf0, sign_location, 0], *size);
buf.extend_from_slice(&bit_offset.to_le_bytes());
buf.extend_from_slice(&bit_precision.to_le_bytes());
buf.push(*exponent_location);
@@ -818,6 +824,20 @@ fn build_dt_header(class: u8, version: u8, bf: [u8; 3], size: u32) -> Vec<u8> {
mod tests {
use super::*;
#[test]
fn float_sign_location_is_the_top_bit_of_the_value() {
// The HDF5 library rejects a float whose sign position is not inside
// its precision; this was hard-coded to 63, so every f32 we wrote was
// unreadable by h5py. Byte 2 of the message is the sign position.
use crate::type_builders::{make_f32_type, make_f64_type};
for (dt, sign) in [(make_f32_type(), 31), (make_f64_type(), 63)] {
let bytes = dt.serialize();
assert_eq!(bytes[2], sign, "{dt:?}");
let (parsed, _) = Datatype::parse(&bytes).unwrap();
assert_eq!(parsed, dt);
}
}
// Helper to build a fixed-point datatype message
fn build_fixed_point(
size: u32,
@@ -86,6 +86,12 @@ pub(crate) fn build_dataset_oh(
let mut dl = Vec::new();
dl.push(4); // version
dl.push(1); // class = contiguous
// An empty dataset has no storage: its address must be the undefined
// address, as libhdf5 writes it. A real address with size 0 trips
// libhdf5's `addr + size <= addr` overflow check, and it refuses the
// dataset as "invalid dataset size, likely file corruption" — which made
// every store with no sessions or knowledge graph unreadable by h5py.
let data_addr = if data_size == 0 { u64::MAX } else { data_addr };
dl.extend_from_slice(&data_addr.to_le_bytes());
dl.extend_from_slice(&data_size.to_le_bytes());
w.add_message(MessageType::DataLayout, dl);