fix(format): honour each bit of a chunk's filter mask

A chunk's filter mask has one bit per pipeline filter; bit i set means
filter i was not applied to that chunk (an optional filter that
declined, or a direct chunk write). Every read path treated any nonzero
mask as "no filters applied" and returned the stored bytes, so a chunk
that skipped only gzip in a shuffle+gzip pipeline came back still
shuffled (h5py write_direct_chunk with filter_mask=0b10: 8 of 32 values
wrong).

decompress_chunk_masked undoes the filters the mask leaves set and skips
the rest; an unsupported filter is no longer an error when the chunk
skipped it. The full, cached, sweep, indexed, parallel and selection
(partial_read) paths all use it, and a chunk is copied straight from the
file only when every filter was skipped. decompress_chunk is the mask-0
case.

Regression: h5py_partial_filter_mask_skips_only_masked_filters (1-D
shuffle+gzip with masks 0, 0b10 and 0b11; 2-D with 0b01; full and
hyperslab reads) and filter_mask_skips_only_the_masked_filters.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-25 21:05:07 -05:00
co-authored by Claude Opus 5.5
parent 9ea44d473d
commit 585e14d5e2
5 changed files with 244 additions and 59 deletions
@@ -8,6 +8,7 @@
use std::process::Command;
use clawhdf5::File;
use clawhdf5_format::selection::Selection;
/// The Python interpreter to drive interop checks with (see
/// `h5py_interop_tests.rs`).
@@ -103,3 +104,72 @@ for name, lengths in (("{p}", 4), ("{p}.l8", 8)):
}
}
}
// ---------------------------------------------------------------------------
// Per-chunk filter masks
// ---------------------------------------------------------------------------
/// A chunk's filter mask has one bit per pipeline filter: bit i set means
/// filter i was not applied to that chunk. Any nonzero mask used to skip the
/// whole pipeline, so a chunk that skipped only gzip was handed back still
/// shuffled.
#[test]
fn h5py_partial_filter_mask_skips_only_masked_filters() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mask.h5");
let p = path.display().to_string();
run_python(&format!(
r#"
import h5py, numpy as np, zlib
def shuffle(b, es):
a = np.frombuffer(b, dtype=np.uint8).reshape(-1, es)
return a.T.copy().tobytes()
with h5py.File("{p}", "w") as f:
# shuffle (0) + gzip (1). Even chunks: both applied. Odd chunks: mask
# 0b10, gzip skipped, shuffle applied. Chunk 3: mask 0b11, raw.
ds = f.create_dataset("shuf_gzip", shape=(32,), chunks=(8,), dtype="<i4",
compression="gzip", shuffle=True)
for i in range(4):
raw = np.arange(1000 + i * 8, 1008 + i * 8, dtype="<i4").tobytes()
if i == 3:
ds.id.write_direct_chunk((i * 8,), raw, filter_mask=0b11)
elif i % 2:
ds.id.write_direct_chunk((i * 8,), shuffle(raw, 4), filter_mask=0b10)
else:
ds.id.write_direct_chunk((i * 8,), zlib.compress(shuffle(raw, 4)), filter_mask=0)
# shuffle (0) + gzip (1) on a 2-D dataset, chunk (0, 1) skips shuffle only.
ds = f.create_dataset("grid", shape=(8, 8), chunks=(4, 4), dtype="<f8",
compression="gzip", shuffle=True)
full = np.arange(64, dtype="<f8").reshape(8, 8)
for r in (0, 4):
for c in (0, 4):
raw = np.ascontiguousarray(full[r:r + 4, c:c + 4]).tobytes()
if (r, c) == (0, 4):
ds.id.write_direct_chunk((r, c), zlib.compress(raw), filter_mask=0b01)
else:
ds.id.write_direct_chunk((r, c), zlib.compress(shuffle(raw, 8)), filter_mask=0)
assert (f["grid"][...] == full).all()
assert (f["shuf_gzip"][...] == np.arange(1000, 1032)).all()
"#
));
let file = File::open(&path).unwrap();
let want: Vec<i32> = (1000..1032).collect();
assert_eq!(file.dataset("shuf_gzip").unwrap().read_i32().unwrap(), want);
let grid: Vec<f64> = (0..64).map(f64::from).collect();
assert_eq!(file.dataset("grid").unwrap().read_f64().unwrap(), grid);
// The selection path decodes chunks on its own.
let part = file
.dataset("shuf_gzip")
.unwrap()
.read_selection(&Selection::Hyperslab {
start: vec![6],
stride: vec![1],
count: vec![1],
block: vec![20],
})
.unwrap();
let want: Vec<u8> = (1006..1026i32).flat_map(i32::to_le_bytes).collect();
assert_eq!(part, want);
}